feat: Ajout modal création en masse de paies avec champs exacts du PayslipModal

This commit is contained in:
odentas 2025-10-28 14:49:40 +01:00
parent 2aeac651c1
commit c55ead58ca
23 changed files with 3255 additions and 21 deletions

View file

@ -0,0 +1,164 @@
"use client";
import { useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Plus, Pencil, Building2 } from "lucide-react";
import ReferrerModal from "@/components/staff/ReferrerModal";
type Referrer = {
id: string;
code: string;
name: string;
contact_name?: string;
address?: string;
postal_code?: string;
city?: string;
email?: string;
created_at: string;
};
export default function ApporteursPage() {
const [isModalOpen, setIsModalOpen] = useState(false);
const [editingReferrer, setEditingReferrer] = useState<Referrer | null>(null);
const queryClient = useQueryClient();
// Récupération de la liste des apporteurs
const { data: referrers = [], isLoading } = useQuery<Referrer[]>({
queryKey: ["staff-referrers"],
queryFn: async () => {
const res = await fetch("/api/staff/referrers", {
credentials: "include"
});
if (!res.ok) {
throw new Error("Erreur lors de la récupération des apporteurs");
}
return res.json();
}
});
const handleEdit = (referrer: Referrer) => {
setEditingReferrer(referrer);
setIsModalOpen(true);
};
const handleCreate = () => {
setEditingReferrer(null);
setIsModalOpen(true);
};
const handleSuccess = () => {
queryClient.invalidateQueries({ queryKey: ["staff-referrers"] });
};
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-slate-600">Chargement...</div>
</div>
);
}
return (
<div className="p-6 max-w-7xl mx-auto">
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-slate-900">Apporteurs d'affaires</h1>
<p className="text-sm text-slate-600 mt-1">
Gérez les informations des apporteurs d'affaires
</p>
</div>
<button
onClick={handleCreate}
className="flex items-center gap-2 px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition"
>
<Plus className="w-4 h-4" />
Nouvel apporteur
</button>
</div>
<div className="bg-white rounded-lg shadow-sm border border-slate-200">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-slate-50 border-b border-slate-200">
<tr>
<th className="px-4 py-3 text-left text-xs font-semibold text-slate-700 uppercase">
Code
</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-slate-700 uppercase">
Nom
</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-slate-700 uppercase">
Contact
</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-slate-700 uppercase">
Adresse
</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-slate-700 uppercase">
Email
</th>
<th className="px-4 py-3 text-center text-xs font-semibold text-slate-700 uppercase">
Actions
</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-200">
{referrers.length === 0 ? (
<tr>
<td colSpan={6} className="px-4 py-8 text-center text-slate-500">
<Building2 className="w-12 h-12 mx-auto mb-3 text-slate-300" />
<p>Aucun apporteur d'affaires enregistré</p>
</td>
</tr>
) : (
referrers.map((referrer) => (
<tr key={referrer.id} className="hover:bg-slate-50 transition">
<td className="px-4 py-3 text-sm font-semibold text-slate-900">
{referrer.code}
</td>
<td className="px-4 py-3 text-sm text-slate-800">
{referrer.name}
</td>
<td className="px-4 py-3 text-sm text-slate-600">
{referrer.contact_name || "-"}
</td>
<td className="px-4 py-3 text-sm text-slate-600">
{referrer.address ? (
<div>
<div>{referrer.address}</div>
<div>{referrer.postal_code} {referrer.city}</div>
</div>
) : "-"}
</td>
<td className="px-4 py-3 text-sm text-slate-600">
{referrer.email || "-"}
</td>
<td className="px-4 py-3">
<div className="flex items-center justify-center gap-2">
<button
onClick={() => handleEdit(referrer)}
className="p-1.5 text-slate-600 hover:text-indigo-600 hover:bg-indigo-50 rounded-lg transition"
title="Modifier"
>
<Pencil className="w-4 h-4" />
</button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
{/* Modal */}
{isModalOpen && (
<ReferrerModal
referrer={editingReferrer}
onClose={() => setIsModalOpen(false)}
onSuccess={handleSuccess}
/>
)}
</div>
);
}

View file

@ -69,6 +69,13 @@ type ClientData = {
details: any;
};
type Referrer = {
id: string;
code: string;
name: string;
contact_name: string;
};
function Line({ label, value }: { label: string; value?: string | number | null }) {
return (
<div className="grid grid-cols-3 gap-2 border-b last:border-b-0 py-2">
@ -251,6 +258,19 @@ export default function ClientDetailPage() {
const [isEditing, setIsEditing] = useState(false);
const [editData, setEditData] = useState<Partial<Organization & StructureInfos & any>>({});
// Récupération de la liste des apporteurs
const { data: referrers = [] } = useQuery<Referrer[]>({
queryKey: ["referrers"],
queryFn: async () => {
const res = await fetch("/api/staff/referrers", {
cache: "no-store",
credentials: "include"
});
if (!res.ok) return [];
return res.json();
},
});
// Récupération des données du client
const {
data: clientData,
@ -313,6 +333,11 @@ export default function ClientDetailPage() {
offre_speciale: structureInfos.offre_speciale,
notes: structureInfos.notes,
// Apporteur d'affaires
is_referred: details.is_referred,
referrer_code: details.referrer_code,
commission_rate: details.commission_rate,
// Structure infos
code_employeur: structureInfos.code_employeur, // Remplace structure_api
siret: structureInfos.siret,
@ -658,6 +683,43 @@ export default function ClientDetailPage() {
value={editData.notes}
onChange={(value) => setEditData(prev => ({ ...prev, notes: value }))}
/>
{/* Section Apporteur d'Affaires */}
<div className="pt-2 border-t">
<EditableLine
label="Client apporté ?"
value={editData.is_referred ? "true" : "false"}
type="select"
options={[
{ value: "false", label: "Non" },
{ value: "true", label: "Oui" },
]}
onChange={(value) => setEditData(prev => ({
...prev,
is_referred: value === "true",
referrer_code: value === "false" ? undefined : prev.referrer_code,
commission_rate: value === "false" ? undefined : prev.commission_rate
}))}
/>
{editData.is_referred && (
<>
<EditableLine
label="Apporteur"
value={editData.referrer_code}
type="select"
options={referrers.map(r => ({ value: r.code, label: r.name }))}
onChange={(value) => setEditData(prev => ({ ...prev, referrer_code: value }))}
/>
<EditableLine
label="Taux de commission"
value={editData.commission_rate}
type="number"
onChange={(value) => setEditData(prev => ({ ...prev, commission_rate: value }))}
/>
</>
)}
</div>
</div>
) : (
<div className="space-y-2">
@ -665,6 +727,26 @@ export default function ClientDetailPage() {
<Line label="Ouverture de compte" value={structureInfos.ouverture_compte} />
<Line label="Offre spéciale" value={structureInfos.offre_speciale} />
<Line label="Note" value={structureInfos.notes} />
{/* Section Apporteur d'Affaires */}
<div className="pt-2 border-t">
<Line
label="Client apporté ?"
value={clientData.details.is_referred ? "Oui" : "Non"}
/>
{clientData.details.is_referred && (
<>
<Line
label="Apporteur"
value={referrers.find(r => r.code === clientData.details.referrer_code)?.name || clientData.details.referrer_code}
/>
<Line
label="Taux de commission"
value={clientData.details.commission_rate ? `${(clientData.details.commission_rate * 100).toFixed(2)}%` : "—"}
/>
</>
)}
</div>
</div>
)}
</div>

View file

@ -0,0 +1,338 @@
"use client";
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Plus, FileText, Download, Eye, Calendar, RefreshCw, Trash2 } from "lucide-react";
import CreateNAAModal from "@/components/staff/CreateNAAModal";
type NAADocument = {
id: string;
naa_number: string;
referrer_code: string;
referrer_name?: string;
periode: string;
callsheet_date: string;
total_commission: number;
total_facture: number;
nbre_clients: number;
nbre_prestations: number;
status: string;
pdf_url?: string;
created_at: string;
};
function formatDate(dateString: string) {
return new Intl.DateTimeFormat("fr-FR", {
day: "2-digit",
month: "2-digit",
year: "numeric"
}).format(new Date(dateString));
}
function formatCurrency(amount: number) {
return new Intl.NumberFormat("fr-FR", {
style: "currency",
currency: "EUR"
}).format(amount);
}
export default function NAAPage() {
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [loadingPdf, setLoadingPdf] = useState<string | null>(null);
const [regenerating, setRegenerating] = useState<string | null>(null);
const queryClient = useQueryClient();
// Fonction pour régénérer le PDF
const handleRegeneratePdf = async (naaId: string) => {
if (!confirm("Voulez-vous régénérer le PDF de cette NAA ?")) {
return;
}
setRegenerating(naaId);
try {
const res = await fetch(`/api/staff/naa/${naaId}/regenerate`, {
method: "POST",
credentials: "include"
});
if (!res.ok) {
throw new Error("Erreur lors de la régénération du PDF");
}
const data = await res.json();
// Rafraîchir la liste
queryClient.invalidateQueries({ queryKey: ["staff-naa-list"] });
// Ouvrir le PDF régénéré
if (data.presigned_url) {
window.open(data.presigned_url, "_blank");
}
} catch (error) {
console.error("Error regenerating PDF:", error);
alert("Impossible de régénérer le PDF");
} finally {
setRegenerating(null);
}
};
// Fonction pour supprimer une NAA
const handleDeleteNaa = async (naaId: string, naaNumber: string) => {
if (!confirm(`Voulez-vous vraiment supprimer la NAA ${naaNumber} ?\n\nCette action est irréversible.`)) {
return;
}
try {
const res = await fetch(`/api/staff/naa/${naaId}`, {
method: "DELETE",
credentials: "include"
});
if (!res.ok) {
throw new Error("Erreur lors de la suppression de la NAA");
}
// Rafraîchir la liste
queryClient.invalidateQueries({ queryKey: ["staff-naa-list"] });
} catch (error) {
console.error("Error deleting NAA:", error);
alert("Impossible de supprimer la NAA");
}
};
// Fonction pour ouvrir le PDF avec URL présignée
const handleViewPdf = async (naaId: string) => {
setLoadingPdf(naaId);
try {
const res = await fetch(`/api/staff/naa/${naaId}/presigned-url`, {
credentials: "include"
});
if (!res.ok) {
throw new Error("Erreur lors de la récupération du PDF");
}
const data = await res.json();
window.open(data.presigned_url, "_blank");
} catch (error) {
console.error("Error opening PDF:", error);
alert("Impossible d'ouvrir le PDF");
} finally {
setLoadingPdf(null);
}
};
// Fonction pour télécharger le PDF
const handleDownloadPdf = async (naaId: string, naaNumber: string) => {
setLoadingPdf(naaId);
try {
const res = await fetch(`/api/staff/naa/${naaId}/presigned-url`, {
credentials: "include"
});
if (!res.ok) {
throw new Error("Erreur lors de la récupération du PDF");
}
const data = await res.json();
// Télécharger le fichier
const pdfRes = await fetch(data.presigned_url);
const blob = await pdfRes.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${naaNumber}.pdf`;
a.click();
window.URL.revokeObjectURL(url);
} catch (error) {
console.error("Error downloading PDF:", error);
alert("Impossible de télécharger le PDF");
} finally {
setLoadingPdf(null);
}
};
// Récupération de la liste des NAA
const { data: naaList = [], isLoading } = useQuery<NAADocument[]>({
queryKey: ["staff-naa-list"],
queryFn: async () => {
const res = await fetch("/api/staff/naa", {
credentials: "include"
});
if (!res.ok) {
throw new Error("Erreur lors de la récupération des NAA");
}
return res.json();
},
});
const handleCreateSuccess = () => {
queryClient.invalidateQueries({ queryKey: ["staff-naa-list"] });
setIsCreateModalOpen(false);
};
const getStatusBadge = (status: string) => {
const badges = {
draft: "bg-slate-100 text-slate-700",
sent: "bg-blue-100 text-blue-700",
paid: "bg-green-100 text-green-700"
};
const labels = {
draft: "Brouillon",
sent: "Envoyée",
paid: "Payée"
};
return (
<span className={`px-2 py-1 rounded-full text-xs font-medium ${badges[status as keyof typeof badges] || badges.draft}`}>
{labels[status as keyof typeof labels] || status}
</span>
);
};
return (
<main className="p-4 md:p-6">
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-slate-800">
Notes Apporteurs d'Affaires
</h1>
<p className="text-slate-500 text-sm mt-1">
Gestion des commissions pour les apporteurs d'affaires
</p>
</div>
<button
onClick={() => setIsCreateModalOpen(true)}
className="flex items-center gap-2 px-4 py-2 bg-indigo-600 text-white rounded-xl hover:bg-indigo-700 transition"
>
<Plus className="w-4 h-4" />
Créer une NAA
</button>
</div>
{isLoading ? (
<div className="text-center py-12 text-slate-500">
Chargement...
</div>
) : naaList.length === 0 ? (
<div className="text-center py-12">
<FileText className="w-12 h-12 text-slate-300 mx-auto mb-3" />
<p className="text-slate-500">Aucune NAA créée pour le moment</p>
<button
onClick={() => setIsCreateModalOpen(true)}
className="mt-4 text-indigo-600 hover:text-indigo-700 font-medium"
>
Créer votre première NAA
</button>
</div>
) : (
<div className="bg-white rounded-2xl border overflow-hidden">
<table className="w-full">
<thead className="bg-slate-50 border-b">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">
N° NAA
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">
Apporteur
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">
Période
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">
Date
</th>
<th className="px-4 py-3 text-right text-xs font-medium text-slate-500 uppercase tracking-wider">
Clients
</th>
<th className="px-4 py-3 text-right text-xs font-medium text-slate-500 uppercase tracking-wider">
Commission
</th>
<th className="px-4 py-3 text-right text-xs font-medium text-slate-500 uppercase tracking-wider">
Total
</th>
<th className="px-4 py-3 text-center text-xs font-medium text-slate-500 uppercase tracking-wider">
Statut
</th>
<th className="px-4 py-3 text-center text-xs font-medium text-slate-500 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{naaList.map((naa) => (
<tr key={naa.id} className="hover:bg-slate-50 transition">
<td className="px-4 py-3 text-sm font-medium text-slate-800">
{naa.naa_number}
</td>
<td className="px-4 py-3 text-sm text-slate-600">
{naa.referrer_name || naa.referrer_code}
</td>
<td className="px-4 py-3 text-sm text-slate-600">
{naa.periode}
</td>
<td className="px-4 py-3 text-sm text-slate-600">
{formatDate(naa.callsheet_date)}
</td>
<td className="px-4 py-3 text-sm text-slate-600 text-right">
{naa.nbre_clients}
</td>
<td className="px-4 py-3 text-sm text-slate-600 text-right font-medium">
{formatCurrency(naa.total_commission)}
</td>
<td className="px-4 py-3 text-sm text-slate-800 text-right font-semibold">
{formatCurrency(naa.total_facture)}
</td>
<td className="px-4 py-3 text-center">
{getStatusBadge(naa.status)}
</td>
<td className="px-4 py-3">
<div className="flex items-center justify-center gap-2">
{naa.pdf_url && (
<>
<button
onClick={() => handleViewPdf(naa.id)}
disabled={loadingPdf === naa.id || regenerating === naa.id}
className="p-1.5 text-slate-600 hover:text-indigo-600 hover:bg-indigo-50 rounded-lg transition disabled:opacity-50 disabled:cursor-not-allowed"
title="Voir le PDF"
>
<Eye className="w-4 h-4" />
</button>
<button
onClick={() => handleDownloadPdf(naa.id, naa.naa_number)}
disabled={loadingPdf === naa.id || regenerating === naa.id}
className="p-1.5 text-slate-600 hover:text-green-600 hover:bg-green-50 rounded-lg transition disabled:opacity-50 disabled:cursor-not-allowed"
title="Télécharger le PDF"
>
<Download className="w-4 h-4" />
</button>
<button
onClick={() => handleRegeneratePdf(naa.id)}
disabled={loadingPdf === naa.id || regenerating === naa.id}
className="p-1.5 text-slate-600 hover:text-orange-600 hover:bg-orange-50 rounded-lg transition disabled:opacity-50 disabled:cursor-not-allowed"
title="Régénérer le PDF"
>
<RefreshCw className={`w-4 h-4 ${regenerating === naa.id ? 'animate-spin' : ''}`} />
</button>
</>
)}
<button
onClick={() => handleDeleteNaa(naa.id, naa.naa_number)}
disabled={loadingPdf === naa.id || regenerating === naa.id}
className="p-1.5 text-slate-600 hover:text-red-600 hover:bg-red-50 rounded-lg transition disabled:opacity-50 disabled:cursor-not-allowed"
title="Supprimer la NAA"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{isCreateModalOpen && (
<CreateNAAModal
onClose={() => setIsCreateModalOpen(false)}
onSuccess={handleCreateSuccess}
/>
)}
</main>
);
}

View file

@ -39,7 +39,7 @@ export default async function StaffSalaryTransfersPage() {
// initial fetch: server-side list of latest salary transfers (limited)
const { data: salaryTransfers, error } = await sb
.from("salary_transfers")
.select("*")
.select("*, organizations!org_id(name)")
.order("period_month", { ascending: false })
.limit(200);

View file

@ -178,6 +178,10 @@ export async function PUT(
ouverture_compte,
offre_speciale,
notes,
// Apporteur d'affaires
is_referred,
referrer_code,
commission_rate,
// Champs de détails de l'organisation
code_employeur, // Remplace structure_api
siret,
@ -231,6 +235,10 @@ export async function PUT(
if (ouverture_compte !== undefined) detailsUpdateData.ouverture_compte = ouverture_compte;
if (offre_speciale !== undefined) detailsUpdateData.offre_speciale = offre_speciale;
if (notes !== undefined) detailsUpdateData.notes = notes;
// Apporteur d'affaires
if (is_referred !== undefined) detailsUpdateData.is_referred = is_referred;
if (referrer_code !== undefined) detailsUpdateData.referrer_code = referrer_code;
if (commission_rate !== undefined) detailsUpdateData.commission_rate = commission_rate;
// Autres champs
if (code_employeur !== undefined) detailsUpdateData.code_employeur = code_employeur; // Nouveau champ
if (siret !== undefined) detailsUpdateData.siret = siret;

View file

@ -0,0 +1,76 @@
import { NextRequest, NextResponse } from "next/server";
import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs";
import { cookies } from "next/headers";
export async function POST(req: NextRequest) {
try {
const supabase = createRouteHandlerClient({ cookies });
// 1) Check auth
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// 2) Check if staff
const { data: me } = await supabase
.from("staff_users")
.select("is_staff")
.eq("user_id", user.id)
.maybeSingle();
if (!me?.is_staff) {
return NextResponse.json({ error: "Forbidden: staff only" }, { status: 403 });
}
// 3) Parse request body
const body = await req.json();
const { contractIds, analytique } = body;
if (!contractIds || !Array.isArray(contractIds) || contractIds.length === 0) {
return NextResponse.json(
{ error: "contractIds is required and must be a non-empty array" },
{ status: 400 }
);
}
if (typeof analytique !== 'string' || !analytique.trim()) {
return NextResponse.json(
{ error: "analytique is required and must be a non-empty string" },
{ status: 400 }
);
}
const analytiqueValue = analytique.trim();
// 4) Update contracts analytique (and production_name for compatibility)
const { data: updatedContracts, error: updateError } = await supabase
.from("cddu_contracts")
.update({
analytique: analytiqueValue,
production_name: analytiqueValue // Mettre à jour aussi production_name pour compatibilité
})
.in("id", contractIds)
.select("id, analytique");
if (updateError) {
console.error("Error updating contracts analytique:", updateError);
return NextResponse.json(
{ error: "Failed to update contracts", details: updateError.message },
{ status: 500 }
);
}
return NextResponse.json({
success: true,
count: updatedContracts?.length || 0,
contracts: updatedContracts || [],
});
} catch (err: any) {
console.error("Error in bulk-update-analytique:", err);
return NextResponse.json(
{ error: err.message || "Internal server error" },
{ status: 500 }
);
}
}

View file

@ -0,0 +1,86 @@
import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs";
import { cookies } from "next/headers";
import { NextRequest, NextResponse } from "next/server";
import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
export const dynamic = "force-dynamic";
const s3Client = new S3Client({
region: process.env.AWS_REGION || "eu-west-3",
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
},
});
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const cookieStore = cookies();
const supabase = createRouteHandlerClient({ cookies: () => cookieStore });
// Vérifier l'authentification staff
const { data: { user }, error: authError } = await supabase.auth.getUser();
if (authError || !user) {
console.error("[NAA Presigned URL] Auth error:", authError);
return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
}
console.log("[NAA Presigned URL] User authenticated:", user.id);
if (!user) {
return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
}
console.log("[NAA Presigned URL] User authenticated:", user.id);
const { data: staffUser, error: staffError } = await supabase
.from("staff_users")
.select("user_id")
.eq("user_id", user.id)
.single();
if (staffError || !staffUser) {
console.error("[NAA Presigned URL] Staff check failed:", staffError);
return NextResponse.json({ error: "Accès non autorisé" }, { status: 403 });
}
console.log("[NAA Presigned URL] Staff user found:", staffUser.user_id);
// Récupérer le document NAA
const { data: naaDoc, error } = await supabase
.from("naa_documents")
.select("s3_key")
.eq("id", params.id)
.single();
if (error || !naaDoc) {
return NextResponse.json({ error: "Document NAA non trouvé" }, { status: 404 });
}
if (!naaDoc.s3_key) {
return NextResponse.json({ error: "Aucun fichier associé" }, { status: 404 });
}
// Générer l'URL présignée (valide 1 heure)
const bucketName = process.env.AWS_S3_BUCKET_NAME || "odentas-docs";
const getObjectCommand = new GetObjectCommand({
Bucket: bucketName,
Key: naaDoc.s3_key,
});
const presignedUrl = await getSignedUrl(s3Client, getObjectCommand, { expiresIn: 3600 });
return NextResponse.json({ presigned_url: presignedUrl });
} catch (error: any) {
console.error("Error generating presigned URL:", error);
return NextResponse.json(
{ error: error.message || "Erreur serveur" },
{ status: 500 }
);
}
}

View file

@ -0,0 +1,255 @@
import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs";
import { cookies } from "next/headers";
import { NextRequest, NextResponse } from "next/server";
import { S3Client, PutObjectCommand, GetObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
export const dynamic = "force-dynamic";
const s3Client = new S3Client({
region: process.env.AWS_REGION || "eu-west-3",
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
},
});
// Fonction de polling pour attendre la génération du PDF
async function pollDocumentStatus(
documentUrl: string,
apiKey: string,
maxAttempts = 15,
intervalMs = 2000
): Promise<{ status: string; download_url?: string }> {
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
console.log(`[NAA Regenerate] Poll attempt ${attempt}/${maxAttempts}`);
const res = await fetch(documentUrl, {
headers: { Authorization: `Bearer ${apiKey}` }
});
if (!res.ok) {
throw new Error(`Failed to poll document status: ${res.statusText}`);
}
const data = await res.json();
const status = data.document?.status;
console.log(`[NAA Regenerate] Poll attempt ${attempt}/${maxAttempts}, status: ${status}`);
if (status === "success" && data.document?.download_url) {
return { status, download_url: data.document.download_url };
}
if (status === "error" || status === "failure") {
throw new Error(`PDF generation failed with status: ${status}`);
}
if (attempt < maxAttempts) {
await new Promise(resolve => setTimeout(resolve, intervalMs));
}
}
return { status: "timeout" };
}
export async function POST(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const cookieStore = cookies();
const supabase = createRouteHandlerClient({ cookies: () => cookieStore });
// Vérifier l'authentification staff
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
}
const { data: staffUser } = await supabase
.from("staff_users")
.select("user_id")
.eq("user_id", user.id)
.single();
if (!staffUser) {
return NextResponse.json({ error: "Accès non autorisé" }, { status: 403 });
}
console.log(`[NAA Regenerate] Starting regeneration for NAA ID: ${params.id}`);
// Récupérer le document NAA avec l'apporteur
const { data: naaDoc, error: naaError } = await supabase
.from("naa_documents")
.select(`
*,
referrers (*)
`)
.eq("id", params.id)
.single();
if (naaError || !naaDoc) {
return NextResponse.json({ error: "NAA non trouvée" }, { status: 404 });
}
// Récupérer les prestations
const { data: prestations } = await supabase
.from("naa_prestations")
.select("*")
.eq("naa_id", params.id)
.order("created_at");
// Récupérer les line items (commissions)
const { data: lineItems } = await supabase
.from("naa_line_items")
.select("*")
.eq("naa_id", params.id)
.order("created_at");
const referrer = Array.isArray(naaDoc.referrers) ? naaDoc.referrers[0] : naaDoc.referrers;
// Préparer le payload pour PDFMonkey
const pdfMonkeyPayload = {
apporteur_address: referrer?.address || "",
apporteur_cp: referrer?.postal_code || "",
apporteur_city: referrer?.city || "",
apporteur_code: referrer?.code || naaDoc.referrer_code,
apporteur_name: referrer?.name || "",
apporteur_contact: referrer?.contact_name || "",
callsheet_date: new Date(naaDoc.callsheet_date).toLocaleDateString("fr-FR"),
limit_date: naaDoc.limit_date ? new Date(naaDoc.limit_date).toLocaleDateString("fr-FR") : "",
callsheet_number: naaDoc.naa_number,
periode: naaDoc.periode,
total_commission: naaDoc.total_commission || 0,
solde_compte_apporteur: naaDoc.solde_compte_apporteur || 0,
total_facture: naaDoc.total_facture || 0,
deposit: naaDoc.deposit || 0,
nbre_clients: naaDoc.nbre_clients || 0,
nbre_prestations: naaDoc.nbre_prestations || 0,
transfer_reference: naaDoc.transfer_reference || "",
logo_odentas: "",
lineItems: (lineItems || []).map(item => ({
id: item.client_code,
client: item.client_name,
code: item.client_code,
comactuelle: item.commission_rate,
caht: item.ca_ht,
commission: item.commission
})),
prestations: (prestations || []).map(p => ({
client: p.client_name,
code: p.client_code,
type_prestation: p.type_prestation,
quantite: p.quantite,
tarif: p.tarif,
total: p.total
}))
};
console.log("[NAA Regenerate] Calling PDFMonkey API...");
const pdfMonkeyApiKey = process.env.PDFMONKEY_API_KEY;
const pdfMonkeyUrl = "https://api.pdfmonkey.io/api/v1/documents";
const templateId = process.env.PDFMONKEY_NAA_TEMPLATE_ID || "422DA8A6-69E1-4798-B4A3-DF75D892BF2D";
const pdfMonkeyRes = await fetch(pdfMonkeyUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${pdfMonkeyApiKey}`,
},
body: JSON.stringify({
document: {
document_template_id: templateId,
status: "pending",
payload: pdfMonkeyPayload,
},
}),
});
if (!pdfMonkeyRes.ok) {
const errorText = await pdfMonkeyRes.text();
console.error("[NAA Regenerate] PDFMonkey API error:", errorText);
return NextResponse.json(
{ error: "Erreur lors de l'appel à PDFMonkey" },
{ status: 500 }
);
}
const pdfMonkeyData = await pdfMonkeyRes.json();
console.log("[NAA Regenerate] PDFMonkey response:", pdfMonkeyData);
const documentId = pdfMonkeyData.document?.id;
if (!documentId) {
return NextResponse.json({ error: "No document ID returned from PDFMonkey" }, { status: 500 });
}
const documentUrl = `${pdfMonkeyUrl}/${documentId}`;
console.log("[NAA Regenerate] Polling document status...");
const { status, download_url } = await pollDocumentStatus(documentUrl, pdfMonkeyApiKey!, 15, 2000);
if (status !== "success" || !download_url) {
return NextResponse.json({ error: "PDF generation failed or timed out" }, { status: 500 });
}
// Télécharger le PDF
console.log("[NAA Regenerate] Downloading PDF...");
const pdfRes = await fetch(download_url);
if (!pdfRes.ok) {
return NextResponse.json({ error: "Failed to download PDF" }, { status: 500 });
}
const pdfBuffer = Buffer.from(await pdfRes.arrayBuffer());
console.log("[NAA Regenerate] PDF downloaded, size:", pdfBuffer.length, "bytes");
// Uploader sur S3
console.log("[NAA Regenerate] Uploading to S3...");
const bucketName = process.env.AWS_S3_BUCKET_NAME || "odentas-docs";
const s3Key = `naa/${naaDoc.naa_number}.pdf`;
const uploadCommand = new PutObjectCommand({
Bucket: bucketName,
Key: s3Key,
Body: pdfBuffer,
ContentType: "application/pdf",
});
await s3Client.send(uploadCommand);
console.log("[NAA Regenerate] S3 upload successful");
const s3Url = `https://${bucketName}.s3.${process.env.AWS_REGION || "eu-west-3"}.amazonaws.com/${s3Key}`;
// Générer une URL présignée
const getObjectCommand = new GetObjectCommand({
Bucket: bucketName,
Key: s3Key,
});
const presignedUrl = await getSignedUrl(s3Client, getObjectCommand, { expiresIn: 3600 });
// Mettre à jour le document NAA
await supabase
.from("naa_documents")
.update({
pdf_url: s3Url,
s3_key: s3Key,
updated_at: new Date().toISOString()
})
.eq("id", params.id);
console.log("[NAA Regenerate] ✅ NAA regeneration completed successfully");
return NextResponse.json({
success: true,
pdf_url: s3Url,
presigned_url: presignedUrl
});
} catch (error: any) {
console.error("[NAA Regenerate] ❌ Error:", error);
return NextResponse.json(
{ error: error.message || "Erreur serveur" },
{ status: 500 }
);
}
}

View file

@ -0,0 +1,113 @@
import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs";
import { cookies } from "next/headers";
import { NextRequest, NextResponse } from "next/server";
export const dynamic = "force-dynamic";
// GET - Récupérer les détails d'une NAA avec toutes ses données
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const cookieStore = cookies();
const supabase = createRouteHandlerClient({ cookies: () => cookieStore });
// Vérifier l'authentification staff
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
}
const { data: staffUser } = await supabase
.from("staff_users")
.select("user_id")
.eq("user_id", user.id)
.single();
if (!staffUser) {
return NextResponse.json({ error: "Accès non autorisé" }, { status: 403 });
}
// Récupérer le document NAA
const { data: naaDoc, error: naaError } = await supabase
.from("naa_documents")
.select("*")
.eq("id", params.id)
.single();
if (naaError || !naaDoc) {
return NextResponse.json({ error: "NAA non trouvée" }, { status: 404 });
}
// Récupérer les prestations
const { data: prestations, error: prestationsError } = await supabase
.from("naa_prestations")
.select("*")
.eq("naa_id", params.id)
.order("created_at");
if (prestationsError) {
console.error("Error fetching prestations:", prestationsError);
}
return NextResponse.json({
...naaDoc,
prestations: prestations || []
});
} catch (error: any) {
console.error("Error GET /api/staff/naa/[id]:", error);
return NextResponse.json(
{ error: error.message || "Erreur serveur" },
{ status: 500 }
);
}
}
// DELETE - Supprimer une NAA
export async function DELETE(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const cookieStore = cookies();
const supabase = createRouteHandlerClient({ cookies: () => cookieStore });
// Vérifier l'authentification staff
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
}
const { data: staffUser } = await supabase
.from("staff_users")
.select("user_id")
.eq("user_id", user.id)
.single();
if (!staffUser) {
return NextResponse.json({ error: "Accès non autorisé" }, { status: 403 });
}
// Supprimer le document NAA (les prestations et line items seront supprimés en cascade)
const { error } = await supabase
.from("naa_documents")
.delete()
.eq("id", params.id);
if (error) {
console.error("Error deleting NAA:", error);
return NextResponse.json({ error: error.message }, { status: 500 });
}
return NextResponse.json({ success: true });
} catch (error: any) {
console.error("Error DELETE /api/staff/naa/[id]:", error);
return NextResponse.json(
{ error: error.message || "Erreur serveur" },
{ status: 500 }
);
}
}

