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>
|
</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();
|
||||||
|
|||||||
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,
|
"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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
42
server/db.ts
42
server/db.ts
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
10
todo.md
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user