mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge pull request #181 from Tria-plc/freight_feature/priority
Freight feature/priority
This commit is contained in:
@@ -13,6 +13,7 @@ import telebirrConfig from "./config/telebirr.config";
|
||||
import rabbitmqConfig from "./config/rabbitmq.config";
|
||||
|
||||
import { BookingsModule } from "./modules/bookings/bookings.module";
|
||||
import { SignaturesModule } from "./modules/signatures/signatures.module";
|
||||
import { FilesModule } from "./modules/files/files.module";
|
||||
import { ConsignmentsModule } from "./modules/consignments/consignments.module";
|
||||
|
||||
@@ -82,6 +83,7 @@ import { OverviewModule } from './modules/overview/overview.module';
|
||||
permissions: EDR_FREIGHT_PERMISSIONS,
|
||||
}),
|
||||
BookingsModule,
|
||||
SignaturesModule,
|
||||
FilesModule,
|
||||
ConsignmentsModule,
|
||||
LocomotivesModule,
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class CreateSavedSignatures1784000000000 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE freight.saved_signatures (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
user_id UUID NOT NULL,
|
||||
signer_display_name VARCHAR(200) NOT NULL,
|
||||
signature_file_id UUID NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
deleted_at TIMESTAMPTZ NULL,
|
||||
CONSTRAINT uq_saved_signatures_user_id UNIQUE (user_id)
|
||||
);
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.saved_signatures;`);
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import { ContractViewDto } from './dto/contract-view.dto';
|
||||
import { SignContractDto } from './dto/sign-contract.dto';
|
||||
import { ContractSignerRole } from './entities/booking-contract-signature.entity';
|
||||
import { BookingBatchService } from '../train-scheduling/booking-batch.service';
|
||||
import { SignaturesService } from '../signatures/signatures.service';
|
||||
|
||||
@Injectable()
|
||||
export class BookingContractService {
|
||||
@@ -38,6 +39,7 @@ export class BookingContractService {
|
||||
private readonly pdfService: ContractPdfService,
|
||||
@Inject(forwardRef(() => BookingBatchService))
|
||||
private readonly bookingBatchService: BookingBatchService,
|
||||
private readonly signaturesService: SignaturesService,
|
||||
) {}
|
||||
|
||||
buildContractSummary(booking: Booking): string {
|
||||
@@ -75,10 +77,16 @@ export class BookingContractService {
|
||||
return { summary };
|
||||
}
|
||||
|
||||
async getContractView(bookingId: string): Promise<ContractViewDto> {
|
||||
async getContractView(
|
||||
bookingId: string,
|
||||
viewerUserId?: string,
|
||||
): Promise<ContractViewDto> {
|
||||
const { view } = await this.viewModelBuilder.build(bookingId);
|
||||
await this.inlineSignatureImages(view.signatures);
|
||||
const html = this.renderer.render(view);
|
||||
const savedSignature = viewerUserId
|
||||
? ((await this.signaturesService.getForUser(viewerUserId)) ?? undefined)
|
||||
: undefined;
|
||||
return {
|
||||
bookingId: view.bookingId,
|
||||
reference: view.reference,
|
||||
@@ -90,6 +98,7 @@ export class BookingContractService {
|
||||
canSignStaff: view.canSignStaff,
|
||||
hasContractDocument: view.hasContractDocument,
|
||||
signatures: view.signatures,
|
||||
savedSignature,
|
||||
pricingSchedule: view.pricing as unknown as Record<string, unknown>,
|
||||
};
|
||||
}
|
||||
@@ -194,6 +203,23 @@ export class BookingContractService {
|
||||
ipAddress: options.ipAddress ?? null,
|
||||
});
|
||||
|
||||
// Persist the just-used signature to the signer's reusable profile so they
|
||||
// don't have to redraw it on the next contract. Best-effort: a failure here
|
||||
// must never block contract execution.
|
||||
if (options.signerUserId) {
|
||||
try {
|
||||
await this.signaturesService.upsertForUser({
|
||||
userId: options.signerUserId,
|
||||
signerDisplayName: dto.signerDisplayName,
|
||||
signatureImageBase64: dto.signatureImageBase64,
|
||||
});
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`Could not save reusable signature for user ${options.signerUserId}: ${err}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const updates: Record<string, unknown> = {};
|
||||
|
||||
if (role === 'CUSTOMER') {
|
||||
|
||||
@@ -342,8 +342,12 @@ export class BookingsController {
|
||||
@Get(':id/contract/view')
|
||||
@ApiOkResponse({ type: ContractViewDto })
|
||||
@ApiOperation({ summary: 'Contract HTML view for portal and backoffice' })
|
||||
getContractView(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.contractService.getContractView(id);
|
||||
getContractView(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Request() req: { user?: { id?: string; sub?: string } },
|
||||
) {
|
||||
const userId = req.user?.id ?? req.user?.sub;
|
||||
return this.contractService.getContractView(id, userId);
|
||||
}
|
||||
|
||||
@Get(':id/contract/document')
|
||||
|
||||
@@ -6,6 +6,7 @@ import { CompaniesModule } from '../companies/companies.module';
|
||||
import { FilesModule } from '../files/files.module';
|
||||
import { MinioModule } from '../minio/minio.module';
|
||||
import { RuleEngineModule } from '../rule-engine/rule-engine.module';
|
||||
import { SignaturesModule } from '../signatures/signatures.module';
|
||||
import { BookingContractService } from './booking-contract.service';
|
||||
import { BookingPaymentService } from './booking-payment.service';
|
||||
import { BookingPricingService } from './booking-pricing.service';
|
||||
@@ -50,6 +51,7 @@ import { CbeExchangeService } from '../cbe-exchange/cbe-exchange.service';
|
||||
CompaniesModule,
|
||||
// CustomersModule,
|
||||
RuleEngineModule,
|
||||
SignaturesModule,
|
||||
],
|
||||
controllers: [BookingsController, PayController],
|
||||
providers: [
|
||||
|
||||
@@ -14,6 +14,14 @@ export class ContractSignatureDto {
|
||||
signatureImageUrl?: string | null;
|
||||
}
|
||||
|
||||
export class SavedSignatureViewDto {
|
||||
@ApiProperty()
|
||||
signerDisplayName!: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
signatureImageUrl?: string | null;
|
||||
}
|
||||
|
||||
export class ContractViewDto {
|
||||
@ApiProperty()
|
||||
bookingId!: string;
|
||||
@@ -45,6 +53,9 @@ export class ContractViewDto {
|
||||
@ApiProperty({ type: [ContractSignatureDto] })
|
||||
signatures!: ContractSignatureDto[];
|
||||
|
||||
@ApiPropertyOptional({ type: SavedSignatureViewDto })
|
||||
savedSignature?: SavedSignatureViewDto;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
pricingSchedule?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsString, MinLength } from 'class-validator';
|
||||
|
||||
export class SaveSignatureDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
signerDisplayName!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'PNG signature image as base64 (with or without data URL prefix)',
|
||||
})
|
||||
@IsString()
|
||||
@MinLength(20)
|
||||
signatureImageBase64!: string;
|
||||
}
|
||||
|
||||
export class SavedSignatureDto {
|
||||
@ApiProperty()
|
||||
signerDisplayName!: string;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
signatureImageUrl!: string | null;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
|
||||
import { FileRecord } from '../../files/entities/file.entity';
|
||||
|
||||
/**
|
||||
* A reusable signature that belongs to a single user (customer or staff).
|
||||
* Captured once and applied to many booking contracts so the signer does not
|
||||
* have to redraw it every time. One active saved signature per user.
|
||||
*/
|
||||
@Entity({ schema: 'freight', name: 'saved_signatures' })
|
||||
@Index(['userId'], { unique: true })
|
||||
export class SavedSignature extends BaseEntity {
|
||||
@Column({ name: 'user_id', type: 'uuid' })
|
||||
userId!: string;
|
||||
|
||||
@Column({ name: 'signer_display_name', type: 'varchar', length: 200 })
|
||||
signerDisplayName!: string;
|
||||
|
||||
@Column({ name: 'signature_file_id', type: 'uuid', nullable: true })
|
||||
signatureFileId?: string | null;
|
||||
|
||||
@ManyToOne(() => FileRecord, { nullable: true })
|
||||
@JoinColumn({ name: 'signature_file_id' })
|
||||
signatureFile?: FileRecord | null;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Body, Controller, Get, Put, Request } from '@nestjs/common';
|
||||
import { ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { SignaturesService } from './signatures.service';
|
||||
import { SaveSignatureDto, SavedSignatureDto } from './dto/save-signature.dto';
|
||||
|
||||
@ApiTags('Signatures')
|
||||
@Controller('me/signature')
|
||||
export class SignaturesController {
|
||||
constructor(private readonly signaturesService: SignaturesService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOkResponse({ type: SavedSignatureDto })
|
||||
@ApiOperation({ summary: "Current user's reusable saved signature" })
|
||||
getMySignature(
|
||||
@Request() req: { user?: { id?: string; sub?: string } },
|
||||
): Promise<SavedSignatureDto | null> {
|
||||
const userId = req.user?.id ?? req.user?.sub;
|
||||
if (!userId) return Promise.resolve(null);
|
||||
return this.signaturesService.getForUser(userId);
|
||||
}
|
||||
|
||||
@Put()
|
||||
@ApiOkResponse({ type: SavedSignatureDto })
|
||||
@ApiOperation({ summary: 'Create or update the reusable saved signature' })
|
||||
async saveMySignature(
|
||||
@Body() dto: SaveSignatureDto,
|
||||
@Request() req: { user?: { id?: string; sub?: string } },
|
||||
): Promise<SavedSignatureDto | null> {
|
||||
const userId = req.user?.id ?? req.user?.sub;
|
||||
if (!userId) return null;
|
||||
await this.signaturesService.upsertForUser({
|
||||
userId,
|
||||
signerDisplayName: dto.signerDisplayName,
|
||||
signatureImageBase64: dto.signatureImageBase64,
|
||||
});
|
||||
return this.signaturesService.getForUser(userId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { FilesModule } from '../files/files.module';
|
||||
import { MinioModule } from '../minio/minio.module';
|
||||
import { SignaturesController } from './signatures.controller';
|
||||
import { SignaturesService } from './signatures.service';
|
||||
import { SignaturesRepository } from './signatures.repository';
|
||||
import { SavedSignature } from './entities/saved-signature.entity';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([SavedSignature]),
|
||||
FilesModule,
|
||||
MinioModule,
|
||||
],
|
||||
controllers: [SignaturesController],
|
||||
providers: [SignaturesService, SignaturesRepository],
|
||||
exports: [SignaturesService],
|
||||
})
|
||||
export class SignaturesModule {}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { BaseRepository } from '@edr/api-common';
|
||||
import { SavedSignature } from './entities/saved-signature.entity';
|
||||
|
||||
@Injectable()
|
||||
export class SignaturesRepository extends BaseRepository<SavedSignature> {
|
||||
constructor(
|
||||
@InjectRepository(SavedSignature)
|
||||
repo: Repository<SavedSignature>,
|
||||
) {
|
||||
super(repo);
|
||||
}
|
||||
|
||||
findByUserId(userId: string): Promise<SavedSignature | null> {
|
||||
return this.repository.findOne({
|
||||
where: { userId } as never,
|
||||
relations: ['signatureFile'],
|
||||
});
|
||||
}
|
||||
|
||||
/** Insert or update the single saved signature for a user. */
|
||||
async upsert(data: Partial<SavedSignature>): Promise<SavedSignature> {
|
||||
const existing = await this.repository.findOne({
|
||||
where: { userId: data.userId! } as never,
|
||||
});
|
||||
if (existing) {
|
||||
Object.assign(existing, data);
|
||||
return this.repository.save(existing);
|
||||
}
|
||||
return this.repository.save(this.repository.create(data));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Readable } from 'stream';
|
||||
|
||||
import { FilesService } from '../files/files.service';
|
||||
import { MinioService } from '../minio/minio.service';
|
||||
import { SignaturesRepository } from './signatures.repository';
|
||||
import { SavedSignature } from './entities/saved-signature.entity';
|
||||
import { SavedSignatureDto } from './dto/save-signature.dto';
|
||||
|
||||
export interface UpsertSignatureInput {
|
||||
userId: string;
|
||||
signerDisplayName: string;
|
||||
signatureImageBase64: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SignaturesService {
|
||||
constructor(
|
||||
private readonly signaturesRepository: SignaturesRepository,
|
||||
private readonly filesService: FilesService,
|
||||
private readonly minioService: MinioService,
|
||||
) {}
|
||||
|
||||
/** Saved signature for a user, with the image inlined as a data URL (or null). */
|
||||
async getForUser(userId: string): Promise<SavedSignatureDto | null> {
|
||||
const saved = await this.signaturesRepository.findByUserId(userId);
|
||||
if (!saved) return null;
|
||||
return {
|
||||
signerDisplayName: saved.signerDisplayName,
|
||||
signatureImageUrl: await this.inlineImageUrl(saved.signatureFile?.url),
|
||||
};
|
||||
}
|
||||
|
||||
/** Insert or update the user's reusable signature, storing the image in MinIO. */
|
||||
async upsertForUser(input: UpsertSignatureInput): Promise<SavedSignature> {
|
||||
const buffer = this.decodeSignatureImage(input.signatureImageBase64);
|
||||
const file: Express.Multer.File = {
|
||||
fieldname: 'signature',
|
||||
originalname: `signature-${input.userId}.png`,
|
||||
encoding: '7bit',
|
||||
mimetype: 'image/png',
|
||||
size: buffer.length,
|
||||
buffer,
|
||||
stream: Readable.from(buffer),
|
||||
destination: '',
|
||||
filename: '',
|
||||
path: '',
|
||||
};
|
||||
|
||||
const fileRecord = await this.filesService.upsertByCode({
|
||||
resourceId: input.userId,
|
||||
resource: 'saved_signatures',
|
||||
code: 'signature',
|
||||
file,
|
||||
});
|
||||
|
||||
return this.signaturesRepository.upsert({
|
||||
userId: input.userId,
|
||||
signerDisplayName: input.signerDisplayName,
|
||||
signatureFileId: fileRecord.id,
|
||||
});
|
||||
}
|
||||
|
||||
private async inlineImageUrl(
|
||||
url?: string | null,
|
||||
): Promise<string | null> {
|
||||
if (!url) return null;
|
||||
if (url.startsWith('data:')) return url;
|
||||
try {
|
||||
const objectName = this.minioService.getObjectNameFromUrl(url);
|
||||
const stream = await this.minioService.getFileStream(objectName);
|
||||
const buffer = await this.streamToBuffer(stream);
|
||||
return `data:image/png;base64,${buffer.toString('base64')}`;
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
private decodeSignatureImage(base64: string): Buffer {
|
||||
const raw = base64.includes(',') ? base64.split(',')[1]! : base64;
|
||||
return Buffer.from(raw, 'base64');
|
||||
}
|
||||
|
||||
private streamToBuffer(stream: Readable): Promise<Buffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks: Buffer[] = [];
|
||||
stream.on('data', (chunk: Buffer | string) => {
|
||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||
});
|
||||
stream.on('error', reject);
|
||||
stream.on('end', () => resolve(Buffer.concat(chunks)));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,7 @@ import UserManagementHostPage from "./pages/dashboard/user-management/UserManage
|
||||
import DemoUser1Page from "./pages/dashboard/demo/DemoUser1Page";
|
||||
import DemoUser2Page from "./pages/dashboard/demo/DemoUser2Page";
|
||||
import OverviewPage from "./pages/dashboard/OverviewPage";
|
||||
import MyProfilePage from "./pages/dashboard/MyProfilePage";
|
||||
//import EmployeesPage from "./pages/dashboard/user-management/EmployeesPage";
|
||||
import PermissionsPage from "./pages/dashboard/user-management/PermissionsPage";
|
||||
import PositionTypesPage from "./pages/dashboard/user-management/PositionTypesPage";
|
||||
@@ -257,6 +258,7 @@ const App = () => {
|
||||
<Route path="/dashboard" element={<Navigate to="/dashboard/overview" replace />} />
|
||||
<Route path="/dashboard" element={<DashboardShell />}>
|
||||
<Route path="overview" element={<OverviewPage />} />
|
||||
<Route path="profile" element={<MyProfilePage />} />
|
||||
|
||||
<Route path="booking-requests" element={<BookingRequestsPage />} />
|
||||
<Route path="booking-requests/new" element={<NewBookingPage />} />
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
User,
|
||||
} from "lucide-react";
|
||||
import { Group, Stack, Text, Menu, Tooltip, Box } from "@mantine/core";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import type { PageMeta } from "./types";
|
||||
import "./FreightDashboardHeader.css";
|
||||
@@ -37,6 +38,7 @@ const FreightDashboardHeader = ({
|
||||
theme,
|
||||
onToggleTheme,
|
||||
}: FreightDashboardHeaderProps) => {
|
||||
const navigate = useNavigate();
|
||||
const initials =
|
||||
userInitials ??
|
||||
(userName
|
||||
@@ -200,7 +202,10 @@ const FreightDashboardHeader = ({
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
leftSection={<User size={15} />}
|
||||
onClick={() => setIsUserMenuOpen(false)}
|
||||
onClick={() => {
|
||||
setIsUserMenuOpen(false);
|
||||
navigate("/dashboard/profile");
|
||||
}}
|
||||
>
|
||||
Profile
|
||||
</Menu.Item>
|
||||
|
||||
@@ -36,6 +36,13 @@ const ROUTE_META: Array<{ prefix: string; meta: PageMeta }> = [
|
||||
subtitle: "Dashboard summary and key metrics",
|
||||
},
|
||||
},
|
||||
{
|
||||
prefix: "/dashboard/profile",
|
||||
meta: {
|
||||
title: "My Profile",
|
||||
subtitle: "Manage your account and signature",
|
||||
},
|
||||
},
|
||||
{
|
||||
prefix: "/dashboard/operations/train-scheduling-v2/",
|
||||
meta: {
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import { useState } from "react";
|
||||
import { FileSignature, Loader2 } from "lucide-react";
|
||||
|
||||
import { ContractSignaturePad } from "@/components/bookings/ContractSignaturePad";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import {
|
||||
useMySignature,
|
||||
useSaveSignature,
|
||||
} from "@/hooks/useSavedSignature";
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
Input,
|
||||
Label,
|
||||
} from "@edr/ui-common";
|
||||
|
||||
/**
|
||||
* Lets the signed-in user view and update the reusable signature stored on
|
||||
* their profile. The same signature is offered for approval when signing a
|
||||
* booking contract.
|
||||
*/
|
||||
export function MySignatureCard() {
|
||||
const { user } = useAuth();
|
||||
const { data: saved, isLoading } = useMySignature();
|
||||
const saveMutation = useSaveSignature();
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [signerName, setSignerName] = useState("");
|
||||
const [signatureData, setSignatureData] = useState<string | null>(null);
|
||||
|
||||
const defaultName =
|
||||
user?.name?.en || user?.username || user?.email || "";
|
||||
|
||||
const openDialog = () => {
|
||||
setSignerName(saved?.signerDisplayName ?? defaultName);
|
||||
setSignatureData(null);
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
const save = () => {
|
||||
if (!signatureData || !signerName.trim()) return;
|
||||
saveMutation.mutate(
|
||||
{
|
||||
signerDisplayName: signerName.trim(),
|
||||
signatureImageBase64: signatureData,
|
||||
},
|
||||
{ onSuccess: () => setOpen(false) },
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FileSignature className="size-4" />
|
||||
My signature
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
This signature can be reused to sign booking contracts.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{isLoading ? (
|
||||
<div className="flex h-36 items-center justify-center">
|
||||
<Loader2 className="size-6 animate-spin text-primary" />
|
||||
</div>
|
||||
) : saved?.signatureImageUrl ? (
|
||||
<div className="space-y-2">
|
||||
<div className="overflow-hidden rounded-lg border-2 border-dashed border-border bg-white">
|
||||
<img
|
||||
src={saved.signatureImageUrl}
|
||||
alt="My saved signature"
|
||||
className="mx-auto h-36 w-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Saved as {saved.signerDisplayName}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
You have not saved a signature yet.
|
||||
</p>
|
||||
)}
|
||||
<Button variant="outline" size="sm" onClick={openDialog}>
|
||||
{saved?.signatureImageUrl ? "Update signature" : "Add signature"}
|
||||
</Button>
|
||||
</CardContent>
|
||||
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Save your signature</DialogTitle>
|
||||
<DialogDescription>
|
||||
Draw your signature below. It will be stored on your profile for
|
||||
future contracts.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="profileSignerName">Full name</Label>
|
||||
<Input
|
||||
id="profileSignerName"
|
||||
value={signerName}
|
||||
onChange={(e) => setSignerName(e.target.value)}
|
||||
placeholder="As shown on contracts"
|
||||
/>
|
||||
</div>
|
||||
<ContractSignaturePad onChange={setSignatureData} />
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
disabled={
|
||||
saveMutation.isPending || !signatureData || !signerName.trim()
|
||||
}
|
||||
onClick={save}
|
||||
>
|
||||
{saveMutation.isPending ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
"Save signature"
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import {
|
||||
signaturesService,
|
||||
type SaveSignaturePayload,
|
||||
} from "@/services/signatures.service";
|
||||
|
||||
const SAVED_SIGNATURE_KEY = ["me", "signature"] as const;
|
||||
|
||||
export function useMySignature() {
|
||||
return useQuery({
|
||||
queryKey: SAVED_SIGNATURE_KEY,
|
||||
queryFn: () => signaturesService.getMySignature(),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useSaveSignature() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (payload: SaveSignaturePayload) =>
|
||||
signaturesService.saveMySignature(payload),
|
||||
onSuccess: () => {
|
||||
toast.success("Signature saved");
|
||||
void qc.invalidateQueries({ queryKey: SAVED_SIGNATURE_KEY });
|
||||
},
|
||||
onError: () => toast.error("Failed to save signature"),
|
||||
});
|
||||
}
|
||||
@@ -40,6 +40,9 @@ export default function BookingContractPage() {
|
||||
const [signOpen, setSignOpen] = useState(false);
|
||||
const [signerName, setSignerName] = useState("");
|
||||
const [signatureData, setSignatureData] = 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);
|
||||
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
queryKey: [...QUERY_KEYS.BOOKINGS.byId(id ?? ""), "contract-view"],
|
||||
@@ -53,6 +56,12 @@ export default function BookingContractPage() {
|
||||
? "STAFF"
|
||||
: null;
|
||||
|
||||
const savedSignature = data?.savedSignature ?? null;
|
||||
const savedSignatureImage = savedSignature?.signatureImageUrl ?? 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;
|
||||
|
||||
const signMutation = useMutation({
|
||||
mutationFn: (payload: SignContractPayload) =>
|
||||
bookingsService.signContract(id!, payload),
|
||||
@@ -88,16 +97,22 @@ export default function BookingContractPage() {
|
||||
};
|
||||
|
||||
const openSign = () => {
|
||||
setSignerName("");
|
||||
// Prefill from the saved signature when available so the user only has to
|
||||
// approve it; otherwise start with an empty pad.
|
||||
setSignerName(savedSignature?.signerDisplayName ?? "");
|
||||
setSignatureData(null);
|
||||
setDrawNew(false);
|
||||
setSignOpen(true);
|
||||
};
|
||||
|
||||
const confirmSign = () => {
|
||||
if (!signRole || !signatureData || !signerName.trim()) return;
|
||||
if (!signRole || !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,
|
||||
signatureImageBase64: signatureData,
|
||||
signatureImageBase64: image,
|
||||
signerDisplayName: signerName.trim(),
|
||||
consentText: "I agree to the terms of this contract.",
|
||||
});
|
||||
@@ -183,7 +198,9 @@ export default function BookingContractPage() {
|
||||
{signRole === "CUSTOMER" ? "Customer signature" : "Staff signature"}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Sign to execute the contract for {data.reference}.
|
||||
{usingSaved
|
||||
? `Review your saved signature and approve it to execute the contract for ${data.reference}.`
|
||||
: `Sign to execute the contract for ${data.reference}.`}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
@@ -196,7 +213,32 @@ export default function BookingContractPage() {
|
||||
placeholder="As shown on the contract"
|
||||
/>
|
||||
</div>
|
||||
<ContractSignaturePad onChange={setSignatureData} />
|
||||
{usingSaved ? (
|
||||
<div className="space-y-2">
|
||||
<Label>Saved signature</Label>
|
||||
<div className="overflow-hidden rounded-lg border-2 border-dashed border-border bg-white">
|
||||
<img
|
||||
src={savedSignatureImage ?? undefined}
|
||||
alt="Saved signature"
|
||||
className="mx-auto h-36 w-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
size="sm"
|
||||
className="h-auto p-0 text-xs"
|
||||
onClick={() => {
|
||||
setDrawNew(true);
|
||||
setSignatureData(null);
|
||||
}}
|
||||
>
|
||||
Draw a new signature instead
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<ContractSignaturePad onChange={setSignatureData} />
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setSignOpen(false)}>
|
||||
@@ -205,13 +247,15 @@ export default function BookingContractPage() {
|
||||
<Button
|
||||
disabled={
|
||||
signMutation.isPending ||
|
||||
!signatureData ||
|
||||
(!usingSaved && !signatureData) ||
|
||||
!signerName.trim()
|
||||
}
|
||||
onClick={confirmSign}
|
||||
>
|
||||
{signMutation.isPending ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : usingSaved ? (
|
||||
"Approve & sign"
|
||||
) : (
|
||||
"Confirm signature"
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { MySignatureCard } from "@/components/profile/MySignatureCard";
|
||||
|
||||
export default function MyProfilePage() {
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-2xl space-y-6 p-4">
|
||||
<MySignatureCard />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -80,6 +80,11 @@ export interface ContractView {
|
||||
signedAt: string;
|
||||
signatureImageUrl?: string | null;
|
||||
}>;
|
||||
/** Current viewer's reusable saved signature, if they have one. */
|
||||
savedSignature?: {
|
||||
signerDisplayName: string;
|
||||
signatureImageUrl?: string | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface SignContractPayload {
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { api as client } from "../auth/http";
|
||||
import { unwrap } from "@/utils/endpoint";
|
||||
|
||||
const SIGNATURE_URL = "/me/signature";
|
||||
|
||||
export interface SavedSignature {
|
||||
signerDisplayName: string;
|
||||
signatureImageUrl?: string | null;
|
||||
}
|
||||
|
||||
export interface SaveSignaturePayload {
|
||||
signerDisplayName: string;
|
||||
signatureImageBase64: string;
|
||||
}
|
||||
|
||||
export const signaturesService = {
|
||||
/** Returns the current user's reusable signature, or null if none saved. */
|
||||
getMySignature: async (): Promise<SavedSignature | null> => {
|
||||
const response = await client.get<SavedSignature | null>(SIGNATURE_URL);
|
||||
return (unwrap(response.data) as SavedSignature | null) ?? null;
|
||||
},
|
||||
|
||||
saveMySignature: async (
|
||||
payload: SaveSignaturePayload,
|
||||
): Promise<SavedSignature | null> => {
|
||||
const response = await client.put<SavedSignature | null>(
|
||||
SIGNATURE_URL,
|
||||
payload,
|
||||
);
|
||||
return (unwrap(response.data) as SavedSignature | null) ?? null;
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user