Updated signature handling, added booking detail components, introduced a signature management page, updated routing, enabled document downloads, and simplified the profile page.

This commit is contained in:
Marshal
2026-06-17 01:02:09 +00:00
parent e65addc619
commit c76f8648a9
12 changed files with 222 additions and 22 deletions

View File

@@ -1,7 +1,9 @@
import { Injectable } from '@nestjs/common';
import { Readable } from 'stream';
import { DataSource } from 'typeorm';
import { FilesService } from '../files/files.service';
import { FileRecord } from '../files/entities/file.entity';
import { MinioService } from '../minio/minio.service';
import { SignaturesRepository } from './signatures.repository';
import { SavedSignature } from './entities/saved-signature.entity';
@@ -19,6 +21,7 @@ export class SignaturesService {
private readonly signaturesRepository: SignaturesRepository,
private readonly filesService: FilesService,
private readonly minioService: MinioService,
private readonly dataSource: DataSource,
) {}
/** Saved signature for a user, with the image inlined as a data URL (or null). */
@@ -47,18 +50,32 @@ export class SignaturesService {
path: '',
};
const fileRecord = await this.filesService.upsertByCode({
// Capture the previously referenced file so we can remove it only AFTER the
// saved_signatures row is repointed — deleting it first would violate the
// FK constraint (saved_signatures.signature_file_id -> files.id).
const existing = await this.signaturesRepository.findByUserId(input.userId);
const previousFileId = existing?.signatureFileId ?? null;
const fileRecord = await this.filesService.upload({
resourceId: input.userId,
resource: 'saved_signatures',
code: 'signature',
file,
});
return this.signaturesRepository.upsert({
const saved = await this.signaturesRepository.upsert({
userId: input.userId,
signerDisplayName: input.signerDisplayName,
signatureFileId: fileRecord.id,
});
if (previousFileId && previousFileId !== fileRecord.id) {
await this.dataSource
.getRepository(FileRecord)
.delete({ id: previousFileId });
}
return saved;
}
private async inlineImageUrl(

View File

@@ -0,0 +1,102 @@
import type { LucideIcon } from "lucide-react";
import {
Building2,
FileCheck,
Mail,
MapPin,
Phone,
User,
} from "lucide-react";
import { Group, Stack, Text, Divider } from "@mantine/core";
import type { BookingDetail } from "@/types/booking";
import { SectionCard } from "./SectionCard";
interface InfoRowProps {
icon: LucideIcon;
label: string;
value?: string | null;
}
function InfoRow({ icon: Icon, label, value }: InfoRowProps) {
return (
<Group justify="space-between" wrap="nowrap" py={6} gap="md">
<Group gap="xs" wrap="nowrap">
<Icon size={15} color="var(--mantine-color-gray-5)" />
<Text size="sm" c="dimmed">
{label}
</Text>
</Group>
<Text size="sm" fw={600} ta="right" style={{ minWidth: 0 }} truncate>
{value || "—"}
</Text>
</Group>
);
}
export interface BookingCompanyCardProps {
booking: BookingDetail;
}
/** Customer (company) information for the booking. */
export function BookingCompanyCard({ booking }: BookingCompanyCardProps) {
const company = booking.company;
// Government bookings may not carry a company; show the institution instead.
if (!company && booking.isGovernment) {
return (
<SectionCard icon={Building2} title="Customer" accent="blue">
<InfoRow
icon={Building2}
label="Government"
value={booking.governmentInstitution}
/>
</SectionCard>
);
}
if (!company) {
return (
<SectionCard icon={Building2} title="Customer" accent="blue">
<Text size="sm" c="dimmed">
No customer linked to this booking.
</Text>
</SectionCard>
);
}
const companyName = company.companyName ?? company.name ?? company.label;
const rows: InfoRowProps[] = [
{ icon: FileCheck, label: "TIN", value: company.tin },
{ icon: Mail, label: "Email", value: company.email },
{ icon: Phone, label: "Phone", value: company.phone },
{ icon: MapPin, label: "Address", value: company.address },
{ icon: User, label: "Contact person", value: company.contactPersonName },
{ icon: Phone, label: "Contact phone", value: company.contactPersonPhone },
].filter((r) => r.value);
return (
<SectionCard
icon={Building2}
title="Customer"
subtitle={companyName}
accent="blue"
>
<Stack gap={0}>
{rows.length === 0 ? (
<Text size="sm" c="dimmed">
No additional company details available.
</Text>
) : (
rows.map((row, index) => (
<div key={row.label}>
{index > 0 && <Divider color="var(--mantine-color-gray-2)" />}
<InfoRow {...row} />
</div>
))
)}
</Stack>
</SectionCard>
);
}

View File

@@ -17,3 +17,4 @@ export * from "./BookingRouteServiceCard";
export * from "./BookingMileServicesCard";
export * from "./BookingCargoCard";
export * from "./BookingContractSummaryCard";
export * from "./BookingCompanyCard";

View File

@@ -50,11 +50,8 @@ export default function BookingContractPage() {
enabled: Boolean(id),
});
const signRole: "CUSTOMER" | "STAFF" | null = data?.canSignCustomer
? "CUSTOMER"
: data?.canSignStaff
? "STAFF"
: null;
// Backoffice only ever signs as STAFF — customers sign in the portal.
const canSign = Boolean(data?.canSignStaff);
const savedSignature = data?.savedSignature ?? null;
const savedSignatureImage = savedSignature?.signatureImageUrl ?? null;
@@ -106,12 +103,12 @@ export default function BookingContractPage() {
};
const confirmSign = () => {
if (!signRole || !signerName.trim()) return;
if (!canSign || !signerName.trim()) return;
// Approve the saved signature, or submit the freshly drawn one.
const image = usingSaved ? savedSignatureImage : signatureData;
if (!image) return;
signMutation.mutate({
role: signRole,
role: "STAFF",
signatureImageBase64: image,
signerDisplayName: signerName.trim(),
consentText: "I agree to the terms of this contract.",
@@ -167,10 +164,10 @@ export default function BookingContractPage() {
<Download className="size-4" />
Download PDF
</Button>
{signRole && (
{canSign && (
<Button size="sm" className="gap-2" onClick={openSign}>
<FileSignature className="size-4" />
Sign as {signRole === "CUSTOMER" ? "Customer" : "Staff"}
{usingSaved ? "Approve & sign" : "Sign contract"}
</Button>
)}
</div>
@@ -194,9 +191,7 @@ export default function BookingContractPage() {
<Dialog open={signOpen} onOpenChange={setSignOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>
{signRole === "CUSTOMER" ? "Customer signature" : "Staff signature"}
</DialogTitle>
<DialogTitle>Staff signature</DialogTitle>
<DialogDescription>
{usingSaved
? `Review your saved signature and approve it to execute the contract for ${data.reference}.`

View File

@@ -24,11 +24,16 @@ import {
BookingRouteServiceCard,
BookingMileServicesCard,
BookingCargoCard,
BookingCompanyCard,
BookingContractSummaryCard,
BookingDocumentsCard,
type BookingFileView,
} from "@/components/bookings/detail";
import { getStatusMeta } from "@/features/bookings/booking-status.config";
import { toBookingListRow } from "@/features/bookings/mapBookingListRow";
import { downloadBookingFile } from "@/services/files.service";
import { useBookingDetail, useBookingMutations } from "@/hooks/bookings/useBookings";
import toast from "react-hot-toast";
export default function BookingRequestDetailPage() {
const { id } = useParams<{ id: string }>();
@@ -36,6 +41,14 @@ export default function BookingRequestDetailPage() {
const { data: booking, isLoading, isError, refetch, isFetching } = useBookingDetail(id);
const mutations = useBookingMutations(id ?? "");
const handleDownloadFile = async (file: BookingFileView) => {
try {
await downloadBookingFile(file.id, file.name);
} catch {
toast.error("Could not download file.");
}
};
if (isLoading) {
return (
<Box style={detailStyles.page}>
@@ -147,6 +160,10 @@ export default function BookingRequestDetailPage() {
{booking.contractSummary && (
<BookingContractSummaryCard summary={booking.contractSummary} />
)}
<BookingDocumentsCard
files={booking.files ?? []}
onDownload={handleDownloadFile}
/>
</Stack>
</Grid.Col>
@@ -154,6 +171,7 @@ export default function BookingRequestDetailPage() {
<Grid.Col span={{ base: 12, lg: 4 }}>
<Box style={{ position: "sticky", top: 24 }}>
<Stack gap="lg">
<BookingCompanyCard booking={booking} />
<BookingPricingSummary booking={booking} />
<BookingActionsToolbar booking={booking} mutations={mutations} />
{showContractButton && (

View File

@@ -0,0 +1,26 @@
import { api as client } from "../auth/http";
import { URL_CONSTANTS } from "@/constants/URLS";
const F = URL_CONSTANTS.FILES;
export const filesService = {
/** Stream a stored file by id (backend route: GET /files/:id). */
download: async (id: string): Promise<Blob> => {
const response = await client.get(F.BY_ID(id), { responseType: "blob" });
return response.data as Blob;
},
};
/** Download a file blob and trigger a browser save with the given name. */
export async function downloadBookingFile(
id: string,
filename: string,
): Promise<void> {
const blob = await filesService.download(id);
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}

View File

@@ -30,6 +30,27 @@ export interface BookingNamedRef {
companyName?: string;
}
/** Full company record the booking response joins in (subset used by the UI). */
export interface BookingCompany {
id: string;
name?: string;
type?: string;
status?: string;
tin?: string | null;
vatNumber?: string | null;
businessLicense?: string | null;
country?: string | null;
address?: string | null;
phone?: string | null;
email?: string | null;
contactPersonName?: string | null;
contactPersonPhone?: string | null;
generalManagerName?: string | null;
generalManagerEmail?: string | null;
generalManagerPhone?: string | null;
website?: string | null;
}
export interface BookingContainerLine {
id: string;
containerTypeId: string;
@@ -81,6 +102,8 @@ export interface BookingFile {
name: string;
mimeType?: string;
code?: string;
url?: string;
size?: number;
}
export interface BookingDetail {
@@ -121,7 +144,7 @@ export interface BookingDetail {
createdAt: string;
updatedAt: string;
// customer?: BookingNamedRef & { companyName?: string };
company?: BookingNamedRef;
company?: BookingNamedRef & Partial<BookingCompany>;
originYard?: BookingNamedRef;
destinationYard?: BookingNamedRef;
serviceType?: BookingNamedRef & { code?: string; priorityBonusPoints?: number };

View File

@@ -22,6 +22,7 @@ import useAuth from "./hooks/useAuth";
import EDRFreightLandingPage from "./pages/EDRFreightLandingPage";
import MyPortalPage from "./pages/MyPortalPage";
import ProfilePage from "./pages/ProfilePage";
import MySignaturePage from "./pages/MySignaturePage";
import SettingsPage from "./pages/SettingsPage";
import LoginPage from "./pages/accounts/LoginPage";
import OnboardingPage from "./pages/accounts/OnboardingPage";
@@ -201,6 +202,7 @@ const App = () => {
<Route path="/tracking" element={<TrackingPage />} />
<Route path="/billing" element={<BillingPage />} />
<Route path="/profile" element={<ProfilePage />} />
<Route path="/signature" element={<MySignaturePage />} />
<Route path="/settings" element={<SettingsPage />} />
</Route>
</Route>

View File

@@ -315,7 +315,7 @@ export function AppLayout({
</Menu.Item>
<Menu.Item
leftSection={<FileSignature size={15} />}
onClick={() => navigate("/profile#signature")}
onClick={() => navigate("/signature")}
>
My signature
</Menu.Item>

View File

@@ -1 +1,3 @@
export const API_BASE_URL = 'https://edrfreightapi.triaplc.com';
// export const API_BASE_URL = 'https://edrfreightapi.triaplc.com';
export const API_BASE_URL = 'http://localhost:3001';

View File

@@ -0,0 +1,19 @@
import { MySignatureCard } from "@/components/profile/MySignatureCard";
export default function MySignaturePage() {
return (
<div className="px-4 py-8">
<div className="mx-auto flex max-w-md flex-col gap-6">
<div>
<h1 className="text-2xl font-black tracking-tight text-foreground">
My signature
</h1>
<p className="text-sm text-muted-foreground">
Saved and reused to approve and sign booking contracts.
</p>
</div>
<MySignatureCard />
</div>
</div>
);
}

View File

@@ -1,7 +1,6 @@
import { User, Building2, Phone, Mail, MapPin, ShieldCheck, Briefcase, UserCheck, Fingerprint, FileCheck, Globe, Building } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { api } from "@/services/api";
import { MySignatureCard } from "@/components/profile/MySignatureCard";
import { Card, CardHeader, CardTitle, CardDescription, CardContent, Badge, Separator } from "@edr/ui-common";
function InfoItem({ icon, label, value }: { icon?: React.ReactNode; label: string; value?: string | null }) {
@@ -176,10 +175,6 @@ export default function ProfilePage() {
</div>
</CardContent>
</Card>
<div id="signature">
<MySignatureCard />
</div>
</div>
</div>
</div>