mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 22:25:42 +00:00
chnages
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
@@ -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],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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")}
|
||||
/>
|
||||
|
||||
@@ -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'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()}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
})),
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
39
apps/edr-freight-web/backoffice/src/services/eims.service.ts
Normal file
39
apps/edr-freight-web/backoffice/src/services/eims.service.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
44
apps/edr-freight-web/backoffice/src/types/eims.ts
Normal file
44
apps/edr-freight-web/backoffice/src/types/eims.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user