This commit is contained in:
Marshal
2026-08-08 14:02:58 +00:00
104 changed files with 8507 additions and 962 deletions

View File

@@ -57,6 +57,7 @@ import NoAccessPage from "./pages/NoAccessPage";
import FileUploadSettingsPage from "./pages/documents/FileUploadSettingsPage";
import DropdownSettingsPage from "./pages/dropdown_settings/DropdownSettingsPage";
import ContractTemplatesPage from "./pages/contract_templates/ContractTemplatesPage";
import PortalContentPage from "./pages/portal_content/PortalContentPage";
import ContractTemplateEditorPage from "./pages/contract_templates/ContractTemplateEditorPage";
import FleetResourcePage from "./pages/fleet/FleetResourcePage";
import WagonTransfersPage from "./pages/wagons/WagonTransfersPage";
@@ -773,7 +774,9 @@ const App = () => {
<Route
path="compliance"
element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
<RequirePermission
permission={[FREIGHT_PERMS.compliance.view, FREIGHT_PERMS.fleet.view]}
>
<CompliancePage />
</RequirePermission>
}
@@ -797,7 +800,9 @@ const App = () => {
<Route
path="procurement"
element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
<RequirePermission
permission={[FREIGHT_PERMS.procurement.view, FREIGHT_PERMS.fleet.view]}
>
<ProcurementPage />
</RequirePermission>
}
@@ -820,7 +825,9 @@ const App = () => {
<Route
path="file-settings"
element={
<RequirePermission permission={FREIGHT_PERMS.admin}>
<RequirePermission
permission={[FREIGHT_PERMS.settings.fileUpload.view, FREIGHT_PERMS.admin]}
>
<FileUploadSettingsPage />
</RequirePermission>
}
@@ -828,7 +835,9 @@ const App = () => {
<Route
path="dropdown-settings"
element={
<RequirePermission permission={FREIGHT_PERMS.admin}>
<RequirePermission
permission={[FREIGHT_PERMS.settings.dropdown.view, FREIGHT_PERMS.admin]}
>
<DropdownSettingsPage />
</RequirePermission>
}
@@ -867,6 +876,20 @@ const App = () => {
</RequirePermission>
}
/>
<Route
path="portal-content"
element={
<RequirePermission
permission={[
FREIGHT_PERMS.settings.supportContent.view,
FREIGHT_PERMS.settings.supportContent.manage,
FREIGHT_PERMS.admin,
]}
>
<PortalContentPage />
</RequirePermission>
}
/>
<Route
path="configuration"
@@ -885,7 +908,9 @@ const App = () => {
<Route
path="configuration/trade-access"
element={
<RequirePermission permission={FREIGHT_PERMS.admin}>
<RequirePermission
permission={[FREIGHT_PERMS.tradeAccess.view, FREIGHT_PERMS.admin]}
>
<TradeAccessPage />
</RequirePermission>
}
@@ -893,7 +918,9 @@ const App = () => {
<Route
path="configuration/exchange-rate"
element={
<RequirePermission permission={FREIGHT_PERMS.admin}>
<RequirePermission
permission={[FREIGHT_PERMS.settings.exchangeRate.view, FREIGHT_PERMS.admin]}
>
<div className="p-4">
<ExchangeRateSettingsCard />
</div>

View File

@@ -276,9 +276,8 @@ export default function GlCreateBookingForm() {
const [trainScheduleId, setTrainScheduleId] = useState("");
const [contractRouteId, setContractRouteId] = useState<string | null>(null);
const [notes, setNotes] = useState("");
// The customer states the billing currency on their shipment request — GL
// books in it. Intercity is always ETB (the API enforces this too).
const [paymentCurrency, setPaymentCurrency] = useState<"USD" | "ETB">("USD");
// ponytail: ETB-only for now — widen back to "USD" | "ETB" when multi-currency billing returns.
const [paymentCurrency, setPaymentCurrency] = useState<"USD" | "ETB">("ETB");
// What the containers carry — captured per booking (moved off the contract).
const [cargoDescription, setCargoDescription] = useState("");
const [containerLines, setContainerLines] = useState<ContainerLineDraft[]>([]);
@@ -449,9 +448,6 @@ export default function GlCreateBookingForm() {
}
if (bookingRequest.contractRouteId)
setContractRouteId(bookingRequest.contractRouteId);
if (bookingRequest.paymentCurrency === "USD" || bookingRequest.paymentCurrency === "ETB") {
setPaymentCurrency(bookingRequest.paymentCurrency);
}
if (bookingRequest.notes) setNotes(bookingRequest.notes);
}, [bookingRequest, prefilled]);
@@ -1761,11 +1757,7 @@ export default function GlCreateBookingForm() {
Billing currency
</Text>
<Text size="xs" c="dimmed" mb={8}>
{isIntercity
? "Intercity shipments are invoiced in ETB."
: bookingRequest?.paymentCurrency
? "Requested by the customer on their shipment request."
: "The contract is quoted in USD — pick the currency this shipment is invoiced in."}
Shipments are invoiced in ETB.
</Text>
<CurrencySelector
value={isIntercity ? "ETB" : paymentCurrency}

View File

@@ -0,0 +1,158 @@
import { Alert, Badge, Button, Card, Group, SimpleGrid, Stack, Text } from "@mantine/core";
import { useMutation, useQuery } from "@tanstack/react-query";
import { AlertTriangle, RefreshCw, Send, ShieldCheck } from "lucide-react";
import { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import { api } from "@/services/api";
import type { EimsInvoiceStatus } from "@/types/eims";
import { useToast } from "@/hooks/use-toast";
const STATUS_COLOR: Record<EimsInvoiceStatus, string> = {
NOT_SUBMITTED: "gray",
SUBMITTING: "yellow",
REGISTERED: "edr-green",
FAILED: "red",
UNKNOWN: "orange",
};
const STATUS_LABEL: Record<EimsInvoiceStatus, string> = {
NOT_SUBMITTED: "Not filed",
SUBMITTING: "Filing…",
REGISTERED: "Filed",
FAILED: "Rejected",
UNKNOWN: "Unacknowledged",
};
function Field({ label, value }: { label: string; value?: string | number | null }) {
return (
<Stack gap={2}>
<Text size="xs" fw={600} c="edr-muted" tt="uppercase">
{label}
</Text>
<Text size="sm" c="edr-text" style={{ wordBreak: "break-all" }}>
{value === null || value === undefined || value === "" ? "—" : value}
</Text>
</Stack>
);
}
/**
* MoR EIMS filing state for one invoice, with the manual actions.
*
* Filing normally happens on the API's cron sweep, not here — these controls exist for controlled
* testing and for the exceptional cases the sweep deliberately refuses: a rejected invoice that
* needs re-filing, and an unacknowledged one that has blocked all further filing.
*/
export function EimsFilingCard({ invoiceId }: { invoiceId: string }) {
const { user } = useAuth();
const { toast } = useToast();
const canFile = hasPermission(user, FREIGHT_PERMS.invoices.eimsRegister);
const { data: eims, isLoading } = useQuery(
api.invoices.eimsStatus.queryOptions({ input: { id: invoiceId }, enabled: Boolean(invoiceId) }),
);
const register = useMutation(
api.invoices.eimsRegister.mutationOptions({
onSuccess: (result) =>
toast({
title: result.eimsIrn ? "Filed with MoR" : "Filing finished",
description: result.eimsIrn ? `IRN ${result.eimsIrn}` : `Status ${result.eimsStatus}`,
}),
}),
);
const verify = useMutation(
api.invoices.eimsVerify.mutationOptions({
onSuccess: (result) =>
toast({
title: "MoR confirmed the filing",
description: `Document ${result.body?.DocumentDetails?.DocumentNumber ?? "—"}`,
}),
}),
);
if (isLoading || !eims) return null;
const status = eims.eimsStatus;
const busy = register.isPending || verify.isPending;
return (
<Card>
<Stack gap="lg">
<Group justify="space-between">
<Text fw={600} c="edr-text">
MoR e-invoicing
</Text>
<Badge color={STATUS_COLOR[status] ?? "gray"} variant="light" size="sm" radius="md" fw={600}>
{STATUS_LABEL[status] ?? status}
</Badge>
</Group>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }} spacing="lg">
<Field label="IRN" value={eims.eimsIrn} />
<Field label="Invoice counter" value={eims.eimsInvoiceCounter} />
<Field
label="Submitted"
value={eims.eimsSubmittedAt ? new Date(eims.eimsSubmittedAt).toLocaleString() : null}
/>
<Field label="Acknowledged" value={eims.eimsAckDate} />
</SimpleGrid>
{status === "UNKNOWN" && (
<Alert color="orange" icon={<AlertTriangle size={16} />} title="All filing is blocked">
This invoice was sent but never acknowledged, so its IRN is unknown and no further
invoice can be filed. Confirm its status with MoR, then have a supervisor record the IRN
or discard the attempt.
</Alert>
)}
{eims.eimsLastError && (
<Alert
color={status === "FAILED" ? "red" : "orange"}
icon={<AlertTriangle size={16} />}
title={`MoR reported: ${eims.eimsLastError.kind}`}
>
{eims.eimsLastError.message}
</Alert>
)}
{canFile && (
<Group gap="sm">
{/* UNKNOWN is never re-filed from here: resubmitting risks a duplicate registration. */}
{status !== "REGISTERED" && status !== "UNKNOWN" && (
<Button
size="xs"
variant="light"
radius="md"
loading={register.isPending}
disabled={busy}
leftSection={status === "FAILED" ? <RefreshCw size={14} /> : <Send size={14} />}
onClick={() => register.mutate({ id: invoiceId })}
>
{status === "FAILED" ? "File again" : "File with MoR"}
</Button>
)}
{eims.eimsIrn && (
<Button
size="xs"
variant="light"
radius="md"
loading={verify.isPending}
disabled={busy}
leftSection={<ShieldCheck size={14} />}
onClick={() => verify.mutate({ id: invoiceId })}
>
Verify with MoR
</Button>
)}
</Group>
)}
</Stack>
</Card>
);
}
export default EimsFilingCard;

View File

@@ -1,5 +1,6 @@
import {
ArrowLeftRight,
BookOpen,
Boxes,
Building2,
BarChart3,
@@ -286,19 +287,21 @@ export const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[]
label: "Compliance & Alerts",
href: "/dashboard/compliance",
icon: <ShieldCheck />,
permission: FREIGHT_PERMS.fleet.view,
permission: [FREIGHT_PERMS.compliance.view, FREIGHT_PERMS.fleet.view],
},
{
label: "Incidents",
href: "/dashboard/incidents",
icon: <FileText />,
// No dedicated backend key exists for incidents yet — stuck on the
// blanket fleet:view fallback until one is added.
permission: FREIGHT_PERMS.fleet.view,
},
{
label: "Procurement",
href: "/dashboard/procurement",
icon: <Package />,
permission: FREIGHT_PERMS.fleet.view,
permission: [FREIGHT_PERMS.procurement.view, FREIGHT_PERMS.fleet.view],
},
{
label: "Financial Reports",
@@ -483,13 +486,13 @@ export const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[]
label: "File settings",
href: "/dashboard/file-settings",
icon: <Paperclip />,
permission: FREIGHT_PERMS.admin,
permission: [FREIGHT_PERMS.settings.fileUpload.view, FREIGHT_PERMS.admin],
},
{
label: "Dropdown settings",
href: "/dashboard/dropdown-settings",
icon: <Settings />,
permission: FREIGHT_PERMS.admin,
permission: [FREIGHT_PERMS.settings.dropdown.view, FREIGHT_PERMS.admin],
},
{
label: "Contract templates",
@@ -501,6 +504,16 @@ export const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[]
FREIGHT_PERMS.admin,
],
},
{
label: "Portal content",
href: "/dashboard/portal-content",
icon: <BookOpen />,
permission: [
FREIGHT_PERMS.settings.supportContent.view,
FREIGHT_PERMS.settings.supportContent.manage,
FREIGHT_PERMS.admin,
],
},
{
label: "Audit logs",
href: "/dashboard/audit-logs",
@@ -521,12 +534,12 @@ export const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[]
{
label: "Trade access",
href: "/dashboard/configuration/trade-access",
permission: FREIGHT_PERMS.admin,
permission: [FREIGHT_PERMS.tradeAccess.view, FREIGHT_PERMS.admin],
},
{
label: "Exchange rate",
href: "/dashboard/configuration/exchange-rate",
permission: FREIGHT_PERMS.admin,
permission: [FREIGHT_PERMS.settings.exchangeRate.view, FREIGHT_PERMS.admin],
},
],
},

View File

@@ -53,6 +53,7 @@ export const QUERY_KEYS = {
byId: (id: string) => ["invoices", "detail", id] as const,
offlineUsd: (filter?: InvoiceListFilter) =>
["invoices", "offline-usd", filter ?? {}] as const,
eimsStatus: (id: string) => ["invoices", "eims", id] as const,
},
BOOKINGS: {

View File

@@ -109,6 +109,14 @@ export const URL_CONSTANTS = {
CONFIRM_OFFLINE: (id: string) => `/billing/invoices/${id}/confirm-offline`,
},
// MoR EIMS filing. Mounted on /invoices, not /billing/invoices — see EimsInvoiceController.
EIMS: {
STATUS: (id: string) => `/invoices/${id}/eims/status`,
REGISTER: (id: string) => `/invoices/${id}/eims/register`,
VERIFY: (id: string) => `/invoices/${id}/eims/verify`,
RESOLVE: (id: string) => `/invoices/${id}/eims/resolve`,
},
CUSTOMERS_API: {
BASE: "/api/customers",
BY_ID: (id: string) => `/api/customers/${id}`,

View File

@@ -0,0 +1,78 @@
import type { SupportDocPayload, SupportDocSlug } from "@edr/types";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import toast from "react-hot-toast";
import { portalContentService } from "@/services/portal-content.service";
/**
* Every key shares the `portal-content` prefix so one invalidate after a save
* or a restore sweeps the document and its version list together.
*/
const KEYS = {
ROOT: ["portal-content"] as const,
bySlug: (slug: string) => ["portal-content", "detail", slug] as const,
versions: (slug: string) => ["portal-content", "versions", slug] as const,
};
export function usePortalDoc(slug: SupportDocSlug) {
return useQuery({
queryKey: KEYS.bySlug(slug),
queryFn: () => portalContentService.getBySlug(slug),
});
}
/** Version history. Stays idle until the history modal is opened. */
export function usePortalDocVersions(slug: SupportDocSlug, enabled: boolean) {
return useQuery({
queryKey: KEYS.versions(slug),
queryFn: () => portalContentService.listVersions(slug),
enabled,
});
}
/** One historical payload, fetched only when a version is previewed. */
export function usePortalDocVersion(
slug: SupportDocSlug,
version: number | null,
) {
return useQuery({
queryKey: [...KEYS.versions(slug), version],
queryFn: () => portalContentService.getVersion(slug, version as number),
enabled: version !== null,
});
}
function usePortalContentMutation<TVariables>(
mutationFn: (vars: TVariables) => Promise<unknown>,
successMessage: string,
) {
const queryClient = useQueryClient();
return useMutation({
mutationFn,
onSuccess: () => {
toast.success(successMessage);
void queryClient.invalidateQueries({ queryKey: KEYS.ROOT });
},
onError: (error: unknown) => {
const message =
(error as { response?: { data?: { message?: string } } })?.response?.data
?.message ?? "Something went wrong";
toast.error(Array.isArray(message) ? message.join(", ") : message);
},
});
}
export function useUpdatePortalDoc(slug: SupportDocSlug) {
return usePortalContentMutation(
(vars: { payload: SupportDocPayload; note?: string }) =>
portalContentService.update(slug, vars.payload, vars.note),
"Portal content saved",
);
}
export function useRestorePortalVersion(slug: SupportDocSlug) {
return usePortalContentMutation(
(version: number) => portalContentService.restore(slug, version),
"Version restored",
);
}

View File

@@ -129,6 +129,10 @@ export const FREIGHT_PERMS = {
view: "edr_freight_app:invoices:view",
export: "edr_freight_app:invoices:export",
confirmOffline: "edr_freight_app:invoices:confirm_offline",
// Filing with MoR EIMS. Held by named admins rather than a role preset: registration is
// irreversible at the tax authority, and resolving clears a system-wide filing block.
eimsRegister: "edr_freight_app:invoices:eims_register",
eimsResolve: "edr_freight_app:invoices:eims_resolve",
},
firstMile: {
view: "edr_freight_app:first_mile:view",
@@ -323,6 +327,11 @@ export const FREIGHT_PERMS = {
delete: "edr_freight_app:settings:contract_templates:delete",
read: "edr_freight_app:settings:contract_templates:read",
},
// Portal-facing help/FAQ/legal copy, edited from Portal content.
supportContent: {
view: "edr_freight_app:settings:support_content:view",
manage: "edr_freight_app:settings:support_content:manage",
},
},
audit: {
view: "edr_freight_app:audit:view",

View File

@@ -529,10 +529,7 @@ export default function NewBookingPage() {
/>
<Select
label="Payment currency"
data={[
{ value: "ETB", label: "ETB — Birr" },
{ value: "USD", label: "USD — Dollar" },
]}
data={[{ value: "ETB", label: "ETB — Birr" }]}
value={paymentCurrency}
onChange={(v) => setPaymentCurrency(v ?? "ETB")}
/>

View File

@@ -30,13 +30,17 @@ import { useToast } from "@/hooks/use-toast";
import { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import { vehiclesService } from "@/services/vehicles.service";
import { gpsTrackingService, type GpsDevice } from "@/services/gps-tracking.service";
import {
gpsTrackingService,
type GpsDevice,
} from "@/services/gps-tracking.service";
import { freightBrand } from "@/theme/freight-brand";
// Same default key + env override the portal's LocationPicker uses.
const GOOGLE_MAPS_API_KEY =
import.meta.env.VITE_GOOGLE_MAPS_API_KEY ||
"AIzaSyBg4tN31-fgvH_2Ix_TPo6VSfOA2uA5CCI";
// Maps JavaScript API keys are public client-side keys — lock them down by
// HTTP-referrer in the Google Cloud console. No fallback: a hardcoded default
// used to live here and expired, turning a missing env var into a blank map
// that read as broken GPS rather than absent configuration.
const GOOGLE_MAPS_API_KEY = import.meta.env.VITE_GOOGLE_MAPS_API_KEY?.trim();
const DEFAULT_CENTER = { lat: 9.03, lng: 38.74 }; // Addis Ababa
const toNum = (v: number | string | null | undefined): number | null =>
@@ -55,8 +59,12 @@ const fmtTime = (iso?: string | null) => {
const StatBox = ({ label, value }: { label: string; value: string }) => (
<Box p="sm" style={{ backgroundColor: "#f8f9fa", borderRadius: 8 }}>
<Text size="xs" c="dimmed">{label}</Text>
<Text fw={600} size="sm">{value}</Text>
<Text size="xs" c="dimmed">
{label}
</Text>
<Text fw={600} size="sm">
{value}
</Text>
</Box>
);
@@ -95,11 +103,20 @@ function useAddress(lat: number, lng: number): string | null {
if (typeof google === "undefined" || !google.maps?.Geocoder) return;
setAddr(null);
let cancelled = false;
new google.maps.Geocoder().geocode({ location: { lat, lng } }, (res, status) => {
if (cancelled) return;
setAddr(status === "OK" && res?.[0] ? res[0].formatted_address : "Unknown location");
});
return () => { cancelled = true; };
new google.maps.Geocoder().geocode(
{ location: { lat, lng } },
(res, status) => {
if (cancelled) return;
setAddr(
status === "OK" && res?.[0]
? res[0].formatted_address
: "Unknown location",
);
},
);
return () => {
cancelled = true;
};
}, [lat, lng]);
return addr;
}
@@ -118,16 +135,24 @@ function HoverInfo({
}) {
const address = useAddress(lat, lng);
return (
<InfoWindow position={{ lat, lng }} pixelOffset={[0, -46]} onCloseClick={onClose}>
<InfoWindow
position={{ lat, lng }}
pixelOffset={[0, -46]}
onCloseClick={onClose}
>
<div style={{ minWidth: 190, fontSize: 13 }}>
<div style={{ fontWeight: 600, marginBottom: 2 }}>{deviceLabel(device)}</div>
<div style={{ fontWeight: 600, marginBottom: 2 }}>
{deviceLabel(device)}
</div>
<div style={{ fontFamily: "monospace" }}>
{lat.toFixed(5)}, {lng.toFixed(5)}
</div>
<div style={{ color: "#555" }}>
{toNum(device.lastSpeed) ?? 0} km/h · {device.lastCourse ?? 0}°
</div>
<div style={{ color: "#777", marginTop: 4 }}>{address ?? "Locating…"}</div>
<div style={{ color: "#777", marginTop: 4 }}>
{address ?? "Locating…"}
</div>
</div>
</InfoWindow>
);
@@ -182,7 +207,8 @@ export function TrackingPage() {
const { data: vehiclesData } = useQuery({
queryKey: ["vehicles", "all"],
queryFn: async () => (await vehiclesService.getAll({ limit: 1000 })).data ?? [],
queryFn: async () =>
(await vehiclesService.getAll({ limit: 1000 })).data ?? [],
});
const vehicleOptions = useMemo(
() =>
@@ -197,7 +223,10 @@ export function TrackingPage() {
() =>
devices
.map((d) => ({ d, lat: toNum(d.lastLat), lng: toNum(d.lastLng) }))
.filter((x): x is { d: GpsDevice; lat: number; lng: number } => x.lat != null && x.lng != null),
.filter(
(x): x is { d: GpsDevice; lat: number; lng: number } =>
x.lat != null && x.lng != null,
),
[devices],
);
@@ -207,19 +236,31 @@ export function TrackingPage() {
// Route history for the selected device's vehicle (chronological trail).
const { data: history = [] } = useQuery({
queryKey: ["gps", "history", selected?.vehicleId],
queryFn: async () => (await gpsTrackingService.history(selected!.vehicleId!, 300)).data ?? [],
queryFn: async () =>
(await gpsTrackingService.history(selected!.vehicleId!, 300)).data ?? [],
enabled: Boolean(selected?.vehicleId),
});
const trail = useMemo(
() => [...history].reverse().map((h) => ({ lat: Number(h.lat), lng: Number(h.lng) })),
() =>
[...history]
.reverse()
.map((h) => ({ lat: Number(h.lat), lng: Number(h.lng) })),
[history],
);
// Teardrop pin colored by state with a white truck glyph inside.
const markerIcon = (d: GpsDevice, selectedFlag: boolean): google.maps.Icon | undefined => {
const markerIcon = (
d: GpsDevice,
selectedFlag: boolean,
): google.maps.Icon | undefined => {
// Maps API loads async — Size/Point classes may not exist yet at first render.
if (typeof google === "undefined" || !google.maps?.Size || !mapsReady) return undefined;
const color = selectedFlag ? freightBrand.primary : d.online ? "#2f80ed" : "#95a5a6";
if (typeof google === "undefined" || !google.maps?.Size || !mapsReady)
return undefined;
const color = selectedFlag
? freightBrand.primary
: d.online
? "#2f80ed"
: "#95a5a6";
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="40" height="48" viewBox="0 0 40 48">
<path d="M20 2C10 2 2 10 2 20c0 12 18 26 18 26s18-14 18-26C38 10 30 2 20 2Z" fill="${color}" stroke="#ffffff" stroke-width="1.5"/>
<g transform="translate(8,7)" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
@@ -258,8 +299,13 @@ export function TrackingPage() {
},
onError: (err: unknown) => {
const description =
(err as { response?: { data?: { message?: string } } })?.response?.data?.message ?? "Failed";
toast({ title: editDevice ? "Update failed" : "Registration failed", description, variant: "destructive" });
(err as { response?: { data?: { message?: string } } })?.response?.data
?.message ?? "Failed";
toast({
title: editDevice ? "Update failed" : "Registration failed",
description,
variant: "destructive",
});
},
});
@@ -285,15 +331,25 @@ export function TrackingPage() {
return (
<Container size="xl" py="xl" px="lg">
<Breadcrumbs items={[{ label: "Fleet" }, { label: "Vehicle Tracking" }]} />
<Breadcrumbs
items={[{ label: "Fleet" }, { label: "Vehicle Tracking" }]}
/>
<Group justify="space-between" mb="xl">
<div>
<Text fw={700} size="xl">Real-Time Vehicle Tracking</Text>
<Text c="dimmed" size="sm">Live GPS positions from GT06 trackers</Text>
<Text fw={700} size="xl">
Real-Time Vehicle Tracking
</Text>
<Text c="dimmed" size="sm">
Live GPS positions from GT06 trackers
</Text>
</div>
{canManage && (
<Button leftSection={<Plus size={16} />} color="edr-green" onClick={openRegister}>
<Button
leftSection={<Plus size={16} />}
color="edr-green"
onClick={openRegister}
>
Register tracker
</Button>
)}
@@ -312,36 +368,82 @@ export function TrackingPage() {
</Group>
</Card.Section>
<Card.Section p="md">
<Box style={{ height: 500, width: "100%", borderRadius: 8, overflow: "hidden" }}>
<APIProvider apiKey={GOOGLE_MAPS_API_KEY}>
<GoogleMap
defaultCenter={DEFAULT_CENTER}
defaultZoom={7}
gestureHandling="greedy"
disableDefaultUI={false}
style={{ width: "100%", height: "100%" }}
<Box
style={{
height: 500,
width: "100%",
borderRadius: 8,
overflow: "hidden",
}}
>
{!GOOGLE_MAPS_API_KEY ? (
// Name the missing variable rather than showing an empty map: the
// device list beside this still works, so a blank panel reads as
// "no GPS fixes" instead of "no map key".
<Box
style={{
height: "100%",
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: 24,
textAlign: "center",
border: "1px solid #F0D2A8",
borderRadius: 8,
background: "#FFF9F0",
}}
>
<ReadyProbe onReady={() => setMapsReady(true)} />
{positioned.map(({ d, lat, lng }) => (
<Marker
key={d.id}
position={{ lat, lng }}
title={`${deviceLabel(d)}\n${lat.toFixed(5)}, ${lng.toFixed(5)}`}
icon={markerIcon(d, d.id === selectedId)}
onClick={() => setSelectedId(d.id)}
onMouseOver={() => setHoverId(d.id)}
<Text fz="sm" c="#8A5A16">
Map unavailable <code>VITE_GOOGLE_MAPS_API_KEY</code> is
not set. Add a Google Maps key with the Maps JavaScript
API and Places API enabled to this app&apos;s{" "}
<code>.env</code>, then restart the dev server. Device
positions below are unaffected.
</Text>
</Box>
) : (
<APIProvider apiKey={GOOGLE_MAPS_API_KEY}>
<GoogleMap
defaultCenter={DEFAULT_CENTER}
defaultZoom={7}
gestureHandling="greedy"
disableDefaultUI={false}
style={{ width: "100%", height: "100%" }}
>
<ReadyProbe onReady={() => setMapsReady(true)} />
{positioned.map(({ d, lat, lng }) => (
<Marker
key={d.id}
position={{ lat, lng }}
title={`${deviceLabel(d)}\n${lat.toFixed(5)}, ${lng.toFixed(5)}`}
icon={markerIcon(d, d.id === selectedId)}
onClick={() => setSelectedId(d.id)}
onMouseOver={() => setHoverId(d.id)}
/>
))}
{(() => {
const h = positioned.find((p) => p.d.id === hoverId);
return h ? (
<HoverInfo
device={h.d}
lat={h.lat}
lng={h.lng}
onClose={() => setHoverId(null)}
/>
) : null;
})()}
<FitBounds
points={positioned.map((p) => ({
lat: p.lat,
lng: p.lng,
}))}
/>
))}
{(() => {
const h = positioned.find((p) => p.d.id === hoverId);
return h ? (
<HoverInfo device={h.d} lat={h.lat} lng={h.lng} onClose={() => setHoverId(null)} />
) : null;
})()}
<FitBounds points={positioned.map((p) => ({ lat: p.lat, lng: p.lng }))} />
{selected?.vehicleId && trail.length > 1 && <RouteTrail path={trail} />}
</GoogleMap>
</APIProvider>
{selected?.vehicleId && trail.length > 1 && (
<RouteTrail path={trail} />
)}
</GoogleMap>
</APIProvider>
)}
</Box>
{positioned.length === 0 && (
<Text size="sm" c="dimmed" ta="center" mt="sm">
@@ -361,11 +463,19 @@ export function TrackingPage() {
<Group justify="space-between">
<Text fw={500}>{deviceLabel(selected)}</Text>
<Group gap="xs">
<Badge color={selected.online ? "edr-green" : "gray"} leftSection={<Activity size={12} />}>
<Badge
color={selected.online ? "edr-green" : "gray"}
leftSection={<Activity size={12} />}
>
{selected.online ? "Live" : "Offline"}
</Badge>
{canManage && (
<ActionIcon variant="subtle" color="red" aria-label="Remove tracker" onClick={() => deleteMutation.mutate(selected.id)}>
<ActionIcon
variant="subtle"
color="red"
aria-label="Remove tracker"
onClick={() => deleteMutation.mutate(selected.id)}
>
<Trash2 size={16} />
</ActionIcon>
)}
@@ -373,21 +483,55 @@ export function TrackingPage() {
</Group>
<SimpleGrid cols={2} spacing="sm">
<StatBox label="Latitude" value={toNum(selected.lastLat)?.toFixed(5) ?? "—"} />
<StatBox label="Longitude" value={toNum(selected.lastLng)?.toFixed(5) ?? "—"} />
<StatBox label="Speed" value={`${toNum(selected.lastSpeed) ?? 0} km/h`} />
<StatBox label="Course" value={`${selected.lastCourse ?? 0}°`} />
<StatBox label="Voltage" value={selected.voltageLevel != null ? `${selected.voltageLevel}/6` : "—"} />
<StatBox label="GSM" value={selected.gsmLevel != null ? `${selected.gsmLevel}/4` : "—"} />
<StatBox
label="Latitude"
value={toNum(selected.lastLat)?.toFixed(5) ?? "—"}
/>
<StatBox
label="Longitude"
value={toNum(selected.lastLng)?.toFixed(5) ?? "—"}
/>
<StatBox
label="Speed"
value={`${toNum(selected.lastSpeed) ?? 0} km/h`}
/>
<StatBox
label="Course"
value={`${selected.lastCourse ?? 0}°`}
/>
<StatBox
label="Voltage"
value={
selected.voltageLevel != null
? `${selected.voltageLevel}/6`
: "—"
}
/>
<StatBox
label="GSM"
value={
selected.gsmLevel != null
? `${selected.gsmLevel}/4`
: "—"
}
/>
</SimpleGrid>
<div>
<Text size="xs" c="dimmed">IMEI</Text>
<Text fw={500} size="sm">{selected.imei}</Text>
<Text size="xs" c="dimmed">
IMEI
</Text>
<Text fw={500} size="sm">
{selected.imei}
</Text>
</div>
<div>
<Text size="xs" c="dimmed">Last fix</Text>
<Text fw={500} size="sm">{fmtTime(selected.lastFixAt)}</Text>
<Text size="xs" c="dimmed">
Last fix
</Text>
<Text fw={500} size="sm">
{fmtTime(selected.lastFixAt)}
</Text>
</div>
{selected.vehicleId && (
<Text size="xs" c="dimmed">
@@ -400,7 +544,9 @@ export function TrackingPage() {
placeholder="Unassigned"
data={vehicleOptions}
value={selected.vehicleId ?? null}
onChange={(v) => assignMutation.mutate({ id: selected.id, vehicleId: v })}
onChange={(v) =>
assignMutation.mutate({ id: selected.id, vehicleId: v })
}
disabled={!canManage}
searchable
clearable
@@ -418,24 +564,43 @@ export function TrackingPage() {
{devices.map((d) => (
<Table.Tr
key={d.id}
style={{ cursor: "pointer", backgroundColor: d.id === selectedId ? freightBrand.mutedBg : "transparent" }}
style={{
cursor: "pointer",
backgroundColor:
d.id === selectedId
? freightBrand.mutedBg
: "transparent",
}}
onClick={() => setSelectedId(d.id)}
>
<Table.Td>
<Stack gap={0}>
<Text size="sm" fw={600}>{deviceLabel(d)}</Text>
<Text size="xs" c="dimmed">{toNum(d.lastSpeed) ?? 0} km/h · {fmtTime(d.lastFixAt)}</Text>
<Text size="sm" fw={600}>
{deviceLabel(d)}
</Text>
<Text size="xs" c="dimmed">
{toNum(d.lastSpeed) ?? 0} km/h ·{" "}
{fmtTime(d.lastFixAt)}
</Text>
</Stack>
</Table.Td>
<Table.Td align="right">
<Group gap={6} justify="flex-end" wrap="nowrap">
<Badge color={d.online ? "edr-green" : "gray"} size="sm">{d.online ? "Live" : "Offline"}</Badge>
<Badge
color={d.online ? "edr-green" : "gray"}
size="sm"
>
{d.online ? "Live" : "Offline"}
</Badge>
{canManage && (
<ActionIcon
variant="subtle"
size="sm"
aria-label="Edit tracker"
onClick={(e) => { e.stopPropagation(); openEdit(d); }}
onClick={(e) => {
e.stopPropagation();
openEdit(d);
}}
>
<Pencil size={15} />
</ActionIcon>
@@ -447,7 +612,9 @@ export function TrackingPage() {
{devices.length === 0 && (
<Table.Tr>
<Table.Td colSpan={2}>
<Text size="sm" c="dimmed" ta="center" py="md">No trackers registered yet.</Text>
<Text size="sm" c="dimmed" ta="center" py="md">
No trackers registered yet.
</Text>
</Table.Td>
</Table.Tr>
)}
@@ -493,7 +660,9 @@ export function TrackingPage() {
clearable
/>
<Group justify="flex-end">
<Button variant="default" onClick={() => setModalOpen(false)}>Cancel</Button>
<Button variant="default" onClick={() => setModalOpen(false)}>
Cancel
</Button>
<Button
loading={saveMutation.isPending}
disabled={!form.imei.trim()}

View File

@@ -15,6 +15,7 @@ import { useQuery } from "@tanstack/react-query";
import { ArrowLeft, Download } from "lucide-react";
import { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import { EimsFilingCard } from "@/components/invoices/EimsFilingCard";
import { useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
@@ -165,6 +166,8 @@ export default function InvoiceDetailPage() {
</Stack>
</Card>
<EimsFilingCard invoiceId={invoice.id} />
<Card>
<Stack gap="md">
<Text fw={600} c="edr-text">

View File

@@ -0,0 +1,95 @@
import { Accordion, ActionIcon, Center, Group, Text, Tooltip } from "@mantine/core";
import { ChevronDown, ChevronUp, Trash2 } from "lucide-react";
import type { ReactNode } from "react";
interface AccordionRowProps {
value: string;
/** Collapsed summary — the heading, question or card title. */
title: string;
/** Small dimmed line under the title, e.g. a body excerpt. */
subtitle?: string;
index: number;
length: number;
onMove: (delta: number) => void;
onRemove: () => void;
children: ReactNode;
}
/**
* One collapsible item with reorder and delete controls in its header.
*
* Collapsing is the point: a legal document has fifteen sections and the FAQ
* seventeen answers, and rendering every textarea expanded turned each tab into
* an unnavigable mile of boxes. Collapsed, the tab reads as the list of
* headings the customer actually sees.
*
* The buttons sit outside `Accordion.Control` so clicking one does not also
* toggle the panel.
*/
export function AccordionRow({
value,
title,
subtitle,
index,
length,
onMove,
onRemove,
children,
}: AccordionRowProps) {
return (
<Accordion.Item value={value}>
<Center>
<Accordion.Control>
<div style={{ minWidth: 0 }}>
<Text fw={500} truncate>
{title || <Text span c="dimmed">(untitled)</Text>}
</Text>
{subtitle && (
<Text size="xs" c="dimmed" truncate>
{subtitle}
</Text>
)}
</div>
</Accordion.Control>
<Group gap={2} wrap="nowrap" pr="sm">
<Tooltip label="Move up">
<ActionIcon
variant="subtle"
color="gray"
disabled={index === 0}
onClick={() => onMove(-1)}
>
<ChevronUp size={16} />
</ActionIcon>
</Tooltip>
<Tooltip label="Move down">
<ActionIcon
variant="subtle"
color="gray"
disabled={index === length - 1}
onClick={() => onMove(1)}
>
<ChevronDown size={16} />
</ActionIcon>
</Tooltip>
<Tooltip label="Remove">
<ActionIcon variant="subtle" color="red" onClick={onRemove}>
<Trash2 size={16} />
</ActionIcon>
</Tooltip>
</Group>
</Center>
<Accordion.Panel>{children}</Accordion.Panel>
</Accordion.Item>
);
}
/** First line of a markdown body, for an accordion subtitle. */
export function excerpt(markdown: string, max = 90): string {
const line = markdown.replace(/[#*`>-]/g, "").trim().split("\n")[0] ?? "";
return line.length > max ? `${line.slice(0, max)}` : line;
}
export default AccordionRow;

View File

@@ -0,0 +1,242 @@
import type { PortalFaqContent, PortalFaqGroup } from "@edr/types";
import {
Accordion,
Badge,
Button,
Card,
Group,
Stack,
Switch,
TextInput,
} from "@mantine/core";
import { Plus } from "lucide-react";
import { AccordionRow, excerpt } from "./AccordionRow";
import { moveAt, newId, removeAt, replaceAt } from "./array-helpers";
import { MarkdownEditor, MarkdownHint } from "./MarkdownEditor";
interface FaqEditorProps {
value: PortalFaqContent;
onChange: (next: PortalFaqContent) => void;
}
const EMPTY_FOOTER = {
heading: "Still need a hand?",
body: "",
ctaLabel: "Go to Help & Support",
ctaTo: "/help",
};
export function FaqEditor({ value, onChange }: FaqEditorProps) {
const setGroups = (groups: PortalFaqGroup[]) => onChange({ ...value, groups });
const setGroup = (index: number, next: PortalFaqGroup) =>
setGroups(replaceAt(value.groups, index, next));
return (
<Stack gap="lg">
<Card withBorder padding="md" radius="md">
<Stack gap="md">
<TextInput
label="Page title"
value={value.title}
onChange={(e) =>
onChange({ ...value, title: e.currentTarget.value })
}
/>
<TextInput
label="Subtitle"
value={value.subtitle}
onChange={(e) =>
onChange({ ...value, subtitle: e.currentTarget.value })
}
/>
</Stack>
</Card>
<MarkdownHint />
<Accordion variant="separated" radius="md" chevronPosition="left">
{value.groups.map((group, groupIndex) => (
<AccordionRow
key={group.id}
value={group.id}
title={group.title}
subtitle={`${group.items.length} question${group.items.length === 1 ? "" : "s"}`}
index={groupIndex}
length={value.groups.length}
onMove={(delta) => setGroups(moveAt(value.groups, groupIndex, delta))}
onRemove={() => setGroups(removeAt(value.groups, groupIndex))}
>
<Stack gap="md">
<TextInput
label="Group title"
value={group.title}
onChange={(e) =>
setGroup(groupIndex, {
...group,
title: e.currentTarget.value,
})
}
/>
<Accordion variant="contained" radius="sm" chevronPosition="left">
{group.items.map((item, itemIndex) => (
<AccordionRow
key={item.id}
value={item.id}
title={item.question}
subtitle={excerpt(item.answer, 70)}
index={itemIndex}
length={group.items.length}
onMove={(delta) =>
setGroup(groupIndex, {
...group,
items: moveAt(group.items, itemIndex, delta),
})
}
onRemove={() =>
setGroup(groupIndex, {
...group,
items: removeAt(group.items, itemIndex),
})
}
>
<Stack gap="md">
<TextInput
label="Question"
value={item.question}
onChange={(e) =>
setGroup(groupIndex, {
...group,
items: replaceAt(group.items, itemIndex, {
...item,
question: e.currentTarget.value,
}),
})
}
/>
<MarkdownEditor
label="Answer"
value={item.answer}
onChange={(answer) =>
setGroup(groupIndex, {
...group,
items: replaceAt(group.items, itemIndex, {
...item,
answer,
}),
})
}
/>
</Stack>
</AccordionRow>
))}
</Accordion>
<Button
variant="subtle"
size="xs"
leftSection={<Plus size={14} />}
style={{ alignSelf: "flex-start" }}
onClick={() =>
setGroup(groupIndex, {
...group,
items: [
...group.items,
{ id: newId(), question: "New question", answer: "" },
],
})
}
>
Add question
</Button>
</Stack>
</AccordionRow>
))}
</Accordion>
<Button
variant="light"
leftSection={<Plus size={16} />}
style={{ alignSelf: "flex-start" }}
onClick={() =>
setGroups([
...value.groups,
{ id: newId(), title: "New group", items: [] },
])
}
>
Add group
</Button>
<Card withBorder padding="md" radius="md">
<Stack gap="md">
<Group justify="space-between">
<Switch
label="Closing card"
checked={Boolean(value.footer)}
onChange={(e) =>
onChange({
...value,
footer: e.currentTarget.checked ? EMPTY_FOOTER : null,
})
}
/>
{!value.footer && <Badge variant="light" color="gray">Hidden</Badge>}
</Group>
{value.footer && (
<>
<TextInput
label="Heading"
value={value.footer.heading}
onChange={(e) =>
onChange({
...value,
footer: { ...value.footer!, heading: e.currentTarget.value },
})
}
/>
<MarkdownEditor
label="Body"
value={value.footer.body}
onChange={(body) =>
onChange({ ...value, footer: { ...value.footer!, body } })
}
/>
<Group grow>
<TextInput
label="Button label"
value={value.footer.ctaLabel}
onChange={(e) =>
onChange({
...value,
footer: {
...value.footer!,
ctaLabel: e.currentTarget.value,
},
})
}
/>
<TextInput
label="Button link"
description="A portal route (/help) or an https:// URL"
value={value.footer.ctaTo}
onChange={(e) =>
onChange({
...value,
footer: { ...value.footer!, ctaTo: e.currentTarget.value },
})
}
/>
</Group>
</>
)}
</Stack>
</Card>
</Stack>
);
}
export default FaqEditor;

View File

@@ -0,0 +1,125 @@
import type { PortalHelpContent, PortalHelpSection } from "@edr/types";
import { Accordion, Button, Card, Divider, Stack, TextInput } from "@mantine/core";
import { Plus } from "lucide-react";
import { AccordionRow, excerpt } from "./AccordionRow";
import { moveAt, newId, removeAt, replaceAt } from "./array-helpers";
import { MarkdownEditor, MarkdownHint } from "./MarkdownEditor";
import { MediaManager } from "./MediaManager";
interface HelpEditorProps {
value: PortalHelpContent;
onChange: (next: PortalHelpContent) => void;
}
/**
* The help page is built, not filled in: an ordered list of sections, each a
* heading plus free markdown plus any images or videos. Nothing about the page
* is fixed except its title, so support can add, reorder or drop a section
* without a code change.
*/
export function HelpEditor({ value, onChange }: HelpEditorProps) {
// A row written before the free-form conversion has no `sections` at all.
// Tolerate it rather than crashing the tab: the migration rewrites it, but
// an environment can be mid-deploy.
const sections = value.sections ?? [];
const setSections = (next: PortalHelpSection[]) =>
onChange({ ...value, sections: next });
return (
<Stack gap="lg">
<Card withBorder padding="md" radius="md">
<Stack gap="md">
<TextInput
label="Page title"
value={value.title}
onChange={(e) =>
onChange({ ...value, title: e.currentTarget.value })
}
/>
<TextInput
label="Subtitle"
value={value.subtitle}
onChange={(e) =>
onChange({ ...value, subtitle: e.currentTarget.value })
}
/>
</Stack>
</Card>
<MarkdownHint />
<Accordion variant="separated" radius="md" chevronPosition="left">
{sections.map((section, index) => (
<AccordionRow
key={section.id}
value={section.id}
title={section.heading}
subtitle={
section.media.length
? `${excerpt(section.body, 60)} · ${section.media.length} attachment${section.media.length === 1 ? "" : "s"}`
: excerpt(section.body)
}
index={index}
length={sections.length}
onMove={(delta) => setSections(moveAt(sections, index, delta))}
onRemove={() => setSections(removeAt(sections, index))}
>
<Stack gap="md">
<TextInput
label="Heading"
value={section.heading}
onChange={(e) =>
setSections(
replaceAt(sections, index, {
...section,
heading: e.currentTarget.value,
}),
)
}
/>
<MarkdownEditor
label="Body"
value={section.body}
onChange={(body) =>
setSections(
replaceAt(sections, index, { ...section, body }),
)
}
/>
<Divider />
<MediaManager
value={section.media}
onChange={(media) =>
setSections(
replaceAt(sections, index, { ...section, media }),
)
}
/>
</Stack>
</AccordionRow>
))}
</Accordion>
<Button
variant="light"
leftSection={<Plus size={16} />}
style={{ alignSelf: "flex-start" }}
onClick={() =>
setSections([
...sections,
{ id: newId(), heading: "New section", body: "", media: [] },
])
}
>
Add section
</Button>
</Stack>
);
}
export default HelpEditor;

View File

@@ -0,0 +1,110 @@
import type { PortalLegalContent } from "@edr/types";
import { Accordion, Button, Card, Group, Stack, TextInput } from "@mantine/core";
import { Plus } from "lucide-react";
import { AccordionRow, excerpt } from "./AccordionRow";
import { moveAt, newId, removeAt, replaceAt } from "./array-helpers";
import { MarkdownEditor, MarkdownHint } from "./MarkdownEditor";
interface LegalDocEditorProps {
value: PortalLegalContent;
onChange: (next: PortalLegalContent) => void;
}
/** Shared by the Privacy and Terms tabs — the two documents have one shape. */
export function LegalDocEditor({ value, onChange }: LegalDocEditorProps) {
const setSections = (sections: PortalLegalContent["sections"]) =>
onChange({ ...value, sections });
return (
<Stack gap="lg">
<Card withBorder padding="md" radius="md">
<Stack gap="md">
<Group grow align="flex-start">
<TextInput
label="Page title"
value={value.title}
onChange={(e) =>
onChange({ ...value, title: e.currentTarget.value })
}
/>
<TextInput
label="Last updated"
description="Free text, e.g. 6 August 2026"
value={value.lastUpdated}
onChange={(e) =>
onChange({ ...value, lastUpdated: e.currentTarget.value })
}
/>
</Group>
<TextInput
label="Subtitle"
value={value.subtitle}
onChange={(e) =>
onChange({ ...value, subtitle: e.currentTarget.value })
}
/>
</Stack>
</Card>
<MarkdownHint />
<Accordion variant="separated" radius="md" chevronPosition="left">
{value.sections.map((section, index) => (
<AccordionRow
key={section.id}
value={section.id}
title={section.heading}
subtitle={excerpt(section.body)}
index={index}
length={value.sections.length}
onMove={(delta) => setSections(moveAt(value.sections, index, delta))}
onRemove={() => setSections(removeAt(value.sections, index))}
>
<Stack gap="md">
<TextInput
label="Heading"
value={section.heading}
onChange={(e) =>
setSections(
replaceAt(value.sections, index, {
...section,
heading: e.currentTarget.value,
}),
)
}
/>
<MarkdownEditor
label="Body"
value={section.body}
onChange={(body) =>
setSections(
replaceAt(value.sections, index, { ...section, body }),
)
}
/>
</Stack>
</AccordionRow>
))}
</Accordion>
<Button
variant="light"
leftSection={<Plus size={16} />}
style={{ alignSelf: "flex-start" }}
onClick={() =>
setSections([
...value.sections,
{ id: newId(), heading: "New section", body: "" },
])
}
>
Add section
</Button>
</Stack>
);
}
export default LegalDocEditor;

View File

@@ -0,0 +1,24 @@
import ReactMarkdown from "react-markdown";
// Same preflight fix the editor needs — Mantine's `Typography` defines its list
// and margin rules with `:where()`, which Tailwind's preflight outranks, so
// bullets rendered without markers here too.
import "./markdown-editor.css";
/**
* Read-only markdown rendering for the version-history preview. Editing goes
* through `MarkdownEditor` (MDXEditor); this is only for showing what an old
* version said.
*
* Same options as the portal's renderer — no `rehype-raw`, no custom
* `urlTransform` — so neither app grows an HTML-injection surface.
*/
export function Markdown({ children }: { children: string }) {
return (
<div className="edr-md-content">
<ReactMarkdown>{children}</ReactMarkdown>
</div>
);
}
export default Markdown;

View File

@@ -0,0 +1,142 @@
import { PORTAL_MEDIA_URI_SCHEME } from "@edr/types";
import { Box, Stack, Text } from "@mantine/core";
import {
BlockTypeSelect,
BoldItalicUnderlineToggles,
CreateLink,
InsertImage,
InsertThematicBreak,
ListsToggle,
MDXEditor,
UndoRedo,
headingsPlugin,
imagePlugin,
linkDialogPlugin,
linkPlugin,
listsPlugin,
markdownShortcutPlugin,
quotePlugin,
thematicBreakPlugin,
toolbarPlugin,
} from "@mdxeditor/editor";
import "@mdxeditor/editor/style.css";
import { portalContentService } from "@/services/portal-content.service";
// Undoes Tailwind's preflight inside the editor's content area — see the file.
import "./markdown-editor.css";
interface MarkdownEditorProps {
label: string;
value: string;
onChange: (next: string) => void;
description?: string;
}
/**
* Signed URLs are per-request and short-lived, so previews are memoised for the
* life of the page rather than re-signed on every keystroke re-render.
*/
const previewCache = new Map<string, Promise<string>>();
/**
* Inserted images are stored as `minio:<key>`, never as the signed URL the
* upload returns: a presigned URL expires, so persisting one would leave every
* embedded image broken a few hours later. `imagePreviewHandler` resolves the
* ref back to a temporary URL purely for display, on both sides of the wire.
*/
function resolvePreview(url: string): Promise<string> {
if (!url.startsWith(PORTAL_MEDIA_URI_SCHEME)) return Promise.resolve(url);
const key = url.slice(PORTAL_MEDIA_URI_SCHEME.length);
let pending = previewCache.get(key);
if (!pending) {
pending = portalContentService
.mediaUrl(key)
.catch(() => url); // show a broken image rather than blowing up the editor
previewCache.set(key, pending);
}
return pending;
}
export function MarkdownEditor({
label,
value,
onChange,
description,
}: MarkdownEditorProps) {
return (
<Stack gap={6}>
<Text size="sm" fw={500}>
{label}
</Text>
{description && (
<Text size="xs" c="dimmed">
{description}
</Text>
)}
<Box
style={{
border: "1px solid var(--mantine-color-gray-3)",
borderRadius: "var(--mantine-radius-sm)",
}}
>
<MDXEditor
markdown={value}
contentEditableClassName="edr-md-content"
// MDXEditor re-serialises the markdown once on mount, which differs
// harmlessly from what was stored (spacing, escaping). Reporting that
// as an edit made every tab open "unsaved" and let a Save write a
// no-op version, so the normalisation pass is ignored.
onChange={(markdown, initialMarkdownNormalize) => {
if (!initialMarkdownNormalize) onChange(markdown);
}}
plugins={[
headingsPlugin(),
listsPlugin(),
quotePlugin(),
linkPlugin(),
linkDialogPlugin(),
thematicBreakPlugin(),
imagePlugin({
imageUploadHandler: async (file) => {
const { key } = await portalContentService.uploadMedia(file);
return `${PORTAL_MEDIA_URI_SCHEME}${key}`;
},
imagePreviewHandler: resolvePreview,
}),
markdownShortcutPlugin(),
toolbarPlugin({
toolbarContents: () => (
<>
<UndoRedo />
<BoldItalicUnderlineToggles />
<BlockTypeSelect />
<ListsToggle />
<CreateLink />
<InsertImage />
<InsertThematicBreak />
</>
),
}),
]}
/>
</Box>
</Stack>
);
}
/** Reminder of the substitution tokens, rendered once per tab. */
export function MarkdownHint() {
return (
<Text size="xs" c="dimmed">
Placeholders resolve from the Contact tab, so one edit there updates every
page: <code>{"{{supportEmail}}"}</code> · <code>{"{{supportPhone}}"}</code>{" "}
· <code>{"{{supportOffice}}"}</code> · <code>{"{{supportHours}}"}</code> ·{" "}
<code>{"{{supportPhoneTel}}"}</code> (inside a tel: link).
</Text>
);
}
export default MarkdownEditor;

View File

@@ -0,0 +1,122 @@
import type { PortalMedia } from "@edr/types";
import {
ActionIcon,
Button,
Group,
Paper,
Stack,
Text,
TextInput,
Tooltip,
} from "@mantine/core";
import { Film, Image as ImageIcon, Trash2, Upload } from "lucide-react";
import { useRef, useState } from "react";
import toast from "react-hot-toast";
import { portalContentService } from "@/services/portal-content.service";
import { newId, removeAt, replaceAt } from "./array-helpers";
interface MediaManagerProps {
value: PortalMedia[];
onChange: (next: PortalMedia[]) => void;
}
/**
* Attachments for one help section. Uploads store the MinIO object *key*; the
* signed URL the upload returns is short-lived and is never persisted, so the
* list shows the key rather than pretending to be a gallery.
*/
export function MediaManager({ value, onChange }: MediaManagerProps) {
const inputRef = useRef<HTMLInputElement>(null);
const [uploading, setUploading] = useState(false);
const upload = async (file: File) => {
setUploading(true);
try {
const { key, kind } = await portalContentService.uploadMedia(file);
onChange([...value, { id: newId(), kind, src: key, caption: null }]);
} catch (error) {
const message =
(error as { response?: { data?: { message?: string } } })?.response?.data
?.message ?? "Upload failed";
toast.error(Array.isArray(message) ? message.join(", ") : message);
} finally {
setUploading(false);
if (inputRef.current) inputRef.current.value = "";
}
};
return (
<Stack gap="xs">
<Text size="sm" fw={500}>
Attachments
</Text>
{value.map((item, index) => (
<Paper key={item.id} withBorder p="xs" radius="sm">
<Group wrap="nowrap" align="center" gap="sm">
{item.kind === "video" ? (
<Film size={18} />
) : (
<ImageIcon size={18} />
)}
<Stack gap={2} style={{ flex: 1, minWidth: 0 }}>
<Text size="xs" c="dimmed" truncate>
{item.src}
</Text>
<TextInput
size="xs"
placeholder="Caption (optional)"
value={item.caption ?? ""}
onChange={(e) =>
onChange(
replaceAt(value, index, {
...item,
caption: e.currentTarget.value || null,
}),
)
}
/>
</Stack>
<Tooltip label="Remove attachment">
<ActionIcon
variant="subtle"
color="red"
onClick={() => onChange(removeAt(value, index))}
>
<Trash2 size={16} />
</ActionIcon>
</Tooltip>
</Group>
</Paper>
))}
<input
ref={inputRef}
type="file"
accept="image/*,video/*"
hidden
onChange={(e) => {
const file = e.currentTarget.files?.[0];
if (file) void upload(file);
}}
/>
<Button
variant="light"
size="xs"
loading={uploading}
leftSection={<Upload size={14} />}
style={{ alignSelf: "flex-start" }}
onClick={() => inputRef.current?.click()}
>
Upload image or video
</Button>
</Stack>
);
}
export default MediaManager;

View File

@@ -0,0 +1,261 @@
import type {
PortalFaqContent,
PortalHelpContent,
PortalLegalContent,
PortalSupportContact,
SupportDocPayload,
SupportDocSlug,
} from "@edr/types";
import {
Badge,
Button,
Card,
Group,
Loader,
Stack,
Tabs,
Text,
TextInput,
} from "@mantine/core";
import { History, RotateCcw, Save } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { PageContainer, PageHeader } from "@/components/page";
import {
usePortalDoc,
useUpdatePortalDoc,
} from "@/hooks/portal-content/usePortalContentAdmin";
import { FaqEditor } from "./FaqEditor";
import { HelpEditor } from "./HelpEditor";
import { LegalDocEditor } from "./LegalDocEditor";
import { VersionHistoryModal } from "./VersionHistoryModal";
const TABS: { slug: SupportDocSlug; label: string }[] = [
{ slug: "CONTACT", label: "Contact" },
{ slug: "HELP", label: "Help" },
{ slug: "FAQ", label: "FAQ" },
{ slug: "PRIVACY", label: "Privacy" },
{ slug: "TERMS", label: "Terms" },
];
/**
* Edits the copy on the freight portal's public pages — /help, /faq, /terms,
* /privacy — and the support contact block all four quote.
*
* Each tab is a local draft saved in one PATCH of the whole document, rather
* than a mutation per field. That is what makes one editorial change equal one
* version, which is the difference between a history you can read and a history
* of keystrokes.
*/
export default function PortalContentPage() {
const [active, setActive] = useState<SupportDocSlug>("CONTACT");
return (
<PageContainer>
<PageHeader
title="Portal content"
subtitle="Help, FAQ and legal copy shown to customers on the public portal pages. Body text is markdown, every save is versioned, and any version can be restored."
/>
<Tabs
value={active}
onChange={(value) => setActive(value as SupportDocSlug)}
keepMounted={false}
>
<Tabs.List mb="lg">
{TABS.map((tab) => (
<Tabs.Tab key={tab.slug} value={tab.slug}>
{tab.label}
</Tabs.Tab>
))}
</Tabs.List>
{TABS.map((tab) => (
<Tabs.Panel key={tab.slug} value={tab.slug}>
<DocumentTab slug={tab.slug} />
</Tabs.Panel>
))}
</Tabs>
</PageContainer>
);
}
function DocumentTab({ slug }: { slug: SupportDocSlug }) {
const { data, isLoading } = usePortalDoc(slug);
const update = useUpdatePortalDoc(slug);
const [draft, setDraft] = useState<SupportDocPayload | null>(null);
const [note, setNote] = useState("");
const [historyOpen, setHistoryOpen] = useState(false);
// Reseed only when the server's version number moves (load, save, restore).
// Keying off `data` itself would let a background refetch wipe edits that are
// still in progress.
const seededVersion = useRef<number | null>(null);
useEffect(() => {
if (data && seededVersion.current !== data.version) {
seededVersion.current = data.version;
setDraft(data.payload);
setNote("");
}
}, [data]);
if (isLoading || !data || !draft) return <Loader size="sm" />;
const dirty = JSON.stringify(draft) !== JSON.stringify(data.payload);
const reset = () => {
setDraft(data.payload);
setNote("");
};
return (
<Stack gap="lg">
{/* Sticky: these tabs are long lists, and a Save button that scrolls out
of reach is the fastest way to lose an edit. */}
<Card
withBorder
padding="sm"
radius="md"
style={{
position: "sticky",
top: 0,
zIndex: 2,
backgroundColor: "var(--mantine-color-body)",
}}
>
<Group justify="space-between" align="center" wrap="wrap" gap="sm">
<Group gap="xs">
<Badge variant="light" color={dirty ? "orange" : "gray"}>
v{data.version}
</Badge>
<Text size="sm" c={dirty ? "orange" : "dimmed"}>
{dirty ? "Unsaved changes" : "Saved"}
</Text>
</Group>
<Group gap="xs" align="center">
{dirty && (
<TextInput
size="sm"
placeholder="Change note (optional)"
value={note}
onChange={(e) => setNote(e.currentTarget.value)}
w={240}
/>
)}
<Button
variant="default"
leftSection={<History size={16} />}
onClick={() => setHistoryOpen(true)}
>
History
</Button>
<Button
variant="subtle"
leftSection={<RotateCcw size={16} />}
disabled={!dirty}
onClick={reset}
>
Reset
</Button>
<Button
leftSection={<Save size={16} />}
disabled={!dirty}
loading={update.isPending}
onClick={() =>
update.mutate({ payload: draft, note: note || undefined })
}
>
Save
</Button>
</Group>
</Group>
</Card>
<DocumentEditor slug={slug} value={draft} onChange={setDraft} />
<VersionHistoryModal
slug={slug}
opened={historyOpen}
onClose={() => setHistoryOpen(false)}
hasUnsavedChanges={dirty}
onRestored={reset}
/>
</Stack>
);
}
function DocumentEditor({
slug,
value,
onChange,
}: {
slug: SupportDocSlug;
value: SupportDocPayload;
onChange: (next: SupportDocPayload) => void;
}) {
switch (slug) {
case "CONTACT":
return (
<ContactEditor
value={value as PortalSupportContact}
onChange={onChange}
/>
);
case "HELP":
return (
<HelpEditor value={value as PortalHelpContent} onChange={onChange} />
);
case "FAQ":
return <FaqEditor value={value as PortalFaqContent} onChange={onChange} />;
case "PRIVACY":
case "TERMS":
return (
<LegalDocEditor
value={value as PortalLegalContent}
onChange={onChange}
/>
);
}
}
/**
* Four fields, so no separate file. These values feed the help page's contact
* cards and resolve the `{{supportEmail}}`-style placeholders used throughout
* the FAQ and legal copy — editing them here updates every page at once.
*/
function ContactEditor({
value,
onChange,
}: {
value: PortalSupportContact;
onChange: (next: PortalSupportContact) => void;
}) {
return (
<Stack gap="md" maw={640}>
<TextInput
label="Support email"
value={value.email}
onChange={(e) => onChange({ ...value, email: e.currentTarget.value })}
/>
<TextInput
label="Support phone"
description="Displayed as typed; tel: links strip the spacing automatically."
value={value.phone}
onChange={(e) => onChange({ ...value, phone: e.currentTarget.value })}
/>
<TextInput
label="Head office"
value={value.office}
onChange={(e) => onChange({ ...value, office: e.currentTarget.value })}
/>
<TextInput
label="Support hours"
value={value.hours}
onChange={(e) => onChange({ ...value, hours: e.currentTarget.value })}
/>
</Stack>
);
}

View File

@@ -0,0 +1,198 @@
import type { SupportDocSlug } from "@edr/types";
import {
Alert,
Badge,
Button,
Card,
Group,
Loader,
Modal,
Stack,
Text,
} from "@mantine/core";
import { AlertTriangle } from "lucide-react";
import { useState } from "react";
import {
usePortalDocVersion,
usePortalDocVersions,
useRestorePortalVersion,
} from "@/hooks/portal-content/usePortalContentAdmin";
import { Markdown } from "./Markdown";
import { summarizeVersion } from "./version-preview";
interface VersionHistoryModalProps {
slug: SupportDocSlug;
opened: boolean;
onClose: () => void;
/** True when the tab holds unsaved edits a restore would discard. */
hasUnsavedChanges: boolean;
onRestored: () => void;
}
function formatSavedAt(value: string): string {
return new Date(value).toLocaleString("en-GB", {
day: "numeric",
month: "short",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
/**
* Version history for one document. Restoring re-saves the old payload as a new
* version server-side, so the list only ever grows and a restore is itself
* undoable — there is nothing here that can destroy history.
*/
export function VersionHistoryModal({
slug,
opened,
onClose,
hasUnsavedChanges,
onRestored,
}: VersionHistoryModalProps) {
const { data: versions, isLoading } = usePortalDocVersions(slug, opened);
const [previewing, setPreviewing] = useState<number | null>(null);
const [confirming, setConfirming] = useState<number | null>(null);
const { data: preview } = usePortalDocVersion(slug, previewing);
const restore = useRestorePortalVersion(slug);
const close = () => {
setPreviewing(null);
setConfirming(null);
onClose();
};
const latest = versions?.[0]?.version;
return (
<Modal
opened={opened}
onClose={close}
size="xl"
title={`Version history — ${slug}`}
>
<Stack gap="md">
{hasUnsavedChanges && (
<Alert
color="orange"
icon={<AlertTriangle size={16} />}
title="Unsaved changes"
>
This tab has edits that have not been saved. Restoring a version
discards them.
</Alert>
)}
{isLoading && <Loader size="sm" />}
{versions?.map((version) => (
<Card key={version.id} withBorder padding="md" radius="md">
<Group justify="space-between" align="flex-start" wrap="nowrap">
<Stack gap={2}>
<Group gap="xs">
<Text fw={600}>v{version.version}</Text>
{version.version === latest && (
<Badge size="sm" variant="light">
Current
</Badge>
)}
<Text size="sm" c="dimmed">
{formatSavedAt(version.createdAt)}
</Text>
</Group>
{version.note && (
<Text size="sm" c="dimmed">
{version.note}
</Text>
)}
</Stack>
{confirming === version.version ? (
<Group gap="xs" wrap="nowrap">
<Text size="sm">Restore v{version.version}?</Text>
<Button
size="xs"
color="red"
loading={restore.isPending}
onClick={() =>
restore.mutate(version.version, {
onSuccess: () => {
onRestored();
close();
},
})
}
>
Confirm
</Button>
<Button
size="xs"
variant="subtle"
onClick={() => setConfirming(null)}
>
Cancel
</Button>
</Group>
) : (
<Group gap="xs" wrap="nowrap">
<Button
size="xs"
variant="light"
onClick={() =>
setPreviewing(
previewing === version.version ? null : version.version,
)
}
>
{previewing === version.version ? "Hide" : "Preview"}
</Button>
<Button
size="xs"
variant="subtle"
disabled={version.version === latest}
onClick={() => {
setConfirming(version.version);
setPreviewing(null);
}}
>
Restore
</Button>
</Group>
)}
</Group>
{previewing === version.version && (
<Card mt="sm" withBorder padding="sm" radius="sm" bg="gray.0">
{preview ? (
<Stack gap="sm">
{summarizeVersion(slug, preview.payload).map((entry, i) => (
<Stack key={`${entry.label}-${i}`} gap={2}>
<Text size="sm" fw={600}>
{entry.label}
</Text>
<Markdown>{entry.body}</Markdown>
</Stack>
))}
</Stack>
) : (
<Loader size="xs" />
)}
</Card>
)}
</Card>
))}
{versions?.length === 0 && (
<Text size="sm" c="dimmed">
No history yet.
</Text>
)}
</Stack>
</Modal>
);
}
export default VersionHistoryModal;

View File

@@ -0,0 +1,24 @@
/** Immutable list edits shared by the three payload editors. */
export function replaceAt<T>(items: T[], index: number, next: T): T[] {
return items.map((item, i) => (i === index ? next : item));
}
export function removeAt<T>(items: T[], index: number): T[] {
return items.filter((_, i) => i !== index);
}
/**
* Swaps an item with its neighbour. Out-of-range moves return the list
* unchanged, so the ▲/▼ buttons need no disabled-state bookkeeping of their own.
*/
export function moveAt<T>(items: T[], index: number, delta: number): T[] {
const target = index + delta;
if (target < 0 || target >= items.length) return items;
const next = [...items];
[next[index], next[target]] = [next[target], next[index]];
return next;
}
export const newId = () => crypto.randomUUID();

View File

@@ -0,0 +1,109 @@
/*
* Tailwind's preflight zeroes margins on `p`, strips `list-style` from `ul`/`ol`
* and flattens heading sizes. MDXEditor's own stylesheet assumes browser
* defaults, so inside this app its content area renders as one undifferentiated
* block — paragraphs run together and bullets lose their markers.
*
* This restores the handful of element styles the editor needs, scoped to its
* content area so nothing leaks back into the rest of the backoffice. It is a
* deliberate alternative to pulling in @tailwindcss/typography for one widget.
*/
.edr-md-content p {
margin: 0 0 0.75rem;
line-height: 1.6;
}
.edr-md-content p:last-child {
margin-bottom: 0;
}
.edr-md-content ul,
.edr-md-content ol {
margin: 0 0 0.75rem;
padding-left: 1.5rem;
}
.edr-md-content ul {
list-style: disc;
}
.edr-md-content ol {
list-style: decimal;
}
.edr-md-content li {
margin: 0.25rem 0;
line-height: 1.6;
}
/* Nested lists — the editor's indent button produces these. */
.edr-md-content li > ul,
.edr-md-content li > ol {
margin: 0.25rem 0 0;
}
.edr-md-content h1,
.edr-md-content h2,
.edr-md-content h3,
.edr-md-content h4 {
font-weight: 700;
line-height: 1.3;
margin: 1rem 0 0.5rem;
}
.edr-md-content h1 {
font-size: 1.5rem;
}
.edr-md-content h2 {
font-size: 1.25rem;
}
.edr-md-content h3 {
font-size: 1.1rem;
}
.edr-md-content h4 {
font-size: 1rem;
}
.edr-md-content strong {
font-weight: 600;
}
.edr-md-content em {
font-style: italic;
}
.edr-md-content a {
color: var(--mantine-color-blue-6);
text-decoration: underline;
}
.edr-md-content blockquote {
margin: 0 0 0.75rem;
padding-left: 0.75rem;
border-left: 3px solid var(--mantine-color-gray-3);
color: var(--mantine-color-dimmed);
}
.edr-md-content hr {
border: 0;
border-top: 1px solid var(--mantine-color-gray-3);
margin: 1rem 0;
}
.edr-md-content code {
font-family: var(--mantine-font-family-monospace);
font-size: 0.875em;
background: var(--mantine-color-gray-1);
padding: 0.05rem 0.25rem;
border-radius: 3px;
}
.edr-md-content img {
max-width: 100%;
height: auto;
border-radius: 8px;
}

View File

@@ -0,0 +1,79 @@
import type {
PortalFaqContent,
PortalHelpContent,
PortalLegalContent,
PortalSupportContact,
SupportDocPayload,
SupportDocSlug,
} from "@edr/types";
export interface PreviewEntry {
label: string;
/** Markdown, rendered read-only. */
body: string;
}
/**
* Flattens a stored payload into labelled markdown blocks for the history
* modal. An editor deciding whether to roll back needs to read the wording of
* that version — a raw JSON dump technically shows it, but not in a form
* anyone can compare legal prose in.
*/
export function summarizeVersion(
slug: SupportDocSlug,
payload: SupportDocPayload,
): PreviewEntry[] {
switch (slug) {
case "CONTACT": {
const contact = payload as PortalSupportContact;
return [
{ label: "Email", body: contact.email },
{ label: "Phone", body: contact.phone },
{ label: "Head office", body: contact.office },
{ label: "Support hours", body: contact.hours },
];
}
case "HELP": {
const help = payload as PortalHelpContent;
return [
{ label: "Title", body: help.title },
{ label: "Subtitle", body: help.subtitle },
...help.sections.map((section) => ({
label: section.heading,
body: section.media.length
? `${section.body}\n\n_${section.media.length} attachment${section.media.length === 1 ? "" : "s"}: ${section.media.map((m) => m.src).join(", ")}_`
: section.body,
})),
];
}
case "FAQ": {
const faq = payload as PortalFaqContent;
return [
{ label: "Title", body: faq.title },
...faq.groups.flatMap((group) =>
group.items.map((item) => ({
label: `${group.title}${item.question}`,
body: item.answer,
})),
),
...(faq.footer
? [{ label: faq.footer.heading, body: faq.footer.body }]
: []),
];
}
case "PRIVACY":
case "TERMS": {
const legal = payload as PortalLegalContent;
return [
{ label: "Last updated", body: legal.lastUpdated },
...legal.sections.map((section) => ({
label: section.heading,
body: section.body,
})),
];
}
}
}

View File

@@ -149,6 +149,8 @@ import {
import { containerTypesService } from "./container-types.service";
import { containerService, type Container } from "./containerService";
import { customersService } from "./customers.service";
import { eimsService } from "./eims.service";
import type { EimsInvoiceStatusView, EimsVerifyResult } from "@/types/eims";
import { invoicesService } from "./invoices.service";
import { dropdownSettingsService } from "./dropdownSettings.service";
import { fileUploadSettingsService } from "./fileUploadSettings.service";
@@ -2938,6 +2940,36 @@ export const api = {
// Settling the invoice also advances the booking, so refresh both trees.
() => [QUERY_KEYS.INVOICES.ROOT, QUERY_KEYS.BOOKINGS.ROOT],
),
eimsStatus: endpoint<{ id: string }, EimsInvoiceStatusView>(
"invoices",
"eimsStatus",
({ id }) => eimsService.status(id),
({ id }) => QUERY_KEYS.INVOICES.eimsStatus(id),
),
// Both mutations refresh the filing panel; register also moves the invoice's own row.
eimsRegister: endpoint<{ id: string }, EimsInvoiceStatusView>(
"invoices",
"eimsRegister",
({ id }) => eimsService.register(id),
undefined,
({ id }) => [QUERY_KEYS.INVOICES.eimsStatus(id), QUERY_KEYS.INVOICES.byId(id)],
),
eimsVerify: endpoint<{ id: string }, EimsVerifyResult>(
"invoices",
"eimsVerify",
({ id }) => eimsService.verify(id),
),
eimsResolve: endpoint<{ id: string; irn?: string; discard?: boolean }, EimsInvoiceStatusView>(
"invoices",
"eimsResolve",
({ id, irn, discard }) => eimsService.resolve(id, { irn, discard }),
undefined,
({ id }) => [QUERY_KEYS.INVOICES.eimsStatus(id), QUERY_KEYS.INVOICES.byId(id)],
),
},
overview: {

View File

@@ -0,0 +1,39 @@
import { api as apiClient } from "@/auth/http";
import { URL_CONSTANTS } from "@/constants/URLS";
import type { EimsInvoiceStatusView, EimsVerifyResult } from "@/types/eims";
/**
* MoR EIMS filing actions on an invoice.
*
* Registration is irreversible at the tax authority, so these are admin actions rather than part
* of the ordinary invoice screen: the normal production path is the API's cron sweep.
*/
export const eimsService = {
status(invoiceId: string): Promise<EimsInvoiceStatusView> {
return apiClient
.get<EimsInvoiceStatusView>(URL_CONSTANTS.EIMS.STATUS(invoiceId))
.then((r) => r.data);
},
register(invoiceId: string): Promise<EimsInvoiceStatusView> {
return apiClient
.post<EimsInvoiceStatusView>(URL_CONSTANTS.EIMS.REGISTER(invoiceId))
.then((r) => r.data);
},
verify(invoiceId: string): Promise<EimsVerifyResult> {
return apiClient
.post<EimsVerifyResult>(URL_CONSTANTS.EIMS.VERIFY(invoiceId))
.then((r) => r.data);
},
/** Record an IRN confirmed with MoR, or discard the attempt. Clears the system-wide block. */
resolve(
invoiceId: string,
input: { irn?: string; discard?: boolean },
): Promise<EimsInvoiceStatusView> {
return apiClient
.post<EimsInvoiceStatusView>(URL_CONSTANTS.EIMS.RESOLVE(invoiceId), input)
.then((r) => r.data);
},
};

View File

@@ -0,0 +1,98 @@
import type {
PortalMediaKind,
SupportDocPayload,
SupportDocSlug,
SupportDocumentDetail,
SupportDocVersionDetail,
SupportDocVersionSummary,
} from "@edr/types";
import { api as client } from "../auth/http";
const ROOT = "/support-content";
const BASE = `${ROOT}/documents`;
/**
* Customer-facing help/FAQ/legal copy for the freight portal. The client's
* response interceptor already unwraps the `{ success, data }` envelope, so
* every method is a one-liner.
*/
export const portalContentService = {
async getBySlug(slug: SupportDocSlug): Promise<SupportDocumentDetail> {
const { data } = await client.get<SupportDocumentDetail>(`${BASE}/${slug}`);
return data;
},
/**
* Replaces the document's whole payload. Whole-payload rather than per-field
* on purpose: one Save becomes exactly one version, which is what keeps the
* history list readable.
*/
async update(
slug: SupportDocSlug,
payload: SupportDocPayload,
note?: string,
): Promise<SupportDocumentDetail> {
const { data } = await client.patch<SupportDocumentDetail>(
`${BASE}/${slug}`,
{ payload, note },
);
return data;
},
async listVersions(slug: SupportDocSlug): Promise<SupportDocVersionSummary[]> {
const { data } = await client.get<SupportDocVersionSummary[]>(
`${BASE}/${slug}/versions`,
);
return data;
},
async getVersion(
slug: SupportDocSlug,
version: number,
): Promise<SupportDocVersionDetail> {
const { data } = await client.get<SupportDocVersionDetail>(
`${BASE}/${slug}/versions/${version}`,
);
return data;
},
/**
* Uploads an image or video and returns its object *key*. The key is what
* gets saved in the document; `url` is only for showing the editor a preview
* right now, and expires.
*/
async uploadMedia(
file: File,
): Promise<{ key: string; kind: PortalMediaKind; url: string }> {
const form = new FormData();
form.append("file", file);
const { data } = await client.post<{
key: string;
kind: PortalMediaKind;
url: string;
}>(`${ROOT}/media`, form);
return data;
},
/** Resolves one stored key to a temporary URL, for editor previews. */
async mediaUrl(key: string): Promise<string> {
const { data } = await client.get<{ url: string }>(`${ROOT}/media-url`, {
params: { key },
});
return data.url;
},
/** Re-saves an old payload as a new version — never destructive. */
async restore(
slug: SupportDocSlug,
version: number,
): Promise<SupportDocumentDetail> {
const { data } = await client.post<SupportDocumentDetail>(
`${BASE}/${slug}/versions/${version}/restore`,
{},
);
return data;
},
};

View File

@@ -0,0 +1,44 @@
/**
* MoR EIMS filing state for one invoice.
*
* Mirrors `EimsInvoiceStatusView` in the freight API (`modules/eims/eims-registration.types.ts`).
* Kept local rather than in `@edr/types` because only the backoffice reads it.
*/
export type EimsInvoiceStatus =
| "NOT_SUBMITTED"
| "SUBMITTING"
| "REGISTERED"
| "FAILED"
| "UNKNOWN";
/** Sanitized gateway failure: MoR's own error fields, never our signed envelope. */
export interface EimsInvoiceError {
kind: string;
message: string;
httpStatus?: number;
details?: Record<string, unknown>;
at: string;
}
export interface EimsInvoiceStatusView {
invoiceId: string;
invoiceNumber: string;
eimsStatus: EimsInvoiceStatus;
eimsIrn: string | null;
eimsInvoiceCounter: number | null;
eimsSubmittedAt: string | null;
/** MoR returns a Java ZonedDateTime string, stored verbatim — display as-is. */
eimsAckDate: string | null;
eimsLastError: EimsInvoiceError | null;
}
/** `POST /v1/verify` response, echoed back from the gateway. */
export interface EimsVerifyResult {
statusCode?: number;
message?: string;
body?: {
Irn?: string;
DocumentDetails?: { Type?: string; DocumentNumber?: string; Date?: string };
[section: string]: unknown;
};
}

View File

@@ -85,14 +85,15 @@ export function getNextInventoryAction(item: WarehouseInventoryItem): InventoryA
if (isImport) return inspected ? 'ready-for-pickup' : null;
return 'store';
case 'STORED':
// Reserve is retired: a stored export item goes straight to loading prep
// once inspection passes. An import item parked back into storage returns
// to pickup — otherwise Store would strand it with no action.
// Reserve is retired: a stored export item goes straight to loading prep.
// An import item parked back into storage returns to pickup — otherwise
// Store would strand it with no action.
if (isImport) return inspected ? 'ready-for-pickup' : null;
return inspected ? 'ready-for-loading' : null;
return 'ready-for-loading';
case 'RESERVED':
// Export loading is gated on a passed inspection.
return inspected ? 'ready-for-loading' : null;
// Export loading is not gated on inspection — inspection is tracked, but a
// train is never held waiting for it.
return 'ready-for-loading';
case 'READY_FOR_PICKUP':
// Issue the DO / release order first, then hand over the goods.
return item.releaseDate ? 'deliver' : 'release';