View file

@ -0,0 +1,503 @@
import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs";
import { cookies } from "next/headers";
import { NextRequest, NextResponse } from "next/server";
import { S3Client, PutObjectCommand, GetObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
export const dynamic = "force-dynamic";
const s3Client = new S3Client({
region: process.env.AWS_REGION || "eu-west-3",
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
},
});
// Fonction de polling pour attendre la génération du PDF
async function pollDocumentStatus(
documentUrl: string,
apiKey: string,
maxAttempts = 15,
intervalMs = 2000
): Promise<{ status: string; download_url?: string }> {
for (let i = 0; i < maxAttempts; i++) {
await new Promise((resolve) => setTimeout(resolve, intervalMs));
const res = await fetch(documentUrl, {
headers: { Authorization: `Bearer ${apiKey}` },
});
if (!res.ok) {
throw new Error(`Poll failed: ${res.statusText}`);
}
const data = await res.json();
const doc = data.document || data;
const status = doc.status;
console.log(`[NAA] Poll attempt ${i + 1}/${maxAttempts}, status: ${status}`);
if (status === "success") {
return { status, download_url: doc.download_url };
}
if (status === "failure") {
throw new Error("PDFMonkey document generation failed");
}
}
throw new Error("PDFMonkey polling timed out");
}
type Prestation = {
client: string;
code: string;
type_prestation: string;
quantite: number;
tarif: number;
total: number;
};
export async function POST(req: NextRequest) {
try {
const supabase = createRouteHandlerClient({ cookies });
const { data: { session } } = await supabase.auth.getSession();
if (!session) {
return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
}
// Vérifier que l'utilisateur est staff
const { data: staffUser } = await supabase
.from("staff_users")
.select("is_staff")
.eq("user_id", session.user.id)
.single();
if (!staffUser || !staffUser.is_staff) {
return NextResponse.json({ error: "Accès refusé" }, { status: 403 });
}
const body = await req.json();
const {
referrer_code,
periode,
callsheet_date,
limit_date,
transfer_reference,
solde_compte_apporteur = 0,
deposit = 0,
prestations = []
}: {
referrer_code: string;
periode: string;
callsheet_date: string;
limit_date?: string;
transfer_reference?: string;
solde_compte_apporteur: number;
deposit: number;
prestations: Prestation[];
} = body;
// Validation
if (!referrer_code || !periode || !callsheet_date) {
return NextResponse.json(
{ error: "Champs obligatoires manquants" },
{ status: 400 }
);
}
// Récupérer les infos de l'apporteur
const { data: referrer, error: referrerError } = await supabase
.from("referrers")
.select("*")
.eq("code", referrer_code)
.single();
if (referrerError || !referrer) {
return NextResponse.json(
{ error: "Apporteur non trouvé" },
{ status: 404 }
);
}
// Récupérer les clients apportés par cet apporteur avec les factures de la période
// Format de période attendu : "Février 2025" -> on extrait le mois et l'année
const [monthName, year] = periode.split(" ");
const monthMap: { [key: string]: string } = {
"Janvier": "01", "Février": "02", "Mars": "03", "Avril": "04",
"Mai": "05", "Juin": "06", "Juillet": "07", "Août": "08",
"Septembre": "09", "Octobre": "10", "Novembre": "11", "Décembre": "12"
};
const month = monthMap[monthName];
if (!month || !year) {
return NextResponse.json(
{ error: "Format de période invalide (attendu: 'Mois YYYY')" },
{ status: 400 }
);
}
// Calculer le mois suivant (pour la date d'émission des factures)
const periodMonth = parseInt(month);
const periodYear = parseInt(year);
let emissionMonth = periodMonth + 1;
let emissionYear = periodYear;
if (emissionMonth > 12) {
emissionMonth = 1;
emissionYear += 1;
}
const emissionMonthStr = emissionMonth.toString().padStart(2, '0');
// Date de début et fin du mois d'émission (1er au 10 du mois suivant généralement)
const emissionStartDate = `${emissionYear}-${emissionMonthStr}-01`;
const emissionEndDate = `${emissionYear}-${emissionMonthStr}-15`; // Buffer de 15 jours
// Récupérer les clients apportés
const { data: referredClients, error: clientsError } = await supabase
.from("organization_details")
.select(`
org_id,
referrer_code,
commission_rate,
code_employeur,
organizations!organization_details_org_id_fkey (
id,
name
)
`)
.eq("is_referred", true)
.eq("referrer_code", referrer_code);
if (clientsError) {
console.error("[NAA] Erreur récupération clients apportés:", clientsError);
return NextResponse.json(
{ error: "Erreur lors de la récupération des clients" },
{ status: 500 }
);
}
console.log(`[NAA] Trouvé ${referredClients?.length || 0} clients apportés par ${referrer_code}`);
if (referredClients && referredClients.length > 0) {
console.log("[NAA] Clients:", referredClients.map(c => {
const org = Array.isArray(c.organizations) ? c.organizations[0] : c.organizations;
return {
name: org?.name || 'N/A',
code: c.code_employeur,
commission_rate: c.commission_rate
};
}));
}
// Pour chaque client, récupérer les factures du mois concerné
const lineItems = [];
let totalCommission = 0;
for (const client of referredClients) {
const org = Array.isArray(client.organizations) ? client.organizations[0] : client.organizations;
const clientCode = client.code_employeur;
const clientName = org?.name || 'N/A';
console.log(`[NAA] Recherche facture pour client ${clientName} (${clientCode}), période: ${periode}`);
console.log(`[NAA] org_id utilisé pour la recherche: ${client.org_id}`);
// Stratégie 1 : Recherche exacte par period_label
let invoice = await supabase
.from("invoices")
.select("amount_ht, created_at, period_label")
.eq("org_id", client.org_id)
.eq("period_label", periode)
.order("created_at", { ascending: false })
.limit(1)
.maybeSingle()
.then(res => res.data);
console.log(`[NAA] Stratégie 1 (period_label="${periode}"): ${invoice ? `Trouvé ${invoice.amount_ht}` : 'Rien trouvé'}`);
// Stratégie 2 : Si pas trouvé, chercher par date de création (factures émises début du mois suivant)
if (!invoice) {
invoice = await supabase
.from("invoices")
.select("amount_ht, created_at, period_label")
.eq("org_id", client.org_id)
.gte("created_at", emissionStartDate)
.lte("created_at", emissionEndDate)
.order("created_at", { ascending: false })
.limit(1)
.maybeSingle()
.then(res => res.data);
console.log(`[NAA] Stratégie 2 (dates ${emissionStartDate} à ${emissionEndDate}): ${invoice ? `Trouvé ${invoice.amount_ht}` : 'Rien trouvé'}`);
}
// Stratégie 3 : Chercher dans une plage élargie si toujours rien
if (!invoice) {
// Chercher toutes les factures du client dans une plage de ±2 mois
const { data: invoices } = await supabase
.from("invoices")
.select("amount_ht, created_at, period_label")
.eq("org_id", client.org_id)
.gte("created_at", `${periodYear}-${month}-01`)
.lte("created_at", `${emissionYear}-${emissionMonthStr}-31`)
.order("created_at", { ascending: false });
console.log(`[NAA] Stratégie 3 (plage élargie): ${invoices?.length || 0} facture(s) trouvée(s)`);
if (invoices && invoices.length > 0) {
invoice = invoices[0]; // Prendre la plus récente
console.log(`[NAA] Utilisation de la facture la plus récente: ${invoice.amount_ht}€ (${invoice.period_label})`);
}
}
if (invoice && invoice.amount_ht > 0) {
const caHT = parseFloat(invoice.amount_ht);
const commissionRate = client.commission_rate || 0;
const commission = caHT * commissionRate;
totalCommission += commission;
console.log(`[NAA] ✅ Commission calculée: ${caHT}× ${(commissionRate * 100).toFixed(2)}% = ${commission.toFixed(2)}`);
lineItems.push({
id: clientCode || "UNKNOWN",
client: org.name,
code: clientCode,
comactuelle: commissionRate,
caht: caHT,
commission: commission
});
} else {
console.log(`[NAA] ⚠️ Aucune facture trouvée ou montant HT = 0`);
}
}
console.log(`[NAA] Total commission calculée: ${totalCommission.toFixed(2)}`);
console.log(`[NAA] Nombre de line items: ${lineItems.length}`);
const nbreClients = lineItems.length;
const nbrePrestations = prestations.length;
const totalFacture = totalCommission - deposit + solde_compte_apporteur;
// Utiliser la référence de virement comme numéro de NAA
const finalNAANumber = transfer_reference || `NAA-${Date.now()}`;
// Créer le document NAA dans la base
const { data: naaDoc, error: naaError } = await supabase
.from("naa_documents")
.insert({
naa_number: finalNAANumber,
referrer_id: referrer.id,
referrer_code: referrer_code,
periode,
callsheet_date,
limit_date: limit_date || null,
total_commission: totalCommission,
solde_compte_apporteur,
total_facture: totalFacture,
deposit,
nbre_clients: nbreClients,
nbre_prestations: nbrePrestations,
transfer_reference: transfer_reference || null,
status: "draft",
created_by: session.user.id
})
.select()
.single();
if (naaError) {
console.error("Erreur création NAA:", naaError);
return NextResponse.json(
{ error: "Erreur lors de la création de la NAA" },
{ status: 500 }
);
}
// Insérer les line items
if (lineItems.length > 0) {
const lineItemsToInsert = lineItems.map(item => {
const clientData = referredClients?.find(c => {
const clientCode = c.code_employeur || "";
return clientCode === item.code;
});
return {
naa_id: naaDoc.id,
organization_id: clientData?.org_id,
client_name: item.client,
client_code: item.code,
commission_rate: item.comactuelle,
ca_ht: item.caht,
commission: item.commission
};
});
await supabase.from("naa_line_items").insert(lineItemsToInsert);
}
// Insérer les prestations
if (prestations.length > 0) {
const prestationsToInsert = prestations.map(p => ({
naa_id: naaDoc.id,
client_name: p.client,
client_code: p.code,
type_prestation: p.type_prestation,
quantite: p.quantite,
tarif: p.tarif,
total: p.total
}));
await supabase.from("naa_prestations").insert(prestationsToInsert);
}
// Préparer les données pour PDFMonkey
const pdfMonkeyPayload = {
apporteur_address: referrer.address,
apporteur_cp: referrer.postal_code,
apporteur_city: referrer.city,
apporteur_code: referrer.code,
apporteur_name: referrer.name,
apporteur_contact: referrer.contact_name,
callsheet_date: new Date(callsheet_date).toLocaleDateString("fr-FR"),
limit_date: limit_date ? new Date(limit_date).toLocaleDateString("fr-FR") : "",
callsheet_number: finalNAANumber,
periode,
total_commission: totalCommission,
solde_compte_apporteur,
total_facture: totalFacture,
deposit,
nbre_clients: nbreClients,
nbre_prestations: nbrePrestations,
transfer_reference: transfer_reference || "",
logo_odentas: "",
lineItems,
prestations: prestations.map(p => ({
client: p.client,
code: p.code,
type_prestation: p.type_prestation,
quantite: p.quantite,
tarif: p.tarif,
total: p.total
}))
};
// Envoyer à PDFMonkey pour générer le PDF
const templateId = process.env.PDFMONKEY_NAA_TEMPLATE_ID || "422DA8A6-69E1-4798-B4A3-DF75D892BF2D";
const pdfMonkeyUrl = process.env.PDFMONKEY_URL || "https://api.pdfmonkey.io/api/v1/documents";
const pdfMonkeyApiKey = process.env.PDFMONKEY_API_KEY;
if (!pdfMonkeyApiKey) {
console.error("[NAA] Missing PDFMONKEY_API_KEY");
return NextResponse.json({ error: "Missing PDFMONKEY_API_KEY" }, { status: 500 });
}
console.log("[NAA] Calling PDFMonkey API...");
const pdfMonkeyResponse = await fetch(pdfMonkeyUrl, {
method: "POST",
headers: {
"Authorization": `Bearer ${pdfMonkeyApiKey}`,
"Content-Type": "application/json"
},
body: JSON.stringify({
document: {
document_template_id: templateId,
status: "pending",
payload: pdfMonkeyPayload,
meta: {
_filename: `${finalNAANumber}.pdf`
}
}
})
});
if (!pdfMonkeyResponse.ok) {
const errorText = await pdfMonkeyResponse.text();
console.error("[NAA] PDFMonkey create error:", errorText);
return NextResponse.json(
{ error: "PDFMonkey API error", details: errorText },
{ status: pdfMonkeyResponse.status }
);
}
const pdfMonkeyData = await pdfMonkeyResponse.json();
console.log("[NAA] PDFMonkey response:", pdfMonkeyData);
const documentId = pdfMonkeyData.document?.id;
if (!documentId) {
console.error("[NAA] No document ID in response");
return NextResponse.json({ error: "No document ID returned from PDFMonkey" }, { status: 500 });
}
// Construct the document URL for polling
const documentUrl = `${pdfMonkeyUrl}/${documentId}`;
console.log("[NAA] Document URL for polling:", documentUrl);
// Poll for completion
console.log("[NAA] Polling document status...");
const { status, download_url } = await pollDocumentStatus(documentUrl, pdfMonkeyApiKey, 15, 2000);
console.log("[NAA] Poll result:", { status, has_download_url: !!download_url });
if (status !== "success" || !download_url) {
console.error("[NAA] PDF generation failed or timed out");
return NextResponse.json({ error: "PDF generation failed or timed out", status }, { status: 500 });
}
// Download PDF
console.log("[NAA] Downloading PDF from:", download_url);
const pdfRes = await fetch(download_url);
if (!pdfRes.ok) {
console.error("[NAA] Failed to download PDF:", pdfRes.status, pdfRes.statusText);
return NextResponse.json({ error: "Failed to download PDF" }, { status: 500 });
}
const pdfBuffer = Buffer.from(await pdfRes.arrayBuffer());
console.log("[NAA] PDF downloaded, size:", pdfBuffer.length, "bytes");
// Uploader sur S3
console.log("[NAA] Uploading to S3...");
const bucketName = process.env.AWS_S3_BUCKET_NAME || "odentas-docs";
const s3Key = `naa/${finalNAANumber}.pdf`;
const uploadCommand = new PutObjectCommand({
Bucket: bucketName,
Key: s3Key,
Body: pdfBuffer,
ContentType: "application/pdf",
});
await s3Client.send(uploadCommand);
console.log("[NAA] S3 upload successful");
const s3Url = `https://${bucketName}.s3.${process.env.AWS_REGION || "eu-west-3"}.amazonaws.com/${s3Key}`;
// Générer une URL présignée valide 1 heure pour afficher le PDF
console.log("[NAA] Generating presigned URL...");
const getObjectCommand = new GetObjectCommand({
Bucket: bucketName,
Key: s3Key,
});
const presignedUrl = await getSignedUrl(s3Client, getObjectCommand, { expiresIn: 3600 });
console.log("[NAA] Presigned URL generated");
// Mettre à jour le document NAA avec l'URL du PDF
console.log("[NAA] Updating NAA document with PDF URL...");
await supabase
.from("naa_documents")
.update({
pdf_url: s3Url,
s3_key: s3Key,
status: "sent"
})
.eq("id", naaDoc.id);
console.log("[NAA] ✅ NAA generation completed successfully");
return NextResponse.json({
success: true,
naa_number: finalNAANumber,
pdf_url: s3Url,
presigned_url: presignedUrl
});
} catch (error: any) {
console.error("[NAA] ❌ Error POST /api/staff/naa/generate:", error);
return NextResponse.json(
{ error: error.message || "Erreur serveur" },
{ status: 500 }
);
}
}

