merge conflict fix

This commit is contained in:
hagiye
2026-06-24 12:31:59 +03:00
494 changed files with 21885 additions and 14336 deletions

View File

@@ -17,7 +17,6 @@ import {
PackageOpen,
Users,
Wallet,
//TrainTrack,
} from "lucide-react";
import { FreightDashboardLayout, type SidebarItem, type SidebarSection } from "@/components/layout";

View File

@@ -1,330 +0,0 @@
// components/baselineRatematrix/RateMatrixForm.tsx
import React, { useState, useCallback } from 'react';
// import { useForm } from 'react-hook-form';
// import { zodResolver } from '@hookform/resolvers/zod';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { z } from 'zod';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Loader2, Save, Send, Shield, AlertTriangle } from 'lucide-react';
import { RateTypeSection } from './RateTypeSection';
import { ConfirmationDialog } from './ConfirmationDialog';
import { ValidationSummary } from './ValidationSummary';
import { LoadingScreen } from '@/ui/LoadingScreen';
import { useRateMatrixAuth } from '@/auth/hooks/useAuth';
import { useReferenceData } from '@/hooks/useReferenceData';
import { queryKeys } from '@/constants/queryKeys';
import { API_URLS } from '@/constants/apiUrls';
import {
RATE_TYPES,
RATE_TYPE_LABELS,
REQUIRED_RATE_TYPES
} from '@/constants/rateMatrixConstants';
import { rateMatrixRulesEngine } from '../../ruleEngine/rateMatrixRules';
import type { RateEntry } from './types';
const formSchema = z.object({
matrixName: z.string().min(1, 'Matrix name is required').max(200),
effectiveDate: z.string().min(1, 'Effective date is required'),
expiryDate: z.string().optional(),
currency: z.string().min(1, 'Currency is required'),
});
type FormData = z.infer<typeof formSchema>;
const createInitialSections = (): RateEntry[] => {
return REQUIRED_RATE_TYPES.map(rateType => ({
rateType,
entries: [{
validFrom: '',
validTo: '',
}],
}));
};
export function RateMatrixForm() {
const [rateSections, setRateSections] = useState<RateEntry[]>(createInitialSections());
const [showConfirmation, setShowConfirmation] = useState(false);
const [savedMatrixId, setSavedMatrixId] = useState<string | null>(null);
const [validationErrors, setValidationErrors] = useState<any[]>([]);
const { isDirector } = useRateMatrixAuth();
const { data: referenceData, isLoading: isLoadingReference } = useReferenceData();
const queryClient = useQueryClient();
const form = useForm<FormData>({
resolver: zodResolver(formSchema),
defaultValues: {
matrixName: '',
effectiveDate: '',
expiryDate: '',
currency: 'USD',
},
});
// Save draft mutation
const saveDraftMutation = useMutation({
mutationFn: async (data: FormData & { rateSections: RateEntry[] }) => {
const response = await fetch(API_URLS.RATE_MATRIX.DRAFT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (!response.ok) throw new Error('Failed to save draft');
return response.json();
},
onSuccess: (data) => {
setSavedMatrixId(data.id);
queryClient.invalidateQueries({ queryKey: queryKeys.rateMatrix.all });
toast.success('Draft saved successfully');
},
onError: (error) => {
toast.error('Failed to save draft');
},
});
// Submit for approval mutation
const submitMutation = useMutation({
mutationFn: async (matrixId: string) => {
const response = await fetch(API_URLS.RATE_MATRIX.SUBMIT(matrixId), {
method: 'POST',
});
if (!response.ok) throw new Error('Failed to submit');
return response.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.rateMatrix.all });
toast.success('Rate matrix submitted for executive approval and locked!');
setShowConfirmation(false);
},
onError: (error) => {
toast.error('Failed to submit for approval');
setShowConfirmation(false);
},
});
const handleValidate = useCallback(() => {
const validation = rateMatrixRulesEngine.validate(rateSections);
setValidationErrors([...validation.errors, ...validation.warnings]);
if (validation.isValid) {
toast.success('All validations passed!');
}
}, [rateSections]);
const handleSaveDraft = async () => {
const formData = form.getValues();
await saveDraftMutation.mutateAsync({
...formData,
rateSections,
});
};
const handleSubmitClick = async () => {
const isFormValid = await form.trigger();
if (!isFormValid) return;
const validation = rateMatrixRulesEngine.validate(rateSections);
setValidationErrors([...validation.errors, ...validation.warnings]);
if (!validation.isValid) {
toast.error('Please fix validation errors before submitting');
return;
}
setShowConfirmation(true);
};
const handleConfirmSubmit = async () => {
const formData = form.getValues();
try {
let matrixId = savedMatrixId;
if (!matrixId) {
const draftResult = await saveDraftMutation.mutateAsync({
...formData,
rateSections,
});
matrixId = draftResult.id;
}
await submitMutation.mutateAsync(matrixId!);
} catch (error) {
// Error handling done in mutations
}
};
if (isLoadingReference) {
return <LoadingScreen message="Loading reference data..." />;
}
if (!isDirector) {
return (
<div className="flex items-center justify-center min-h-screen">
<Alert variant="destructive" className="max-w-md">
<AlertTriangle className="h-4 w-4" />
<AlertTitle>Access Denied</AlertTitle>
<AlertDescription>
Only Directors can access the rate matrix registration.
</AlertDescription>
</Alert>
</div>
);
}
return (
<div className="container mx-auto py-8 px-4 max-w-7xl">
{/* Header */}
<div className="mb-8">
<h1 className="text-3xl font-bold tracking-tight">
Baseline Rate Matrix Registration
</h1>
<p className="text-muted-foreground mt-2">
Submit a comprehensive rate matrix for executive approval
</p>
</div>
{/* Director Warning */}
<Alert variant="warning" className="mb-6 border-amber-500 bg-amber-50">
<Shield className="h-4 w-4" />
<AlertTitle>Director Notice</AlertTitle>
<AlertDescription>
Once submitted, this matrix will be locked pending Chief Executive approval.
No edits can be made by any user until authorization is granted.
</AlertDescription>
</Alert>
<form onSubmit={(e) => e.preventDefault()}>
{/* Matrix Metadata */}
<Card className="mb-6">
<CardHeader>
<CardTitle>Matrix Information</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<div className="space-y-2">
<Label htmlFor="matrixName">Matrix Name *</Label>
<Input
id="matrixName"
{...form.register('matrixName')}
placeholder="e.g., Q4 2026 Baseline Matrix"
className={form.formState.errors.matrixName ? 'border-destructive' : ''}
/>
{form.formState.errors.matrixName && (
<p className="text-sm text-destructive">
{form.formState.errors.matrixName.message}
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="effectiveDate">Effective Date *</Label>
<Input
id="effectiveDate"
type="date"
{...form.register('effectiveDate')}
className={form.formState.errors.effectiveDate ? 'border-destructive' : ''}
/>
</div>
<div className="space-y-2">
<Label htmlFor="expiryDate">Expiry Date</Label>
<Input
id="expiryDate"
type="date"
{...form.register('expiryDate')}
/>
</div>
<div className="space-y-2">
<Label htmlFor="currency">Currency *</Label>
<select
id="currency"
{...form.register('currency')}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2"
>
{referenceData?.currencies?.map((currency: any) => (
<option key={currency.code} value={currency.code}>
{currency.code} - {currency.name}
</option>
))}
</select>
</div>
</div>
</CardContent>
</Card>
{/* Rate Type Sections */}
<div className="space-y-6">
{rateSections.map((section, index) => (
<RateTypeSection
key={section.rateType}
section={section}
sectionIndex={index}
onUpdate={(updatedSection) => {
const newSections = [...rateSections];
newSections[index] = updatedSection;
setRateSections(newSections);
}}
referenceData={referenceData}
/>
))}
</div>
{/* Validation Errors */}
{validationErrors.length > 0 && (
<div className="mt-6">
<ValidationSummary errors={validationErrors} />
</div>
)}
{/* Form Actions */}
<div className="sticky bottom-6 mt-8 p-6 bg-background border rounded-lg shadow-lg flex gap-4 justify-end">
<Button
type="button"
variant="outline"
onClick={handleSaveDraft}
disabled={saveDraftMutation.isPending}
>
{saveDraftMutation.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Save className="mr-2 h-4 w-4" />
)}
Save as Draft
</Button>
<Button
type="button"
variant="secondary"
onClick={handleValidate}
>
Validate All Rates
</Button>
<Button
type="button"
onClick={handleSubmitClick}
disabled={submitMutation.isPending}
>
<Send className="mr-2 h-4 w-4" />
Submit for Executive Approval
</Button>
</div>
</form>
{/* Confirmation Dialog */}
<ConfirmationDialog
open={showConfirmation}
onOpenChange={setShowConfirmation}
onConfirm={handleConfirmSubmit}
isLoading={submitMutation.isPending}
/>
</div>
);
}

View File

