feat: Ajout de l'upload manuel du contrat signé dans staff/contrats/[id]

- Création de l'API route /api/staff/contrats/[id]/upload-signed-pdf
  - Accepte un fichier PDF via FormData
  - Stocke le PDF dans S3 avec la clé contracts/<org-slug>/<contract_number>.pdf
  - Met à jour contract_pdf_s3_key et contrat_signe='Oui' dans la BDD

- Création du composant ManualSignedContractUpload
  - Modal avec drag & drop pour sélectionner un PDF
  - Validation du type de fichier et de la taille (max 10 Mo)
  - Feedback visuel de l'upload (loading, succès)

- Intégration dans ContractEditor
  - Ajout du bouton d'upload dans la section Documents
  - Invalidation de la query après upload pour rafraîchir automatiquement
  - Affichage du contrat uploadé comme ceux signés via Docuseal

Permet de gérer les contrats signés reçus par email ou autre moyen externe
This commit is contained in:
odentas 2025-10-22 17:55:05 +02:00
parent 542e0e963d
commit b0fea6813b
3 changed files with 395 additions and 3 deletions

View file

@ -0,0 +1,162 @@
// app/api/staff/contrats/[id]/upload-signed-pdf/route.ts
import { NextRequest, NextResponse } from "next/server";
import { createSbServer } from "@/lib/supabaseServer";
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
const REGION = process.env.AWS_REGION || "eu-west-3";
const BUCKET = (process.env.AWS_S3_BUCKET || "odentas-docs").trim();
const s3Client = new S3Client({
region: REGION,
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
},
});
export async function POST(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const sb = createSbServer();
// Vérification de l'authentification
const { data: { user }, error: authError } = await sb.auth.getUser();
if (authError || !user) {
return NextResponse.json(
{ error: "Non autorisé" },
{ status: 401 }
);
}
// Vérification des droits staff
const { data: me } = await sb
.from("staff_users")
.select("is_staff")
.eq("user_id", user.id)
.maybeSingle();
if (!me?.is_staff) {
return NextResponse.json(
{ error: "Accès refusé - staff requis" },
{ status: 403 }
);
}
// Récupération du contrat
const { data: contract, error: contractError } = await sb
.from("cddu_contracts")
.select("id, contract_number, organization_id, contract_pdf_s3_key")
.eq("id", params.id)
.single();
if (contractError || !contract) {
return NextResponse.json(
{ error: "Contrat introuvable" },
{ status: 404 }
);
}
// Récupération de l'organisation pour obtenir l'api_name
let orgApiName = "unknown_org";
if (contract.organization_id) {
const { data: org } = await sb
.from("organizations")
.select("api_name")
.eq("id", contract.organization_id)
.single();
if (org?.api_name) {
orgApiName = org.api_name;
}
}
// Récupération du fichier depuis FormData
const formData = await request.formData();
const file = formData.get("file") as File;
if (!file) {
return NextResponse.json(
{ error: "Aucun fichier fourni" },
{ status: 400 }
);
}
// Vérification du type MIME
if (file.type !== "application/pdf") {
return NextResponse.json(
{ error: "Le fichier doit être un PDF" },
{ status: 400 }
);
}
// Conversion du fichier en buffer
const arrayBuffer = await file.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
// Génération de la clé S3 (même format que Docuseal)
// Format: contracts/<org-slug>/<contract_number>.pdf
const s3Key = `contracts/${orgApiName}/${contract.contract_number}.pdf`;
console.log("📤 Upload manuel du contrat signé:", {
contractId: contract.id,
contractNumber: contract.contract_number,
s3Key,
fileSize: buffer.length,
});
// Upload vers S3
const uploadParams = {
Bucket: BUCKET,
Key: s3Key,
Body: buffer,
ContentType: "application/pdf",
ACL: "private" as const,
};
try {
const command = new PutObjectCommand(uploadParams);
await s3Client.send(command);
console.log("✅ PDF uploadé avec succès dans S3:", s3Key);
} catch (s3Error) {
console.error("❌ Erreur lors de l'upload S3:", s3Error);
return NextResponse.json(
{ error: "Erreur lors de l'upload du fichier" },
{ status: 500 }
);
}
// Mise à jour du contrat avec la clé S3
const { error: updateError } = await sb
.from("cddu_contracts")
.update({
contract_pdf_s3_key: s3Key,
contrat_signe: "Oui", // Marquer le contrat comme signé
})
.eq("id", contract.id);
if (updateError) {
console.error("❌ Erreur lors de la mise à jour du contrat:", updateError);
return NextResponse.json(
{ error: "Erreur lors de la mise à jour du contrat" },
{ status: 500 }
);
}
console.log("✅ Contrat mis à jour avec la clé S3:", contract.id);
return NextResponse.json({
success: true,
s3Key,
message: "Contrat signé uploadé avec succès",
});
} catch (error) {
console.error("❌ Erreur lors de l'upload du contrat signé:", error);
return NextResponse.json(
{ error: "Erreur interne du serveur" },
{ status: 500 }
);
}
}

