diff --git a/app/(app)/staff/apporteurs/page.tsx b/app/(app)/staff/apporteurs/page.tsx new file mode 100644 index 0000000..d283711 --- /dev/null +++ b/app/(app)/staff/apporteurs/page.tsx @@ -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(null); + const queryClient = useQueryClient(); + + // Récupération de la liste des apporteurs + const { data: referrers = [], isLoading } = useQuery({ + 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 ( +
+
Chargement...
+
+ ); + } + + return ( +
+
+
+

Apporteurs d'affaires

+

+ Gérez les informations des apporteurs d'affaires +

+
+ +
+ +
+
+ + + + + + + + + + + + + {referrers.length === 0 ? ( + + + + ) : ( + referrers.map((referrer) => ( + + + + + + + + + )) + )} + +
+ Code + + Nom + + Contact + + Adresse + + Email + + Actions +
+ +

Aucun apporteur d'affaires enregistré

+
+ {referrer.code} + + {referrer.name} + + {referrer.contact_name || "-"} + + {referrer.address ? ( +
+
{referrer.address}
+
{referrer.postal_code} {referrer.city}
+
+ ) : "-"} +
+ {referrer.email || "-"} + +
+ +
+
+
+
+ + {/* Modal */} + {isModalOpen && ( + setIsModalOpen(false)} + onSuccess={handleSuccess} + /> + )} +
+ ); +} diff --git a/app/(app)/staff/clients/[id]/page.tsx b/app/(app)/staff/clients/[id]/page.tsx index 1a64b54..bc94975 100644 --- a/app/(app)/staff/clients/[id]/page.tsx +++ b/app/(app)/staff/clients/[id]/page.tsx @@ -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 (
@@ -251,6 +258,19 @@ export default function ClientDetailPage() { const [isEditing, setIsEditing] = useState(false); const [editData, setEditData] = useState>({}); + // Récupération de la liste des apporteurs + const { data: referrers = [] } = useQuery({ + 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 */} +
+ 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 && ( + <> + ({ value: r.code, label: r.name }))} + onChange={(value) => setEditData(prev => ({ ...prev, referrer_code: value }))} + /> + setEditData(prev => ({ ...prev, commission_rate: value }))} + /> + + )} +
) : (
@@ -665,6 +727,26 @@ export default function ClientDetailPage() { + + {/* Section Apporteur d'Affaires */} +
+ + {clientData.details.is_referred && ( + <> + r.code === clientData.details.referrer_code)?.name || clientData.details.referrer_code} + /> + + + )} +
)} diff --git a/app/(app)/staff/naa/page.tsx b/app/(app)/staff/naa/page.tsx new file mode 100644 index 0000000..ee725c1 --- /dev/null +++ b/app/(app)/staff/naa/page.tsx @@ -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(null); + const [regenerating, setRegenerating] = useState(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({ + 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 ( + + {labels[status as keyof typeof labels] || status} + + ); + }; + + return ( +
+
+
+

+ Notes Apporteurs d'Affaires +

+

+ Gestion des commissions pour les apporteurs d'affaires +

+
+ +
+ + {isLoading ? ( +
+ Chargement... +
+ ) : naaList.length === 0 ? ( +
+ +

Aucune NAA créée pour le moment

+ +
+ ) : ( +
+ + + + + + + + + + + + + + + + {naaList.map((naa) => ( + + + + + + + + + + + + ))} + +
+ N° NAA + + Apporteur + + Période + + Date + + Clients + + Commission + + Total + + Statut + + Actions +
+ {naa.naa_number} + + {naa.referrer_name || naa.referrer_code} + + {naa.periode} + + {formatDate(naa.callsheet_date)} + + {naa.nbre_clients} + + {formatCurrency(naa.total_commission)} + + {formatCurrency(naa.total_facture)} + + {getStatusBadge(naa.status)} + +
+ {naa.pdf_url && ( + <> + + + + + )} + +
+
+
+ )} + + {isCreateModalOpen && ( + setIsCreateModalOpen(false)} + onSuccess={handleCreateSuccess} + /> + )} +
+ ); +} diff --git a/app/(app)/staff/virements-salaires/page.tsx b/app/(app)/staff/virements-salaires/page.tsx index 51912e5..aad19af 100644 --- a/app/(app)/staff/virements-salaires/page.tsx +++ b/app/(app)/staff/virements-salaires/page.tsx @@ -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); diff --git a/app/api/staff/clients/[id]/route.ts b/app/api/staff/clients/[id]/route.ts index 21e6872..466174e 100644 --- a/app/api/staff/clients/[id]/route.ts +++ b/app/api/staff/clients/[id]/route.ts @@ -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; diff --git a/app/api/staff/contracts/bulk-update-analytique/route.ts b/app/api/staff/contracts/bulk-update-analytique/route.ts new file mode 100644 index 0000000..05c2be1 --- /dev/null +++ b/app/api/staff/contracts/bulk-update-analytique/route.ts @@ -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 } + ); + } +} diff --git a/app/api/staff/naa/[id]/presigned-url/route.ts b/app/api/staff/naa/[id]/presigned-url/route.ts new file mode 100644 index 0000000..7f043d4 --- /dev/null +++ b/app/api/staff/naa/[id]/presigned-url/route.ts @@ -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 } + ); + } +} diff --git a/app/api/staff/naa/[id]/regenerate/route.ts b/app/api/staff/naa/[id]/regenerate/route.ts new file mode 100644 index 0000000..ea9773e --- /dev/null +++ b/app/api/staff/naa/[id]/regenerate/route.ts @@ -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 } + ); + } +} diff --git a/app/api/staff/naa/[id]/route.ts b/app/api/staff/naa/[id]/route.ts new file mode 100644 index 0000000..ee4680c --- /dev/null +++ b/app/api/staff/naa/[id]/route.ts @@ -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 } + ); + } +} diff --git a/app/api/staff/naa/generate/route.ts b/app/api/staff/naa/generate/route.ts new file mode 100644 index 0000000..4b9994d --- /dev/null +++ b/app/api/staff/naa/generate/route.ts @@ -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 } + ); + } +} diff --git a/app/api/staff/naa/route.ts b/app/api/staff/naa/route.ts new file mode 100644 index 0000000..be7c834 --- /dev/null +++ b/app/api/staff/naa/route.ts @@ -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 } + ); + } +} diff --git a/app/api/staff/organizations/[orgId]/emails/route.ts b/app/api/staff/organizations/[orgId]/emails/route.ts new file mode 100644 index 0000000..6263768 --- /dev/null +++ b/app/api/staff/organizations/[orgId]/emails/route.ts @@ -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 } + ); + } +} diff --git a/app/api/staff/referrers/[code]/clients/route.ts b/app/api/staff/referrers/[code]/clients/route.ts new file mode 100644 index 0000000..bfc63d5 --- /dev/null +++ b/app/api/staff/referrers/[code]/clients/route.ts @@ -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 } + ); + } +} diff --git a/app/api/staff/referrers/route.ts b/app/api/staff/referrers/route.ts new file mode 100644 index 0000000..4e932a9 --- /dev/null +++ b/app/api/staff/referrers/route.ts @@ -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 } + ); + } +} diff --git a/app/api/staff/virements-salaires/create/route.ts b/app/api/staff/virements-salaires/create/route.ts index 9eb4305..f02d477 100644 --- a/app/api/staff/virements-salaires/create/route.ts +++ b/app/api/staff/virements-salaires/create/route.ts @@ -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) { diff --git a/components/Sidebar.tsx b/components/Sidebar.tsx index 72e48d7..8420e5f 100644 --- a/components/Sidebar.tsx +++ b/components/Sidebar.tsx @@ -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 Cotisations + 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"> + + + NAA + + + 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"> + + + Apporteurs + + {/* Administration */} diff --git a/components/staff/CreateNAAModal.tsx b/components/staff/CreateNAAModal.tsx new file mode 100644 index 0000000..81eae56 --- /dev/null +++ b/components/staff/CreateNAAModal.tsx @@ -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(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([]); + + // Récupération des apporteurs + const { data: referrers = [] } = useQuery({ + 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({ + 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 ( +
+
+ {/* Header */} +
+

+ Créer une Note Apporteur d'Affaires +

+ +
+ + {/* Body */} +
+
+ {error && ( +
+ {error} +
+ )} + + {/* Informations générales */} +
+

Informations générales

+
+
+ + +
+ +
+ + 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 + /> +
+ +
+ + 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 + /> +
+ +
+ + setLimitDate(e.target.value)} + className="w-full px-3 py-2 border rounded-xl focus:outline-none focus:ring-2 focus:ring-indigo-500" + /> +
+ +
+ + 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" + /> +
+ +
+ + setSoldeCompte(e.target.value)} + className="w-full px-3 py-2 border rounded-xl focus:outline-none focus:ring-2 focus:ring-indigo-500" + /> +
+ +
+ + setDeposit(e.target.value)} + className="w-full px-3 py-2 border rounded-xl focus:outline-none focus:ring-2 focus:ring-indigo-500" + /> +
+
+
+ + {/* Prestations groupées par client */} +
+
+

