add file viewer functionality across various components

- Implemented a shared file viewer modal using `useFileViewer` hook to allow inline viewing of documents (images, PDFs, videos, etc.) across the application.
- Updated `ContractClearanceReviewSection`, `ContractRequestDetailPage`, `ContractViewPage`, and booking-related components to utilize the new file viewer for document previews.
- Added "Approve all" button in `ContractClearanceReviewSection` to bulk approve documents.
- Enhanced document action buttons to include view and download options based on file type.
- Introduced `isViewable` utility to determine if a file can be previewed inline.
- Created `FileViewer` component to handle rendering of various file types and added appropriate fallback for unsupported formats.
This commit is contained in:
Marshal
2026-06-27 19:18:43 +00:00
parent e977893888
commit 0ab553bf48
19 changed files with 893 additions and 116 deletions

View File

@@ -231,10 +231,14 @@ export class ContractTransitionService {
await this.contractsRepository.completeApprovalStep(step.id, actorId, 'APPROVED');
// Record who acted on this step, but DO NOT advance the contract status here —
// approving one step (e.g. LINE_STAFF) must not finalize the chain while later
// steps (e.g. DIRECTOR) are still pending. Status only moves to APPROVED once
// every step in the chain is complete; until then the contract stays in
// PENDING_APPROVAL so the next required role can act.
const updates: Record<string, unknown> = {};
const now = new Date();
if (requiredRole === 'LINE_STAFF') {
updates.status = 'APPROVED_PENDING_SIGNATURE';
updates.approvedByStaffId = actorId;
updates.approvedByStaffAt = now;
} else if (requiredRole === 'DIRECTOR') {
@@ -246,9 +250,7 @@ export class ContractTransitionService {
}
const allDone = await this.contractsRepository.allApprovalStepsComplete(contractId);
if (allDone) {
updates.status = 'APPROVED';
}
updates.status = allDone ? 'APPROVED' : 'PENDING_APPROVAL';
if (Object.keys(updates).length > 0) {
await this.contractsRepository.update(contractId, updates as never);
@@ -368,9 +370,28 @@ export class ContractTransitionService {
options: { signerUserId?: string },
): Promise<void> {
const role = dto.role as ContractSignerRole;
const raw = dto.signatureImageBase64.includes(',')
? dto.signatureImageBase64.split(',')[1]!
: dto.signatureImageBase64;
// Resolve the signature image. The client may send a freshly-drawn image, or
// omit it to reuse the signer's saved profile signature. Fall back to the
// saved one whenever no image is supplied.
let imageBase64 = dto.signatureImageBase64;
let signerDisplayName = dto.signerDisplayName;
if (!imageBase64 && options.signerUserId) {
const saved = await this.signaturesService.getForUser(options.signerUserId);
if (saved?.signatureImageUrl) {
imageBase64 = saved.signatureImageUrl;
signerDisplayName = signerDisplayName || saved.signerDisplayName;
}
}
if (!imageBase64) {
throw new BadRequestException(
'No signature provided and no saved signature found on the profile.',
);
}
const raw = imageBase64.includes(',')
? imageBase64.split(',')[1]!
: imageBase64;
const buffer = Buffer.from(raw, 'base64');
const sigFile: Express.Multer.File = {
fieldname: `signature_${role.toLowerCase()}`,
@@ -395,17 +416,19 @@ export class ContractTransitionService {
await this.contractsRepository.saveSignature({
contractId: contract.id,
role,
signerDisplayName: dto.signerDisplayName,
signerDisplayName,
signedAt: new Date(),
signatureFileId: fileRecord.id,
consentText: dto.consentText ?? null,
});
if (options.signerUserId) {
// Only (re)save the reusable profile signature when the signer drew a NEW
// image. Reusing the saved signature must not rewrite it with itself.
if (options.signerUserId && dto.signatureImageBase64) {
try {
await this.signaturesService.upsertForUser({
userId: options.signerUserId,
signerDisplayName: dto.signerDisplayName,
signerDisplayName,
signatureImageBase64: dto.signatureImageBase64,
});
} catch (err) {

View File

@@ -42,6 +42,7 @@ import { ContractTransitionService } from './contract-transition.service';
import { ContractClearanceService } from './contract-clearance.service';
import { ContractBookingService } from './contract-booking.service';
import { ClearanceMilestoneService } from './clearance-milestone.service';
import { SignaturesService } from '../signatures/signatures.service';
import { CreateContractDto } from './dto/create-contract.dto';
import { UpdateContractDto } from './dto/update-contract.dto';
import { FilterContractDto } from './dto/filter-contract.dto';
@@ -68,6 +69,7 @@ export class ContractsController {
private readonly clearanceService: ContractClearanceService,
private readonly contractBookingService: ContractBookingService,
private readonly milestoneService: ClearanceMilestoneService,
private readonly signaturesService: SignaturesService,
) {}
@Post()
@@ -313,6 +315,12 @@ export class ContractsController {
}
const { view, html, signatures } =
await this.transitionService.getContractDocumentView(id);
// The signer's reusable saved signature (if any) so the sign UI can offer
// "Approve & sign" with the stored image instead of forcing a fresh draw.
const signerId = resolveAuthUserId(user);
const savedSignature = signerId
? await this.signaturesService.getForUser(signerId)
: null;
return {
contractId: view.bookingId,
reference: view.reference,
@@ -325,6 +333,7 @@ export class ContractsController {
canSignStaff: view.canSignStaff,
hasContractDocument: view.hasContractDocument,
signatures,
savedSignature,
};
}

View File

@@ -6,10 +6,16 @@ export class SignContractDto {
@IsIn(['CUSTOMER', 'STAFF', 'DIRECTOR', 'CEO'])
role!: 'CUSTOMER' | 'STAFF' | 'DIRECTOR' | 'CEO';
@ApiProperty({ description: 'PNG signature image as base64 (with or without data URL prefix)' })
@ApiPropertyOptional({
description:
'PNG signature image as base64 (with or without data URL prefix). ' +
'Optional: when omitted, the signer\'s reusable saved signature from their ' +
'profile is used instead.',
})
@IsOptional()
@IsString()
@MinLength(20)
signatureImageBase64!: string;
signatureImageBase64?: string;
@ApiProperty()
@IsString()