View file

@ -2,7 +2,7 @@
"use client";
import { useMemo, useState, useEffect, useRef, useCallback } from "react";
import { useQuery } from "@tanstack/react-query";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
@ -10,7 +10,7 @@ import { Textarea } from "@/components/ui/textarea";
import { Separator } from "@/components/ui/separator";
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from "@/components/ui/sheet";
import { toast } from "sonner";
import { CalendarRange, FilePlus2, FileText, Save, Search, FileDown, PenTool, RefreshCw, Mail, Clock, CheckCircle2, XCircle, Users, Send, Check } from "lucide-react";
import { CalendarRange, FilePlus2, FileText, Save, Search, FileDown, PenTool, RefreshCw, Mail, Clock, CheckCircle2, XCircle, Users, Send, Check, Upload } from "lucide-react";
import PayslipForm from "./PayslipForm";
import { api } from "@/lib/fetcher";
import { PROFESSIONS_ARTISTE } from "@/components/constants/ProfessionsArtiste";
@ -24,6 +24,7 @@ import DatePickerCalendar from "@/components/DatePickerCalendar";
import DatesQuantityModal from "@/components/DatesQuantityModal";
import { parseDateString } from "@/lib/dateFormatter";
import { supabase } from "@/lib/supabaseClient";
import { ManualSignedContractUpload } from "./ManualSignedContractUpload";
type AnyObj = Record<string, any>;
@ -203,6 +204,8 @@ export default function ContractEditor({
payslips: AnyObj[];
organizationDetails?: any;
}) {
const queryClient = useQueryClient();
// Récupération des informations d'organisation avec la CCN
const { data: clientOrganizationDetails } = useOrganizationDetails();
@ -473,6 +476,9 @@ export default function ContractEditor({
const [sendESignNotification, setSendESignNotification] = useState(true);
const [verifiedEmployeeEmail, setVerifiedEmployeeEmail] = useState<string>("");
// Manual upload modal state
const [isUploadModalOpen, setIsUploadModalOpen] = useState(false);
// Handler pour le calendrier des dates de représentations
// Ouvrir le modal de quantités pour permettre la précision par date
const handleDatesRepresentationsApply = (result: {
@ -675,7 +681,7 @@ export default function ContractEditor({
}
return await response.json();
},
enabled: !!contract.id && !!form.contract_pdf_s3_key,
enabled: !!contract.id,
staleTime: 5 * 60 * 1000, // 5 minutes
retry: 1,
});
@ -2582,6 +2588,24 @@ export default function ContractEditor({
<p className="text-sm">Utilisez le bouton "Créer le PDF" pour générer le contrat</p>
</div>
)}
{/* Bouton pour upload manuel du contrat signé */}
<Separator className="my-4" />
<div className="flex flex-col gap-2">
<p className="text-sm font-medium">Upload manuel</p>
<Button
variant="outline"
size="sm"
onClick={() => setIsUploadModalOpen(true)}
className="w-full"
>
<Upload className="size-4 mr-2" />
Ajouter le contrat signé manuellement
</Button>
<p className="text-xs text-muted-foreground">
Si vous avez reçu le contrat signé par email ou autre moyen
</p>
</div>
</CardContent>
</Card>
@ -2692,6 +2716,18 @@ export default function ContractEditor({
isLoading={isLaunchingSignature}
/>
{/* Manual Signed Contract Upload Modal */}
<ManualSignedContractUpload
contractId={contract.id}
contractNumber={contract.contract_number}
open={isUploadModalOpen}
onOpenChange={setIsUploadModalOpen}
onSuccess={() => {
// Invalider la query pour rafraîchir les données du contrat signé
queryClient.invalidateQueries({ queryKey: ["signed-contract-pdf", contract.id] });
}}
/>
{/* Dates Quantity Modal for precise selection */}
<DatesQuantityModal
isOpen={quantityModalOpen}

View file

@ -0,0 +1,194 @@
// components/staff/contracts/ManualSignedContractUpload.tsx
"use client";
import { useState } from "react";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { toast } from "sonner";
import { Upload, FileText, Loader2, CheckCircle2, AlertCircle } from "lucide-react";
interface ManualSignedContractUploadProps {
contractId: string;
contractNumber: string;
open: boolean;
onOpenChange: (open: boolean) => void;
onSuccess?: () => void;
}
export function ManualSignedContractUpload({
contractId,
contractNumber,
open,
onOpenChange,
onSuccess,
}: ManualSignedContractUploadProps) {
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [uploading, setUploading] = useState(false);
const [uploadSuccess, setUploadSuccess] = useState(false);
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) {
if (file.type !== "application/pdf") {
toast.error("Le fichier doit être un PDF");
return;
}
if (file.size > 10 * 1024 * 1024) {
toast.error("Le fichier ne doit pas dépasser 10 Mo");
return;
}
setSelectedFile(file);
setUploadSuccess(false);
}
};
const handleUpload = async () => {
if (!selectedFile) {
toast.error("Veuillez sélectionner un fichier PDF");
return;
}
setUploading(true);
try {
const formData = new FormData();
formData.append("file", selectedFile);
const response = await fetch(`/api/staff/contrats/${contractId}/upload-signed-pdf`, {
method: "POST",
body: formData,
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || "Erreur lors de l'upload");
}
const result = await response.json();
console.log("Upload réussi:", result);
setUploadSuccess(true);
toast.success("Contrat signé uploadé avec succès");
// Attendre 1 seconde puis fermer et notifier le succès
setTimeout(() => {
onOpenChange(false);
if (onSuccess) {
onSuccess();
}
// Réinitialiser l'état
setSelectedFile(null);
setUploadSuccess(false);
}, 1000);
} catch (error) {
console.error("Erreur lors de l'upload:", error);
toast.error(error instanceof Error ? error.message : "Erreur lors de l'upload");
} finally {
setUploading(false);
}
};
const handleClose = () => {
if (!uploading) {
setSelectedFile(null);
setUploadSuccess(false);
onOpenChange(false);
}
};
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Upload className="size-5" />
Upload manuel du contrat signé
</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="text-sm text-muted-foreground">
<p>Contrat: <span className="font-medium text-foreground">{contractNumber}</span></p>
<p className="mt-2">
Uploadez le PDF du contrat signé. Il sera stocké dans S3 au même emplacement
qu'un contrat signé via Docuseal.
</p>
</div>
<div className="space-y-3">
<label
htmlFor="pdf-upload"
className="flex flex-col items-center justify-center w-full h-32 border-2 border-dashed border-gray-300 rounded-lg cursor-pointer bg-gray-50 hover:bg-gray-100 transition-colors"
>
<div className="flex flex-col items-center justify-center pt-5 pb-6">
{selectedFile ? (
<>
<FileText className="size-8 text-green-600 mb-2" />
<p className="text-sm text-gray-600">
{selectedFile.name}
</p>
<p className="text-xs text-gray-500">
{(selectedFile.size / 1024 / 1024).toFixed(2)} Mo
</p>
</>
) : (
<>
<Upload className="size-8 text-gray-400 mb-2" />
<p className="text-sm text-gray-600">
Cliquez pour sélectionner un PDF
</p>
<p className="text-xs text-gray-500">
Maximum 10 Mo
</p>
</>
)}
</div>
<input
id="pdf-upload"
type="file"
accept="application/pdf"
className="hidden"
onChange={handleFileChange}
disabled={uploading || uploadSuccess}
/>
</label>
{uploadSuccess && (
<div className="flex items-center gap-2 text-sm text-green-600 bg-green-50 p-3 rounded-lg">
<CheckCircle2 className="size-4" />
<span>Upload réussi</span>
</div>
)}
</div>
<div className="flex justify-end gap-2 pt-2">
<Button
variant="outline"
onClick={handleClose}
disabled={uploading}
>
Annuler
</Button>
<Button
onClick={handleUpload}
disabled={!selectedFile || uploading || uploadSuccess}
>
{uploading ? (
<>
<Loader2 className="size-4 mr-2 animate-spin" />
Upload en cours...
</>
) : (
<>
<Upload className="size-4 mr-2" />
Uploader
</>
)}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}