add migration of servicew

This commit is contained in:
LathanDevers
2026-07-02 16:40:37 +02:00
parent c29047e6fa
commit ef8f4a96b4
2 changed files with 384 additions and 45 deletions
@@ -0,0 +1,291 @@
import { useState, useEffect } from 'react';
import { CreditCard, Check, Globe, Loader, ToggleLeft, ShieldAlert, Calendar } from 'lucide-react';
export default function SubscriptionManager({ order, onClose, onRefresh, onAlert }) {
const [loading, setLoading] = useState(false);
const [catalog, setCatalog] = useState([]);
const [selectedPlan, setSelectedPlan] = useState(null);
const [isCatalogLoading, setIsCatalogLoading] = useState(true);
const [billingPeriod, setBillingPeriod] = useState(order.period || '1M');
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é');
const renderMarkdownFeatures = (text) => {
if (!text) return <span className="text-gray-500 italic">Aucune spécification disponible.</span>;
return text.split('\n').map((line, index) => {
const cleanLine = line.trim();
if (cleanLine.startsWith('* ') || cleanLine.startsWith('- ')) {
return (
<div key={index} className="flex items-center gap-3 text-sm font-semibold text-white my-2">
<div className="shrink-0 w-5 h-5 rounded-full border border-cyan-500/40 flex items-center justify-center bg-cyan-950/10">
<Check className="w-3 h-3 text-cyan-400 stroke-[3]" />
</div>
<span>{parseBold(cleanLine.substring(2))}</span>
</div>
);
}
return <p key={index} className="text-gray-400 text-xs my-1">{parseBold(cleanLine)}</p>;
});
};
const parseBold = (text) => text.split('**').map((part, i) => i % 2 === 1 ? <strong key={i} className="text-white font-bold">{part}</strong> : 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 };
}
const recurrent = pricing.recurrent;
const priceW = recurrent['1W']?.price ? parseFloat(recurrent['1W'].price) : 0;
const priceM = recurrent['1M']?.price ? parseFloat(recurrent['1M'].price) : 0;
const priceY = recurrent['1Y']?.price ? parseFloat(recurrent['1Y'].price) : 0;
let billingPrice = 0, oldBillingPrice = 0, savingPercent = 0, isAvailable = true;
let displayPrice = 0, oldDisplayPrice = 0;
if (period === '1W') {
billingPrice = priceW;
oldBillingPrice = priceW;
displayPrice = priceW * 4.333; // Mensualisation estimée
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;
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;
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
};
};
const getPlanBadge = (index) => {
if (index === 0) return { label: "ESSENTIEL", color: "text-gray-400 bg-gray-900 border-gray-700" };
if (index === 1) return { label: "PLUS POPULAIRE", color: "text-cyan-300 bg-cyan-950 border-cyan-800 shadow-[0_0_10px_rgba(6,182,212,0.3)]" };
return { label: "PRO", color: "text-amber-400 bg-amber-950 border-amber-800 shadow-[0_0_10px_rgba(245,158,11,0.2)]" };
};
useEffect(() => {
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());
if (current) setSelectedPlan(current);
}
} catch (err) {
if (onAlert) onAlert("Erreur", "Impossible de mapper les offres.", "error");
} finally {
setIsCatalogLoading(false);
}
};
fetchCatalog();
}, [order.id, currentPlanName]);
const isNoChange = selectedPlan && (currentPlanId === selectedPlan.id) && (currentBillingPeriod === billingPeriod);
return (
// 🎯 Ligne modifiée : Retrait du "bg-[#060b14]" pour s'adapter à ta fenêtre Modal
<div className="flex flex-col w-full h-[90vh] md:h-[85vh] max-w-6xl mx-auto text-white">
<div className="shrink-0 space-y-4 mb-4 pr-2">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="bg-[#090f1c] border border-gray-800 p-3.5 rounded-xl flex items-center gap-3">
<div className="p-2 bg-gray-950 rounded-lg text-gray-400"><CreditCard className="w-4 h-4" /></div>
<div>
<p className="text-[10px] uppercase font-bold text-gray-500 tracking-wider">Abonnement Actuel</p>
<p className="text-sm font-bold text-white">{currentPlanName}</p>
</div>
</div>
<div className="bg-[#090f1c] border border-gray-800 p-3.5 rounded-xl flex items-center gap-3">
<div className="p-2 bg-gray-950 rounded-lg text-cyan-400"><Globe className="w-4 h-4" /></div>
<div className="truncate">
<p className="text-[10px] uppercase font-bold text-gray-500 tracking-wider">Espace Domaine</p>
<p className="text-sm font-mono text-cyan-400 truncate">{extractedDomain}</p>
</div>
</div>
</div>
<div className="bg-[#090f1c] border border-gray-800 p-4 rounded-xl flex flex-col md:flex-row items-start md:items-center justify-between gap-4">
<div className="flex items-center gap-3">
<div className="p-2 bg-gray-950 rounded-lg text-emerald-400 border border-gray-800/50">
<Calendar className="w-4 h-4" />
</div>
<div>
<p className="text-[10px] uppercase font-bold text-gray-500 tracking-wider">Prochain Renouvellement</p>
<p className="text-sm font-medium text-gray-300">
{order.expires_at ? order.expires_at : "Date non définie"}
<span className="text-gray-600 mx-2">|</span>
<span className="font-mono text-white font-bold">{order.price || '0.00'} {order.currency || '€'}</span>
<span className="text-[10px] text-gray-500 ml-1 uppercase">/ {order.period === '1W' ? 'semaine' : order.period === '1Y' ? 'an' : 'mois'}</span>
</p>
</div>
</div>
<a
href={`/invoice`}
className="text-xs font-bold text-cyan-400 hover:text-cyan-300 transition-colors flex items-center gap-1.5 bg-cyan-950/20 px-4 py-2 rounded-lg border border-cyan-900/30"
>
Voir la facture
</a>
</div>
<div className="bg-[#090f1c] p-2 rounded-xl border border-gray-800 flex items-center justify-between">
<span className="text-xs font-bold text-gray-400 ml-3 flex items-center gap-2">
<ToggleLeft className="w-4 h-4 text-cyan-400" /> Options de renouvellement
</span>
<div className="flex space-x-1">
{[
{ id: '1W', label: 'Semaine' },
{ id: '1M', label: 'Mois' },
{ id: '1Y', label: 'Année' }
].map((p) => (
<button
key={p.id}
onClick={() => setBillingPeriod(p.id)}
className={`px-5 py-2 rounded-lg text-xs font-black tracking-wide transition-all ${billingPeriod === p.id ? 'bg-cyan-500 text-black shadow-lg shadow-cyan-500/20' : 'text-gray-400 hover:text-white hover:bg-gray-900'}`}
>
{p.label}
</button>
))}
</div>
</div>
</div>
<div className="grow pr-2 custom-scrollbar">
{isCatalogLoading ? (
<div className="h-full flex flex-col items-center justify-center text-cyan-400">
<Loader className="w-8 h-8 animate-spin mx-auto mb-2" />
<p className="text-xs font-mono text-gray-500">Synchronisation des tarifs...</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-3 gap-5 pt-3 pb-4">
{catalog.map((plan, index) => {
const metrics = getProductCardMetrics(plan.pricing, billingPeriod);
const isSelected = selectedPlan?.id === plan.id;
const isCurrentActive = currentPlanId === plan.id && currentBillingPeriod === billingPeriod;
const topBadge = getPlanBadge(index);
return (
<div
key={plan.id}
onClick={() => 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'}
${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' : ''}`}
>
<div className="absolute -top-3 left-1/2 -translate-x-1/2">
<span className={`text-[9px] font-black tracking-widest px-3 py-1 rounded-full border ${topBadge.color}`}>
{topBadge.label}
</span>
</div>
<div className="flex justify-between items-center w-full mb-5 mt-2">
<span className="text-[9px] font-black tracking-widest text-white bg-[#090f1c] px-2.5 py-1 rounded border border-gray-800 uppercase">
{plan.category?.title || "WEB"}
</span>
{metrics.saving > 0 && metrics.isAvailable && (
<span className="text-[10px] font-bold text-[#00df89] bg-[#00df89]/10 border border-[#00df89]/20 px-3 py-0.5 rounded-full uppercase tracking-wide">
Économie {metrics.saving}%
</span>
)}
</div>
<div className="mb-5 shrink-0">
<h4 className="font-bold text-xl text-white tracking-tight mb-3 flex items-center justify-between">
{plan.title}
{isCurrentActive && <span className="text-[9px] font-bold text-orange-500 uppercase tracking-widest bg-orange-900 px-2 py-0.5 rounded border border-orange-800">Actif</span>}
</h4>
{metrics.isAvailable ? (
<>
{/* 🎯 L'affichage dynamique toujours converti au mois */}
<div className="flex items-baseline gap-1.5">
<span className="text-3xl font-black text-cyan-400 tracking-tighter">{metrics.displayPrice} </span>
<span className="text-xs text-gray-400 font-medium">/ mois</span>
</div>
{metrics.saving > 0 && (
<p className="text-xs text-gray-500 line-through mt-1">
Au lieu de {metrics.oldDisplayPrice} / mois
</p>
)}
{/* 🎯 L'engagement de facturation reste avec le vrai montant et le vrai cycle */}
<div className="mt-3.5 inline-block bg-[#0e2238] border border-cyan-950/40 text-cyan-400 text-[9px] font-black tracking-widest uppercase px-3 py-1.5 rounded-md">
Facturé {metrics.billingPrice} par {billingPeriod === '1W' ? 'semaine' : billingPeriod === '1M' ? 'mois' : 'an'}
</div>
</>
) : (
<div className="py-2">
<span className="text-lg font-bold text-gray-500">Non disponible</span>
<p className="text-[10px] text-gray-600 mt-1 uppercase tracking-wider">Pour ce cycle</p>
</div>
)}
</div>
<div className="border-t border-gray-800/60 pt-4 flex-grow space-y-1.5">
{renderMarkdownFeatures(plan.description)}
</div>
</div>
);
})}
</div>
)}
</div>
<div className="shrink-0 mt-auto pt-4 border-t border-gray-800/40 space-y-3">
{selectedPlan && (
<button
disabled={loading || isNoChange}
className={`w-full py-4 rounded-xl font-black uppercase text-xs tracking-widest transition-all duration-300
${isNoChange
? 'bg-gray-900 text-gray-500 border border-gray-800 cursor-not-allowed shadow-none'
: 'bg-cyan-500 hover:bg-cyan-400 text-black shadow-[0_0_20px_rgba(6,182,212,0.2)] hover:shadow-[0_0_25px_rgba(6,182,212,0.4)]'
}`}
>
{loading
? 'Traitement en cours...'
: isNoChange
? 'Abonnement actuel (Aucune modification)'
: `Valider la modification`
}
</button>
)}
<button className="w-full flex items-center justify-center gap-2 text-red-400/80 hover:text-red-400 border border-red-950/30 bg-red-950/5 hover:bg-red-950/15 p-3 rounded-xl text-xs font-bold uppercase tracking-wider transition-all">
<ShieldAlert className="w-4 h-4" /> Résilier l'abonnement réseau
</button>
</div>
</div>
);
}
+93 -45
View File
@@ -1,11 +1,13 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { getClientOrders } from '../../services/api'; import { getClientOrders } from '../../services/api';
import { AlertCircle, Loader } from 'lucide-react'; import { AlertCircle, Loader, FileText } from 'lucide-react';
// Importation des composants isolés // Importation des composants
import DashboardServiceCard from '../../components/dashboard/DashboardServiceCard'; import DashboardServiceCard from '../../components/dashboard/DashboardServiceCard';
import NewServiceCard from '../../components/dashboard/NewServiceCard'; import NewServiceCard from '../../components/dashboard/NewServiceCard';
import NotificationModal from '../../components/ui/NotificationModal';
import SubscriptionManager from '../../components/dashboard/SubscriptionManager'; // 🌟 Le nouveau composant !
export default function Dashboard() { export default function Dashboard() {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -13,54 +15,82 @@ export default function Dashboard() {
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null); const [error, setError] = useState(null);
// Chargement des données à l'ouverture du Sas const [activeSubscriptionModal, setActiveSubscriptionModal] = useState(null);
useEffect(() => { const [customAlert, setCustomAlert] = useState(null);
const fetchInventory = async () => {
try {
const data = await getClientOrders();
if (data.list) {
// LE FILTRE CHIRURGICAL PAR PREFIXE
const filteredOrders = data.list.filter(order => {
const title = (order.title || '').toLowerCase();
const type = (order.type || '').toLowerCase();
const isGhostProduct = const triggerAlert = (title, message, type = "info") => setCustomAlert({ title, message, type });
type === 'domain' ||
title.startsWith('domain ') ||
title.startsWith('domaine ') ||
title.startsWith('enregistrement ');
return !isGhostProduct;
});
setOrders(filteredOrders); const fetchInventory = async () => {
} else { setIsLoading(true);
setOrders([]); try {
} const data = await getClientOrders();
} catch (err) {
setError(err.message || "Impossible de récupérer la télémétrie des services."); if (data.list) {
} finally { // LE FILTRE CHIRURGICAL PAR PREFIXE
setIsLoading(false); const filteredOrders = data.list.filter(order => {
const title = (order.title || '').toLowerCase();
const type = (order.type || '').toLowerCase();
const isGhostProduct =
type === 'domain' ||
title.startsWith('domain ') ||
title.startsWith('domaine ') ||
title.startsWith('enregistrement ');
return !isGhostProduct;
});
setOrders(filteredOrders);
} else {
setOrders([]);
} }
}; } catch (err) {
setError(err.message || "Impossible de récupérer la télémétrie des services.");
} finally {
setIsLoading(false);
}
};
useEffect(() => {
fetchInventory(); fetchInventory();
}, []); }, []);
// 🌟 Plus besoin de charger les IPs, on ouvre juste la modale comptable !
const handleManageSubscription = (order) => {
// 1. Détection du type de service
const titleLower = (order.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');
// C'est un hébergement web s'il ne correspond à aucun des cas ci-dessus
const isWeb = !isVPS && !isCloud && !isDB;
// 2. Logique de blocage
if (isWeb) {
// On ouvre la modale comptable uniquement pour le WEB
setActiveSubscriptionModal(order);
} else {
// On affiche une notification pour les autres types d'instances
triggerAlert(
"Action non disponible",
"La modification autonome d'abonnement est actuellement exclusive aux Hébergements Web. Pour restructurer cette instance, veuillez contacter les ingénieurs via le Centre de Support.",
"info"
);
}
};
return ( return (
<div className="w-full max-w-6xl p-6 mx-auto"> <div className="w-full max-w-6xl p-6 mx-auto relative">
<header className="mb-8"> <header className="mb-8">
<h1 className="text-3xl font-black text-white tracking-wider">TABLEAU DE <span className="text-cyan-400">BORD</span></h1> <h1 className="text-3xl font-black text-white tracking-wider">TABLEAU DE <span className="text-cyan-400">BORD</span></h1>
<p className="text-gray-400 mt-2">Aperçu de vos accréditations réseau et infrastructures.</p> <p className="text-gray-400 mt-2">Gestion financière et accréditations de vos infrastructures.</p>
</header> </header>
{/* GESTION DES ERREURS & CHARGEMENT */}
{isLoading && ( {isLoading && (
<div className="flex items-center space-x-3 text-cyan-400"> <div className="flex items-center space-x-3 text-cyan-400">
<Loader className="w-6 h-6 animate-spin" /> <Loader className="w-6 h-6 animate-spin" />
<span>Synchronisation avec l'orchestrateur en cours...</span> <span>Synchronisation avec le registre comptable...</span>
</div> </div>
)} )}
@@ -71,24 +101,42 @@ export default function Dashboard() {
</div> </div>
)} )}
{/* GRILLE DES SERVICES */}
{!isLoading && !error && ( {!isLoading && !error && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{/* Boucle sur les composants isolés */}
{orders.map((order) => ( {orders.map((order) => (
<DashboardServiceCard <DashboardServiceCard
key={order.id} key={order.id}
order={order} order={order}
onClick={() => navigate(`/services/${order.id}`)} onClick={() => handleManageSubscription(order)}
/> />
))} ))}
{/* Le composant carte d'ajout */}
<NewServiceCard onClick={() => navigate('/store')} /> <NewServiceCard onClick={() => navigate('/store')} />
</div> </div>
)} )}
{/* 🌟 LA MODALE DE GESTION D'ABONNEMENT */}
{activeSubscriptionModal && (
<div className="fixed inset-0 bg-black/80 backdrop-blur-sm z-50 flex items-center justify-center p-4">
<div className="bg-gray-900 border border-cyan-500/50 shadow-2xl shadow-cyan-500/10 rounded-lg max-w-xxl w-full p-6 text-gray-200">
<div className="flex justify-between items-center mb-6 border-b border-gray-800 pb-4">
<h3 className="text-xl font-bold text-white tracking-wider flex items-center gap-3">
<FileText className="w-6 h-6 text-cyan-400" />
GESTION DE L'ABONNEMENT
</h3>
<button onClick={() => setActiveSubscriptionModal(null)} className="text-gray-400 hover:text-white transition text-xl"></button>
</div>
<SubscriptionManager
order={activeSubscriptionModal}
onClose={() => setActiveSubscriptionModal(null)}
onRefresh={fetchInventory}
onAlert={triggerAlert}
/>
</div>
</div>
)}
<NotificationModal notification={customAlert} onClose={() => setCustomAlert(null)} />
</div> </div>
); );
} }