Checkpoint: Ajout de l'association Service→Signature dans l'onglet Signatures des Paramètres et apposition automatique de la signature sur les PDFs exportés lors de l'export BAP. Inclut : table serviceSignatures, routes tRPC, interface de configuration, et modification de exportToPdf pour apposer la signature en bas à droite du PDF avec le nom du signataire.
This commit is contained in:
@@ -402,10 +402,155 @@ function SignaturesSection() {
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Service → Signature associations */}
|
||||
<ServiceSignaturesSection signatures={signatures || []} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ServiceSignaturesSection({ signatures }: { signatures: Array<{ id: number; firstName: string; lastName: string; imageUrl: string }> }) {
|
||||
const utils = trpc.useUtils();
|
||||
const { data: departments, isLoading: depsLoading } = trpc.departments.getByUser.useQuery();
|
||||
const { data: associations, isLoading: assocLoading } = trpc.serviceSignatures.list.useQuery();
|
||||
|
||||
const upsertMutation = trpc.serviceSignatures.upsert.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Association enregistrée");
|
||||
utils.serviceSignatures.list.invalidate();
|
||||
},
|
||||
onError: (e) => toast.error(e.message || "Erreur lors de l'enregistrement"),
|
||||
});
|
||||
|
||||
const deleteMutation = trpc.serviceSignatures.delete.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Association supprimée");
|
||||
utils.serviceSignatures.list.invalidate();
|
||||
},
|
||||
onError: (e) => toast.error(e.message || "Erreur lors de la suppression"),
|
||||
});
|
||||
|
||||
const getAssociation = (serviceName: string) =>
|
||||
associations?.find(a => a.serviceName.toLowerCase() === serviceName.toLowerCase());
|
||||
|
||||
if (depsLoading || assocLoading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader><CardTitle className="flex items-center gap-2"><User className="w-5 h-5 text-emerald-500" />Association Service → Signature</CardTitle></CardHeader>
|
||||
<CardContent><div className="flex justify-center py-6"><Loader2 className="w-6 h-6 animate-spin text-primary" /></div></CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (!departments || departments.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2"><User className="w-5 h-5 text-emerald-500" />Association Service → Signature</CardTitle>
|
||||
<CardDescription>Associez une signature à chaque service pour l'apposer automatiquement lors de l'export BAP</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground text-center py-4">Aucun service configuré. Ajoutez des services dans l'onglet <strong>Listes</strong> d'abord.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (!signatures || signatures.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2"><User className="w-5 h-5 text-emerald-500" />Association Service → Signature</CardTitle>
|
||||
<CardDescription>Associez une signature à chaque service pour l'apposer automatiquement lors de l'export BAP</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground text-center py-4">Aucune signature enregistrée. Ajoutez des signatures ci-dessus d'abord.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2"><User className="w-5 h-5 text-emerald-500" />Association Service → Signature</CardTitle>
|
||||
<CardDescription>Associez une signature à chaque service pour l'apposer automatiquement lors de l'export PDF BAP</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Service</TableHead>
|
||||
<TableHead>Signature associée</TableHead>
|
||||
<TableHead>Aperçu</TableHead>
|
||||
<TableHead className="w-20">Action</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{departments.map((dept: { id: number; name: string; userId: number; createdAt: Date }) => {
|
||||
const assoc = getAssociation(dept.name);
|
||||
const currentSigId = assoc?.signatureId ?? 0;
|
||||
return (
|
||||
<TableRow key={dept.id}>
|
||||
<TableCell className="font-medium">{dept.name}</TableCell>
|
||||
<TableCell>
|
||||
<select
|
||||
className="w-full border rounded-md px-3 py-1.5 text-sm bg-background"
|
||||
value={currentSigId}
|
||||
onChange={(e) => {
|
||||
const sigId = parseInt(e.target.value);
|
||||
if (sigId === 0) {
|
||||
deleteMutation.mutate({ serviceName: dept.name });
|
||||
} else {
|
||||
upsertMutation.mutate({ serviceName: dept.name, signatureId: sigId });
|
||||
}
|
||||
}}
|
||||
>
|
||||
<option value={0}>— Aucune signature —</option>
|
||||
{signatures.map(sig => (
|
||||
<option key={sig.id} value={sig.id}>
|
||||
{sig.firstName} {sig.lastName}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{assoc && signatures.find(s => s.id === assoc.signatureId) ? (
|
||||
<div className="h-10 w-24 border rounded overflow-hidden bg-white flex items-center justify-center">
|
||||
<img
|
||||
src={signatures.find(s => s.id === assoc.signatureId)!.imageUrl}
|
||||
alt="aperçu"
|
||||
className="max-h-full max-w-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground italic">Aucune</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{assoc && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-red-500 hover:text-red-700 hover:bg-red-50 h-7 w-7 p-0"
|
||||
onClick={() => deleteMutation.mutate({ serviceName: dept.name })}
|
||||
disabled={deleteMutation.isPending}
|
||||
title="Supprimer l'association"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function LlmFieldsConfigSection() {
|
||||
const { data: fields, isLoading } = trpc.llmFieldsConfig.getAll.useQuery();
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
10
drizzle/0014_watery_talos.sql
Normal file
10
drizzle/0014_watery_talos.sql
Normal file
@@ -0,0 +1,10 @@
|
||||
CREATE TABLE `serviceSignatures` (
|
||||
`id` int AUTO_INCREMENT NOT NULL,
|
||||
`userId` int NOT NULL,
|
||||
`serviceName` varchar(100) NOT NULL,
|
||||
`signatureId` int NOT NULL,
|
||||
`createdAt` timestamp NOT NULL DEFAULT (now()),
|
||||
`updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT `serviceSignatures_id` PRIMARY KEY(`id`),
|
||||
CONSTRAINT `user_service_unique` UNIQUE(`userId`,`serviceName`)
|
||||
);
|
||||
1450
drizzle/meta/0014_snapshot.json
Normal file
1450
drizzle/meta/0014_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -99,6 +99,13 @@
|
||||
"when": 1773528566252,
|
||||
"tag": "0013_absent_santa_claus",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 14,
|
||||
"version": "5",
|
||||
"when": 1773530695041,
|
||||
"tag": "0014_watery_talos",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -323,3 +323,24 @@ export const signatures = mysqlTable("signatures", {
|
||||
|
||||
export type Signature = typeof signatures.$inferSelect;
|
||||
export type InsertSignature = typeof signatures.$inferInsert;
|
||||
|
||||
/**
|
||||
* Service-Signature association table
|
||||
* Associates a department/service with a specific signature for PDF exports
|
||||
*/
|
||||
export const serviceSignatures = mysqlTable("serviceSignatures", {
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
userId: int("userId").notNull(),
|
||||
serviceName: varchar("serviceName", { length: 100 }).notNull(), // Department name (e.g., "DSI", "TRAVAUX")
|
||||
signatureId: int("signatureId").notNull(), // FK to signatures.id
|
||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
|
||||
}, (table) => {
|
||||
return {
|
||||
// One association per service per user
|
||||
userServiceIdx: uniqueIndex("user_service_unique").on(table.userId, table.serviceName),
|
||||
};
|
||||
});
|
||||
|
||||
export type ServiceSignature = typeof serviceSignatures.$inferSelect;
|
||||
export type InsertServiceSignature = typeof serviceSignatures.$inferInsert;
|
||||
|
||||
42
server/db.ts
42
server/db.ts
@@ -35,7 +35,10 @@ import {
|
||||
LlmFieldConfig,
|
||||
signatures,
|
||||
InsertSignature,
|
||||
Signature
|
||||
Signature,
|
||||
serviceSignatures,
|
||||
InsertServiceSignature,
|
||||
ServiceSignature
|
||||
} from "../drizzle/schema";
|
||||
import { ENV } from './_core/env';
|
||||
|
||||
@@ -701,3 +704,40 @@ export async function deleteSignature(id: number): Promise<void> {
|
||||
if (!db) return;
|
||||
await db.delete(signatures).where(eq(signatures.id, id));
|
||||
}
|
||||
|
||||
// ============= SERVICE SIGNATURES HELPERS =============
|
||||
|
||||
export async function getServiceSignaturesByUser(userId: number): Promise<ServiceSignature[]> {
|
||||
const db = await getDb();
|
||||
if (!db) return [];
|
||||
return db.select().from(serviceSignatures).where(eq(serviceSignatures.userId, userId));
|
||||
}
|
||||
|
||||
export async function getServiceSignatureByService(userId: number, serviceName: string): Promise<ServiceSignature | undefined> {
|
||||
const db = await getDb();
|
||||
if (!db) return undefined;
|
||||
const results = await db.select().from(serviceSignatures)
|
||||
.where(eq(serviceSignatures.userId, userId))
|
||||
.limit(1);
|
||||
// Filter in JS to avoid SQL case sensitivity issues
|
||||
return results.find(r => r.serviceName.toLowerCase() === serviceName.toLowerCase());
|
||||
}
|
||||
|
||||
export async function upsertServiceSignature(data: InsertServiceSignature): Promise<void> {
|
||||
const db = await getDb();
|
||||
if (!db) throw new Error("Database not available");
|
||||
await db.insert(serviceSignatures).values(data)
|
||||
.onDuplicateKeyUpdate({
|
||||
set: { signatureId: data.signatureId, updatedAt: new Date() }
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteServiceSignature(userId: number, serviceName: string): Promise<void> {
|
||||
const db = await getDb();
|
||||
if (!db) return;
|
||||
const all = await db.select().from(serviceSignatures).where(eq(serviceSignatures.userId, userId));
|
||||
const match = all.find(r => r.serviceName.toLowerCase() === serviceName.toLowerCase());
|
||||
if (match) {
|
||||
await db.delete(serviceSignatures).where(eq(serviceSignatures.id, match.id));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,6 +60,9 @@ import {
|
||||
getSignatureById,
|
||||
createSignature,
|
||||
deleteSignature,
|
||||
getServiceSignaturesByUser,
|
||||
upsertServiceSignature,
|
||||
deleteServiceSignature,
|
||||
} from "./db";
|
||||
import { loginLocal, hashPassword, getAzureAuthUrl, isAzureAdConfigured, generateToken } from "./auth";
|
||||
import { extractInvoicesWithMistral, generateMetadataJSON } from "./invoiceExtractor";
|
||||
@@ -568,11 +571,15 @@ export const appRouter = router({
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const fs = await import('fs/promises');
|
||||
const path = await import('path');
|
||||
const { PDFDocument } = await import('pdf-lib');
|
||||
|
||||
// Get export folder from settings
|
||||
const settings = await getImportSettingsByUser(ctx.user.id);
|
||||
const exportFolder = settings?.exportFolder;
|
||||
|
||||
// Load service→signature associations
|
||||
const serviceAssociations = await getServiceSignaturesByUser(ctx.user.id);
|
||||
|
||||
if (!exportFolder) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
@@ -622,9 +629,76 @@ export const appRouter = router({
|
||||
const filename = path.basename(invoice.fileKey);
|
||||
const destPath = path.join(exportFolder, filename);
|
||||
|
||||
// Copy file
|
||||
await fs.copyFile(sourcePath, destPath);
|
||||
copiedFiles.push(destPath);
|
||||
// Find signature associated with invoice service
|
||||
const serviceName = invoice.serviceConcerne || '';
|
||||
const assoc = serviceAssociations.find(
|
||||
a => a.serviceName.toLowerCase() === serviceName.toLowerCase()
|
||||
);
|
||||
|
||||
if (assoc) {
|
||||
// Load signature image and embed it in the PDF
|
||||
try {
|
||||
const sig = await getSignatureById(assoc.signatureId);
|
||||
if (sig) {
|
||||
const sigImagePath = path.join(STORAGE_BASE_PATH, sig.imageKey);
|
||||
const pdfBytes = await fs.readFile(sourcePath);
|
||||
const pdfDoc = await PDFDocument.load(pdfBytes);
|
||||
const pages = pdfDoc.getPages();
|
||||
const lastPage = pages[pages.length - 1];
|
||||
const { width, height } = lastPage.getSize();
|
||||
|
||||
// Read signature image
|
||||
const sigImageBytes = await fs.readFile(sigImagePath);
|
||||
const sigImageBase64 = sigImageBytes.toString('base64');
|
||||
const mimeType = sig.imageKey.toLowerCase().endsWith('.png') ? 'image/png' : 'image/jpeg';
|
||||
|
||||
let embeddedSig;
|
||||
if (mimeType === 'image/png') {
|
||||
embeddedSig = await pdfDoc.embedPng(sigImageBytes);
|
||||
} else {
|
||||
embeddedSig = await pdfDoc.embedJpg(sigImageBytes);
|
||||
}
|
||||
|
||||
// Draw signature in bottom-right corner
|
||||
const sigWidth = 120;
|
||||
const sigHeight = 50;
|
||||
const margin = 30;
|
||||
lastPage.drawImage(embeddedSig, {
|
||||
x: width - sigWidth - margin,
|
||||
y: margin,
|
||||
width: sigWidth,
|
||||
height: sigHeight,
|
||||
});
|
||||
|
||||
// Add signer name below signature
|
||||
const { StandardFonts } = await import('pdf-lib');
|
||||
const font = await pdfDoc.embedFont(StandardFonts.Helvetica);
|
||||
lastPage.drawText(`${sig.firstName} ${sig.lastName}`, {
|
||||
x: width - sigWidth - margin,
|
||||
y: margin - 14,
|
||||
size: 9,
|
||||
font,
|
||||
});
|
||||
|
||||
const signedPdfBytes = await pdfDoc.save();
|
||||
await fs.writeFile(destPath, signedPdfBytes);
|
||||
copiedFiles.push(destPath);
|
||||
console.log(`[Export] Signature apposée pour ${serviceName} sur ${filename}`);
|
||||
} else {
|
||||
// Signature not found, copy without signature
|
||||
await fs.copyFile(sourcePath, destPath);
|
||||
copiedFiles.push(destPath);
|
||||
}
|
||||
} catch (sigError: any) {
|
||||
console.warn(`[Export] Impossible d'apposer la signature: ${sigError.message}. Copie sans signature.`);
|
||||
await fs.copyFile(sourcePath, destPath);
|
||||
copiedFiles.push(destPath);
|
||||
}
|
||||
} else {
|
||||
// No signature association, copy as-is
|
||||
await fs.copyFile(sourcePath, destPath);
|
||||
copiedFiles.push(destPath);
|
||||
}
|
||||
|
||||
// Update export status
|
||||
await updateInvoice(invoice.id, {
|
||||
@@ -1222,7 +1296,7 @@ export const appRouter = router({
|
||||
});
|
||||
}),
|
||||
|
||||
delete: protectedProcedure
|
||||
delete: protectedProcedure
|
||||
.input(z.object({ id: z.number() }))
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const sig = await getSignatureById(input.id);
|
||||
@@ -1233,6 +1307,33 @@ export const appRouter = router({
|
||||
return { success: true };
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
// ============= SERVICE SIGNATURES ROUTES =============
|
||||
serviceSignatures: router({
|
||||
list: protectedProcedure.query(async ({ ctx }) => {
|
||||
return await getServiceSignaturesByUser(ctx.user.id);
|
||||
}),
|
||||
|
||||
upsert: protectedProcedure
|
||||
.input(z.object({
|
||||
serviceName: z.string().min(1).max(100),
|
||||
signatureId: z.number(),
|
||||
}))
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
await upsertServiceSignature({
|
||||
userId: ctx.user.id,
|
||||
serviceName: input.serviceName,
|
||||
signatureId: input.signatureId,
|
||||
});
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
delete: protectedProcedure
|
||||
.input(z.object({ serviceName: z.string() }))
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
await deleteServiceSignature(ctx.user.id, input.serviceName);
|
||||
return { success: true };
|
||||
}),
|
||||
}),
|
||||
});
|
||||
export type AppRouter = typeof appRouter;
|
||||
|
||||
10
todo.md
10
todo.md
@@ -531,3 +531,13 @@
|
||||
## Correction affichage canvas signature
|
||||
- [x] Corriger le problème d'initialisation du canvas (fond blanc non affiché)
|
||||
- [x] S'assurer que le canvas est visible et fonctionnel dans l'onglet Signatures
|
||||
|
||||
## Association Service → Signature et export PDF avec signature
|
||||
- [x] Créer la table `serviceSignatures` dans drizzle/schema.ts (serviceName → signatureId)
|
||||
- [x] Appliquer la migration DB (pnpm db:push)
|
||||
- [x] Créer les routes tRPC : serviceSignatures.list, serviceSignatures.upsert, serviceSignatures.delete
|
||||
- [x] Ajouter une section "Association Service → Signature" dans l'onglet Signatures des Paramètres
|
||||
- [x] Afficher la liste des services disponibles avec un sélecteur de signature pour chacun
|
||||
- [x] Modifier la route exportToPdf pour récupérer la signature associée au service de la facture
|
||||
- [x] Apposer la signature sur le PDF exporté (image en bas à droite + nom du signataire)
|
||||
- [ ] Tester l'export PDF avec signature automatique
|
||||
|
||||
Reference in New Issue
Block a user