Merge pull request 'Feat/billing history' (#3) from feat/billing_history into master
Deploy Nexus Portal to HestiaCP (FTP) / build-and-deploy (push) Successful in 14s

Reviewed-on: maximus/portail-gise#3
This commit was merged in pull request #3.
This commit is contained in:
maximus
2026-07-17 14:33:31 +02:00
6 changed files with 348 additions and 11 deletions
+2
View File
@@ -20,6 +20,7 @@ import Store from './pages/app/Store';
import Checkout from './pages/app/Checkout';
import Services from './pages/app/Services';
import Support from './pages/app/Support';
import BillingHistory from './pages/app/BillingHistory';
export default function App() {
return (
@@ -49,6 +50,7 @@ export default function App() {
<Route path="/checkout/:productId" element={<Checkout />} />
<Route path="/services" element={<Services />} />
<Route path="/support" element={<Support />} />
<Route path="/facturation" element={<BillingHistory />} />
</Route>
</Route>
@@ -246,8 +246,9 @@ export default function WebServiceSubscriptionManager({ order, onClose, onRefres
// 🛡️ NOUVELLE LOGIQUE DE RÉSILIATION (US 2.1 / 2.2)
// ==========================================
const calculateRefundEligibility = () => {
if (!order || !order.created_at) return false;
const orderDate = new Date(order.created_at);
if (!order || !order.activated_at) return false;
const orderDate = new Date(order.activated_at);
console.log("Order Date:", orderDate);
const now = new Date();
const diffDays = Math.ceil(Math.abs(now - orderDate) / (1000 * 60 * 60 * 24));
return diffDays <= 14;
@@ -508,7 +509,7 @@ export default function WebServiceSubscriptionManager({ order, onClose, onRefres
<p className="text-emerald-500/80 mt-2">
Votre abonnement a été activé il y a moins de 14 jours.
<strong className="text-emerald-400"> Vous allez être intégralement remboursé</strong>.
Vos services HestiaCP et bases de données seront supprimés immédiatement.
Vos services seront supprimés immédiatement.
</p>
</div>
) : (
+1 -4
View File
@@ -74,10 +74,7 @@ export default function AppLayout() {
</div>
</NavLink>
<div className="flex justify-between items-center px-6 py-4 text-[#444] border-l-4 border-transparent uppercase tracking-widest text-sm font-mono cursor-not-allowed select-none">
<span>Facturation</span>
<span className="text-[9px] text-[#333] border border-[#333] px-1.5 py-0.5 rounded font-bold">WIP</span>
</div>
<NavLink className={navLinkClass} to="/facturation">Facturation</NavLink>
<div className="flex justify-between items-center px-6 py-4 text-[#444] border-l-4 border-transparent uppercase tracking-widest text-sm font-mono cursor-not-allowed select-none">
<span>Profil & Sécurité</span>
<span className="text-[9px] text-[#333] border border-[#333] px-1.5 py-0.5 rounded font-bold">WIP</span>
+336
View File
@@ -0,0 +1,336 @@
import { useState, useEffect } from 'react';
import { FileText, Download, CreditCard, Clock, CheckCircle2, XCircle, AlertCircle, Loader, X, Receipt, Trash2, ArrowRight, RefreshCcw } from 'lucide-react';
import NotificationModal from '../../components/ui/NotificationModal'; // Vérifie ton chemin
import { getClient, getInvoiceList } from '../../services/billing_api'; // Vérifie ton chemin
export default function BillingHistory() {
const [invoices, setInvoices] = useState([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
const [customAlert, setCustomAlert] = useState(null);
// NOUVEL ÉTAT : La facture actuellement ouverte dans la modale
const [selectedInvoice, setSelectedInvoice] = useState(null);
const triggerAlert = (title, message, type) => {
setCustomAlert({ title, message, type });
};
useEffect(() => {
const fetchBillingData = async () => {
setIsLoading(true);
setError(null);
try {
const profileData = await getClient();
if (!profileData || !profileData.id) {
throw new Error("Impossible de vérifier votre identité ou session expirée.");
}
const invoicesData = await getInvoiceList(profileData.id);
setInvoices(invoicesData.list || []);
} catch (err) {
setError(err.message || "Problème de connexion avec le serveur.");
} finally {
setIsLoading(false);
}
};
fetchBillingData();
}, []);
const getStatusBadge = (status, total = 0) => {
const isNegative = parseFloat(total) < 0;
switch (status?.toLowerCase()) {
case 'paid':
if (isNegative) {
return { label: 'REMBOURSÉE', css: 'bg-blue-500/10 text-blue-400 border-blue-500/20', icon: <RefreshCcw className="w-3.5 h-3.5" /> };
}
return { label: 'PAYÉE', css: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20', icon: <CheckCircle2 className="w-3.5 h-3.5" /> };
case 'refunded':
// NOUVEAU STATUT : REMBOURSÉE
case 'unpaid':
// SI IMPAYÉE MAIS NÉGATIVE = EN COURS DE TRAITEMENT
if (isNegative) {
return { label: 'REMBOURSEMENT EN COURS', css: 'bg-amber-500/10 text-amber-400 border-amber-500/20', icon: <Clock className="w-3.5 h-3.5" /> };
}
return { label: 'EN ATTENTE', css: 'bg-amber-500/10 text-amber-400 border-amber-500/20', icon: <Clock className="w-3.5 h-3.5" /> };
case 'canceled':
return { label: 'ANNULÉE', css: 'bg-gray-500/10 text-gray-400 border-gray-500/20', icon: <XCircle className="w-3.5 h-3.5" /> };
default:
return { label: status?.toUpperCase() || 'INCONNU', css: 'bg-blue-500/10 text-blue-400 border-blue-500/20', icon: <AlertCircle className="w-3.5 h-3.5" /> };
}
};
const formatDate = (dateString) => {
if (!dateString) return '--/--/----';
return new Date(dateString).toLocaleDateString('fr-FR', { year: 'numeric', month: 'long', day: 'numeric' });
};
// Fonctions vides pour la future logique
const handlePayInvoice = (invoiceId) => {
triggerAlert("Paiement initié", `Logique de paiement Stripe/PayPal à venir pour la facture #${invoiceId}.`, "info");
};
const handleCancelInvoice = (invoiceId) => {
triggerAlert("Annulation", `Logique d'annulation à venir pour la facture #${invoiceId}.`, "info");
};
return (
<div className="w-full max-w-6xl p-6 mx-auto relative">
<header className="mb-8">
<h1 className="text-3xl font-black text-white tracking-wider">HISTORIQUE DE <span className="text-cyan-400">FACTURATION</span></h1>
<p className="text-gray-400 mt-2">Consultez vos reçus, payez vos transactions en attente et gérez vos factures.</p>
</header>
{isLoading && (
<div className="flex items-center space-x-3 text-cyan-400 mb-6">
<Loader className="w-6 h-6 animate-spin" />
<span>Synchronisation avec le registre comptable...</span>
</div>
)}
{error && (
<div className="flex items-center space-x-3 text-red-400 bg-red-400/10 border border-red-400 p-4 rounded-lg mb-6">
<AlertCircle className="w-6 h-6" />
<span>{error}</span>
</div>
)}
{!isLoading && !error && (
<div className="bg-[#090f1c] border border-gray-800 rounded-lg overflow-hidden shadow-xl">
{invoices.length === 0 ? (
<div className="text-center py-12 px-4">
<FileText className="w-10 h-10 mx-auto text-gray-600 mb-3" />
<p className="text-base font-bold text-gray-400 tracking-wider uppercase">Aucune facture</p>
<p className="text-sm text-gray-600 mt-1">Vos transactions apparaîtront ici dès qu'elles seront générées.</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-left border-collapse">
<thead>
<tr className="bg-gray-900 border-b border-gray-800 text-gray-500 text-[10px] uppercase font-black tracking-widest">
<th className="py-4 px-6">Référence</th>
<th className="py-4 px-6">Date d'émission</th>
<th className="py-4 px-6">Statut</th>
<th className="py-4 px-6 text-right">Montant</th>
<th className="py-4 px-6 text-center">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-800/40 text-sm">
{invoices.map((invoice) => {
const badge = getStatusBadge(invoice.status, invoice.total);
return (
<tr key={invoice.id} className="hover:bg-gray-800/20 transition-colors">
<td className="py-5 px-6 font-mono text-xs font-bold text-gray-300">
{invoice.invoice_number}
</td>
<td className="py-5 px-6 text-gray-400 text-xs">
{formatDate(invoice.created_at)}
</td>
<td className="py-5 px-6">
<span className={`inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-[10px] font-black tracking-widest border ${badge.css}`}>
{badge.icon}
{badge.label}
</span>
</td>
<td className="py-5 px-6 text-right font-mono font-bold text-white tracking-wider">
{parseFloat(invoice.total).toFixed(2)} {invoice.currency || '€'}
</td>
<td className="py-5 px-6">
<div className="flex items-center justify-center gap-3">
{invoice.status?.toLowerCase() === 'unpaid' && parseFloat(invoice.total) >= 0 ? (
// 🎯 Facture positive et impayée : BOUTON PAYER
<button
onClick={() => setSelectedInvoice(invoice)}
className="flex items-center gap-1.5 px-4 py-2 bg-amber-500 hover:bg-amber-400 text-black text-[10px] font-black uppercase tracking-widest rounded transition-all shadow-[0_0_15px_rgba(245,158,11,0.2)]"
>
<CreditCard className="w-3.5 h-3.5" /> PAYER
</button>
) : (
// 🎯 Facture payée, annulée, ou NÉGATIVE (Remboursement) : BOUTON DÉTAILS UNIQUEMENT
<button
onClick={() => setSelectedInvoice(invoice)}
className="flex items-center gap-1.5 px-4 py-2 bg-gray-900 hover:bg-gray-800 text-gray-300 hover:text-cyan-400 border border-gray-700 hover:border-cyan-900 text-[10px] font-black uppercase tracking-widest rounded transition-all"
>
<Receipt className="w-3.5 h-3.5" /> DÉTAILS
</button>
)}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
)}
{/* ========================================== */}
{/* LA MODALE DE DÉTAILS DE FACTURE */}
{selectedInvoice && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm">
<div className="bg-[#090f1c] border border-gray-800 shadow-2xl shadow-cyan-500/10 rounded-2xl w-full max-w-2xl flex flex-col max-h-[90vh] overflow-hidden">
{/* HEADER MODALE */}
<div className="flex items-center justify-between p-6 border-b border-gray-800 shrink-0">
<div className="flex items-center gap-4">
<div className="p-3 bg-cyan-950/30 text-cyan-400 rounded-xl border border-cyan-900/50">
<Receipt className="w-6 h-6" />
</div>
<div>
<h3 className="text-xl font-black text-white tracking-widest uppercase">
FACTURE <span className="text-cyan-400">{selectedInvoice.invoice_number}</span>
</h3>
<p className="text-xs text-gray-500 font-medium mt-1">
Émise le {formatDate(selectedInvoice.created_at)}
</p>
</div>
</div>
<button onClick={() => setSelectedInvoice(null)} className="p-2 text-gray-500 hover:text-white bg-gray-900 hover:bg-gray-800 rounded-lg transition-colors">
<X className="w-5 h-5" />
</button>
</div>
{/* CORPS DE LA MODALE */}
<div className="p-6 overflow-y-auto grow custom-scrollbar space-y-6">
{/* Statut Badge */}
<div className="flex items-center justify-between p-4 bg-[#0d1527] rounded-xl border border-gray-800">
<span className="text-xs font-bold text-gray-400 uppercase tracking-wider">Statut du document</span>
<span className={`inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-black tracking-widest border ${getStatusBadge(selectedInvoice.status).css}`}>
{getStatusBadge(selectedInvoice.status).icon}
{getStatusBadge(selectedInvoice.status).label}
</span>
</div>
{/* Lignes de facture (Filtrées : uniquement les prix > 0) */}
<div>
<h4 className="text-xs font-bold text-gray-500 uppercase tracking-widest mb-3 border-b border-gray-800 pb-2">Détail des prestations</h4>
<div className="space-y-4">
{(() => {
// FILTRE : On ne garde que les items dont le prix est strictement supérieur à 0
const validItems = selectedInvoice.lines
if (validItems.length > 0) {
return validItems.map((item, idx) => (
<div key={idx} className="flex justify-between items-start text-sm">
<div className="pr-4">
<p className="text-white font-medium">{item.title}</p>
<p className="text-xs text-gray-500 mt-0.5">Quantité : {item.quantity}</p>
</div>
<div className="text-right font-mono font-bold text-gray-300 shrink-0">
{(parseFloat(item.price) * parseInt(item.quantity)).toFixed(2)} {selectedInvoice.currency || '€'}
</div>
</div>
));
} else {
// Fallback si la facture n'a que des items à 0€ ou aucun détail
return (
<div className="flex justify-between items-start text-sm">
<div>
<p className="text-white font-medium">Services d'infrastructure technique</p>
<p className="text-xs text-gray-500 mt-0.5">Détail inclus ou facturation sans frais complémentaires.</p>
</div>
<div className="text-right font-mono font-bold text-gray-300 shrink-0">
{parseFloat(selectedInvoice.total).toFixed(2)} {selectedInvoice.currency || ''}
</div>
</div>
);
}
})()}
</div>
</div>
{/* CALCULS (EXTRACTION TVA DEPUIS LE TTC) */}
{(() => {
// Le total récupéré de la BDD est considéré comme le PRIX FINAL (TTC)
const totalTTC = parseFloat(selectedInvoice.total) || 0;
const vatRate = 0.21; // TVA Belge à 21%
// Extraction du Hors Taxe : TTC / 1.21
const subtotalHT = totalTTC / (1 + vatRate);
// Le montant exact de la taxe
const vatAmount = totalTTC - subtotalHT;
return (
<div className="flex flex-col items-end pt-5 border-t border-gray-800">
<div className="flex justify-between w-full sm:w-[60%] text-sm mb-2">
<span className="text-gray-500">Sous-total (HT)</span>
<span className="text-gray-400 font-mono">{subtotalHT.toFixed(2)} {selectedInvoice.currency || ''}</span>
</div>
<div className="flex justify-between w-full sm:w-[60%] text-sm mb-4">
<span className="text-gray-500">TVA (21%)</span>
<span className="text-gray-400 font-mono">{vatAmount.toFixed(2)} {selectedInvoice.currency || ''}</span>
</div>
<div className="flex justify-between w-full sm:w-[60%] text-lg border-t border-gray-800 pt-3">
<span className="font-bold text-white uppercase tracking-wider">Total TTC</span>
<span className="font-black text-cyan-400 font-mono text-xl">{totalTTC.toFixed(2)} {selectedInvoice.currency || ''}</span>
</div>
</div>
);
})()}
</div>
{/* PIED DE LA MODALE (Actions) */}
<div className="p-6 border-t border-gray-800 bg-gray-900/50 shrink-0 flex flex-col sm:flex-row items-center justify-between gap-4">
{selectedInvoice.status?.toLowerCase() === 'unpaid' && parseFloat(selectedInvoice.total) >= 0 ? (
<>
{/* Cas standard : Facture positive en attente de paiement */}
<button
onClick={() => handleCancelInvoice(selectedInvoice.id)}
className="w-full sm:w-auto px-5 py-3 text-red-400 hover:text-white bg-red-500/10 hover:bg-red-500 border border-red-500/20 hover:border-red-500 text-xs font-bold uppercase tracking-wider rounded-xl transition-all flex items-center justify-center gap-2"
>
<Trash2 className="w-4 h-4" /> Annuler la facture
</button>
<button
onClick={() => handlePayInvoice(selectedInvoice.id)}
className="w-full sm:w-auto px-8 py-3 bg-amber-500 hover:bg-amber-400 text-black text-xs font-black uppercase tracking-widest rounded-xl transition-all shadow-[0_0_20px_rgba(245,158,11,0.2)] flex items-center justify-center gap-2"
>
<CreditCard className="w-4 h-4" /> Procéder au paiement <ArrowRight className="w-4 h-4" />
</button>
</>
) : (
<>
{/* Cas alternatif : Facture payée, annulée, ou NÉGATIVE (Remboursement) */}
<button onClick={() => setSelectedInvoice(null)} className="w-full sm:w-auto px-5 py-3 text-gray-400 hover:text-white bg-gray-900 hover:bg-gray-800 border border-gray-800 text-xs font-bold uppercase tracking-wider rounded-xl transition-all">
Fermer
</button>
{parseFloat(selectedInvoice.total) < 0 && selectedInvoice.status?.toLowerCase() === 'unpaid' && (
<div className="text-xs font-bold text-amber-400 bg-amber-500/5 border border-amber-500/10 px-4 py-2.5 rounded-xl uppercase tracking-wider animate-pulse">
⚡ Flux financier en cours de traitement
</div>
)}
{selectedInvoice.status?.toLowerCase() === 'refunded' && (
<div className="text-xs font-bold text-blue-400 bg-blue-500/5 border border-blue-500/10 px-4 py-2.5 rounded-xl uppercase tracking-wider">
✓ Fonds reversés au client
</div>
)}
{selectedInvoice.status?.toLowerCase() === 'paid' && (
<a
href={`/invoice/pdf/${selectedInvoice.id}`}
target="_blank" rel="noopener noreferrer"
className="w-full sm:w-auto px-8 py-3 bg-cyan-500 hover:bg-cyan-400 text-black text-xs font-black uppercase tracking-widest rounded-xl transition-all shadow-[0_0_20px_rgba(6,182,212,0.2)] flex items-center justify-center gap-2"
>
<Download className="w-4 h-4" /> Télécharger le reçu
</a>
)}
</>
)}
</div>
</div>
</div>
)}
<NotificationModal notification={customAlert} onClose={() => setCustomAlert(null)} />
</div>
);
}
+1
View File
@@ -70,6 +70,7 @@ export default function Dashboard() {
// 🌟 Plus besoin de charger les IPs, on ouvre juste la modale comptable !
const handleManageSubscription = (order) => {
console.log("Gestion de l'abonnement pour la commande :", order);
// 1. Détection du type de service
const titleLower = (order.title || '').toLowerCase();
const isVPS = titleLower.includes('vps') || titleLower.includes('compute');
+4 -4
View File
@@ -305,12 +305,12 @@ export const updateHostingPlan = (order_id, new_plan_id) =>
// Suppression
// Modification
// Lister
export const getInvoiceList = () =>
apiCall(`${BASE_URL}/api/client/invoice/get_list`);
export const getInvoiceList = (client_id) =>
apiCall(`${BASE_URL}/api/client/invoice/get_list`, { client_id: client_id });
// Lire
export const getInvoiceDetails = (InvoiceHash) =>
apiCall(`${BASE_URL}/api/client/invoice/get`);
export const getInvoiceDetails = (invoiceHash) =>
apiCall(`${BASE_URL}/api/client/invoice/get`, { hash: invoiceHash });
// ==========================================