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:
@@ -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,38 +240,100 @@ function SignaturesSection() {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<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()}
|
||||
|
||||
{/* 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(""); }}
|
||||
>
|
||||
{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>
|
||||
)}
|
||||
<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
|
||||
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
|
||||
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 ? (
|
||||
|
||||
8
todo.md
8
todo.md
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user