mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 19:58:11 +00:00
merge conflict fix
This commit is contained in:
@@ -1,330 +0,0 @@
|
||||
// components/baselineRatematrix/RateMatrixForm.tsx
|
||||
import React, { useState, useCallback } from 'react';
|
||||
// import { useForm } from 'react-hook-form';
|
||||
// import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import { z } from 'zod';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { Loader2, Save, Send, Shield, AlertTriangle } from 'lucide-react';
|
||||
import { RateTypeSection } from './RateTypeSection';
|
||||
import { ConfirmationDialog } from './ConfirmationDialog';
|
||||
import { ValidationSummary } from './ValidationSummary';
|
||||
import { LoadingScreen } from '@/ui/LoadingScreen';
|
||||
import { useRateMatrixAuth } from '@/auth/hooks/useAuth';
|
||||
import { useReferenceData } from '@/hooks/useReferenceData';
|
||||
import { queryKeys } from '@/constants/queryKeys';
|
||||
import { API_URLS } from '@/constants/apiUrls';
|
||||
import {
|
||||
RATE_TYPES,
|
||||
RATE_TYPE_LABELS,
|
||||
REQUIRED_RATE_TYPES
|
||||
} from '@/constants/rateMatrixConstants';
|
||||
import { rateMatrixRulesEngine } from '../../ruleEngine/rateMatrixRules';
|
||||
import type { RateEntry } from './types';
|
||||
|
||||
const formSchema = z.object({
|
||||
matrixName: z.string().min(1, 'Matrix name is required').max(200),
|
||||
effectiveDate: z.string().min(1, 'Effective date is required'),
|
||||
expiryDate: z.string().optional(),
|
||||
currency: z.string().min(1, 'Currency is required'),
|
||||
});
|
||||
|
||||
type FormData = z.infer<typeof formSchema>;
|
||||
|
||||
const createInitialSections = (): RateEntry[] => {
|
||||
return REQUIRED_RATE_TYPES.map(rateType => ({
|
||||
rateType,
|
||||
entries: [{
|
||||
validFrom: '',
|
||||
validTo: '',
|
||||
}],
|
||||
}));
|
||||
};
|
||||
|
||||
export function RateMatrixForm() {
|
||||
const [rateSections, setRateSections] = useState<RateEntry[]>(createInitialSections());
|
||||
const [showConfirmation, setShowConfirmation] = useState(false);
|
||||
const [savedMatrixId, setSavedMatrixId] = useState<string | null>(null);
|
||||
const [validationErrors, setValidationErrors] = useState<any[]>([]);
|
||||
|
||||
const { isDirector } = useRateMatrixAuth();
|
||||
const { data: referenceData, isLoading: isLoadingReference } = useReferenceData();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const form = useForm<FormData>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
matrixName: '',
|
||||
effectiveDate: '',
|
||||
expiryDate: '',
|
||||
currency: 'USD',
|
||||
},
|
||||
});
|
||||
|
||||
// Save draft mutation
|
||||
const saveDraftMutation = useMutation({
|
||||
mutationFn: async (data: FormData & { rateSections: RateEntry[] }) => {
|
||||
const response = await fetch(API_URLS.RATE_MATRIX.DRAFT, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to save draft');
|
||||
return response.json();
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
setSavedMatrixId(data.id);
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.rateMatrix.all });
|
||||
toast.success('Draft saved successfully');
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error('Failed to save draft');
|
||||
},
|
||||
});
|
||||
|
||||
// Submit for approval mutation
|
||||
const submitMutation = useMutation({
|
||||
mutationFn: async (matrixId: string) => {
|
||||
const response = await fetch(API_URLS.RATE_MATRIX.SUBMIT(matrixId), {
|
||||
method: 'POST',
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to submit');
|
||||
return response.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.rateMatrix.all });
|
||||
toast.success('Rate matrix submitted for executive approval and locked!');
|
||||
setShowConfirmation(false);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error('Failed to submit for approval');
|
||||
setShowConfirmation(false);
|
||||
},
|
||||
});
|
||||
|
||||
const handleValidate = useCallback(() => {
|
||||
const validation = rateMatrixRulesEngine.validate(rateSections);
|
||||
setValidationErrors([...validation.errors, ...validation.warnings]);
|
||||
|
||||
if (validation.isValid) {
|
||||
toast.success('All validations passed!');
|
||||
}
|
||||
}, [rateSections]);
|
||||
|
||||
const handleSaveDraft = async () => {
|
||||
const formData = form.getValues();
|
||||
await saveDraftMutation.mutateAsync({
|
||||
...formData,
|
||||
rateSections,
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmitClick = async () => {
|
||||
const isFormValid = await form.trigger();
|
||||
if (!isFormValid) return;
|
||||
|
||||
const validation = rateMatrixRulesEngine.validate(rateSections);
|
||||
setValidationErrors([...validation.errors, ...validation.warnings]);
|
||||
|
||||
if (!validation.isValid) {
|
||||
toast.error('Please fix validation errors before submitting');
|
||||
return;
|
||||
}
|
||||
|
||||
setShowConfirmation(true);
|
||||
};
|
||||
|
||||
const handleConfirmSubmit = async () => {
|
||||
const formData = form.getValues();
|
||||
|
||||
try {
|
||||
let matrixId = savedMatrixId;
|
||||
|
||||
if (!matrixId) {
|
||||
const draftResult = await saveDraftMutation.mutateAsync({
|
||||
...formData,
|
||||
rateSections,
|
||||
});
|
||||
matrixId = draftResult.id;
|
||||
}
|
||||
|
||||
await submitMutation.mutateAsync(matrixId!);
|
||||
} catch (error) {
|
||||
// Error handling done in mutations
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoadingReference) {
|
||||
return <LoadingScreen message="Loading reference data..." />;
|
||||
}
|
||||
|
||||
if (!isDirector) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<Alert variant="destructive" className="max-w-md">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertTitle>Access Denied</AlertTitle>
|
||||
<AlertDescription>
|
||||
Only Directors can access the rate matrix registration.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-8 px-4 max-w-7xl">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold tracking-tight">
|
||||
Baseline Rate Matrix Registration
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
Submit a comprehensive rate matrix for executive approval
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Director Warning */}
|
||||
<Alert variant="warning" className="mb-6 border-amber-500 bg-amber-50">
|
||||
<Shield className="h-4 w-4" />
|
||||
<AlertTitle>Director Notice</AlertTitle>
|
||||
<AlertDescription>
|
||||
Once submitted, this matrix will be locked pending Chief Executive approval.
|
||||
No edits can be made by any user until authorization is granted.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<form onSubmit={(e) => e.preventDefault()}>
|
||||
{/* Matrix Metadata */}
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle>Matrix Information</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="matrixName">Matrix Name *</Label>
|
||||
<Input
|
||||
id="matrixName"
|
||||
{...form.register('matrixName')}
|
||||
placeholder="e.g., Q4 2026 Baseline Matrix"
|
||||
className={form.formState.errors.matrixName ? 'border-destructive' : ''}
|
||||
/>
|
||||
{form.formState.errors.matrixName && (
|
||||
<p className="text-sm text-destructive">
|
||||
{form.formState.errors.matrixName.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="effectiveDate">Effective Date *</Label>
|
||||
<Input
|
||||
id="effectiveDate"
|
||||
type="date"
|
||||
{...form.register('effectiveDate')}
|
||||
className={form.formState.errors.effectiveDate ? 'border-destructive' : ''}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="expiryDate">Expiry Date</Label>
|
||||
<Input
|
||||
id="expiryDate"
|
||||
type="date"
|
||||
{...form.register('expiryDate')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="currency">Currency *</Label>
|
||||
<select
|
||||
id="currency"
|
||||
{...form.register('currency')}
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2"
|
||||
>
|
||||
{referenceData?.currencies?.map((currency: any) => (
|
||||
<option key={currency.code} value={currency.code}>
|
||||
{currency.code} - {currency.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Rate Type Sections */}
|
||||
<div className="space-y-6">
|
||||
{rateSections.map((section, index) => (
|
||||
<RateTypeSection
|
||||
key={section.rateType}
|
||||
section={section}
|
||||
sectionIndex={index}
|
||||
onUpdate={(updatedSection) => {
|
||||
const newSections = [...rateSections];
|
||||
newSections[index] = updatedSection;
|
||||
setRateSections(newSections);
|
||||
}}
|
||||
referenceData={referenceData}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Validation Errors */}
|
||||
{validationErrors.length > 0 && (
|
||||
<div className="mt-6">
|
||||
<ValidationSummary errors={validationErrors} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Form Actions */}
|
||||
<div className="sticky bottom-6 mt-8 p-6 bg-background border rounded-lg shadow-lg flex gap-4 justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleSaveDraft}
|
||||
disabled={saveDraftMutation.isPending}
|
||||
>
|
||||
{saveDraftMutation.isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Save as Draft
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={handleValidate}
|
||||
>
|
||||
Validate All Rates
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleSubmitClick}
|
||||
disabled={submitMutation.isPending}
|
||||
>
|
||||
<Send className="mr-2 h-4 w-4" />
|
||||
Submit for Executive Approval
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Confirmation Dialog */}
|
||||
<ConfirmationDialog
|
||||
open={showConfirmation}
|
||||
onOpenChange={setShowConfirmation}
|
||||
onConfirm={handleConfirmSubmit}
|
||||
isLoading={submitMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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)}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" }}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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" }}
|
||||
>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" }}
|
||||
>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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]}`;
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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";
|
||||
@@ -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"),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -333,7 +333,7 @@ const ManageRuleEngineOrderDialog = ({
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="green"
|
||||
color="edr-green"
|
||||
onClick={handleSave}
|
||||
disabled={isLoading || isSaving}
|
||||
leftSection={
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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" }}
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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={() => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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",
|
||||
}}
|
||||
|
||||
@@ -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 },
|
||||
};
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
import type { TrainScheduleDetail } from "@/types/trainScheduling";
|
||||
import { freightBrand } from "@/theme/freight-brand";
|
||||
|
||||
type Wagon = TrainScheduleDetail["trainSet"]["wagons"][number];
|
||||
type Wagon = NonNullable<TrainScheduleDetail["trainSet"]>["wagons"][number];
|
||||
type Locomotive = NonNullable<TrainScheduleDetail["trainSet"]>["locomotive"];
|
||||
|
||||
interface InteractiveTrainConsistProps {
|
||||
|
||||
@@ -1,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 (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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)" },
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>[] => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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})` }));
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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('');
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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('');
|
||||
|
||||
@@ -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})` })),
|
||||
|
||||
@@ -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: '',
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -2,7 +2,9 @@ import { useState } from 'react';
|
||||
import { Badge, Button, Card, Divider, Group, Stack, Text } from '@mantine/core';
|
||||
import { PackagePlus, Train as TrainIcon, Warehouse as WarehouseIcon } from 'lucide-react';
|
||||
|
||||
import { useBookingSchedule, useWarehouseInventory } from '@/hooks/useWarehouses';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { api } from '@/services/api';
|
||||
import { InventoryStatusBadge } from './badges';
|
||||
import { FreightVisual } from './FreightVisual';
|
||||
import { formatDate } from './options';
|
||||
@@ -28,8 +30,15 @@ function Row({ label, value }: { label: string; value: React.ReactNode }) {
|
||||
|
||||
export function WarehouseInfoCard({ bookingId, bookingReference }: WarehouseInfoCardProps) {
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const { data, isLoading } = useWarehouseInventory({ bookingId });
|
||||
const { data: scheduleView } = useBookingSchedule(bookingId);
|
||||
const { data, isLoading } = useQuery(
|
||||
api.warehouses.listInventory.queryOptions({ input: { filter: { bookingId } } }),
|
||||
);
|
||||
const { data: scheduleView } = useQuery(
|
||||
api.warehouses.bookingSchedule.queryOptions({
|
||||
input: { bookingId },
|
||||
enabled: Boolean(bookingId),
|
||||
}),
|
||||
);
|
||||
|
||||
const items = data ?? [];
|
||||
const latest = items[0];
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user