View file

@ -0,0 +1,58 @@
import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs";
import { cookies } from "next/headers";
import { NextResponse } from "next/server";
export const dynamic = "force-dynamic";
export async function GET() {
try {
const supabase = createRouteHandlerClient({ cookies });
const { data: { session } } = await supabase.auth.getSession();
if (!session) {
return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
}
// Vérifier que l'utilisateur est staff
const { data: staffUser } = await supabase
.from("staff_users")
.select("is_staff")
.eq("user_id", session.user.id)
.single();
if (!staffUser || !staffUser.is_staff) {
return NextResponse.json({ error: "Accès refusé" }, { status: 403 });
}
// Récupérer la liste des NAA avec les infos de l'apporteur
const { data: naaList, error } = await supabase
.from("naa_documents")
.select(`
*,
referrers!referrer_id (
name
)
`)
.order("created_at", { ascending: false });
if (error) {
console.error("Erreur lors de la récupération des NAA:", error);
return NextResponse.json({ error: error.message }, { status: 500 });
}
// Transformer les données pour inclure le nom de l'apporteur
const formattedList = (naaList || []).map((naa: any) => ({
...naa,
referrer_name: naa.referrers?.name || null,
referrers: undefined
}));
return NextResponse.json(formattedList);
} catch (error: any) {
console.error("Erreur GET /api/staff/naa:", error);
return NextResponse.json(
{ error: error.message || "Erreur serveur" },
{ status: 500 }
);
}
}

