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:
parent
542e0e963d
commit
b0fea6813b
3 changed files with 395 additions and 3 deletions
162
app/api/staff/contrats/[id]/upload-signed-pdf/route.ts
Normal file
162
app/api/staff/contrats/[id]/upload-signed-pdf/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
194
components/staff/contracts/ManualSignedContractUpload.tsx
Normal file
194
components/staff/contracts/ManualSignedContractUpload.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue