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:
Manus
2026-03-14 19:28:50 -04:00
parent cf886a1adb
commit ee49992530
8 changed files with 1790 additions and 6 deletions

View File

@@ -402,10 +402,155 @@ function SignaturesSection() {
)} )}
</CardContent> </CardContent>
</Card> </Card>
{/* Service → Signature associations */}
<ServiceSignaturesSection signatures={signatures || []} />
</div> </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() { function LlmFieldsConfigSection() {
const { data: fields, isLoading } = trpc.llmFieldsConfig.getAll.useQuery(); const { data: fields, isLoading } = trpc.llmFieldsConfig.getAll.useQuery();
const utils = trpc.useUtils(); const utils = trpc.useUtils();

View 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`)
);

File diff suppressed because it is too large Load Diff

View File

@@ -99,6 +99,13 @@
"when": 1773528566252, "when": 1773528566252,
"tag": "0013_absent_santa_claus", "tag": "0013_absent_santa_claus",
"breakpoints": true "breakpoints": true
},
{
"idx": 14,
"version": "5",
"when": 1773530695041,
"tag": "0014_watery_talos",
"breakpoints": true
} }
] ]
} }

View File

@@ -323,3 +323,24 @@ export const signatures = mysqlTable("signatures", {
export type Signature = typeof signatures.$inferSelect; export type Signature = typeof signatures.$inferSelect;
export type InsertSignature = typeof signatures.$inferInsert; 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;

View File

@@ -35,7 +35,10 @@ import {
LlmFieldConfig, LlmFieldConfig,
signatures, signatures,
InsertSignature, InsertSignature,
Signature Signature,
serviceSignatures,
InsertServiceSignature,
ServiceSignature
} from "../drizzle/schema"; } from "../drizzle/schema";
import { ENV } from './_core/env'; import { ENV } from './_core/env';
@@ -701,3 +704,40 @@ export async function deleteSignature(id: number): Promise<void> {
if (!db) return; if (!db) return;
await db.delete(signatures).where(eq(signatures.id, id)); 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));
}
}

View File

@@ -60,6 +60,9 @@ import {
getSignatureById, getSignatureById,
createSignature, createSignature,
deleteSignature, deleteSignature,
getServiceSignaturesByUser,
upsertServiceSignature,
deleteServiceSignature,
} from "./db"; } from "./db";
import { loginLocal, hashPassword, getAzureAuthUrl, isAzureAdConfigured, generateToken } from "./auth"; import { loginLocal, hashPassword, getAzureAuthUrl, isAzureAdConfigured, generateToken } from "./auth";
import { extractInvoicesWithMistral, generateMetadataJSON } from "./invoiceExtractor"; import { extractInvoicesWithMistral, generateMetadataJSON } from "./invoiceExtractor";
@@ -568,11 +571,15 @@ export const appRouter = router({
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
const fs = await import('fs/promises'); const fs = await import('fs/promises');
const path = await import('path'); const path = await import('path');
const { PDFDocument } = await import('pdf-lib');
// Get export folder from settings // Get export folder from settings
const settings = await getImportSettingsByUser(ctx.user.id); const settings = await getImportSettingsByUser(ctx.user.id);
const exportFolder = settings?.exportFolder; const exportFolder = settings?.exportFolder;
// Load service→signature associations
const serviceAssociations = await getServiceSignaturesByUser(ctx.user.id);
if (!exportFolder) { if (!exportFolder) {
throw new TRPCError({ throw new TRPCError({
code: "BAD_REQUEST", code: "BAD_REQUEST",
@@ -622,9 +629,76 @@ export const appRouter = router({
const filename = path.basename(invoice.fileKey); const filename = path.basename(invoice.fileKey);
const destPath = path.join(exportFolder, filename); const destPath = path.join(exportFolder, filename);
// Copy file // Find signature associated with invoice service
await fs.copyFile(sourcePath, destPath); const serviceName = invoice.serviceConcerne || '';
copiedFiles.push(destPath); 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 // Update export status
await updateInvoice(invoice.id, { await updateInvoice(invoice.id, {
@@ -1222,7 +1296,7 @@ export const appRouter = router({
}); });
}), }),
delete: protectedProcedure delete: protectedProcedure
.input(z.object({ id: z.number() })) .input(z.object({ id: z.number() }))
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
const sig = await getSignatureById(input.id); const sig = await getSignatureById(input.id);
@@ -1233,6 +1307,33 @@ export const appRouter = router({
return { success: true }; 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; export type AppRouter = typeof appRouter;

10
todo.md
View File

@@ -531,3 +531,13 @@
## Correction affichage canvas signature ## Correction affichage canvas signature
- [x] Corriger le problème d'initialisation du canvas (fond blanc non affiché) - [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 - [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