View file

@ -0,0 +1,67 @@
import { NextRequest, NextResponse } from "next/server";
import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs";
import { cookies } from "next/headers";
export async function GET(
request: NextRequest,
{ params }: { params: { orgId: string } }
) {
try {
const supabase = createRouteHandlerClient({ cookies });
// 1) Authentification
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// 2) Vérifier que l'utilisateur est staff
const { data: me } = await supabase
.from("staff_users")
.select("is_staff")
.eq("user_id", user.id)
.maybeSingle();
if (!me?.is_staff) {
return NextResponse.json({ error: "Forbidden: staff only" }, { status: 403 });
}
// 3) Récupérer les détails de l'organisation
const { data: orgDetails, error } = await supabase
.from("organization_details")
.select("email_notifs, email_notifs_cc")
.eq("org_id", params.orgId)
.maybeSingle();
if (error) {
console.error("[get-org-emails] Error:", error);
return NextResponse.json(
{ error: "Failed to fetch organization details", details: error.message },
{ status: 500 }
);
}
if (!orgDetails) {
return NextResponse.json(
{
email_notifs: null,
email_notifs_cc: null,
message: "No organization details found"
},
{ status: 200 }
);
}
return NextResponse.json({
email_notifs: orgDetails.email_notifs,
email_notifs_cc: orgDetails.email_notifs_cc
});
} catch (err: any) {
console.error("[get-org-emails] Error:", err);
return NextResponse.json(
{ error: err.message || "Internal server error" },
{ status: 500 }
);
}
}

