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

@@ -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>
);
}