diff --git a/src/App.jsx b/src/App.jsx index c982aca..1b9547e 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -11,7 +11,7 @@ import ProtectedRoute from './components/ProtectedRoute'; // Import des Pages Publiques import Register from './pages/public/Register'; import Login from './pages/public/Login'; -import Home from './pages/public/Home'; +import Home from './pages/public/Home'; //import Offres from './pages/public/Offres'; // Import des Pages Privées (Espace Client) @@ -20,12 +20,13 @@ 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 ( - + {/* ========================================== */} {/* ZONE PUBLIQUE (Accès Libre) */} {/* ========================================== */} @@ -41,7 +42,7 @@ export default function App() { {/* ========================================== */} {/* Le Garde du corps bloque l'entrée ici */} }> - + {/* Si autorisé, on charge l'interface avec la Sidebar */} }> } /> @@ -49,8 +50,9 @@ export default function App() { } /> } /> } /> + } /> - + diff --git a/src/components/dashboard/NewServiceCard.jsx b/src/components/dashboard/NewServiceCard.jsx index 9533ca3..d8f8c00 100644 --- a/src/components/dashboard/NewServiceCard.jsx +++ b/src/components/dashboard/NewServiceCard.jsx @@ -4,7 +4,7 @@ export default function NewServiceCard({ onClick }) { return (
@@ -13,7 +13,7 @@ export default function NewServiceCard({ onClick }) { Demander une accréditation

- Déployer un nouveau serveur Web, VPS ou Cloud. + Déployer un nouveau serveur Web, VPS, DB ou Cloud.

); diff --git a/src/components/dashboard/SubscriptionManager.jsx b/src/components/dashboard/SubscriptionManager.jsx index 11fcc93..a7a9cd0 100644 --- a/src/components/dashboard/SubscriptionManager.jsx +++ b/src/components/dashboard/SubscriptionManager.jsx @@ -1,20 +1,47 @@ import { useState, useEffect } from 'react'; -import { CreditCard, Check, Globe, Loader, ToggleLeft, ShieldAlert, Calendar } from 'lucide-react'; +import { CreditCard, Check, Globe, Loader, ToggleLeft, ShieldAlert, Calendar, TrendingUp, TrendingDown, RefreshCcw } from 'lucide-react'; +import { getProductList, getHostingServiceDetails } from '../../services/api'; +const CUSTOM_API_BASE_URL = import.meta.env.VITE_CUSTOM_API_BASE_URL || ''; export default function SubscriptionManager({ order, onClose, onRefresh, onAlert }) { const [loading, setLoading] = useState(false); + const [cancelLoading, setCancelLoading] = useState(false); const [catalog, setCatalog] = useState([]); const [selectedPlan, setSelectedPlan] = useState(null); const [isCatalogLoading, setIsCatalogLoading] = useState(true); - + + const [serviceDetails, setServiceDetails] = useState(null); + const [isDetailsLoading, setIsDetailsLoading] = useState(true); + const [billingPeriod, setBillingPeriod] = useState(order.period || '1M'); + // 1. Récupération des détails techniques (Domaine/IP) + useEffect(() => { + const fetchDetails = async () => { + setIsDetailsLoading(true); + try { + const data = await getHostingServiceDetails(order.id); + setServiceDetails(data); + } catch (err) { + console.error("Erreur détails service:", err); + } finally { + setIsDetailsLoading(false); + } + }; + fetchDetails(); + }, [order.id]); + const rawOrderTitle = order.title || ""; const titleParts = rawOrderTitle.split(' pour '); const currentPlanName = titleParts[0].trim(); const currentPlanId = order.product_id; const currentBillingPeriod = order.period; - const extractedDomain = titleParts[1] ? titleParts[1].trim() : (order.sld ? `${order.sld}.${order.tld}` : 'Non configuré'); + + // 2. Extraction robuste du domaine + let extractedDomain = "Aucun domaine lié"; + if (serviceDetails?.domain || serviceDetails?.config?.domain) { + extractedDomain = serviceDetails.domain || serviceDetails.config.domain; + } const renderMarkdownFeatures = (text) => { if (!text) return Aucune spécification disponible.; @@ -24,7 +51,7 @@ export default function SubscriptionManager({ order, onClose, onRefresh, onAlert return (
- +
{parseBold(cleanLine.substring(2))}
@@ -36,7 +63,6 @@ export default function SubscriptionManager({ order, onClose, onRefresh, onAlert const parseBold = (text) => text.split('**').map((part, i) => i % 2 === 1 ? {part} : part); - // 🎯 Nouveau Moteur Financier (Mensualisation de l'affichage) const getProductCardMetrics = (pricing, period) => { if (!pricing || !pricing.recurrent) { return { displayPrice: "0.00", billingPrice: "0.00", oldDisplayPrice: "0.00", saving: 0, isAvailable: false }; @@ -51,35 +77,22 @@ export default function SubscriptionManager({ order, onClose, onRefresh, onAlert let displayPrice = 0, oldDisplayPrice = 0; if (period === '1W') { - billingPrice = priceW; - oldBillingPrice = priceW; - displayPrice = priceW * 4.333; // Mensualisation estimée - oldDisplayPrice = displayPrice; + billingPrice = priceW; oldBillingPrice = priceW; + displayPrice = priceW * 4.333; oldDisplayPrice = displayPrice; if (!priceW) isAvailable = false; } else if (period === '1M') { - billingPrice = priceM; - oldBillingPrice = priceW > 0 ? priceW * 4.333 : priceM; - displayPrice = priceM; // Déjà au mois - oldDisplayPrice = oldBillingPrice; + billingPrice = priceM; oldBillingPrice = priceW > 0 ? priceW * 4.333 : priceM; + displayPrice = priceM; oldDisplayPrice = oldBillingPrice; if (!priceM) isAvailable = false; } else if (period === '1Y') { - billingPrice = priceY; - oldBillingPrice = priceW > 0 ? priceW * 52 : (priceM > 0 ? priceM * 12 : priceY); - displayPrice = priceY / 12; // Mensualisation exacte - oldDisplayPrice = oldBillingPrice / 12; + billingPrice = priceY; oldBillingPrice = priceW > 0 ? priceW * 52 : (priceM > 0 ? priceM * 12 : priceY); + displayPrice = priceY / 12; oldDisplayPrice = oldBillingPrice / 12; if (!priceY) isAvailable = false; } - // Le pourcentage d'économie se calcule toujours sur le montant global facturé savingPercent = (oldBillingPrice > billingPrice && billingPrice > 0) ? Math.round(((oldBillingPrice - billingPrice) / oldBillingPrice) * 100) : 0; - return { - displayPrice: displayPrice.toFixed(2), - billingPrice: billingPrice.toFixed(2), - oldDisplayPrice: oldDisplayPrice.toFixed(2), - saving: savingPercent, - isAvailable - }; + return { displayPrice: displayPrice.toFixed(2), billingPrice: billingPrice.toFixed(2), oldDisplayPrice: oldDisplayPrice.toFixed(2), saving: savingPercent, isAvailable }; }; const getPlanBadge = (index) => { @@ -92,31 +105,184 @@ export default function SubscriptionManager({ order, onClose, onRefresh, onAlert const fetchCatalog = async () => { setIsCatalogLoading(true); try { - const resp = await fetch('https://web.gise.be/custom_api/nexus_subscription.php', { - method: 'POST', - body: new URLSearchParams({ action: 'get_catalog', order_id: order.id }) - }); - const data = await resp.json(); - if (data.status === 'success') { - setCatalog(data.catalog); - const current = data.catalog.find(p => currentPlanName.toLowerCase() === p.title.toLowerCase()); + const data = await getProductList(); + const rawProducts = data.list || data.catalog || (Array.isArray(data) ? data : []); + const webProducts = rawProducts.filter(p => p.product_category_id === 1); + + if (webProducts.length > 0) { + setCatalog(webProducts); + const current = webProducts.find(p => currentPlanName.toLowerCase() === p.title.toLowerCase()); if (current) setSelectedPlan(current); + } else { + console.warn("Aucun produit de type 'Web Service' n'a été trouvé."); + setCatalog([]); } } catch (err) { - if (onAlert) onAlert("Erreur", "Impossible de mapper les offres.", "error"); + console.error("Erreur API Catalogue:", err); } finally { setIsCatalogLoading(false); } }; fetchCatalog(); - }, [order.id, currentPlanName]); + }, [currentPlanName]); const isNoChange = selectedPlan && (currentPlanId === selectedPlan.id) && (currentBillingPeriod === billingPeriod); + // ========================================== + // 🧠 LOGIQUE UPGRADE / DOWNGRADE & PRORATA + // ========================================== + let migrationLabel = "Valider la modification"; + let buttonColor = "bg-cyan-500 hover:bg-cyan-400 shadow-[0_0_20px_rgba(6,182,212,0.2)]"; + let ActionIcon = null; + let finalInvoicePrice = 0; + let isProrataApplied = false; + let isRefund = false; + + if (selectedPlan && !isNoChange) { + const currentPrice = parseFloat(order.price) || 0; + let currentMonthly = currentPrice; + if (currentBillingPeriod === '1W') currentMonthly = currentPrice * 4.333; + if (currentBillingPeriod === '1M') currentMonthly = currentPrice; + if (currentBillingPeriod === '1Y') currentMonthly = currentPrice / 12; + + const selectedMetrics = getProductCardMetrics(selectedPlan.pricing, billingPeriod); + const selectedMonthly = parseFloat(selectedMetrics.displayPrice); + + const p1 = parseFloat(order.price) || 0; + const p2 = parseFloat(selectedMetrics.billingPrice) || 0; + + let m = 30; + if (currentBillingPeriod === '1Y') m = 365; + if (currentBillingPeriod === '1M') m = 30; + if (currentBillingPeriod === '1W') m = 7; + + const expiresAt = new Date(order.expires_at); + const now = new Date(); + let remainingDays = 0; + + if (!isNaN(expiresAt)) { + remainingDays = Math.max(0, Math.ceil((expiresAt - now) / (1000 * 60 * 60 * 24))); + } + + if (remainingDays > 0 && remainingDays <= m) { + const n = m - remainingDays; // Jours écoulés + finalInvoicePrice = p2 - p1 - (n * (p1 - p2) / m); + isProrataApplied = true; + } else { + finalInvoicePrice = p2; + } + + isRefund = finalInvoicePrice < -0.01; + + // --- 3. Style du bouton (Triggers : Upgrade / Downgrade / Cycle) --- + + // On récupère le forfait actuel depuis le catalogue pour comparer la "puissance" brute des forfaits + const currentPlanFromCatalog = catalog.find(p => p.id === currentPlanId); + + // Pour définir la hiérarchie absolue, on compare les prix de base sur 1 Mois + const baseCurrentPrice = currentPlanFromCatalog?.pricing?.recurrent?.['1M']?.price || 0; + const baseSelectedPrice = selectedPlan?.pricing?.recurrent?.['1M']?.price || 0; + + const isSamePlan = currentPlanId === selectedPlan.id; + const isSamePeriod = currentBillingPeriod === billingPeriod; + + if (isSamePlan && !isSamePeriod) { + // Le pack est identique, seule la période change + migrationLabel = "Changer de cycle"; + buttonColor = "bg-blue-500 hover:bg-blue-400 text-black shadow-[0_0_20px_rgba(59,130,246,0.2)]"; + ActionIcon = ; + } else if (!isSamePlan) { + // Le pack change, on compare la hiérarchie absolue + if (parseFloat(baseSelectedPrice) > parseFloat(baseCurrentPrice)) { + migrationLabel = `Valider l'Upgrade`; + buttonColor = "bg-emerald-500 hover:bg-emerald-400 text-black shadow-[0_0_20px_rgba(16,185,129,0.2)]"; + ActionIcon = ; + } else { + migrationLabel = "Valider le Downgrade"; + buttonColor = "bg-amber-500 hover:bg-amber-400 text-black shadow-[0_0_20px_rgba(245,158,11,0.2)]"; + ActionIcon = ; + } + } + } + // ========================================== + + const handleMigration = async () => { + setLoading(true); + + // 🎯 On détermine dynamiquement le type d'action pour la facture + let actionType = 'upgrade'; + const currentPlanFromCatalog = catalog.find(p => p.id === currentPlanId); + const baseCurrent = parseFloat(currentPlanFromCatalog?.pricing?.recurrent?.['1M']?.price || 0); + const baseSelected = parseFloat(selectedPlan?.pricing?.recurrent?.['1M']?.price || 0); + + if (selectedPlan.id === currentPlanId) { + actionType = 'cycle'; + } else if (baseSelected < baseCurrent) { + actionType = 'downgrade'; + } + + try { + const resp = await fetch(`${CUSTOM_API_BASE_URL}/custom_api/nexus_subscription.php`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + action: 'migrate', + order_id: order.id, + target_plan_id: selectedPlan.id, + new_period: billingPeriod, + new_price: finalInvoicePrice.toFixed(2), + action_type: actionType // 🎯 On envoie l'information au backend ! + }) + }); + const data = await resp.json(); + if (data.status === 'success') { + onAlert?.("Facture générée", "Redirection...", "success"); + onClose?.(); + setTimeout(() => window.location.href = '/facturation', 2000); + } else { + onAlert?.("Erreur", data.error, "error"); + } + } catch (err) { + onAlert?.("Erreur", "Problème réseau", "error"); + } finally { + setLoading(false); + } + }; + + const handleCancel = async () => { + if (!window.confirm("Êtes-vous sûr de vouloir résilier cet abonnement ? Le service sera désactivé au prochain cycle de facturation.")) { + return; + } + + setCancelLoading(true); + try { + const resp = await fetch(`${CUSTOM_API_BASE_URL}/custom_api/nexus_subscription.php`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + action: 'cancel', + order_id: order.id + }) + }); + const data = await resp.json(); + + if (data.status === 'success') { + if (onAlert) onAlert("Résilié", data.message || "L'abonnement a été annulé avec succès.", "success"); + if (onRefresh) onRefresh(); + if (onClose) onClose(); + } else { + if (onAlert) onAlert("Erreur", data.error || "Impossible de résilier l'abonnement.", "error"); + } + } catch (err) { + if (onAlert) onAlert("Erreur", "Problème réseau ou serveur.", "error"); + } finally { + setCancelLoading(false); + } + }; + return ( - // 🎯 Ligne modifiée : Retrait du "bg-[#060b14]" pour s'adapter à ta fenêtre Modal -
- +
+
@@ -143,18 +309,18 @@ export default function SubscriptionManager({ order, onClose, onRefresh, onAlert

Prochain Renouvellement

- {order.expires_at ? order.expires_at : "Date non définie"} - | + {order.expires_at ? order.expires_at : "Date non définie"} + | {order.price || '0.00'} {order.currency || '€'} / {order.period === '1W' ? 'semaine' : order.period === '1Y' ? 'an' : 'mois'}

- - Voir la facture ↗ + Voir les factures
@@ -168,10 +334,11 @@ export default function SubscriptionManager({ order, onClose, onRefresh, onAlert { id: '1M', label: 'Mois' }, { id: '1Y', label: 'Année' } ].map((p) => ( - @@ -195,11 +362,11 @@ export default function SubscriptionManager({ order, onClose, onRefresh, onAlert const topBadge = getPlanBadge(index); return ( -
metrics.isAvailable && setSelectedPlan(plan)} +
!loading && !cancelLoading && metrics.isAvailable && setSelectedPlan(plan)} className={`relative h-full p-6 rounded-2xl border transition-all duration-300 flex flex-col justify-between bg-[#0d1527] mt-3 - ${!metrics.isAvailable ? 'opacity-50 grayscale cursor-not-allowed border-gray-900' : 'cursor-pointer'} + ${!metrics.isAvailable || loading || cancelLoading ? 'opacity-50 grayscale cursor-not-allowed border-gray-900' : 'cursor-pointer'} ${isSelected && metrics.isAvailable ? 'border-cyan-500 shadow-[0_0_20px_rgba(6,182,212,0.15)] scale-[1.02] z-10' : 'border-gray-800/80 hover:border-gray-700'} ${isCurrentActive ? 'ring-1 ring-gray-700' : ''}`} > @@ -225,10 +392,9 @@ export default function SubscriptionManager({ order, onClose, onRefresh, onAlert {plan.title} {isCurrentActive && Actif} - + {metrics.isAvailable ? ( <> - {/* 🎯 L'affichage dynamique toujours converti au mois */}
{metrics.displayPrice} € / mois @@ -240,7 +406,6 @@ export default function SubscriptionManager({ order, onClose, onRefresh, onAlert

)} - {/* 🎯 L'engagement de facturation reste avec le vrai montant et le vrai cycle */}
Facturé {metrics.billingPrice} € par {billingPeriod === '1W' ? 'semaine' : billingPeriod === '1M' ? 'mois' : 'an'}
@@ -253,7 +418,7 @@ export default function SubscriptionManager({ order, onClose, onRefresh, onAlert )}
-
+
{renderMarkdownFeatures(plan.description)}
@@ -264,26 +429,57 @@ export default function SubscriptionManager({ order, onClose, onRefresh, onAlert
+ {/* 🎯 NOUVEAU : AFFICHAGE DYNAMIQUE (PRORATA ET REMBOURSEMENT) */} + {selectedPlan && !isNoChange && ( +
+
+

+ {isRefund + ? "Remboursement estimé" + : finalInvoicePrice > 0.01 + ? "Montant à régler aujourd'hui" + : "Aucun frais immédiat"} +

+ {isProrataApplied &&

Calculé au prorata des jours restants

} +
+
+ + {isRefund ? "-" : ""}{Math.abs(finalInvoicePrice).toFixed(2)} € + +
+
+ )} + {selectedPlan && ( - )} - -
diff --git a/src/components/services/InstanceCard.jsx b/src/components/services/InstanceCard.jsx index 42171e4..9ff1af5 100644 --- a/src/components/services/InstanceCard.jsx +++ b/src/components/services/InstanceCard.jsx @@ -1,22 +1,58 @@ +import React, { useState, useEffect } from 'react'; import { Server, Database, Cloud, Globe, ExternalLink, Play, Loader } from 'lucide-react'; +import { getHostingServiceDetails } from '../../services/api'; export default function InstanceCard({ service, vpcs, onAssignVpc, onRemoveVpc, onOpenConsole, isConnecting }) { + // 1. États pour les détails techniques chargés via API + const [serviceDetails, setServiceDetails] = useState(null); + const [isLoadingDetails, setIsLoadingDetails] = useState(true); + + // 2. Chargement des détails au montage du composant + useEffect(() => { + let isMounted = true; + const fetchDetails = async () => { + try { + const data = await getHostingServiceDetails(service.id); + if (isMounted) { + setServiceDetails(data); + } + } catch (error) { + console.error("Erreur lors du chargement des détails du service:", error); + } finally { + if (isMounted) setIsLoadingDetails(false); + } + }; + + fetchDetails(); + return () => { isMounted = false; }; + }, [service.id]); + + // 3. Logique d'affichage (Titre, Type de service) const titleLower = (service.title || '').toLowerCase(); const isVPS = titleLower.includes('vps') || titleLower.includes('compute'); const isCloud = titleLower.includes('cloud') || titleLower.includes('nextcloud'); const isDB = titleLower.includes('db') || titleLower.includes('base') || titleLower.includes('sql'); const isWeb = !isVPS && !isCloud && !isDB; - const hasIP = service.hostingDetails?.ip && service.hostingDetails.ip !== '127.0.0.1' && service.hostingDetails.ip !== ''; + // 4. Extraction intelligente du domaine et de l'IP + // Priorité : API > Titre de la commande + const rawTitle = service.title || ''; + const titleMatch = rawTitle.match(/(?: for | pour )(.+)$/i); + const domainFromTitle = titleMatch ? titleMatch[1].trim() : null; + + const displayDomain = serviceDetails?.domain || serviceDetails?.config?.domain || domainFromTitle || null; + const displayIP = serviceDetails?.ip || serviceDetails?.config?.ip || null; + + // Logique des boutons let buttonText = "CONSOLE D'ADMINISTRATION"; let ButtonIcon = ExternalLink; let buttonStyle = "bg-cyan-400/10 hover:bg-cyan-400 text-cyan-400 hover:text-gray-900 border-cyan-400"; if (isVPS) { - if (hasIP) { buttonText = "GÉRER L'INSTANCE"; ButtonIcon = ExternalLink; } + if (displayIP) { buttonText = "GÉRER L'INSTANCE"; ButtonIcon = ExternalLink; } else { buttonText = "INITIALISATION"; ButtonIcon = Play; buttonStyle = "bg-yellow-500/10 text-yellow-500 hover:bg-yellow-500 hover:text-gray-900 border-yellow-500/50"; } } else if (isWeb) { buttonText = "GÉRER L'HÉBERGEMENT"; ButtonIcon = Globe; buttonStyle = "bg-emerald-400/10 hover:bg-emerald-400 text-emerald-400 hover:text-gray-900 border-emerald-400"; } - else if (isCloud) { buttonText = "ACCÉDER AU CLOUD"; buttonStyle = "bg-blue-400/10 hover:bg-blue-400 text-blue-400 hover:text-gray-900 border-blue-400"; } + else if (isCloud) { buttonText = "ACCÉDER AU CLOUD"; buttonStyle = "bg-blue-400/10 hover:bg-blue-400 text-blue-400 hover:text-gray-900 border-blue-400"; } else if (isDB) { buttonText = "PHPMYADMIN / CLUSTER"; ButtonIcon = Database; buttonStyle = "bg-purple-400/10 hover:bg-purple-400 text-purple-400 hover:text-gray-900 border-purple-400"; } const getServiceIcon = () => { @@ -26,12 +62,8 @@ export default function InstanceCard({ service, vpcs, onAssignVpc, onRemoveVpc, return ; }; - const baseTitle = (service.title || '').split(/(?: for | pour )/i)[0].trim(); + const baseTitle = rawTitle.split(/(?: for | pour )/i)[0].trim(); const shortTitle = baseTitle.split(' ').slice(0, 2).join(' '); - const titleMatch = (service.title || '').match(/(?: for | pour )(.+)$/i); - const domainFromTitle = titleMatch ? titleMatch[1].trim() : null; - let displayDomain = service.hostingDetails?.domain && service.hostingDetails.domain !== '127.0.0.1' ? service.hostingDetails.domain : domainFromTitle || service.domain; - if (!displayDomain || displayDomain === '127.0.0.1') displayDomain = null; return (
@@ -40,27 +72,63 @@ export default function InstanceCard({ service, vpcs, onAssignVpc, onRemoveVpc,
{getServiceIcon()}
{service.status === 'active' ? ( - {isVPS && !hasIP ? 'AWAITING INIT' : 'ONLINE'} + {isVPS && !displayIP ? 'AWAITING INIT' : 'ONLINE'} ) : ( DEPLOYING )}
+

{shortTitle}

-
- {displayDomain ? (

{displayDomain}

) : (

En attente de déploiement

)} + + {/* Zone Domaine & IP */} +
+ {isLoadingDetails ? ( +
+ Chargement... +
+ ) : ( + <> + {displayDomain ? ( +

+ {displayDomain} +

+ ) : ( +

Aucun domaine

+ )} + {displayIP && ( +

+ IP: {displayIP} +

+ )} + + )}
+
Projet VPC: - { const val = e.target.value; if (val === "free") onRemoveVpc(service.id); else if (val) onAssignVpc(service.id, val); }} + className="bg-black border border-gray-700 text-gray-300 rounded px-2 py-1 outline-none focus:border-cyan-400 text-xs w-32.5" + defaultValue={vpcs.find(v => v.services.includes(service.id))?.id || "free"} + > {vpcs.map(vpc => ( ))}
-
diff --git a/src/components/store/ProductCard.jsx b/src/components/store/ProductCard.jsx index d9bcd74..3aee561 100644 --- a/src/components/store/ProductCard.jsx +++ b/src/components/store/ProductCard.jsx @@ -66,16 +66,16 @@ export default function ProductCard({ product, selectedPeriod, categoryName }) {
)} -
+

{product.title}

{priceData.isAvailable ? ( -
+
{priceData.displayPrice} € {priceData.suffix}
-
+
{!priceData.isOnce && ( <> {priceData.originalPrice ? ( @@ -96,9 +96,9 @@ export default function ProductCard({ product, selectedPeriod, categoryName }) { )}
-
+
-
    , li: ({node, ...props}) =>
  • {props.children}
  • , p: ({node, ...props}) =>

    , strong: ({node, ...props}) => }}> +

      , li: ({node, ...props}) =>
    • {props.children}
    • , p: ({node, ...props}) =>

      , strong: ({node, ...props}) => }}> {product.description || "Aucune description technique."}

diff --git a/src/components/support/CreateTicketModal.jsx b/src/components/support/CreateTicketModal.jsx index 8301111..f392f27 100644 --- a/src/components/support/CreateTicketModal.jsx +++ b/src/components/support/CreateTicketModal.jsx @@ -1,5 +1,5 @@ import { useState, useEffect } from 'react'; -import { Plus, Lock } from 'lucide-react'; +import { Plus, Lock, X } from 'lucide-react'; export default function CreateTicketModal({ isOpen, onClose, onSubmit, helpdesks, defaultHelpdesk, isSubmitting }) { const [subject, setSubject] = useState(''); @@ -30,7 +30,9 @@ export default function CreateTicketModal({ isOpen, onClose, onSubmit, helpdesks

OUVRIR UNE REQUÊTE

- +
diff --git a/src/components/support/TicketThreadModal.jsx b/src/components/support/TicketThreadModal.jsx index 4ccb36c..25cd135 100644 --- a/src/components/support/TicketThreadModal.jsx +++ b/src/components/support/TicketThreadModal.jsx @@ -1,5 +1,5 @@ import { useState, useEffect, useRef } from 'react'; -import { Send, Loader } from 'lucide-react'; +import { Send, Loader, X } from 'lucide-react'; export default function TicketThreadModal({ ticket, onClose, onReply, isLoadingConversation }) { const [replyMessage, setReplyMessage] = useState(''); @@ -32,7 +32,9 @@ export default function TicketThreadModal({ ticket, onClose, onReply, isLoadingC

{ticket.subject}

- +
diff --git a/src/layouts/AppLayout.jsx b/src/layouts/AppLayout.jsx index 158dc86..b9acc6d 100644 --- a/src/layouts/AppLayout.jsx +++ b/src/layouts/AppLayout.jsx @@ -1,6 +1,7 @@ import { useState, useEffect } from 'react'; import { Outlet, NavLink, useNavigate } from 'react-router-dom'; import { getClientTickets } from '../services/api'; +import { LayoutDashboard, Globe, Settings, FileText } from 'lucide-react'; import ConfirmLogoutModal from '../components/ui/ConfirmLogoutModal'; // Le modal extrait ! export default function AppLayout() { @@ -15,7 +16,7 @@ export default function AppLayout() { const unread = data.list.filter(t => t.status === 'on_hold' || t.unread).length; setUnreadCount(unread); } - } catch (err) {} + } catch (err) { } }; useEffect(() => { @@ -30,19 +31,18 @@ export default function AppLayout() { }; // 🌟 La fonction magique Tailwind pour les liens du menu - const navLinkClass = ({ isActive }) => - `block px-6 py-4 no-underline border-l-4 transition-all uppercase tracking-widest text-sm font-mono ${ - isActive - ? 'text-cyan-400 bg-cyan-400/5 border-cyan-400' - : 'text-[#888] border-transparent hover:text-gray-300 hover:bg-white/5' + const navLinkClass = ({ isActive }) => + `block px-6 py-4 no-underline border-l-4 transition-all uppercase tracking-widest text-sm font-mono ${isActive + ? 'text-cyan-400 bg-cyan-400/5 border-cyan-400' + : 'text-[#888] border-transparent hover:text-gray-300 hover:bg-white/5' }`; return (
- + {/* SIDEBAR */}
{activeServiceModal.type === 'vps' ? ( activeServiceModal.details?.ip && activeServiceModal.details.ip !== '127.0.0.1' ? : { setActiveServiceModal(null); fetchServices(); }} onAlert={triggerAlert} /> diff --git a/src/services/api.js b/src/services/api.js index d546f75..04f0fa4 100644 --- a/src/services/api.js +++ b/src/services/api.js @@ -220,4 +220,19 @@ 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 & REÇUS +// ========================================== + +// Récupère l'historique des factures via notre script Custom PHP +export const getInvoicesHistory = (clientId) => + apiCall(`${CUSTOM_API_BASE_URL}/custom_api/nexus_subscription.php`, 'POST', { + action: 'get_invoices', + client_id: clientId + }); + +// (Optionnel) FOSSBilling Native : Récupère les détails complets d'une facture +export const getInvoiceDetails = (invoiceHash) => + apiCall(`${BASE_URL}/api/client/invoice/get`, 'POST', { hash: invoiceHash }); \ No newline at end of file