Prestations par client

+ +
+ + {clientsPrestations.length === 0 ? ( +
+ Aucun client ajouté +
+ ) : ( +
+ {clientsPrestations.map((clientPresta, clientIndex) => ( +
+ {/* En-tête client */} +
+
+ +
+
+ +
+
+ +
+
+
+
Total client
+
+ {getClientTotal(clientPresta).toFixed(2)} € +
+
+
+ +
+ + {/* Lignes de prestations */} + {clientPresta.expanded && ( +
+ {clientPresta.lines.map((line, lineIndex) => ( +
+
+
+ + +
+
+ + 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" + /> +
+
+ + 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" + /> +
+
+ + +
+
+ +
+ ))} + + +
+ )} +
+ ))} +
+ )} +
+ +
+ Note : 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é. +
+
+ + {/* Footer */} +
+ + +
+
+
+
+ ); +} diff --git a/components/staff/ReferrerModal.tsx b/components/staff/ReferrerModal.tsx new file mode 100644 index 0000000..1d22b01 --- /dev/null +++ b/components/staff/ReferrerModal.tsx @@ -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(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 ( +
+
+
+

+ {referrer ? "Modifier l'apporteur" : "Nouvel apporteur"} +

+ +
+ +
+ {error && ( +
+ {error} +
+ )} + +
+
+ + 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 && ( +

Le code ne peut pas être modifié

+ )} +
+ +
+ + 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" + /> +
+
+ +
+ + 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" + /> +
+ +
+ + 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" + /> +
+ +
+
+ + 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" + /> +
+ +
+ + 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" + /> +
+
+ +
+ + 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" + /> +
+ +
+ + +
+
+
+
+ ); +} diff --git a/components/staff/SalaryTransfersGrid.tsx b/components/staff/SalaryTransfersGrid.tsx index 5377314..080ee71 100644 --- a/components/staff/SalaryTransfersGrid.tsx +++ b/components/staff/SalaryTransfersGrid.tsx @@ -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); } + + // Ouvrir le modal après avoir chargé les données + setShowNotifyClientModal(true); } catch (err) { - console.error("Error loading organization details:", err); + console.error("Error loading organization emails:", err); setOrganizationDetails(null); + // Ouvrir quand même le modal pour afficher l'erreur + setShowNotifyClientModal(true); } - - 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] + : [] + } /> ); diff --git a/components/staff/salary-transfers/NotifyClientModal.tsx b/components/staff/salary-transfers/NotifyClientModal.tsx index 67bf180..b878312 100644 --- a/components/staff/salary-transfers/NotifyClientModal.tsx +++ b/components/staff/salary-transfers/NotifyClientModal.tsx @@ -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 (
diff --git a/migrations/add_email_to_referrers.sql b/migrations/add_email_to_referrers.sql new file mode 100644 index 0000000..d467edc --- /dev/null +++ b/migrations/add_email_to_referrers.sql @@ -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'; diff --git a/migrations/create_naa_system.sql b/migrations/create_naa_system.sql new file mode 100644 index 0000000..3b49fe6 --- /dev/null +++ b/migrations/create_naa_system.sql @@ -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 + ) + ); diff --git a/test-complete-odentas-sign-workflow.sh b/test-complete-odentas-sign-workflow.sh new file mode 100755 index 0000000..6454912 --- /dev/null +++ b/test-complete-odentas-sign-workflow.sh @@ -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 ""