diff --git a/apps/edr-freight-api/src/modules/signatures/signatures.service.ts b/apps/edr-freight-api/src/modules/signatures/signatures.service.ts index 6ff10f7d5..7137ab6a5 100644 --- a/apps/edr-freight-api/src/modules/signatures/signatures.service.ts +++ b/apps/edr-freight-api/src/modules/signatures/signatures.service.ts @@ -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( diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingCompanyCard.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingCompanyCard.tsx new file mode 100644 index 000000000..06fafc099 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingCompanyCard.tsx @@ -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 ( + + + + + {label} + + + + {value || "—"} + + + ); +} + +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 ( + + + + ); + } + + if (!company) { + return ( + + + No customer linked to this booking. + + + ); + } + + 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 ( + + + {rows.length === 0 ? ( + + No additional company details available. + + ) : ( + rows.map((row, index) => ( +
+ {index > 0 && } + +
+ )) + )} +
+
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/index.ts b/apps/edr-freight-web/backoffice/src/components/bookings/detail/index.ts index 36d782730..001bc6976 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/detail/index.ts +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/index.ts @@ -17,3 +17,4 @@ export * from "./BookingRouteServiceCard"; export * from "./BookingMileServicesCard"; export * from "./BookingCargoCard"; export * from "./BookingContractSummaryCard"; +export * from "./BookingCompanyCard"; diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingContractPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingContractPage.tsx index 4689d28fa..6ea4c40e9 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingContractPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingContractPage.tsx @@ -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 PDF - {signRole && ( + {canSign && ( )} @@ -194,9 +191,7 @@ export default function BookingContractPage() { - - {signRole === "CUSTOMER" ? "Customer signature" : "Staff signature"} - + Staff signature {usingSaved ? `Review your saved signature and approve it to execute the contract for ${data.reference}.` diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx index b534b44cc..42bf866b3 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx @@ -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 ( @@ -147,6 +160,10 @@ export default function BookingRequestDetailPage() { {booking.contractSummary && ( )} + @@ -154,6 +171,7 @@ export default function BookingRequestDetailPage() { + {showContractButton && ( diff --git a/apps/edr-freight-web/backoffice/src/services/files.service.ts b/apps/edr-freight-web/backoffice/src/services/files.service.ts new file mode 100644 index 000000000..d9b4369c3 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/services/files.service.ts @@ -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 => { + 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 { + 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); +} diff --git a/apps/edr-freight-web/backoffice/src/types/booking.ts b/apps/edr-freight-web/backoffice/src/types/booking.ts index ec178daec..f419ee70a 100644 --- a/apps/edr-freight-web/backoffice/src/types/booking.ts +++ b/apps/edr-freight-web/backoffice/src/types/booking.ts @@ -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; originYard?: BookingNamedRef; destinationYard?: BookingNamedRef; serviceType?: BookingNamedRef & { code?: string; priorityBonusPoints?: number }; diff --git a/apps/edr-freight-web/portal/src/App.tsx b/apps/edr-freight-web/portal/src/App.tsx index 080df0ed2..9938daff9 100644 --- a/apps/edr-freight-web/portal/src/App.tsx +++ b/apps/edr-freight-web/portal/src/App.tsx @@ -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 = () => { } /> } /> } /> + } /> } /> diff --git a/apps/edr-freight-web/portal/src/components/AppLayout.tsx b/apps/edr-freight-web/portal/src/components/AppLayout.tsx index 78ab57293..8d00baeb6 100644 --- a/apps/edr-freight-web/portal/src/components/AppLayout.tsx +++ b/apps/edr-freight-web/portal/src/components/AppLayout.tsx @@ -315,7 +315,7 @@ export function AppLayout({ } - onClick={() => navigate("/profile#signature")} + onClick={() => navigate("/signature")} > My signature diff --git a/apps/edr-freight-web/portal/src/constants/apiConfig.ts b/apps/edr-freight-web/portal/src/constants/apiConfig.ts index 02bf46ac9..fc9f57a58 100644 --- a/apps/edr-freight-web/portal/src/constants/apiConfig.ts +++ b/apps/edr-freight-web/portal/src/constants/apiConfig.ts @@ -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'; + diff --git a/apps/edr-freight-web/portal/src/pages/MySignaturePage.tsx b/apps/edr-freight-web/portal/src/pages/MySignaturePage.tsx new file mode 100644 index 000000000..4c30c878a --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/MySignaturePage.tsx @@ -0,0 +1,19 @@ +import { MySignatureCard } from "@/components/profile/MySignatureCard"; + +export default function MySignaturePage() { + return ( +
+
+
+

+ My signature +

+

+ Saved and reused to approve and sign booking contracts. +

+
+ +
+
+ ); +} diff --git a/apps/edr-freight-web/portal/src/pages/ProfilePage.tsx b/apps/edr-freight-web/portal/src/pages/ProfilePage.tsx index 8defc7036..9c4354723 100644 --- a/apps/edr-freight-web/portal/src/pages/ProfilePage.tsx +++ b/apps/edr-freight-web/portal/src/pages/ProfilePage.tsx @@ -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() { - -
- -