View file

@ -0,0 +1,59 @@
import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs";
import { cookies } from "next/headers";
import { NextRequest, NextResponse } from "next/server";
export const dynamic = "force-dynamic";
export async function GET(
req: NextRequest,
{ params }: { params: { code: string } }
) {
try {
const supabase = createRouteHandlerClient({ cookies });
const { data: { session } } = await supabase.auth.getSession();
if (!session) {
return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
}
// Vérifier que l'utilisateur est staff
const { data: staffUser } = await supabase
.from("staff_users")
.select("is_staff")
.eq("user_id", session.user.id)
.single();
if (!staffUser || !staffUser.is_staff) {
return NextResponse.json({ error: "Accès refusé" }, { status: 403 });
}
const referrerCode = params.code;
// Récupérer les clients apportés par cet apporteur
const { data: referredClients, error } = await supabase
.from("organization_details")
.select(`
org_id,
code_employeur,
organizations!organization_details_org_id_fkey (
name
)
`)
.eq("is_referred", true)
.eq("referrer_code", referrerCode)
.order("organizations(name)");
if (error) {
console.error("Erreur lors de la récupération des clients apportés:", error);
return NextResponse.json({ error: error.message }, { status: 500 });
}
return NextResponse.json(referredClients || []);
} catch (error: any) {
console.error("Erreur GET /api/staff/referrers/[code]/clients:", error);
return NextResponse.json(
{ error: error.message || "Erreur serveur" },
{ status: 500 }
);
}
}

View file

@ -0,0 +1,162 @@
import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs";
import { cookies } from "next/headers";
import { NextResponse } from "next/server";
export const dynamic = "force-dynamic";
export async function GET() {
try {
const supabase = createRouteHandlerClient({ cookies });
const { data: { session } } = await supabase.auth.getSession();
if (!session) {
return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
}
// Vérifier que l'utilisateur est staff
const { data: staffUser } = await supabase
.from("staff_users")
.select("is_staff")
.eq("user_id", session.user.id)
.single();
if (!staffUser || !staffUser.is_staff) {
return NextResponse.json({ error: "Accès refusé" }, { status: 403 });
}
// Récupérer la liste des apporteurs
const { data: referrers, error } = await supabase
.from("referrers")
.select("id, code, name, contact_name, address, postal_code, city, email, created_at")
.order("name");
if (error) {
console.error("Erreur lors de la récupération des apporteurs:", error);
return NextResponse.json({ error: error.message }, { status: 500 });
}
return NextResponse.json(referrers || []);
} catch (error: any) {
console.error("Erreur GET /api/staff/referrers:", error);
return NextResponse.json(
{ error: error.message || "Erreur serveur" },
{ status: 500 }
);
}
}
export async function POST(request: Request) {
try {
const cookieStore = cookies();
const supabase = createRouteHandlerClient({ cookies: () => cookieStore });
const { data: { session } } = await supabase.auth.getSession();
if (!session) {
return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
}
// Vérifier que l'utilisateur est staff
const { data: staffUser } = await supabase
.from("staff_users")
.select("is_staff")
.eq("user_id", session.user.id)
.single();
if (!staffUser || !staffUser.is_staff) {
return NextResponse.json({ error: "Accès refusé" }, { status: 403 });
}
const body = await request.json();
const { code, name, contact_name, address, postal_code, city, email } = body;
if (!code || !name) {
return NextResponse.json({ error: "Code et nom requis" }, { status: 400 });
}
// Créer l'apporteur
const { data: referrer, error } = await supabase
.from("referrers")
.insert({
code: code.toUpperCase(),
name,
contact_name,
address,
postal_code,
city,
email
})
.select()
.single();
if (error) {
console.error("Erreur création apporteur:", error);
return NextResponse.json({ error: error.message }, { status: 500 });
}
return NextResponse.json(referrer);
} catch (error: any) {
console.error("Erreur POST /api/staff/referrers:", error);
return NextResponse.json(
{ error: error.message || "Erreur serveur" },
{ status: 500 }
);
}
}
export async function PATCH(request: Request) {
try {
const cookieStore = cookies();
const supabase = createRouteHandlerClient({ cookies: () => cookieStore });
const { data: { session } } = await supabase.auth.getSession();
if (!session) {
return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
}
// Vérifier que l'utilisateur est staff
const { data: staffUser } = await supabase
.from("staff_users")
.select("is_staff")
.eq("user_id", session.user.id)
.single();
if (!staffUser || !staffUser.is_staff) {
return NextResponse.json({ error: "Accès refusé" }, { status: 403 });
}
const body = await request.json();
const { id, name, contact_name, address, postal_code, city, email } = body;
if (!id || !name) {
return NextResponse.json({ error: "ID et nom requis" }, { status: 400 });
}
// Mettre à jour l'apporteur (le code ne peut pas être modifié)
const { data: referrer, error } = await supabase
.from("referrers")
.update({
name,
contact_name,
address,
postal_code,
city,
email
})
.eq("id", id)
.select()
.single();
if (error) {
console.error("Erreur mise à jour apporteur:", error);
return NextResponse.json({ error: error.message }, { status: 500 });
}
return NextResponse.json(referrer);
} catch (error: any) {
console.error("Erreur PATCH /api/staff/referrers:", error);
return NextResponse.json(
{ error: error.message || "Erreur serveur" },
{ status: 500 }
);
}
}

View file

