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 { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
@@ -25,7 +25,7 @@ import {
Upload,
User
} from "lucide-react";
import { useRef, useState as useStateAlias } from "react";
// useRef, useCallback already imported above
import { toast } from "sonner";
import { Checkbox } from "@/components/ui/checkbox";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
@@ -34,13 +34,93 @@ function SignaturesSection() {
const utils = trpc.useUtils();
const { data: signatures, isLoading } = trpc.signatures.list.useQuery();
const [firstName, setFirstName] = useStateAlias("");
const [lastName, setLastName] = useStateAlias("");
const [previewUrl, setPreviewUrl] = useStateAlias<string | null>(null);
const [fileData, setFileData] = useStateAlias<string | null>(null);
const [fileName, setFileName] = useStateAlias("");
const [mimeType, setMimeType] = useStateAlias("image/png");
const [firstName, setFirstName] = useState("");
const [lastName, setLastName] = useState("");
const [mode, setMode] = useState<"file" | "draw">("draw");
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [fileData, setFileData] = useState<string | null>(null);
const [fileName, setFileName] = useState("");
const [mimeType, setMimeType] = useState("image/png");
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({
onSuccess: () => {
@@ -96,11 +176,23 @@ function SignaturesSection() {
toast.error("Veuillez renseigner le prénom et le nom");
return;
}
if (!fileData) {
toast.error("Veuillez sélectionner une image de signature");
// In draw mode, capture canvas first
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;
}
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 (
@@ -124,6 +216,8 @@ function SignaturesSection() {
{/* Add form */}
<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>
{/* Nom / Prénom */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="sig-firstname" className="font-medium">Prénom</Label>
@@ -146,6 +240,66 @@ function SignaturesSection() {
/>
</div>
</div>
{/* Mode selector */}
<div className="flex gap-2">
<Button
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(""); }}
>
<PenLine className="w-4 h-4 mr-1" /> Dessiner
</Button>
<Button
type="button"
variant={mode === "file" ? "default" : "outline"}
size="sm"
className={mode === "file" ? "bg-emerald-600 hover:bg-emerald-700 text-white" : ""}
onClick={() => { setMode("file"); setPreviewUrl(null); setFileData(null); setFileName(""); clearCanvas(); }}
>
<Upload className="w-4 h-4 mr-1" /> Importer un fichier
</Button>
</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
@@ -175,9 +329,11 @@ function SignaturesSection() {
</Button>
)}
</div>
)}
<Button
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"
>
{uploadMutation.isPending ? (

View File

@@ -519,3 +519,11 @@
- [x] Afficher la liste des signatures avec aperçu de l'image en grille
- [x] Ajouter le bouton de suppression par signature (hover)
- [ ] 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