diff --git a/src/App.jsx b/src/App.jsx
index c982aca..2ed5c47 100644
--- a/src/App.jsx
+++ b/src/App.jsx
@@ -20,6 +20,8 @@ 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 Payment from './pages/app/Payment';
+import OrderManagement from './pages/app/OrderManagement';
export default function App() {
return (
@@ -49,6 +51,8 @@ export default function App() {
} />
} />
} />
+ } />
+ } />
diff --git a/src/components/dashboard/DashboardServiceCard.jsx b/src/components/dashboard/DashboardServiceCard.jsx
index 6435788..2a06324 100644
--- a/src/components/dashboard/DashboardServiceCard.jsx
+++ b/src/components/dashboard/DashboardServiceCard.jsx
@@ -1,6 +1,6 @@
-import { Server, Database, Cloud, Globe } from 'lucide-react';
+import { Server, Database, Cloud, Globe, Loader } from 'lucide-react';
-export default function DashboardServiceCard({ order, onClick }) {
+export default function DashboardServiceCard({ order, onClick, isActionLoading }) {
// Fonction Radar : Détecte le type de service selon son nom pour afficher la bonne icône
const getServiceIcon = (title) => {
const t = title.toLowerCase();
@@ -35,6 +35,13 @@ export default function DashboardServiceCard({ order, onClick }) {
return periods[periodCode] || periodCode;
};
+ const baseTitle = (order.title || '').split(/(?: for | pour )/i)[0].trim();
+ const shortTitle = baseTitle.split(' ').slice(0, 2).join(' ');
+ const titleMatch = (order.title || '').match(/(?: for | pour )(.+)$/i);
+ const domainFromTitle = titleMatch ? titleMatch[1].trim() : null;
+ let displayDomain = order.hostingDetails?.domain && order.hostingDetails.domain !== '127.0.0.1' ? order.hostingDetails.domain : domainFromTitle || order.domain;
+ if (!displayDomain || displayDomain === '127.0.0.1') displayDomain = null;
+
return (
{getStatusBadge(order.status)}
-
- {order.title}
+
+ {shortTitle}
+
+ {displayDomain ? (
{displayDomain}
) : (
En attente de déploiement
)}
+
Facturation : {formatPeriod(order.period)}
- ID Réseau: #{order.id}
- Gérer >
+
);
diff --git a/src/pages/app/Checkout.jsx b/src/pages/app/Checkout.jsx
index 861a432..ae0bed7 100644
--- a/src/pages/app/Checkout.jsx
+++ b/src/pages/app/Checkout.jsx
@@ -111,11 +111,12 @@ export default function Checkout() {
};
await addToCart(productId, period, productConfig);
- await checkoutCart();
+ // FOSSBilling retourne le hash sécurisé après le checkout
+ const checkoutResult = await checkoutCart();
+ const invoiceHash = checkoutResult?.hash || checkoutResult?.invoice_hash || checkoutResult?.id;
- navigate('/dashboard', {
- state: { successMessage: `Instance initialisée sur ${serverName}.gise.be` }
- });
+ // On redirige vers /payment/LE_HASH
+ navigate(`/payment/${invoiceHash}`);
} catch (err) {
setError(err.message || "La transaction a échoué. L'API a refusé le contrat.");
@@ -185,7 +186,7 @@ export default function Checkout() {
disabled={isProcessing}
className="w-2/3 bg-cyan-400 hover:bg-cyan-500 text-gray-900 py-3 rounded-lg font-black tracking-widest transition-all disabled:opacity-50 disabled:cursor-not-allowed shadow-[0_0_20px_rgba(34,211,238,0.15)] font-mono"
>
- {isProcessing ? 'PROVISIONNEMENT...' : 'INITIALISER L\'INSTANCE'}
+ {isProcessing ? 'GÉNÉRATION...' : 'GÉNÉRER LA FACTURE'}
diff --git a/src/pages/app/Dashboard.jsx b/src/pages/app/Dashboard.jsx
index b78f41b..170308a 100644
--- a/src/pages/app/Dashboard.jsx
+++ b/src/pages/app/Dashboard.jsx
@@ -1,7 +1,7 @@
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
-import { getClientOrders } from '../../services/api';
import { AlertCircle, Loader } from 'lucide-react';
+import { getClientOrders, getClientInvoices } from '../../services/api';
// Importation des composants isolés
import DashboardServiceCard from '../../components/dashboard/DashboardServiceCard';
@@ -11,6 +11,7 @@ export default function Dashboard() {
const navigate = useNavigate();
const [orders, setOrders] = useState([]);
const [isLoading, setIsLoading] = useState(true);
+ const [loadingActionId, setLoadingActionId] = useState(null);
const [error, setError] = useState(null);
// Chargement des données à l'ouverture du Sas
@@ -48,6 +49,51 @@ export default function Dashboard() {
fetchInventory();
}, []);
+ const handleManageOrder = async (order) => {
+ // Sécurité pour éviter les doubles clics rapides
+ if (loadingActionId) return;
+ setLoadingActionId(order.id);
+
+ try {
+ let needsPayment = false;
+ let targetHash = null;
+
+ // 1. On vérifie la présence d'une facture liée
+ if (order.unpaid_invoice_id) {
+ const invoicesData = await getClientInvoices();
+ const invoicesList = Array.isArray(invoicesData) ? invoicesData : (invoicesData?.list || invoicesData?.result || []);
+ const targetInvoice = invoicesList.find(inv => inv.id == order.unpaid_invoice_id);
+
+ // 🌟 LE CORRECTIF EST ICI 🌟
+ // On vérifie que la facture est RÉELLEMENT impayée ('unpaid')
+ if (targetInvoice && targetInvoice.status === 'unpaid') {
+ needsPayment = true;
+ targetHash = targetInvoice.hash;
+ }
+ }
+
+ // 2. ROUTAGE DÉFINITIF
+ if (needsPayment && targetHash) {
+ // A. La facture est vraiment impayée -> Page de Paiement
+ navigate(`/payment/${targetHash}`);
+ }
+ else if (order.status === 'active') {
+ // B. Payé et Actif -> Page de Gestion du Serveur
+ navigate(`/manage/${order.id}`);
+ }
+ else {
+ // C. Payé mais en cours d'installation (Pending)
+ alert(`[ LOGISTIQUE NÉXUS ]\n\nInstance en cours de configuration sur le réseau.\nStatut actuel : ${order.status.toUpperCase()}`);
+ }
+
+ } catch (err) {
+ console.error(err);
+ alert("Erreur de communication avec la base de données de facturation.");
+ } finally {
+ setLoadingActionId(null);
+ }
+ };
+
return (
@@ -80,7 +126,8 @@ export default function Dashboard() {
navigate(`/services/${order.id}`)}
+ onClick={() => handleManageOrder(order)}
+ isActionLoading={loadingActionId === order.id}
/>
))}
diff --git a/src/pages/app/OrderManagement.jsx b/src/pages/app/OrderManagement.jsx
new file mode 100644
index 0000000..3e556f0
--- /dev/null
+++ b/src/pages/app/OrderManagement.jsx
@@ -0,0 +1,161 @@
+import { useState, useEffect } from 'react';
+import { useParams, useNavigate } from 'react-router-dom';
+import { getOrderDetails, getOrderUpgradables, createTicket, getHelpdesks } from '../../services/api';
+import { Server, ArrowUpRight, ArrowDownRight, Trash2, ShieldCheck, Loader, AlertCircle } from 'lucide-react';
+import NotificationModal from '../../components/ui/NotificationModal';
+
+export default function OrderManagement() {
+ const { orderId } = useParams();
+ const navigate = useNavigate();
+
+ const [order, setOrder] = useState(null);
+ const [upgradables, setUpgradables] = useState({});
+ const [isLoading, setIsLoading] = useState(true);
+ const [isProcessing, setIsProcessing] = useState(false);
+ const [customAlert, setCustomAlert] = useState(null);
+
+ useEffect(() => {
+ const fetchOrderData = async () => {
+ try {
+ const orderData = await getOrderDetails(orderId);
+ setOrder(orderData);
+
+ // Si la commande est active, on cherche les upgrades possibles
+ if (orderData.status === 'active') {
+ const upgradeData = await getOrderUpgradables(orderId);
+ if (upgradeData) setUpgradables(upgradeData);
+ }
+ } catch (err) {
+ setCustomAlert({ type: 'error', title: 'Erreur', message: "Impossible de récupérer les données du contrat." });
+ } finally {
+ setIsLoading(false);
+ }
+ };
+ fetchOrderData();
+ }, [orderId]);
+
+ const handleUpgrade = async (targetProductId, targetProductName) => {
+ setIsProcessing(true);
+ try {
+ // 1. On récupère le département de support réseau (ou le premier disponible)
+ const hdesks = await getHelpdesks();
+ const targetHelpdeskId = Object.keys(hdesks)[1] || 1;
+
+ // 2. On formate une requête automatique claire pour l'administrateur
+ const subject = `[AUTO] Migration d'instance - ${order.title}`;
+ const message = `SYSTÈME NEXUS : Demande de migration automatisée.\n\nLe client demande le passage de l'instance #${order.id} (${order.title}) vers le forfait ID : ${targetProductId} (${targetProductName}).\n\nMerci de générer la facture de prorata depuis l'interface d'administration.`;
+
+ // 3. On génère le ticket silencieusement
+ await createTicket(subject, message, targetHelpdeskId);
+
+ setCustomAlert({
+ type: 'success',
+ title: 'Requête Transmise',
+ message: `La demande de migration vers ${targetProductName} a été envoyée aux ingénieurs GISE.\n\nUne facture de prorata sera générée dans votre espace d'ici quelques minutes. Vous serez notifié par e-mail.`
+ });
+
+ setTimeout(() => navigate('/support'), 4500);
+ } catch (err) {
+ setCustomAlert({ type: 'error', title: 'Échec de la transmission', message: err.message });
+ setIsProcessing(false);
+ }
+ };
+
+ const handleCancel = async () => {
+ if (!window.confirm("Alerte : Êtes-vous sûr de vouloir renoncer à cet abonnement ? Le service restera actif jusqu'à la fin de la période facturée.")) return;
+
+ setIsProcessing(true);
+ try {
+ const hdesks = await getHelpdesks();
+ const targetHelpdeskId = Object.keys(hdesks)[1] || 1;
+
+ const subject = `[AUTO] Demande de résiliation - ${order.title}`;
+ const message = `SYSTÈME NEXUS : Demande de résiliation automatisée.\n\nLe client demande la non-reconduction du contrat #${order.id} (${order.title}).\n\nL'instance doit être détruite à la fin du cycle de facturation actuel.`;
+
+ await createTicket(subject, message, targetHelpdeskId);
+
+ setCustomAlert({
+ type: 'success',
+ title: 'Résiliation Programmée',
+ message: "L'ordre d'arrêt a été transmis. L'abonnement ne sera pas renouvelé à la fin du cycle et l'instance sera détruite."
+ });
+
+ setTimeout(() => navigate('/support'), 4500);
+ } catch (err) {
+ setCustomAlert({ type: 'error', title: 'Échec', message: err.message });
+ setIsProcessing(false);
+ }
+ };
+
+ if (isLoading) {
+ return ANALYSE DU CONTRAT...
;
+ }
+
+ if (!order) return null;
+
+ // FOSSBilling renvoie souvent les upgradables sous forme de { "id_produit": "Nom du produit" }
+ const upgradeOptions = Object.entries(upgradables);
+
+ return (
+
+
+
+
+
Gestion du contrat : {order.title}
+
Contrat réseau #{order.id} — Renouvellement : {order.period}
+
+
+
+ {/* SECTIONS DES UPGRADES / DOWNGRADES */}
+
Moduler l'infrastructure
+
+ {upgradeOptions.length === 0 ? (
+
+ Aucune modification de forfait n'est disponible pour cette instance actuellement.
+
+ ) : (
+
+ {upgradeOptions.map(([targetId, targetName]) => (
+
+
{targetName}
+
+
+ ))}
+
+ * Le système calculera automatiquement le prorata. Une facture de différence ou une note de crédit sera générée selon votre choix.
+
+
+ )}
+
+ {/* ZONE DANGER : RÉSILIATION */}
+
+
+ Zone Critique
+
+
+
+
Renoncer à l'abonnement
+
+ L'instance restera opérationnelle jusqu'à la fin de la période facturée. Elle sera ensuite détruite et les données effacées.
+
+
+
+
+
+
+
setCustomAlert(null)} />
+
+ );
+}
\ No newline at end of file
diff --git a/src/pages/app/Payment.jsx b/src/pages/app/Payment.jsx
new file mode 100644
index 0000000..ed8cc74
--- /dev/null
+++ b/src/pages/app/Payment.jsx
@@ -0,0 +1,274 @@
+import { useState, useEffect } from 'react';
+import { useParams, useNavigate } from 'react-router-dom';
+import { getInvoiceDetails, getPaymentGateways, deleteOrder } from '../../services/api';
+import { CreditCard, Smartphone, Wallet, ShieldCheck, Loader, ArrowRight, AlertCircle, Building, Check, Copy, Trash2 } from 'lucide-react';
+
+export default function Payment() {
+ const { invoiceId } = useParams();
+ const navigate = useNavigate();
+
+ const [invoice, setInvoice] = useState(null);
+ const [gateways, setGateways] = useState([]);
+ const [isLoading, setIsLoading] = useState(true);
+ const [isProcessing, setIsProcessing] = useState(false);
+ const [error, setError] = useState(null);
+ const [selectedMethod, setSelectedMethod] = useState(null);
+
+ // 🌟 Nouvel état pour l'affichage natif des virements
+ const [showBankInstructions, setShowBankInstructions] = useState(false);
+ const [copied, setCopied] = useState(false);
+
+ useEffect(() => {
+ const fetchPaymentData = async () => {
+ try {
+ const invData = await getInvoiceDetails(invoiceId);
+ setInvoice(invData);
+
+ if (invData.status === 'paid') {
+ navigate('/dashboard');
+ return;
+ }
+
+ const gwData = await getPaymentGateways();
+ if (Array.isArray(gwData)) setGateways(gwData);
+ else if (gwData && Array.isArray(gwData.result)) setGateways(gwData.result);
+ else if (gwData && gwData.list) setGateways(gwData.list);
+
+ } catch (err) {
+ setError("Impossible de récupérer la facture sécurisée.");
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ if (invoiceId) fetchPaymentData();
+ }, [invoiceId, navigate]);
+
+ const getGatewayStyle = (gatewayName) => {
+ const name = (gatewayName || '').toLowerCase();
+ if (name.includes('paypal')) return { icon: , color: 'hover:border-blue-500 hover:bg-blue-500/10 text-gray-400' };
+ if (name.includes('bancontact') || name.includes('mollie')) return { icon: , color: 'hover:border-orange-500 hover:bg-orange-500/10 text-gray-400' };
+ if (name.includes('virement') || name.includes('bank') || name.includes('transfer') || name.includes('custom')) return { icon: , color: 'hover:border-purple-500 hover:bg-purple-500/10 text-gray-400' };
+ return { icon: , color: 'hover:border-cyan-500 hover:bg-cyan-500/10 text-gray-400' };
+ };
+
+ const handlePayment = () => {
+ if (!selectedMethod || !invoice) return;
+ setIsProcessing(true);
+
+ // On cherche le moyen de paiement sélectionné dans la liste
+ const selectedGw = gateways.find(g => g.id === selectedMethod);
+
+ // 🌟 L'INTERCEPTEUR : Si c'est le module Custom (Virement), on affiche notre interface native
+ if (selectedGw && selectedGw.code === 'Custom') {
+ setTimeout(() => {
+ setIsProcessing(false);
+ setShowBankInstructions(true); // Bascule l'affichage
+ }, 600); // Petit délai de simulation pour l'UX
+ return;
+ }
+
+ // Sinon (Stripe, PayPal, Mollie...), on redirige vers l'URL bancaire générée par FOSSBilling
+ const billingBaseUrl = import.meta.env.VITE_API_BASE_URL || 'https://web.gise.be';
+ window.location.href = `${billingBaseUrl}/invoice/banklink/${invoice.hash}/${selectedMethod}`;
+ };
+
+ const handleCopyCommunication = () => {
+ navigator.clipboard.writeText(`${invoice.serie}${invoice.nr}`);
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ };
+
+ const handleCancelOrder = async () => {
+ // La facture contient les lignes de commande. On récupère l'ID de la commande liée.
+ const orderId = invoice.lines && invoice.lines.length > 0 ? invoice.lines[0].order_id : null;
+
+ if (!orderId) {
+ alert("Impossible de lier cette facture à une commande.");
+ return;
+ }
+
+ if (!window.confirm("Ceci annulera définitivement cette commande et cette facture. Confirmer ?")) return;
+
+ setIsProcessing(true);
+ try {
+ await deleteOrder(orderId);
+ navigate('/dashboard');
+ } catch (err) {
+ alert("Erreur lors de la suppression : " + err.message);
+ setIsProcessing(false);
+ }
+ };
+
+ if (isLoading) {
+ return (
+
+
RÉCUPÉRATION DU CONTRAT FINANCIER...
+
+ );
+ }
+
+ if (error) {
+ return (
+
+
+
{error}
+
+
+ );
+ }
+
+ // =========================================================================
+ // ÉCRAN 2 : INSTRUCTIONS DE VIREMENT NATIVES (Pas de redirection)
+ // =========================================================================
+ if (showBankInstructions) {
+ return (
+
+
+
+
+
PROCÉDURE DE VIREMENT
+
Action manuelle requise pour activer l'instance
+
+
+
+
+
Montant exact à transférer :
+
{invoice.total} {invoice.currency}
+
+
+
+
Bénéficiaire
+
+ GISE CLOUD SERVICES
+
+
+
+
IBAN (Compte Bancaire)
+
+ BE43 XXXX XXXX XXXX {/* 🌟 METS TON VRAI IBAN ICI 🌟 */}
+
+
+
+
Communication Structurée
+
+ {invoice.serie}{invoice.nr}
+
+
+
+
+
+
+
+ L'instance sera provisionnée automatiquement sur l'hyperviseur dès réception des fonds par notre service comptable.
+
+
+
+
+ );
+ }
+
+ // =========================================================================
+ // ÉCRAN 1 : SÉLECTION DU MOYEN DE PAIEMENT
+ // =========================================================================
+ return (
+
+
+
+
+
PASSERELLE SÉCURISÉE
+
Règlement de la facture de provisionnement
+
+
+
+
+
+ Description
+
+ {invoice.lines && invoice.lines.length > 0 ? invoice.lines[0].title : 'Instance Cloud'}
+
+ Facture N° {invoice.serie}{invoice.nr}
+
+
+ Montant TTC
+ {invoice.total} {invoice.currency}
+
+
+
+
+ Sélectionner un canal de paiement
+
+
+
+ {gateways.length === 0 ? (
+
+ Aucun terminal de paiement n'est configuré sur l'infrastructure (FOSSBilling).
+
+ ) : (
+ gateways.map((gw) => {
+ const style = getGatewayStyle(gw.title);
+ const isSelected = selectedMethod === gw.id;
+
+ return (
+
setSelectedMethod(gw.id)}
+ className={`p-4 rounded-xl border cursor-pointer transition-all flex flex-col items-center justify-center gap-3 h-28
+ ${isSelected
+ ? 'border-emerald-500 bg-emerald-500/10 shadow-[0_0_15px_rgba(16,185,129,0.2)]'
+ : `border-gray-800 bg-black/40 ${style.color}`
+ }
+ `}
+ >
+
+ {style.icon}
+
+
+ {gw.title}
+
+
+ );
+ })
+ )}
+
+
+
+
+
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/services/api.js b/src/services/api.js
index d546f75..b11a710 100644
--- a/src/services/api.js
+++ b/src/services/api.js
@@ -220,4 +220,35 @@ export const replyTicket = (ticketId, message) =>
// Récupère la liste dynamique des départements (Helpdesks) configurés sur FOSSBilling
export const getHelpdesks = () =>
- apiCall(`${BASE_URL}/api/client/support/helpdesk_get_pairs`, 'GET');
\ No newline at end of file
+ apiCall(`${BASE_URL}/api/client/support/helpdesk_get_pairs`, 'GET');
+
+// ==========================================
+// ROUTES FACTURATION & PAIEMENTS
+// ==========================================
+
+// Récupère les détails d'une facture générée
+export const getInvoiceDetails = (invoiceHash) =>
+ apiCall(`${BASE_URL}/api/client/invoice/get`, 'POST', { hash: invoiceHash });
+
+// Récupère la liste des terminaux de paiement (Gateways) activés sur FOSSBilling
+export const getPaymentGateways = () =>
+ apiCall(`${BASE_URL}/api/guest/invoice/gateways`, 'GET');
+
+// ==========================================
+// GESTION DU CYCLE DE VIE (UPGRADE / CANCEL)
+// ==========================================
+
+// Récupère la liste de toutes les factures (utile pour trouver le hash d'une facture impayée)
+export const getClientInvoices = () =>
+ apiCall(`${BASE_URL}/api/client/invoice/get_list`, 'GET');
+
+// Récupère les détails spécifiques d'une commande
+export const getOrderDetails = (orderId) =>
+ apiCall(`${BASE_URL}/api/client/order/get`, 'POST', { id: orderId });
+
+// Liste les forfaits vers lesquels cette commande peut migrer
+export const getOrderUpgradables = (orderId) =>
+ apiCall(`${BASE_URL}/api/client/order/upgradables`, 'POST', { id: orderId });
+
+export const deleteOrder = (orderId) =>
+ apiCall(`${BASE_URL}/api/client/order/delete`, 'POST', { id: orderId });
\ No newline at end of file