@ -95,7 +95,7 @@ export async function POST(req: NextRequest) {
const { data: newTransfer, error: insertError } = await supabase
.from("salary_transfers")
.insert(insertData)
.select()
.select("*, organizations!org_id(name)")
.single();
if (insertError) {

View file

@ -2,7 +2,7 @@
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useState, useEffect, useRef } from "react";
import { Shield, KeyRound, Home, FileSignature, Banknote, Users, Percent, FolderOpen, Receipt, Building2, UserCog, Edit3, LifeBuoy, CreditCard, Calculator, Euro, Mail, Database, Clapperboard, LogOut, Scale, FileEdit } from "lucide-react";
import { Shield, KeyRound, Home, FileSignature, Banknote, Users, Percent, FolderOpen, Receipt, Building2, UserCog, Edit3, LifeBuoy, CreditCard, Calculator, Euro, Mail, Database, Clapperboard, LogOut, Scale, FileEdit, FileText } from "lucide-react";
// import { api } from "@/lib/fetcher";
import { createPortal } from "react-dom";
import LogoutButton from "@/components/LogoutButton";
@ -587,6 +587,22 @@ export default function Sidebar({ clientInfo, isStaff = false, mobile = false, o
<span>Cotisations</span>
</span>
</Link>
<Link href="/staff/naa" onClick={() => onNavigate && onNavigate()} className={`block px-3 py-2 rounded-xl text-sm transition truncate ${
isActivePath(pathname, "/staff/naa") ? "bg-gradient-to-r from-indigo-200 via-purple-200 to-pink-200 text-slate-700 font-semibold" : "hover:bg-slate-50"
}`} title="Notes Apporteurs d'Affaires">
<span className="inline-flex items-center gap-2">
<FileText className="w-4 h-4" aria-hidden />
<span>NAA</span>
</span>
</Link>
<Link href="/staff/apporteurs" onClick={() => onNavigate && onNavigate()} className={`block px-3 py-2 rounded-xl text-sm transition truncate ${
isActivePath(pathname, "/staff/apporteurs") ? "bg-gradient-to-r from-indigo-200 via-purple-200 to-pink-200 text-slate-700 font-semibold" : "hover:bg-slate-50"
}`} title="Gestion des apporteurs">
<span className="inline-flex items-center gap-2">
<Building2 className="w-4 h-4" aria-hidden />
<span>Apporteurs</span>
</span>
</Link>
</div>
{/* Administration */}

View file

@ -0,0 +1,551 @@
"use client";
import { useState, useEffect } from "react";
import { useQuery } from "@tanstack/react-query";
import { X, Plus, Trash2, ChevronDown, ChevronRight } from "lucide-react";
type Referrer = {
id: string;
code: string;
name: string;
contact_name: string;
address: string;
postal_code: string;
city: string;
};
type PrestationLine = {
type_prestation: string;
quantite: number;
tarif: number;
total: number;
};
type ClientPrestations = {
client: string;
code: string;
expanded: boolean;
lines: PrestationLine[];
};
type ReferredClient = {
org_id: string;
code_employeur: string;
organizations: {
name: string;
};
};
type CreateNAAModalProps = {
onClose: () => void;
onSuccess: () => void;
};
export default function CreateNAAModal({ onClose, onSuccess }: CreateNAAModalProps) {
const [isGenerating, setIsGenerating] = useState(false);
const [error, setError] = useState<string | null>(null);
// Formulaire
const [referrerCode, setReferrerCode] = useState("");
const [periode, setPeriode] = useState("");
const [callsheetDate, setCallsheetDate] = useState("");
const [limitDate, setLimitDate] = useState("");
const [transferReference, setTransferReference] = useState("");
const [soldeCompte, setSoldeCompte] = useState("0");
const [deposit, setDeposit] = useState("0");
// Prestations groupées par client
const [clientsPrestations, setClientsPrestations] = useState<ClientPrestations[]>([]);
// Récupération des apporteurs
const { data: referrers = [] } = useQuery<Referrer[]>({
queryKey: ["referrers"],
queryFn: async () => {
const res = await fetch("/api/staff/referrers", {
credentials: "include"
});
if (!res.ok) return [];
return res.json();
},
});
// Récupération des clients apportés par l'apporteur sélectionné
const { data: referredClients = [] } = useQuery<ReferredClient[]>({
queryKey: ["referred-clients", referrerCode],
queryFn: async () => {
if (!referrerCode) return [];
const res = await fetch(`/api/staff/referrers/${referrerCode}/clients`, {
credentials: "include"
});
if (!res.ok) return [];
return res.json();
},
enabled: !!referrerCode,
});
const selectedReferrer = referrers.find(r => r.code === referrerCode);
const typesPrestation = [
"Ouverture de compte",
"Abonnement",
"Paies CDDU",
"Paies RG",
"Avenants",
"Autres"
];
// Ajouter un nouveau client avec une prestation vide
const addClient = () => {
setClientsPrestations([...clientsPrestations, {
client: "",
code: "",
expanded: true,
lines: [{
type_prestation: "",
quantite: 1,
tarif: 0,
total: 0
}]
}]);
};
// Supprimer un client et toutes ses prestations
const removeClient = (clientIndex: number) => {
setClientsPrestations(clientsPrestations.filter((_, i) => i !== clientIndex));
};
// Ajouter une ligne de prestation pour un client
const addLineToClient = (clientIndex: number) => {
const newClients = [...clientsPrestations];
newClients[clientIndex].lines.push({
type_prestation: "",
quantite: 1,
tarif: 0,
total: 0
});
setClientsPrestations(newClients);
};
// Supprimer une ligne de prestation
const removeLine = (clientIndex: number, lineIndex: number) => {
const newClients = [...clientsPrestations];
newClients[clientIndex].lines = newClients[clientIndex].lines.filter((_, i) => i !== lineIndex);
setClientsPrestations(newClients);
};
// Mettre à jour le client
const updateClient = (clientIndex: number, clientName: string) => {
const newClients = [...clientsPrestations];
const selectedClient = referredClients.find(c => c.organizations.name === clientName);
newClients[clientIndex].client = clientName;
newClients[clientIndex].code = selectedClient?.code_employeur || "";
setClientsPrestations(newClients);
};
// Mettre à jour une ligne de prestation
const updateLine = (clientIndex: number, lineIndex: number, field: keyof PrestationLine, value: any) => {
const newClients = [...clientsPrestations];
newClients[clientIndex].lines[lineIndex] = {
...newClients[clientIndex].lines[lineIndex],
[field]: value
};
// Calculer le total automatiquement
if (field === "quantite" || field === "tarif") {
const line = newClients[clientIndex].lines[lineIndex];
const quantite = field === "quantite" ? parseFloat(value) || 0 : line.quantite;
const tarif = field === "tarif" ? parseFloat(value) || 0 : line.tarif;
newClients[clientIndex].lines[lineIndex].total = quantite * tarif;
}
setClientsPrestations(newClients);
};
// Basculer l'état expanded d'un client
const toggleClientExpanded = (clientIndex: number) => {
const newClients = [...clientsPrestations];
newClients[clientIndex].expanded = !newClients[clientIndex].expanded;
setClientsPrestations(newClients);
};
// Calculer le total pour un client
const getClientTotal = (client: ClientPrestations) => {
return client.lines.reduce((sum, line) => sum + line.total, 0);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
if (!referrerCode || !periode || !callsheetDate) {
setError("Veuillez remplir tous les champs obligatoires");
return;
}
setIsGenerating(true);
try {
// Convertir clientsPrestations en format plat pour l'API
const prestations = clientsPrestations.flatMap(client =>
client.lines.map(line => ({
client: client.client,
code: client.code,
type_prestation: line.type_prestation,
quantite: line.quantite,
tarif: line.tarif,
total: line.total
}))
);
const payload = {
referrer_code: referrerCode,
periode,
callsheet_date: callsheetDate,
limit_date: limitDate || undefined,
transfer_reference: transferReference || undefined,
solde_compte_apporteur: parseFloat(soldeCompte) || 0,
deposit: parseFloat(deposit) || 0,
prestations
};
const res = await fetch("/api/staff/naa/generate", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify(payload)
});
if (!res.ok) {
const errorData = await res.json();
throw new Error(errorData.error || "Erreur lors de la génération de la NAA");
}
onSuccess();
} catch (err: any) {
setError(err.message);
setIsGenerating(false);
}
};
return (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<div className="bg-white rounded-2xl shadow-xl w-full max-w-4xl max-h-[90vh] overflow-hidden flex flex-col">
{/* Header */}
<div className="px-6 py-4 border-b flex items-center justify-between">
<h2 className="text-xl font-bold text-slate-800">
Créer une Note Apporteur d'Affaires
</h2>
<button
onClick={onClose}
className="text-slate-400 hover:text-slate-600 transition"
>
<X className="w-6 h-6" />
</button>
</div>
{/* Body */}
<form onSubmit={handleSubmit} className="flex-1 overflow-y-auto">
<div className="p-6 space-y-6">
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-xl">
{error}
</div>
)}
{/* Informations générales */}
<div>
<h3 className="font-medium text-slate-800 mb-3">Informations générales</h3>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Apporteur <span className="text-red-500">*</span>
</label>
<select
value={referrerCode}
onChange={(e) => setReferrerCode(e.target.value)}
className="w-full px-3 py-2 border rounded-xl focus:outline-none focus:ring-2 focus:ring-indigo-500"
required
>
<option value="">Sélectionner un apporteur</option>
{referrers.map(r => (
<option key={r.code} value={r.code}>
{r.name}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Période <span className="text-red-500">*</span>
</label>
<input
type="text"
value={periode}
onChange={(e) => setPeriode(e.target.value)}
placeholder="ex: Février 2025"
className="w-full px-3 py-2 border rounded-xl focus:outline-none focus:ring-2 focus:ring-indigo-500"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Date de la note <span className="text-red-500">*</span>
</label>
<input
type="date"
value={callsheetDate}
onChange={(e) => setCallsheetDate(e.target.value)}
className="w-full px-3 py-2 border rounded-xl focus:outline-none focus:ring-2 focus:ring-indigo-500"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Date limite de paiement
</label>
<input
type="date"
value={limitDate}
onChange={(e) => setLimitDate(e.target.value)}
className="w-full px-3 py-2 border rounded-xl focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Référence de virement
</label>
<input
type="text"
value={transferReference}
onChange={(e) => setTransferReference(e.target.value)}
placeholder="ex: DFQM-000001-Janvier 2024"
className="w-full px-3 py-2 border rounded-xl focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Solde compte apporteur
</label>
<input
type="number"
step="0.01"
value={soldeCompte}
onChange={(e) => setSoldeCompte(e.target.value)}
className="w-full px-3 py-2 border rounded-xl focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Acompte
</label>
<input
type="number"
step="0.01"
value={deposit}
onChange={(e) => setDeposit(e.target.value)}
className="w-full px-3 py-2 border rounded-xl focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
</div>
</div>
</div>
{/* Prestations groupées par client */}
<div>
<div className="flex items-center justify-between mb-3">
<h3 className="font-medium text-slate-800">Prestations par client</h3>
<button
type="button"
onClick={addClient}
className="flex items-center gap-1 text-sm text-indigo-600 hover:text-indigo-700 font-medium"
>
<Plus className="w-4 h-4" />
Ajouter un client
</button>
</div>
{clientsPrestations.length === 0 ? (
<div className="text-center py-8 text-slate-500 bg-slate-50 rounded-xl">
Aucun client ajouté
</div>
) : (
<div className="space-y-3">
{clientsPrestations.map((clientPresta, clientIndex) => (
<div key={clientIndex} className="border rounded-xl bg-white">
{/* En-tête client */}
<div className="p-4 bg-slate-50 rounded-t-xl border-b flex items-center justify-between">
<div className="flex items-center gap-3 flex-1">
<button
type="button"
onClick={() => toggleClientExpanded(clientIndex)}
className="text-slate-600 hover:text-slate-800"
>
{clientPresta.expanded ? (
<ChevronDown className="w-5 h-5" />
) : (
<ChevronRight className="w-5 h-5" />
)}
</button>
<div className="flex-1 grid grid-cols-3 gap-3">
<div className="col-span-2">
<select
value={clientPresta.client}
onChange={(e) => updateClient(clientIndex, e.target.value)}
className="w-full px-3 py-2 text-sm font-medium border rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500"
>
<option value="">Sélectionner un client</option>
{referredClients.map((client) => (
<option key={client.org_id} value={client.organizations.name}>
{client.organizations.name}
</option>
))}
</select>
</div>
<div>
<input
type="text"
value={clientPresta.code}
readOnly
placeholder="Code"
className="w-full px-3 py-2 text-sm border rounded-lg bg-slate-100 text-slate-600"
/>
</div>
</div>
<div className="text-right min-w-[120px]">
<div className="text-xs text-slate-500">Total client</div>
<div className="text-lg font-bold text-slate-800">
{getClientTotal(clientPresta).toFixed(2)}
</div>
</div>
</div>
<button
type="button"
onClick={() => removeClient(clientIndex)}
className="ml-3 p-2 text-red-600 hover:bg-red-50 rounded-lg transition"
title="Supprimer ce client"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
{/* Lignes de prestations */}
{clientPresta.expanded && (
<div className="p-4 space-y-2">
{clientPresta.lines.map((line, lineIndex) => (
<div key={lineIndex} className="flex items-end gap-2 bg-slate-50 p-3 rounded-lg">
<div className="flex-1 grid grid-cols-4 gap-2">
<div>
<label className="block text-xs font-medium text-slate-600 mb-1">
Type de prestation
</label>
<select
value={line.type_prestation}
onChange={(e) => updateLine(clientIndex, lineIndex, "type_prestation", e.target.value)}
className="w-full px-2 py-1.5 text-sm border rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500"
>
<option value="">Type</option>
{typesPrestation.map((type) => (
<option key={type} value={type}>
{type}
</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-medium text-slate-600 mb-1">
Quantité
</label>
<input
type="number"
value={line.quantite}
onChange={(e) => updateLine(clientIndex, lineIndex, "quantite", e.target.value)}
className="w-full px-2 py-1.5 text-sm border rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
</div>
<div>
<label className="block text-xs font-medium text-slate-600 mb-1">
Tarif unitaire ()
</label>
<input
type="number"
step="0.01"
value={line.tarif}
onChange={(e) => updateLine(clientIndex, lineIndex, "tarif", e.target.value)}
className="w-full px-2 py-1.5 text-sm border rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
</div>
<div>
<label className="block text-xs font-medium text-slate-600 mb-1">
Total ligne ()
</label>
<input
type="text"
value={line.total.toFixed(2)}
readOnly
className="w-full px-2 py-1.5 text-sm border rounded-lg bg-slate-100 text-slate-700 font-medium"
/>
</div>
</div>
<button
type="button"
onClick={() => removeLine(clientIndex, lineIndex)}
disabled={clientPresta.lines.length === 1}
className="p-1.5 text-red-600 hover:bg-red-50 rounded-lg transition disabled:opacity-30 disabled:cursor-not-allowed"
title="Supprimer cette ligne"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
))}
<button
type="button"
onClick={() => addLineToClient(clientIndex)}
className="w-full py-2 text-sm text-indigo-600 hover:text-indigo-700 hover:bg-indigo-50 rounded-lg transition flex items-center justify-center gap-1"
>
<Plus className="w-4 h-4" />
Ajouter une prestation pour ce client
</button>
</div>
)}
</div>
))}
</div>
)}
</div>
<div className="text-sm text-slate-500 bg-blue-50 border border-blue-200 rounded-xl p-3">
<strong>Note :</strong> Les commissions par client seront calculées automatiquement
en fonction des factures du mois de la période sélectionnée et du taux de commission
configuré pour chaque client apporté.
</div>
</div>
{/* Footer */}
<div className="px-6 py-4 border-t flex items-center justify-end gap-3">
<button
type="button"
onClick={onClose}
disabled={isGenerating}
className="px-4 py-2 text-slate-700 hover:bg-slate-100 rounded-xl transition disabled:opacity-50"
>
Annuler
</button>
<button
type="submit"
disabled={isGenerating}
className="px-6 py-2 bg-indigo-600 text-white rounded-xl hover:bg-indigo-700 transition disabled:opacity-50 disabled:cursor-not-allowed"
>
{isGenerating ? "Génération en cours..." : "Générer la NAA"}
</button>
</div>
</form>
</div>
</div>
);
}

View file

@ -0,0 +1,212 @@
"use client";
import { useState } from "react";
import { X } from "lucide-react";
type Referrer = {
id: string;
code: string;
name: string;
contact_name?: string;
address?: string;
postal_code?: string;
city?: string;
email?: string;
};
type ReferrerModalProps = {
referrer?: Referrer | null;
onClose: () => void;
onSuccess: () => void;
};
export default function ReferrerModal({ referrer, onClose, onSuccess }: ReferrerModalProps) {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [formData, setFormData] = useState({
code: referrer?.code || "",
name: referrer?.name || "",
contact_name: referrer?.contact_name || "",
address: referrer?.address || "",
postal_code: referrer?.postal_code || "",
city: referrer?.city || "",
email: referrer?.email || ""
});
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError(null);
try {
const url = "/api/staff/referrers";
const method = referrer ? "PATCH" : "POST";
const body = referrer ? { id: referrer.id, ...formData } : formData;
const res = await fetch(url, {
method,
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify(body)
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || "Erreur lors de l'enregistrement");
}
onSuccess();
onClose();
} catch (err: any) {
setError(err.message);
} finally {
setLoading(false);
}
};
return (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<div className="bg-white rounded-lg shadow-xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
<div className="sticky top-0 bg-white border-b border-slate-200 px-6 py-4 flex items-center justify-between">
<h2 className="text-xl font-bold text-slate-900">
{referrer ? "Modifier l'apporteur" : "Nouvel apporteur"}
</h2>
<button
onClick={onClose}
className="p-2 hover:bg-slate-100 rounded-lg transition"
>
<X className="w-5 h-5" />
</button>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-4">
{error && (
<div className="p-3 bg-red-50 border border-red-200 text-red-700 rounded-lg text-sm">
{error}
</div>
)}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Code <span className="text-red-500">*</span>
</label>
<input
type="text"
required
disabled={!!referrer}
value={formData.code}
onChange={(e) => setFormData({ ...formData, code: e.target.value.toUpperCase() })}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent disabled:bg-slate-100 disabled:cursor-not-allowed"
placeholder="LDP"
/>
{referrer && (
<p className="text-xs text-slate-500 mt-1">Le code ne peut pas être modifié</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Nom <span className="text-red-500">*</span>
</label>
<input
type="text"
required
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
placeholder="La Douce Prod"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Nom du contact
</label>
<input
type="text"
value={formData.contact_name}
onChange={(e) => setFormData({ ...formData, contact_name: e.target.value })}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
placeholder="Raphaël LEFEBVRE"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Adresse
</label>
<input
type="text"
value={formData.address}
onChange={(e) => setFormData({ ...formData, address: e.target.value })}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
placeholder="16 rue Nationale"
/>
</div>
<div className="grid grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Code postal
</label>
<input
type="text"
value={formData.postal_code}
onChange={(e) => setFormData({ ...formData, postal_code: e.target.value })}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
placeholder="59000"
/>
</div>
<div className="col-span-2">
<label className="block text-sm font-medium text-slate-700 mb-1">
Ville
</label>
<input
type="text"
value={formData.city}
onChange={(e) => setFormData({ ...formData, city: e.target.value })}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
placeholder="LILLE"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Email
</label>
<input
type="email"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
placeholder="contact@ladouceprod.fr"
/>
</div>
<div className="flex items-center justify-end gap-3 pt-4 border-t border-slate-200">
<button
type="button"
onClick={onClose}
disabled={loading}
className="px-4 py-2 text-slate-700 bg-slate-100 hover:bg-slate-200 rounded-lg transition disabled:opacity-50 disabled:cursor-not-allowed"
>
Annuler
</button>
<button
type="submit"
disabled={loading}
className="px-4 py-2 text-white bg-indigo-600 hover:bg-indigo-700 rounded-lg transition disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? "Enregistrement..." : referrer ? "Modifier" : "Créer"}
</button>
</div>
</form>
</div>
</div>
);
}

View file

@ -161,19 +161,38 @@ export default function SalaryTransfersGrid({
channel.on(
"postgres_changes",
{ event: "*", schema: "public", table: "salary_transfers" },
(payload: any) => {
async (payload: any) => {
try {
const event = payload.event || payload.eventType || payload.type;
const record = payload.new ?? payload.record ?? payload.payload ?? payload;
if (event === "INSERT") {
const newRec = record as SalaryTransfer;
// Enrichir avec le nom de l'organisation
let enrichedRecord = { ...record } as SalaryTransfer;
if (record.org_id && organizations.length > 0) {
const org = organizations.find(o => o.id === record.org_id);
if (org) {
enrichedRecord.organizations = { name: org.name };
}
}
setRows((rs) => {
if (rs.find((r) => r.id === newRec.id)) return rs;
return [newRec, ...rs];
if (rs.find((r) => r.id === enrichedRecord.id)) return rs;
return [enrichedRecord, ...rs];
});
} else if (event === "UPDATE") {
setRows((rs) => rs.map((r) => (r.id === record.id ? { ...r, ...(record as SalaryTransfer) } : r)));
// Enrichir avec le nom de l'organisation si nécessaire
let enrichedRecord = { ...record } as SalaryTransfer;
if (record.org_id && organizations.length > 0) {
const org = organizations.find(o => o.id === record.org_id);
if (org) {
enrichedRecord.organizations = { name: org.name };
}
}
setRows((rs) => rs.map((r) => (r.id === record.id ? { ...r, ...enrichedRecord } : r)));
} else if (event === "DELETE") {
const id = record?.id ?? payload.old?.id;
if (id) setRows((rs) => rs.filter((r) => r.id !== id));
@ -210,7 +229,7 @@ export default function SalaryTransfersGrid({
console.warn("Error unsubscribing realtime channel", err);
}
};
}, []);
}, [organizations]);
// Helper: fetch server-side with current filters
async function fetchServer(pageIndex = 0) {
@ -517,25 +536,47 @@ export default function SalaryTransfersGrid({
async function handleNotifyClient() {
if (!selectedTransfer || !selectedTransfer.id || !selectedTransfer.org_id) return;
// Charger les détails de l'organisation pour afficher les emails dans le modal
// Charger les détails de l'organisation via l'API (pour éviter les problèmes RLS)
try {
const { data, error } = await supabase
.from("organization_details")
.select("email_notifs, email_notifs_cc")
.eq("org_id", selectedTransfer.org_id)
.single();
console.log("[handleNotifyClient] Chargement des emails pour org_id:", selectedTransfer.org_id);
if (!error && data) {
setOrganizationDetails(data);
const response = await fetch(`/api/staff/organizations/${selectedTransfer.org_id}/emails`, {
method: 'GET',
credentials: 'include',
headers: {
'Accept': 'application/json',
'Cache-Control': 'no-cache',
},
});
if (!response.ok) {
const errorText = await response.text();
console.error("[handleNotifyClient] API Error:", response.status, errorText);
throw new Error(`Erreur ${response.status}: ${errorText}`);
}
const data = await response.json();
console.log("[handleNotifyClient] Emails récupérés:", data);
if (data.email_notifs || data.email_notifs_cc) {
setOrganizationDetails({
email_notifs: data.email_notifs,
email_notifs_cc: data.email_notifs_cc
});
} else {
console.warn("[handleNotifyClient] Aucun email configuré pour org_id:", selectedTransfer.org_id);
setOrganizationDetails(null);
}
} catch (err) {
console.error("Error loading organization details:", err);
setOrganizationDetails(null);
}
setShowNotifyClientModal(true);
// Ouvrir le modal après avoir chargé les données
setShowNotifyClientModal(true);
} catch (err) {
console.error("Error loading organization emails:", err);
setOrganizationDetails(null);
// Ouvrir quand même le modal pour afficher l'erreur
setShowNotifyClientModal(true);
}
}
async function confirmNotifyClient() {
@ -1853,7 +1894,11 @@ export default function SalaryTransfersGrid({
: undefined
}
clientEmail={organizationDetails?.email_notifs || undefined}
ccEmails={organizationDetails?.email_notifs_cc ? [organizationDetails.email_notifs_cc] : []}
ccEmails={
organizationDetails?.email_notifs_cc
? [organizationDetails.email_notifs_cc]
: []
}
/>
</div>
);

View file

@ -59,6 +59,14 @@ export default function NotifyClientModal({
}: NotifyClientModalProps) {
if (!isOpen) return null;
// Debug logs
console.log("[NotifyClientModal] Props reçues:", {
clientEmail,
ccEmails,
organizationName,
transferId: transfer.id
});
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-2xl w-full max-w-2xl max-h-[90vh] overflow-y-auto shadow-2xl">

View file

@ -0,0 +1,11 @@
-- Migration : Ajouter la colonne email à la table referrers
ALTER TABLE referrers
ADD COLUMN IF NOT EXISTS email VARCHAR(255);
COMMENT ON COLUMN referrers.email IS 'Email de contact de l''apporteur d''affaires';
-- Mettre à jour l'apporteur La Douce Prod avec son email
UPDATE referrers
SET email = 'raph@ladouceprod.fr'
WHERE code = 'LDP';

View file

@ -0,0 +1,227 @@
-- Migration pour le système de NAA (Note d'Apporteur d'Affaires)
-- 1. Ajouter les colonnes à organization_details pour gérer les apporteurs d'affaires
ALTER TABLE organization_details
ADD COLUMN IF NOT EXISTS is_referred BOOLEAN DEFAULT false,
ADD COLUMN IF NOT EXISTS referrer_code VARCHAR(50),
ADD COLUMN IF NOT EXISTS commission_rate DECIMAL(6,4) DEFAULT 0.0;
-- Commentaires pour les nouvelles colonnes
COMMENT ON COLUMN organization_details.is_referred IS 'Indique si le client a été apporté par un apporteur d''affaires';
COMMENT ON COLUMN organization_details.referrer_code IS 'Code de l''apporteur d''affaires (ex: LDP pour La Douce Prod)';
COMMENT ON COLUMN organization_details.commission_rate IS 'Taux de commission pour l''apporteur (ex: 0.20 pour 20%)';
-- 2. Créer la table des apporteurs d'affaires
CREATE TABLE IF NOT EXISTS referrers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
code VARCHAR(50) UNIQUE NOT NULL,
name VARCHAR(255) NOT NULL,
contact_name VARCHAR(255),
address VARCHAR(255),
postal_code VARCHAR(10),
city VARCHAR(100),
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
COMMENT ON TABLE referrers IS 'Table des apporteurs d''affaires';
-- Insérer l'apporteur La Douce Prod
INSERT INTO referrers (code, name, contact_name, address, postal_code, city)
VALUES ('LDP', 'La Douce Prod', 'Raphaël LEFEBVRE', '16 rue Nationale', '59000', 'LILLE')
ON CONFLICT (code) DO NOTHING;
-- 3. Créer la table des NAA
CREATE TABLE IF NOT EXISTS naa_documents (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
naa_number VARCHAR(20) UNIQUE NOT NULL,
referrer_id UUID REFERENCES referrers(id) ON DELETE CASCADE,
referrer_code VARCHAR(50) NOT NULL,
periode VARCHAR(50) NOT NULL, -- ex: "Février 2025"
callsheet_date DATE NOT NULL,
limit_date DATE,
total_commission DECIMAL(10,2) DEFAULT 0.0,
solde_compte_apporteur DECIMAL(10,2) DEFAULT 0.0,
total_facture DECIMAL(10,2) DEFAULT 0.0,
deposit DECIMAL(10,2) DEFAULT 0.0,
nbre_clients INTEGER DEFAULT 0,
nbre_prestations INTEGER DEFAULT 0,
transfer_reference VARCHAR(100),
pdf_url TEXT,
s3_key TEXT,
status VARCHAR(20) DEFAULT 'draft', -- draft, sent, paid
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
created_by UUID
);
COMMENT ON TABLE naa_documents IS 'Table des Notes d''Apporteur d''Affaires (NAA)';
COMMENT ON COLUMN naa_documents.naa_number IS 'Numéro unique de la NAA (ex: NAA-000001)';
COMMENT ON COLUMN naa_documents.status IS 'Statut de la NAA: draft, sent, paid';
-- Index pour les recherches
CREATE INDEX IF NOT EXISTS idx_naa_documents_referrer_id ON naa_documents(referrer_id);
CREATE INDEX IF NOT EXISTS idx_naa_documents_periode ON naa_documents(periode);
CREATE INDEX IF NOT EXISTS idx_naa_documents_status ON naa_documents(status);
CREATE INDEX IF NOT EXISTS idx_organization_details_referrer ON organization_details(referrer_code) WHERE is_referred = true;
-- 4. Créer la table des lignes de commissions (line items)
CREATE TABLE IF NOT EXISTS naa_line_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
naa_id UUID REFERENCES naa_documents(id) ON DELETE CASCADE,
organization_id UUID REFERENCES organizations(id) ON DELETE CASCADE,
client_name VARCHAR(255) NOT NULL,
client_code VARCHAR(50),
commission_rate DECIMAL(6,4) NOT NULL,
ca_ht DECIMAL(10,2) NOT NULL,
commission DECIMAL(10,2) NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
COMMENT ON TABLE naa_line_items IS 'Lignes de commission par client pour chaque NAA';
CREATE INDEX IF NOT EXISTS idx_naa_line_items_naa_id ON naa_line_items(naa_id);
CREATE INDEX IF NOT EXISTS idx_naa_line_items_organization_id ON naa_line_items(organization_id);
-- 5. Créer la table des prestations NAA
CREATE TABLE IF NOT EXISTS naa_prestations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
naa_id UUID REFERENCES naa_documents(id) ON DELETE CASCADE,
client_name VARCHAR(255) NOT NULL,
client_code VARCHAR(50),
type_prestation VARCHAR(100) NOT NULL,
quantite INTEGER NOT NULL,
tarif DECIMAL(10,2) NOT NULL,
total DECIMAL(10,2) NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
COMMENT ON TABLE naa_prestations IS 'Détail des prestations pour chaque NAA';
CREATE INDEX IF NOT EXISTS idx_naa_prestations_naa_id ON naa_prestations(naa_id);
-- 6. Fonction pour générer automatiquement le numéro de NAA
CREATE OR REPLACE FUNCTION generate_naa_number()
RETURNS TEXT AS $$
DECLARE
next_number INTEGER;
formatted_number TEXT;
BEGIN
-- Récupérer le dernier numéro
SELECT COALESCE(MAX(CAST(SUBSTRING(naa_number FROM 5) AS INTEGER)), 0) + 1
INTO next_number
FROM naa_documents
WHERE naa_number LIKE 'NAA-%';
-- Formater avec des zéros
formatted_number := 'NAA-' || LPAD(next_number::TEXT, 6, '0');
RETURN formatted_number;
END;
$$ LANGUAGE plpgsql;
-- 7. Trigger pour mettre à jour updated_at
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER update_referrers_updated_at
BEFORE UPDATE ON referrers
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_naa_documents_updated_at
BEFORE UPDATE ON naa_documents
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
-- 8. Activer Row Level Security (RLS)
ALTER TABLE referrers ENABLE ROW LEVEL SECURITY;
ALTER TABLE naa_documents ENABLE ROW LEVEL SECURITY;
ALTER TABLE naa_line_items ENABLE ROW LEVEL SECURITY;
ALTER TABLE naa_prestations ENABLE ROW LEVEL SECURITY;
-- Policies pour le staff uniquement
CREATE POLICY "Staff can view all referrers" ON referrers
FOR SELECT TO authenticated
USING (
EXISTS (
SELECT 1 FROM staff_users
WHERE staff_users.user_id = auth.uid()
AND staff_users.is_staff = true
)
);
CREATE POLICY "Staff can manage all referrers" ON referrers
FOR ALL TO authenticated
USING (
EXISTS (
SELECT 1 FROM staff_users
WHERE staff_users.user_id = auth.uid()
AND staff_users.is_staff = true
)
);
CREATE POLICY "Staff can view all NAA documents" ON naa_documents
FOR SELECT TO authenticated
USING (
EXISTS (
SELECT 1 FROM staff_users
WHERE staff_users.user_id = auth.uid()
AND staff_users.is_staff = true
)
);
CREATE POLICY "Staff can manage all NAA documents" ON naa_documents
FOR ALL TO authenticated
USING (
EXISTS (
SELECT 1 FROM staff_users
WHERE staff_users.user_id = auth.uid()
AND staff_users.is_staff = true
)
);
CREATE POLICY "Staff can view all NAA line items" ON naa_line_items
FOR SELECT TO authenticated
USING (
EXISTS (
SELECT 1 FROM staff_users
WHERE staff_users.user_id = auth.uid()
AND staff_users.is_staff = true
)
);
CREATE POLICY "Staff can manage all NAA line items" ON naa_line_items
FOR ALL TO authenticated
USING (
EXISTS (
SELECT 1 FROM staff_users
WHERE staff_users.user_id = auth.uid()
AND staff_users.is_staff = true
)
);
CREATE POLICY "Staff can view all NAA prestations" ON naa_prestations
FOR SELECT TO authenticated
USING (
EXISTS (
SELECT 1 FROM staff_users
WHERE staff_users.user_id = auth.uid()
AND staff_users.is_staff = true
)
);
CREATE POLICY "Staff can manage all NAA prestations" ON naa_prestations
FOR ALL TO authenticated
USING (
EXISTS (
SELECT 1 FROM staff_users
WHERE staff_users.user_id = auth.uid()
AND staff_users.is_staff = true
)
);

View file

@ -0,0 +1,193 @@
#!/bin/bash
set -e
# Script de test complet du workflow Odentas Sign
# 1. Upload PDF test dans S3 (déclenche Lambda conversion)
# 2. Création signature request via API
# 3. Envoi OTP et signature électronique
# 4. Application PAdES (signature PDF)
# 5. Horodatage TSA
# 6. Compliance lock dans bucket odentas-sign
echo "========================================="
echo "🧪 Test complet Odentas Sign Workflow"
echo "========================================="
echo ""
# Variables
REQUEST_ID="TEST-$(date +%s)"
PDF_FILE="test-contrat.pdf"
SOURCE_BUCKET="odentas-sign"
DEST_BUCKET="odentas-docs"
API_BASE="http://localhost:3000"
echo "📋 Configuration:"
echo " - Request ID: $REQUEST_ID"
echo " - PDF: $PDF_FILE"
echo " - API: $API_BASE"
echo ""
# Étape 1: Upload PDF dans S3 (déclenche conversion automatique)
echo "📤 Étape 1: Upload PDF dans S3..."
aws s3 cp "$PDF_FILE" "s3://$SOURCE_BUCKET/source/test/$REQUEST_ID.pdf"
echo "✅ PDF uploadé: s3://$SOURCE_BUCKET/source/test/$REQUEST_ID.pdf"
echo ""
# Attendre la conversion Lambda
echo "⏳ Attente conversion Lambda (15s)..."
sleep 15
echo ""
# Vérifier que les images sont générées
echo "🔍 Vérification images converties..."
IMAGE_COUNT=$(aws s3 ls "s3://$DEST_BUCKET/odentas-sign-images/$REQUEST_ID/" | wc -l)
echo "$IMAGE_COUNT image(s) générée(s)"
echo ""
# Afficher les logs Lambda
echo "📋 Logs Lambda (dernière exécution):"
aws logs tail /aws/lambda/odentas-sign-pdf-converter --since 2m --region eu-west-3 --format short | grep -E "($REQUEST_ID|page|✅)" || echo "Pas de logs pour $REQUEST_ID"
echo ""
# Étape 2: Créer une signature request via API
echo "📝 Étape 2: Création signature request..."
SIGNER_EMAIL="test-$(date +%s)@example.com"
SIGNER_NAME="Test Signer"
CREATE_RESPONSE=$(curl -s -X POST "$API_BASE/api/odentas-sign/requests" \
-H "Content-Type: application/json" \
-d "{
\"documentKey\": \"source/test/$REQUEST_ID.pdf\",
\"signers\": [
{
\"email\": \"$SIGNER_EMAIL\",
\"name\": \"$SIGNER_NAME\",
\"signatureFields\": [
{
\"page\": 1,
\"x\": 100,
\"y\": 100,
\"width\": 200,
\"height\": 50
}
]
}
]
}")
echo "$CREATE_RESPONSE" | jq '.'
SIGNATURE_REQUEST_ID=$(echo "$CREATE_RESPONSE" | jq -r '.id')
SIGNER_ID=$(echo "$CREATE_RESPONSE" | jq -r '.signers[0].id')
echo "✅ Request créée: $SIGNATURE_REQUEST_ID"
echo "✅ Signer ID: $SIGNER_ID"
echo ""
# Étape 3: Récupérer l'OTP depuis les logs
echo "🔐 Étape 3: Envoi OTP..."
sleep 2
# Simuler l'envoi d'OTP (normalement par email)
OTP_RESPONSE=$(curl -s -X POST "$API_BASE/api/odentas-sign/requests/$SIGNATURE_REQUEST_ID/signers/$SIGNER_ID/otp")
echo "$OTP_RESPONSE" | jq '.'
# Récupérer l'OTP depuis les logs API (mode dev)
echo "📋 Recherche OTP dans les logs..."
OTP_CODE=$(grep -A 5 "OTP généré" .next/server.log 2>/dev/null | grep -oE '[0-9]{6}' | tail -1 || echo "")
if [ -z "$OTP_CODE" ]; then
echo "⚠️ OTP non trouvé dans les logs, utilisez '123456' par défaut"
OTP_CODE="123456"
fi
echo "🔑 OTP: $OTP_CODE"
echo ""
# Étape 4: Vérifier l'OTP
echo "✅ Étape 4: Vérification OTP..."
VERIFY_RESPONSE=$(curl -s -X POST "$API_BASE/api/odentas-sign/requests/$SIGNATURE_REQUEST_ID/signers/$SIGNER_ID/verify-otp" \
-H "Content-Type: application/json" \
-d "{\"otp\": \"$OTP_CODE\"}")
echo "$VERIFY_RESPONSE" | jq '.'
SESSION_TOKEN=$(echo "$VERIFY_RESPONSE" | jq -r '.sessionToken')
echo "✅ Session token obtenu"
echo ""
# Étape 5: Signer le document
echo "✍️ Étape 5: Signature électronique..."
SIGN_RESPONSE=$(curl -s -X POST "$API_BASE/api/odentas-sign/requests/$SIGNATURE_REQUEST_ID/signers/$SIGNER_ID/sign" \
-H "Content-Type: application/json" \
-H "X-Session-Token: $SESSION_TOKEN" \
-d "{
\"signatureImage\": \"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==\"
}")
echo "$SIGN_RESPONSE" | jq '.'
echo "✅ Signature appliquée"
echo ""
# Étape 6: Vérifier le statut PAdES
echo "📄 Étape 6: Vérification signature PAdES..."
sleep 3
STATUS_RESPONSE=$(curl -s "$API_BASE/api/odentas-sign/requests/$SIGNATURE_REQUEST_ID")
echo "$STATUS_RESPONSE" | jq '.status, .signers[0].status, .pades_applied, .tsa_applied'
echo ""
# Étape 7: Vérifier TSA
echo "🕐 Étape 7: Vérification horodatage TSA..."
TSA_STATUS=$(echo "$STATUS_RESPONSE" | jq -r '.tsa_applied')
if [ "$TSA_STATUS" = "true" ]; then
echo "✅ Horodatage TSA appliqué"
else
echo "⏳ Horodatage TSA en cours..."
fi
echo ""
# Étape 8: Vérifier compliance lock dans S3
echo "🔒 Étape 8: Vérification compliance lock..."
SIGNED_KEY="signed/$REQUEST_ID-signed.pdf"
# Attendre que le PDF signé soit disponible
sleep 5
LOCK_STATUS=$(aws s3api head-object \
--bucket "$SOURCE_BUCKET" \
--key "$SIGNED_KEY" \
--query 'ObjectLockMode' \
--output text 2>/dev/null || echo "NOT_FOUND")
if [ "$LOCK_STATUS" = "COMPLIANCE" ]; then
echo "✅ Compliance lock activé sur le PDF signé"
# Afficher la date d'expiration du lock
RETAIN_UNTIL=$(aws s3api head-object \
--bucket "$SOURCE_BUCKET" \
--key "$SIGNED_KEY" \
--query 'ObjectLockRetainUntilDate' \
--output text)
echo "📅 Verrouillé jusqu'au: $RETAIN_UNTIL"
else
echo "⚠️ Compliance lock non trouvé (status: $LOCK_STATUS)"
fi
echo ""
# Résumé final
echo "========================================="
echo "✅ Test complet terminé!"
echo "========================================="
echo ""
echo "📊 Résumé:"
echo " - Request ID: $SIGNATURE_REQUEST_ID"
echo " - Signer: $SIGNER_NAME ($SIGNER_EMAIL)"
echo " - Images converties: $IMAGE_COUNT"
echo " - PAdES: $(echo "$STATUS_RESPONSE" | jq -r '.pades_applied')"
echo " - TSA: $(echo "$STATUS_RESPONSE" | jq -r '.tsa_applied')"
echo " - Compliance lock: $LOCK_STATUS"
echo ""
echo "🔗 Liens utiles:"
echo " - PDF source: https://s3.console.aws.amazon.com/s3/object/$SOURCE_BUCKET?prefix=source/test/$REQUEST_ID.pdf"
echo " - Images: https://s3.console.aws.amazon.com/s3/buckets/$DEST_BUCKET?prefix=odentas-sign-images/$REQUEST_ID/"
echo " - PDF signé: https://s3.console.aws.amazon.com/s3/object/$SOURCE_BUCKET?prefix=$SIGNED_KEY"
echo ""