Checkpoint: Amélioration de l'onglet Signatures : ajout d'un canvas interactif pour dessiner la signature à la souris/tactile, sélecteur de mode Dessiner/Importer, bouton Effacer, export automatique du canvas en PNG base64 lors de l'enregistrement

This commit is contained in:
Manus
2026-03-14 19:07:03 -04:00
parent 33a82e2ad2
commit 20be9c6060
2 changed files with 203 additions and 39 deletions

View File

@@ -1,4 +1,4 @@
import { useState, useEffect } from "react"; import { useState, useEffect, useRef, useCallback } from "react";
import DashboardLayout from "@/components/DashboardLayout"; import DashboardLayout from "@/components/DashboardLayout";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -25,7 +25,7 @@ import {
Upload, Upload,
User User
} from "lucide-react"; } from "lucide-react";
import { useRef, useState as useStateAlias } from "react"; // useRef, useCallback already imported above
import { toast } from "sonner"; import { toast } from "sonner";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
@@ -34,13 +34,93 @@ function SignaturesSection() {
const utils = trpc.useUtils(); const utils = trpc.useUtils();
const { data: signatures, isLoading } = trpc.signatures.list.useQuery(); const { data: signatures, isLoading } = trpc.signatures.list.useQuery();
const [firstName, setFirstName] = useStateAlias(""); const [firstName, setFirstName] = useState("");
const [lastName, setLastName] = useStateAlias(""); const [lastName, setLastName] = useState("");
const [previewUrl, setPreviewUrl] = useStateAlias<string | null>(null); const [mode, setMode] = useState<"file" | "draw">("draw");
const [fileData, setFileData] = useStateAlias<string | null>(null); const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [fileName, setFileName] = useStateAlias(""); const [fileData, setFileData] = useState<string | null>(null);
const [mimeType, setMimeType] = useStateAlias("image/png"); const [fileName, setFileName] = useState("");
const [mimeType, setMimeType] = useState("image/png");
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const isDrawingRef = useRef(false);
const lastPosRef = useRef<{ x: number; y: number } | null>(null);
// Initialize canvas
useEffect(() => {
if (mode !== "draw") return;
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
ctx.fillStyle = "#ffffff";
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.strokeStyle = "#1e293b";
ctx.lineWidth = 2;
ctx.lineCap = "round";
ctx.lineJoin = "round";
}, [mode]);
const getPos = useCallback((e: React.MouseEvent<HTMLCanvasElement> | React.TouchEvent<HTMLCanvasElement>, canvas: HTMLCanvasElement) => {
const rect = canvas.getBoundingClientRect();
const scaleX = canvas.width / rect.width;
const scaleY = canvas.height / rect.height;
if ("touches" in e) {
const touch = e.touches[0];
return { x: (touch.clientX - rect.left) * scaleX, y: (touch.clientY - rect.top) * scaleY };
}
return { x: (e.clientX - rect.left) * scaleX, y: (e.clientY - rect.top) * scaleY };
}, []);
const startDrawing = useCallback((e: React.MouseEvent<HTMLCanvasElement> | React.TouchEvent<HTMLCanvasElement>) => {
e.preventDefault();
const canvas = canvasRef.current;
if (!canvas) return;
isDrawingRef.current = true;
lastPosRef.current = getPos(e, canvas);
}, [getPos]);
const draw = useCallback((e: React.MouseEvent<HTMLCanvasElement> | React.TouchEvent<HTMLCanvasElement>) => {
e.preventDefault();
if (!isDrawingRef.current) return;
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx || !lastPosRef.current) return;
const pos = getPos(e, canvas);
ctx.beginPath();
ctx.moveTo(lastPosRef.current.x, lastPosRef.current.y);
ctx.lineTo(pos.x, pos.y);
ctx.stroke();
lastPosRef.current = pos;
}, [getPos]);
const stopDrawing = useCallback(() => {
isDrawingRef.current = false;
lastPosRef.current = null;
}, []);
const clearCanvas = useCallback(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
ctx.fillStyle = "#ffffff";
ctx.fillRect(0, 0, canvas.width, canvas.height);
setFileData(null);
setPreviewUrl(null);
}, []);
const captureCanvas = useCallback(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const dataUrl = canvas.toDataURL("image/png");
const base64 = dataUrl.split(",")[1];
setFileData(base64);
setPreviewUrl(dataUrl);
setFileName("signature-dessinee.png");
setMimeType("image/png");
}, []);
const uploadMutation = trpc.signatures.upload.useMutation({ const uploadMutation = trpc.signatures.upload.useMutation({
onSuccess: () => { onSuccess: () => {
@@ -96,11 +176,23 @@ function SignaturesSection() {
toast.error("Veuillez renseigner le prénom et le nom"); toast.error("Veuillez renseigner le prénom et le nom");
return; return;
} }
if (!fileData) { // In draw mode, capture canvas first
toast.error("Veuillez sélectionner une image de signature"); let finalFileData = fileData;
let finalFileName = fileName;
let finalMimeType = mimeType;
if (mode === "draw") {
const canvas = canvasRef.current;
if (!canvas) { toast.error("Erreur canvas"); return; }
const dataUrl = canvas.toDataURL("image/png");
finalFileData = dataUrl.split(",")[1];
finalFileName = "signature-dessinee.png";
finalMimeType = "image/png";
}
if (!finalFileData) {
toast.error(mode === "draw" ? "Veuillez dessiner votre signature" : "Veuillez sélectionner une image de signature");
return; return;
} }
uploadMutation.mutate({ firstName: firstName.trim(), lastName: lastName.trim(), fileName, fileData, mimeType }); uploadMutation.mutate({ firstName: firstName.trim(), lastName: lastName.trim(), fileName: finalFileName, fileData: finalFileData, mimeType: finalMimeType });
}; };
return ( return (
@@ -124,6 +216,8 @@ function SignaturesSection() {
{/* Add form */} {/* Add form */}
<div className="p-4 bg-muted/30 rounded-lg border border-dashed border-muted-foreground/30 space-y-4"> <div className="p-4 bg-muted/30 rounded-lg border border-dashed border-muted-foreground/30 space-y-4">
<p className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">Ajouter une signature</p> <p className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">Ajouter une signature</p>
{/* Nom / Prénom */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="sig-firstname" className="font-medium">Prénom</Label> <Label htmlFor="sig-firstname" className="font-medium">Prénom</Label>
@@ -146,38 +240,100 @@ function SignaturesSection() {
/> />
</div> </div>
</div> </div>
<div className="space-y-2">
<Label className="font-medium">Image de la signature</Label> {/* Mode selector */}
<div <div className="flex gap-2">
className="flex items-center gap-4 p-3 border-2 border-dashed rounded-lg cursor-pointer hover:border-primary/50 transition-colors" <Button
onClick={() => fileInputRef.current?.click()} type="button"
variant={mode === "draw" ? "default" : "outline"}
size="sm"
className={mode === "draw" ? "bg-emerald-600 hover:bg-emerald-700 text-white" : ""}
onClick={() => { setMode("draw"); setPreviewUrl(null); setFileData(null); setFileName(""); }}
> >
{previewUrl ? ( <PenLine className="w-4 h-4 mr-1" /> Dessiner
<img src={previewUrl} alt="Aperçu signature" className="h-16 max-w-[200px] object-contain rounded border bg-white p-1" /> </Button>
) : ( <Button
<div className="flex flex-col items-center justify-center w-full py-4 text-muted-foreground gap-2"> type="button"
<Upload className="w-8 h-8" /> variant={mode === "file" ? "default" : "outline"}
<span className="text-sm">Cliquez pour sélectionner une image (PNG, JPG, GIF...)</span> size="sm"
<span className="text-xs">Taille max : 2 Mo</span> className={mode === "file" ? "bg-emerald-600 hover:bg-emerald-700 text-white" : ""}
</div> onClick={() => { setMode("file"); setPreviewUrl(null); setFileData(null); setFileName(""); clearCanvas(); }}
)} >
<input <Upload className="w-4 h-4 mr-1" /> Importer un fichier
ref={fileInputRef} </Button>
type="file"
accept="image/*"
className="hidden"
onChange={handleFileChange}
/>
</div>
{previewUrl && (
<Button variant="ghost" size="sm" className="text-muted-foreground" onClick={() => { setPreviewUrl(null); setFileData(null); setFileName(""); if (fileInputRef.current) fileInputRef.current.value = ""; }}>
Changer l'image
</Button>
)}
</div> </div>
{/* Draw mode */}
{mode === "draw" && (
<div className="space-y-2">
<Label className="font-medium">Dessinez votre signature ci-dessous</Label>
<div className="relative rounded-lg border-2 border-slate-300 bg-white overflow-hidden" style={{ touchAction: "none" }}>
<canvas
ref={canvasRef}
width={600}
height={180}
className="w-full cursor-crosshair block"
style={{ touchAction: "none" }}
onMouseDown={startDrawing}
onMouseMove={draw}
onMouseUp={stopDrawing}
onMouseLeave={stopDrawing}
onTouchStart={startDrawing}
onTouchMove={draw}
onTouchEnd={stopDrawing}
/>
<div className="absolute bottom-1 right-2 text-xs text-slate-400 pointer-events-none select-none">
Signez ici
</div>
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={clearCanvas}
className="text-muted-foreground"
>
<Trash2 className="w-3.5 h-3.5 mr-1" /> Effacer
</Button>
</div>
)}
{/* File mode */}
{mode === "file" && (
<div className="space-y-2">
<Label className="font-medium">Image de la signature</Label>
<div
className="flex items-center gap-4 p-3 border-2 border-dashed rounded-lg cursor-pointer hover:border-primary/50 transition-colors"
onClick={() => fileInputRef.current?.click()}
>
{previewUrl ? (
<img src={previewUrl} alt="Aperçu signature" className="h-16 max-w-[200px] object-contain rounded border bg-white p-1" />
) : (
<div className="flex flex-col items-center justify-center w-full py-4 text-muted-foreground gap-2">
<Upload className="w-8 h-8" />
<span className="text-sm">Cliquez pour sélectionner une image (PNG, JPG, GIF...)</span>
<span className="text-xs">Taille max : 2 Mo</span>
</div>
)}
<input
ref={fileInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={handleFileChange}
/>
</div>
{previewUrl && (
<Button variant="ghost" size="sm" className="text-muted-foreground" onClick={() => { setPreviewUrl(null); setFileData(null); setFileName(""); if (fileInputRef.current) fileInputRef.current.value = ""; }}>
Changer l'image
</Button>
)}
</div>
)}
<Button <Button
onClick={handleAdd} onClick={handleAdd}
disabled={uploadMutation.isPending || !firstName || !lastName || !fileData} disabled={uploadMutation.isPending || !firstName || !lastName}
className="w-full h-10 bg-emerald-600 hover:bg-emerald-700 text-white gap-2" className="w-full h-10 bg-emerald-600 hover:bg-emerald-700 text-white gap-2"
> >
{uploadMutation.isPending ? ( {uploadMutation.isPending ? (

View File

@@ -519,3 +519,11 @@
- [x] Afficher la liste des signatures avec aperçu de l'image en grille - [x] Afficher la liste des signatures avec aperçu de l'image en grille
- [x] Ajouter le bouton de suppression par signature (hover) - [x] Ajouter le bouton de suppression par signature (hover)
- [ ] Tester l'upload et l'affichage des signatures sur le VPS - [ ] Tester l'upload et l'affichage des signatures sur le VPS
## Canvas de signature à la souris (onglet Signatures)
- [x] Ajouter un sélecteur de mode : "Importer un fichier" / "Dessiner la signature"
- [x] Créer un composant canvas interactif pour dessiner la signature à la souris
- [x] Gérer les événements souris (mousedown, mousemove, mouseup) et tactile (touch)
- [x] Ajouter un bouton "Effacer" pour réinitialiser le canvas
- [x] Exporter le canvas en PNG base64 pour l'upload
- [ ] Tester le dessin à la souris et l'enregistrement