mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
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:
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -17,3 +17,4 @@ export * from "./BookingRouteServiceCard";
|
||||
export * from "./BookingMileServicesCard";
|
||||
export * from "./BookingCargoCard";
|
||||
export * from "./BookingContractSummaryCard";
|
||||
export * from "./BookingCompanyCard";
|
||||
|
||||
@@ -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}.`
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 };
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
19
apps/edr-freight-web/portal/src/pages/MySignaturePage.tsx
Normal file
19
apps/edr-freight-web/portal/src/pages/MySignaturePage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user