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;
|
||||
};
|
||||
|
||||
type Referrer = {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
contact_name: string;
|
||||
};
|
||||
|
||||
function Line({ label, value }: { label: string; value?: string | number | null }) {
|
||||
return (
|
||||
<div className="grid grid-cols-3 gap-2 border-b last:border-b-0 py-2">
|
||||
|
|
@ -251,6 +258,19 @@ export default function ClientDetailPage() {
|
|||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editData, setEditData] = useState<Partial<Organization & StructureInfos & any>>({});
|
||||
|
||||
// Récupération de la liste des apporteurs
|
||||
const { data: referrers = [] } = useQuery<Referrer[]>({
|
||||
queryKey: ["referrers"],
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/staff/referrers", {
|
||||
cache: "no-store",
|
||||
credentials: "include"
|
||||
});
|
||||
if (!res.ok) return [];
|
||||
return res.json();
|
||||
},
|
||||
});
|
||||
|
||||
// Récupération des données du client
|
||||
const {
|
||||
data: clientData,
|
||||
|
|
@ -313,6 +333,11 @@ export default function ClientDetailPage() {
|
|||
offre_speciale: structureInfos.offre_speciale,
|
||||
notes: structureInfos.notes,
|
||||
|
||||
// Apporteur d'affaires
|
||||
is_referred: details.is_referred,
|
||||
referrer_code: details.referrer_code,
|
||||
commission_rate: details.commission_rate,
|
||||
|
||||
// Structure infos
|
||||
code_employeur: structureInfos.code_employeur, // Remplace structure_api
|
||||
siret: structureInfos.siret,
|
||||
|
|
@ -658,6 +683,43 @@ export default function ClientDetailPage() {
|
|||
value={editData.notes}
|
||||
onChange={(value) => setEditData(prev => ({ ...prev, notes: value }))}
|
||||
/>
|
||||
|
||||
{/* Section Apporteur d'Affaires */}
|
||||
<div className="pt-2 border-t">
|
||||
<EditableLine
|
||||
label="Client apporté ?"
|
||||
value={editData.is_referred ? "true" : "false"}
|
||||
type="select"
|
||||
options={[
|
||||
{ value: "false", label: "Non" },
|
||||
{ value: "true", label: "Oui" },
|
||||
]}
|
||||
onChange={(value) => setEditData(prev => ({
|
||||
...prev,
|
||||
is_referred: value === "true",
|
||||
referrer_code: value === "false" ? undefined : prev.referrer_code,
|
||||
commission_rate: value === "false" ? undefined : prev.commission_rate
|
||||
}))}
|
||||
/>
|
||||
|
||||
{editData.is_referred && (
|
||||
<>
|
||||
<EditableLine
|
||||
label="Apporteur"
|
||||
value={editData.referrer_code}
|
||||
type="select"
|
||||
options={referrers.map(r => ({ value: r.code, label: r.name }))}
|
||||
onChange={(value) => setEditData(prev => ({ ...prev, referrer_code: value }))}
|
||||
/>
|
||||
<EditableLine
|
||||
label="Taux de commission"
|
||||
value={editData.commission_rate}
|
||||
type="number"
|
||||
onChange={(value) => setEditData(prev => ({ ...prev, commission_rate: value }))}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
|
|
@ -665,6 +727,26 @@ export default function ClientDetailPage() {
|
|||
<Line label="Ouverture de compte" value={structureInfos.ouverture_compte} />
|
||||
<Line label="Offre spéciale" value={structureInfos.offre_speciale} />
|
||||
<Line label="Note" value={structureInfos.notes} />
|
||||
|
||||
{/* Section Apporteur d'Affaires */}
|
||||
<div className="pt-2 border-t">
|
||||
<Line
|
||||
label="Client apporté ?"
|
||||
value={clientData.details.is_referred ? "Oui" : "Non"}
|
||||
/>
|
||||
{clientData.details.is_referred && (
|
||||
<>
|
||||
<Line
|
||||
label="Apporteur"
|
||||
value={referrers.find(r => r.code === clientData.details.referrer_code)?.name || clientData.details.referrer_code}
|
||||
/>
|
||||
<Line
|
||||
label="Taux de commission"
|
||||
value={clientData.details.commission_rate ? `${(clientData.details.commission_rate * 100).toFixed(2)}%` : "—"}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
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)
|
||||
const { data: salaryTransfers, error } = await sb
|
||||
.from("salary_transfers")
|
||||
.select("*")
|
||||
.select("*, organizations!org_id(name)")
|
||||
.order("period_month", { ascending: false })
|
||||
.limit(200);
|
||||
|
||||
|
|
|
|||
|
|
@ -178,6 +178,10 @@ export async function PUT(
|
|||
ouverture_compte,
|
||||
offre_speciale,
|
||||
notes,
|
||||
// Apporteur d'affaires
|
||||
is_referred,
|
||||
referrer_code,
|
||||
commission_rate,
|
||||
// Champs de détails de l'organisation
|
||||
code_employeur, // Remplace structure_api
|
||||
siret,
|
||||
|
|
@ -231,6 +235,10 @@ export async function PUT(
|
|||
if (ouverture_compte !== undefined) detailsUpdateData.ouverture_compte = ouverture_compte;
|
||||
if (offre_speciale !== undefined) detailsUpdateData.offre_speciale = offre_speciale;
|
||||
if (notes !== undefined) detailsUpdateData.notes = notes;
|
||||
// Apporteur d'affaires
|
||||
if (is_referred !== undefined) detailsUpdateData.is_referred = is_referred;
|
||||
if (referrer_code !== undefined) detailsUpdateData.referrer_code = referrer_code;
|
||||
if (commission_rate !== undefined) detailsUpdateData.commission_rate = commission_rate;
|
||||
// Autres champs
|
||||
if (code_employeur !== undefined) detailsUpdateData.code_employeur = code_employeur; // Nouveau champ
|
||||
if (siret !== undefined) detailsUpdateData.siret = siret;
|
||||
|
|
|
|||
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
|
||||
.from("salary_transfers")
|
||||
.insert(insertData)
|
||||
.select()
|
||||
.select("*, organizations!org_id(name)")
|
||||
.single();
|
||||
|
||||
if (insertError) {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { Shield, KeyRound, Home, FileSignature, Banknote, Users, Percent, FolderOpen, Receipt, Building2, UserCog, Edit3, LifeBuoy, CreditCard, Calculator, Euro, Mail, Database, Clapperboard, LogOut, Scale, FileEdit } from "lucide-react";
|
||||
import { Shield, KeyRound, Home, FileSignature, Banknote, Users, Percent, FolderOpen, Receipt, Building2, UserCog, Edit3, LifeBuoy, CreditCard, Calculator, Euro, Mail, Database, Clapperboard, LogOut, Scale, FileEdit, FileText } from "lucide-react";
|
||||
// import { api } from "@/lib/fetcher";
|
||||
import { createPortal } from "react-dom";
|
||||
import LogoutButton from "@/components/LogoutButton";
|
||||
|
|
@ -587,6 +587,22 @@ export default function Sidebar({ clientInfo, isStaff = false, mobile = false, o
|
|||
<span>Cotisations</span>
|
||||
</span>
|
||||
</Link>
|
||||
<Link href="/staff/naa" onClick={() => onNavigate && onNavigate()} className={`block px-3 py-2 rounded-xl text-sm transition truncate ${
|
||||
isActivePath(pathname, "/staff/naa") ? "bg-gradient-to-r from-indigo-200 via-purple-200 to-pink-200 text-slate-700 font-semibold" : "hover:bg-slate-50"
|
||||
}`} title="Notes Apporteurs d'Affaires">
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<FileText className="w-4 h-4" aria-hidden />
|
||||
<span>NAA</span>
|
||||
</span>
|
||||
</Link>
|
||||
<Link href="/staff/apporteurs" onClick={() => onNavigate && onNavigate()} className={`block px-3 py-2 rounded-xl text-sm transition truncate ${
|
||||
isActivePath(pathname, "/staff/apporteurs") ? "bg-gradient-to-r from-indigo-200 via-purple-200 to-pink-200 text-slate-700 font-semibold" : "hover:bg-slate-50"
|
||||
}`} title="Gestion des apporteurs">
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Building2 className="w-4 h-4" aria-hidden />
|
||||
<span>Apporteurs</span>
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Administration */}
|
||||
|
|
|
|||
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(
|
||||
"postgres_changes",
|
||||
{ event: "*", schema: "public", table: "salary_transfers" },
|
||||
(payload: any) => {
|
||||
async (payload: any) => {
|
||||
try {
|
||||
const event = payload.event || payload.eventType || payload.type;
|
||||
const record = payload.new ?? payload.record ?? payload.payload ?? payload;
|
||||
|
||||
if (event === "INSERT") {
|
||||
const newRec = record as SalaryTransfer;
|
||||
// Enrichir avec le nom de l'organisation
|
||||
let enrichedRecord = { ...record } as SalaryTransfer;
|
||||
|
||||
if (record.org_id && organizations.length > 0) {
|
||||
const org = organizations.find(o => o.id === record.org_id);
|
||||
if (org) {
|
||||
enrichedRecord.organizations = { name: org.name };
|
||||
}
|
||||
}
|
||||
|
||||
setRows((rs) => {
|
||||
if (rs.find((r) => r.id === newRec.id)) return rs;
|
||||
return [newRec, ...rs];
|
||||
if (rs.find((r) => r.id === enrichedRecord.id)) return rs;
|
||||
return [enrichedRecord, ...rs];
|
||||
});
|
||||
} else if (event === "UPDATE") {
|
||||
setRows((rs) => rs.map((r) => (r.id === record.id ? { ...r, ...(record as SalaryTransfer) } : r)));
|
||||
// Enrichir avec le nom de l'organisation si nécessaire
|
||||
let enrichedRecord = { ...record } as SalaryTransfer;
|
||||
|
||||
if (record.org_id && organizations.length > 0) {
|
||||
const org = organizations.find(o => o.id === record.org_id);
|
||||
if (org) {
|
||||
enrichedRecord.organizations = { name: org.name };
|
||||
}
|
||||
}
|
||||
|
||||
setRows((rs) => rs.map((r) => (r.id === record.id ? { ...r, ...enrichedRecord } : r)));
|
||||
} else if (event === "DELETE") {
|
||||
const id = record?.id ?? payload.old?.id;
|
||||
if (id) setRows((rs) => rs.filter((r) => r.id !== id));
|
||||
|
|
@ -210,7 +229,7 @@ export default function SalaryTransfersGrid({
|
|||
console.warn("Error unsubscribing realtime channel", err);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
}, [organizations]);
|
||||
|
||||
// Helper: fetch server-side with current filters
|
||||
async function fetchServer(pageIndex = 0) {
|
||||
|
|
@ -517,25 +536,47 @@ export default function SalaryTransfersGrid({
|
|||
async function handleNotifyClient() {
|
||||
if (!selectedTransfer || !selectedTransfer.id || !selectedTransfer.org_id) return;
|
||||
|
||||
// Charger les détails de l'organisation pour afficher les emails dans le modal
|
||||
// Charger les détails de l'organisation via l'API (pour éviter les problèmes RLS)
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from("organization_details")
|
||||
.select("email_notifs, email_notifs_cc")
|
||||
.eq("org_id", selectedTransfer.org_id)
|
||||
.single();
|
||||
console.log("[handleNotifyClient] Chargement des emails pour org_id:", selectedTransfer.org_id);
|
||||
|
||||
if (!error && data) {
|
||||
setOrganizationDetails(data);
|
||||
const response = await fetch(`/api/staff/organizations/${selectedTransfer.org_id}/emails`, {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Cache-Control': 'no-cache',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error("[handleNotifyClient] API Error:", response.status, errorText);
|
||||
throw new Error(`Erreur ${response.status}: ${errorText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
console.log("[handleNotifyClient] Emails récupérés:", data);
|
||||
|
||||
if (data.email_notifs || data.email_notifs_cc) {
|
||||
setOrganizationDetails({
|
||||
email_notifs: data.email_notifs,
|
||||
email_notifs_cc: data.email_notifs_cc
|
||||
});
|
||||
} else {
|
||||
console.warn("[handleNotifyClient] Aucun email configuré pour org_id:", selectedTransfer.org_id);
|
||||
setOrganizationDetails(null);
|
||||
}
|
||||
|
||||
// Ouvrir le modal après avoir chargé les données
|
||||
setShowNotifyClientModal(true);
|
||||
} catch (err) {
|
||||
console.error("Error loading organization details:", err);
|
||||
console.error("Error loading organization emails:", err);
|
||||
setOrganizationDetails(null);
|
||||
// Ouvrir quand même le modal pour afficher l'erreur
|
||||
setShowNotifyClientModal(true);
|
||||
}
|
||||
|
||||
setShowNotifyClientModal(true);
|
||||
}
|
||||
|
||||
async function confirmNotifyClient() {
|
||||
|
|
@ -1853,7 +1894,11 @@ export default function SalaryTransfersGrid({
|
|||
: undefined
|
||||
}
|
||||
clientEmail={organizationDetails?.email_notifs || undefined}
|
||||
ccEmails={organizationDetails?.email_notifs_cc ? [organizationDetails.email_notifs_cc] : []}
|
||||
ccEmails={
|
||||
organizationDetails?.email_notifs_cc
|
||||
? [organizationDetails.email_notifs_cc]
|
||||
: []
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -59,6 +59,14 @@ export default function NotifyClientModal({
|
|||
}: NotifyClientModalProps) {
|
||||
if (!isOpen) return null;
|
||||
|
||||
// Debug logs
|
||||
console.log("[NotifyClientModal] Props reçues:", {
|
||||
clientEmail,
|
||||
ccEmails,
|
||||
organizationName,
|
||||
transferId: transfer.id
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-2xl w-full max-w-2xl max-h-[90vh] overflow-y-auto shadow-2xl">
|
||||
|
|
|
|||
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