@@ -68,7 +68,7 @@ export function ApprovalStepsCard({ booking, mutations }: ApprovalStepsCardProps
icon={ShieldCheck}
title="Approval chain"
extra={
<Badge color="green" variant="light" radius="sm">
<Badge color="edr-green" variant="light" radius="sm">
{steps.filter((s) => s.status === "APPROVED").length}/{steps.length}
</Badge>
}
@@ -144,11 +144,11 @@ function StepRow({
const canApprove = canActOnApprovalStep(user, step, steps);
const statusColor =
step.status === "APPROVED"
? "green"
? "edr-green"
: step.status === "REJECTED"
? "red"
: isNext
? "green"
? "edr-green"
: "gray";
return (
@@ -200,7 +200,7 @@ function StepRow({
{canApprove && (
<Button
size="compact-sm"
color="green"
color="edr-green"
leftSection={<Check size={14} />}
disabled={isPending}
onClick={() => onApprove(step)}

View File

@@ -86,7 +86,7 @@ export function BookingActionsMenu({
key={action.id}
size="sm"
variant={action.primary && !destructive ? "filled" : "default"}
color={destructive ? "red" : action.primary ? "green" : "gray"}
color={destructive ? "red" : action.primary ? "edr-green" : "gray"}
leftSection={<Icon size={16} />}
disabled={mutations.isPending}
onClick={() => handleAction(action)}
@@ -113,7 +113,7 @@ export function BookingActionsMenu({
{variant === "table" && primary && (
<Button
size="compact-sm"
color="green"
color="edr-green"
visibleFrom="lg"
leftSection={<primary.icon size={14} />}
disabled={mutations.isPending}

View File

@@ -49,7 +49,7 @@ export function BookingConfirmDialog({
const inputMissing =
(needsTextInput && !inputValue.trim()) || (needsFileInput && !selectedFile);
const isDestructive = action.variant === "destructive";
const accent = isDestructive ? "red" : "green";
const accent = isDestructive ? "red" : "edr-green";
return (
<Modal

View File

@@ -20,7 +20,7 @@ export function BookingPricingSummary({ booking }: { booking: BookingDetail }) {
<Text
size="xl"
fw={700}
c="green.9"
c="edr-green.9"
mt={4}
style={{ fontVariantNumeric: "tabular-nums", letterSpacing: "-0.5px" }}
>

View File

@@ -21,20 +21,6 @@ import type {
BookingListSummaryTabs,
} from "@/services/bookings.service";
/** Lifecycle stages for the pipeline distribution bar (in flow order). */
const PIPELINE_STAGES: Array<{
key: keyof BookingListSummaryTabs;
label: string;
color: string;
}> = [
{ key: "intake", label: "Intake", color: "#38bdf8" },
{ key: "in_approval", label: "Approval", color: "#f59e0b" },
{ key: "approved_contract", label: "Contract", color: "#8b5cf6" },
{ key: "payment", label: "Payment", color: "#fb923c" },
{ key: "operations", label: "Operations", color: "#14b8a6" },
{ key: "completed", label: "Completed", color: "#22c55e" },
];
const CARD_STYLE = {
background: "var(--mantine-color-gray-0)",
border: "1px solid var(--mantine-color-gray-2)",
@@ -62,7 +48,12 @@ export function BookingRequestsHeader({
return (
<Stack gap="lg">
<Group justify="flex-end" gap="sm">
<Button color="green" radius="lg" leftSection={<Plus size={18} />} onClick={onCreate}>
<Button
color="edr-green"
radius="lg"
leftSection={<Plus size={18} />}
onClick={onCreate}
>
Create booking
</Button>
<Button
@@ -90,7 +81,9 @@ export function BookingRequestsHeader({
label="Needs action"
value={val(metrics?.needsAction)}
hint="Submitted or pending"
ratio={metrics?.inQueue ? (metrics.needsAction ?? 0) / metrics.inQueue : 0}
ratio={
metrics?.inQueue ? (metrics.needsAction ?? 0) / metrics.inQueue : 0
}
accent="orange"
/>
<HeroStat
@@ -137,12 +130,18 @@ function HeroStat({
accent?: OverviewAccent;
variant?: "area" | "line";
}) {
const [, accentDeep] = overviewAccentGradients[accent] ?? overviewAccentGradients.default;
const [, accentDeep] =
overviewAccentGradients[accent] ?? overviewAccentGradients.default;
const chipBg = ACCENT_CHIP_BG[accent] ?? ACCENT_CHIP_BG.default;
const pct = ratio != null ? Math.round(Math.min(1, Math.max(0, ratio)) * 100) : null;
const pct =
ratio != null ? Math.round(Math.min(1, Math.max(0, ratio)) * 100) : null;
return (
<Paper p="md" radius="lg" style={{ flex: "1 1 180px", minWidth: 160, ...CARD_STYLE }}>
<Paper
p="md"
radius="lg"
style={{ flex: "1 1 180px", minWidth: 160, ...CARD_STYLE }}
>
<Stack gap={8}>
<Group gap="sm" wrap="nowrap" align="center">
<Box
@@ -161,7 +160,13 @@ function HeroStat({
<Icon size={20} strokeWidth={2} />
</Box>
<Stack gap={1} style={{ minWidth: 0, flex: 1 }}>
<Text fw={800} size="24px" lh={1.05} style={{ color: "#0f172a" }} truncate>
<Text
fw={800}
size="24px"
lh={1.05}
style={{ color: "#0f172a" }}
truncate
>
{value}
</Text>
<Text size="xs" fw={600} c="dimmed" truncate>
@@ -179,66 +184,15 @@ function HeroStat({
</Group>
{pct == null ? (
<MiniSparkline variant={variant} accent={accent} baseline={0.5} seed={label} height={22} />
<MiniSparkline
variant={variant}
accent={accent}
baseline={0.5}
seed={label}
height={22}
/>
) : null}
</Stack>
</Paper>
);
}
function PipelineBar({ tabs }: { tabs: BookingListSummaryTabs }) {
const segments = PIPELINE_STAGES.map((s) => ({ ...s, count: tabs[s.key] ?? 0 }));
const total = segments.reduce((sum, s) => sum + s.count, 0);
return (
<Paper p="md" radius="lg" style={CARD_STYLE}>
<Group justify="space-between" mb={10}>
<Text size="sm" fw={700} style={{ color: "#0f172a" }}>
Booking pipeline
</Text>
<Text size="xs" c="dimmed">
{total} active
</Text>
</Group>
<Box
style={{
display: "flex",
height: 14,
borderRadius: 999,
overflow: "hidden",
background: "var(--mantine-color-gray-2)",
gap: 2,
}}
>
{total > 0 ? (
segments.map((s) =>
s.count > 0 ? (
<Box
key={s.key}
title={`${s.label}: ${s.count}`}
style={{ width: `${(s.count / total) * 100}%`, background: s.color, transition: "width 200ms ease" }}
/>
) : null,
)
) : (
<Box style={{ width: "100%" }} />
)}
</Box>
<Group gap="md" mt={10} wrap="wrap">
{segments.map((s) => (
<Group key={s.key} gap={6} wrap="nowrap">
<Box style={{ width: 9, height: 9, borderRadius: 3, background: s.color }} />
<Text size="xs" c="dimmed">
{s.label}
</Text>
<Text size="xs" fw={700} style={{ color: "#0f172a" }}>
{s.count}
</Text>
</Group>
))}
</Group>
</Paper>
);
}

View File

@@ -8,7 +8,7 @@ const statusColorMap: Record<string, string> = {
CHANGES_REQUESTED: "orange",
PENDING_APPROVAL: "yellow",
APPROVED_PENDING_SIGNATURE: "cyan",
APPROVED: "green",
APPROVED: "edr-green",
CONTRACT_READY: "indigo",
SIGNED_CUSTOMER: "cyan",
FULLY_EXECUTED: "indigo",
@@ -16,7 +16,7 @@ const statusColorMap: Record<string, string> = {
PAYMENT_VERIFICATION_IN_PROGRESS: "yellow",
SELECTED_FOR_BATCH: "orange",
EXPIRED: "red",
PAID: "green",
PAID: "edr-green",
IN_TRANSIT: "cyan",
COMPLETED: "indigo",
REJECTED: "red",

View File

@@ -1,3 +1,4 @@
import { Badge, ScrollArea, Tabs } from "@mantine/core";
import {
CheckCircle,
ClipboardCheck,
@@ -8,13 +9,12 @@ import {
Wallet,
XCircle,
} from "lucide-react";
import { Badge, Tabs } from "@mantine/core";
import "@/components/overview/overview.css";
import {
BOOKING_LIST_TABS,
type BookingStatusTabKey,
} from "@/features/bookings/booking-status.config";
import "@/components/overview/overview.css";
const TAB_ICONS: Record<BookingStatusTabKey, React.ReactNode> = {
all: <LayoutGrid size={17} strokeWidth={1.85} />,
@@ -43,12 +43,13 @@ export function BookingStatusTabs({
value={active}
onChange={(value) => onChange((value as BookingStatusTabKey) ?? "all")}
variant="pills"
color="green"
color="edr-green"
keepMounted={false}
classNames={{ list: "ov-tablist", tab: "ov-tab" }}
>
<Tabs.List>
{BOOKING_LIST_TABS.map((tab) => {
<ScrollArea type="auto" scrollbarSize={6} offsetScrollbars="x">
<Tabs.List style={{ flexWrap: "nowrap", width: "max-content" }}>
{BOOKING_LIST_TABS.map((tab) => {
const isActive = active === tab.key;
const count = counts?.[tab.key];
return (
@@ -56,13 +57,14 @@ export function BookingStatusTabs({
key={tab.key}
value={tab.key}
leftSection={TAB_ICONS[tab.key]}
size={"sm"}
rightSection={
count !== undefined ? (
<Badge
size="sm"
radius="sm"
variant={isActive ? "white" : "light"}
color={isActive ? "green" : "gray"}
color={isActive ? "edr-green" : "gray"}
styles={
isActive
? { root: { background: "rgba(255,255,255,0.9)", color: "#15805f" } }
@@ -77,8 +79,9 @@ export function BookingStatusTabs({
{tab.label}
</Tabs.Tab>
);
})}
</Tabs.List>
})}
</Tabs.List>
</ScrollArea>
</Tabs>
);
}

View File

@@ -83,7 +83,7 @@ export function BookingWorkflowStepper({
<Text
size="xs"
fw={isActive ? 600 : 500}
c={isActive ? "green.7" : isComplete ? "dark" : "dimmed"}
c={isActive ? "edr-green.7" : isComplete ? "dark" : "dimmed"}
ta="center"
style={{ whiteSpace: "nowrap" }}
>

View File

@@ -19,14 +19,14 @@ export function BookingApprovalCard({ steps, approvedCount }: BookingApprovalCar
<SectionCard
icon={CheckCircle}
title="Approval Workflow"
accent="green"
accent="edr-green"
extra={
<Badge color="green" variant="light" radius="sm">
<Badge color="edr-green" variant="light" radius="sm">
{approvedCount} / {steps.length} approved
</Badge>
}
>
<Timeline active={approvedCount - 1} bulletSize={26} lineWidth={2} color="green">
<Timeline active={approvedCount - 1} bulletSize={26} lineWidth={2} color="edr-green">
{steps.map((step) => (
<Timeline.Item
key={step.id}

View File

@@ -28,7 +28,7 @@ export function BookingDetailToolbar({
<Button variant="default" leftSection={<Download size={16} />} onClick={onExport}>
Export
</Button>
<Button color="green" leftSection={<CheckCircle size={16} />} onClick={onAction}>
<Button color="edr-green" leftSection={<CheckCircle size={16} />} onClick={onAction}>
Take Action
</Button>
</Group>

View File

@@ -68,7 +68,7 @@ export function BookingLifecycleStepper({ status }: BookingLifecycleStepperProps
<Text
size="xs"
fw={isActive ? 600 : 500}
c={isActive ? "green.7" : isComplete ? "dark" : "dimmed"}
c={isActive ? "edr-green.7" : isComplete ? "dark" : "dimmed"}
ta="center"
style={{ whiteSpace: "nowrap" }}
>

View File

@@ -29,7 +29,7 @@ export function BookingPaymentCard({
</Text>
</Group>
<Badge
color={paymentStatus === "PAID" ? "green" : "yellow"}
color={paymentStatus === "PAID" ? "edr-green" : "yellow"}
variant="light"
radius="sm"
mt="xs"

View File

@@ -10,7 +10,15 @@ import {
Wallet,
Weight,
} from "lucide-react";
import { Box, Button, Group, Paper, Stack, Text, ThemeIcon, Title } from "@mantine/core";
import {
Button,
Group,
Paper,
Stack,
Text,
ThemeIcon,
Title,
} from "@mantine/core";
import type { LucideIcon } from "lucide-react";
import type { BookingDetail } from "@/types/booking";
@@ -49,13 +57,7 @@ export function BookingRequestHero({
<Paper
radius="xl"
p="xl"
style={{
position: "relative",
overflow: "hidden",
background: "#ffffff",
border: "1px solid var(--mantine-color-gray-2)",
boxShadow: "0 1px 3px rgba(15,23,42,0.04)",
}}
style={{ position: "relative", overflow: "hidden" }}
>
<Stack gap="lg" style={{ position: "relative" }}>
<Group justify="space-between" align="flex-start" wrap="wrap">
@@ -70,7 +72,7 @@ export function BookingRequestHero({
</Button>
<Button
variant="light"
color="green"
color="edr-green"
size="compact-sm"
radius="lg"
leftSection={<RefreshCw size={15} />}
@@ -83,11 +85,16 @@ 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">
<Title order={2} fw={700} style={{ letterSpacing: "-0.4px", color: "#0f172a" }}>
<Title order={2} fw={700} style={{ letterSpacing: "-0.4px" }}>
{booking.reference}
</Title>
<BookingStatusBadge status={booking.status} />
@@ -105,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>
@@ -116,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>
@@ -126,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="green"
accent="edr-green"
/>
<HeroTile
icon={Weight}
label="Cargo weight"
value={`${weight} T`}
hint="VGM total"
accent="blue"
/>
<HeroTile icon={Weight} label="Cargo weight" value={`${weight} T`} hint="VGM total" accent="blue" />
<HeroTile
icon={ContainerIcon}
label="Containers"
@@ -177,7 +202,7 @@ function HeroTile({
label,
value,
hint,
accent = "green",
accent = "edr-green",
}: {
icon: LucideIcon;
label: string;
@@ -201,10 +226,16 @@ 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", color: "#0f172a" }}>
<Text fw={700} size="lg" lh={1.1} style={{ whiteSpace: "nowrap" }}>
{value}
</Text>
{hint ? (

View File

@@ -30,7 +30,7 @@ export function BookingReviewNotesCard({ notes }: BookingReviewNotesCardProps) {
</ThemeIcon>
<Stack gap={2} style={{ flex: 1 }}>
<Group justify="space-between">
<Badge color="green" variant="light" size="sm" radius="sm">
<Badge color="edr-green" variant="light" size="sm" radius="sm">
{note.type}
</Badge>
<Text size="xs" c="dimmed">

View File

@@ -9,38 +9,29 @@ export interface SectionCardProps {
title: string;
/** Optional one-line context shown under the title. */
subtitle?: string;
/** Mantine palette key used to tint the icon chip + top accent (default green). */
/** Mantine palette key used to tint the icon chip (default brand green). */
accent?: string;
extra?: ReactNode;
children: ReactNode;
}
/** Consistent card with a colored icon chip + accent stripe header used by every detail section. */
/** Consistent card with a colored icon chip header used by every detail section. */
export function SectionCard({
icon: Icon,
title,
subtitle,
accent = "green",
accent = "edr-green",
extra,
children,
}: SectionCardProps) {
return (
<Paper radius="md" withBorder style={{ ...detailStyles.card, overflow: "hidden" }}>
<Box
style={{
height: 3,
background: `linear-gradient(90deg, var(--mantine-color-${accent}-5) 0%, var(--mantine-color-${accent}-7) 100%)`,
}}
/>
<Group
justify="space-between"
px="xl"
py="md"
wrap="nowrap"
style={{
...detailStyles.cardHeader,
background: `linear-gradient(180deg, var(--mantine-color-${accent}-0) 0%, white 100%)`,
}}
style={detailStyles.cardHeader}
>
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
<Box

View File

@@ -55,7 +55,7 @@ export const detailStyles = {
export function approvalStatusColor(status: string): string {
switch (status) {
case "APPROVED":
return "green";
return "edr-green";
case "PENDING":
return "yellow";
case "REJECTED":
@@ -138,6 +138,8 @@ export interface BookingDetailView {
priorityScore: number;
cargoTotalWeightVgm: number;
pnrCode?: string | null;
consolidationPartnerId?: string | null;
consolidationPartner?: (BookingNamedRefView & { reference?: string }) | null;
/** End of the pay window once the booking is SELECTED_FOR_BATCH. */
paymentDeadline?: string | null;
createdAt: string;

View File

@@ -1,4 +1,5 @@
import { useCargoesByContainer, useDeliverCargo, useUnloadCargo } from '@/hooks/useCargoes';
import { useMutation, useQuery } from '@tanstack/react-query';
import { api } from '@/services/api';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
@@ -6,9 +7,14 @@ import { LoadCargoDialog } from './LoadCargoDialog';
import type { Cargo } from '@/services/cargoService';
export function CargoesTable({ containerId }: { containerId: string }) {
const { data: cargoes, refetch } = useCargoesByContainer(containerId);
const deliver = useDeliverCargo();
const unload = useUnloadCargo();
const { data: cargoes, refetch } = useQuery(
api.cargoes.listByContainer.queryOptions({
input: { containerId },
enabled: !!containerId,
}),
);
const deliver = useMutation(api.cargoes.deliver.mutationOptions());
const unload = useMutation(api.cargoes.unload.mutationOptions());
if (!cargoes?.length) return <div className="text-muted-foreground">No cargoes for this container.</div>;
@@ -34,8 +40,8 @@ export function CargoesTable({ containerId }: { containerId: string }) {
<TableCell><Badge variant="outline">{cargo.status}</Badge></TableCell>
<TableCell className="space-x-2">
{cargo.status === 'PENDING' && <LoadCargoDialog cargoId={cargo.id} onSuccess={() => refetch()} />}
{cargo.status === 'LOADED' && <Button size="sm" onClick={() => deliver.mutateAsync(cargo.id).then(() => refetch())}>Deliver</Button>}
{cargo.status === 'LOADED' && <Button size="sm" variant="outline" onClick={() => unload.mutateAsync(cargo.id).then(() => refetch())}>Unload</Button>}
{cargo.status === 'LOADED' && <Button size="sm" onClick={() => deliver.mutateAsync({ id: cargo.id }).then(() => refetch())}>Deliver</Button>}
{cargo.status === 'LOADED' && <Button size="sm" variant="outline" onClick={() => unload.mutateAsync({ id: cargo.id }).then(() => refetch())}>Unload</Button>}
</TableCell>
</TableRow>
))}

View File

@@ -6,7 +6,8 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { useDeliverCargo } from '@/hooks/useCargoes';
import { useMutation } from '@tanstack/react-query';
import { api } from '@/services/api';
import { useToast } from '@/hooks/use-toast';
/**
@@ -18,7 +19,7 @@ export function DeliverCargoDialog({ cargoId, onSuccess }: { cargoId: string; on
const [receiverName, setReceiverName] = useState('');
const [pickupDate, setPickupDate] = useState('');
const [deliveryRemarks, setDeliveryRemarks] = useState('');
const deliver = useDeliverCargo();
const deliver = useMutation(api.cargoes.deliver.mutationOptions());
const { toast } = useToast();
const handleDeliver = async () => {

View File

@@ -3,7 +3,8 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useLoadCargo } from '@/hooks/useCargoes';
import { useMutation } from '@tanstack/react-query';
import { api } from '@/services/api';
import { useToast } from '@/hooks/use-toast';
export function LoadCargoDialog({ cargoId, onSuccess }: { cargoId: string; onSuccess?: () => void }) {
@@ -11,7 +12,7 @@ export function LoadCargoDialog({ cargoId, onSuccess }: { cargoId: string; onSuc
const [quantity, setQuantity] = useState(0);
const [weight, setWeight] = useState(0);
const [volume, setVolume] = useState<number>();
const load = useLoadCargo();
const load = useMutation(api.cargoes.load.mutationOptions());
const { toast } = useToast();
const handleLoad = async () => {

View File

@@ -0,0 +1,32 @@
import { Box, Card } from "@mantine/core";
import type { ReactNode } from "react";
export interface TableCardProps {
children: ReactNode;
/**
* Minimum width (px) the table is forced to occupy. The Mantine `Table` is
* always `width: 100%`, so without a floor it can never overflow its
* container and the horizontal scroll never engages. Setting a floor lets
* columns keep a sensible width and the card scroll horizontally on narrow
* viewports instead of squishing.
*/
minWidth?: number;
}
/**
* Flush card shell for a `DataTable`: a borderless, padding-less card whose
* single child is a horizontally scrollable region. Pair with the table's
* `containerClassName="border-0 shadow-none bg-transparent"` so every table on
* the customer pages reads identically (same surface, same scroll behaviour).
*/
export function TableCard({ children, minWidth = 860 }: TableCardProps) {
return (
<Card p={0}>
<Box style={{ overflowX: "auto" }} w="100%">
<Box miw={minWidth}>{children}</Box>
</Box>
</Card>
);
}
export default TableCard;

View File

@@ -0,0 +1,316 @@
import { Badge, Button, Group, Tooltip } from "@mantine/core";
import { useMutation } from "@tanstack/react-query";
import { api } from "@/services/api";
import type {
CompanyProfile,
CompanyStatus,
CompanyType,
CustomerBookingStatus,
CustomerPaymentStatus,
ProfileStatus,
ProfileType,
} from "@/types/customer";
import { humanize } from "./format";
const badgeStyle = {
fontSize: "0.7rem",
letterSpacing: "0.04em",
whiteSpace: "nowrap" as const,
};
/** Shared status palette — active/paid green, pending amber, terminal red. */
const STATUS_COLOR: Record<CompanyStatus | ProfileStatus, string> = {
active: "edr-green",
pending: "yellow",
suspended: "orange",
blacklisted: "red",
};
const COMPANY_TYPE_COLOR: Record<CompanyType, string> = {
customer: "edr-green",
freight_forwarder: "blue",
dj_freight_forwarder: "indigo",
transporter: "grape",
};
const PROFILE_TYPE_COLOR: Record<ProfileType, string> = {
importer: "teal",
exporter: "cyan",
freight_forwarder: "blue",
dj_freight_forwarder: "indigo",
transporter: "grape",
};
export function CompanyStatusBadge({ status }: { status: CompanyStatus }) {
return (
<Badge
color={STATUS_COLOR[status] ?? "gray"}
variant="light"
size="sm"
radius="md"
tt="capitalize"
fw={600}
style={badgeStyle}
>
{status}
</Badge>
);
}
export function CompanyTypeBadge({ type }: { type: CompanyType }) {
return (
<Badge
color={COMPANY_TYPE_COLOR[type] ?? "gray"}
variant="light"
size="sm"
radius="md"
fw={600}
style={badgeStyle}
>
{humanize(type)}
</Badge>
);
}
/**
* Profile chips for a company row: one chip per role (Importer / Exporter / …)
* carrying its reference code. Caps at three (a company has at most three
* profiles); any extra collapse into a `+N` chip.
*/
export function ProfileChips({
profiles,
max = 3,
}: {
profiles: CompanyProfile[];
max?: number;
}) {
if (!profiles.length) {
return (
<Badge color="gray" variant="light" size="sm" radius="md" style={badgeStyle}>
No profiles
</Badge>
);
}
const shown = profiles.slice(0, max);
const extra = profiles.length - shown.length;
return (
<Group gap={6} wrap="wrap">
{shown.map((profile) => (
<Tooltip
key={profile.id}
label={`${humanize(profile.type)} · ${humanize(profile.status)}`}
withArrow
>
<Badge
color={PROFILE_TYPE_COLOR[profile.type] ?? "gray"}
variant="light"
size="sm"
radius="md"
fw={600}
style={badgeStyle}
>
{humanize(profile.type)} · {profile.reference}
</Badge>
</Tooltip>
))}
{extra > 0 ? (
<Badge color="gray" variant="light" size="sm" radius="md" style={badgeStyle}>
+{extra}
</Badge>
) : null}
</Group>
);
}
export function ProfileTypeBadge({ type }: { type: ProfileType }) {
return (
<Badge
color={PROFILE_TYPE_COLOR[type] ?? "gray"}
variant="light"
size="sm"
radius="md"
fw={600}
style={badgeStyle}
>
{humanize(type)}
</Badge>
);
}
export function ProfileStatusBadge({ status }: { status: ProfileStatus }) {
return (
<Badge
color={STATUS_COLOR[status] ?? "gray"}
variant="light"
size="sm"
radius="md"
tt="capitalize"
fw={600}
style={badgeStyle}
>
{status}
</Badge>
);
}
const BOOKING_STATUS_COLOR: Record<CustomerBookingStatus, string> = {
DRAFT: "gray",
SUBMITTED: "yellow",
PENDING_APPROVAL: "yellow",
APPROVED: "cyan",
PAID: "edr-green",
IN_TRANSIT: "blue",
COMPLETED: "indigo",
REJECTED: "red",
CANCELLED: "red",
};
export function BookingStatusBadge({ status }: { status: CustomerBookingStatus }) {
return (
<Badge
color={BOOKING_STATUS_COLOR[status] ?? "gray"}
variant="light"
size="sm"
radius="md"
tt="uppercase"
fw={600}
style={badgeStyle}
>
{humanize(status)}
</Badge>
);
}
const PAYMENT_STATUS_COLOR: Record<CustomerPaymentStatus, string> = {
"action-required": "orange",
processing: "yellow",
success: "edr-green",
failed: "red",
canceled: "gray",
refunded: "grape",
};
export function PaymentStatusBadge({ status }: { status: CustomerPaymentStatus }) {
return (
<Badge
color={PAYMENT_STATUS_COLOR[status] ?? "gray"}
variant="light"
size="sm"
radius="md"
tt="capitalize"
fw={600}
style={badgeStyle}
>
{humanize(status)}
</Badge>
);
}
/**
* Inline approval action buttons for a profile row.
* Transitions: pending → approve/reject | active → suspend | suspended → reactivate/blacklist | blacklisted → reinstate
*/
export function ProfileApprovalActions({
profileId,
status,
}: {
profileId: string;
status: ProfileStatus;
}) {
const { mutate, isPending } = useMutation(
api.customers.setProfileStatus.mutationOptions(),
);
const act = (next: ProfileStatus) =>
mutate({ profileId, status: next });
if (status === "pending") {
return (
<Group gap={6} wrap="nowrap">
<Button
size="xs"
variant="light"
color="edr-green"
radius="md"
loading={isPending}
onClick={() => act("active")}
>
Approve
</Button>
<Button
size="xs"
variant="light"
color="red"
radius="md"
loading={isPending}
onClick={() => act("blacklisted")}
>
Reject
</Button>
</Group>
);
}
if (status === "active") {
return (
<Button
size="xs"
variant="light"
color="orange"
radius="md"
loading={isPending}
onClick={() => act("suspended")}
>
Suspend
</Button>
);
}
if (status === "suspended") {
return (
<Group gap={6} wrap="nowrap">
<Button
size="xs"
variant="light"
color="edr-green"
radius="md"
loading={isPending}
onClick={() => act("active")}
>
Reactivate
</Button>
<Button
size="xs"
variant="light"
color="red"
radius="md"
loading={isPending}
onClick={() => act("blacklisted")}
>
Blacklist
</Button>
</Group>
);
}
if (status === "blacklisted") {
return (
<Button
size="xs"
variant="light"
color="gray"
radius="md"
loading={isPending}
onClick={() => act("pending")}
>
Reinstate
</Button>
);
}
return null;
}

View File

@@ -0,0 +1,38 @@
/** Shared formatting helpers for the customer-management pages. */
/** snake_case / SCREAMING_CASE → Title Case. */
export function humanize(value: string): string {
return value
.toLowerCase()
.split(/[_\s]+/)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(" ");
}
export function formatDate(value: string | null | undefined): string {
if (!value) return "—";
const d = new Date(value);
return Number.isNaN(d.getTime())
? "—"
: d.toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
});
}
export function formatMoney(amount: number, currency: string): string {
return new Intl.NumberFormat(undefined, {
style: "currency",
currency,
maximumFractionDigits: 0,
}).format(amount);
}
export function formatBytes(bytes: number): string {
if (!bytes) return "0 B";
const units = ["B", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
const value = bytes / Math.pow(1024, i);
return `${value.toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
}

View File

@@ -0,0 +1,12 @@
export {
BookingStatusBadge,
CompanyStatusBadge,
CompanyTypeBadge,
PaymentStatusBadge,
ProfileApprovalActions,
ProfileChips,
ProfileStatusBadge,
ProfileTypeBadge,
} from "./badges";
export { formatBytes, formatDate, formatMoney, humanize } from "./format";
export { TableCard, type TableCardProps } from "./TableCard";

View File

@@ -107,11 +107,11 @@ const FleetCardGrid = ({
<Group gap="sm" wrap="nowrap" style={{ flex: 1 }}>
<div
style={{
width: 48,
height: 48,
borderRadius: 12,
background: "linear-gradient(135deg, var(--mantine-color-green-0), var(--mantine-color-green-1))",
color: "var(--mantine-color-green-8)",
width: 40,
height: 40,
borderRadius: 10,
background: "var(--mantine-color-edr-green-0)",
color: "var(--mantine-color-edr-green-7)",
display: "flex",
alignItems: "center",
justifyContent: "center",

View File

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

View File

@@ -1,5 +1,5 @@
import { MoreHorizontal, Pencil, Trash2, Eye, Copy } from "lucide-react";
import { ActionIcon, Button, Group, Menu, Tooltip } from "@mantine/core";
import { Edit2, Trash2, Eye, Users, MoreVertical } from "lucide-react";
import { ActionIcon, Menu, MenuItem, Tooltip } from "@mantine/core";
import { useNavigate } from "react-router-dom";
import type { FleetResourceConfig } from "@/pages/fleet/config/resources";
@@ -10,6 +10,7 @@ export interface FleetRecordActionsProps {
config: FleetResourceConfig;
onEdit: (record: FleetRecord) => void;
onRemove: (record: FleetRecord) => void;
onAssignDriver?: (record: FleetRecord) => void;
layout?: "row" | "compact";
}
@@ -18,11 +19,13 @@ const FleetRecordActions = ({
config,
onEdit,
onRemove,
onAssignDriver,
layout = "row",
}: FleetRecordActionsProps) => {
const navigate = useNavigate();
const removeLabel = config.removeActionLabel ?? "Delete";
const showDetail = Boolean(config.detailPath && "id" in record);
const isVehicle = config.slug === "vehicles";
const handleDetail = () => {
if (!config.detailPath || !("id" in record)) return;
@@ -31,75 +34,90 @@ const FleetRecordActions = ({
if (layout === "compact") {
return (
<Group gap={6} wrap="nowrap" justify="flex-end">
{showDetail ? (
<Tooltip label="View details">
<ActionIcon
variant="light"
color="green"
size="md"
radius="md"
onClick={handleDetail}
>
<Eye size={16} />
</ActionIcon>
</Tooltip>
) : null}
<Tooltip label="Edit">
<ActionIcon
variant="light"
color="teal"
size="md"
radius="md"
onClick={() => onEdit(record)}
>
<Pencil size={16} />
</ActionIcon>
</Tooltip>
<Tooltip label={removeLabel}>
<ActionIcon
variant="light"
color="red"
size="md"
radius="md"
onClick={() => onRemove(record)}
>
<Trash2 size={16} />
</ActionIcon>
</Tooltip>
</Group>
);
}
return (
<Group gap={4} wrap="nowrap" justify="flex-end">
{showDetail ? (
<Tooltip label="View details">
<ActionIcon variant="light" color="green" size="md" radius="md" onClick={handleDetail}>
<Eye size={16} />
</ActionIcon>
</Tooltip>
) : null}
<Tooltip label="Edit">
<ActionIcon variant="light" color="teal" size="md" radius="md" onClick={() => onEdit(record)}>
<Pencil size={16} />
</ActionIcon>
</Tooltip>
<Menu position="bottom-end" withinPortal>
<Menu position="bottom-end" withinPortal shadow="md">
<Menu.Target>
<Tooltip label="More actions">
<ActionIcon variant="light" color="green" size="md" radius="md">
<MoreHorizontal size={16} />
<Tooltip label="Actions">
<ActionIcon variant="subtle" color="gray" size="sm">
<MoreVertical size={16} strokeWidth={2} />
</ActionIcon>
</Tooltip>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item color="red" leftSection={<Trash2 size={14} />} onClick={() => onRemove(record)}>
{isVehicle && onAssignDriver ? (
<MenuItem
onClick={() => onAssignDriver(record)}
leftSection={<Users size={14} strokeWidth={2} />}
>
Assign Driver
</MenuItem>
) : null}
<MenuItem
onClick={() => onEdit(record)}
leftSection={<Edit2 size={14} strokeWidth={2} />}
>
Edit
</MenuItem>
{showDetail ? (
<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} />}
>
{removeLabel}
</Menu.Item>
</MenuItem>
</Menu.Dropdown>
</Menu>
</Group>
);
}
return (
<Menu position="bottom-end" withinPortal shadow="md">
<Menu.Target>
<Tooltip label="Actions">
<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} />}
>
Assign Driver
</MenuItem>
) : null}
<MenuItem
onClick={() => onEdit(record)}
leftSection={<Edit2 size={14} strokeWidth={2} />}
>
Edit
</MenuItem>
{showDetail ? (
<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} />}
>
{removeLabel}
</MenuItem>
</Menu.Dropdown>
</Menu>
);
};

View File

@@ -1,6 +1,6 @@
import type { ReactNode } from "react";
import { LayoutGrid, Plus, Search, Table2 } from "lucide-react";
import { Box, Button, Group, SegmentedControl, TextInput } from "@mantine/core";
import { LayoutGrid, Plus, Search, Table2 } from "lucide-react";
import type { ReactNode } from "react";
import type { FleetViewMode } from "./useFleetViewMode";
@@ -67,7 +67,6 @@ const FleetToolbar = ({
onChange={(value) => onViewModeChange(value as FleetViewMode)}
size="sm"
radius="lg"
color="green"
data={[
{
value: "table",
@@ -94,7 +93,7 @@ const FleetToolbar = ({
/>
{onAdd ? (
<Button
color="green"
color="edr-green"
radius="lg"
size="sm"
fw={600}

View File

@@ -20,10 +20,24 @@ export const formatFleetCell = (
format?: FleetColumnFormat,
accessorKey?: string,
): ReactNode => {
console.log('formatFleetCell:', { value, format, accessorKey, type: typeof value });
if (format === "statusBadge") {
const status = value == null || value === "" ? "—" : String(value);
const getStatusColor = (st: string): string => {
const s = st.toUpperCase();
console.log('Status for color mapping:', s);
if (s === "ACTIVE") return "green";
if (s === "INACTIVE") return "gray";
if (s === "SUSPENDED" || s === "OUT_OF_SERVICE") return "red";
if (s === "MAINTENANCE" || s === "ON_LEAVE") return "orange";
if (s === "RETIRED") return "gray";
return "gray";
};
const color = getStatusColor(status);
console.log('Assigned color:', color, 'for status:', status);
return (
<Badge variant="light" color="gray" size="sm" radius="md">
<Badge variant="light" color={color} size="sm" radius="md">
{status}
</Badge>
);
@@ -36,5 +50,7 @@ export const formatFleetCell = (
}
}
return formatRuleEngineCell(value, format as ColumnFormat | undefined);
const result = formatRuleEngineCell(value, format as ColumnFormat | undefined);
console.log('formatRuleEngineCell result for', accessorKey, ':', result);
return result;
};

View File

@@ -1,126 +0,0 @@
/* ============================================================
EDR Freight — Header styles
============================================================ */
.fdh-root {
display: flex;
height: 80px;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 0 22px;
}
/* eyebrow above the page title */
.fdh-eyebrow {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.6px;
text-transform: uppercase;
color: #1B9E7A;
margin-bottom: 3px;
}
.fdh-eyebrow-dot {
width: 5px;
height: 5px;
border-radius: 50%;
background: #2DBF95;
box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.16);
}
/* action icon buttons */
.fdh-icon-btn {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: 12px;
background: #f7f9fb;
border: 1px solid #eef1f4;
color: #475569;
cursor: pointer;
transition: all 160ms ease;
}
.fdh-icon-btn:hover {
background: #ffffff;
border-color: rgba(27, 158, 122, 0.28);
color: #1B9E7A;
box-shadow: 0 4px 12px -4px rgba(27, 158, 122, 0.28);
transform: translateY(-1px);
}
.fdh-icon-btn:active {
transform: translateY(0);
}
/* notification badge */
.fdh-badge {
position: absolute;
top: -5px;
right: -5px;
min-width: 17px;
height: 17px;
padding: 0 4px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 9px;
background: linear-gradient(135deg, #f87171 0%, #ef4444 100%);
color: #ffffff;
font-size: 10px;
font-weight: 700;
line-height: 1;
border: 2px solid #ffffff;
box-shadow: 0 2px 6px -1px rgba(239, 68, 68, 0.45);
}
.fdh-divider {
width: 1px;
height: 30px;
background: #e9eef3;
margin: 0 2px;
}
/* user button */
.fdh-user {
display: flex;
align-items: center;
gap: 10px;
padding: 5px 12px 5px 5px;
border-radius: 13px;
cursor: pointer;
border: 1px solid transparent;
transition: all 160ms ease;
}
.fdh-user:hover {
background: #f7f9fb;
border-color: #eef1f4;
}
.fdh-avatar-ring {
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
padding: 2px;
background: linear-gradient(135deg, #2DBF95 0%, #1B9E7A 100%);
box-shadow: 0 4px 10px -3px rgba(27, 158, 122, 0.4);
}
.fdh-avatar {
display: flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
border-radius: 50%;
background: linear-gradient(135deg, #1B9E7A 0%, #15805F 100%);
color: #ffffff;
font-size: 13px;
font-weight: 700;
letter-spacing: 0.3px;
border: 2px solid #ffffff;
}

View File

@@ -1,20 +1,31 @@
import { type ReactNode, useEffect, useRef, useState } from "react";
import {
AppShell,
Avatar,
Box,
Burger,
Divider,
Group,
Indicator,
Menu,
Text,
Tooltip,
UnstyledButton,
} from "@mantine/core";
import {
Bell,
ChevronDown,
FileSignature,
Languages,
LogOut,
MessageSquare,
Moon,
Search,
Sun,
User,
} from "lucide-react";
import { Group, Stack, Text, Menu, Tooltip, Box } from "@mantine/core";
import { type ReactNode } from "react";
import { useNavigate } from "react-router-dom";
import type { PageMeta } from "./types";
import "./FreightDashboardHeader.css";
export interface FreightDashboardHeaderProps {
pageMeta: PageMeta;
@@ -26,10 +37,16 @@ export interface FreightDashboardHeaderProps {
onLogout?: () => void;
theme: "light" | "dark";
onToggleTheme: () => void;
mobileOpened: boolean;
onToggleMobile: () => void;
}
// Every header control is a consistent 36px frosted chip — same language as the
// portal AppLayout's floating "islands".
const ISLAND =
"flex size-9 shrink-0 items-center justify-center rounded-full border border-edr-border bg-white text-edr-text transition-colors hover:bg-[#F1F4F7]";
const FreightDashboardHeader = ({
pageMeta,
headerRight,
enableThemeToggle = false,
userName = "User",
@@ -38,8 +55,11 @@ const FreightDashboardHeader = ({
onLogout,
theme,
onToggleTheme,
mobileOpened,
onToggleMobile,
}: FreightDashboardHeaderProps) => {
const navigate = useNavigate();
const initials =
userInitials ??
(userName
@@ -50,191 +70,147 @@ const FreightDashboardHeader = ({
.join("") ||
"U");
const [isUserMenuOpen, setIsUserMenuOpen] = useState(false);
const userMenuRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (!isUserMenuOpen) return;
const handlePointerDown = (event: MouseEvent) => {
if (
userMenuRef.current &&
!userMenuRef.current.contains(event.target as Node)
) {
setIsUserMenuOpen(false);
}
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") setIsUserMenuOpen(false);
};
document.addEventListener("mousedown", handlePointerDown);
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("mousedown", handlePointerDown);
document.removeEventListener("keydown", handleKeyDown);
};
}, [isUserMenuOpen]);
return (
<header className="fdh-root">
<Stack gap={0} style={{ flex: 1, minWidth: 0 }}>
<span className="fdh-eyebrow">
{/* <span className="fdh-eyebrow-dot" />
Freight Backoffice */}
</span>
<Text
fw={700}
truncate
style={{ fontSize: "20px", lineHeight: 1.2, color: "#0f172a", letterSpacing: "-0.4px" }}
>
{pageMeta.title}
</Text>
{/* <Text size="sm" truncate style={{ color: "#94a3b8", lineHeight: 1.35 }}>
{pageMeta.subtitle}
</Text> */}
</Stack>
<Group gap={10} wrap="nowrap">
{enableThemeToggle && (
<Tooltip
label={theme === "dark" ? "Light mode" : "Dark mode"}
withArrow
openDelay={300}
<AppShell.Header
withBorder={false}
// Frosted glass: fully transparent background + backdrop blur, mirroring
// the portal AppLayout. Inline styles win over Mantine's cascade layer
// (a Tailwind bg-transparent would lose to --mantine-color-body).
style={{
backdropFilter: "blur(14px)",
WebkitBackdropFilter: "blur(14px)",
border: "none",
boxShadow: "none",
background: "transparent",
}}
>
<Group h="100%" px={20} justify="space-between" wrap="nowrap">
{/* Left: burger (mobile) + search — the search now occupies the slot
the page title used to hold; each page owns its own title. */}
<Group gap={12} wrap="nowrap" align="center" style={{ minWidth: 0 }}>
<Burger
opened={mobileOpened}
onClick={onToggleMobile}
hiddenFrom="sm"
size="sm"
aria-label="Toggle sidebar"
/>
<Group
gap={8}
align="center"
visibleFrom="sm"
className="h-9 cursor-text rounded-full border border-edr-border bg-white px-3.5"
style={{ width: 280 }}
>
<button
type="button"
className="fdh-icon-btn"
onClick={onToggleTheme}
aria-label="Toggle theme"
>
{theme === "dark" ? <Sun size={18} /> : <Moon size={18} />}
</button>
<Search size={15} className="text-edr-muted" strokeWidth={1.8} />
<Text size="sm" className="select-none text-edr-muted!">
Search bookings, trains
</Text>
</Group>
</Group>
{/* Right: actions + avatar */}
<Group gap={10} wrap="nowrap" align="center">
<Tooltip label="Language" withArrow openDelay={300}>
<UnstyledButton className={ISLAND} aria-label="Language">
<Languages size={17} strokeWidth={1.8} />
</UnstyledButton>
</Tooltip>
)}
<Tooltip label="Language" withArrow openDelay={300}>
<button type="button" className="fdh-icon-btn" aria-label="Language">
<Languages size={18} />
</button>
</Tooltip>
<Tooltip label="Notifications" withArrow openDelay={300}>
<Indicator
color="edr-accent"
size={8}
offset={6}
withBorder
aria-label="Unread notifications"
>
<UnstyledButton className={ISLAND} aria-label="Notifications">
<Bell size={17} strokeWidth={1.8} />
</UnstyledButton>
</Indicator>
</Tooltip>
<Tooltip label="Messages" withArrow openDelay={300}>
<button type="button" className="fdh-icon-btn" aria-label="Messages">
<MessageSquare size={18} />
<span className="fdh-badge">3</span>
</button>
</Tooltip>
{enableThemeToggle && (
<Tooltip
label={theme === "dark" ? "Light mode" : "Dark mode"}
withArrow
openDelay={300}
>
<UnstyledButton
className={ISLAND}
onClick={onToggleTheme}
aria-label="Toggle theme"
>
{theme === "dark" ? (
<Sun size={17} strokeWidth={1.8} />
) : (
<Moon size={17} strokeWidth={1.8} />
)}
</UnstyledButton>
</Tooltip>
)}
<Tooltip label="Notifications" withArrow openDelay={300}>
<button
type="button"
className="fdh-icon-btn"
aria-label="Notifications"
>
<Bell size={18} />
<span className="fdh-badge">5</span>
</button>
</Tooltip>
<div className="fdh-divider" />
<Menu
position="bottom-end"
shadow="lg"
radius="md"
width={240}
opened={isUserMenuOpen}
onOpen={() => setIsUserMenuOpen(true)}
onClose={() => setIsUserMenuOpen(false)}
>
<Menu.Target>
<div className="fdh-user">
<div className="fdh-avatar-ring">
<div className="fdh-avatar">{initials}</div>
</div>
<Stack gap={0} style={{ minWidth: 0 }} visibleFrom="sm">
<Text
size="sm"
fw={600}
truncate
style={{ color: "#0f172a", lineHeight: 1.25, maxWidth: 140 }}
>
{userName}
</Text>
<Text
size="xs"
truncate
style={{ color: "#94a3b8", lineHeight: 1.25, maxWidth: 140 }}
>
{userEmail ?? "Administrator"}
</Text>
</Stack>
<ChevronDown
size={16}
style={{
color: "#94a3b8",
flexShrink: 0,
transition: "transform 0.2s",
transform: isUserMenuOpen ? "rotate(180deg)" : "rotate(0deg)",
}}
/>
</div>
</Menu.Target>
<Menu.Dropdown>
<Box px="sm" py="xs">
<Group gap={10} wrap="nowrap">
<div className="fdh-avatar-ring">
<div className="fdh-avatar">{initials}</div>
</div>
<Stack gap={0} style={{ minWidth: 0 }}>
<Text size="sm" fw={600} truncate style={{ color: "#0f172a" }}>
{/* Avatar pill */}
<Menu width={240} position="bottom-end" withinPortal shadow="md" offset={8} radius="md">
<Menu.Target>
<UnstyledButton className="flex h-9 cursor-pointer items-center gap-2 rounded-full border border-edr-border bg-white py-0 pl-1 pr-2.5">
<Avatar radius="xl" size={28} color="edr-green.5">
<Text fw={700} fz={12} c="white">
{initials}
</Text>
</Avatar>
<Box visibleFrom="sm" style={{ minWidth: 0 }}>
<Text size="xs" fw={600} truncate className="text-edr-text!" style={{ lineHeight: 1.2, maxWidth: 120 }}>
{userName}
</Text>
{userEmail && (
<Text size="xs" truncate style={{ color: "#94a3b8" }}>
{userEmail}
</Text>
)}
</Stack>
</Group>
</Box>
<Menu.Divider />
<Menu.Item
leftSection={<User size={15} />}
onClick={() => {
setIsUserMenuOpen(false);
navigate("/dashboard/profile");
}}
>
Profile
</Menu.Item>
<Menu.Item
leftSection={<FileSignature size={15} />}
onClick={() => {
setIsUserMenuOpen(false);
navigate("/dashboard/profile#signature");
}}
>
My signature
</Menu.Item>
<Menu.Item
leftSection={<LogOut size={15} />}
color="red"
onClick={() => {
setIsUserMenuOpen(false);
onLogout?.();
}}
>
Logout
</Menu.Item>
</Menu.Dropdown>
</Menu>
<Text size="xs" truncate className="text-edr-muted!" style={{ lineHeight: 1.2, maxWidth: 120, fontSize: 11 }}>
{userEmail ?? "Administrator"}
</Text>
</Box>
<ChevronDown size={15} className="text-edr-muted" strokeWidth={1.8} />
</UnstyledButton>
</Menu.Target>
{headerRight}
<Menu.Dropdown>
<Box px="sm" py="xs">
<Text size="sm" fw={600} truncate className="text-edr-text!">
{userName}
</Text>
{userEmail && (
<Text size="xs" c="dimmed" truncate>
{userEmail}
</Text>
)}
</Box>
<Divider />
<Menu.Item
leftSection={<User size={15} />}
onClick={() => navigate("/dashboard/profile")}
>
Profile
</Menu.Item>
<Menu.Item
leftSection={<FileSignature size={15} />}
onClick={() => navigate("/dashboard/profile#signature")}
>
My signature
</Menu.Item>
<Divider />
<Menu.Item
leftSection={<LogOut size={15} />}
color="red"
onClick={() => onLogout?.()}
>
Logout
</Menu.Item>
</Menu.Dropdown>
</Menu>
{headerRight}
</Group>
</Group>
</header>
</AppShell.Header>
);
};

View File

@@ -1,14 +1,16 @@
import { AppShell, Box } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { type ReactNode, useEffect, useState } from "react";
import { Box, Paper, MantineProvider } from "@mantine/core";
import FreightDashboardHeader from "./FreightDashboardHeader";
import FreightSidebar from "./FreightSidebar";
import { getPageMeta } from "./route-meta";
import type { SidebarSection } from "./types";
import { freightMantineTheme } from "@/theme/freight-brand";
type Theme = "light" | "dark";
const THEME_STORAGE_KEY = "edr-theme";
const HEADER_HEIGHT = 64;
const NAVBAR_WIDTH = 280;
function getInitialTheme(): Theme {
if (typeof window === "undefined") return "light";
@@ -45,6 +47,9 @@ const FreightDashboardLayout = ({
children,
}: FreightDashboardLayoutProps) => {
const pageMeta = getPageMeta(activeHref);
const [mobileOpened, { toggle: toggleMobile, close: closeMobile }] =
useDisclosure(false);
const [theme, setTheme] = useState<Theme>(() =>
enableThemeToggle ? getInitialTheme() : "light",
);
@@ -52,100 +57,62 @@ const FreightDashboardLayout = ({
useEffect(() => {
if (!enableThemeToggle) return;
const root = document.documentElement;
if (theme === "dark") {
root.classList.add("dark");
} else {
root.classList.remove("dark");
}
root.classList.toggle("dark", theme === "dark");
window.localStorage.setItem(THEME_STORAGE_KEY, theme);
}, [theme, enableThemeToggle]);
const toggleTheme = () =>
setTheme((current) => (current === "dark" ? "light" : "dark"));
const navigate = (href: string) => {
closeMobile();
onNavigate?.(href);
};
return (
<>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="" />
<link
href="https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700&display=swap"
rel="stylesheet"
<AppShell
layout="alt"
padding={0}
className="bg-edr-bg"
header={{ height: HEADER_HEIGHT }}
navbar={{
width: NAVBAR_WIDTH,
breakpoint: "sm",
collapsed: { mobile: !mobileOpened },
}}
>
<FreightDashboardHeader
pageMeta={pageMeta}
headerRight={headerRight}
enableThemeToggle={enableThemeToggle}
userName={userName}
userEmail={userEmail}
userInitials={userInitials}
onLogout={onLogout}
theme={theme}
onToggleTheme={toggleTheme}
mobileOpened={mobileOpened}
onToggleMobile={toggleMobile}
/>
<MantineProvider theme={freightMantineTheme}>
<FreightSidebar
sections={sidebarSections}
activeHref={activeHref}
onNavigate={navigate}
onClose={closeMobile}
/>
<AppShell.Main>
{/* Internal scroll keeps the fixed-viewport model the dashboard pages
assume (sidebar + header stay put, content scrolls beneath). */}
<Box
style={{
display: "flex",
height: "100dvh",
overflow: "hidden",
background: "var(--mantine-color-gray-1)",
padding: "0px",
fontFamily: "'Outfit', var(--font-sans)",
}}
className="overflow-y-auto bg-edr-bg"
style={{ height: `calc(100dvh - ${HEADER_HEIGHT}px)` }}
>
<Box style={{ display: "flex", height: "100%", minHeight: 0, width: "100%", gap: "5px" }}>
<FreightSidebar
sections={sidebarSections}
activeHref={activeHref}
onNavigate={onNavigate}
/>
<Box
style={{
display: "flex",
height: "100%",
minHeight: 0,
minWidth: 0,
flex: 1,
flexDirection: "column",
gap: "8px",
}}
>
<Paper
p={0}
radius="lg"
withBorder
style={{
flexShrink: 0,
background: "white",
border: "1px solid var(--mantine-color-gray-2)",
boxShadow: "0 1px 3px rgba(0, 0, 0, 0.05)",
}}
>
<FreightDashboardHeader
pageMeta={pageMeta}
headerRight={headerRight}
enableThemeToggle={enableThemeToggle}
userName={userName}
userEmail={userEmail}
userInitials={userInitials}
onLogout={onLogout}
theme={theme}
onToggleTheme={toggleTheme}
/>
</Paper>
<Paper
p={{ base: 16, md: 24 }}
radius="lg"
withBorder
style={{
minHeight: 0,
flex: 1,
overflowY: "auto",
overscrollBehavior: "contain",
background: "white",
border: "1px solid var(--mantine-color-gray-2)",
boxShadow: "0 1px 3px rgba(0, 0, 0, 0.05)",
}}
>
{children}
</Paper>
</Box>
</Box>
{children}
</Box>
</MantineProvider>
</>
</AppShell.Main>
</AppShell>
);
};

View File

@@ -1,320 +0,0 @@
/* ============================================================
EDR Freight — Sidebar styles
Polished, professional navigation surface.
============================================================ */
.fsb-aside {
height: 100%;
max-height: 100%;
width: 280px;
flex-shrink: 0;
border-radius: 16px;
border: 1px solid #eef1f4;
background: #ffffff;
box-shadow:
0 1px 2px rgba(15, 23, 42, 0.04),
0 8px 24px -16px rgba(15, 23, 42, 0.12);
display: flex;
flex-direction: column;
overflow: hidden;
}
/* ---- Brand header ---- */
.fsb-brand {
position: relative;
display: flex;
align-items: center;
gap: 12px;
height: 80px;
padding: 0 20px;
flex-shrink: 0;
border-bottom: 1px solid #f1f5f9;
overflow: hidden;
}
.fsb-brand::after {
content: "";
position: absolute;
inset: 0;
background:
radial-gradient(120px 80px at 24px 18px, rgba(34, 197, 94, 0.08), transparent 70%);
pointer-events: none;
}
.fsb-logo {
position: relative;
z-index: 1;
display: flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
border-radius: 13px;
background: linear-gradient(135deg, #2DBF95 0%, #1B9E7A 60%, #15805F 100%);
box-shadow:
0 6px 16px -4px rgba(27, 158, 122, 0.45),
inset 0 1px 0 rgba(255, 255, 255, 0.25);
flex-shrink: 0;
}
/* ---- Nav scroll region ---- */
.fsb-nav {
flex: 1;
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
padding: 14px 12px 12px;
display: flex;
flex-direction: column;
gap: 20px;
}
.fsb-nav::-webkit-scrollbar {
width: 6px;
}
.fsb-nav::-webkit-scrollbar-thumb {
background: #e2e8f0;
border-radius: 3px;
}
.fsb-nav::-webkit-scrollbar-thumb:hover {
background: #cbd5e1;
}
.fsb-nav::-webkit-scrollbar-track {
background: transparent;
}
.fsb-section-label {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.7px;
text-transform: uppercase;
color: #94a3b8;
padding: 0 12px;
margin-bottom: 6px;
}
/* ---- Top-level item ---- */
.fsb-item {
position: relative;
display: flex;
align-items: center;
gap: 11px;
width: 100%;
padding: 9px 12px;
border-radius: 11px;
cursor: pointer;
color: #475569;
font-size: 14px;
font-weight: 500;
line-height: 1.2;
text-align: left;
text-decoration: none;
transition:
background-color 160ms ease,
color 160ms ease,
box-shadow 160ms ease;
}
.fsb-item:hover {
background-color: #f5f7fa;
color: #0f172a;
}
.fsb-item[data-active="true"] {
background: linear-gradient(
135deg,
rgba(34, 197, 94, 0.12) 0%,
rgba(27, 158, 122, 0.06) 100%
);
color: #1B9E7A;
font-weight: 600;
}
.fsb-item[data-active="true"]::before {
content: "";
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 3px;
height: 22px;
border-radius: 0 4px 4px 0;
background: linear-gradient(180deg, #2DBF95 0%, #1B9E7A 100%);
}
.fsb-item-label {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* ---- Icon well ---- */
.fsb-icon {
display: flex;
align-items: center;
justify-content: center;
width: 31px;
height: 31px;
border-radius: 9px;
flex-shrink: 0;
background: #f1f5f9;
color: #64748b;
transition: all 160ms ease;
}
.fsb-item:hover .fsb-icon {
background: #e6ebf1;
color: #334155;
}
.fsb-item[data-active="true"] .fsb-icon {
background: linear-gradient(135deg, #2DBF95 0%, #1B9E7A 100%);
color: #ffffff;
box-shadow: 0 5px 12px -2px rgba(27, 158, 122, 0.45);
}
.fsb-chevron {
flex-shrink: 0;
color: #94a3b8;
transition: transform 220ms ease;
}
.fsb-chevron-btn {
display: flex;
align-items: center;
justify-content: center;
padding: 0;
margin: 0;
border: none;
background: transparent;
cursor: pointer;
flex-shrink: 0;
}
.fsb-chevron-btn:hover .fsb-chevron {
color: #64748b;
}
/* ---- Nested branch ---- */
.fsb-branch {
margin: 2px 0 2px 22px;
padding-left: 12px;
border-left: 1.5px solid #eef2f6;
display: flex;
flex-direction: column;
gap: 2px;
}
/* group header (non-navigable) */
.fsb-group {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 7px 10px;
border-radius: 8px;
cursor: pointer;
background: transparent;
transition: background-color 150ms ease;
}
.fsb-group:hover {
background-color: #f5f7fa;
}
.fsb-group-label {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.4px;
text-transform: uppercase;
color: #94a3b8;
}
.fsb-group[data-active="true"] .fsb-group-label {
color: #1B9E7A;
}
/* child leaf */
.fsb-child {
position: relative;
display: flex;
align-items: center;
gap: 9px;
width: 100%;
padding: 7px 10px;
border-radius: 8px;
cursor: pointer;
color: #64748b;
font-size: 13px;
font-weight: 500;
text-decoration: none;
transition:
background-color 150ms ease,
color 150ms ease;
}
.fsb-child:hover {
background-color: #f5f7fa;
color: #0f172a;
}
.fsb-child[data-active="true"] {
color: #1B9E7A;
font-weight: 600;
background-color: rgba(27, 158, 122, 0.08);
}
.fsb-dot {
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
background: #cbd5e1;
transition: all 150ms ease;
}
.fsb-child:hover .fsb-dot {
background: #94a3b8;
}
.fsb-child[data-active="true"] .fsb-dot {
background: #1B9E7A;
box-shadow: 0 0 0 3px rgba(27, 158, 122, 0.16);
}
/* ---- Footer status card ---- */
.fsb-footer {
flex-shrink: 0;
padding: 12px;
border-top: 1px solid #f1f5f9;
}
.fsb-status {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border-radius: 11px;
background: linear-gradient(135deg, #E7F8F2 0%, #f8fafc 100%);
border: 1px solid #e7f3ec;
}
.fsb-pulse {
position: relative;
width: 9px;
height: 9px;
border-radius: 50%;
background: #2DBF95;
flex-shrink: 0;
}
.fsb-pulse::after {
content: "";
position: absolute;
inset: 0;
border-radius: 50%;
background: #2DBF95;
animation: fsb-pulse 2s ease-out infinite;
}
@keyframes fsb-pulse {
0% {
transform: scale(1);
opacity: 0.6;
}
100% {
transform: scale(2.6);
opacity: 0;
}
}

View File

@@ -1,277 +1,248 @@
import {
type MouseEvent,
AppShell,
Box,
Group,
NavLink,
ScrollArea,
Stack,
Text,
UnstyledButton,
} from "@mantine/core";
import { ChevronDown, X } from "lucide-react";
import {
type ReactNode,
useCallback,
useEffect,
useMemo,
useState,
} from "react";
import { ChevronDown, Train } from "lucide-react";
import { Box, Stack, Text } from "@mantine/core";
import type { SidebarItem, SidebarSection } from "./types";
import "./FreightSidebar.css";
export interface FreightSidebarProps {
sections: SidebarSection[];
activeHref?: string;
onNavigate?: (href: string) => void;
/** Close handler for the mobile drawer (X button, hidden on desktop). */
onClose?: () => void;
}
const sidebarItemKey = (item: SidebarItem, parentKey: string) =>
item.href ?? `${parentKey}::${item.label}`;
const BRAND_LOGO = "/assets/logo.svg";
const collectSidebarHrefs = (items: SidebarItem[]): string[] =>
items.flatMap((item) => {
const hrefs: string[] = [];
if (item.href) hrefs.push(item.href.toLowerCase());
if (item.children?.length)
hrefs.push(...collectSidebarHrefs(item.children));
return hrefs;
});
// Active / inactive NavLink styling, expressed through the shared edr-* theme
// tokens (bridged into Tailwind in index.css). Items are pills floating on the
// page background — the navbar itself has no surface of its own.
const navClassNames = (active: boolean) =>
active
? {
root: "rounded-md transition-all duration-150 bg-edr-soft! ring-1 ring-inset ring-edr-primary/40 [&_svg]:size-[16px]",
label: "text-edr-primary-dark! font-medium! text-sm!",
section: "text-edr-primary-dark!",
}
: {
root: "rounded-md transition-all duration-150 hover:bg-[#EEF2F6]! [&_svg]:size-4",
label: "text-edr-text! font-medium! text-sm! hover:text-edr-ink!",
section: "text-edr-text!",
};
const flattenSectionItems = (sections: SidebarSection[]) =>
sections.flatMap((section) => section.items);
const itemKey = (parentKey: string, item: SidebarItem, index: number) =>
`${parentKey}/${item.href ?? item.label}/${index}`;
const collectHrefs = (items: SidebarItem[]): string[] =>
items.flatMap((item) => [
...(item.href ? [item.href.toLowerCase()] : []),
...(item.children?.length ? collectHrefs(item.children) : []),
]);
const FreightSidebar = ({
sections,
activeHref,
onNavigate,
onClose,
}: FreightSidebarProps) => {
const items = useMemo(() => flattenSectionItems(sections), [sections]);
const activePath = activeHref?.toLowerCase() ?? "";
const isHrefActive = useCallback(
(href: string) => {
const normalized = href.toLowerCase();
return (
activePath === normalized || activePath.startsWith(`${normalized}/`)
);
return activePath === normalized || activePath.startsWith(`${normalized}/`);
},
[activePath],
);
const branchContainsActive = useCallback(
(branch: SidebarItem[]) =>
collectSidebarHrefs(branch).some((href) => isHrefActive(href)),
const branchActive = useCallback(
(items: SidebarItem[]) => collectHrefs(items).some(isHrefActive),
[isHrefActive],
);
const defaultExpanded = useMemo(() => {
// Branches containing the active route start expanded; manual toggles win
// afterwards (merge keeps user intent while still opening newly-active paths).
const defaultOpen = useMemo(() => {
const acc: Record<string, boolean> = {};
const walk = (entries: SidebarItem[], parentKey: string) => {
for (const entry of entries) {
if (!entry.children?.length) continue;
const key = sidebarItemKey(entry, parentKey);
const walk = (items: SidebarItem[], parentKey: string) => {
items.forEach((item, i) => {
if (!item.children?.length) return;
const key = itemKey(parentKey, item, i);
acc[key] =
branchContainsActive(entry.children) ||
(entry.href ? isHrefActive(entry.href) : false);
walk(entry.children, key);
}
(item.href ? isHrefActive(item.href) : false) ||
branchActive(item.children);
walk(item.children, key);
});
};
for (const item of items) {
if (!item.children?.length) continue;
const key = item.href ?? item.label;
acc[key] =
activePath === key.toLowerCase() ||
activePath.startsWith(`${key.toLowerCase()}/`) ||
branchContainsActive(item.children);
walk(item.children, key);
}
sections.forEach((section) => walk(section.items, section.title));
return acc;
}, [activePath, branchContainsActive, isHrefActive, items]);
const [expanded, setExpanded] =
useState<Record<string, boolean>>(defaultExpanded);
}, [sections, isHrefActive, branchActive]);
const [openMap, setOpenMap] = useState(defaultOpen);
useEffect(() => {
setExpanded((current) => ({ ...defaultExpanded, ...current }));
}, [defaultExpanded]);
setOpenMap((current) => ({ ...defaultOpen, ...current }));
}, [defaultOpen]);
const navigateTo = (event: MouseEvent<HTMLAnchorElement>, href: string) => {
if (onNavigate) {
event.preventDefault();
onNavigate(href);
}
};
const toggle = useCallback(
(key: string) => setOpenMap((m) => ({ ...m, [key]: !m[key] })),
[],
);
const toggleExpanded = (key: string) => {
setExpanded((current) => ({ ...current, [key]: !current[key] }));
};
const renderItem = useCallback(
(item: SidebarItem, key: string): ReactNode => {
const hasChildren = !!item.children?.length;
const renderNavBranch = (
children: SidebarItem[],
depth: number,
parentKey: string,
): ReactNode =>
children.map((child) => {
const key = sidebarItemKey(child, parentKey);
const isGroup = Boolean(child.children?.length) && !child.href;
if (isGroup) {
const isOpen = expanded[key] ?? false;
const groupActive = branchContainsActive(child.children!);
if (hasChildren) {
const isLink = !!item.href;
const active =
(isLink ? isHrefActive(item.href!) : false) ||
branchActive(item.children!);
const isOpen = openMap[key] ?? false;
return (
<div key={key}>
<button
type="button"
className="fsb-group"
data-active={groupActive}
onClick={() => toggleExpanded(key)}
>
<span className="fsb-group-label">{child.label}</span>
<ChevronDown
size={13}
className="fsb-chevron"
style={{
transform: isOpen ? "rotate(0deg)" : "rotate(-90deg)",
color: groupActive ? "#1B9E7A" : undefined,
// `opened` is controlled so a link-parent navigates on row click
// without collapsing; the chevron is the only toggle affordance.
<NavLink
key={key}
label={item.label}
leftSection={item.icon}
active={active}
opened={isOpen}
classNames={navClassNames(active)}
onClick={ () => toggle(key)}
rightSection={
<Box
component="span"
role="button"
aria-label={isOpen ? "Collapse section" : "Expand section"}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
toggle(key);
}}
/>
</button>
{isOpen && (
<div className="fsb-branch">
{renderNavBranch(child.children!, depth + 1, key)}
</div>
className="flex cursor-pointer items-center"
>
<ChevronDown
size={16}
className="text-edr-muted transition-transform duration-200"
style={{ transform: isOpen ? "rotate(-180deg)" : "rotate(180deg)" }}
/>
</Box>
}
>
{item.children!.map((child, i) =>
renderItem(child, itemKey(key, child, i)),
)}
</div>
</NavLink>
);
}
if (!child.href) return null;
const childActive = isHrefActive(child.href);
if (!item.href) return null;
const active = isHrefActive(item.href);
return (
<a
<NavLink
key={key}
href={child.href}
className="fsb-child"
data-active={childActive}
onClick={(e) => navigateTo(e, child.href!)}
>
<span className="fsb-dot" />
<span className="fsb-item-label">{child.label}</span>
</a>
label={item.label}
leftSection={item.icon}
active={active}
classNames={navClassNames(active)}
onClick={() => onNavigate?.(item.href!)}
/>
);
});
},
[branchActive, isHrefActive, onNavigate, openMap, toggle],
);
const renderTopLevelItem = (item: SidebarItem) => {
if (!item.href) return null;
const hasChildren = Boolean(item.children?.length);
const itemHref = item.href.toLowerCase();
const childActive = hasChildren
? branchContainsActive(item.children!)
: false;
const isCurrentItem = hasChildren
? activePath === itemHref
: isHrefActive(itemHref);
const isActive = isCurrentItem || childActive;
const isOpen = expanded[item.href] ?? false;
return (
<Box key={item.href}>
<a
href={item.href}
className="fsb-item"
data-active={isActive}
onClick={(e) => {
if (hasChildren) {
setExpanded((current) => ({
...current,
[item.href!]: true,
}));
}
navigateTo(e, item.href!);
}}
>
{item.icon && <span className="fsb-icon">{item.icon}</span>}
<span className="fsb-item-label">{item.label}</span>
{hasChildren && (
<button
type="button"
className="fsb-chevron-btn"
aria-label={isOpen ? "Collapse section" : "Expand section"}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
toggleExpanded(item.href!);
}}
>
<ChevronDown
size={16}
className="fsb-chevron"
style={{
transform: isOpen ? "rotate(0deg)" : "rotate(-90deg)",
}}
/>
</button>
)}
</a>
{hasChildren && isOpen && (
<div className="fsb-branch">
{renderNavBranch(item.children!, 0, item.href)}
</div>
)}
</Box>
);
};
return (
<Box component="aside" className="fsb-aside">
<div className="fsb-brand">
<div className="fsb-logo">
<Train size={23} color="white" strokeWidth={2.1} />
</div>
<Stack gap={1} style={{ minWidth: 0, position: "relative", zIndex: 1 }}>
<Text
size="md"
fw={700}
style={{ letterSpacing: "-0.3px", lineHeight: 1.2, color: "#0f172a" }}
>
EDR Freight
</Text>
const renderedSections = useMemo(
() =>
sections.map((section) => (
<Box key={section.title}>
<Text
size="xs"
fw={600}
style={{ letterSpacing: "0.4px", color: "#94a3b8" }}
tt="uppercase"
px="sm"
mb={6}
className={ "text-edr-muted!" }
style={{ fontWeight: 500, fontSize: 10, letterSpacing: "0.05em" }}
>
Backoffice Console
{section.title}
</Text>
</Stack>
</div>
<nav className="fsb-nav">
{sections.map((section) => (
<div key={section.title}>
<div className="fsb-section-label">{section.title}</div>
<Stack gap={3}>
{section.items.map((item) => renderTopLevelItem(item))}
</Stack>
</div>
))}
</nav>
<div className="fsb-footer">
<div className="fsb-status">
<span className="fsb-pulse" />
<Stack gap={0} style={{ minWidth: 0 }}>
<Text size="xs" fw={600} style={{ color: "#15805F", lineHeight: 1.3 }}>
All systems operational
</Text>
<Text size="10px" style={{ color: "#94a3b8", lineHeight: 1.3 }}>
EDR Platform · v1.0
</Text>
<Stack gap={2}>
{section.items.map((item, i) =>
renderItem(item, itemKey(section.title, item, i)),
)}
</Stack>
</div>
</div>
</Box>
</Box>
)),
[renderItem, sections],
);
return (
<AppShell.Navbar
withBorder={false}
// White surface + hairline right border, matching the portal AppLayout.
// Inline styles beat Mantine's cascade layer (where a Tailwind bg-* would
// lose to the navbar's default --mantine-color-body).
style={{
backgroundColor: "var(--mantine-color-edr-card-6)",
borderRight: "1px solid var(--mantine-color-edr-border-6)",
}}
>
{/* Brand — aligns with the 64px header for a continuous top edge */}
<Box className="flex h-16 shrink-0 items-center justify-between px-5">
<Group gap={10} wrap="nowrap">
<img
src={BRAND_LOGO}
alt="EDR Freight"
className="size-8 shrink-0 object-contain"
/>
<Box>
<Text
className="text-edr-primary! leading-tight"
fw={700}
fz={15}
style={{ letterSpacing: "-0.01em" }}
>
EDR Freight
</Text>
<Text
className="text-edr-muted!"
fz={10}
fw={500}
style={{ letterSpacing: "0.02em" }}
>
Backoffice Console
</Text>
</Box>
</Group>
{onClose && (
<UnstyledButton onClick={onClose} hiddenFrom="sm" aria-label="Close sidebar">
<X size={18} className="text-edr-muted" strokeWidth={1.8} />
</UnstyledButton>
)}
</Box>
{/* Nav */}
<AppShell.Section grow component={ScrollArea} type="never" px="sm" pb="md">
<Stack gap="lg">{renderedSections}</Stack>
</AppShell.Section>
</AppShell.Navbar>
);
};

View File

@@ -50,6 +50,41 @@ const ROUTE_META: Array<{ prefix: string; meta: PageMeta }> = [
subtitle: "View booking payment transactions",
},
},
{
prefix: "/dashboard/operations/first-mile",
meta: {
title: "First Mile",
subtitle: "Assign vehicles to initial-leg pickups",
},
},
{
prefix: "/dashboard/operations/last-mile",
meta: {
title: "Last Mile",
subtitle: "Assign vehicles to final-leg deliveries"
}
},
{
prefix: "/dashboard/warehouse-dashboard",
meta: {
title: "Warehouse Dashboard",
subtitle: "Live overview of warehouse capacity and inventory lifecycle",
},
},
{
prefix: "/dashboard/warehouses/",
meta: {
title: "Warehouse detail",
subtitle: "Yards, zones, and inventory for this warehouse",
},
},
{
prefix: "/dashboard/warehouses",
meta: {
title: "Warehouses",
subtitle: "Manage warehouses, yards and zones",
},
},
{
prefix: "/dashboard/operations/train-scheduling-v2/",
meta: {

View File

@@ -1,65 +1,36 @@
import { Group, Paper, Text } from "@mantine/core";
import { KpiStrip, type KpiItem } from "@/components/page";
import {
OverviewKpiCard,
type KpiGraphVariant,
type OverviewKpiItem,
} from "./OverviewKpiCard";
import type { OverviewKpiItem } from "./OverviewKpiCard";
/** Rotate mini-graph types per card so each strip reads as a lively mix. */
const VARIANT_CYCLE: KpiGraphVariant[] = ["area", "line", "ring"];
/** Cohesive accent rotation — gold-forward with an orange and neutral break. */
const ACCENT_CYCLE: NonNullable<OverviewKpiItem["accent"]>[] = [
"gold",
"orange",
"default",
];
/**
* Map the overview accent vocabulary onto brand / Mantine palette colors so the
* shared KpiStrip renders a flat tinted icon chip per cell — no gradients,
* gauges or sparklines.
*/
const ACCENT_COLOR: Record<string, string> = {
default: "edr-green",
emerald: "edr-green",
amber: "yellow",
rose: "red",
sky: "blue",
violet: "violet",
gold: "yellow",
orange: "orange",
};
interface OverviewKpiStripProps {
title?: string;
items: OverviewKpiItem[];
}
/** Parse a numeric magnitude out of a KPI value (handles formatted currency strings). */
function toNumber(value: number | string): number {
if (typeof value === "number") return value;
const parsed = Number(String(value).replace(/[^0-9.-]/g, ""));
return Number.isFinite(parsed) ? parsed : 0;
}
/** Clean KPI strip for the overview tabs — delegates to the shared KpiStrip. */
export function OverviewKpiStrip({ items }: OverviewKpiStripProps) {
const kpiItems: KpiItem[] = items.map((item) => ({
label: item.label,
value: item.value,
icon: item.icon,
hint: item.hint,
color: ACCENT_COLOR[item.accent ?? "default"] ?? "edr-green",
}));
export function OverviewKpiStrip({ title, items }: OverviewKpiStripProps) {
const max = Math.max(...items.map((item) => toNumber(item.value)), 0);
return (
<Paper
p="lg"
radius="lg"
withBorder
style={{
background: "#ffffff",
border: "1px solid var(--mantine-color-gray-2)",
}}
>
{title && (
<Text size="sm" fw={600} mb="md" c="dimmed">
{title}
</Text>
)}
<Group gap="md" align="stretch" wrap="wrap">
{items.map((item, index) => (
<OverviewKpiCard
key={item.label}
item={{
...item,
accent: ACCENT_CYCLE[index % ACCENT_CYCLE.length],
variant: item.variant ?? VARIANT_CYCLE[index % VARIANT_CYCLE.length],
progress:
item.progress ?? (max > 0 ? toNumber(item.value) / max : 0),
}}
/>
))}
</Group>
</Paper>
);
return <KpiStrip items={kpiItems} />;
}

View File

@@ -47,11 +47,11 @@ export function OverviewPageHeader({
data={RANGE_OPTIONS}
size="sm"
radius="lg"
color="green"
color="edr-green"
/>
<ActionIcon
variant="light"
color="green"
color="edr-green"
size="lg"
radius="lg"
aria-label="Refresh dashboard"

View File

@@ -49,7 +49,7 @@ export function OverviewQuickLinks() {
>
<Group justify="space-between" align="flex-start" wrap="nowrap">
<Group align="flex-start" gap="sm" wrap="nowrap">
<ThemeIcon variant="light" color="green" size="lg" radius="md">
<ThemeIcon variant="light" color="edr-green" size="lg" radius="md">
<Icon size={18} />
</ThemeIcon>
<Stack gap={2}>

View File

@@ -78,7 +78,7 @@ export function OverviewTabContent({ tab, range }: OverviewTabContentProps) {
<Stack gap="md" pos="relative">
{isFetching && (
<Center style={{ position: "absolute", top: 8, right: 8, zIndex: 2 }}>
<Loader size="sm" color="green" />
<Loader size="sm" color="edr-green" />
</Center>
)}

View File

@@ -16,7 +16,7 @@
font-weight: 600;
}
.ov-seg-label[data-active] {
color: #15805f;
color: var(--mantine-color-edr-green-7);
}
/* ---- Premium tab bar ---- */
@@ -47,10 +47,8 @@
box-shadow: 0 2px 10px -4px rgba(15, 23, 42, 0.18);
}
.ov-tab[data-active] {
background: linear-gradient(135deg, #2dbf95 0%, #1b9e7a 100%) !important;
background: var(--mantine-color-edr-green-5) !important;
color: #ffffff !important;
box-shadow: 0 10px 20px -8px rgba(27, 158, 122, 0.55);
transform: translateY(-1px);
}
.ov-tab[data-active]:hover {
color: #ffffff;

View File

@@ -0,0 +1,94 @@
import { Card, Skeleton, Text } from "@mantine/core";
import type { LucideIcon } from "lucide-react";
import type { ReactNode } from "react";
import { cn } from "@/lib/utils";
export interface KpiItem {
label: string;
value: ReactNode;
/** Optional leading icon rendered in a tinted chip. */
icon?: LucideIcon;
/** Secondary line under the label (e.g. a unit or comparison). */
hint?: string;
/**
* Mantine color name for the icon chip (e.g. "edr-green", "red", "yellow").
* Defaults to the brand green so a strip reads as uniform unless a page opts
* into semantic tints.
*/
color?: string;
}
export interface KpiStripProps {
items: KpiItem[];
/** Show skeletons in place of values while data loads. */
loading?: boolean;
}
/**
* A single bordered card divided into up to five KPI cells:
* `[ kpi | kpi | kpi ]`. Hairline dividers separate cells (vertical on wide
* screens, horizontal when they wrap). Surface, border and shadow all come from
* the theme — no per-cell backgrounds, gradients or custom shadows.
*/
export function KpiStrip({ items, loading = false }: KpiStripProps) {
// The spec caps a strip at five cells; extra items are dropped rather than
// silently overflowing into an unreadable row.
const cells = items.slice(0, 5);
return (
<Card withBorder shadow="sm" p={0} className="overflow-hidden">
<div className="flex flex-col sm:flex-row">
{cells.map((item, index) => {
const Icon = item.icon;
const color = item.color ?? "edr-green";
return (
<div
key={item.label}
className={cn(
"flex flex-1 items-center gap-3 px-5 py-4",
index > 0 &&
"border-t border-edr-border sm:border-l sm:border-t-0",
)}
>
{Icon ? (
<div
className="flex size-10 shrink-0 items-center justify-center rounded-lg"
style={{
background: `var(--mantine-color-${color}-1)`,
color: `var(--mantine-color-${color}-7)`,
}}
>
<Icon size={20} strokeWidth={2} />
</div>
) : null}
<div style={{ minWidth: 0 }}>
{loading ? (
<Skeleton height={26} width={72} radius="sm" my={2} />
) : (
<Text
fw={800}
fz={24}
lh={1.05}
c="edr-text"
style={{ letterSpacing: "-0.02em" }}
truncate
>
{item.value}
</Text>
)}
<Text size="xs" fw={600} c="edr-muted" truncate>
{item.label}
{item.hint ? ` · ${item.hint}` : ""}
</Text>
</div>
</div>
);
})}
</div>
</Card>
);
}
export default KpiStrip;

View File

@@ -0,0 +1,26 @@
import { Box, Stack, type MantineSpacing } from "@mantine/core";
import type { ReactNode } from "react";
export interface PageContainerProps {
children: ReactNode;
/** Drop the max-width cap for full-bleed pages (boards, very wide tables). */
fluid?: boolean;
/** Vertical gap between the page's stacked sections. */
gap?: MantineSpacing;
}
/**
* Standard page shell: one consistent inset + a vertical Stack so every
* dashboard page shares the same outer padding and inter-section rhythm.
* The surrounding AppShell.Main already paints the page background, so this
* never sets its own — pages stay on the shared `edr-bg` surface.
*/
export function PageContainer({ children, fluid = false, gap = "lg" }: PageContainerProps) {
return (
<Box px="lg" py="lg" mx="auto" w="100%" maw={fluid ? undefined : 1600}>
<Stack gap={gap}>{children}</Stack>
</Box>
);
}
export default PageContainer;

View File

@@ -0,0 +1,78 @@
import { ActionIcon, Group, Stack, Text, Title } from "@mantine/core";
import { ArrowLeft } from "lucide-react";
import type { ReactNode } from "react";
import { useNavigate } from "react-router-dom";
import Breadcrumbs, { type BreadcrumbItem } from "@/components/ui/Breadcrumbs";
export interface PageHeaderProps {
title: string;
subtitle?: string;
/** Breadcrumb trail — pass only on nested pages (details, sub-resources). */
breadcrumbs?: BreadcrumbItem[];
/** Route to return to; renders a back arrow before the title. */
backTo?: string;
/** Inline content beside the title (e.g. status badges). */
meta?: ReactNode;
/** Right-aligned actions — the primary CTA lives here. */
action?: ReactNode;
}
/**
* Unified page header: optional breadcrumbs, a title (with optional back arrow
* and inline meta), a subtitle, and a right-aligned action slot. Keeps title /
* action placement and spacing identical across every dashboard page.
*/
export function PageHeader({
title,
subtitle,
breadcrumbs,
backTo,
meta,
action,
}: PageHeaderProps) {
const navigate = useNavigate();
return (
<Stack gap="sm">
{breadcrumbs?.length ? <Breadcrumbs items={breadcrumbs} /> : null}
<Group justify="space-between" align="flex-start" gap="md">
<Group gap="sm" align="center" wrap="nowrap" style={{ minWidth: 0 }}>
{backTo ? (
<ActionIcon
variant="subtle"
color="gray"
onClick={() => navigate(backTo)}
aria-label="Go back"
>
<ArrowLeft size={18} />
</ActionIcon>
) : null}
<div style={{ minWidth: 0 }}>
<Group gap="sm" align="center" wrap="nowrap">
<Title order={2} className="truncate">
{title}
</Title>
{meta}
</Group>
{subtitle ? (
<Text c="dimmed" size="sm" mt={4}>
{subtitle}
</Text>
) : null}
</div>
</Group>
{action ? (
<Group gap="sm" wrap="nowrap">
{action}
</Group>
) : null}
</Group>
</Stack>
);
}
export default PageHeader;

View File

@@ -0,0 +1,6 @@
export { PageContainer } from "./PageContainer";
export type { PageContainerProps } from "./PageContainer";
export { PageHeader } from "./PageHeader";
export type { PageHeaderProps } from "./PageHeader";
export { KpiStrip } from "./KpiStrip";
export type { KpiItem, KpiStripProps } from "./KpiStrip";

View File

@@ -1,6 +1,9 @@
import { useState } from "react";
import { FileSignature, Loader2 } from "lucide-react";
import { useMutation, useQuery } from "@tanstack/react-query";
import toast from "react-hot-toast";
import { api } from "@/services/api";
import { ContractSignaturePad } from "@/components/bookings/ContractSignaturePad";
import {
Card,
@@ -10,10 +13,6 @@ import {
CardTitle,
} from "@/components/ui/card";
import { useAuth } from "@/auth/useAuth";
import {
useMySignature,
useSaveSignature,
} from "@/hooks/useSavedSignature";
import {
Button,
Dialog,
@@ -33,8 +32,10 @@ import {
*/
export function MySignatureCard() {
const { user } = useAuth();
const { data: saved, isLoading } = useMySignature();
const saveMutation = useSaveSignature();
const { data: saved, isLoading } = useQuery(
api.signatures.mySignature.queryOptions({ staleTime: 60_000 }),
);
const saveMutation = useMutation(api.signatures.save.mutationOptions());
const [open, setOpen] = useState(false);
const [signerName, setSignerName] = useState("");
@@ -56,7 +57,13 @@ export function MySignatureCard() {
signerDisplayName: signerName.trim(),
signatureImageBase64: signatureData,
},
{ onSuccess: () => setOpen(false) },
{
onSuccess: () => {
toast.success("Signature saved");
setOpen(false);
},
onError: () => toast.error("Failed to save signature"),
},
);
};

View File

@@ -333,7 +333,7 @@ const ManageRuleEngineOrderDialog = ({
Cancel
</Button>
<Button
color="green"
color="edr-green"
onClick={handleSave}
disabled={isLoading || isSaving}
leftSection={

View File

@@ -1,5 +1,5 @@
import type { OnChangeFn, PaginationState } from "@tanstack/react-table";
import { Stack, Group, Text, Card, SimpleGrid } from "@mantine/core";
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";
import type { RuleEngineRecord } from "@/types/rule-engine";
@@ -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>
@@ -99,13 +106,16 @@ const RuleEngineCardGrid = ({
<Stack gap="md" p="md">
<SimpleGrid cols={{ base: 1, sm: 2, md: 2, lg: 3 }} spacing="md">
{Array.from({ length: 6 }).map((_, index) => (
<Card key={index} p="md" radius="lg" withBorder style={{ height: "280px", background: "var(--mantine-color-gray-0)" }}>
<div style={{ animation: "pulse 2s infinite", opacity: 0.5 }}>
<div style={{ height: "20px", background: "var(--mantine-color-gray-3)", borderRadius: "4px", marginBottom: "12px" }} />
<div style={{ height: "16px", background: "var(--mantine-color-gray-3)", borderRadius: "4px", marginBottom: "20px", width: "80%" }} />
<div style={{ height: "16px", background: "var(--mantine-color-gray-3)", borderRadius: "4px", marginBottom: "8px" }} />
<div style={{ height: "16px", background: "var(--mantine-color-gray-3)", borderRadius: "4px" }} />
</div>
<Card key={index} p="lg">
<Group gap="sm" mb="md">
<Skeleton height={44} width={44} radius="md" />
<Stack gap={6} style={{ flex: 1 }}>
<Skeleton height={14} width="70%" radius="sm" />
<Skeleton height={10} width="40%" radius="sm" />
</Stack>
</Group>
<Skeleton height={12} radius="sm" mb={8} />
<Skeleton height={12} width="80%" radius="sm" />
</Card>
))}
</SimpleGrid>
@@ -115,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>
@@ -124,8 +141,8 @@ const RuleEngineCardGrid = ({
);
}
const avatarBg = "#f1f5f9";
const avatarText = "#475569";
const avatarBg = "var(--mantine-color-gray-1)";
const avatarText = "var(--mantine-color-gray-7)";
return (
<Stack gap="md" p="md">
@@ -152,24 +169,7 @@ const RuleEngineCardGrid = ({
<Card
key={record.id}
p="lg"
radius="lg"
withBorder
style={{
background: "white",
border: "1px solid var(--mantine-color-gray-2)",
display: "flex",
flexDirection: "column",
transition: "all 0.2s ease",
cursor: "pointer",
}}
onMouseEnter={(e) => {
e.currentTarget.style.boxShadow = "0 4px 12px rgba(0, 0, 0, 0.08)";
e.currentTarget.style.borderColor = "var(--mantine-color-gray-3)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.boxShadow = "none";
e.currentTarget.style.borderColor = "var(--mantine-color-gray-2)";
}}
style={{ display: "flex", flexDirection: "column" }}
>
<Group justify="space-between" align="flex-start" mb="md">
<Group gap="sm" style={{ flex: 1, minWidth: 0 }}>
@@ -207,11 +207,14 @@ const RuleEngineCardGrid = ({
</Group>
{(subtitle || presentation.detailColumns.length > 0) && (
<Stack gap="xs" style={{ flex: 1, marginBottom: "md" }}>
<Stack gap="xs" mb="md" style={{ flex: 1 }}>
{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}
@@ -221,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>
@@ -234,14 +242,19 @@ const RuleEngineCardGrid = ({
</Stack>
)}
<Group justify="flex-end" gap="xs" style={{ borderTop: "1px solid var(--mantine-color-gray-1)", paddingTop: "md" }}>
<Group
justify="flex-end"
gap="xs"
pt="md"
style={{ borderTop: "1px solid var(--mantine-color-gray-2)" }}
>
<RuleEngineRecordActions
record={record}
config={config}
layout="compact"
readOnly={readOnly}
onEdit={onEdit ?? (() => {})}
onDelete={onDelete ?? (() => {})}
onEdit={onEdit ?? (() => { })}
onDelete={onDelete ?? (() => { })}
onViewChain={onViewChain}
onSubmitRate={onSubmitRate}
onApproveRate={onApproveRate}

View File

@@ -119,15 +119,6 @@ const resolveSelectValue = (
const inputStyles = {
label: { fontWeight: 600, marginBottom: 6, color: "var(--mantine-color-gray-8)" },
input: {
borderColor: "#e2e8f0",
background: "white",
transition: "border-color 0.15s ease, box-shadow 0.15s ease",
"&:focus": {
borderColor: "var(--freight-brand)",
boxShadow: "0 0 0 3px var(--freight-brand-ring)",
},
},
} as const;
const FieldLabel = ({ label, required }: { label: string; required?: boolean }) => (
@@ -228,8 +219,8 @@ const RuleEngineFormDialog = ({
px="md"
style={{
minHeight: 42,
background: "#f8fafc",
border: "1px solid #e2e8f0",
background: "var(--mantine-color-gray-0)",
border: "1px solid var(--mantine-color-gray-3)",
borderRadius: "var(--mantine-radius-md)",
}}
>
@@ -240,7 +231,7 @@ const RuleEngineFormDialog = ({
checked={Boolean(values[field.name])}
onChange={(e) => setField(field.name, e.currentTarget.checked)}
size="md"
color="green"
color="edr-green"
/>
</Group>
);
@@ -387,7 +378,7 @@ const RuleEngineFormDialog = ({
) : undefined
}
radius="md"
color="green"
color="edr-green"
variant="filled"
fw={600}
size="md"

View File

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

View File

@@ -52,7 +52,7 @@ const RuleEngineToolbar = ({
onChange={(value) => onViewModeChange(value as RuleEngineViewMode)}
size="sm"
radius="lg"
color="green"
color="edr-green"
data={[
{
value: "table",
@@ -101,7 +101,7 @@ const RuleEngineToolbar = ({
leftSection={<Plus size={18} />}
size="sm"
radius="lg"
color="green"
color="edr-green"
variant="filled"
fw={600}
style={{ whiteSpace: "nowrap" }}

View File

@@ -39,7 +39,7 @@ export const formatCell = (value: unknown, format?: ColumnFormat): ReactNode =>
const active = Boolean(value);
return (
<Badge
color={active ? "green" : "gray"}
color={active ? "edr-green" : "gray"}
variant={active ? "filled" : "light"}
size="sm"
radius="md"
@@ -53,7 +53,7 @@ export const formatCell = (value: unknown, format?: ColumnFormat): ReactNode =>
const status = String(value);
const color =
status === "LIVE"
? "green"
? "edr-green"
: status === "DRAFT"
? "yellow"
: status === "PENDING_APPROVAL"
@@ -85,6 +85,15 @@ export const formatCell = (value: unknown, format?: ColumnFormat): ReactNode =>
);
}
if (format === "text") {
return <Text size="sm">{String(value)}</Text>;
}
if (format === "number") {
const num = Number(value);
return <Text size="sm">{Number.isNaN(num) ? String(value) : num.toLocaleString()}</Text>;
}
if (format === "date") {
const d = new Date(String(value));
if (Number.isNaN(d.getTime())) return <Text size="sm">{String(value)}</Text>;

View File

@@ -40,7 +40,7 @@ export const ruleEngineCard = {
"group flex flex-col overflow-hidden rounded-lg border border-border bg-card shadow-sm transition-shadow duration-200 hover:shadow-md",
header: "border-b border-border bg-muted/25 px-3 py-2.5 sm:px-4 sm:py-3.5",
avatar:
"flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-[#f1f5f9] text-xs font-semibold text-slate-600 sm:h-10 sm:w-10 sm:text-sm",
"flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-muted text-xs font-semibold text-muted-foreground sm:h-10 sm:w-10 sm:text-sm",
title: "truncate text-sm font-semibold text-foreground sm:text-[15px]",
meta: "text-xs text-muted-foreground",
detailLabel:

View File

@@ -31,13 +31,8 @@ import {
Weight,
} from "lucide-react";
import {
useAvailableLocomotives,
useEligibleBookings,
useScheduleList,
useScheduleMutations,
} from "@/hooks/trainScheduling/useTrainScheduling";
import { useRoutes } from "@/hooks/useRoutes";
import { useMutation, useQuery } from "@tanstack/react-query";
import { api } from "@/services/api";
import { useToast } from "@/hooks/use-toast";
import { trainSchedulingService } from "@/services/trainScheduling.service";
import type { BookingDetail } from "@/types/booking";
@@ -133,13 +128,27 @@ export function AllocateBookingWizard({
[originId, destinationId],
);
const eligibleQuery = useEligibleBookings(eligibleFilters, opened);
const schedulesQuery = useScheduleList();
const routesQuery = useRoutes();
const locomotivesQuery = useAvailableLocomotives(
scheduleMode === "new" && routeId ? routeId : undefined,
const eligibleQuery = useQuery(
api.trainScheduling.eligibleBookings.queryOptions({
input: { filters: eligibleFilters },
enabled: opened,
}),
);
const { create, preview, assign, finalize } = useScheduleMutations(selectedScheduleId ?? undefined);
const schedulesQuery = useQuery(
api.trainScheduling.scheduleList.queryOptions({ input: {} }),
);
const routesQuery = useQuery(api.routes.list.queryOptions());
const locomotivesQuery = useQuery(
api.trainScheduling.availableLocomotives.queryOptions({
input: {
routeId: scheduleMode === "new" && routeId ? routeId : undefined,
},
}),
);
const create = useMutation(api.trainScheduling.createSchedule.mutationOptions());
const preview = useMutation(api.trainScheduling.preview.mutationOptions());
const assign = useMutation(api.trainScheduling.assignBookings.mutationOptions());
const finalize = useMutation(api.trainScheduling.finalizeSchedule.mutationOptions());
useEffect(() => {
if (scheduleMode === "new") {
@@ -447,27 +456,27 @@ export function AllocateBookingWizard({
if (key === "bookings") {
if (previewResult) {
return (
<Badge variant="light" color={previewResult.valid ? "green" : "red"} radius="sm">
<Badge variant="light" color={previewResult.valid ? "edr-green" : "red"} radius="sm">
{previewResult.valid ? "Plan valid" : "Has issues"}
</Badge>
);
}
return allBookingIds.length ? (
<Badge variant="light" color="green" radius="sm">
<Badge variant="light" color="edr-green" radius="sm">
{allBookingIds.length} selected
</Badge>
) : null;
}
if (key === "wagon" && displayWagonPlan.length) {
return (
<Badge variant="light" color="green" radius="sm">
<Badge variant="light" color="edr-green" radius="sm">
{displayWagonPlan.length} wagons
</Badge>
);
}
if (key === "container" && containerUnits.length) {
return (
<Badge variant="light" color={containerComplete ? "green" : "yellow"} radius="sm">
<Badge variant="light" color={containerComplete ? "edr-green" : "yellow"} radius="sm">
{containerUnits.length} units
</Badge>
);
@@ -577,7 +586,7 @@ export function AllocateBookingWizard({
size="sm"
/>
<Button
color="green"
color="edr-green"
radius="md"
leftSection={<Eye size={16} />}
loading={preview.isPending}
@@ -648,7 +657,7 @@ export function AllocateBookingWizard({
<Group>
{!hasContainerStep ? (
<Button
color="green"
color="edr-green"
radius="md"
loading={assign.isPending || create.isPending}
onClick={handleAssign}
@@ -657,7 +666,7 @@ export function AllocateBookingWizard({
</Button>
) : (
<Button
color="green"
color="edr-green"
radius="md"
rightSection={<ContainerIcon size={16} />}
onClick={() => setActiveStep(2)}
@@ -692,7 +701,7 @@ export function AllocateBookingWizard({
)}
<Group>
<Button
color="green"
color="edr-green"
radius="md"
loading={assign.isPending || create.isPending}
onClick={handleAssign}
@@ -732,7 +741,7 @@ export function AllocateBookingWizard({
style={{ background: scheduleBrand.softSurface, borderColor: scheduleBrand.mutedBorder }}
>
<Group gap="md" align="flex-start" wrap="nowrap">
<ThemeIcon size={44} radius="md" variant="light" color="green">
<ThemeIcon size={44} radius="md" variant="light" color="edr-green">
<CheckCircle2 size={22} />
</ThemeIcon>
<Stack gap={2} style={{ flex: 1 }}>
@@ -741,14 +750,14 @@ export function AllocateBookingWizard({
</Text>
<Text size="sm" c="dimmed">
Booking {booking.reference} is scheduled on train{" "}
<Text span fw={600} c="green.7">
<Text span fw={600} c="edr-green.7">
{assignedSchedule?.trainSet?.locomotive?.code ?? "—"}
</Text>
.
</Text>
<Group mt="sm">
<Button
color="green"
color="edr-green"
radius="md"
onClick={() => {
onClose();
@@ -777,14 +786,14 @@ export function AllocateBookingWizard({
style={{ background: scheduleBrand.softSurface, borderColor: scheduleBrand.mutedBorder }}
>
<Group gap="md" align="flex-start" wrap="nowrap">
<ThemeIcon size={44} radius="md" variant="light" color="green">
<ThemeIcon size={44} radius="md" variant="light" color="edr-green">
<CheckCircle2 size={22} />
</ThemeIcon>
<Stack gap={2}>
<Text fw={600}>Ready to finalize</Text>
<Text size="sm" c="dimmed">
Finalizing locks the plan, moves the schedule to{" "}
<Text span fw={600} c="green.7">
<Text span fw={600} c="edr-green.7">
SCHEDULED
</Text>
, and completes the booking allocation.
@@ -794,7 +803,7 @@ export function AllocateBookingWizard({
</Paper>
<Group>
<Button
color="green"
color="edr-green"
size="md"
radius="md"
leftSection={<CheckCircle2 size={18} />}
@@ -852,7 +861,7 @@ export function AllocateBookingWizard({
size={56}
radius="lg"
variant="white"
style={{ color: "var(--mantine-color-green-7)" }}
style={{ color: "var(--mantine-color-edr-green-7)" }}
>
<Train size={28} />
</ThemeIcon>
@@ -884,7 +893,7 @@ export function AllocateBookingWizard({
size="lg"
radius="sm"
variant="white"
c={previewResult.valid ? "green.8" : "red.7"}
c={previewResult.valid ? "edr-green.8" : "red.7"}
leftSection={
<Box
w={8}
@@ -892,7 +901,7 @@ export function AllocateBookingWizard({
style={{
borderRadius: 999,
background: previewResult.valid
? "var(--mantine-color-green-6)"
? "var(--mantine-color-edr-green-6)"
: "var(--mantine-color-red-6)",
}}
/>
@@ -941,7 +950,7 @@ export function AllocateBookingWizard({
size={44}
radius="md"
variant="gradient"
gradient={{ from: "green", to: "teal", deg: 135 }}
gradient={{ from: "edr-green", to: "teal", deg: 135 }}
>
<RouteIcon size={22} />
</ThemeIcon>
@@ -959,9 +968,9 @@ export function AllocateBookingWizard({
size={64}
thickness={6}
roundCaps
sections={[{ value: progressPct, color: "green" }]}
sections={[{ value: progressPct, color: "edr-green" }]}
label={
<Text ta="center" size="xs" fw={700} c="green.7">
<Text ta="center" size="xs" fw={700} c="edr-green.7">
{progressPct}%
</Text>
}

View File

@@ -120,7 +120,7 @@ export function ContainerPlacementGrid({
value={progress}
size="sm"
radius="xl"
color={issues.length ? "yellow" : "green"}
color={issues.length ? "yellow" : "edr-green"}
/>
</Stack>
</Paper>
@@ -135,7 +135,7 @@ export function ContainerPlacementGrid({
</Stack>
) : (
<Badge
color="green"
color="edr-green"
variant="light"
size="sm"
w="fit-content"
@@ -163,7 +163,7 @@ export function ContainerPlacementGrid({
{unit.label} · {unit.containerTypeCode} · {unit.grossWeightTons}T
</Text>
</Stack>
<Badge size="sm" variant="light" color={isComplete ? "green" : "gray"}>
<Badge size="sm" variant="light" color={isComplete ? "edr-green" : "gray"}>
{isComplete ? "Ready" : "Pending"}
</Badge>
</Group>

View File

@@ -37,14 +37,14 @@ function EligibleBookingRow({
p="sm"
style={{
border: `1px solid ${
selected ? "var(--mantine-color-green-3)" : "var(--mantine-color-gray-2)"
selected ? "var(--mantine-color-edr-green-3)" : "var(--mantine-color-gray-2)"
}`,
borderRadius: 12,
background: selected ? "var(--mantine-color-green-0)" : "white",
background: selected ? "var(--mantine-color-edr-green-0)" : "white",
transition: "border-color 120ms ease, background 120ms ease",
}}
>
<Checkbox checked={selected} onChange={onToggle} mt={4} color="green" />
<Checkbox checked={selected} onChange={onToggle} mt={4} color="edr-green" />
<Stack gap={4} style={{ flex: 1 }}>
<Group gap="xs" wrap="wrap">
<Package size={14} />
@@ -205,7 +205,7 @@ export function EligibleBookingsPanel({
</Text>
</Stack>
<Group gap="xs" onClick={(e) => e.stopPropagation()}>
<Badge variant="light" color="green">
<Badge variant="light" color="edr-green">
{selectedInBucket.length} selected
</Badge>
<Button

View File

@@ -43,7 +43,7 @@ export function FleetAvailabilitySummary({
</Text>
</Stack>
</Group>
<Badge variant="light" color={totalShortfall > 0 ? "yellow" : "green"}>
<Badge variant="light" color={totalShortfall > 0 ? "yellow" : "edr-green"}>
{fillRate}% fleet coverage
</Badge>
</Group>
@@ -59,7 +59,7 @@ export function FleetAvailabilitySummary({
value={fillRate}
size="sm"
radius="xl"
color={totalShortfall > 0 ? "yellow" : "green"}
color={totalShortfall > 0 ? "yellow" : "edr-green"}
/>
</Stack>
) : null}
@@ -86,7 +86,7 @@ export function FleetAvailabilitySummary({
{row.shortfall}
</Badge>
) : (
<Text size="sm" c="green">
<Text size="sm" c="edr-green">
0
</Text>
)}

View File

@@ -143,7 +143,7 @@ export function RouteCorridorTrack({
fw={passed ? 700 : 600}
ta="center"
lineClamp={2}
c={passed ? "green.8" : "dimmed"}
c={passed ? "edr-green.8" : "dimmed"}
>
{station.label}
</Text>
@@ -172,7 +172,7 @@ export function RouteCorridorTrack({
<Button
size="compact-xs"
radius="md"
color={isFinal ? "teal" : "green"}
color={isFinal ? "teal" : "edr-green"}
variant={isFinal ? "filled" : "light"}
loading={loggingSeq === station.sequenceNo}
onClick={() => onLogCheckpoint?.(station.sequenceNo)}

View File

@@ -13,11 +13,10 @@ import {
} from "@mantine/core";
import { CheckCircle2, Layers, Lock, LockOpen, PlayCircle, Repeat, XCircle } from "lucide-react";
import { useMutation, useQuery } from "@tanstack/react-query";
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
import {
useBatchActions,
useBookableSchedules,
} from "@/hooks/trainScheduling/useTrainScheduling";
import { api } from "@/services/api";
import { useToast } from "@/hooks/use-toast";
import type { TrainScheduleDetail } from "@/types/trainScheduling";
@@ -26,23 +25,38 @@ interface ScheduleBatchPanelProps {
}
const windowColor: Record<string, string> = {
OPEN: "green",
OPEN: "edr-green",
FULL: "orange",
CLOSED: "gray",
};
export function ScheduleBatchPanel({ schedule }: ScheduleBatchPanelProps) {
const { toast } = useToast();
const actions = useBatchActions(schedule.id);
const actions = {
runBatch: useMutation(api.trainScheduling.runBatch.mutationOptions()),
setWindow: useMutation(api.trainScheduling.setBookingWindow.mutationOptions()),
markPaid: useMutation(api.trainScheduling.markBookingPaid.mutationOptions()),
expire: useMutation(api.trainScheduling.expireBooking.mutationOptions()),
moveSchedule: useMutation(
api.trainScheduling.moveBookingSchedule.mutationOptions(),
),
};
const windowStatus = (schedule as { bookingWindowStatus?: string }).bookingWindowStatus ?? "OPEN";
const locked = schedule.status === "DISPATCHED" || schedule.status === "ARRIVED";
const [moveBookingId, setMoveBookingId] = useState<string | null>(null);
const [moveTarget, setMoveTarget] = useState<string | null>(null);
const { data: targets } = useBookableSchedules(
schedule.originStation?.id,
schedule.destinationStation?.id,
const { data: targets } = useQuery(
api.trainScheduling.bookableSchedules.queryOptions({
input: {
originYardId: schedule.originStation?.id,
destinationYardId: schedule.destinationStation?.id,
},
enabled: Boolean(
schedule.originStation?.id && schedule.destinationStation?.id,
),
}),
);
const moveOptions = useMemo(
() =>
@@ -68,7 +82,7 @@ export function ScheduleBatchPanel({ schedule }: ScheduleBatchPanelProps) {
<Paper radius="lg" withBorder p="lg" style={{ borderColor: "var(--mantine-color-gray-2)" }}>
<Group justify="space-between" align="center" mb="md" wrap="wrap">
<Group gap="sm">
<ThemeIcon size={36} radius="md" variant="light" color="green">
<ThemeIcon size={36} radius="md" variant="light" color="edr-green">
<Layers size={18} />
</ThemeIcon>
<div>
@@ -90,7 +104,7 @@ export function ScheduleBatchPanel({ schedule }: ScheduleBatchPanelProps) {
<Button
size="compact-sm"
variant="light"
color="green"
color="edr-green"
leftSection={<PlayCircle size={15} />}
loading={actions.runBatch.isPending}
onClick={() => run(actions.runBatch.mutateAsync(schedule.id), "Batch fill run")}
@@ -169,7 +183,7 @@ export function ScheduleBatchPanel({ schedule }: ScheduleBatchPanelProps) {
<Button
size="compact-xs"
variant="light"
color="green"
color="edr-green"
leftSection={<CheckCircle2 size={13} />}
onClick={() => run(actions.markPaid.mutateAsync(b.id), "Marked paid")}
>
@@ -228,7 +242,7 @@ export function ScheduleBatchPanel({ schedule }: ScheduleBatchPanelProps) {
Cancel
</Button>
<Button
color="green"
color="edr-green"
disabled={!moveTarget}
loading={actions.moveSchedule.isPending}
onClick={() => {

View File

@@ -1,4 +1,3 @@
import { ArrowRight, Package, Train } from "lucide-react";
import {
Badge,
Button,
@@ -8,6 +7,7 @@ import {
Tabs,
Text,
} from "@mantine/core";
import { ArrowRight, Package, Train } from "lucide-react";
import type { EligibleContainerBooking, FreightType } from "@/types/trainScheduling";
@@ -46,7 +46,7 @@ export function ScheduleBookingsStep({
defaultValue={assignedBookings.length ? "on-train" : "add"}
radius="md"
variant="pills"
color="green"
color="edr-green"
>
<Tabs.List mb="md">
<Tabs.Tab
@@ -54,7 +54,7 @@ export function ScheduleBookingsStep({
leftSection={<Train size={14} />}
rightSection={
assignedBookings.length ? (
<Badge size="xs" variant="light" color="green" circle>
<Badge size="xs" variant="light" color="edr-green" circle>
{assignedBookings.length}
</Badge>
) : undefined
@@ -76,9 +76,9 @@ export function ScheduleBookingsStep({
justify="space-between"
p="sm"
style={{
border: "1px solid var(--mantine-color-green-2)",
border: "1px solid var(--mantine-color-edr-green-2)",
borderRadius: 12,
background: "var(--mantine-color-green-0)",
background: "var(--mantine-color-edr-green-0)",
}}
>
<Stack gap={4}>
@@ -87,7 +87,7 @@ export function ScheduleBookingsStep({
{booking.reference}
</Text>
{booking.weightTons != null ? (
<Badge variant="outline" size="xs" color="green">
<Badge variant="outline" size="xs" color="edr-green">
{booking.weightTons}T
</Badge>
) : null}
@@ -97,7 +97,7 @@ export function ScheduleBookingsStep({
Assigned to this consist
</Text>
<ArrowRight size={12} />
<Text size="xs" c="green.7" fw={500}>
<Text size="xs" c="edr-green.7" fw={500}>
Ready for wagon plan
</Text>
</Group>

View File

@@ -3,7 +3,7 @@ import { Badge } from "@mantine/core";
const STATUS_COLORS: Record<string, string> = {
DRAFT: "gray",
SCHEDULED: "blue",
DISPATCHED: "green",
DISPATCHED: "edr-green",
ARRIVED: "teal",
CANCELLED: "red",
};
@@ -34,7 +34,7 @@ export function SchedulingStatusBadge({ status }: { status?: string | null }) {
HOLDING: "yellow",
ELIGIBLE: "blue",
SCHEDULED: "indigo",
DISPATCHED: "green",
DISPATCHED: "edr-green",
};
return (
<Badge variant="light" color={colors[status] ?? "gray"} size="sm">

View File

@@ -54,7 +54,7 @@ export function PreviewSummary({
{ label: "Train length", value: `${summary.totalLengthMeters}m` },
];
return (
<Paper p="md" radius="xl" withBorder bg="green.0">
<Paper p="md" radius="xl" withBorder bg="edr-green.0">
<Text size="sm" fw={600} mb="sm">
Plan summary
</Text>

View File

@@ -49,7 +49,7 @@ export function SchedulingWorkflowHeader({
>
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
<Group gap="md" align="flex-start">
<ThemeIcon size={42} radius="xl" variant="gradient" gradient={{ from: "green", to: "teal", deg: 135 }}>
<ThemeIcon size={42} radius="xl" variant="gradient" gradient={{ from: "edr-green", to: "teal", deg: 135 }}>
<Icon size={20} />
</ThemeIcon>
<Stack gap={4}>
@@ -63,7 +63,7 @@ export function SchedulingWorkflowHeader({
) : null}
</Stack>
</Group>
<Badge size="lg" variant="light" color="green">
<Badge size="lg" variant="light" color="edr-green">
Step {activeStep + 1} of {totalSteps}
</Badge>
</Group>
@@ -83,7 +83,7 @@ export function SchedulingWorkflowHeader({
{progress}%
</Text>
</Group>
<Progress value={progress} size="sm" radius="xl" color="green" />
<Progress value={progress} size="sm" radius="xl" color="edr-green" />
</Stack>
</Paper>
);

View File

@@ -618,18 +618,18 @@ export function TrainCompositionDiagram({
p="sm"
style={{
borderRadius: 12,
background: "linear-gradient(135deg, var(--mantine-color-green-0), #F2FBF7)",
border: "1px solid var(--mantine-color-green-1)",
background: "linear-gradient(135deg, var(--mantine-color-edr-green-0), #F2FBF7)",
border: "1px solid var(--mantine-color-edr-green-1)",
}}
>
<Group justify="space-between" mb={6}>
<Group gap={6} wrap="nowrap">
<Gauge size={14} color={freightBrand.primary} />
<Text size="xs" fw={700} c="green.8">
<Text size="xs" fw={700} c="edr-green.8">
Locomotive load · {stats.totalWeight}T of {locomotive?.maxPullWeightTons}T
</Text>
</Group>
<Text size="sm" fw={800} c={stats.pullUtil > 95 ? "red.7" : "green.7"}>
<Text size="sm" fw={800} c={stats.pullUtil > 95 ? "red.7" : "edr-green.7"}>
{stats.pullUtil}%
</Text>
</Group>
@@ -639,7 +639,7 @@ export function TrainCompositionDiagram({
radius="xl"
striped={stats.pullUtil > 95}
animated={stats.pullUtil > 95}
color={stats.pullUtil > 95 ? "red" : stats.pullUtil > 80 ? "yellow" : "green"}
color={stats.pullUtil > 95 ? "red" : stats.pullUtil > 80 ? "yellow" : "edr-green"}
/>
</Box>
) : null}

View File

@@ -133,7 +133,7 @@ export function WagonPlanGrid({
value={utilization}
size="sm"
radius="xl"
color={utilization > 95 ? "red" : utilization > 80 ? "yellow" : "green"}
color={utilization > 95 ? "red" : utilization > 80 ? "yellow" : "edr-green"}
/>
</Stack>
) : null}

View File

@@ -123,13 +123,13 @@ export function WorkflowStep({
<Text
size="xs"
fw={700}
c={isComplete || isActive ? "green.7" : "dimmed"}
c={isComplete || isActive ? "edr-green.7" : "dimmed"}
style={{ letterSpacing: 0.6 }}
>
STEP {index + 1}
</Text>
{isComplete ? (
<Badge size="xs" variant="light" color="green" radius="sm">
<Badge size="xs" variant="light" color="edr-green" radius="sm">
Done
</Badge>
) : null}
@@ -155,10 +155,10 @@ export function WorkflowStep({
height: 28,
borderRadius: 8,
background: open
? "var(--mantine-color-green-0)"
? "var(--mantine-color-edr-green-0)"
: "var(--mantine-color-gray-1)",
color: open
? "var(--mantine-color-green-7)"
? "var(--mantine-color-edr-green-7)"
: "var(--mantine-color-gray-6)",
}}
>
@@ -209,7 +209,7 @@ export function WorkflowRail({ children }: { children: ReactNode }) {
bottom: 24,
width: 2,
background:
"linear-gradient(180deg, var(--mantine-color-green-3) 0%, var(--mantine-color-gray-3) 100%)",
"linear-gradient(180deg, var(--mantine-color-edr-green-3) 0%, var(--mantine-color-gray-3) 100%)",
borderRadius: 2,
pointerEvents: "none",
}}

View File

@@ -27,7 +27,7 @@ export const PIPELINE_STAGES: ReadonlyArray<{
{
key: "allocated",
label: "Allocated",
color: "green",
color: "edr-green",
hint: "Assigned to wagons on the train",
},
{
@@ -137,7 +137,7 @@ export function BookingPipeline({
}
const WINDOW_META: Record<string, { color: string; label: string; pulse: boolean }> = {
OPEN: { color: "green", label: "Window open", pulse: true },
OPEN: { color: "edr-green", label: "Window open", pulse: true },
FULL: { color: "orange", label: "Full", pulse: false },
CLOSED: { color: "gray", label: "Closed", pulse: false },
};

View File

@@ -4,7 +4,8 @@ import { Building2, Package, TrainFront, Weight, X } from "lucide-react";
import type { TrainScheduleDetail } from "@/types/trainScheduling";
import type { BookingDetailData } from "./BookingDetailModal";
import { RemoveBookingConfirmModal, type RemovalTarget } from "./RemoveBookingConfirmModal";
import { useScheduleMutations } from "@/hooks/trainScheduling/useTrainScheduling";
import { useMutation } from "@tanstack/react-query";
import { api } from "@/services/api";
import { useToast } from "@/hooks/use-toast";
import { freightBrand } from "@/theme/freight-brand";
@@ -22,7 +23,7 @@ export const AssignedBookingsPanel = ({
onSelect,
}: AssignedBookingsPanelProps) => {
const { toast } = useToast();
const unassign = useScheduleMutations(scheduleId).unassign;
const unassign = useMutation(api.trainScheduling.unassignBooking.mutationOptions());
const isDispatched = scheduleDetail.status === "DISPATCHED";
const [removalTarget, setRemovalTarget] = useState<RemovalTarget | null>(null);
@@ -95,7 +96,7 @@ export const AssignedBookingsPanel = ({
}}
>
<Group gap={8} wrap="nowrap" align="flex-start">
<ThemeIcon size={30} radius="md" variant="light" color="green">
<ThemeIcon size={30} radius="md" variant="light" color="edr-green">
<Package size={16} />
</ThemeIcon>
<Box style={{ flex: 1, minWidth: 0 }}>
@@ -107,7 +108,7 @@ export const AssignedBookingsPanel = ({
<Badge
size="xs"
variant="light"
color="green"
color="edr-green"
leftSection={<TrainFront size={9} />}
>
{wagonCountByBooking.get(booking.id)}

View File

@@ -11,7 +11,7 @@ import {
import type { TrainScheduleDetail } from "@/types/trainScheduling";
import { freightBrand } from "@/theme/freight-brand";
type Wagon = TrainScheduleDetail["trainSet"]["wagons"][number];
type Wagon = NonNullable<TrainScheduleDetail["trainSet"]>["wagons"][number];
export interface BookingDetailData {
bookingId: string;
@@ -43,7 +43,7 @@ function InfoRow({
return (
<Group justify="space-between" wrap="nowrap" gap="md">
<Group gap={8} wrap="nowrap">
<ThemeIcon size={28} radius="md" variant="light" color="green">
<ThemeIcon size={28} radius="md" variant="light" color="edr-green">
{icon}
</ThemeIcon>
<Text size="sm" c="dimmed">
@@ -146,7 +146,7 @@ export const BookingDetailModal = ({
bookingWagons.length ? (
<Group gap={4} justify="flex-end">
{bookingWagons.map((w) => (
<Badge key={w.id} size="sm" variant="outline" color="green" radius="sm">
<Badge key={w.id} size="sm" variant="outline" color="edr-green" radius="sm">
#{w.sequenceNo}
</Badge>
))}

View File

@@ -8,10 +8,8 @@ import { UnassignedBookingsPanel } from "./UnassignedBookingsPanel";
import { RemovalLogPanel } from "./RemovalLogPanel";
import { BatchBookingList } from "./BatchBookingList";
import { BookingDetailModal, type BookingDetailData } from "./BookingDetailModal";
import {
useCompositionRemovals,
useUnassignedBookings,
} from "@/hooks/trainScheduling/useTrainScheduling";
import { useQuery } from "@tanstack/react-query";
import { api } from "@/services/api";
import { freightBrand } from "@/theme/freight-brand";
interface CompositionBookingTabsProps {
@@ -29,7 +27,7 @@ interface CompositionBookingTabsProps {
type TabKey = "assigned" | "unassigned" | "payment" | "expired" | "removed";
const TAB_META: Record<TabKey, { label: string; icon: LucideIcon; color: string }> = {
assigned: { label: "Assigned to train", icon: PackageCheck, color: "green" },
assigned: { label: "Assigned to train", icon: PackageCheck, color: "edr-green" },
unassigned: { label: "Unassigned (ready to load)", icon: PackagePlus, color: "orange" },
payment: { label: "Awaiting payment", icon: CreditCard, color: "orange" },
expired: { label: "Expired bookings", icon: XCircle, color: "red" },
@@ -47,8 +45,18 @@ export const CompositionBookingTabs = ({
const [detailBooking, setDetailBooking] = useState<BookingDetailData | null>(null);
const [tab, setTab] = useState<TabKey>("assigned");
const unassignedQuery = useUnassignedBookings(scheduleId);
const removalsQuery = useCompositionRemovals(scheduleId);
const unassignedQuery = useQuery(
api.trainScheduling.unassignedBookings.queryOptions({
input: { scheduleId },
enabled: Boolean(scheduleId),
}),
);
const removalsQuery = useQuery(
api.trainScheduling.compositionRemovals.queryOptions({
input: { scheduleId },
enabled: Boolean(scheduleId),
}),
);
const { assignedCount } = useMemo(() => {
const wagons = scheduleDetail.trainSet?.wagons ?? [];
@@ -152,7 +160,7 @@ export const CompositionBookingTabs = ({
value={tab}
onChange={(v) => v && setTab(v as TabKey)}
variant="default"
color="green"
color="edr-green"
style={{ flex: 1, display: "flex", flexDirection: "column", minHeight: 0 }}
>
<Tabs.List grow>
@@ -220,7 +228,7 @@ export const CompositionBookingTabs = ({
}}
>
<Group gap={5} wrap="nowrap">
<PackageCheck size={13} color="var(--mantine-color-green-7)" />
<PackageCheck size={13} color="var(--mantine-color-edr-green-7)" />
<Text size="xs" c="dimmed">
{assignedCount} on train
</Text>

View File

@@ -1,6 +1,7 @@
import { useState } from "react";
import { Group, TextInput, Text } from "@mantine/core";
import { useUpdateContainerItem } from "@/hooks/trainScheduling/useTrainScheduling";
import { useMutation } from "@tanstack/react-query";
import { api } from "@/services/api";
interface ContainerNumberInputProps {
value: string | null;
@@ -19,13 +20,16 @@ export const ContainerNumberInput = ({
const [inputValue, setInputValue] = useState(value ?? "");
const [error, setError] = useState<string | null>(null);
const updateMutation = useUpdateContainerItem(scheduleId);
const updateMutation = useMutation(
api.trainScheduling.updateContainerItem.mutationOptions(),
);
const isLoading = updateMutation.isPending;
const handleSave = async () => {
try {
setError(null);
await updateMutation.mutateAsync({
scheduleId,
itemId,
containerNumber: inputValue || null,
});

View File

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

View File

@@ -1,13 +1,19 @@
import { Box, Card, Group, Stack, Text, ThemeIcon } from "@mantine/core";
import { History, PackageX } from "lucide-react";
import { useCompositionRemovals } from "@/hooks/trainScheduling/useTrainScheduling";
import { useQuery } from "@tanstack/react-query";
import { api } from "@/services/api";
interface RemovalLogPanelProps {
scheduleId: string;
}
export const RemovalLogPanel = ({ scheduleId }: RemovalLogPanelProps) => {
const removalQuery = useCompositionRemovals(scheduleId);
const removalQuery = useQuery(
api.trainScheduling.compositionRemovals.queryOptions({
input: { scheduleId },
enabled: Boolean(scheduleId),
}),
);
if (removalQuery.isLoading) {
return (

View File

@@ -60,7 +60,7 @@ export const RemoveBookingConfirmModal = ({
<Text fw={800} size="sm">
{target?.reference ?? "Booking"}
</Text>
<Badge variant="light" color="green" leftSection={<TrainFront size={10} />}>
<Badge variant="light" color="edr-green" leftSection={<TrainFront size={10} />}>
{target?.wagonCount ?? 0} wagon{target?.wagonCount === 1 ? "" : "s"}
</Badge>
</Group>

View File

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

View File

@@ -6,10 +6,11 @@ import { TrainStatsBar } from "./TrainStatsBar";
import { WagonCard } from "./WagonCard";
import { InteractiveTrainConsist } from "./InteractiveTrainConsist";
import { RemoveBookingModal } from "./RemoveBookingModal";
import { useScheduleMutations, useRemoveWagonSlot } from "@/hooks/trainScheduling/useTrainScheduling";
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;
@@ -47,8 +48,12 @@ export const TrainConsistView = ({
const [selectedWagonId, setSelectedWagonId] = useState<string | null>(null);
const [removeModalOpen, setRemoveModalOpen] = useState(false);
const unassignMutation = useScheduleMutations(scheduleId).unassign;
const removeWagonMutation = useRemoveWagonSlot(scheduleId);
const unassignMutation = useMutation(
api.trainScheduling.unassignBooking.mutationOptions(),
);
const removeWagonMutation = useMutation(
api.trainScheduling.removeWagonSlot.mutationOptions(),
);
const trainSet = scheduleDetail.trainSet;
const wagons = trainSet?.wagons ?? [];
@@ -83,7 +88,7 @@ export const TrainConsistView = ({
const handleRemoveWagon = async (wagonId: string) => {
if (confirm("Are you sure you want to remove this wagon slot?")) {
await removeWagonMutation.mutateAsync(wagonId);
await removeWagonMutation.mutateAsync({ scheduleId, wagonId });
setSelectedWagonId(null);
}
};
@@ -98,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}
/>
@@ -165,7 +170,7 @@ export const TrainConsistView = ({
{selectedWagon ? (
<Box>
<Group gap={6} mb={6} wrap="nowrap">
<Badge variant="light" color="green" radius="sm">
<Badge variant="light" color="edr-green" radius="sm">
Editing wagon #{selectedWagon.sequenceNo}
</Badge>
<Text size="xs" c="dimmed">

View File

@@ -1,9 +1,7 @@
import { Badge, Box, Button, Card, Group, Stack, Text, ThemeIcon, Tooltip } from "@mantine/core";
import { AlertTriangle, Container as ContainerIcon, MapPin, Plus, TrainFront } from "lucide-react";
import {
useUnassignedBookings,
useScheduleMutations,
} from "@/hooks/trainScheduling/useTrainScheduling";
import { useMutation, useQuery } from "@tanstack/react-query";
import { api } from "@/services/api";
import { useToast } from "@/hooks/use-toast";
import type { FleetAvailabilityRow } from "@/types/trainScheduling";
import type { BookingDetailData } from "./BookingDetailModal";
@@ -48,18 +46,18 @@ const YardFleetBanner = ({ fleetAtOrigin }: { fleetAtOrigin: FleetAvailabilityRo
py={6}
style={{
borderRadius: 8,
background: "var(--mantine-color-green-0)",
border: "1px solid var(--mantine-color-green-1)",
background: "var(--mantine-color-edr-green-0)",
border: "1px solid var(--mantine-color-edr-green-1)",
}}
>
<Group gap={5} wrap="nowrap">
<MapPin size={13} color="var(--mantine-color-green-7)" />
<Text size="xs" fw={700} c="green.8">
<MapPin size={13} color="var(--mantine-color-edr-green-7)" />
<Text size="xs" fw={700} c="edr-green.8">
Origin yard
</Text>
</Group>
{fleetAtOrigin.map((row) => (
<Badge key={row.wagonTypeId} size="sm" variant="light" color="green">
<Badge key={row.wagonTypeId} size="sm" variant="light" color="edr-green">
{row.wagonTypeCode}: {row.available}
</Badge>
))}
@@ -73,8 +71,15 @@ export const UnassignedBookingsPanel = ({
onSelect,
}: UnassignedBookingsPanelProps) => {
const { toast } = useToast();
const unassignedQuery = useUnassignedBookings(scheduleId);
const assignMutation = useScheduleMutations(scheduleId).assignUnassigned;
const unassignedQuery = useQuery(
api.trainScheduling.unassignedBookings.queryOptions({
input: { scheduleId },
enabled: Boolean(scheduleId),
}),
);
const assignMutation = useMutation(
api.trainScheduling.assignUnassignedBooking.mutationOptions(),
);
const handleAssign = async (bookingId: string, reference: string | null) => {
try {
@@ -148,7 +153,7 @@ export const UnassignedBookingsPanel = ({
}
style={{
cursor: "pointer",
borderColor: isActive ? "var(--mantine-color-green-5)" : undefined,
borderColor: isActive ? "var(--mantine-color-edr-green-5)" : undefined,
}}
>
<Stack gap={6}>
@@ -162,7 +167,7 @@ export const UnassignedBookingsPanel = ({
{booking.reference}
</Text>
{booking.priorityScore ? (
<Badge size="xs" color="green">
<Badge size="xs" color="edr-green">
P{booking.priorityScore}
</Badge>
) : null}
@@ -194,7 +199,7 @@ export const UnassignedBookingsPanel = ({
<Button
size="xs"
variant="light"
color="green"
color="edr-green"
disabled={!fits}
onClick={(e) => {
e.stopPropagation();

View File

@@ -12,7 +12,7 @@ import type { TrainScheduleDetail } from "@/types/trainScheduling";
import { ContainerNumberInput } from "./ContainerNumberInput";
import { freightBrand } from "@/theme/freight-brand";
type Wagon = TrainScheduleDetail["trainSet"]["wagons"][number];
type Wagon = NonNullable<TrainScheduleDetail["trainSet"]>["wagons"][number];
interface WagonCardProps {
wagon: Wagon;
@@ -47,7 +47,7 @@ export const WagonCard = ({
<Card.Section withBorder inheritPadding py="xs" style={{ background: freightBrand.mutedBg }}>
<Group justify="space-between">
<Group gap={6}>
<ThemeIcon size={28} radius="md" variant="white" color="green">
<ThemeIcon size={28} radius="md" variant="white" color="edr-green">
<TrainFront size={16} />
</ThemeIcon>
<div>
@@ -55,7 +55,7 @@ export const WagonCard = ({
<Text size="sm" fw={800}>
Wagon #{wagon.sequenceNo}
</Text>
<Badge size="xs" variant="light" color="green">
<Badge size="xs" variant="light" color="edr-green">
{wagonType}
</Badge>
</Group>
@@ -142,7 +142,7 @@ export const WagonCard = ({
</Group>
<Progress
value={Math.min(weightPercent, 100)}
color={weightPercent > 90 ? "red" : weightPercent > 75 ? "orange" : "green"}
color={weightPercent > 90 ? "red" : weightPercent > 75 ? "orange" : "edr-green"}
size="sm"
radius="xl"
/>

View File

@@ -32,7 +32,7 @@ const STATUS_META: Record<
{ color: string; dot: string; label?: string }
> = {
DRAFT: { color: "gray", dot: "var(--mantine-color-gray-5)" },
SCHEDULED: { color: "green", dot: freightBrand.primary },
SCHEDULED: { color: "edr-green", dot: freightBrand.primary },
DISPATCHED: { color: "teal", dot: "var(--mantine-color-teal-6)" },
ARRIVED: { color: "blue", dot: "var(--mantine-color-blue-6)" },
CANCELLED: { color: "red", dot: "var(--mantine-color-red-6)" },

View File

@@ -2,7 +2,7 @@ import type { MantineTheme } from "@mantine/core";
export const schedulingWorkflow = {
stepper: {
color: "green" as const,
color: "edr-green" as const,
iconSize: 32,
size: "sm" as const,
},
@@ -12,11 +12,11 @@ export const schedulingWorkflow = {
withBorder: true,
},
heroGradient: (theme: MantineTheme) =>
`linear-gradient(135deg, ${theme.colors.green[0]} 0%, ${theme.white} 55%, ${theme.colors.gray[0]} 100%)`,
`linear-gradient(135deg, ${theme.colors["edr-green"][0]} 0%, ${theme.white} 55%, ${theme.colors.gray[0]} 100%)`,
workflowGradient: (theme: MantineTheme) =>
`linear-gradient(180deg, ${theme.white} 0%, ${theme.colors.gray[0]} 100%)`,
accentColor: "green" as const,
successColor: "green" as const,
accentColor: "edr-green" as const,
successColor: "edr-green" as const,
warningColor: "yellow" as const,
};

View File

@@ -1,6 +1,6 @@
import { Fragment } from "react";
import { Anchor, Breadcrumbs as MantineBreadcrumbs, Text } from "@mantine/core";
import { ChevronRight } from "lucide-react";
import { Link } from "react-router-dom";
import { ChevronRight, Home } from "lucide-react";
export interface BreadcrumbItem {
label: string;
@@ -12,45 +12,48 @@ export interface BreadcrumbsProps {
}
export default function Breadcrumbs({ items }: BreadcrumbsProps) {
return (
<nav
aria-label="Breadcrumb"
className="flex items-center text-sm text-slate-500"
>
<Link
to="/"
aria-label="Home"
className="flex items-center transition hover:text-[var(--freight-brand)]"
>
{/* <Home className="h-4 w-4" /> */}
Dashboard
</Link>
// The dashboard root is always the first crumb; callers pass only the trail
// beyond it.
const crumbs: BreadcrumbItem[] = [{ label: "Dashboard", href: "/" }, ...items];
{items.map((item, i) => {
const isLast = i === items.length - 1;
return (
<MantineBreadcrumbs
separator={<ChevronRight size={14} aria-hidden />}
separatorMargin="xs"
styles={{
root: { flexWrap: "wrap", rowGap: 4 },
separator: { color: "var(--mantine-color-edr-muted-5)" },
}}
>
{crumbs.map((item, index) => {
const isLast = index === crumbs.length - 1;
if (item.href && !isLast) {
return (
<Anchor
key={`${item.label}-${index}`}
component={Link}
to={item.href}
size="sm"
c="dimmed"
>
{item.label}
</Anchor>
);
}
return (
<Fragment key={`${item.label}-${i}`}>
<ChevronRight className="mx-2 h-4 w-4 text-slate-300" />
{item.href && !isLast ? (
<Link
to={item.href}
className="transition hover:text-[var(--freight-brand)]"
>
{item.label}
</Link>
) : (
<span
aria-current={isLast ? "page" : undefined}
className="font-medium text-slate-900"
>
{item.label}
</span>
)}
</Fragment>
<Text
key={`${item.label}-${index}`}
size="sm"
fw={isLast ? 600 : 400}
c={isLast ? "edr-text" : "dimmed"}
aria-current={isLast ? "page" : undefined}
>
{item.label}
</Text>
);
})}
</nav>
</MantineBreadcrumbs>
);
}

View File

@@ -4,17 +4,18 @@ import { Button, Group, Modal, NumberInput, Select, Stack, Text } from "@mantine
import { Freight } from "@edr/types";
import { useMutation, useQuery } from "@tanstack/react-query";
import { api } from "@/services/api";
import { useToast } from "@/hooks/use-toast";
import { useRouteYards } from "@/hooks/useRoutes";
import { useAssignWagonToTrain, useWagons } from "@/hooks/useWagons";
export function AssignWagonDialog({ trainId }: { trainId: string }) {
const [open, setOpen] = useState(false);
const [wagonId, setWagonId] = useState<string | null>(null);
const [sequence, setSequence] = useState<number | "">("");
const { data: wagons } = useWagons();
const { data: yards = [] } = useRouteYards();
const assign = useAssignWagonToTrain();
const { data: wagons } = useQuery(api.wagons.list.queryOptions({ input: {} }));
const { data: yards = [] } = useQuery(api.routes.yards.queryOptions());
const assign = useMutation(api.wagons.assignToTrain.mutationOptions());
const { toast } = useToast();
const available = (wagons ?? []).filter(
@@ -56,7 +57,7 @@ export function AssignWagonDialog({ trainId }: { trainId: string }) {
return (
<>
<Button
color="green"
color="edr-green"
size="sm"
radius="lg"
leftSection={<Plus size={16} />}
@@ -91,7 +92,7 @@ export function AssignWagonDialog({ trainId }: { trainId: string }) {
<Button variant="default" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button color="green" loading={assign.isPending} onClick={handleAssign}>
<Button color="edr-green" loading={assign.isPending} onClick={handleAssign}>
Assign
</Button>
</Group>

View File

@@ -3,15 +3,22 @@ import { Trash2 } from "lucide-react";
import type { ColumnDef } from "@edr/ui-common";
import { ActionIcon, Badge, Group, Text, Tooltip } from "@mantine/core";
import { useMutation, useQuery } from "@tanstack/react-query";
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
import { api } from "@/services/api";
import { useToast } from "@/hooks/use-toast";
import { useUnassignWagon, useWagonsByTrain } from "@/hooks/useWagons";
import type { Wagon } from "@/services/wagon.service";
import { DataTable } from "@edr/ui-common";
export function WagonsTable({ trainId }: { trainId: string }) {
const { data: wagons = [], isLoading, refetch } = useWagonsByTrain(trainId);
const unassign = useUnassignWagon();
const { data: wagons = [], isLoading, refetch } = useQuery(
api.wagons.listByTrain.queryOptions({
input: { trainId },
enabled: !!trainId,
}),
);
const unassign = useMutation(api.wagons.unassign.mutationOptions());
const { toast } = useToast();
const columns = useMemo((): ColumnDef<Wagon>[] => {

View File

@@ -9,7 +9,9 @@ import {
Warehouse,
} from 'lucide-react';
import { useInventoryActivity } from '@/hooks/useWarehouses';
import { useQuery } from '@tanstack/react-query';
import { api } from '@/services/api';
import type { ActivityType } from '@/types/warehouse';
import { formatDate, humanizeEnum } from './options';
@@ -24,7 +26,12 @@ const activityIcon: Record<ActivityType, React.ReactNode> = {
};
export function ActivityTimeline({ inventoryId }: { inventoryId: string }) {
const { data, isLoading } = useInventoryActivity(inventoryId);
const { data, isLoading } = useQuery(
api.warehouses.activity.queryOptions({
input: { id: inventoryId },
enabled: Boolean(inventoryId),
}),
);
const items = data ?? [];
if (isLoading) {

View File

@@ -9,9 +9,10 @@ import {
TextInput,
} from '@mantine/core';
import { useMutation, useQuery } from '@tanstack/react-query';
import { api } from '@/services/api';
import { useToast } from '@/hooks/use-toast';
import { useStations } from '@/hooks/useStations';
import { useCreateWarehouse, useUpdateWarehouse } from '@/hooks/useWarehouses';
import type { SaveWarehousePayload, Warehouse, WarehouseType } from '@/types/warehouse';
import { extractErrorMessage, statusOptions, warehouseTypeOptions } from './options';
@@ -48,9 +49,11 @@ const emptyForm = (): FormState => ({
export function CreateWarehouseModal({ opened, onClose, warehouse }: CreateWarehouseModalProps) {
const isEdit = Boolean(warehouse);
const { toast } = useToast();
const createMutation = useCreateWarehouse();
const updateMutation = useUpdateWarehouse();
const { data: stations } = useStations();
const createMutation = useMutation(api.warehouses.create.mutationOptions());
const updateMutation = useMutation(api.warehouses.update.mutationOptions());
const { data: stations } = useQuery(
api.stations.list.queryOptions({ staleTime: 5 * 60 * 1000 }),
);
const [form, setForm] = useState<FormState>(emptyForm());
const stationOptions = (stations ?? []).map((s) => ({ value: s.id, label: `${s.name} (${s.code})` }));

View File

@@ -1,8 +1,10 @@
import { useEffect, useState } from 'react';
import { Button, Group, Modal, NumberInput, Select, Stack, TextInput } from '@mantine/core';
import { useMutation } from '@tanstack/react-query';
import { api } from '@/services/api';
import { useToast } from '@/hooks/use-toast';
import { useCreateYard, useUpdateYard } from '@/hooks/useWarehouses';
import type { SaveYardPayload, WarehouseYard, WarehouseYardType } from '@/types/warehouse';
import { extractErrorMessage, statusOptions, yardTypeOptions } from './options';
@@ -36,8 +38,8 @@ const emptyForm = (): FormState => ({
export function CreateYardModal({ opened, onClose, warehouseId, yard }: CreateYardModalProps) {
const isEdit = Boolean(yard);
const { toast } = useToast();
const createMutation = useCreateYard();
const updateMutation = useUpdateYard();
const createMutation = useMutation(api.warehouses.createYard.mutationOptions());
const updateMutation = useMutation(api.warehouses.updateYard.mutationOptions());
const [form, setForm] = useState<FormState>(emptyForm());
useEffect(() => {

View File

@@ -1,8 +1,10 @@
import { useEffect, useState } from 'react';
import { Button, Group, Modal, NumberInput, Select, Stack, TextInput } from '@mantine/core';
import { useMutation } from '@tanstack/react-query';
import { api } from '@/services/api';
import { useToast } from '@/hooks/use-toast';
import { useCreateZone, useUpdateZone } from '@/hooks/useWarehouses';
import type { SaveZonePayload, WarehouseZone, WarehouseZoneType } from '@/types/warehouse';
import { extractErrorMessage, statusOptions, zoneTypeOptions } from './options';
@@ -36,8 +38,8 @@ const emptyForm = (): FormState => ({
export function CreateZoneModal({ opened, onClose, yardId, zone }: CreateZoneModalProps) {
const isEdit = Boolean(zone);
const { toast } = useToast();
const createMutation = useCreateZone();
const updateMutation = useUpdateZone();
const createMutation = useMutation(api.warehouses.createZone.mutationOptions());
const updateMutation = useMutation(api.warehouses.updateZone.mutationOptions());
const [form, setForm] = useState<FormState>(emptyForm());
useEffect(() => {

View File

@@ -2,8 +2,10 @@ import { useEffect, useState } from 'react';
import { Alert, Button, Group, Modal, Stack, Text, Textarea, TextInput } from '@mantine/core';
import { Info } from 'lucide-react';
import { useMutation } from '@tanstack/react-query';
import { api } from '@/services/api';
import { useToast } from '@/hooks/use-toast';
import { useDeliverInventory } from '@/hooks/useWarehouses';
import type { WarehouseInventoryItem } from '@/types/warehouse';
import { extractErrorMessage } from './options';
@@ -15,7 +17,7 @@ interface DeliverInventoryModalProps {
export function DeliverInventoryModal({ opened, onClose, item }: DeliverInventoryModalProps) {
const { toast } = useToast();
const deliverMutation = useDeliverInventory();
const deliverMutation = useMutation(api.warehouses.deliver.mutationOptions());
const [receiverName, setReceiverName] = useState('');
const [remarks, setRemarks] = useState('');

View File

@@ -1,6 +1,9 @@
import { Badge, Button, Card, Divider, Group, Loader, Modal, Stack, Text } from '@mantine/core';
import { CalendarClock, Coins, DoorOpen, FileText } from 'lucide-react';
import { useMutation, useQuery } from '@tanstack/react-query';
import { api } from '@/services/api';
import { useToast } from '@/hooks/use-toast';
import {
useFeePreview,
@@ -17,7 +20,7 @@ const INVOICE_STATUS_COLOR: Record<WarehouseInvoiceStatus, string> = {
DRAFT: 'gray',
ISSUED: 'orange',
PARTIALLY_PAID: 'yellow',
PAID: 'green',
PAID: 'edr-green',
CANCELLED: 'gray',
};
@@ -93,10 +96,20 @@ function Row({ label, value }: { label: string; value: string }) {
export function FeePreviewModal({ opened, onClose, inventoryId }: FeePreviewModalProps) {
const { toast } = useToast();
const enabledId = opened ? inventoryId ?? undefined : undefined;
const { data, isLoading } = useFeePreview(enabledId);
const { data: invoices } = useInvoicesForInventory(enabledId);
const generate = useGenerateInvoice();
const gateClear = useGateClearance();
const { data, isLoading } = useQuery(
api.warehouses.feePreview.queryOptions({
input: { inventoryId: enabledId ?? '' },
enabled: Boolean(enabledId),
}),
);
const { data: invoices } = useQuery(
api.warehouses.invoicesForInventory.queryOptions({
input: { inventoryId: enabledId ?? '' },
enabled: Boolean(enabledId),
}),
);
const generate = useMutation(api.warehouses.generateInvoice.mutationOptions());
const gateClear = useMutation(api.warehouses.gateClearance.mutationOptions());
const activeInvoice = (invoices ?? []).find((i) => i.status !== 'CANCELLED');
@@ -191,7 +204,7 @@ export function FeePreviewModal({ opened, onClose, inventoryId }: FeePreviewModa
<Button
variant="light"
color="green"
color="edr-green"
leftSection={<DoorOpen size={16} />}
loading={gateClear.isPending}
onClick={handleGateClearance}

View File

@@ -12,6 +12,9 @@ import {
} from '@mantine/core';
import { Upload } from 'lucide-react';
import { useMutation } from '@tanstack/react-query';
import { api } from '@/services/api';
import { useToast } from '@/hooks/use-toast';
import { useCreateInspectionReport, useInspectionReports, useUploadInspectionAttachments } from '@/hooks/useWarehouses';
import {
@@ -227,7 +230,7 @@ export function InspectionReportModal({ opened, onClose, inventoryId }: Inspecti
<Button variant="default" onClick={onClose} disabled={submitting}>
Cancel
</Button>
<Button color="green" onClick={handleSubmit} loading={submitting} disabled={!inventoryId}>
<Button color="edr-green" onClick={handleSubmit} loading={submitting} disabled={!inventoryId}>
Save report
</Button>
</Group>

View File

@@ -1,12 +1,19 @@
import { Center, Loader, Table, Text } from '@mantine/core';
import { useInventoryMovements } from '@/hooks/useWarehouses';
import { useQuery } from '@tanstack/react-query';
import { api } from '@/services/api';
import { formatDate } from './options';
const shortId = (id?: string | null) => (id ? `${id.slice(0, 8)}` : '—');
export function InventoryMovementHistoryTable({ inventoryId }: { inventoryId: string }) {
const { data, isLoading } = useInventoryMovements(inventoryId);
const { data, isLoading } = useQuery(
api.warehouses.movements.queryOptions({
input: { id: inventoryId },
enabled: Boolean(inventoryId),
}),
);
const movements = data ?? [];
if (isLoading) {

View File

@@ -2,6 +2,9 @@ import { useState } from 'react';
import { Button, Center, Group, Loader, Stack, Text } from '@mantine/core';
import { ClipboardCheck } from 'lucide-react';
import { useMutation } from '@tanstack/react-query';
import { api } from '@/services/api';
import { useToast } from '@/hooks/use-toast';
import {
useBulkMarkInspected,
@@ -46,11 +49,17 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo
const [releaseItem, setReleaseItem] = useState<WarehouseInventoryItem | null>(null);
const [deliverItem, setDeliverItem] = useState<WarehouseInventoryItem | null>(null);
const storeMutation = useStoreInventory();
const readyMutation = useMarkReadyForLoading();
const pickupMutation = useMarkReadyForPickup();
const dispatchMutation = useDispatchInventory();
const inspectMutation = useBulkMarkInspected();
const storeMutation = useMutation(api.warehouses.store.mutationOptions());
const readyMutation = useMutation(
api.warehouses.markReadyForLoading.mutationOptions(),
);
const pickupMutation = useMutation(
api.warehouses.markReadyForPickup.mutationOptions(),
);
const dispatchMutation = useMutation(api.warehouses.dispatch.mutationOptions());
const inspectMutation = useMutation(
api.warehouses.bulkMarkInspected.mutationOptions(),
);
const [selected, setSelected] = useState<Set<string>>(new Set());
const allSelected = items.length > 0 && selected.size === items.length;
@@ -70,10 +79,7 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo
return;
}
try {
const res = (await inspectMutation.mutateAsync({ inventoryIds: [...selected] })) as {
data: { inspectedCount: number; skippedCount: number };
};
const r = res.data;
const r = await inspectMutation.mutateAsync({ inventoryIds: [...selected] });
toast({
title: `${r.inspectedCount} marked inspected`,
description: r.skippedCount ? `${r.skippedCount} skipped` : undefined,

View File

@@ -2,8 +2,10 @@ import { useEffect, useState } from 'react';
import { Alert, Button, Group, Modal, NumberInput, Stack, Text, Textarea } from '@mantine/core';
import { Info } from 'lucide-react';
import { useMutation } from '@tanstack/react-query';
import { api } from '@/services/api';
import { useToast } from '@/hooks/use-toast';
import { useLoadInventory } from '@/hooks/useWarehouses';
import type { WarehouseInventoryItem } from '@/types/warehouse';
import { WagonSelect } from './WagonSelect';
import { extractErrorMessage } from './options';
@@ -17,7 +19,7 @@ interface LoadInventoryModalProps {
/** Load READY_FOR_LOADING inventory onto a wagon (creates a loading record). */
export function LoadInventoryModal({ opened, onClose, item }: LoadInventoryModalProps) {
const { toast } = useToast();
const loadMutation = useLoadInventory();
const loadMutation = useMutation(api.warehouses.load.mutationOptions());
const [wagonId, setWagonId] = useState('');
const [loadedWeight, setLoadedWeight] = useState<number | ''>('');
const [notes, setNotes] = useState('');

View File

@@ -1,8 +1,10 @@
import { useEffect, useMemo, useState } from 'react';
import { Button, Group, Modal, Select, Stack, Textarea } from '@mantine/core';
import { useMutation, useQuery } from '@tanstack/react-query';
import { api } from '@/services/api';
import { useToast } from '@/hooks/use-toast';
import { useMoveInventory, useWarehouseYards, useWarehouseZones, useWarehouses } from '@/hooks/useWarehouses';
import type { WarehouseInventoryItem } from '@/types/warehouse';
import { extractErrorMessage } from './options';
@@ -14,7 +16,7 @@ interface MoveInventoryModalProps {
export function MoveInventoryModal({ opened, onClose, item }: MoveInventoryModalProps) {
const { toast } = useToast();
const moveMutation = useMoveInventory();
const moveMutation = useMutation(api.warehouses.move.mutationOptions());
const [warehouseId, setWarehouseId] = useState('');
const [yardId, setYardId] = useState('');
const [zoneId, setZoneId] = useState('');
@@ -29,9 +31,21 @@ export function MoveInventoryModal({ opened, onClose, item }: MoveInventoryModal
}
}, [opened]);
const warehousesQuery = useWarehouses({ status: 'ACTIVE' });
const yardsQuery = useWarehouseYards(warehouseId || undefined);
const zonesQuery = useWarehouseZones(yardId || undefined);
const warehousesQuery = useQuery(
api.warehouses.list.queryOptions({ input: { filter: { status: 'ACTIVE' } } }),
);
const yardsQuery = useQuery(
api.warehouses.listYards.queryOptions({
input: { warehouseId },
enabled: Boolean(warehouseId),
}),
);
const zonesQuery = useQuery(
api.warehouses.listZones.queryOptions({
input: { yardId },
enabled: Boolean(yardId),
}),
);
const warehouseOptions = useMemo(
() => (warehousesQuery.data ?? []).map((w) => ({ value: w.id, label: `${w.name} (${w.code})` })),

View File

@@ -18,34 +18,14 @@ import {
} from '@mantine/core';
import { ChevronDown, ChevronRight, ClipboardCheck, Info, PackageSearch, Train, Truck } from 'lucide-react';
import { useMutation, useQuery } from '@tanstack/react-query';
import { api } from '@/services/api';
import { useToast } from '@/hooks/use-toast';
import {
useAutoUnloadArrivedBookings,
useBulkDispatchExport,
useBulkMarkInspected,
useBulkReceive,
useEligibleBookings,
useImportArriveQueue,
useImportTrainItems,
useImportUnloadedQueue,
useLoadPassedExport,
useLoadedExport,
useReadyToLoadExport,
useReceiveInventory,
useWarehouseInventory,
useWarehouseYards,
useWarehouseZones,
useWarehouses,
} from '@/hooks/useWarehouses';
import type {
AutoUnloadArrivedResult,
BulkDispatchResult,
BulkInspectResult,
BulkReceiveResult,
ImportTrain,
ImportTrainItem,
ImportUnloadedItem,
LoadPassedExportResult,
ReadyToLoadRow,
ReceiveInventoryPayload,
} from '@/types/warehouse';
@@ -77,9 +57,21 @@ function LocationSelects({
value: Location;
onChange: (next: Location) => void;
}) {
const warehousesQuery = useWarehouses({ status: 'ACTIVE' });
const yardsQuery = useWarehouseYards(value.warehouseId || undefined);
const zonesQuery = useWarehouseZones(value.yardId || undefined);
const warehousesQuery = useQuery(
api.warehouses.list.queryOptions({ input: { filter: { status: 'ACTIVE' } } }),
);
const yardsQuery = useQuery(
api.warehouses.listYards.queryOptions({
input: { warehouseId: value.warehouseId ?? '' },
enabled: Boolean(value.warehouseId),
}),
);
const zonesQuery = useQuery(
api.warehouses.listZones.queryOptions({
input: { yardId: value.yardId ?? '' },
enabled: Boolean(value.yardId),
}),
);
const warehouseOptions = useMemo(
() => (warehousesQuery.data ?? []).map((w) => ({ value: w.id, label: `${w.name} (${w.code})` })),
@@ -148,10 +140,12 @@ function EligibleTab({
onChanged?: () => void;
}) {
const { toast } = useToast();
const { data: allRows = [], isLoading } = useEligibleBookings(enabled);
const { data: allRows = [], isLoading } = useQuery(
api.warehouses.eligibleBookings.queryOptions({ enabled }),
);
const rows = useMemo(() => allRows.filter((r) => r.direction === direction), [allRows, direction]);
const bulkReceive = useBulkReceive();
const loadPassed = useLoadPassedExport();
const bulkReceive = useMutation(api.warehouses.bulkReceive.mutationOptions());
const loadPassed = useMutation(api.warehouses.loadPassedExport.mutationOptions());
const [selected, setSelected] = useState<Set<string>>(new Set());
const locationReady = Boolean(location.warehouseId && location.yardId && location.zoneId);
@@ -177,10 +171,7 @@ function EligibleTab({
return;
}
try {
const res = (await bulkReceive.mutateAsync({ direction, ...location, bookingIds })) as {
data: BulkReceiveResult;
};
const r = res.data;
const r = await bulkReceive.mutateAsync({ direction, ...location, bookingIds });
toast({
title: `${r.receivedCount} received at ${direction === 'EXPORT' ? 'facility' : 'warehouse'}`,
description: r.skippedCount ? `${r.skippedCount} skipped` : undefined,
@@ -194,8 +185,7 @@ function EligibleTab({
const loadPassedExport = async () => {
try {
const res = (await loadPassed.mutateAsync(undefined)) as { data: LoadPassedExportResult };
const r = res.data;
const r = await loadPassed.mutateAsync(undefined);
toast({
title: `${r.loadedCount} loaded`,
description: r.skippedCount ? `${r.skippedCount} skipped — inspection not passed` : undefined,
@@ -353,8 +343,10 @@ function EligibleTab({
/** Export items that passed inspection and are queued to be loaded onto a train. */
function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?: () => void }) {
const { toast } = useToast();
const { data: rows = [], isLoading } = useReadyToLoadExport(enabled);
const loadPassed = useLoadPassedExport();
const { data: rows = [], isLoading } = useQuery(
api.warehouses.readyToLoadExport.queryOptions({ enabled }),
);
const loadPassed = useMutation(api.warehouses.loadPassedExport.mutationOptions());
const [selected, setSelected] = useState<Set<string>>(new Set());
const allSelected = rows.length > 0 && selected.size === rows.length;
@@ -369,8 +361,7 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?:
const autoLoad = async () => {
try {
const res = (await loadPassed.mutateAsync(undefined)) as { data: LoadPassedExportResult };
const r = res.data;
const r = await loadPassed.mutateAsync(undefined);
toast({
title: `${r.loadedCount} items loaded`,
description: r.skippedCount ? `${r.skippedCount} skipped` : undefined,
@@ -494,8 +485,12 @@ function LoadedExportTab({
onChanged?: () => void;
}) {
const { toast } = useToast();
const { data: rows = [], isLoading } = useLoadedExport(enabled);
const bulkDispatch = useBulkDispatchExport();
const { data: rows = [], isLoading } = useQuery(
api.warehouses.loadedExport.queryOptions({ enabled }),
);
const bulkDispatch = useMutation(
api.warehouses.bulkDispatchExport.mutationOptions(),
);
const [selected, setSelected] = useState<Set<string>>(new Set());
const allSelected = rows.length > 0 && selected.size === rows.length;
@@ -514,8 +509,7 @@ function LoadedExportTab({
return;
}
try {
const res = (await bulkDispatch.mutateAsync(inventoryIds)) as { data: BulkDispatchResult };
const r = res.data;
const r = await bulkDispatch.mutateAsync(inventoryIds);
toast({
title: `${r.dispatchedCount} dispatched`,
description: r.skippedCount ? `${r.skippedCount} skipped` : undefined,
@@ -659,7 +653,12 @@ function LoadedExportTab({
/** Assigned bookings/items for an arrived import train (read-only detail view). */
function ImportTrainDetailTable({ scheduleId }: { scheduleId: string }) {
const { data: items = [], isLoading } = useImportTrainItems(scheduleId);
const { data: items = [], isLoading } = useQuery(
api.warehouses.importTrainItems.queryOptions({
input: { scheduleId },
enabled: Boolean(scheduleId),
}),
);
if (isLoading) {
return (
@@ -735,18 +734,19 @@ function ImportArriveQueueTab({
onChanged?: () => void;
}) {
const { toast } = useToast();
const { data: trains = [], isLoading } = useImportArriveQueue(enabled);
const autoUnloadMutation = useAutoUnloadArrivedBookings();
const { data: trains = [], isLoading } = useQuery(
api.warehouses.importArriveQueue.queryOptions({ enabled }),
);
const autoUnloadMutation = useMutation(
api.warehouses.autoUnloadArrivedBookings.mutationOptions(),
);
const [openId, setOpenId] = useState<string | null>(null);
const [busyId, setBusyId] = useState<string | null>(null);
const autoUnload = async (train: ImportTrain) => {
setBusyId(train.scheduleId);
try {
const res = (await autoUnloadMutation.mutateAsync(train.scheduleId)) as {
data: AutoUnloadArrivedResult;
};
const r = res.data;
const r = await autoUnloadMutation.mutateAsync(train.scheduleId);
const extra = [
r.skippedCount ? `${r.skippedCount} skipped` : '',
r.failedCount ? `${r.failedCount} failed` : '',
@@ -866,8 +866,12 @@ function ImportArriveQueueTab({
*/
function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
const { toast } = useToast();
const { data: rows = [], isLoading } = useImportUnloadedQueue(enabled);
const inspectMutation = useBulkMarkInspected();
const { data: rows = [], isLoading } = useQuery(
api.warehouses.importUnloadedQueue.queryOptions({ enabled }),
);
const inspectMutation = useMutation(
api.warehouses.bulkMarkInspected.mutationOptions(),
);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [inspectId, setInspectId] = useState<string | null>(null);
@@ -888,10 +892,7 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
return;
}
try {
const res = (await inspectMutation.mutateAsync({ inventoryIds: [...selected] })) as {
data: BulkInspectResult;
};
const r = res.data;
const r = await inspectMutation.mutateAsync({ inventoryIds: [...selected] });
toast({
title: `${r.inspectedCount} marked inspected`,
description: r.skippedCount ? `${r.skippedCount} skipped` : undefined,
@@ -1033,8 +1034,10 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
*/
function ImportDispatchQueueTab({ enabled }: { enabled: boolean }) {
const { toast } = useToast();
const { data: items = [], isLoading } = useWarehouseInventory(
enabled ? { status: 'READY_FOR_PICKUP' } : undefined,
const { data: items = [], isLoading } = useQuery(
api.warehouses.listInventory.queryOptions({
input: { filter: enabled ? { status: 'READY_FOR_PICKUP' } : undefined },
}),
);
return (
@@ -1155,7 +1158,9 @@ function SingleBookingReceiveModal({
onReceived,
}: ReceiveInventoryModalProps) {
const { toast } = useToast();
const receiveMutation = useReceiveInventory();
const receiveMutation = useMutation(
api.warehouses.receiveInventory.mutationOptions(),
);
const [selectedBooking, setSelectedBooking] = useState(bookingId ?? '');
const [form, setForm] = useState<SingleFormState>({
warehouseId: '',

View File

@@ -2,6 +2,9 @@ import { useEffect, useState } from 'react';
import { Alert, Button, Group, Modal, Stack, Text, TextInput } from '@mantine/core';
import { Info } from 'lucide-react';
import { useMutation } from '@tanstack/react-query';
import { api } from '@/services/api';
import { useToast } from '@/hooks/use-toast';
import { useReleaseInventory } from '@/hooks/useWarehouses';
import { warehouseService } from '@/services/warehouse.service';
@@ -17,7 +20,7 @@ interface ReleaseOrderModalProps {
export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalProps) {
const { toast } = useToast();
const releaseMutation = useReleaseInventory();
const releaseMutation = useMutation(api.warehouses.release.mutationOptions());
const [reference, setReference] = useState('');
const [downloading, setDownloading] = useState(false);

View File

@@ -2,8 +2,10 @@ import { useEffect, useState } from 'react';
import { Alert, Button, Group, Modal, Stack, Text } from '@mantine/core';
import { Info } from 'lucide-react';
import { useMutation } from '@tanstack/react-query';
import { api } from '@/services/api';
import { useToast } from '@/hooks/use-toast';
import { useReserveInventory } from '@/hooks/useWarehouses';
import type { WarehouseInventoryItem } from '@/types/warehouse';
import { BookingSelect } from './BookingSelect';
import { extractErrorMessage } from './options';
@@ -16,7 +18,7 @@ interface ReserveInventoryModalProps {
export function ReserveInventoryModal({ opened, onClose, item }: ReserveInventoryModalProps) {
const { toast } = useToast();
const reserveMutation = useReserveInventory();
const reserveMutation = useMutation(api.warehouses.reserve.mutationOptions());
const [bookingId, setBookingId] = useState('');
useEffect(() => {

View File

@@ -1,6 +1,7 @@
import { Select } from '@mantine/core';
import { useQuery } from '@tanstack/react-query';
import { useLoadableWagons } from '@/hooks/useWarehouses';
import { api } from '@/services/api';
interface WagonSelectProps {
value: string;
@@ -11,7 +12,9 @@ interface WagonSelectProps {
/** Searchable wagon picker. Lists wagons that are loadable (read-only from scheduling). */
export function WagonSelect({ value, onChange, label = 'Wagon', required }: WagonSelectProps) {
const { data, isLoading } = useLoadableWagons();
const { data, isLoading } = useQuery(
api.warehouses.loadableWagons.queryOptions(),
);
const options = (data ?? []).map((w) => ({
value: w.id,

View File

@@ -2,7 +2,9 @@ import { useMemo } from 'react';
import { ActionIcon, Box, Card, Divider, Group, Progress, SimpleGrid, Stack, Text, Tooltip } from '@mantine/core';
import { Building2, Eye, MapPin, Package, Pencil, Weight } from 'lucide-react';
import { useStations } from '@/hooks/useStations';
import { useQuery } from '@tanstack/react-query';
import { api } from '@/services/api';
import type { Warehouse } from '@/types/warehouse';
import { WarehouseStatusBadge, WarehouseTypeBadge } from './badges';
import { formatCapacity } from './options';
@@ -14,7 +16,9 @@ interface WarehouseCardViewProps {
}
export function WarehouseCardView({ warehouses, onView, onEdit }: WarehouseCardViewProps) {
const { data: stations } = useStations();
const { data: stations } = useQuery(
api.stations.list.queryOptions({ staleTime: 5 * 60 * 1000 }),
);
const stationNameById = useMemo(
() => new Map((stations ?? []).map((s) => [s.id, s.name])),
[stations],

View File

@@ -15,7 +15,9 @@ import {
YAxis,
} from 'recharts';
import { useWarehouseInventory } from '@/hooks/useWarehouses';
import { useQuery } from '@tanstack/react-query';
import { api } from '@/services/api';
import type { WarehouseDashboard, WarehouseInventoryItem } from '@/types/warehouse';
interface WarehouseDashboardChartsProps {
@@ -38,7 +40,9 @@ type Granularity = 'week' | 'month' | 'year';
export function WarehouseDashboardCharts({ data }: WarehouseDashboardChartsProps) {
const [granularity, setGranularity] = useState<Granularity>('month');
const { data: inventory } = useWarehouseInventory();
const { data: inventory } = useQuery(
api.warehouses.listInventory.queryOptions({ input: {} }),
);
const statusData = STATUS_SERIES.map((s) => ({
name: s.label,

Some files were not shown because too many files have changed in this diff Show More