feat: Ajout modal création en masse de paies avec champs exacts du PayslipModal
This commit is contained in:
parent
2aeac651c1
commit
c55ead58ca
23 changed files with 3255 additions and 21 deletions
164
app/(app)/staff/apporteurs/page.tsx
Normal file
164
app/(app)/staff/apporteurs/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -69,6 +69,13 @@ type ClientData = {
|
||||||
details: any;
|
details: any;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type Referrer = {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
contact_name: string;
|
||||||
|
};
|
||||||
|
|
||||||
function Line({ label, value }: { label: string; value?: string | number | null }) {
|
function Line({ label, value }: { label: string; value?: string | number | null }) {
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-3 gap-2 border-b last:border-b-0 py-2">
|
<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 [isEditing, setIsEditing] = useState(false);
|
||||||
const [editData, setEditData] = useState<Partial<Organization & StructureInfos & any>>({});
|
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
|
// Récupération des données du client
|
||||||
const {
|
const {
|
||||||
data: clientData,
|
data: clientData,
|
||||||
|
|
@ -313,6 +333,11 @@ export default function ClientDetailPage() {
|
||||||
offre_speciale: structureInfos.offre_speciale,
|
offre_speciale: structureInfos.offre_speciale,
|
||||||
notes: structureInfos.notes,
|
notes: structureInfos.notes,
|
||||||
|
|
||||||
|
// Apporteur d'affaires
|
||||||
|
is_referred: details.is_referred,
|
||||||
|
referrer_code: details.referrer_code,
|
||||||
|
commission_rate: details.commission_rate,
|
||||||
|
|
||||||
// Structure infos
|
// Structure infos
|
||||||
code_employeur: structureInfos.code_employeur, // Remplace structure_api
|
code_employeur: structureInfos.code_employeur, // Remplace structure_api
|
||||||
siret: structureInfos.siret,
|
siret: structureInfos.siret,
|
||||||
|
|
@ -658,6 +683,43 @@ export default function ClientDetailPage() {
|
||||||
value={editData.notes}
|
value={editData.notes}
|
||||||
onChange={(value) => setEditData(prev => ({ ...prev, notes: value }))}
|
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>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|
@ -665,6 +727,26 @@ export default function ClientDetailPage() {
|
||||||
<Line label="Ouverture de compte" value={structureInfos.ouverture_compte} />
|
<Line label="Ouverture de compte" value={structureInfos.ouverture_compte} />
|
||||||
<Line label="Offre spéciale" value={structureInfos.offre_speciale} />
|
<Line label="Offre spéciale" value={structureInfos.offre_speciale} />
|
||||||
<Line label="Note" value={structureInfos.notes} />
|
<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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
338
app/(app)/staff/naa/page.tsx
Normal file
338
app/(app)/staff/naa/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -39,7 +39,7 @@ export default async function StaffSalaryTransfersPage() {
|
||||||
// initial fetch: server-side list of latest salary transfers (limited)
|
// initial fetch: server-side list of latest salary transfers (limited)
|
||||||
const { data: salaryTransfers, error } = await sb
|
const { data: salaryTransfers, error } = await sb
|
||||||
.from("salary_transfers")
|
.from("salary_transfers")
|
||||||
.select("*")
|
.select("*, organizations!org_id(name)")
|
||||||
.order("period_month", { ascending: false })
|
.order("period_month", { ascending: false })
|
||||||
.limit(200);
|
.limit(200);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -178,6 +178,10 @@ export async function PUT(
|
||||||
ouverture_compte,
|
ouverture_compte,
|
||||||
offre_speciale,
|
offre_speciale,
|
||||||
notes,
|
notes,
|
||||||
|
// Apporteur d'affaires
|
||||||
|
is_referred,
|
||||||
|
referrer_code,
|
||||||
|
commission_rate,
|
||||||
// Champs de détails de l'organisation
|
// Champs de détails de l'organisation
|
||||||
code_employeur, // Remplace structure_api
|
code_employeur, // Remplace structure_api
|
||||||
siret,
|
siret,
|
||||||
|
|
@ -231,6 +235,10 @@ export async function PUT(
|
||||||
if (ouverture_compte !== undefined) detailsUpdateData.ouverture_compte = ouverture_compte;
|
if (ouverture_compte !== undefined) detailsUpdateData.ouverture_compte = ouverture_compte;
|
||||||
if (offre_speciale !== undefined) detailsUpdateData.offre_speciale = offre_speciale;
|
if (offre_speciale !== undefined) detailsUpdateData.offre_speciale = offre_speciale;
|
||||||
if (notes !== undefined) detailsUpdateData.notes = notes;
|
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
|
// Autres champs
|
||||||
if (code_employeur !== undefined) detailsUpdateData.code_employeur = code_employeur; // Nouveau champ
|
if (code_employeur !== undefined) detailsUpdateData.code_employeur = code_employeur; // Nouveau champ
|
||||||
if (siret !== undefined) detailsUpdateData.siret = siret;
|
if (siret !== undefined) detailsUpdateData.siret = siret;
|
||||||
|
|
|
||||||
76
app/api/staff/contracts/bulk-update-analytique/route.ts
Normal file
76
app/api/staff/contracts/bulk-update-analytique/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
86
app/api/staff/naa/[id]/presigned-url/route.ts
Normal file
86
app/api/staff/naa/[id]/presigned-url/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
255
app/api/staff/naa/[id]/regenerate/route.ts
Normal file
255
app/api/staff/naa/[id]/regenerate/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
113
app/api/staff/naa/[id]/route.ts
Normal file
113
app/api/staff/naa/[id]/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
503
app/api/staff/naa/generate/route.ts
Normal file
503
app/api/staff/naa/generate/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
58
app/api/staff/naa/route.ts
Normal file
58
app/api/staff/naa/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
67
app/api/staff/organizations/[orgId]/emails/route.ts
Normal file
67
app/api/staff/organizations/[orgId]/emails/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
59
app/api/staff/referrers/[code]/clients/route.ts
Normal file
59
app/api/staff/referrers/[code]/clients/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
162
app/api/staff/referrers/route.ts
Normal file
162
app/api/staff/referrers/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -95,7 +95,7 @@ export async function POST(req: NextRequest) {
|
||||||
const { data: newTransfer, error: insertError } = await supabase
|
const { data: newTransfer, error: insertError } = await supabase
|
||||||
.from("salary_transfers")
|
.from("salary_transfers")
|
||||||
.insert(insertData)
|
.insert(insertData)
|
||||||
.select()
|
.select("*, organizations!org_id(name)")
|
||||||
.single();
|
.single();
|
||||||
|
|
||||||
if (insertError) {
|
if (insertError) {
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { useState, useEffect, useRef } from "react";
|
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 { api } from "@/lib/fetcher";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import LogoutButton from "@/components/LogoutButton";
|
import LogoutButton from "@/components/LogoutButton";
|
||||||
|
|
@ -587,6 +587,22 @@ export default function Sidebar({ clientInfo, isStaff = false, mobile = false, o
|
||||||
<span>Cotisations</span>
|
<span>Cotisations</span>
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Administration */}
|
{/* Administration */}
|
||||||
|
|
|
||||||
551
components/staff/CreateNAAModal.tsx
Normal file
551
components/staff/CreateNAAModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
212
components/staff/ReferrerModal.tsx
Normal file
212
components/staff/ReferrerModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -161,19 +161,38 @@ export default function SalaryTransfersGrid({
|
||||||
channel.on(
|
channel.on(
|
||||||
"postgres_changes",
|
"postgres_changes",
|
||||||
{ event: "*", schema: "public", table: "salary_transfers" },
|
{ event: "*", schema: "public", table: "salary_transfers" },
|
||||||
(payload: any) => {
|
async (payload: any) => {
|
||||||
try {
|
try {
|
||||||
const event = payload.event || payload.eventType || payload.type;
|
const event = payload.event || payload.eventType || payload.type;
|
||||||
const record = payload.new ?? payload.record ?? payload.payload ?? payload;
|
const record = payload.new ?? payload.record ?? payload.payload ?? payload;
|
||||||
|
|
||||||
if (event === "INSERT") {
|
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) => {
|
setRows((rs) => {
|
||||||
if (rs.find((r) => r.id === newRec.id)) return rs;
|
if (rs.find((r) => r.id === enrichedRecord.id)) return rs;
|
||||||
return [newRec, ...rs];
|
return [enrichedRecord, ...rs];
|
||||||
});
|
});
|
||||||
} else if (event === "UPDATE") {
|
} 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") {
|
} else if (event === "DELETE") {
|
||||||
const id = record?.id ?? payload.old?.id;
|
const id = record?.id ?? payload.old?.id;
|
||||||
if (id) setRows((rs) => rs.filter((r) => r.id !== 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);
|
console.warn("Error unsubscribing realtime channel", err);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, []);
|
}, [organizations]);
|
||||||
|
|
||||||
// Helper: fetch server-side with current filters
|
// Helper: fetch server-side with current filters
|
||||||
async function fetchServer(pageIndex = 0) {
|
async function fetchServer(pageIndex = 0) {
|
||||||
|
|
@ -517,25 +536,47 @@ export default function SalaryTransfersGrid({
|
||||||
async function handleNotifyClient() {
|
async function handleNotifyClient() {
|
||||||
if (!selectedTransfer || !selectedTransfer.id || !selectedTransfer.org_id) return;
|
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 {
|
try {
|
||||||
const { data, error } = await supabase
|
console.log("[handleNotifyClient] Chargement des emails pour org_id:", selectedTransfer.org_id);
|
||||||
.from("organization_details")
|
|
||||||
.select("email_notifs, email_notifs_cc")
|
|
||||||
.eq("org_id", selectedTransfer.org_id)
|
|
||||||
.single();
|
|
||||||
|
|
||||||
if (!error && data) {
|
const response = await fetch(`/api/staff/organizations/${selectedTransfer.org_id}/emails`, {
|
||||||
setOrganizationDetails(data);
|
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 {
|
} else {
|
||||||
|
console.warn("[handleNotifyClient] Aucun email configuré pour org_id:", selectedTransfer.org_id);
|
||||||
setOrganizationDetails(null);
|
setOrganizationDetails(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ouvrir le modal après avoir chargé les données
|
||||||
|
setShowNotifyClientModal(true);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Error loading organization details:", err);
|
console.error("Error loading organization emails:", err);
|
||||||
setOrganizationDetails(null);
|
setOrganizationDetails(null);
|
||||||
|
// Ouvrir quand même le modal pour afficher l'erreur
|
||||||
|
setShowNotifyClientModal(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
setShowNotifyClientModal(true);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function confirmNotifyClient() {
|
async function confirmNotifyClient() {
|
||||||
|
|
@ -1853,7 +1894,11 @@ export default function SalaryTransfersGrid({
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
clientEmail={organizationDetails?.email_notifs || 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>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,14 @@ export default function NotifyClientModal({
|
||||||
}: NotifyClientModalProps) {
|
}: NotifyClientModalProps) {
|
||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
// Debug logs
|
||||||
|
console.log("[NotifyClientModal] Props reçues:", {
|
||||||
|
clientEmail,
|
||||||
|
ccEmails,
|
||||||
|
organizationName,
|
||||||
|
transferId: transfer.id
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
<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">
|
<div className="bg-white rounded-2xl w-full max-w-2xl max-h-[90vh] overflow-y-auto shadow-2xl">
|
||||||
|
|
|
||||||
11
migrations/add_email_to_referrers.sql
Normal file
11
migrations/add_email_to_referrers.sql
Normal 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';
|
||||||
227
migrations/create_naa_system.sql
Normal file
227
migrations/create_naa_system.sql
Normal 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
|
||||||
|
)
|
||||||
|
);
|
||||||
193
test-complete-odentas-sign-workflow.sh
Executable file
193
test-complete-odentas-sign-workflow.sh
Executable 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 ""
|
||||||
Loading…
Reference in a new issue