5 Commits

Author SHA1 Message Date
LathanDevers 338dccf30b Merge branch 'feat/updatesub-web-process' of https://git.bunker.lan/maximus/portail-gise into feat/updatesub-web-process 2026-07-16 09:05:44 +02:00
LathanDevers 151aca69de us1 2026-07-16 09:05:24 +02:00
maximus 9ad44d57b3 facturation: correction 2026-07-12 16:25:53 +02:00
LathanDevers eb48f506cf add billing system 2026-07-12 10:28:02 +02:00
LathanDevers ef8f4a96b4 add migration of servicew 2026-07-02 16:40:37 +02:00
14 changed files with 1177 additions and 93 deletions
+101
View File
@@ -0,0 +1,101 @@
### 🛒 Épique 1 : Souscription et Achat (Abonnement Web)
**US 1.1 : Souscription initiale et information légale**
> **En tant que** client,
> **Je veux** pouvoir choisir un abonnement web et le payer en ligne,
> **Afin d'** y accéder immédiatement tout en étant informé de mes droits.
* **CA 1 :** Le client doit pouvoir sélectionner un plan d'hébergement et un cycle de facturation (ex: 1 mois, 1 an).
* **CA 2 :** Dès validation du paiement, le compte HestiaCP est provisionné automatiquement et les accès sont envoyés par email.
* **CA 3 :** Le système enregistre l'horodatage exact (date et heure) de l'activation, définissant le "Jour 0".
* **CA 4 :** L'email de confirmation de commande **doit explicitement mentionner** le droit de rétractation de 14 jours prévu par le Code de droit économique belge.
---
### 🛑 Épique 2 : Résiliation et Rétractation
**US 2.1 : Droit de rétractation (Résiliation Jours 0 à 14 inclus)**
> **En tant que** client dans ma période d'essai légale de 14 jours,
> **Je veux** pouvoir annuler mon abonnement d'un simple clic depuis mon espace,
> **Afin de** stopper le service immédiatement et d'obtenir un remboursement complet.
* **CA 1 :** Un bouton "Annuler mon abonnement" est disponible sur le détail du service.
* **CA 2 :** **Règle métier :** Si la demande d'annulation intervient avant ou le 14ème jour à 23h59 suivant l'activation, le système affiche un message indiquant que le remboursement sera intégral et immédiat.
* **CA 3 :** Dès confirmation du client, le service (FOSSBilling + HestiaCP) est **immédiatement suspendu ou supprimé**.
* **CA 4 :** Une facture de remboursement (montant négatif) est générée, et l'ordre de remboursement est envoyé à la passerelle de paiement.
**US 2.2 : Résiliation standard (Après 14 jours)**
> **En tant que** client abonné depuis plus de 14 jours,
> **Je veux** pouvoir annuler le renouvellement automatique de mon abonnement,
> **Afin de** ne pas être facturé à la prochaine échéance.
* **CA 1 :** **Règle métier :** Si la demande d'annulation intervient après le 14ème jour, aucun remboursement n'est proposé.
* **CA 2 :** L'interface affiche clairement : *"Votre abonnement restera actif jusqu'au [Date de fin de période payée] et ne sera pas renouvelé."*
* **CA 3 :** Le statut de l'abonnement dans FOSSBilling passe en "Annulé à l'échéance".
* **CA 4 :** Le système coupe le service HestiaCP **uniquement à la date d'échéance**, sans action manuelle de l'administrateur.
---
### ⬆️ Épique 3 : Upgrade (Montée en gamme)
**US 3.1 : Upgrade d'une offre en cours de cycle**
> **En tant que** client,
> **Je veux** pouvoir passer à une offre supérieure à tout moment,
> **Afin de** bénéficier de plus de ressources immédiatement.
* **CA 1 :** L'interface propose les plans supérieurs disponibles et calcule le prix à payer au *Prorata Temporis* (différence de prix calculée au prorata des jours restants avant le prochain renouvellement).
* **CA 2 :** Le changement de ressources techniques sur HestiaCP et la mise à jour des paramètres sur FOSSBilling ont lieu **immédiatement après validation du paiement de l'upgrade**.
* **CA 3 :** **Légal :** L'upgrade constituant un nouveau contrat/avenant, si le client se rétracte dans les 14 jours suivant cet upgrade, il est remboursé du prorata payé et redescendu à son offre initiale (ses ressources HestiaCP sont rétrogradées).
---
### ⬇️ Épique 4 : Downgrade (Descente en gamme standard)
**US 4.1 : Demande de Downgrade pour le prochain cycle**
> **En tant que** client,
> **Je veux** pouvoir demander le passage à une offre inférieure,
> **Afin de** réduire mes coûts lors de mon prochain renouvellement.
* **CA 1 :** L'interface permet de sélectionner un plan inférieur.
* **CA 2 :** Un message d'avertissement clair doit s'afficher : *"Votre demande est enregistrée. Le changement d'offre (ressources et tarif) prendra effet à votre prochaine date de renouvellement, le [Date]. Aucun remboursement partiel ne sera effectué pour la période en cours."*
* **CA 3 :** Le système enregistre l'intention de downgrade (Modification du `product_id` et du tarif pour la prochaine facture, mais maintien du package actuel sur HestiaCP jusqu'à la date d'échéance).
* **CA 4 :** À la date anniversaire, un script Cron effectue la bascule effective des ressources sur HestiaCP et génère la facture au nouveau tarif.
### 🟢 Phase 1 : Le "Quick Win" légal (Épique 1)
C'est le plus facile et on peut le régler en 5 minutes directement dans l'interface de FOSSBilling, sans toucher au code.
* **L'objectif :** Modifier les templates d'emails automatiques envoyés lors d'une commande pour y inclure la mention légale obligatoire du droit de rétractation de 14 jours.
* **L'action :** Éditer le template `mod_email_order_activated` dans FOSSBilling.
### 🟠 Phase 2 : Le cœur du réacteur - Les 14 jours (Épique 2)
C'est ici qu'on va faire du vrai développement PHP. Par défaut, quand un client annule sur FOSSBilling, ça coupe à la fin du mois sans rembourser. On doit créer un "intercepteur" (un Hook).
* **L'objectif :** Quand le client clique sur "Annuler", le système calcule la différence entre la date d'aujourd'hui et la date de la commande.
* **La logique :**
* Si `<= 14 jours` : On déclenche l'API `refund` de FOSSBilling, on suspend HestiaCP immédiatement, et on passe la commande en annulée.
* Si `> 14 jours` : On laisse FOSSBilling faire son comportement par défaut (Annulation à la prochaine date d'échéance).
### 🔵 Phase 3 : L'adaptation du Downgrade (Épique 4)
Tu te souviens de notre script de Downgrade instantané qu'on avait fait il y a quelques jours ? Puisque tu as choisi le modèle "Standard SaaS" (le downgrade ne s'applique qu'à la prochaine facture), on va devoir le modifier !
* **L'objectif :** Le clic sur "Downgrade" ne doit plus modifier HestiaCP immédiatement. Il doit juste préparer la prochaine facture dans FOSSBilling, et c'est un Cron (une tâche planifiée) qui réduira les ressources HestiaCP le jour du renouvellement.
### 🟣 Phase 4 : L'Upgrade (Épique 3)
FOSSBilling gère plutôt bien les upgrades nativement (avec calcul du prorata). On devra juste s'assurer que l'appel API vers HestiaCP se fait correctement dès que la facture de prorata est payée.
---
**Comment on attaque ?**
Est-ce qu'on se met en chauffe avec la **Phase 1 (Les templates d'emails)**, ou est-ce qu'on plonge directement dans le code PHP avec la **Phase 2 (La logique de rétractation des 14 jours)** ?
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>GISE Nexus | Portail d'Infrastructure Centralisée</title> <title>NEXUS by gise | Portail d'Infrastructure Centralisée</title>
<meta name="description" content="Accédez au portail GISE Nexus. Pilotez votre hébergement web, vos instances serveurs et votre stockage Cloud depuis une console unifiée."> <meta name="description" content="Accédez au portail GISE Nexus. Pilotez votre hébergement web, vos instances serveurs et votre stockage Cloud depuis une console unifiée.">
</head> </head>
<body> <body>
+2
View File
@@ -20,6 +20,7 @@ import Store from './pages/app/Store';
import Checkout from './pages/app/Checkout'; import Checkout from './pages/app/Checkout';
import Services from './pages/app/Services'; import Services from './pages/app/Services';
import Support from './pages/app/Support'; import Support from './pages/app/Support';
import BillingHistory from './pages/app/BillingHistory';
export default function App() { export default function App() {
return ( return (
@@ -49,6 +50,7 @@ export default function App() {
<Route path="/checkout/:productId" element={<Checkout />} /> <Route path="/checkout/:productId" element={<Checkout />} />
<Route path="/services" element={<Services />} /> <Route path="/services" element={<Services />} />
<Route path="/support" element={<Support />} /> <Route path="/support" element={<Support />} />
<Route path="/facturation" element={<BillingHistory />} />
</Route> </Route>
</Route> </Route>
+2 -2
View File
@@ -4,7 +4,7 @@ export default function NewServiceCard({ onClick }) {
return ( return (
<div <div
onClick={onClick} onClick={onClick}
className="bg-transparent border-2 border-dashed border-gray-700 hover:border-cyan-400 p-6 rounded-xl transition-colors cursor-pointer flex flex-col items-center justify-center text-center group min-h-[200px]" className="bg-transparent border-2 border-dashed border-gray-700 hover:border-cyan-400 p-6 rounded-xl transition-colors cursor-pointer flex flex-col items-center justify-center text-center group min-h-50"
> >
<div className="p-3 bg-gray-800/50 rounded-full group-hover:bg-cyan-400/20 transition-colors mb-4"> <div className="p-3 bg-gray-800/50 rounded-full group-hover:bg-cyan-400/20 transition-colors mb-4">
<Plus className="w-8 h-8 text-gray-400 group-hover:text-cyan-400" /> <Plus className="w-8 h-8 text-gray-400 group-hover:text-cyan-400" />
@@ -13,7 +13,7 @@ export default function NewServiceCard({ onClick }) {
Demander une accréditation Demander une accréditation
</h3> </h3>
<p className="text-sm text-gray-500 mt-2"> <p className="text-sm text-gray-500 mt-2">
Déployer un nouveau serveur Web, VPS ou Cloud. Déployer un nouveau serveur Web, VPS, DB ou Cloud.
</p> </p>
</div> </div>
); );
@@ -0,0 +1,498 @@
import { useState, useEffect } from '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;
// 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 <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);
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; oldDisplayPrice = displayPrice;
if (!priceW) isAvailable = false;
} else if (period === '1M') {
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; oldDisplayPrice = oldBillingPrice / 12;
if (!priceY) isAvailable = false;
}
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 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) {
console.error("Erreur API Catalogue:", err);
} finally {
setIsCatalogLoading(false);
}
};
fetchCatalog();
}, [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 = <RefreshCcw className="w-4 h-4" />;
} 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 = <TrendingUp className="w-4 h-4" />;
} 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 = <TrendingDown className="w-4 h-4" />;
}
}
}
// ==========================================
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.")) {
try {
const response = await cancelSubscription(user.id, order.id);
// Le message s'adapte tout seul !
// Soit: "Annulé avec effet immédiat et remboursé"
// Soit: "Actif jusqu'à la fin de la période"
alert(response.message);
// Rafraîchir la liste des abonnements...
} catch (error) {
alert("Erreur lors de l'annulation : " + error.message);
}
}
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 (
<div className="flex flex-col w-full max-h-[95vh] md:h-auto md:max-h-[90vh] 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={`/facturation`}
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 les factures
</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)}
disabled={loading || cancelLoading}
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'} disabled:opacity-50`}
>
{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={() => !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 || 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' : ''}`}
>
<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 ? (
<>
<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>
)}
<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 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">
{/* 🎯 NOUVEAU : AFFICHAGE DYNAMIQUE (PRORATA ET REMBOURSEMENT) */}
{selectedPlan && !isNoChange && (
<div className={`border p-3 rounded-xl mb-3 flex items-center justify-between transition-colors
${isRefund ? 'bg-amber-950/20 border-amber-900/50' : 'bg-[#0e2238] border-cyan-900/50'}`}
>
<div>
<p className={`text-xs font-bold uppercase tracking-widest ${isRefund ? 'text-amber-400' : 'text-cyan-400'}`}>
{isRefund
? "Remboursement estimé"
: finalInvoicePrice > 0.01
? "Montant à régler aujourd'hui"
: "Aucun frais immédiat"}
</p>
{isProrataApplied && <p className="text-[10px] text-gray-500 mt-0.5">Calculé au prorata des jours restants</p>}
</div>
<div className="text-right">
<span className={`text-xl font-black font-mono ${isRefund ? 'text-amber-400' : 'text-white'}`}>
{isRefund ? "-" : ""}{Math.abs(finalInvoicePrice).toFixed(2)}
</span>
</div>
</div>
)}
{selectedPlan && (
<button
onClick={handleMigration}
disabled={loading || cancelLoading || isNoChange}
className={`w-full py-4 rounded-xl font-black uppercase text-xs tracking-widest transition-all duration-300 flex items-center justify-center gap-3
${isNoChange
? 'bg-gray-900 text-gray-500 border border-gray-800 cursor-not-allowed shadow-none'
: buttonColor
}`}
>
{loading ? <Loader className="w-4 h-4 animate-spin" /> : (!isNoChange && ActionIcon)}
{loading
? 'Traitement en cours...'
: isNoChange
? 'Abonnement actuel (Aucune modification)'
: migrationLabel
}
</button>
)}
<button
onClick={handleCancel}
disabled={loading || cancelLoading}
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 disabled:opacity-50 disabled:cursor-not-allowed"
>
{cancelLoading ? <Loader className="w-4 h-4 animate-spin" /> : <ShieldAlert className="w-4 h-4" />}
{cancelLoading ? 'Résiliation en cours...' : 'Résilier l\'abonnement réseau'}
</button>
</div>
</div>
);
}
+81 -13
View File
@@ -1,19 +1,55 @@
import React, { useState, useEffect } from 'react';
import { Server, Database, Cloud, Globe, ExternalLink, Play, Loader } from 'lucide-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 }) { 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 titleLower = (service.title || '').toLowerCase();
const isVPS = titleLower.includes('vps') || titleLower.includes('compute'); const isVPS = titleLower.includes('vps') || titleLower.includes('compute');
const isCloud = titleLower.includes('cloud') || titleLower.includes('nextcloud'); const isCloud = titleLower.includes('cloud') || titleLower.includes('nextcloud');
const isDB = titleLower.includes('db') || titleLower.includes('base') || titleLower.includes('sql'); const isDB = titleLower.includes('db') || titleLower.includes('base') || titleLower.includes('sql');
const isWeb = !isVPS && !isCloud && !isDB; 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 buttonText = "CONSOLE D'ADMINISTRATION";
let ButtonIcon = ExternalLink; let ButtonIcon = ExternalLink;
let buttonStyle = "bg-cyan-400/10 hover:bg-cyan-400 text-cyan-400 hover:text-gray-900 border-cyan-400"; let buttonStyle = "bg-cyan-400/10 hover:bg-cyan-400 text-cyan-400 hover:text-gray-900 border-cyan-400";
if (isVPS) { 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 { 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 (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"; }
@@ -26,12 +62,8 @@ export default function InstanceCard({ service, vpcs, onAssignVpc, onRemoveVpc,
return <Globe className="w-8 h-8 text-emerald-400" />; return <Globe className="w-8 h-8 text-emerald-400" />;
}; };
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 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 ( return (
<div className="bg-gray-900 border border-gray-800 rounded-xl p-5 flex flex-col justify-between hover:border-gray-700 transition-all shadow-lg"> <div className="bg-gray-900 border border-gray-800 rounded-xl p-5 flex flex-col justify-between hover:border-gray-700 transition-all shadow-lg">
@@ -40,27 +72,63 @@ export default function InstanceCard({ service, vpcs, onAssignVpc, onRemoveVpc,
<div className="p-2 bg-black/40 rounded-lg">{getServiceIcon()}</div> <div className="p-2 bg-black/40 rounded-lg">{getServiceIcon()}</div>
{service.status === 'active' ? ( {service.status === 'active' ? (
<span className="bg-emerald-500/10 text-emerald-400 px-2 py-1 rounded text-xs border border-emerald-500/20 font-bold tracking-widest"> <span className="bg-emerald-500/10 text-emerald-400 px-2 py-1 rounded text-xs border border-emerald-500/20 font-bold tracking-widest">
{isVPS && !hasIP ? 'AWAITING INIT' : 'ONLINE'} {isVPS && !displayIP ? 'AWAITING INIT' : 'ONLINE'}
</span> </span>
) : ( ) : (
<span className="bg-orange-500/10 text-orange-400 px-2 py-1 rounded text-xs border border-orange-500/20 animate-pulse">DEPLOYING</span> <span className="bg-orange-500/10 text-orange-400 px-2 py-1 rounded text-xs border border-orange-500/20 animate-pulse">DEPLOYING</span>
)} )}
</div> </div>
<h3 className="font-bold text-white text-lg truncate" title={baseTitle}>{shortTitle}</h3> <h3 className="font-bold text-white text-lg truncate" title={baseTitle}>{shortTitle}</h3>
<div className="flex flex-col gap-1 mt-2 mb-4 h-8 justify-center">
{displayDomain ? ( <p className={`${isVPS ? 'text-cyan-400' : 'text-emerald-400'} text-xs font-mono truncate`} title={displayDomain}>{displayDomain}</p> ) : ( <p className="text-gray-600 text-xs font-mono italic">En attente de déploiement</p> )} {/* Zone Domaine & IP */}
<div className="flex flex-col gap-1 mt-2 mb-4 h-12 justify-center">
{isLoadingDetails ? (
<div className="flex items-center text-gray-500 text-xs gap-2">
<Loader className="w-3 h-3 animate-spin" /> Chargement...
</div>
) : (
<>
{displayDomain ? (
<p className={`${isVPS ? 'text-cyan-400' : 'text-emerald-400'} text-xs font-mono truncate`} title={displayDomain}>
{displayDomain}
</p>
) : (
<p className="text-gray-600 text-xs font-mono italic">Aucun domaine</p>
)}
{displayIP && (
<p className="text-gray-500 text-[10px] font-mono mt-0.5" title={`IP: ${displayIP}`}>
IP: {displayIP}
</p>
)}
</>
)}
</div> </div>
</div> </div>
<div className="mt-auto space-y-3"> <div className="mt-auto space-y-3">
<div className="flex items-center justify-between text-sm border-t border-gray-800 pt-3"> <div className="flex items-center justify-between text-sm border-t border-gray-800 pt-3">
<span className="text-gray-500 text-xs">Projet VPC:</span> <span className="text-gray-500 text-xs">Projet VPC:</span>
<select onChange={(e) => { 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"}> <select
onChange={(e) => { 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"}
>
<option value="free">-- Libre --</option> <option value="free">-- Libre --</option>
{vpcs.map(vpc => ( <option key={vpc.id} value={vpc.id}>{vpc.name}</option> ))} {vpcs.map(vpc => ( <option key={vpc.id} value={vpc.id}>{vpc.name}</option> ))}
</select> </select>
</div> </div>
<button onClick={() => onOpenConsole(service)} disabled={isConnecting === service.id || service.status !== 'active'} className={`w-full flex items-center justify-center space-x-2 py-2.5 rounded-lg font-bold text-sm tracking-wide transition-all disabled:opacity-50 border ${buttonStyle}`}>
{isConnecting === service.id ? ( <><Loader className="w-4 h-4 animate-spin" /><span>CONNEXION...</span></> ) : ( <><ButtonIcon className="w-4 h-4" /><span>{buttonText}</span></> )} <button
onClick={() => onOpenConsole(service)}
disabled={isConnecting === service.id || service.status !== 'active'}
className={`w-full flex items-center justify-center space-x-2 py-2.5 rounded-lg font-bold text-sm tracking-wide transition-all disabled:opacity-50 border ${buttonStyle}`}
>
{isConnecting === service.id ? (
<><Loader className="w-4 h-4 animate-spin" /><span>CONNEXION...</span></>
) : (
<><ButtonIcon className="w-4 h-4" /><span>{buttonText}</span></>
)}
</button> </button>
</div> </div>
</div> </div>
+5 -5
View File
@@ -66,16 +66,16 @@ export default function ProductCard({ product, selectedPeriod, categoryName }) {
</div> </div>
)} )}
<div className="p-8 pt-14 border-b border-gray-800 relative bg-gradient-to-b from-gray-800/30 to-transparent min-h-[190px] flex flex-col"> <div className="p-8 pt-14 border-b border-gray-800 relative bg-linear-to-b from-gray-800/30 to-transparent min-h-47.5 flex flex-col">
<h3 className="text-2xl font-bold text-white mb-4 relative z-10">{product.title}</h3> <h3 className="text-2xl font-bold text-white mb-4 relative z-10">{product.title}</h3>
{priceData.isAvailable ? ( {priceData.isAvailable ? (
<div className="flex-grow flex flex-col justify-end"> <div className="grow flex flex-col justify-end">
<div className="flex items-baseline space-x-2"> <div className="flex items-baseline space-x-2">
<span className="text-4xl font-black text-cyan-400">{priceData.displayPrice} </span> <span className="text-4xl font-black text-cyan-400">{priceData.displayPrice} </span>
<span className="text-gray-500">{priceData.suffix}</span> <span className="text-gray-500">{priceData.suffix}</span>
</div> </div>
<div className="mt-3 min-h-[44px] flex flex-col justify-end"> <div className="mt-3 min-h-11 flex flex-col justify-end">
{!priceData.isOnce && ( {!priceData.isOnce && (
<> <>
{priceData.originalPrice ? ( {priceData.originalPrice ? (
@@ -96,9 +96,9 @@ export default function ProductCard({ product, selectedPeriod, categoryName }) {
)} )}
</div> </div>
<div className="p-8 flex-grow flex flex-col justify-between"> <div className="p-8 grow flex flex-col justify-between">
<div className="text-gray-400 text-sm mb-8 space-y-3 prose prose-invert max-w-none"> <div className="text-gray-400 text-sm mb-8 space-y-3 prose prose-invert max-w-none">
<ReactMarkdown components={{ ul: ({node, ...props}) => <ul className="space-y-2" {...props} />, li: ({node, ...props}) => <li className="flex items-start space-x-2"><CheckCircle2 className="w-4 h-4 text-cyan-400 mt-0.5 flex-shrink-0"/> <span>{props.children}</span></li>, p: ({node, ...props}) => <p className="mb-2 text-gray-300" {...props} />, strong: ({node, ...props}) => <strong className="text-white font-semibold" {...props} /> }}> <ReactMarkdown components={{ ul: ({node, ...props}) => <ul className="space-y-2" {...props} />, li: ({node, ...props}) => <li className="flex items-start space-x-2"><CheckCircle2 className="w-4 h-4 text-cyan-400 mt-0.5 shrink-0"/> <span>{props.children}</span></li>, p: ({node, ...props}) => <p className="mb-2 text-gray-300" {...props} />, strong: ({node, ...props}) => <strong className="text-white font-semibold" {...props} /> }}>
{product.description || "Aucune description technique."} {product.description || "Aucune description technique."}
</ReactMarkdown> </ReactMarkdown>
</div> </div>
+4 -2
View File
@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react'; 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 }) { export default function CreateTicketModal({ isOpen, onClose, onSubmit, helpdesks, defaultHelpdesk, isSubmitting }) {
const [subject, setSubject] = useState(''); const [subject, setSubject] = useState('');
@@ -30,7 +30,9 @@ export default function CreateTicketModal({ isOpen, onClose, onSubmit, helpdesks
<h3 className="text-xl font-bold text-white tracking-wider flex items-center gap-2"> <h3 className="text-xl font-bold text-white tracking-wider flex items-center gap-2">
<Plus className="w-5 h-5 text-cyan-400" /> OUVRIR UNE REQUÊTE <Plus className="w-5 h-5 text-cyan-400" /> OUVRIR UNE REQUÊTE
</h3> </h3>
<button onClick={onClose} className="text-gray-400 hover:text-white transition text-xl"></button> <button onClick={onClose} 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> </div>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
+4 -2
View File
@@ -1,5 +1,5 @@
import { useState, useEffect, useRef } from 'react'; 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 }) { export default function TicketThreadModal({ ticket, onClose, onReply, isLoadingConversation }) {
const [replyMessage, setReplyMessage] = useState(''); const [replyMessage, setReplyMessage] = useState('');
@@ -32,7 +32,9 @@ export default function TicketThreadModal({ ticket, onClose, onReply, isLoadingC
</div> </div>
<h3 className="text-xl font-bold text-white line-clamp-1">{ticket.subject}</h3> <h3 className="text-xl font-bold text-white line-clamp-1">{ticket.subject}</h3>
</div> </div>
<button onClick={onClose} className="text-gray-400 hover:text-white transition text-xl bg-black/50 p-2 rounded-lg"></button> <button onClick={onClose} 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> </div>
<div className="flex-1 overflow-y-auto p-4 sm:p-6 space-y-6 scrollbar-thin scrollbar-thumb-gray-800"> <div className="flex-1 overflow-y-auto p-4 sm:p-6 space-y-6 scrollbar-thin scrollbar-thumb-gray-800">
+5 -7
View File
@@ -1,6 +1,7 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { Outlet, NavLink, useNavigate } from 'react-router-dom'; import { Outlet, NavLink, useNavigate } from 'react-router-dom';
import { getClientTickets } from '../services/api'; import { getClientTickets } from '../services/api';
import { LayoutDashboard, Globe, Settings, FileText } from 'lucide-react';
import ConfirmLogoutModal from '../components/ui/ConfirmLogoutModal'; // Le modal extrait ! import ConfirmLogoutModal from '../components/ui/ConfirmLogoutModal'; // Le modal extrait !
export default function AppLayout() { 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; const unread = data.list.filter(t => t.status === 'on_hold' || t.unread).length;
setUnreadCount(unread); setUnreadCount(unread);
} }
} catch (err) {} } catch (err) { }
}; };
useEffect(() => { useEffect(() => {
@@ -31,8 +32,7 @@ export default function AppLayout() {
// 🌟 La fonction magique Tailwind pour les liens du menu // 🌟 La fonction magique Tailwind pour les liens du menu
const navLinkClass = ({ isActive }) => const navLinkClass = ({ isActive }) =>
`block px-6 py-4 no-underline border-l-4 transition-all uppercase tracking-widest text-sm font-mono ${ `block px-6 py-4 no-underline border-l-4 transition-all uppercase tracking-widest text-sm font-mono ${isActive
isActive
? 'text-cyan-400 bg-cyan-400/5 border-cyan-400' ? 'text-cyan-400 bg-cyan-400/5 border-cyan-400'
: 'text-[#888] border-transparent hover:text-gray-300 hover:bg-white/5' : 'text-[#888] border-transparent hover:text-gray-300 hover:bg-white/5'
}`; }`;
@@ -74,10 +74,8 @@ export default function AppLayout() {
</div> </div>
</NavLink> </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"> <NavLink className={navLinkClass} to="/facturation">Facturation</NavLink>
<span>Facturation</span>
<span className="text-[9px] text-[#333] border border-[#333] px-1.5 py-0.5 rounded font-bold">WIP</span>
</div>
<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"> <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>Profil & Sécurité</span>
<span className="text-[9px] text-[#333] border border-[#333] px-1.5 py-0.5 rounded font-bold">WIP</span> <span className="text-[9px] text-[#333] border border-[#333] px-1.5 py-0.5 rounded font-bold">WIP</span>
+338
View File
@@ -0,0 +1,338 @@
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 { getClientProfile, getInvoicesHistory } from '../../services/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 getClientProfile();
if (!profileData || !profileData.id) {
throw new Error("Impossible de vérifier votre identité ou session expirée.");
}
const invoicesData = await getInvoicesHistory(profileData.id);
setInvoices(invoicesData.data || []);
} 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.items
? selectedInvoice.items.filter(item => parseFloat(item.price) > 0)
: [];
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>
);
}
+66 -16
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, X } 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,9 +15,13 @@ 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 triggerAlert = (title, message, type = "info") => setCustomAlert({ title, message, type });
const fetchInventory = async () => { const fetchInventory = async () => {
setIsLoading(true);
try { try {
const data = await getClientOrders(); const data = await getClientOrders();
@@ -45,22 +51,46 @@ export default function Dashboard() {
} }
}; };
useEffect(() => {
fetchInventory(); fetchInventory();
}, []); }, []);
return ( // 🌟 Plus besoin de charger les IPs, on ouvre juste la modale comptable !
<div className="w-full max-w-6xl p-6 mx-auto"> 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 (
<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,44 @@ 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="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>
<SubscriptionManager
order={activeSubscriptionModal}
onClose={() => setActiveSubscriptionModal(null)}
onRefresh={fetchInventory}
onAlert={triggerAlert}
/>
</div>
</div>
)}
<NotificationModal notification={customAlert} onClose={() => setCustomAlert(null)} />
</div> </div>
); );
} }
+5 -3
View File
@@ -1,7 +1,7 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { getMyServices, getHostingServiceDetails } from '../../services/api'; import { getMyServices, getHostingServiceDetails } from '../../services/api';
import { useVPC } from '../../services/useVPC'; import { useVPC } from '../../services/useVPC';
import { Server, Globe, Folder, Trash2, Loader, Plus, AlertCircle } from 'lucide-react'; import { Server, Globe, Folder, Trash2, Loader, Plus, AlertCircle, X } from 'lucide-react';
// Importation de tes nouveaux composants modulaires ! // Importation de tes nouveaux composants modulaires !
import NotificationModal from '../../components/ui/NotificationModal'; import NotificationModal from '../../components/ui/NotificationModal';
@@ -30,7 +30,7 @@ export default function Services() {
const detailedServices = await Promise.all(data.list.map(async (order) => { const detailedServices = await Promise.all(data.list.map(async (order) => {
let hDetails = null; let hDetails = null;
if (order.status === 'active' && (order.plugin === 'hosting' || (order.title || '').toLowerCase().includes('vps'))) { if (order.status === 'active' && (order.plugin === 'hosting' || (order.title || '').toLowerCase().includes('vps'))) {
try { hDetails = await getHostingServiceDetails(order.id); } catch (e) {} try { hDetails = await getHostingServiceDetails(order.id); } catch (e) { }
} }
return { ...order, hostingDetails: hDetails }; return { ...order, hostingDetails: hDetails };
})); }));
@@ -132,7 +132,9 @@ export default function Services() {
<h3 className="text-xl font-bold text-white tracking-wider flex items-center gap-3"> <h3 className="text-xl font-bold text-white tracking-wider flex items-center gap-3">
{activeServiceModal.type === 'vps' ? <><Server className="w-6 h-6 text-cyan-400" /> GESTION VPS</> : <><Globe className="w-6 h-6 text-emerald-400" /> GESTION WEB</>} {activeServiceModal.type === 'vps' ? <><Server className="w-6 h-6 text-cyan-400" /> GESTION VPS</> : <><Globe className="w-6 h-6 text-emerald-400" /> GESTION WEB</>}
</h3> </h3>
<button onClick={() => setActiveServiceModal(null)} className="text-gray-400 hover:text-white transition text-xl"></button> <button onClick={() => setActiveServiceModal(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> </div>
{activeServiceModal.type === 'vps' ? ( {activeServiceModal.type === 'vps' ? (
activeServiceModal.details?.ip && activeServiceModal.details.ip !== '127.0.0.1' ? <VpsManager details={activeServiceModal.details} orderId={activeServiceModal.data.id} onRefresh={fetchServices} onAlert={triggerAlert} /> : <VpsDeployer serviceId={activeServiceModal.data.id} onDeploySuccess={() => { setActiveServiceModal(null); fetchServices(); }} onAlert={triggerAlert} /> activeServiceModal.details?.ip && activeServiceModal.details.ip !== '127.0.0.1' ? <VpsManager details={activeServiceModal.details} orderId={activeServiceModal.data.id} onRefresh={fetchServices} onAlert={triggerAlert} /> : <VpsDeployer serviceId={activeServiceModal.data.id} onDeploySuccess={() => { setActiveServiceModal(null); fetchServices(); }} onAlert={triggerAlert} />
+23
View File
@@ -221,3 +221,26 @@ export const replyTicket = (ticketId, message) =>
// Récupère la liste dynamique des départements (Helpdesks) configurés sur FOSSBilling // Récupère la liste dynamique des départements (Helpdesks) configurés sur FOSSBilling
export const getHelpdesks = () => export const getHelpdesks = () =>
apiCall(`${BASE_URL}/api/client/support/helpdesk_get_pairs`, 'GET'); 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 });
// Annule un abonnement (Gère automatiquement la règle des 14 jours côté serveur)
export const cancelSubscription = (clientId, orderId) =>
apiCall(`${CUSTOM_API_BASE_URL}/custom_api/nexus_subscription.php`, 'POST', {
action: 'cancel_order',
client_id: clientId,
order_id: orderId
});