Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 338dccf30b | |||
| 151aca69de | |||
| 9ad44d57b3 | |||
| eb48f506cf | |||
| ef8f4a96b4 |
@@ -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
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<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.">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -20,6 +20,7 @@ import Store from './pages/app/Store';
|
||||
import Checkout from './pages/app/Checkout';
|
||||
import Services from './pages/app/Services';
|
||||
import Support from './pages/app/Support';
|
||||
import BillingHistory from './pages/app/BillingHistory';
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
@@ -49,6 +50,7 @@ export default function App() {
|
||||
<Route path="/checkout/:productId" element={<Checkout />} />
|
||||
<Route path="/services" element={<Services />} />
|
||||
<Route path="/support" element={<Support />} />
|
||||
<Route path="/facturation" element={<BillingHistory />} />
|
||||
</Route>
|
||||
|
||||
</Route>
|
||||
|
||||
@@ -4,7 +4,7 @@ export default function NewServiceCard({ onClick }) {
|
||||
return (
|
||||
<div
|
||||
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">
|
||||
<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
|
||||
</h3>
|
||||
<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>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -1,19 +1,55 @@
|
||||
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"; }
|
||||
@@ -26,12 +62,8 @@ export default function InstanceCard({ service, vpcs, onAssignVpc, onRemoveVpc,
|
||||
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 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 (
|
||||
<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>
|
||||
{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">
|
||||
{isVPS && !hasIP ? 'AWAITING INIT' : 'ONLINE'}
|
||||
{isVPS && !displayIP ? 'AWAITING INIT' : 'ONLINE'}
|
||||
</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>
|
||||
|
||||
<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 className="mt-auto space-y-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>
|
||||
<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>
|
||||
{vpcs.map(vpc => ( <option key={vpc.id} value={vpc.id}>{vpc.name}</option> ))}
|
||||
</select>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -66,16 +66,16 @@ export default function ProductCard({ product, selectedPeriod, categoryName }) {
|
||||
</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>
|
||||
|
||||
{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">
|
||||
<span className="text-4xl font-black text-cyan-400">{priceData.displayPrice} €</span>
|
||||
<span className="text-gray-500">{priceData.suffix}</span>
|
||||
</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.originalPrice ? (
|
||||
@@ -96,9 +96,9 @@ export default function ProductCard({ product, selectedPeriod, categoryName }) {
|
||||
)}
|
||||
</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">
|
||||
<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."}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
|
||||
@@ -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
|
||||
<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
|
||||
</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>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
|
||||
@@ -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
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-white line-clamp-1">{ticket.subject}</h3>
|
||||
</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 className="flex-1 overflow-y-auto p-4 sm:p-6 space-y-6 scrollbar-thin scrollbar-thumb-gray-800">
|
||||
|
||||
@@ -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() {
|
||||
@@ -31,8 +32,7 @@ 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
|
||||
`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'
|
||||
}`;
|
||||
@@ -74,10 +74,8 @@ export default function AppLayout() {
|
||||
</div>
|
||||
</NavLink>
|
||||
|
||||
<div className="flex justify-between items-center px-6 py-4 text-[#444] border-l-4 border-transparent uppercase tracking-widest text-sm font-mono cursor-not-allowed select-none">
|
||||
<span>Facturation</span>
|
||||
<span className="text-[9px] text-[#333] border border-[#333] px-1.5 py-0.5 rounded font-bold">WIP</span>
|
||||
</div>
|
||||
<NavLink className={navLinkClass} to="/facturation">Facturation</NavLink>
|
||||
|
||||
<div className="flex justify-between items-center px-6 py-4 text-[#444] border-l-4 border-transparent uppercase tracking-widest text-sm font-mono cursor-not-allowed select-none">
|
||||
<span>Profil & Sécurité</span>
|
||||
<span className="text-[9px] text-[#333] border border-[#333] px-1.5 py-0.5 rounded font-bold">WIP</span>
|
||||
|
||||
@@ -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
@@ -1,11 +1,13 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
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 NewServiceCard from '../../components/dashboard/NewServiceCard';
|
||||
import NotificationModal from '../../components/ui/NotificationModal';
|
||||
import SubscriptionManager from '../../components/dashboard/SubscriptionManager'; // 🌟 Le nouveau composant !
|
||||
|
||||
export default function Dashboard() {
|
||||
const navigate = useNavigate();
|
||||
@@ -13,9 +15,13 @@ export default function Dashboard() {
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
// Chargement des données à l'ouverture du Sas
|
||||
useEffect(() => {
|
||||
const [activeSubscriptionModal, setActiveSubscriptionModal] = useState(null);
|
||||
const [customAlert, setCustomAlert] = useState(null);
|
||||
|
||||
const triggerAlert = (title, message, type = "info") => setCustomAlert({ title, message, type });
|
||||
|
||||
const fetchInventory = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const data = await getClientOrders();
|
||||
|
||||
@@ -45,22 +51,46 @@ export default function Dashboard() {
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchInventory();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-6xl p-6 mx-auto">
|
||||
// 🌟 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 (
|
||||
<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">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>
|
||||
|
||||
{/* GESTION DES ERREURS & CHARGEMENT */}
|
||||
{isLoading && (
|
||||
<div className="flex items-center space-x-3 text-cyan-400">
|
||||
<Loader className="w-6 h-6 animate-spin" />
|
||||
<span>Synchronisation avec l'orchestrateur en cours...</span>
|
||||
<span>Synchronisation avec le registre comptable...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -71,24 +101,44 @@ export default function Dashboard() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* GRILLE DES SERVICES */}
|
||||
{!isLoading && !error && (
|
||||
<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) => (
|
||||
<DashboardServiceCard
|
||||
key={order.id}
|
||||
order={order}
|
||||
onClick={() => navigate(`/services/${order.id}`)}
|
||||
onClick={() => handleManageSubscription(order)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Le composant carte d'ajout */}
|
||||
<NewServiceCard onClick={() => navigate('/store')} />
|
||||
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { getMyServices, getHostingServiceDetails } from '../../services/api';
|
||||
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 !
|
||||
import NotificationModal from '../../components/ui/NotificationModal';
|
||||
@@ -132,7 +132,9 @@ export default function Services() {
|
||||
<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</>}
|
||||
</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>
|
||||
{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} />
|
||||
|
||||
@@ -221,3 +221,26 @@ 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');
|
||||
|
||||
// ==========================================
|
||||
// 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
|
||||
});
|
||||
Reference in New Issue
Block a user