Merge branch 'dev'

This commit is contained in:
Marshal
2026-08-12 06:44:30 +00:00
94 changed files with 5399 additions and 3583 deletions

View File

@@ -51,6 +51,8 @@ import { NO_ACCESS_PATH, resolveLandingPath } from "./lib/landing";
import NoAccessPage from "./pages/NoAccessPage";
import FileUploadSettingsPage from "./pages/documents/FileUploadSettingsPage";
import DropdownSettingsPage from "./pages/dropdown_settings/DropdownSettingsPage";
import StampSettings from "./record-management/components/Settings/uploadTeeterandSingature";
import InvoiceStampSettingsPage from "./pages/settings/InvoiceStampSettingsPage";
import ContractTemplatesPage from "./pages/contract_templates/ContractTemplatesPage";
import PortalContentPage from "./pages/portal_content/PortalContentPage";
import ContractTemplateEditorPage from "./pages/contract_templates/ContractTemplateEditorPage";
@@ -117,6 +119,20 @@ import {
findActiveSidebarLabel,
} from "@/components/layout/sidebar-sections";
/**
* The per-shipment clearance detail page is the shared destination of three
* hubs (Operations → Clearance, Clearance Documents, Self-Clearance Review),
* none of which are gated on `bookings:clearance_view`. Gating the detail on
* that key alone bounced reviewers back to their landing page (Bookings) the
* moment they opened a row, so accept any key that can reach a hub.
*/
const CLEARANCE_DETAIL_PERMS = [
FREIGHT_PERMS.bookings.clearanceView,
FREIGHT_PERMS.contracts.clearanceReview,
FREIGHT_PERMS.contracts.clearanceEtActions,
FREIGHT_PERMS.contracts.opsClearanceReview,
];
const DashboardShell = () => {
const navigate = useNavigate();
const location = useLocation();
@@ -286,9 +302,7 @@ const App = () => {
<Route
path="clearance/:id"
element={
<RequirePermission
permission={FREIGHT_PERMS.bookings.clearanceView}
>
<RequirePermission permission={CLEARANCE_DETAIL_PERMS}>
<DocumentClearanceDetailPage />
</RequirePermission>
}
@@ -322,9 +336,7 @@ const App = () => {
<Route
path="bookings/:bookingId/clearance"
element={
<RequirePermission
permission={FREIGHT_PERMS.bookings.clearanceView}
>
<RequirePermission permission={CLEARANCE_DETAIL_PERMS}>
<DocumentClearanceDetailPage />
</RequirePermission>
}
@@ -774,6 +786,24 @@ const App = () => {
</RequirePermission>
}
/>
<Route
path="stamp-settings"
element={
<RequirePermission permission={FREIGHT_PERMS.settings.stamp.view}>
<StampSettings />
</RequirePermission>
}
/>
<Route
path="invoice-stamp-settings"
element={
<RequirePermission
permission={FREIGHT_PERMS.settings.invoiceStamp.view}
>
<InvoiceStampSettingsPage />
</RequirePermission>
}
/>
<Route
path="contract-templates"
element={

View File

@@ -170,12 +170,13 @@ export function ContractCustomerCard({
/>
</SectionCard>
<SectionCard icon={User} title="General manager" accent="grape">
{/* Whoever the eTrade licence names as the business's manager. */}
<SectionCard icon={User} title="Owner" accent="grape">
<InfoRows
rows={[
{ icon: User, label: "Name", value: company.generalManagerName },
{ icon: Mail, label: "Email", value: company.generalManagerEmail },
{ icon: Phone, label: "Phone", value: company.generalManagerPhone },
{ icon: User, label: "Name", value: company.ownerName },
{ icon: Mail, label: "Email", value: company.ownerEmail },
{ icon: Phone, label: "Phone", value: company.ownerPhone },
]}
/>
</SectionCard>

View File

@@ -43,9 +43,17 @@ export const FIELD_LABELS: Record<string, string> = {
contactPersonPosition: "Contact position",
contactPersonEmail: "Contact email",
contactPersonPhone: "Contact phone",
generalManagerName: "General manager",
generalManagerEmail: "GM email",
generalManagerPhone: "GM phone",
ownerName: "Owner name",
ownerEmail: "Owner email",
ownerPhone: "Owner phone",
poaDeclared: "Has a Power of Attorney",
poaPassportNumber: "PoA passport number",
// Nothing writes these any more — the general manager was removed — but
// change requests filed before that still carry them, and without a label
// the reviewer sees a raw attribute key.
generalManagerName: "General manager (retired)",
generalManagerEmail: "GM email (retired)",
generalManagerPhone: "GM phone (retired)",
poaName: "PoA name",
poaPhone: "PoA phone",
poaEmail: "PoA email",
@@ -79,9 +87,9 @@ export function currentValue(company: Company, key: string): string {
nationality: c.nationality,
contactPersonName: c.contactPersonName ?? attrs.contactPersonName,
contactPersonPhone: c.contactPersonPhone ?? attrs.contactPersonPhone,
generalManagerName: c.generalManagerName ?? attrs.generalManagerName,
generalManagerEmail: c.generalManagerEmail ?? attrs.generalManagerEmail,
generalManagerPhone: c.generalManagerPhone ?? attrs.generalManagerPhone,
ownerName: c.ownerName ?? attrs.ownerName,
ownerEmail: c.ownerEmail ?? attrs.ownerEmail,
ownerPhone: c.ownerPhone ?? attrs.ownerPhone,
};
const v = key in map ? map[key] : (c[key] ?? attrs[key]);
return v === null || v === undefined || v === "" ? "—" : String(v);

View File

@@ -485,6 +485,18 @@ export const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[]
icon: <Settings />,
permission: FREIGHT_PERMS.settings.dropdown.view,
},
{
label: "Stamp settings",
href: "/dashboard/stamp-settings",
icon: <FileSignature />,
permission: FREIGHT_PERMS.settings.stamp.view,
},
{
label: "Invoice stamp",
href: "/dashboard/invoice-stamp-settings",
icon: <Receipt />,
permission: FREIGHT_PERMS.settings.invoiceStamp.view,
},
{
label: "Contract templates",
href: "/dashboard/contract-templates",

View File

@@ -1,11 +1,10 @@
import { useState } from "react";
import { FileSignature, Loader2, Stamp } from "lucide-react";
import { FileSignature, Loader2 } from "lucide-react";
import { useMutation, useQuery } from "@tanstack/react-query";
import toast from "react-hot-toast";
import { api } from "@/services/api";
import { ContractSignaturePad } from "@/components/bookings/ContractSignaturePad";
import { StampUpload } from "@/components/contracts/StampUpload";
import {
Card,
CardContent,
@@ -27,9 +26,13 @@ import {
} from "@edr/ui-common";
/**
* Lets the signed-in user view and update the reusable signature and company
* stamp stored on their profile — managed independently of each other. Both
* are offered when signing a booking contract.
* Lets the signed-in user view and update the reusable signature stored on
* their profile, offered for approval when signing a contract.
*
* Signature only — there is no per-employee stamp. EDR seals with ONE global
* company stamp, managed under Settings and applied server-side, so a staff
* member never uploads or picks a stamp. (Customers do upload their own, in
* the portal — that is a different card.)
*/
export function MySignatureCard() {
const { user } = useAuth();
@@ -39,10 +42,8 @@ export function MySignatureCard() {
const saveMutation = useMutation(api.signatures.save.mutationOptions());
const [signatureOpen, setSignatureOpen] = useState(false);
const [stampOpen, setStampOpen] = useState(false);
const [signerName, setSignerName] = useState("");
const [signatureData, setSignatureData] = useState<string | null>(null);
const [stampData, setStampData] = useState<string | null>(null);
const defaultName =
user?.name?.en || user?.username || user?.email || "";
@@ -60,7 +61,6 @@ export function MySignatureCard() {
{
signerDisplayName: signerName.trim(),
signatureImageBase64: signatureData,
// Stamp untouched — it is managed by its own dialog.
},
{
onSuccess: () => {
@@ -72,38 +72,16 @@ export function MySignatureCard() {
);
};
const openStampDialog = () => {
setStampData(saved?.stampImageUrl ?? null);
setStampOpen(true);
};
const saveStamp = () => {
if (!stampData) return;
saveMutation.mutate(
{
signerDisplayName: savedName || defaultName,
// Signature untouched — stamp-only update.
stampImageBase64: stampData,
},
{
onSuccess: () => {
toast.success("Stamp saved");
setStampOpen(false);
},
onError: () => toast.error("Failed to save stamp"),
},
);
};
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FileSignature className="size-4" />
Signature &amp; Stamp
Signature
</CardTitle>
<CardDescription>
This signature can be reused to sign booking contracts.
This signature can be reused to sign booking contracts. The EDR
company stamp is applied automatically you do not upload one.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
@@ -112,54 +90,29 @@ export function MySignatureCard() {
<Loader2 className="size-6 animate-spin text-primary" />
</div>
) : (
<>
<div className="space-y-2">
{saved?.signatureImageUrl ? (
<>
<div className="overflow-hidden rounded-lg border-2 border-dashed border-border bg-white">
<img
src={saved.signatureImageUrl}
alt="My saved signature"
className="mx-auto h-36 w-full object-contain"
/>
</div>
<p className="text-xs text-muted-foreground">
Saved as {saved.signerDisplayName}
</p>
</>
) : (
<p className="text-sm text-muted-foreground">
You have not saved a signature yet.
<div className="space-y-2">
{saved?.signatureImageUrl ? (
<>
<div className="overflow-hidden rounded-lg border-2 border-dashed border-border bg-white">
<img
src={saved.signatureImageUrl}
alt="My saved signature"
className="mx-auto h-36 w-full object-contain"
/>
</div>
<p className="text-xs text-muted-foreground">
Saved as {saved.signerDisplayName}
</p>
)}
<Button variant="outline" size="sm" onClick={openSignatureDialog}>
{saved?.signatureImageUrl ? "Update signature" : "Add signature"}
</Button>
</div>
<div className="space-y-2">
{saved?.stampImageUrl ? (
<>
<div className="overflow-hidden rounded-lg border-2 border-dashed border-border bg-white">
<img
src={saved.stampImageUrl}
alt="My saved company stamp"
className="mx-auto h-24 w-full object-contain"
/>
</div>
<p className="text-xs text-muted-foreground">Company stamp</p>
</>
) : (
<p className="text-sm text-muted-foreground">
You have not uploaded a company stamp yet.
</p>
)}
<Button variant="outline" size="sm" onClick={openStampDialog}>
<Stamp className="size-4" />
{saved?.stampImageUrl ? "Update stamp" : "Upload stamp"}
</Button>
</div>
</>
</>
) : (
<p className="text-sm text-muted-foreground">
You have not saved a signature yet.
</p>
)}
<Button variant="outline" size="sm" onClick={openSignatureDialog}>
{saved?.signatureImageUrl ? "Update signature" : "Add signature"}
</Button>
</div>
)}
</CardContent>
@@ -203,39 +156,6 @@ export function MySignatureCard() {
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={stampOpen} onOpenChange={setStampOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Company stamp</DialogTitle>
<DialogDescription>
Upload your official company stamp or seal as an image. It is
stored on your profile and applied next to your signature on
contracts.
</DialogDescription>
</DialogHeader>
<StampUpload
value={stampData}
onChange={setStampData}
description="Stored on your profile and prefilled when you sign contracts."
/>
<DialogFooter>
<Button variant="outline" onClick={() => setStampOpen(false)}>
Cancel
</Button>
<Button
disabled={saveMutation.isPending || !stampData}
onClick={saveStamp}
>
{saveMutation.isPending ? (
<Loader2 className="size-4 animate-spin" />
) : (
"Save stamp"
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</Card>
);
}

View File

@@ -0,0 +1,45 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { stampSettingsService } from "@/services/stampSettings.service";
import { useErrorHandler } from "@/shared/hooks/useErrorHandler";
const QUERY_KEY = ["stampSettings"];
export const useStampSettingsQuery = () =>
useQuery({
queryKey: QUERY_KEY,
queryFn: () => stampSettingsService.get(),
});
export const useSetStamp = () => {
const queryClient = useQueryClient();
const { t } = useTranslation();
const { handleError } = useErrorHandler(t);
return useMutation({
mutationFn: (stampImageBase64: string) =>
stampSettingsService.set(stampImageBase64),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: QUERY_KEY });
toast.success(t("stampSettings.updated", "Company stamp updated"));
},
onError: handleError,
});
};
export const useClearStamp = () => {
const queryClient = useQueryClient();
const { t } = useTranslation();
const { handleError } = useErrorHandler(t);
return useMutation({
mutationFn: () => stampSettingsService.clear(),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: QUERY_KEY });
toast.success(t("stampSettings.cleared", "Company stamp removed"));
},
onError: handleError,
});
};

View File

@@ -328,6 +328,16 @@ export const FREIGHT_PERMS = {
view: "edr_freight_app:settings:dropdown:view",
manage: "edr_freight_app:settings:dropdown:manage",
},
stamp: {
view: "edr_freight_app:settings:stamp:view",
manage: "edr_freight_app:settings:stamp:manage",
},
// Company stamp/seal image stamped onto invoice/receipt PDFs — separate
// from `stamp` above, which is the per-employee approval-record teeter.
invoiceStamp: {
view: "edr_freight_app:settings:invoice_stamp:view",
manage: "edr_freight_app:settings:invoice_stamp:manage",
},
exchangeRate: {
view: "edr_freight_app:settings:exchange_rate:view",
manage: "edr_freight_app:settings:exchange_rate:manage",

View File

@@ -12,7 +12,6 @@ import toast from "react-hot-toast";
import Breadcrumbs from "@/components/ui/Breadcrumbs";
import { ContractSignaturePad } from "@/components/bookings/ContractSignaturePad";
import { StampUpload } from "@/components/contracts/StampUpload";
import { bookingSurface } from "@/components/bookings/booking-ui.styles";
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
import { invalidateBookingDetail } from "@/utils/queryInvalidation";
@@ -41,9 +40,6 @@ export default function BookingContractPage() {
const [signOpen, setSignOpen] = useState(false);
const [signerName, setSignerName] = useState("");
const [signatureData, setSignatureData] = useState<string | null>(null);
// Company stamp: prefilled from the profile, or uploaded here when none is
// saved yet.
const [stampData, setStampData] = useState<string | null>(null);
// When the user has a saved signature we offer it for approval first; they
// can switch to drawing a fresh one.
const [drawNew, setDrawNew] = useState(false);
@@ -59,7 +55,6 @@ export default function BookingContractPage() {
const savedSignature = data?.savedSignature ?? null;
const savedSignatureImage = savedSignature?.signatureImageUrl ?? null;
const savedStampImage = savedSignature?.stampImageUrl ?? null;
// Show the approval view only while a saved signature exists and the user
// hasn't opted to draw a new one.
const usingSaved = Boolean(savedSignatureImage) && !drawNew;
@@ -103,8 +98,6 @@ export default function BookingContractPage() {
// approve it; otherwise start with an empty pad.
setSignerName(savedSignature?.signerDisplayName ?? "");
setSignatureData(null);
// Prefill with the reusable stamp saved on the profile; still replaceable.
setStampData(savedStampImage);
setDrawNew(false);
setSignOpen(true);
};
@@ -113,12 +106,12 @@ export default function BookingContractPage() {
if (!canSign || !signerName.trim()) return;
// Approve the saved signature, or submit the freshly drawn one.
const image = usingSaved ? savedSignatureImage : signatureData;
// The API rejects a STAFF signature without a stamp.
if (!image || !stampData) return;
if (!image) return;
// No stamp is sent: EDR's seal is the ONE global company stamp, applied
// server-side at render time (see StampSettingsService).
signMutation.mutate({
role: "STAFF",
signatureImageBase64: image,
stampImageBase64: stampData,
signerDisplayName: signerName.trim(),
consentText: "I agree to the terms of this contract.",
});
@@ -243,15 +236,6 @@ export default function BookingContractPage() {
) : (
<ContractSignaturePad onChange={setSignatureData} />
)}
<StampUpload
value={stampData}
onChange={setStampData}
description={
savedStampImage
? "Your saved company stamp — replace it for this contract if needed."
: "Required. Attach your official company stamp or seal."
}
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setSignOpen(false)}>
@@ -261,7 +245,6 @@ export default function BookingContractPage() {
disabled={
signMutation.isPending ||
(!usingSaved && !signatureData) ||
!stampData ||
!signerName.trim()
}
onClick={confirmSign}

View File

@@ -18,7 +18,6 @@ import toast from "react-hot-toast";
import { ContractSignSuccessModal } from "@/components/contracts/ContractSignSuccessModal";
import { ContractSignaturePad } from "@/components/bookings/ContractSignaturePad";
import { StampUpload } from "@/components/contracts/StampUpload";
import { contractsService } from "@/services/contracts.service";
import { extractApiError } from "@/utils/result";
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
@@ -40,7 +39,6 @@ export default function ContractViewPage() {
const [successOpen, setSuccessOpen] = useState(false);
const [signerName, setSignerName] = useState("");
const [signatureData, setSignatureData] = useState<string | null>(null);
const [stampData, setStampData] = useState<string | null>(null);
const [drawNew, setDrawNew] = useState(false);
const { data, isLoading, isError, refetch } = useQuery({
@@ -56,10 +54,11 @@ export default function ContractViewPage() {
mutationFn: () =>
contractsService.signContract(id!, {
role: "STAFF",
// No stamp is sent: EDR's seal is the ONE global company stamp, applied
// server-side from StampSettingsService when the signature is stored.
signatureImageBase64: usingSaved
? (savedSignatureImage as string)
: (signatureData ?? ""),
stampImageBase64: stampData ?? "",
signerDisplayName: signerName.trim(),
consentText: "I confirm this contract on behalf of EDR.",
}),
@@ -98,14 +97,12 @@ export default function ContractViewPage() {
const openSign = () => {
setSignerName(data?.savedSignature?.signerDisplayName ?? "");
setSignatureData(null);
// Prefill with the reusable stamp saved on the profile; still replaceable.
setStampData(data?.savedSignature?.stampImageUrl ?? null);
setDrawNew(false);
setSignOpen(true);
};
const confirmSign = () => {
if (!signerName.trim() || !stampData) return;
if (!signerName.trim()) return;
const image = usingSaved ? savedSignatureImage : signatureData;
if (!image) return;
signMutation.mutate();
@@ -243,13 +240,6 @@ export default function ContractViewPage() {
<ContractSignaturePad onChange={setSignatureData} />
)}
<StampUpload
value={stampData}
onChange={setStampData}
label="EDR company stamp"
description="Attach the official EDR stamp or seal — it is applied to the contract next to the signature."
/>
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={() => setSignOpen(false)}>
Cancel
@@ -260,8 +250,7 @@ export default function ContractViewPage() {
disabled={
signMutation.isPending ||
!signerName.trim() ||
(!usingSaved && !signatureData) ||
!stampData
(!usingSaved && !signatureData)
}
onClick={confirmSign}
>

View File

@@ -17,6 +17,7 @@ import {
Text,
} from "@mantine/core";
import {
AlertTriangle,
ArrowLeft,
ArrowRight,
Banknote,
@@ -638,8 +639,9 @@ export default function CustomerDetailPage() {
const hasPoaDetails = poaFields.some((f) => f.value?.trim());
// Shared with the portal (buildCompanyIdentityState) — same derivation, so
// this page can never disagree with the rule the API actually enforces.
const ownerIdentity = company?.identity?.owner;
const poaIdentity = company?.identity?.poa;
const identityState = company?.identity;
const ownerIdentity = identityState?.owner;
const poaIdentity = identityState?.poa;
const hasEtradeRecord = Boolean(company?.licenceNumber?.trim());
// A freight forwarder acts on other companies' behalf, so its PoA — details
// and DARS delegation paper both — is mandatory rather than optional.
@@ -647,7 +649,7 @@ export default function CustomerDetailPage() {
(p) => p.type === "freight_forwarder",
);
const delegationMissing =
(hasPoaDetails || poaMandatory) && poaLive.length === 0;
company?.identity?.poaDeclared === "yes" && poaLive.length === 0;
if (isLoading) {
return (
@@ -820,6 +822,16 @@ export default function CustomerDetailPage() {
: undefined
}
/>
{/* Why this company's registration was typed rather than
fetched, and why it carries no business licence. */}
<InfoField
label="Registration"
value={
company.cooperative
? "Co-operative union / farm (no trade licence)"
: "eTrade trade licence"
}
/>
<InfoField label="Address" value={company.address} />
<InfoField label="Website" value={company.website} />
<InfoField label="Email" value={company.email} />
@@ -834,18 +846,9 @@ export default function CustomerDetailPage() {
value={company.contactPersonPhone}
/>
<Box />
<InfoField
label="General manager"
value={company.generalManagerName}
/>
<InfoField
label="GM email"
value={company.generalManagerEmail}
/>
<InfoField
label="GM phone"
value={company.generalManagerPhone}
/>
<InfoField label="Owner" value={company.ownerName} />
<InfoField label="Owner email" value={company.ownerEmail} />
<InfoField label="Owner phone" value={company.ownerPhone} />
</SimpleGrid>
</Stack>
</Card>
@@ -912,6 +915,11 @@ export default function CustomerDetailPage() {
<Text fw={600} c="edr-text">
Owner identity
</Text>
{identityState?.subject === "owner" && (
<Badge size="sm" color="blue" variant="light">
Verifies for this company
</Badge>
)}
{ownerIdentity?.verified ? (
<Badge size="sm" color="edr-green" variant="light">
Fayda verified
@@ -922,6 +930,41 @@ export default function CustomerDetailPage() {
</Badge>
)}
</Group>
{/* THE check: is the owner the company put forward the person
the eTrade licence actually names? Advisory — eTrade and
Fayda transliterate Amharic names differently, so this is a
prompt to look, not a verdict. */}
{identityState?.ownerMatchesEtrade === false ? (
<Alert
color="amber"
variant="light"
icon={<AlertTriangle size={16} />}
title="Does not match the eTrade licence"
>
The licence names{" "}
<strong>{identityState.etradeManagerName}</strong>, but this
company recorded <strong>{company.ownerName}</strong>.
</Alert>
) : identityState?.ownerMatchesEtrade === true ? (
<Badge
size="sm"
color="edr-green"
variant="light"
style={{ alignSelf: "flex-start" }}
>
Matches the eTrade licence
</Badge>
) : company.cooperative ? (
<Text size="xs" c="dimmed">
A co-operative union or farm holds no trade licence, so
there is no eTrade record to check the owner against.
</Text>
) : (
<Text size="xs" c="dimmed">
No eTrade manager name on file to compare against.
</Text>
)}
{ownerIdentity?.verified ? (
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="lg">
<InfoField label="Name" value={ownerIdentity.name} />

View File

@@ -0,0 +1,88 @@
import { useEffect, useState } from "react";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/shared/common/ui/card";
import { Button } from "@/shared/common/ui/button";
import { Save, Trash2 } from "lucide-react";
import { StampUpload } from "@/components/contracts/StampUpload";
import {
useClearStamp,
useSetStamp,
useStampSettingsQuery,
} from "@/hooks/useStampSettings";
/**
* The one company stamp/seal stamped onto every generated invoice/receipt
* PDF (InvoiceDocumentService). Single global image — no per-employee choice.
*/
export default function InvoiceStampSettingsPage() {
const { data, isLoading } = useStampSettingsQuery();
const setStamp = useSetStamp();
const clearStamp = useClearStamp();
const [draft, setDraft] = useState<string | null>(null);
useEffect(() => {
setDraft(null);
}, [data?.stampImageUrl]);
const value = draft !== null ? draft : (data?.stampImageUrl ?? null);
const dirty = draft !== null && draft !== data?.stampImageUrl;
const handleSave = async () => {
if (!draft) return;
await setStamp.mutateAsync(draft);
};
const handleClear = async () => {
if (!data?.stampImageUrl) return;
await clearStamp.mutateAsync();
};
return (
<div className="p-4 w-full max-w-screen-sm mx-auto">
<Card className="shadow-lg border-gray-200 dark:border-gray-700">
<CardHeader>
<CardTitle>Invoice stamp</CardTitle>
<CardDescription>
Stamped onto every generated invoice and receipt PDF. Replacing it
here changes it everywhere at once there is no per-invoice or
per-user choice.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<StampUpload
value={isLoading ? null : value}
onChange={setDraft}
label="Company stamp"
description="Shown on every invoice/receipt PDF in place of the plain seal."
/>
<div className="flex items-center gap-2">
<Button
onClick={handleSave}
disabled={!dirty || setStamp.isPending}
>
<Save className="mr-2 h-4 w-4" />
Save
</Button>
{data?.stampImageUrl && !dirty && (
<Button
variant="outline"
onClick={handleClear}
disabled={clearStamp.isPending}
>
<Trash2 className="mr-2 h-4 w-4" />
Remove
</Button>
)}
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -609,10 +609,18 @@ const UploadTeeterAndSignature = () => {
)}
</TabsContent>
{/* Teeter Tab */}
{/* Teeter Tab — single active stamp only: remove the current one to upload a replacement. */}
<TabsContent value="teeter" className="p-4 space-y-6">
{teeters.length > 0 && (
<div className="space-y-6">
{teeters.length > 1 && (
<p className="rounded border border-amber-200 bg-amber-50 p-2 text-sm text-amber-700 dark:border-amber-900 dark:bg-amber-950 dark:text-amber-300">
{t(
"signatureUpload.multipleStampsWarning",
"Only one stamp is allowed. Remove the extras below to keep a single active stamp.",
)}
</p>
)}
{teeters.map(({ id, url }) => (
<div key={id} className="space-y-3">
<p className="text-sm text-gray-600 dark:text-gray-300">
@@ -635,6 +643,7 @@ const UploadTeeterAndSignature = () => {
</div>
)}
{teeters.length === 0 && (
<div className="border-2 border-dashed border-primary-300 dark:border-primary-600 rounded-lg p-6 text-center space-y-4">
{!stampBlocks && !showLanguagePicker && (
<Button
@@ -792,6 +801,7 @@ const UploadTeeterAndSignature = () => {
</>
)}
</div>
)}
</TabsContent>
</Tabs>

View File

@@ -111,11 +111,14 @@ export interface ConsolidationDetails {
splitBilling: { bookingShare: number; partnerShare: number } | null;
}
/**
* No stamp field: the backoffice only ever signs as STAFF, and EDR's seal is
* the ONE global company stamp applied server-side. Customer stamps are posted
* from the portal, not here.
*/
export interface SignContractPayload {
role: "CUSTOMER" | "STAFF";
signatureImageBase64: string;
/** Company stamp/seal image; the API requires one for CUSTOMER and STAFF. */
stampImageBase64?: string;
signerDisplayName: string;
consentText?: string;
}

View File

@@ -118,11 +118,14 @@ export interface ContractView {
} | null;
}
/**
* No stamp field: the backoffice only ever counter-signs as STAFF, and EDR's
* seal is the ONE global company stamp, snapshotted server-side from
* StampSettingsService when the signature is stored.
*/
export interface SignContractPayload {
role: "CUSTOMER" | "STAFF";
signatureImageBase64: string;
/** Company stamp/seal image; required to sign a contract. */
stampImageBase64?: string;
signerDisplayName: string;
consentText?: string;
}

View File

@@ -24,7 +24,7 @@ const cleanParams = (params: object) =>
),
);
/** Lift attributes JSONB into the flat contact/manager fields the UI reads. */
/** Lift attributes JSONB into the flat contact/owner fields the UI reads. */
function mapCompany(dto: Record<string, unknown>): Company {
const attrs = (dto.attributes as Record<string, unknown> | null) ?? {};
return {
@@ -32,9 +32,9 @@ function mapCompany(dto: Record<string, unknown>): Company {
companyProfiles: (dto.companyProfiles as Company["companyProfiles"]) ?? [],
contactPersonName: (attrs.contactPersonName as string | null) ?? null,
contactPersonPhone: (attrs.contactPersonPhone as string | null) ?? null,
generalManagerName: (attrs.generalManagerName as string | null) ?? null,
generalManagerEmail: (attrs.generalManagerEmail as string | null) ?? null,
generalManagerPhone: (attrs.generalManagerPhone as string | null) ?? null,
ownerName: (attrs.ownerName as string | null) ?? null,
ownerEmail: (attrs.ownerEmail as string | null) ?? null,
ownerPhone: (attrs.ownerPhone as string | null) ?? null,
poaName: (attrs.poaName as string | null) ?? null,
poaEmail: (attrs.poaEmail as string | null) ?? null,
poaPhone: (attrs.poaPhone as string | null) ?? null,

View File

@@ -9,12 +9,16 @@ export interface SavedSignature {
stampImageUrl?: string | null;
}
/**
* Signature only. A backoffice employee has no personal stamp — EDR seals with
* the one global company stamp managed under Settings — so the stamp half of
* PUT /me/signature is deliberately not exposed here, even though the shared
* endpoint still accepts it for portal (customer) users.
*/
export interface SaveSignaturePayload {
signerDisplayName: string;
/** Omit to keep the existing saved signature (stamp-only update). */
/** Omit to keep the existing saved signature. */
signatureImageBase64?: string;
/** Omit to keep the existing saved stamp. */
stampImageBase64?: string;
}
export const signaturesService = {

View File

@@ -0,0 +1,31 @@
import { api as client } from "../auth/http";
import { unwrap } from "@/utils/endpoint";
import type { ApiResponse } from "@/types/apiResponse";
const BASE = "/stamp-settings";
/** Company stamp/seal used on generated invoice/receipt PDFs. */
export interface StampSettings {
stampImageUrl: string | null;
updatedById: string | null;
updatedAt: string | null;
}
export const stampSettingsService = {
get: async (): Promise<StampSettings> => {
const response = await client.get<ApiResponse<StampSettings>>(BASE);
return unwrap(response.data);
},
set: async (stampImageBase64: string): Promise<StampSettings> => {
const response = await client.put<ApiResponse<StampSettings>>(BASE, {
stampImageBase64,
});
return unwrap(response.data);
},
clear: async (): Promise<StampSettings> => {
const response = await client.delete<ApiResponse<StampSettings>>(BASE);
return unwrap(response.data);
},
};

View File

@@ -64,9 +64,9 @@ export interface BookingCompany {
email?: string | null;
contactPersonName?: string | null;
contactPersonPhone?: string | null;
generalManagerName?: string | null;
generalManagerEmail?: string | null;
generalManagerPhone?: string | null;
ownerName?: string | null;
ownerEmail?: string | null;
ownerPhone?: string | null;
website?: string | null;
}

View File

@@ -179,23 +179,34 @@ export interface IdentityVerificationState {
verifiedAt: string | null;
birthdate: string | null;
gender: string | null;
}
/** Mirrors `OwnerIdentityStateDto`. */
export interface OwnerIdentityState extends IdentityVerificationState {
/** Typed passport number — the foreign-company alternative to Fayda. */
passportNumber: string | null;
}
/**
* Owner/PoA Fayda verification, shared with the portal's derivation
* The company's single identity verification, shared with the portal's derivation
* (`buildCompanyIdentityState`) so backoffice never re-derives — or
* disagrees with — the rule the API actually enforces.
*/
export interface CompanyIdentityState {
faydaRequired: boolean;
passportRequired: boolean;
owner: OwnerIdentityState;
/** Foreign company: a passport number proves the person as Fayda would. */
passportAccepted: boolean;
/** Whether the company named a representative. Null = never answered. */
poaDeclared: "yes" | "no" | null;
/** Whose verification the company is gated on — PoA if declared, else owner. */
subject: "owner" | "poa" | null;
owner: IdentityVerificationState;
poa: IdentityVerificationState;
identityProven: boolean;
/** The manager named on the eTrade licence, captured at lookup. */
etradeManagerName: string | null;
/**
* Does the owner the company put forward match the eTrade licence?
* THE reviewer check. Null when there is nothing to compare. Advisory —
* eTrade and Fayda transliterate Amharic names differently, so a `false` is
* "look at this", not "reject this".
*/
ownerMatchesEtrade: boolean | null;
complete: boolean;
}
@@ -211,14 +222,21 @@ export interface Company {
fanNumber?: string | null;
country: string;
nationality?: CompanyNationality | null;
/**
* A co-operative union or farm: a TIN but no trade licence, so its
* registration was typed rather than fetched from eTrade, there is no eTrade
* manager to check the owner against, and it holds no freight-forwarder role.
*/
cooperative?: boolean;
address?: string | null;
phone?: string | null;
email?: string | null;
contactPersonName?: string | null;
contactPersonPhone?: string | null;
generalManagerName?: string | null;
generalManagerEmail?: string | null;
generalManagerPhone?: string | null;
/** The owner — whoever the eTrade licence names as the business's manager. */
ownerName?: string | null;
ownerEmail?: string | null;
ownerPhone?: string | null;
poaName?: string | null;
poaEmail?: string | null;
poaPhone?: string | null;