Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 338dccf30b | |||
| 151aca69de | |||
| 9ad44d57b3 | |||
| eb48f506cf | |||
| ef8f4a96b4 | |||
| c29047e6fa | |||
| 1872a3c2fa | |||
| 2aac1b0775 | |||
| d83b7ad4e7 | |||
| 323667e835 |
+25
@@ -0,0 +1,25 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
*api-keys*
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { Server, Database, Cloud, Globe } from 'lucide-react';
|
||||
|
||||
export default function DashboardServiceCard({ order, onClick }) {
|
||||
// Fonction Radar : Détecte le type de service selon son nom pour afficher la bonne icône
|
||||
const getServiceIcon = (title) => {
|
||||
const t = title.toLowerCase();
|
||||
if (t.includes('vps') || t.includes('serveur')) return <Server className="w-10 h-10 text-cyan-400" />;
|
||||
if (t.includes('cloud') || t.includes('nextcloud')) return <Cloud className="w-10 h-10 text-blue-400" />;
|
||||
if (t.includes('db') || t.includes('base') || t.includes('sql')) return <Database className="w-10 h-10 text-purple-400" />;
|
||||
return <Globe className="w-10 h-10 text-emerald-400" />; // Par défaut : Web / Hestia
|
||||
};
|
||||
|
||||
// Fonction d'état : Formate le statut du service
|
||||
const getStatusBadge = (status) => {
|
||||
switch (status) {
|
||||
case 'active': return <span className="px-2 py-1 text-xs text-green-400 bg-green-400/10 border border-green-400/20 rounded">ACTIF</span>;
|
||||
case 'pending_setup': return <span className="px-2 py-1 text-xs text-orange-400 bg-orange-400/10 border border-orange-400/20 rounded">EN PRÉPARATION</span>;
|
||||
case 'suspended': return <span className="px-2 py-1 text-xs text-red-400 bg-red-400/10 border border-red-400/20 rounded">SUSPENDU</span>;
|
||||
default: return <span className="px-2 py-1 text-xs text-gray-400 bg-gray-400/10 border border-gray-400/20 rounded">{status.toUpperCase()}</span>;
|
||||
}
|
||||
};
|
||||
|
||||
// Fonction utilitaire pour traduire les cycles de FOSSBilling
|
||||
const formatPeriod = (periodCode) => {
|
||||
const periods = {
|
||||
'1W': 'Hebdomadaire',
|
||||
'1M': 'Mensuel',
|
||||
'3M': 'Trimestriel',
|
||||
'6M': 'Semestriel',
|
||||
'1Y': 'Annuel',
|
||||
'2Y': 'Tous les 2 ans',
|
||||
'3Y': 'Tous les 3 ans'
|
||||
};
|
||||
// Si le code est inconnu, on affiche le code brut par sécurité, sinon on traduit
|
||||
return periods[periodCode] || periodCode;
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="bg-gray-900 border border-gray-800 p-6 rounded-xl hover:border-cyan-400/50 transition-colors group cursor-pointer flex flex-col justify-between shadow-lg"
|
||||
onClick={onClick}
|
||||
>
|
||||
<div>
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<div className="p-3 bg-black/50 rounded-lg group-hover:scale-110 transition-transform">
|
||||
{getServiceIcon(order.title)}
|
||||
</div>
|
||||
{getStatusBadge(order.status)}
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-white truncate" title={order.title}>
|
||||
{order.title}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Facturation : <span className="text-cyan-400 font-medium">{formatPeriod(order.period)}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 pt-4 border-t border-gray-800 flex justify-between items-center text-sm">
|
||||
<span className="text-gray-400">ID Réseau: #{order.id}</span>
|
||||
<span className="text-cyan-400 group-hover:underline">Gérer ></span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Plus } from 'lucide-react';
|
||||
|
||||
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-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" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-white group-hover:text-cyan-400">
|
||||
Demander une accréditation
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 mt-2">
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
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;
|
||||
|
||||
// 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 (displayIP) { buttonText = "GÉRER L'INSTANCE"; ButtonIcon = ExternalLink; }
|
||||
else { buttonText = "INITIALISATION"; ButtonIcon = Play; buttonStyle = "bg-yellow-500/10 text-yellow-500 hover:bg-yellow-500 hover:text-gray-900 border-yellow-500/50"; }
|
||||
} else if (isWeb) { buttonText = "GÉRER L'HÉBERGEMENT"; ButtonIcon = Globe; buttonStyle = "bg-emerald-400/10 hover:bg-emerald-400 text-emerald-400 hover:text-gray-900 border-emerald-400"; }
|
||||
else if (isCloud) { buttonText = "ACCÉDER AU CLOUD"; buttonStyle = "bg-blue-400/10 hover:bg-blue-400 text-blue-400 hover:text-gray-900 border-blue-400"; }
|
||||
else if (isDB) { buttonText = "PHPMYADMIN / CLUSTER"; ButtonIcon = Database; buttonStyle = "bg-purple-400/10 hover:bg-purple-400 text-purple-400 hover:text-gray-900 border-purple-400"; }
|
||||
|
||||
const getServiceIcon = () => {
|
||||
if (isVPS) return <Server className="w-8 h-8 text-cyan-400" />;
|
||||
if (isCloud) return <Cloud className="w-8 h-8 text-blue-400" />;
|
||||
if (isDB) return <Database className="w-8 h-8 text-purple-400" />;
|
||||
return <Globe className="w-8 h-8 text-emerald-400" />;
|
||||
};
|
||||
|
||||
const baseTitle = rawTitle.split(/(?: for | pour )/i)[0].trim();
|
||||
const shortTitle = baseTitle.split(' ').slice(0, 2).join(' ');
|
||||
|
||||
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>
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<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 && !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>
|
||||
|
||||
{/* 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"}
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
export default function VpsDeployer({ serviceId, onDeploySuccess, onAlert }) {
|
||||
const [domain, setDomain] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [isDeploying, setIsDeploying] = useState(false);
|
||||
|
||||
const handleDeploy = async () => {
|
||||
if (password.length < 8) { onAlert("Sécurité", "Le mot de passe doit faire au moins 8 caractères.", "error"); return; }
|
||||
setIsDeploying(true);
|
||||
try {
|
||||
const response = await fetch('https://web.gise.be/custom_api/proxmox_create_vps.php', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ service_id: serviceId, domain: domain, password: password }) });
|
||||
const data = await response.json();
|
||||
if (data.status === 'success') { onDeploySuccess({ ip: data.ip, domain: data.domain, password: password }); }
|
||||
else { onAlert("Échec", "Erreur de provisionnement : " + (data.message || 'Inconnue'), "error"); }
|
||||
} catch (err) { onAlert("Erreur Réseau", "Erreur de communication avec l'API Proxmox Gateway.", "error"); }
|
||||
finally { setIsDeploying(false); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="bg-yellow-900/20 border border-yellow-600/30 p-4 rounded mb-6 text-yellow-500/80 text-sm">
|
||||
<span className="font-bold text-yellow-500">PHASE D'INITIALISATION REQUISE</span><br />
|
||||
La facturation est activée. Veuillez définir un mot de passe Root pour lancer la création de l'instance.
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs uppercase tracking-widest text-cyan-500 mb-1">Hostname personnalisé</label>
|
||||
<input type="text" value={domain} onChange={(e) => setDomain(e.target.value.toLowerCase())} placeholder="Optionnel" className="w-full bg-gray-950 border border-gray-800 text-white p-3 rounded font-mono outline-none focus:border-cyan-500 transition-colors" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs uppercase tracking-widest text-cyan-500 mb-1">Mot de passe Root</label>
|
||||
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} placeholder="••••••••" className="w-full bg-gray-950 border border-gray-800 text-white p-3 rounded font-mono outline-none focus:border-cyan-500 transition-colors" />
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={handleDeploy} disabled={isDeploying} className={`mt-6 w-full px-6 py-3 rounded font-bold font-mono tracking-widest uppercase transition ${isDeploying ? 'bg-gray-800 text-gray-500 cursor-not-allowed' : 'bg-cyan-600 hover:bg-cyan-500 text-white shadow-lg shadow-cyan-500/20'}`}>
|
||||
{isDeploying ? 'Fabrication sur le Métal...' : 'Lancer l\'Instanciation'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { useState } from 'react';
|
||||
import { Folder, ExternalLink, Play, Maximize2, Lock, Copy, Check } from 'lucide-react';
|
||||
|
||||
export default function VpsManager({ details, orderId, onRefresh, onAlert }) {
|
||||
const [showTerminal, setShowTerminal] = useState(false);
|
||||
const [isEditingDomain, setIsEditingDomain] = useState(false);
|
||||
const [newDomain, setNewDomain] = useState(details.domain);
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
const [sslStatus, setSslStatus] = useState(null);
|
||||
const [copiedField, setCopiedField] = useState(null);
|
||||
|
||||
const isInternal = details.domain && details.domain.endsWith('.gise.be');
|
||||
const fileManagerUrl = isInternal ? `https://file-${details.domain}` : `http://${details.domain}:8080`;
|
||||
const terminalUrl = isInternal ? `https://terminal-${details.domain}` : `http://${details.domain}:8081`;
|
||||
|
||||
const handleCopy = (text, field) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
setCopiedField(field);
|
||||
setTimeout(() => setCopiedField(null), 2000);
|
||||
};
|
||||
|
||||
const handleUpdateDomain = async () => {
|
||||
if (!newDomain || newDomain === details.domain) return;
|
||||
setIsUpdating(true);
|
||||
try {
|
||||
const response = await fetch('https://web.gise.be/custom_api/update_service_domain.php', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ order_id: orderId, new_domain: newDomain }) });
|
||||
const data = await response.json();
|
||||
if (data.status === 'success') { setIsEditingDomain(false); if (onRefresh) onRefresh(); onAlert("Succès", "Domaine VPS mis à jour !", "success"); }
|
||||
else onAlert("Erreur", data.error, "error");
|
||||
} catch (err) { onAlert("Erreur", "Erreur de connexion avec l'API.", "error"); } finally { setIsUpdating(false); }
|
||||
};
|
||||
|
||||
const handleGenerateSSL = async () => {
|
||||
setSslStatus('loading');
|
||||
try {
|
||||
const response = await fetch('https://web.gise.be/custom_api/generate_custom_ssl.php', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ domain: details.domain }) });
|
||||
const data = await response.json();
|
||||
if (data.status === 'success') { setSslStatus('success'); onAlert("SSL Activé", "Certificat SSL généré !", "success"); }
|
||||
else { setSslStatus('error'); onAlert("Erreur DNS", "Vérifiez votre pointage.", "error"); }
|
||||
} catch (e) { setSslStatus('error'); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{!showTerminal ? (
|
||||
<>
|
||||
<div className="mb-6 bg-gray-950 border border-gray-800 rounded p-4">
|
||||
<label className="block text-xs uppercase tracking-widest text-cyan-500 mb-2">Domaine de l'instance VPS</label>
|
||||
{!isEditingDomain ? (
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="font-mono text-xl text-green-400 font-bold flex items-center gap-2">
|
||||
<span className="h-3 w-3 rounded-full bg-green-500 shadow-[0_0_10px_#22c55e]"></span>{details.domain}
|
||||
</span>
|
||||
<button onClick={() => setIsEditingDomain(true)} className="text-xs border border-gray-700 hover:border-cyan-500 text-gray-400 hover:text-cyan-400 px-3 py-1.5 rounded transition">MODIFIER</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex gap-2">
|
||||
<input type="text" value={newDomain} onChange={(e) => setNewDomain(e.target.value.toLowerCase())} className="flex-1 bg-black border border-gray-700 rounded px-3 py-1.5 text-white font-mono text-sm focus:border-cyan-500 outline-none" disabled={isUpdating} />
|
||||
<button onClick={handleUpdateDomain} disabled={isUpdating} className="bg-cyan-500 text-gray-900 px-4 py-1.5 rounded text-xs font-bold hover:bg-cyan-400">{isUpdating ? 'SYNC...' : 'SAUVEGARDER'}</button>
|
||||
<button onClick={() => setIsEditingDomain(false)} className="bg-gray-800 text-white px-3 py-1.5 rounded text-xs hover:bg-gray-700">ANNULER</button>
|
||||
</div>
|
||||
)}
|
||||
{!isInternal && (
|
||||
<div className="mt-4 border-t border-gray-900 pt-3 flex justify-between items-center">
|
||||
<p className="text-[11px] text-gray-500">Domaine externe détecté.</p>
|
||||
<button onClick={handleGenerateSSL} disabled={sslStatus === 'loading'} className="text-xs bg-purple-600/20 hover:bg-purple-600 text-purple-400 hover:text-white border border-purple-500/30 px-3 py-1 rounded transition flex items-center gap-1">
|
||||
{sslStatus === 'loading' ? 'GÉNÉRATION...' : <><Lock className="w-3 h-3" /> GÉNÉRER SSL</>}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ENCART DES IDENTIFIANTS */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
|
||||
<div className="bg-black/60 border border-gray-800 rounded p-3">
|
||||
<span className="text-gray-500 font-bold uppercase tracking-wider mb-2 text-[10px] flex items-center gap-1">
|
||||
<Folder className="w-3 h-3" /> Accès Explorateur
|
||||
</span>
|
||||
<div className="flex justify-between items-center mb-1">
|
||||
<span className="text-xs font-mono text-gray-400">User: <strong className="text-cyan-400">root</strong></span>
|
||||
<button onClick={() => handleCopy('root', 'fileUser')} className="text-gray-500 hover:text-cyan-400 p-1">
|
||||
{copiedField === 'fileUser' ? <Check className="w-3 h-3 text-cyan-400" /> : <Copy className="w-3 h-3" />}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-xs font-mono text-gray-400">Pass: <strong className="text-cyan-400">NexusGise2026!</strong></span>
|
||||
<button onClick={() => handleCopy('NexusGise2026!', 'filePass')} className="text-gray-500 hover:text-cyan-400 p-1">
|
||||
{copiedField === 'filePass' ? <Check className="w-3 h-3 text-cyan-400" /> : <Copy className="w-3 h-3" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-black/60 border border-gray-800 rounded p-3">
|
||||
<span className="text-gray-500 font-bold uppercase tracking-wider mb-2 text-[10px] flex items-center gap-1">
|
||||
<Play className="w-3 h-3" /> Accès Console / SSH
|
||||
</span>
|
||||
<div className="flex justify-between items-center mb-1">
|
||||
<span className="text-xs font-mono text-gray-400">User: <strong className="text-cyan-400">root</strong></span>
|
||||
<button onClick={() => handleCopy('root', 'sshUser')} className="text-gray-500 hover:text-cyan-400 p-1">
|
||||
{copiedField === 'sshUser' ? <Check className="w-3 h-3 text-cyan-400" /> : <Copy className="w-3 h-3" />}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-xs font-mono text-gray-400">Pass: <span className="text-gray-600 italic">Défini à l'installation</span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* BOUTONS D'ACTIONS */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-2">
|
||||
<button onClick={() => window.open(fileManagerUrl, '_blank')} className="bg-gray-950 hover:bg-gray-900 border border-gray-800 hover:border-cyan-500/50 rounded p-3 flex justify-between items-center transition group">
|
||||
<span className="font-mono text-gray-300 text-sm flex items-center gap-2"><Folder className="w-4 h-4 text-cyan-500" /> Explorateur</span><ExternalLink className="w-4 h-4 text-gray-600 group-hover:text-cyan-400" />
|
||||
</button>
|
||||
<button onClick={() => setShowTerminal(true)} className="bg-cyan-900/20 hover:bg-cyan-900/40 border border-cyan-800/50 hover:border-cyan-500 rounded p-3 flex justify-between items-center transition group">
|
||||
<span className="font-mono text-cyan-400 text-sm flex items-center gap-2"><span>{">_"}</span> Terminal</span><Play className="w-4 h-4 text-cyan-600 group-hover:text-cyan-400" />
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-4 border-b border-gray-800 pb-2">
|
||||
<span className="text-cyan-400 font-mono text-sm tracking-widest">TERMINAL ({details.domain})</span>
|
||||
<div className="flex items-center space-x-2">
|
||||
<button onClick={() => { window.open(terminalUrl, '_blank'); setShowTerminal(false); }} className="text-xs bg-cyan-600/30 text-cyan-400 hover:bg-cyan-600 hover:text-white border border-cyan-500/30 px-3 py-1 rounded transition flex items-center gap-1"><Maximize2 className="w-3 h-3" /> Plein Écran ↗</button>
|
||||
<button onClick={() => setShowTerminal(false)} className="text-xs bg-red-900/30 text-white px-3 py-1 rounded">Fermer</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-black border border-gray-800 rounded h-100">
|
||||
<iframe src={terminalUrl} className="w-full h-full border-none" title="Terminal" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { useState } from 'react';
|
||||
import { Globe, ExternalLink, Lock, Loader, Copy, Check } from 'lucide-react';
|
||||
import { resetHostingPassword } from '../../services/api';
|
||||
|
||||
export default function WebManager({ details, orderId, onRefresh, onAlert }) {
|
||||
const [isEditingDomain, setIsEditingDomain] = useState(false);
|
||||
const [newDomain, setNewDomain] = useState(details.domain);
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
const [sslStatus, setSslStatus] = useState(null);
|
||||
|
||||
// Nouveaux états pour gérer les accès localement
|
||||
const [isGeneratingSso, setIsGeneratingSso] = useState(false);
|
||||
const [ssoData, setSsoData] = useState(null);
|
||||
const [copiedField, setCopiedField] = useState(null);
|
||||
|
||||
const isCustomDomain = details.domain && !details.domain.endsWith('.gise.be');
|
||||
const siteUrl = (isCustomDomain && sslStatus !== 'success') ? `http://${details.domain}` : `https://${details.domain}`;
|
||||
|
||||
// Fonction utilitaire pour copier
|
||||
const handleCopy = (text, field) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
setCopiedField(field);
|
||||
setTimeout(() => setCopiedField(null), 2000);
|
||||
};
|
||||
|
||||
const handleUpdateDomain = async () => {
|
||||
if (!newDomain || newDomain === details.domain) return;
|
||||
setIsUpdating(true);
|
||||
try {
|
||||
const response = await fetch('https://web.gise.be/custom_api/update_service_domain.php', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ order_id: orderId, new_domain: newDomain }) });
|
||||
const data = await response.json();
|
||||
if (data.status === 'success') { setIsEditingDomain(false); if (onRefresh) onRefresh(); onAlert("Succès", "Domaine mis à jour !", "success"); }
|
||||
else onAlert("Erreur", data.error, "error");
|
||||
} catch (err) { onAlert("Erreur", "Erreur API.", "error"); } finally { setIsUpdating(false); }
|
||||
};
|
||||
|
||||
const handleGenerateSSL = async () => {
|
||||
setSslStatus('loading');
|
||||
try {
|
||||
const response = await fetch('https://web.gise.be/custom_api/generate_custom_ssl.php', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ domain: details.domain }) });
|
||||
const data = await response.json();
|
||||
if (data.status === 'success') { setSslStatus('success'); onAlert("SSL Validé", "Certificat SSL généré !", "success"); }
|
||||
else { setSslStatus('error'); onAlert("Erreur DNS", "Vérifiez vos DNS.", "error"); }
|
||||
} catch (e) { setSslStatus('error'); }
|
||||
};
|
||||
|
||||
const handleOpenPanel = async () => {
|
||||
setIsGeneratingSso(true);
|
||||
try {
|
||||
const secureHash = Math.random().toString(36).slice(-8) + Math.random().toString(36).slice(-4).toUpperCase();
|
||||
const rollingPassword = `Nx${secureHash}`;
|
||||
await resetHostingPassword(orderId, rollingPassword);
|
||||
setSsoData({ username: details.username, password: rollingPassword, url: 'https://panel.gise.be/login/' });
|
||||
} catch (err) {
|
||||
onAlert("Erreur Métal", "Erreur lors de la génération du SSO HestiaCP.", "error");
|
||||
} finally {
|
||||
setIsGeneratingSso(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6 bg-gray-950 border border-gray-800 rounded p-4">
|
||||
<label className="block text-xs uppercase tracking-widest text-emerald-500 mb-2">Domaine du Site Web</label>
|
||||
{!isEditingDomain ? (
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="font-mono text-xl text-emerald-400 font-bold flex items-center gap-2">
|
||||
<span className="h-3 w-3 rounded-full bg-emerald-500 shadow-[0_0_10px_#10b981]"></span>{details.domain}
|
||||
</span>
|
||||
<button onClick={() => setIsEditingDomain(true)} className="text-xs border border-gray-700 hover:border-emerald-500 text-gray-400 hover:text-emerald-400 px-3 py-1.5 rounded transition">MODIFIER</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex gap-2">
|
||||
<input type="text" value={newDomain} onChange={(e) => setNewDomain(e.target.value.toLowerCase())} className="flex-1 bg-black border border-gray-700 rounded px-3 py-1.5 text-white font-mono text-sm focus:border-emerald-500 outline-none" disabled={isUpdating} />
|
||||
<button onClick={handleUpdateDomain} disabled={isUpdating} className="bg-emerald-500 text-gray-900 px-4 py-1.5 rounded text-xs font-bold hover:bg-emerald-400">{isUpdating ? 'SYNC...' : 'SAUVEGARDER'}</button>
|
||||
<button onClick={() => setIsEditingDomain(false)} className="bg-gray-800 text-white px-3 py-1.5 rounded text-xs hover:bg-gray-700">ANNULER</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isCustomDomain && (
|
||||
<div className="mt-4 border-t border-gray-900 pt-3 flex justify-between items-center">
|
||||
<p className="text-[11px] text-gray-500">Domaine personnalisé détecté.</p>
|
||||
<button onClick={handleGenerateSSL} disabled={sslStatus === 'loading'} className="text-xs bg-purple-600/20 hover:bg-purple-600 text-purple-400 hover:text-white border border-purple-500/30 px-3 py-1 rounded transition flex items-center gap-1">
|
||||
{sslStatus === 'loading' ? 'GÉNÉRATION...' : <><Lock className="w-3 h-3" /> GÉNÉRER SSL</>}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 mb-6">
|
||||
<button onClick={() => window.open(siteUrl, '_blank')} className="bg-gray-950 hover:bg-gray-900 border border-gray-800 hover:border-emerald-500/50 rounded p-3 flex justify-between items-center transition group">
|
||||
<span className="font-mono text-gray-300 text-sm flex items-center gap-2"><Globe className="w-4 h-4 text-emerald-500" /> Ouvrir le Site</span><ExternalLink className="w-4 h-4 text-gray-600 group-hover:text-emerald-400" />
|
||||
</button>
|
||||
|
||||
{/* ZONE DES ACCÈS PANEL */}
|
||||
{!ssoData ? (
|
||||
<button onClick={handleOpenPanel} disabled={isGeneratingSso} className="bg-emerald-900/20 hover:bg-emerald-900/40 border border-emerald-800/50 hover:border-emerald-500 rounded p-3 flex justify-between items-center transition group disabled:opacity-50">
|
||||
{isGeneratingSso ? (
|
||||
<span className="font-mono text-emerald-400 text-sm flex items-center gap-2"><Loader className="w-4 h-4 animate-spin" /> GÉNÉRATION DU MOT DE PASSE...</span>
|
||||
) : (
|
||||
<><span className="font-mono text-emerald-400 text-sm flex items-center gap-2"><Lock className="w-4 h-4" /> Obtenir les accès Panel</span><ExternalLink className="w-4 h-4 text-emerald-600 group-hover:text-emerald-400" /></>
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<div className="bg-emerald-900/10 border border-emerald-500/30 rounded p-4">
|
||||
<p className="text-[11px] text-emerald-400/80 uppercase tracking-widest mb-3 flex items-center gap-2">
|
||||
<Lock className="w-3 h-3" /> Accès Panel Généré
|
||||
</p>
|
||||
|
||||
<div className="space-y-2 mb-4">
|
||||
<div className="flex justify-between items-center bg-black/50 border border-gray-800 rounded p-2">
|
||||
<span className="text-gray-400 text-xs font-mono">User: <strong className="text-white ml-1">{ssoData.username}</strong></span>
|
||||
<button onClick={() => handleCopy(ssoData.username, 'webUser')} className="text-emerald-400 hover:text-emerald-300 transition-colors p-1">
|
||||
{copiedField === 'webUser' ? <Check className="w-4 h-4 text-emerald-500" /> : <Copy className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center bg-black/50 border border-gray-800 rounded p-2">
|
||||
<span className="text-gray-400 text-xs font-mono">Pass: <strong className="text-emerald-400 ml-1">{ssoData.password}</strong></span>
|
||||
<button onClick={() => handleCopy(ssoData.password, 'webPass')} className="text-emerald-400 hover:text-emerald-300 transition-colors p-1">
|
||||
{copiedField === 'webPass' ? <Check className="w-4 h-4 text-emerald-500" /> : <Copy className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button onClick={() => window.open(ssoData.url, '_blank')} className="w-full bg-emerald-500 hover:bg-emerald-400 text-black font-bold font-mono tracking-widest uppercase py-2.5 rounded transition shadow-[0_0_15px_rgba(16,185,129,0.2)]">
|
||||
Ouvrir le Panel
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
// src/components/store/CategorySection.jsx
|
||||
import { useState } from 'react';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
import ProductCard from './ProductCard';
|
||||
import { parsePeriod } from './StoreHelpers';
|
||||
|
||||
export default function CategorySection({ categoryName, products, getCategoryIcon }) {
|
||||
const availablePeriods = new Set();
|
||||
products.forEach(p => {
|
||||
if (p.pricing?.type === 'recurrent' && p.pricing.recurrent) {
|
||||
Object.keys(p.pricing.recurrent).forEach(period => {
|
||||
const periodData = p.pricing.recurrent[period];
|
||||
if (periodData.enabled == 1 || periodData.enabled === true) availablePeriods.add(period);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const sortedPeriods = Array.from(availablePeriods).sort((a, b) => parsePeriod(a).weight - parsePeriod(b).weight);
|
||||
const defaultPeriod = sortedPeriods.includes('1M') ? '1M' : sortedPeriods[0];
|
||||
const [sectionPeriod, setSectionPeriod] = useState(defaultPeriod);
|
||||
|
||||
return (
|
||||
<section className="mb-16 bg-black/20 p-6 rounded-3xl border border-gray-800/50">
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between mb-8 pb-6 border-b border-gray-800 space-y-4 md:space-y-0">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="p-3 bg-gray-900 rounded-xl border border-gray-800 shadow-[0_0_15px_rgba(0,0,0,0.5)]">
|
||||
{getCategoryIcon(categoryName)}
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-3xl font-black text-white tracking-wider">{categoryName}</h2>
|
||||
<div className="text-gray-500 text-sm mt-1">{products.length} instance{products.length > 1 ? 's' : ''} disponible{products.length > 1 ? 's' : ''}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{sortedPeriods.length > 0 && (
|
||||
<div className="flex items-center space-x-3 bg-gray-900 p-2 rounded-xl border border-gray-800">
|
||||
<span className="text-sm font-medium text-gray-400 pl-2">Facturation :</span>
|
||||
<div className="relative">
|
||||
<select value={sectionPeriod} onChange={(e) => setSectionPeriod(e.target.value)} className="appearance-none bg-black border border-gray-700 text-cyan-400 font-bold py-2 pl-4 pr-10 rounded-lg outline-none focus:border-cyan-400 transition-colors cursor-pointer hover:bg-gray-950">
|
||||
{sortedPeriods.map(p => ( <option key={p} value={p}>{parsePeriod(p).label}</option> ))}
|
||||
</select>
|
||||
<ChevronDown className="absolute right-3 top-2.5 w-5 h-5 text-cyan-400 pointer-events-none" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
{products.map((product) => (
|
||||
<ProductCard key={product.id} product={product} selectedPeriod={sectionPeriod} categoryName={categoryName} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
// src/components/store/ProductCard.jsx
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import { ShoppingCart, CheckCircle2 } from 'lucide-react';
|
||||
import { parsePeriod, getCategoryBadge } from './StoreHelpers';
|
||||
|
||||
export default function ProductCard({ product, selectedPeriod, categoryName }) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const getPricingData = () => {
|
||||
if (product.pricing?.type !== 'recurrent' || !product.pricing?.recurrent) {
|
||||
const oncePrice = product.pricing?.once?.price ? parseFloat(product.pricing.once.price).toFixed(2) : '0.00';
|
||||
return { isAvailable: true, displayPrice: oncePrice, suffix: '(Une fois)', originalPrice: null, savingsPercent: 0, isOnce: true };
|
||||
}
|
||||
|
||||
const recurrentPrices = product.pricing.recurrent;
|
||||
const availablePeriods = Object.keys(recurrentPrices).filter(
|
||||
period => recurrentPrices[period].enabled == 1 || recurrentPrices[period].enabled === true
|
||||
);
|
||||
|
||||
if (!recurrentPrices[selectedPeriod] || !availablePeriods.includes(selectedPeriod)) return { isAvailable: false };
|
||||
|
||||
const currentPrice = parseFloat(recurrentPrices[selectedPeriod].price);
|
||||
const currentPeriodInfo = parsePeriod(selectedPeriod);
|
||||
const currentYearlyCost = currentPrice * currentPeriodInfo.factorToYear;
|
||||
const currentMonthlyEquivalent = currentYearlyCost / 12;
|
||||
|
||||
let savingsPercent = 0;
|
||||
let originalPrice = null;
|
||||
|
||||
const sortedAvailablePeriods = [...availablePeriods].sort((a, b) => parsePeriod(a).weight - parsePeriod(b).weight);
|
||||
const basePeriodCode = sortedAvailablePeriods[0];
|
||||
|
||||
if (basePeriodCode !== selectedPeriod) {
|
||||
const basePrice = parseFloat(recurrentPrices[basePeriodCode].price);
|
||||
const basePeriodInfo = parsePeriod(basePeriodCode);
|
||||
const baseYearlyCost = basePrice * basePeriodInfo.factorToYear;
|
||||
const baseMonthlyEquivalent = baseYearlyCost / 12;
|
||||
|
||||
if (baseYearlyCost > currentYearlyCost) {
|
||||
savingsPercent = Math.round((1 - (currentYearlyCost / baseYearlyCost)) * 100);
|
||||
originalPrice = baseMonthlyEquivalent.toFixed(2);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isAvailable: true, displayPrice: currentMonthlyEquivalent.toFixed(2), suffix: '/ mois',
|
||||
originalPrice, savingsPercent, billingPrice: currentPrice.toFixed(2),
|
||||
billingPhrase: currentPeriodInfo.billingPhrase, isOnce: false
|
||||
};
|
||||
};
|
||||
|
||||
const priceData = getPricingData();
|
||||
|
||||
return (
|
||||
<div className={`bg-gray-900 border border-gray-800 rounded-2xl overflow-hidden hover:border-cyan-400/50 transition-all duration-300 flex flex-col relative group ${!priceData.isAvailable ? 'opacity-50 grayscale' : ''}`}>
|
||||
<div className="absolute top-4 left-4 z-20">
|
||||
<span className="bg-black/60 backdrop-blur-sm text-gray-300 text-xs font-black px-3 py-1 rounded border border-gray-800 tracking-widest shadow-sm">
|
||||
{getCategoryBadge(categoryName)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{priceData.savingsPercent > 0 && priceData.isAvailable && (
|
||||
<div className="absolute top-4 right-4 z-20 bg-emerald-500/10 border border-emerald-500/20 text-emerald-400 text-xs font-bold px-3 py-1 rounded-full animate-pulse shadow-[0_0_15px_rgba(16,185,129,0.2)]">
|
||||
ÉCONOMIE {priceData.savingsPercent}%
|
||||
</div>
|
||||
)}
|
||||
|
||||
<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="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-11 flex flex-col justify-end">
|
||||
{!priceData.isOnce && (
|
||||
<>
|
||||
{priceData.originalPrice ? (
|
||||
<div className="text-sm text-gray-500">Au lieu de <span className="line-through">{priceData.originalPrice} €</span> / mois</div>
|
||||
) : (
|
||||
<div className="text-sm text-gray-600 italic">Tarif de base équivalent</div>
|
||||
)}
|
||||
<div className="text-xs text-cyan-500 mt-1 font-semibold uppercase tracking-wider bg-cyan-500/10 inline-block px-2 py-1 rounded w-max">
|
||||
Facturé {priceData.billingPrice} € {priceData.billingPhrase}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{priceData.isOnce && <div className="text-sm text-gray-500">Paiement unique</div>}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-red-400 font-medium mt-auto">Non disponible pour cette durée.</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<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 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>
|
||||
|
||||
<button disabled={!priceData.isAvailable} onClick={() => navigate(`/checkout/${product.id}?period=${selectedPeriod}`)} className={`w-full py-3 rounded-lg font-bold tracking-widest transition-all flex justify-center items-center space-x-2 ${priceData.isAvailable ? "bg-cyan-400/10 hover:bg-cyan-400 text-cyan-400 hover:text-gray-900 border border-cyan-400 group-hover:shadow-[0_0_20px_rgba(34,211,238,0.2)]" : "bg-gray-800 text-gray-600 border border-gray-800 cursor-not-allowed"}`}>
|
||||
<ShoppingCart className="w-5 h-5" /><span>COMMANDER</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
// src/components/store/StoreHelpers.js
|
||||
|
||||
export const parsePeriod = (code) => {
|
||||
if (!code) return { label: '', weight: 0, factorToYear: 1, billingPhrase: '' };
|
||||
const value = parseInt(code);
|
||||
if (code.includes('W')) return {
|
||||
label: `${value} Semaine${value > 1 ? 's' : ''}`,
|
||||
weight: value * 7, factorToYear: 52 / value,
|
||||
billingPhrase: value === 1 ? 'par semaine' : `toutes les ${value} semaines`
|
||||
};
|
||||
if (code.includes('M')) return {
|
||||
label: `${value} Mois`,
|
||||
weight: value * 30, factorToYear: 12 / value,
|
||||
billingPhrase: value === 1 ? 'par mois' : `tous les ${value} mois`
|
||||
};
|
||||
if (code.includes('Y')) return {
|
||||
label: `${value} An${value > 1 ? 's' : ''}`,
|
||||
weight: value * 365, factorToYear: 1 / value,
|
||||
billingPhrase: value === 1 ? 'par an' : `tous les ${value} ans`
|
||||
};
|
||||
return { label: code, weight: 999, factorToYear: 1, billingPhrase: `pour ${code}` };
|
||||
};
|
||||
|
||||
export const getCategoryBadge = (categoryName) => {
|
||||
const t = (categoryName || '').toLowerCase();
|
||||
if (t.includes('web') || t.includes('hosting')) return 'WEB';
|
||||
if (t.includes('vps')) return 'VPS';
|
||||
if (t.includes('data') || t.includes('db')) return 'DB';
|
||||
if (t.includes('cloud')) return 'CLOUD';
|
||||
return 'SRV';
|
||||
};
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Plus, Lock, X } from 'lucide-react';
|
||||
|
||||
export default function CreateTicketModal({ isOpen, onClose, onSubmit, helpdesks, defaultHelpdesk, isSubmitting }) {
|
||||
const [subject, setSubject] = useState('');
|
||||
const [message, setMessage] = useState('');
|
||||
const [selectedHelpdesk, setSelectedHelpdesk] = useState('');
|
||||
|
||||
// Réinitialisation du formulaire à chaque ouverture
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setSubject('');
|
||||
setMessage('');
|
||||
setSelectedHelpdesk(defaultHelpdesk);
|
||||
}
|
||||
}, [isOpen, defaultHelpdesk]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
if (!subject.trim() || !message.trim() || !selectedHelpdesk) return;
|
||||
onSubmit(subject, message, selectedHelpdesk);
|
||||
};
|
||||
|
||||
return (
|
||||
<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/30 rounded-lg shadow-2xl max-w-lg 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-2">
|
||||
<Plus className="w-5 h-5 text-cyan-400" /> OUVRIR UNE REQUÊTE
|
||||
</h3>
|
||||
<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">
|
||||
<div>
|
||||
<label className="block text-xs uppercase tracking-widest text-cyan-500 mb-1">Sujet de la demande</label>
|
||||
<input type="text" value={subject} onChange={(e) => setSubject(e.target.value)} required placeholder="Ex: Problème d'accès sur l'API" className="w-full bg-black border border-gray-800 text-white p-3 rounded text-sm focus:border-cyan-500 outline-none transition" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs uppercase tracking-widest text-cyan-500 mb-1">Département (Cible Réseau)</label>
|
||||
<div className="w-full bg-gray-950 border border-gray-800 text-gray-500 p-3 rounded text-sm font-mono flex items-center justify-between select-none">
|
||||
<span>{helpdesks.find(h => h.id === selectedHelpdesk)?.name || 'Service Desk Nexus'}</span>
|
||||
<Lock className="w-4 h-4 text-gray-700" />
|
||||
</div>
|
||||
<p className="text-[10px] text-gray-600 mt-1">Routage automatique vers les ingénieurs d'infrastructure.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs uppercase tracking-widest text-cyan-500 mb-1">Description détaillée</label>
|
||||
<textarea value={message} onChange={(e) => setMessage(e.target.value)} required rows="4" placeholder="Décrivez votre problème technique ici..." className="w-full bg-black border border-gray-800 text-white p-3 rounded text-sm focus:border-cyan-500 outline-none transition resize-none"></textarea>
|
||||
</div>
|
||||
<button type="submit" disabled={isSubmitting} className="w-full bg-cyan-600 hover:bg-cyan-500 text-white font-bold font-mono tracking-widest uppercase py-3 rounded mt-2 transition shadow-lg shadow-cyan-500/20 disabled:opacity-50">
|
||||
{isSubmitting ? 'CHIFFREMENT ET TRANSMISSION...' : 'TRANSMETTRE AU SUPPORT NEXUS'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { MessageSquare, Clock, CheckCircle2, AlertCircle } from 'lucide-react';
|
||||
|
||||
export const getStatusConfig = (status) => {
|
||||
switch (status) {
|
||||
case 'open': return { color: 'text-cyan-400', bg: 'bg-cyan-500/10', border: 'border-cyan-500/20', label: 'SUPPORT', icon: <MessageSquare className="w-3 h-3 mr-1" /> };
|
||||
case 'on_hold': return { color: 'text-yellow-400', bg: 'bg-yellow-500/10', border: 'border-yellow-500/20', label: 'RÉPONSE REÇUE', icon: <AlertCircle className="w-3 h-3 mr-1" /> };
|
||||
case 'pending': return { color: 'text-orange-400', bg: 'bg-orange-500/10', border: 'border-orange-500/20', label: 'EN ATTENTE', icon: <Clock className="w-3 h-3 mr-1" /> };
|
||||
case 'closed': return { color: 'text-gray-400', bg: 'bg-gray-500/10', border: 'border-gray-500/20', label: 'RÉSOLU', icon: <CheckCircle2 className="w-3 h-3 mr-1" /> };
|
||||
default: return { color: 'text-gray-400', bg: 'bg-gray-800', border: 'border-gray-700', label: status.toUpperCase() };
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
import { getStatusConfig } from './SupportHelpers';
|
||||
|
||||
export default function TicketCard({ ticket, onClick }) {
|
||||
const conf = getStatusConfig(ticket.status);
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
className="bg-gray-900 border border-gray-800 rounded-xl p-5 hover:border-cyan-400/50 hover:bg-gray-800/50 transition-all cursor-pointer group flex flex-col justify-between min-h-40 shadow-lg"
|
||||
>
|
||||
<div>
|
||||
<div className="flex justify-between items-start mb-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-gray-500 font-mono text-xs">{ticket.id}</span>
|
||||
{ticket.status === 'on_hold' && (
|
||||
<span className="flex h-2.5 w-2.5 relative" title="Nouveau message en attente de lecture">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-cyan-400 opacity-75"></span>
|
||||
<span className="relative inline-flex rounded-full h-2.5 w-2.5 bg-cyan-500 shadow-[0_0_8px_#00E5FF]"></span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className={`flex items-center px-2 py-1 rounded text-[10px] font-bold border tracking-wider ${conf.color} ${conf.bg} ${conf.border}`}>
|
||||
{conf.icon} {conf.label}
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="text-white font-bold text-lg mb-1 group-hover:text-cyan-400 transition-colors line-clamp-2">
|
||||
{ticket.subject}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="flex justify-between items-center text-xs text-gray-500 font-mono border-t border-gray-800 pt-3 mt-4">
|
||||
<span>Département: {ticket.department}</span>
|
||||
<span className="truncate max-w-30">MàJ: {ticket.lastUpdate}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { Send, Loader, X } from 'lucide-react';
|
||||
|
||||
export default function TicketThreadModal({ ticket, onClose, onReply, isLoadingConversation }) {
|
||||
const [replyMessage, setReplyMessage] = useState('');
|
||||
const messagesEndRef = useRef(null);
|
||||
|
||||
// Défilement automatique
|
||||
useEffect(() => {
|
||||
if (ticket && ticket.messages && !isLoadingConversation) {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}
|
||||
}, [ticket?.messages, isLoadingConversation]);
|
||||
|
||||
if (!ticket) return null;
|
||||
|
||||
const handleReplySubmit = (e) => {
|
||||
e.preventDefault();
|
||||
if (!replyMessage.trim()) return;
|
||||
onReply(replyMessage);
|
||||
setReplyMessage(''); // Reset auto du champ
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/80 backdrop-blur-sm z-50 flex items-center justify-center p-2 sm:p-4">
|
||||
<div className="bg-gray-950 border border-gray-800 rounded-lg shadow-2xl max-w-3xl w-full h-[85vh] flex flex-col">
|
||||
<div className="bg-gray-900 border-b border-gray-800 p-4 sm:p-6 flex justify-between items-start rounded-t-lg">
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<span className="text-cyan-500 font-mono text-sm">{ticket.id}</span>
|
||||
<span className="bg-gray-800 text-gray-300 text-[10px] uppercase font-bold tracking-wider px-2 py-0.5 rounded border border-gray-700">{ticket.department}</span>
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-white line-clamp-1">{ticket.subject}</h3>
|
||||
</div>
|
||||
<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">
|
||||
{isLoadingConversation ? (
|
||||
<div className="flex flex-col items-center justify-center h-full text-cyan-400 gap-2 font-mono text-xs">
|
||||
<Loader className="w-6 h-6 animate-spin" /><span>Téléchargement des paquets de discussion sécurisés...</span>
|
||||
</div>
|
||||
) : ticket.messages.length === 0 ? (
|
||||
<div className="text-center text-gray-600 font-mono text-xs pt-10">Aucun message trouvé dans ce fil.</div>
|
||||
) : (
|
||||
ticket.messages.map((msg, idx) => (
|
||||
<div key={idx} className={`flex flex-col ${msg.sender === 'client' ? 'items-end' : 'items-start'}`}>
|
||||
<div className="flex items-baseline gap-2 mb-1 px-1">
|
||||
<span className={`text-[10px] font-bold uppercase tracking-widest ${msg.sender === 'client' ? 'text-gray-500' : 'text-cyan-400'}`}>
|
||||
{msg.sender === 'client' ? 'VOUS' : 'INGÉNIEUR GISE'}
|
||||
</span>
|
||||
<span className="text-[10px] text-gray-600 font-mono">{msg.date}</span>
|
||||
</div>
|
||||
<div className={`p-4 rounded-xl max-w-[85%] text-sm leading-relaxed ${msg.sender === 'client' ? 'bg-gray-800 text-gray-200 rounded-tr-none' : 'bg-cyan-900/20 border border-cyan-800/30 text-cyan-50 rounded-tl-none'}`}>
|
||||
{msg.text}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{ticket.status !== 'closed' ? (
|
||||
<form onSubmit={handleReplySubmit} className="bg-gray-900 border-t border-gray-800 p-4 rounded-b-lg">
|
||||
<div className="flex gap-2">
|
||||
<textarea rows="2" value={replyMessage} onChange={(e) => setReplyMessage(e.target.value)} required placeholder="Taper une réponse sécurisée..." className="flex-1 bg-black border border-gray-800 text-white p-3 rounded-lg text-sm focus:border-cyan-500 outline-none transition resize-none"></textarea>
|
||||
<button type="submit" className="bg-cyan-600 hover:bg-cyan-500 text-white px-6 rounded-lg font-bold font-mono tracking-widest transition flex flex-col items-center justify-center gap-1 shadow-lg shadow-cyan-500/20">
|
||||
<Send className="w-4 h-4" /><span className="text-[10px]">ENVOYER</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<div className="bg-gray-900 border-t border-gray-800 p-4 rounded-b-lg text-center font-mono text-sm text-gray-500">
|
||||
[ CE TICKET EST VERROUILLÉ ET ARCHIVÉ ]
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
export default function ConfirmLogoutModal({ isOpen, onClose, onConfirm }) {
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
|
||||
backgroundColor: 'rgba(0,0,0,0.8)', backdropFilter: 'blur(4px)',
|
||||
zIndex: 100, display: 'flex', alignItems: 'center', justifyContent: 'center'
|
||||
}}>
|
||||
<div style={{
|
||||
backgroundColor: '#1A1A1A', border: '1px solid #ff003c',
|
||||
boxShadow: '0 10px 30px rgba(255, 0, 60, 0.2)', borderRadius: '8px',
|
||||
maxWidth: '400px', width: '100%', padding: '24px', color: '#FFF', fontFamily: 'monospace'
|
||||
}}>
|
||||
<h4 style={{ fontSize: '1.1rem', fontWeight: 'bold', letterSpacing: '1px', marginBottom: '10px', color: '#ff003c', textTransform: 'uppercase' }}>
|
||||
DÉCONNEXION DU TERMINAL
|
||||
</h4>
|
||||
<p style={{ fontSize: '0.9rem', color: '#AAA', marginBottom: '24px', lineHeight: '1.5' }}>
|
||||
Êtes-vous sûr de vouloir fermer la session sécurisée et quitter l'environnement cloud ?
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: '10px' }}>
|
||||
<button onClick={onClose} style={{ flex: 1, padding: '10px', backgroundColor: '#333', color: '#FFF', border: 'none', borderRadius: '4px', fontFamily: 'monospace', fontWeight: 'bold', cursor: 'pointer', letterSpacing: '1px' }}>
|
||||
ANNULER
|
||||
</button>
|
||||
<button onClick={onConfirm} style={{ flex: 1, padding: '10px', backgroundColor: '#ff003c', color: '#FFF', border: 'none', borderRadius: '4px', fontFamily: 'monospace', fontWeight: 'bold', cursor: 'pointer', letterSpacing: '1px' }}>
|
||||
SE DÉCONNECTER
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
export default function NotificationModal({ notification, onClose }) {
|
||||
if (!notification) return null;
|
||||
const isError = notification.type === 'error';
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/80 backdrop-blur-sm z-[100] flex items-center justify-center p-4">
|
||||
<div className={`bg-gray-900 border ${isError ? 'border-red-500/50 shadow-red-500/10' : 'border-cyan-500/50 shadow-cyan-500/10'} rounded-lg shadow-2xl max-w-sm w-full p-6 text-gray-200`}>
|
||||
<h4 className={`text-lg font-mono font-bold tracking-wider mb-2 ${isError ? 'text-red-400' : 'text-cyan-400'}`}>
|
||||
{notification.title.toUpperCase()}
|
||||
</h4>
|
||||
<p className="text-sm text-gray-400 mb-6">{notification.message}</p>
|
||||
<button onClick={onClose} className={`w-full font-mono py-2 rounded text-xs font-bold transition ${isError ? 'bg-red-600 hover:bg-red-500 text-white' : 'bg-cyan-500 hover:bg-cyan-400 text-gray-950'}`}>
|
||||
COMPRIS
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+37
-183
@@ -1,63 +1,9 @@
|
||||
// src/layouts/AppLayout.jsx
|
||||
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 !
|
||||
|
||||
// ============================================================================
|
||||
// COMPOSANT : MODAL DE CONFIRMATION DE DÉCONNEXION
|
||||
// ============================================================================
|
||||
const ConfirmLogoutModal = ({ isOpen, onClose, onConfirm }) => {
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
|
||||
backgroundColor: 'rgba(0,0,0,0.8)', backdropFilter: 'blur(4px)',
|
||||
zIndex: 100, display: 'flex', alignItems: 'center', justifyContent: 'center'
|
||||
}}>
|
||||
<div style={{
|
||||
backgroundColor: '#1A1A1A',
|
||||
border: '1px solid #ff003c',
|
||||
boxShadow: '0 10px 30px rgba(255, 0, 60, 0.2)',
|
||||
borderRadius: '8px',
|
||||
maxWidth: '400px',
|
||||
width: '100%',
|
||||
padding: '24px',
|
||||
color: '#FFF',
|
||||
fontFamily: 'monospace'
|
||||
}}>
|
||||
<h4 style={{ fontSize: '1.1rem', fontWeight: 'bold', letterSpacing: '1px', marginBottom: '10px', color: '#ff003c', textTransform: 'uppercase' }}>
|
||||
DÉCONNEXION DU TERMINAL
|
||||
</h4>
|
||||
<p style={{ fontSize: '0.9rem', color: '#AAA', marginBottom: '24px', lineHeight: '1.5' }}>
|
||||
Êtes-vous sûr de vouloir fermer la session sécurisée et quitter l'environnement cloud ?
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: '10px' }}>
|
||||
<button onClick={onClose} style={{
|
||||
flex: 1, padding: '10px',
|
||||
backgroundColor: '#333', color: '#FFF',
|
||||
border: 'none', borderRadius: '4px',
|
||||
fontFamily: 'monospace', fontWeight: 'bold', cursor: 'pointer', letterSpacing: '1px'
|
||||
}}>
|
||||
ANNULER
|
||||
</button>
|
||||
<button onClick={onConfirm} style={{
|
||||
flex: 1, padding: '10px',
|
||||
backgroundColor: '#ff003c', color: '#FFF',
|
||||
border: 'none', borderRadius: '4px',
|
||||
fontFamily: 'monospace', fontWeight: 'bold', cursor: 'pointer', letterSpacing: '1px'
|
||||
}}>
|
||||
SE DÉCONNECTER
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// COMPOSANT PRINCIPAL : LAYOUT DU PORTAIL
|
||||
// ============================================================================
|
||||
export default function AppLayout() {
|
||||
const navigate = useNavigate();
|
||||
const [showLogoutModal, setShowLogoutModal] = useState(false);
|
||||
@@ -67,187 +13,95 @@ export default function AppLayout() {
|
||||
try {
|
||||
const data = await getClientTickets();
|
||||
if (data && data.list) {
|
||||
// On cherche les tickets qui ont le statut 'on_hold'
|
||||
const unread = data.list.filter(t => t.status === 'on_hold' || t.unread).length;
|
||||
setUnreadCount(unread);
|
||||
}
|
||||
} catch (err) {
|
||||
// Erreur silencieuse : on ne pollue pas l'écran si le réseau saute
|
||||
}
|
||||
} catch (err) { }
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
checkUnreadTickets(); // Premier check immédiat
|
||||
const intervalId = setInterval(checkUnreadTickets, 120000); // Check toutes les 2 minutes
|
||||
checkUnreadTickets();
|
||||
const intervalId = setInterval(checkUnreadTickets, 120000);
|
||||
return () => clearInterval(intervalId);
|
||||
}, []);
|
||||
|
||||
// Ouvre simplement le modal
|
||||
const handleLogoutClick = () => {
|
||||
setShowLogoutModal(true);
|
||||
};
|
||||
|
||||
// Exécute la vraie déconnexion
|
||||
const confirmLogout = () => {
|
||||
// Ici, tu pourras effacer les tokens (ex: localStorage.removeItem('token'))
|
||||
setShowLogoutModal(false);
|
||||
navigate('/login');
|
||||
};
|
||||
|
||||
// --- DESIGN SYSTEM DU BUNKER ---
|
||||
const sidebarStyle = {
|
||||
width: '260px',
|
||||
backgroundColor: '#121212', // Gris blindage
|
||||
borderRight: '1px solid #222222',
|
||||
height: '100vh',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
fontFamily: 'monospace'
|
||||
};
|
||||
|
||||
const mainContentStyle = {
|
||||
marginLeft: '260px', // Laisse la place à la sidebar
|
||||
minHeight: '100vh',
|
||||
backgroundColor: '#050505', // Fond abyssal
|
||||
color: '#E0E0E0',
|
||||
padding: '40px'
|
||||
};
|
||||
|
||||
// Fonction pour styliser le lien actif (en Cyan)
|
||||
const getNavLinkStyle = ({ isActive }) => ({
|
||||
display: 'block',
|
||||
padding: '15px 25px',
|
||||
color: isActive ? '#00E5FF' : '#888',
|
||||
backgroundColor: isActive ? 'rgba(0, 229, 255, 0.05)' : 'transparent',
|
||||
textDecoration: 'none',
|
||||
borderLeft: isActive ? '4px solid #00E5FF' : '4px solid transparent',
|
||||
transition: 'all 0.2s',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '1px',
|
||||
fontSize: '0.9rem'
|
||||
});
|
||||
// 🌟 La fonction magique Tailwind pour les liens du menu
|
||||
const navLinkClass = ({ isActive }) =>
|
||||
`block px-6 py-4 no-underline border-l-4 transition-all uppercase tracking-widest text-sm font-mono ${isActive
|
||||
? 'text-cyan-400 bg-cyan-400/5 border-cyan-400'
|
||||
: 'text-[#888] border-transparent hover:text-gray-300 hover:bg-white/5'
|
||||
}`;
|
||||
|
||||
return (
|
||||
<div style={{ display: 'block' }}>
|
||||
<div className="flex min-h-screen bg-[#050505] text-[#E0E0E0] font-sans">
|
||||
|
||||
{/* ========================================== */}
|
||||
{/* BARRE LATÉRALE (SIDEBAR) */}
|
||||
{/* ========================================== */}
|
||||
<aside style={sidebarStyle}>
|
||||
{/* SIDEBAR */}
|
||||
<aside className="w-65 bg-[#121212] border-r border-[#222222] h-screen flex flex-col fixed top-0 left-0">
|
||||
|
||||
{/* En-tête de la Sidebar (Logo / Marque) */}
|
||||
<div style={{ padding: '30px 25px', borderBottom: '1px solid #222' }}>
|
||||
<h1 style={{
|
||||
color: '#FFF',
|
||||
margin: 0,
|
||||
fontSize: '1.8rem',
|
||||
letterSpacing: '3px',
|
||||
display: 'flex',
|
||||
alignItems: 'baseline',
|
||||
gap: '8px'
|
||||
}}>
|
||||
<div className="px-6 py-8 border-b border-[#222]">
|
||||
<h1 className="text-white m-0 text-3xl font-black tracking-widest flex items-baseline gap-2">
|
||||
NEXUS
|
||||
<span style={{
|
||||
color: '#00E5FF',
|
||||
fontSize: '0.7rem',
|
||||
letterSpacing: '1px',
|
||||
fontWeight: 'normal',
|
||||
}}>
|
||||
by GISE
|
||||
</span>
|
||||
<span className="text-cyan-400 text-[10px] tracking-widest font-normal">by GISE</span>
|
||||
</h1>
|
||||
<p style={{ color: '#555', fontSize: '0.7rem', margin: '8px 0 0 0' }}>
|
||||
CONSOLE D'INFRASTRUCTURE v1.0
|
||||
<p className="text-[#555] text-[10px] font-mono tracking-wider mt-2 uppercase">
|
||||
Console d'infrastructure v1.0
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Menu de Navigation */}
|
||||
<nav style={{ flex: 1, marginTop: '20px' }}>
|
||||
<div style={{ padding: '0 25px', marginBottom: '10px', color: '#444', fontSize: '0.7rem', fontWeight: 'bold' }}>
|
||||
<nav className="flex-1 mt-6">
|
||||
<div className="px-6 mb-2 text-[#444] text-[10px] font-bold font-mono tracking-widest">
|
||||
// SUPERVISION
|
||||
</div>
|
||||
<NavLink style={getNavLinkStyle} to="/dashboard">Tableau de bord</NavLink>
|
||||
<NavLink style={getNavLinkStyle} to="/services">Inventaire Réseau</NavLink>
|
||||
<NavLink className={navLinkClass} to="/dashboard">Tableau de bord</NavLink>
|
||||
<NavLink className={navLinkClass} to="/services">Inventaire Réseau</NavLink>
|
||||
|
||||
<div style={{ padding: '0 25px', marginTop: '25px', marginBottom: '10px', color: '#444', fontSize: '0.7rem', fontWeight: 'bold' }}>
|
||||
<div className="px-6 mt-8 mb-2 text-[#444] text-[10px] font-bold font-mono tracking-widest">
|
||||
// GESTION
|
||||
</div>
|
||||
{/*<NavLink style={getNavLinkStyle} to="/billing" locked>Facturation</NavLink>*/}
|
||||
<NavLink style={getNavLinkStyle} to="/support">
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', width: '100%' }}>
|
||||
<NavLink className={navLinkClass} to="/support">
|
||||
<div className="flex justify-between items-center w-full">
|
||||
<span>Tickets Support</span>
|
||||
|
||||
{/* LE BADGE DE NOTIFICATION */}
|
||||
{unreadCount > 0 && (
|
||||
<span style={{
|
||||
backgroundColor: '#00E5FF',
|
||||
color: '#000',
|
||||
fontSize: '0.65rem',
|
||||
fontWeight: 'bold',
|
||||
padding: '2px 8px',
|
||||
borderRadius: '12px',
|
||||
boxShadow: '0 0 10px rgba(0,229,255,0.6)'
|
||||
}}>
|
||||
<span className="bg-cyan-400 text-black text-[10px] font-bold px-2 py-0.5 rounded-full shadow-[0_0_10px_rgba(0,229,255,0.6)] animate-pulse">
|
||||
{unreadCount} NEW
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</NavLink>
|
||||
{/*<NavLink style={getNavLinkStyle} to="/settings">Profil & Sécurité</NavLink>*/}
|
||||
{/* === MODULES VERROUILLÉS (WORK IN PROGRESS) === */}
|
||||
<div style={{
|
||||
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
||||
padding: '15px 25px', color: '#444', borderLeft: '4px solid transparent',
|
||||
textTransform: 'uppercase', letterSpacing: '1px', fontSize: '0.9rem',
|
||||
cursor: 'not-allowed', userSelect: 'none'
|
||||
}}>
|
||||
<span>Facturation</span>
|
||||
<span style={{ fontSize: '0.6rem', color: '#333', border: '1px solid #333', padding: '2px 5px', borderRadius: '3px', fontWeight: 'bold' }}>PROCHAINEMENT</span>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
||||
padding: '15px 25px', color: '#444', borderLeft: '4px solid transparent',
|
||||
textTransform: 'uppercase', letterSpacing: '1px', fontSize: '0.9rem',
|
||||
cursor: 'not-allowed', userSelect: 'none'
|
||||
}}>
|
||||
<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 style={{ fontSize: '0.6rem', color: '#333', border: '1px solid #333', padding: '2px 5px', borderRadius: '3px', fontWeight: 'bold' }}>PROCHAINEMENT</span>
|
||||
<span className="text-[9px] text-[#333] border border-[#333] px-1.5 py-0.5 rounded font-bold">WIP</span>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Bas de la Sidebar (Déconnexion) */}
|
||||
<div style={{ padding: '20px', borderTop: '1px solid #222' }}>
|
||||
<div className="p-5 border-t border-[#222]">
|
||||
<button
|
||||
onClick={handleLogoutClick}
|
||||
style={{
|
||||
width: '100%', padding: '12px', backgroundColor: 'transparent',
|
||||
color: '#ff003c', border: '1px solid #ff003c', cursor: 'pointer',
|
||||
fontFamily: 'monospace', textTransform: 'uppercase'
|
||||
}}
|
||||
onClick={() => setShowLogoutModal(true)}
|
||||
className="w-full p-3 bg-transparent text-[#ff003c] hover:bg-[#ff003c]/10 border border-[#ff003c] cursor-pointer font-mono text-sm tracking-widest uppercase transition-colors rounded-sm"
|
||||
>
|
||||
[ Déconnexion ]
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* ========================================== */}
|
||||
{/* ZONE DE CONTENU DYNAMIQUE */}
|
||||
{/* ========================================== */}
|
||||
<main style={mainContentStyle}>
|
||||
{/* Le composant <Outlet /> injecte le contenu de la page demandée sans recharger la sidebar */}
|
||||
{/* ZONE DE CONTENU */}
|
||||
<main className="ml-65 p-10 flex-1 min-h-screen">
|
||||
<Outlet context={{ refreshNavbarTickets: checkUnreadTickets }} />
|
||||
</main>
|
||||
|
||||
{/* MODAL DE DÉCONNEXION */}
|
||||
<ConfirmLogoutModal
|
||||
isOpen={showLogoutModal}
|
||||
onClose={() => setShowLogoutModal(false)}
|
||||
onConfirm={confirmLogout}
|
||||
/>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,35 +1,28 @@
|
||||
// src/layouts/PublicLayout.jsx
|
||||
import { Outlet, Link } from 'react-router-dom';
|
||||
|
||||
export default function PublicLayout() {
|
||||
return (
|
||||
<div style={{ backgroundColor: '#121212', color: '#E0E0E0', minHeight: '100vh', fontFamily: 'monospace' }}>
|
||||
<nav style={{ padding: '20px', borderBottom: '1px solid #242424', display: 'flex', gap: '15px', alignItems: 'center' }}>
|
||||
<Link to="/" style={{ display: 'flex', alignItems: 'baseline', gap: '6px', textDecoration: 'none' }}>
|
||||
<span style={{ color: '#FFF', fontSize: '1.2rem', letterSpacing: '2px', fontWeight: 'bold' }}>NEXUS</span>
|
||||
<span style={{ color: '#00E5FF', fontSize: '0.65rem', letterSpacing: '1px', fontWeight: 'normal'}}>by GISE</span>
|
||||
<div className="bg-[#121212] text-[#E0E0E0] min-h-screen font-mono">
|
||||
<nav className="p-5 border-b border-[#242424] flex gap-4 items-center">
|
||||
<Link to="/" className="flex items-baseline gap-1.5 no-underline">
|
||||
<span className="text-white text-xl tracking-[2px] font-bold">NEXUS</span>
|
||||
<span className="text-cyan-400 text-[10px] tracking-[1px]">by GISE</span>
|
||||
</Link>
|
||||
<Link to="/offres" style={{ color: '#E0E0E0', textDecoration: 'none', marginLeft: '20px' }}>Catalogue</Link>
|
||||
|
||||
{/* AJOUT DU BOUTON D'INSCRIPTION */}
|
||||
<Link to="/register" style={{ color: '#A0A0A0', textDecoration: 'none', marginLeft: 'auto', fontSize: '0.9rem' }}>
|
||||
<Link to="/offres" className="text-[#E0E0E0] no-underline ml-5 hover:text-cyan-400 transition-colors">
|
||||
Catalogue
|
||||
</Link>
|
||||
|
||||
<Link to="/register" className="text-[#A0A0A0] no-underline ml-auto text-sm hover:text-white transition-colors">
|
||||
Créer un compte
|
||||
</Link>
|
||||
|
||||
<Link to="/login" style={{
|
||||
color: '#000',
|
||||
backgroundColor: '#00E5FF',
|
||||
padding: '6px 12px',
|
||||
textDecoration: 'none',
|
||||
fontWeight: 'bold',
|
||||
fontSize: '0.9rem'
|
||||
}}>
|
||||
<Link to="/login" className="text-black bg-cyan-400 hover:bg-cyan-300 transition-colors px-3 py-1.5 no-underline font-bold text-sm">
|
||||
Connexion
|
||||
</Link>
|
||||
</nav>
|
||||
|
||||
{/* C'est ici que les pages (Accueil, Login, etc.) vont s'afficher */}
|
||||
<main style={{ padding: '20px' }}>
|
||||
<main className="p-5">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+93
-95
@@ -1,7 +1,13 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { getClientOrders } from '../../services/api';
|
||||
import { Server, Database, Cloud, Globe, Plus, AlertCircle, Loader } from 'lucide-react';
|
||||
import { AlertCircle, Loader, FileText, X } from 'lucide-react';
|
||||
|
||||
// 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();
|
||||
@@ -9,77 +15,82 @@ export default function Dashboard() {
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
// Chargement des données à l'ouverture du Sas
|
||||
useEffect(() => {
|
||||
const fetchInventory = async () => {
|
||||
try {
|
||||
const data = await getClientOrders();
|
||||
const [activeSubscriptionModal, setActiveSubscriptionModal] = useState(null);
|
||||
const [customAlert, setCustomAlert] = useState(null);
|
||||
|
||||
if (data.list) {
|
||||
// LE FILTRE CHIRURGICAL PAR PREFIXE
|
||||
const filteredOrders = data.list.filter(order => {
|
||||
const title = (order.title || '').toLowerCase();
|
||||
const type = (order.type || '').toLowerCase();
|
||||
const triggerAlert = (title, message, type = "info") => setCustomAlert({ title, message, type });
|
||||
|
||||
// On masque la commande SI :
|
||||
// 1. Le type technique est "domain"
|
||||
// 2. OU le titre COMMENCE par "domain", "domaine" ou "enregistrement"
|
||||
const isGhostProduct =
|
||||
type === 'domain' ||
|
||||
title.startsWith('domain ') ||
|
||||
title.startsWith('domaine ') ||
|
||||
title.startsWith('enregistrement ');
|
||||
const fetchInventory = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const data = await getClientOrders();
|
||||
|
||||
// On retourne true (on affiche) uniquement si ce n'est pas un produit fantôme
|
||||
return !isGhostProduct;
|
||||
});
|
||||
if (data.list) {
|
||||
// LE FILTRE CHIRURGICAL PAR PREFIXE
|
||||
const filteredOrders = data.list.filter(order => {
|
||||
const title = (order.title || '').toLowerCase();
|
||||
const type = (order.type || '').toLowerCase();
|
||||
|
||||
setOrders(filteredOrders);
|
||||
} else {
|
||||
setOrders([]);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err.message || "Impossible de récupérer la télémétrie des services.");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
const isGhostProduct =
|
||||
type === 'domain' ||
|
||||
title.startsWith('domain ') ||
|
||||
title.startsWith('domaine ') ||
|
||||
title.startsWith('enregistrement ');
|
||||
|
||||
return !isGhostProduct;
|
||||
});
|
||||
|
||||
setOrders(filteredOrders);
|
||||
} else {
|
||||
setOrders([]);
|
||||
}
|
||||
};
|
||||
} catch (err) {
|
||||
setError(err.message || "Impossible de récupérer la télémétrie des services.");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchInventory();
|
||||
}, []);
|
||||
|
||||
// Fonction Radar : Détecte le type de service selon son nom pour afficher la bonne icône
|
||||
const getServiceIcon = (title) => {
|
||||
const t = title.toLowerCase();
|
||||
if (t.includes('vps') || t.includes('serveur')) return <Server className="w-10 h-10 text-cyan-400" />;
|
||||
if (t.includes('cloud') || t.includes('nextcloud')) return <Cloud className="w-10 h-10 text-blue-400" />;
|
||||
if (t.includes('db') || t.includes('base') || t.includes('sql')) return <Database className="w-10 h-10 text-purple-400" />;
|
||||
return <Globe className="w-10 h-10 text-emerald-400" />; // Par défaut : Web / Hestia
|
||||
};
|
||||
// 🌟 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');
|
||||
|
||||
// Fonction d'état : Formate le statut du service
|
||||
const getStatusBadge = (status) => {
|
||||
switch (status) {
|
||||
case 'active': return <span className="px-2 py-1 text-xs text-green-400 bg-green-400/10 border border-green-400/20 rounded">ACTIF</span>;
|
||||
case 'pending_setup': return <span className="px-2 py-1 text-xs text-orange-400 bg-orange-400/10 border border-orange-400/20 rounded">EN PRÉPARATION</span>;
|
||||
case 'suspended': return <span className="px-2 py-1 text-xs text-red-400 bg-red-400/10 border border-red-400/20 rounded">SUSPENDU</span>;
|
||||
default: return <span className="px-2 py-1 text-xs text-gray-400 bg-gray-400/10 border border-gray-400/20 rounded">{status.toUpperCase()}</span>;
|
||||
// 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">
|
||||
|
||||
<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>
|
||||
)}
|
||||
|
||||
@@ -90,57 +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 services FOSSBilling */}
|
||||
{orders.map((order) => (
|
||||
<div
|
||||
<DashboardServiceCard
|
||||
key={order.id}
|
||||
className="bg-gray-900 border border-gray-800 p-6 rounded-xl hover:border-cyan-400/50 transition-colors group cursor-pointer flex flex-col justify-between"
|
||||
onClick={() => navigate(`/services/${order.id}`)} // Redirection future vers les détails
|
||||
>
|
||||
<div>
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<div className="p-3 bg-black/50 rounded-lg group-hover:scale-110 transition-transform">
|
||||
{getServiceIcon(order.title)}
|
||||
</div>
|
||||
{getStatusBadge(order.status)}
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-white truncate" title={order.title}>
|
||||
{order.title}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Facturation : {order.period}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 pt-4 border-t border-gray-800 flex justify-between items-center text-sm">
|
||||
<span className="text-gray-400">ID Réseau: #{order.id}</span>
|
||||
<span className="text-cyan-400 group-hover:underline">Gérer ></span>
|
||||
</div>
|
||||
</div>
|
||||
order={order}
|
||||
onClick={() => handleManageSubscription(order)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* LA CARTE : OBTENIR UN NOUVEAU PRODUIT */}
|
||||
<div
|
||||
onClick={() => navigate('/store')} // Remplace /store par l'URL de ton catalogue
|
||||
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]"
|
||||
>
|
||||
<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" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-white group-hover:text-cyan-400">
|
||||
Demander une accréditation
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 mt-2">
|
||||
Déployer un nouveau serveur Web, VPS ou Cloud.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
+37
-592
@@ -1,496 +1,39 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { getMyServices, getServiceDetails, getHostingServiceDetails, resetHostingPassword } from '../../services/api';
|
||||
import { getMyServices, getHostingServiceDetails } from '../../services/api';
|
||||
import { useVPC } from '../../services/useVPC';
|
||||
import { Server, Database, Cloud, Globe, Folder, Trash2, ExternalLink, Loader, Plus, Play, Lock, Maximize2, AlertCircle } from 'lucide-react'; // Ajout de AlertCircle
|
||||
import { Server, Globe, Folder, Trash2, Loader, Plus, AlertCircle, X } from 'lucide-react';
|
||||
|
||||
// ============================================================================
|
||||
// COMPOSANT 0 : MODAL DE NOTIFICATION (Remplace les alert() natifs)
|
||||
// ============================================================================
|
||||
const NotificationModal = ({ notification, onClose }) => {
|
||||
if (!notification) return null;
|
||||
const isError = notification.type === 'error';
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/80 backdrop-blur-sm z-[100] flex items-center justify-center p-4">
|
||||
<div className={`bg-gray-900 border ${isError ? 'border-red-500/50 shadow-red-500/10' : 'border-cyan-500/50 shadow-cyan-500/10'} rounded-lg shadow-2xl max-w-sm w-full p-6 text-gray-200`}>
|
||||
<h4 className={`text-lg font-mono font-bold tracking-wider mb-2 ${isError ? 'text-red-400' : 'text-cyan-400'}`}>
|
||||
{notification.title.toUpperCase()}
|
||||
</h4>
|
||||
<p className="text-sm text-gray-400 mb-6">{notification.message}</p>
|
||||
<button onClick={onClose} className={`w-full font-mono py-2 rounded text-xs font-bold transition ${isError ? 'bg-red-600 hover:bg-red-500 text-white' : 'bg-cyan-500 hover:bg-cyan-400 text-gray-950'}`}>
|
||||
COMPRIS
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
// Importation de tes nouveaux composants modulaires !
|
||||
import NotificationModal from '../../components/ui/NotificationModal';
|
||||
import InstanceCard from '../../components/services/InstanceCard';
|
||||
import VpsDeployer from '../../components/services/VpsDeployer';
|
||||
import VpsManager from '../../components/services/VpsManager';
|
||||
import WebManager from '../../components/services/WebManager';
|
||||
|
||||
// ============================================================================
|
||||
// COMPOSANT 1 : INSTANCE CARD (Mode PaaS Pur - IP Masquée)
|
||||
// ============================================================================
|
||||
const InstanceCard = ({ service, vpcs, onAssignVpc, onRemoveVpc, onOpenConsole, isConnecting }) => {
|
||||
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 !== '';
|
||||
|
||||
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;
|
||||
} else {
|
||||
buttonText = "INITIALISATION";
|
||||
ButtonIcon = Play;
|
||||
buttonStyle = "bg-yellow-500/10 text-yellow-500 hover:bg-yellow-500 hover:text-gray-900 border-yellow-500/50";
|
||||
}
|
||||
} else if (isWeb) {
|
||||
buttonText = "GÉRER L'HÉBERGEMENT";
|
||||
ButtonIcon = Globe;
|
||||
buttonStyle = "bg-emerald-400/10 hover:bg-emerald-400 text-emerald-400 hover:text-gray-900 border-emerald-400";
|
||||
} else if (isCloud) {
|
||||
buttonText = "ACCÉDER AU CLOUD";
|
||||
buttonStyle = "bg-blue-400/10 hover:bg-blue-400 text-blue-400 hover:text-gray-900 border-blue-400";
|
||||
} else if (isDB) {
|
||||
buttonText = "PHPMYADMIN / CLUSTER";
|
||||
ButtonIcon = Database;
|
||||
buttonStyle = "bg-purple-400/10 hover:bg-purple-400 text-purple-400 hover:text-gray-900 border-purple-400";
|
||||
}
|
||||
|
||||
const getServiceIcon = () => {
|
||||
if (isVPS) return <Server className="w-8 h-8 text-cyan-400" />;
|
||||
if (isCloud) return <Cloud className="w-8 h-8 text-blue-400" />;
|
||||
if (isDB) return <Database className="w-8 h-8 text-purple-400" />;
|
||||
return <Globe className="w-8 h-8 text-emerald-400" />;
|
||||
};
|
||||
|
||||
const baseTitle = (service.title || '').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">
|
||||
<div>
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<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'}
|
||||
</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>
|
||||
)}
|
||||
</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-[130px]"
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// COMPOSANT 2 : VPS DEPLOYER (Instanciation)
|
||||
// ============================================================================
|
||||
const VpsDeployer = ({ serviceId, onDeploySuccess, onAlert }) => {
|
||||
const [domain, setDomain] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [isDeploying, setIsDeploying] = useState(false);
|
||||
|
||||
const handleDeploy = async () => {
|
||||
if (password.length < 8) { onAlert("Sécurité", "Le mot de passe doit faire au moins 8 caractères.", "error"); return; }
|
||||
|
||||
setIsDeploying(true);
|
||||
try {
|
||||
const response = await fetch('https://web.gise.be/custom_api/proxmox_create_vps.php', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({ service_id: serviceId, domain: domain, password: password })
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
if (data.status === 'success') {
|
||||
onDeploySuccess({ ip: data.ip, domain: data.domain, password: password });
|
||||
} else {
|
||||
onAlert("Échec", "Erreur de provisionnement : " + (data.message || 'Inconnue'), "error");
|
||||
}
|
||||
} catch (err) {
|
||||
onAlert("Erreur Réseau", "Erreur de communication avec l'API Proxmox Gateway.", "error");
|
||||
} finally {
|
||||
setIsDeploying(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="bg-yellow-900/20 border border-yellow-600/30 p-4 rounded mb-6 text-yellow-500/80 text-sm">
|
||||
<span className="font-bold text-yellow-500">PHASE D'INITIALISATION REQUISE</span><br />
|
||||
La facturation est activée. Veuillez définir un mot de passe Root pour lancer la création de l'instance sur l'hyperviseur.
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs uppercase tracking-widest text-cyan-500 mb-1">Hostname personnalisé (Optionnel)</label>
|
||||
<input type="text" value={domain} onChange={(e) => setDomain(e.target.value.toLowerCase())} placeholder="Laissez vide pour le défaut" className="w-full bg-gray-950 border border-gray-800 text-white p-3 rounded font-mono outline-none focus:border-cyan-500 transition-colors" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs uppercase tracking-widest text-cyan-500 mb-1">Mot de passe Root (Requis)</label>
|
||||
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} placeholder="••••••••" className="w-full bg-gray-950 border border-gray-800 text-white p-3 rounded font-mono outline-none focus:border-cyan-500 transition-colors" />
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={handleDeploy} disabled={isDeploying} className={`mt-6 w-full px-6 py-3 rounded font-bold font-mono tracking-widest uppercase transition ${isDeploying ? 'bg-gray-800 text-gray-500 cursor-not-allowed' : 'bg-cyan-600 hover:bg-cyan-500 text-white shadow-lg shadow-cyan-500/20'}`}>
|
||||
{isDeploying ? 'Fabrication sur le Métal en cours...' : 'Lancer l\'Instanciation'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// COMPOSANT 3 : VPS MANAGER (Day-2 Operations)
|
||||
// ============================================================================
|
||||
const VpsManager = ({ details, orderId, onRefresh, onAlert }) => {
|
||||
const [showTerminal, setShowTerminal] = useState(false);
|
||||
const [isEditingDomain, setIsEditingDomain] = useState(false);
|
||||
const [newDomain, setNewDomain] = useState(details.domain);
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
const [sslStatus, setSslStatus] = useState(null);
|
||||
|
||||
const isInternal = details.domain && details.domain.endsWith('.gise.be');
|
||||
const isCustomDomain = details.domain && !isInternal;
|
||||
|
||||
const fileManagerUrl = isInternal ? `https://file-${details.domain}` : `http://${details.domain}:8080`;
|
||||
const terminalUrl = isInternal ? `https://terminal-${details.domain}` : `http://${details.domain}:8081`;
|
||||
|
||||
const handleUpdateDomain = async () => {
|
||||
if (!newDomain || newDomain === details.domain) return;
|
||||
setIsUpdating(true);
|
||||
try {
|
||||
const response = await fetch('https://web.gise.be/custom_api/update_service_domain.php', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({ order_id: orderId, new_domain: newDomain })
|
||||
});
|
||||
const data = await response.json();
|
||||
if (data.status === 'success') {
|
||||
setIsEditingDomain(false);
|
||||
if (onRefresh) onRefresh();
|
||||
onAlert("Succès", "Domaine VPS mis à jour avec succès sur l'infrastructure !", "success");
|
||||
} else onAlert("Erreur", data.error, "error");
|
||||
} catch (err) {
|
||||
onAlert("Erreur", "Erreur de connexion avec l'API.", "error");
|
||||
} finally { setIsUpdating(false); }
|
||||
};
|
||||
|
||||
const handleGenerateSSL = async () => {
|
||||
setSslStatus('loading');
|
||||
try {
|
||||
const response = await fetch('https://web.gise.be/custom_api/generate_custom_ssl.php', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({ domain: details.domain })
|
||||
});
|
||||
const data = await response.json();
|
||||
if (data.status === 'success') {
|
||||
setSslStatus('success');
|
||||
onAlert("SSL Activé", "Certificat SSL Let's Encrypt généré et activé avec succès !", "success");
|
||||
} else {
|
||||
setSslStatus('error');
|
||||
onAlert("Challenge DNS Échoué", "Vérifiez que votre domaine pointe bien vers notre IP publique.", "error");
|
||||
}
|
||||
} catch (e) { setSslStatus('error'); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{!showTerminal ? (
|
||||
<>
|
||||
<div className="mb-6 bg-gray-950 border border-gray-800 rounded p-4">
|
||||
<label className="block text-xs uppercase tracking-widest text-cyan-500 mb-2">Domaine de l'instance VPS</label>
|
||||
{!isEditingDomain ? (
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="font-mono text-xl text-green-400 font-bold flex items-center gap-2">
|
||||
<span className="h-3 w-3 rounded-full bg-green-500 shadow-[0_0_10px_#22c55e]"></span>
|
||||
{details.domain}
|
||||
</span>
|
||||
<button onClick={() => setIsEditingDomain(true)} className="text-xs border border-gray-700 hover:border-cyan-500 text-gray-400 hover:text-cyan-400 px-3 py-1.5 rounded transition">MODIFIER</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex gap-2">
|
||||
<input type="text" value={newDomain} onChange={(e) => setNewDomain(e.target.value.toLowerCase())} className="flex-1 bg-black border border-gray-700 rounded px-3 py-1.5 text-white font-mono text-sm focus:border-cyan-500 outline-none" disabled={isUpdating} />
|
||||
<button onClick={handleUpdateDomain} disabled={isUpdating} className="bg-cyan-500 text-gray-900 px-4 py-1.5 rounded text-xs font-bold hover:bg-cyan-400">
|
||||
{isUpdating ? 'SYNC...' : 'SAUVEGARDER'}
|
||||
</button>
|
||||
<button onClick={() => setIsEditingDomain(false)} className="bg-gray-800 text-white px-3 py-1.5 rounded text-xs hover:bg-gray-700">ANNULER</button>
|
||||
</div>
|
||||
)}
|
||||
{isCustomDomain && (
|
||||
<div className="mt-4 border-t border-gray-900 pt-3 flex justify-between items-center">
|
||||
<p className="text-[11px] text-gray-500">Domaine externe détecté. Sécurisez après pointage DNS.</p>
|
||||
<button onClick={handleGenerateSSL} disabled={sslStatus === 'loading'} className="text-xs bg-purple-600/20 hover:bg-purple-600 text-purple-400 hover:text-white border border-purple-500/30 px-3 py-1 rounded transition flex items-center gap-1">
|
||||
{sslStatus === 'loading' ? 'GÉNÉRATION...' : <><Lock className="w-3 h-3" /> GÉNÉRER SSL</>}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4 bg-black/60 border border-gray-900 rounded p-3 text-[11px] space-y-1">
|
||||
<span className="text-gray-500 font-bold uppercase tracking-wider block mb-1 text-[10px]">Identifiants d'usine :</span>
|
||||
<p className="text-gray-400 font-mono">Console SSH : <span className="text-cyan-400 font-bold">root</span> / <span className="text-gray-500 italic">Clé choisie à l'initialisation</span></p>
|
||||
<p className="text-gray-400 font-mono">Explorateur : <span className="text-cyan-400 font-bold">root</span> / <span className="text-cyan-400 font-bold">NexusGise2026! (à changer)</span></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
|
||||
<div>
|
||||
<label className="block text-xs uppercase tracking-widest text-cyan-500 mb-1">Explorateur de Fichiers</label>
|
||||
<button onClick={() => window.open(fileManagerUrl, '_blank')} className="w-full bg-gray-950 hover:bg-gray-900 border border-gray-800 hover:border-cyan-500/50 rounded p-3 flex justify-between items-center transition group">
|
||||
<span className="font-mono text-gray-300 text-sm flex items-center gap-2"><Folder className="w-4 h-4 text-cyan-500" /> Ouvrir l'explorateur</span>
|
||||
<ExternalLink className="w-4 h-4 text-gray-600 group-hover:text-cyan-400" />
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs uppercase tracking-widest text-cyan-500 mb-1">Console Système</label>
|
||||
<button onClick={() => setShowTerminal(true)} className="w-full bg-cyan-900/20 hover:bg-cyan-900/40 border border-cyan-800/50 hover:border-cyan-500 rounded p-3 flex justify-between items-center transition group">
|
||||
<span className="font-mono text-cyan-400 text-sm flex items-center gap-2"><span>{">_"}</span> Ouvrir le Terminal</span>
|
||||
<Play className="w-4 h-4 text-cyan-600 group-hover:text-cyan-400" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-4 border-b border-gray-800 pb-2">
|
||||
<span className="text-cyan-400 font-mono text-sm tracking-widest">NEXUS TERMINAL ({details.domain})</span>
|
||||
<div className="flex items-center space-x-2">
|
||||
<button onClick={() => { window.open(terminalUrl, '_blank'); setShowTerminal(false); }} className="text-xs bg-cyan-600/30 text-cyan-400 hover:bg-cyan-600 hover:text-white border border-cyan-500/30 px-3 py-1 rounded transition flex items-center gap-1">
|
||||
<Maximize2 className="w-3 h-3" /> Plein Écran ↗
|
||||
</button>
|
||||
<button onClick={() => setShowTerminal(false)} className="text-xs bg-red-900/30 text-white px-3 py-1 rounded">Fermer</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-black border border-gray-800 rounded h-[400px]">
|
||||
<iframe src={terminalUrl} className="w-full h-full border-none" title="Terminal" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// COMPOSANT 4 : WEB MANAGER (HestiaCP & Day-2 Operations)
|
||||
// ============================================================================
|
||||
const WebManager = ({ details, orderId, onRefresh, onOpenSso, onAlert }) => {
|
||||
const [isEditingDomain, setIsEditingDomain] = useState(false);
|
||||
const [newDomain, setNewDomain] = useState(details.domain);
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
const [sslStatus, setSslStatus] = useState(null);
|
||||
const [isGeneratingSso, setIsGeneratingSso] = useState(false);
|
||||
|
||||
const isCustomDomain = details.domain && !details.domain.endsWith('.gise.be');
|
||||
const siteUrl = (isCustomDomain && sslStatus !== 'success') ? `http://${details.domain}` : `https://${details.domain}`;
|
||||
|
||||
const handleUpdateDomain = async () => {
|
||||
if (!newDomain || newDomain === details.domain) return;
|
||||
setIsUpdating(true);
|
||||
try {
|
||||
const response = await fetch('https://web.gise.be/custom_api/update_service_domain.php', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({ order_id: orderId, new_domain: newDomain })
|
||||
});
|
||||
const data = await response.json();
|
||||
if (data.status === 'success') {
|
||||
setIsEditingDomain(false);
|
||||
if (onRefresh) onRefresh();
|
||||
onAlert("Succès", "Domaine web mis à jour avec succès sur le serveur HestiaCP !", "success");
|
||||
} else onAlert("Erreur", data.error, "error");
|
||||
} catch (err) {
|
||||
onAlert("Erreur", "Erreur de connexion avec l'API.", "error");
|
||||
} finally { setIsUpdating(false); }
|
||||
};
|
||||
|
||||
const handleGenerateSSL = async () => {
|
||||
setSslStatus('loading');
|
||||
try {
|
||||
const response = await fetch('https://web.gise.be/custom_api/generate_custom_ssl.php', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({ domain: details.domain })
|
||||
});
|
||||
const data = await response.json();
|
||||
if (data.status === 'success') {
|
||||
setSslStatus('success');
|
||||
onAlert("SSL Validé", "Certificat SSL Let's Encrypt généré !", "success");
|
||||
} else {
|
||||
setSslStatus('error');
|
||||
onAlert("Erreur DNS", "Échec Let's Encrypt. Vérifiez vos DNS.", "error");
|
||||
}
|
||||
} catch (e) { setSslStatus('error'); }
|
||||
};
|
||||
|
||||
const handleOpenPanel = async () => {
|
||||
setIsGeneratingSso(true);
|
||||
try {
|
||||
const secureHash = Math.random().toString(36).slice(-8) + Math.random().toString(36).slice(-4).toUpperCase();
|
||||
const rollingPassword = `Nx${secureHash}`;
|
||||
await resetHostingPassword(orderId, rollingPassword);
|
||||
onOpenSso({ username: details.username, password: rollingPassword, url: 'https://panel.gise.be/login/' });
|
||||
} catch (err) {
|
||||
onAlert("Erreur Métal", "Erreur lors de la génération du SSO HestiaCP.", "error");
|
||||
} finally {
|
||||
setIsGeneratingSso(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6 bg-gray-950 border border-gray-800 rounded p-4">
|
||||
<label className="block text-xs uppercase tracking-widest text-emerald-500 mb-2">Domaine du Site Web</label>
|
||||
{!isEditingDomain ? (
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="font-mono text-xl text-emerald-400 font-bold flex items-center gap-2">
|
||||
<span className="h-3 w-3 rounded-full bg-emerald-500 shadow-[0_0_10px_#10b981]"></span>
|
||||
{details.domain}
|
||||
</span>
|
||||
<button onClick={() => setIsEditingDomain(true)} className="text-xs border border-gray-700 hover:border-emerald-500 text-gray-400 hover:text-emerald-400 px-3 py-1.5 rounded transition">MODIFIER</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex gap-2">
|
||||
<input type="text" value={newDomain} onChange={(e) => setNewDomain(e.target.value.toLowerCase())} className="flex-1 bg-black border border-gray-700 rounded px-3 py-1.5 text-white font-mono text-sm focus:border-emerald-500 outline-none" disabled={isUpdating} />
|
||||
<button onClick={handleUpdateDomain} disabled={isUpdating} className="bg-emerald-500 text-gray-900 px-4 py-1.5 rounded text-xs font-bold hover:bg-emerald-400">
|
||||
{isUpdating ? 'SYNC...' : 'SAUVEGARDER'}
|
||||
</button>
|
||||
<button onClick={() => setIsEditingDomain(false)} className="bg-gray-800 text-white px-3 py-1.5 rounded text-xs hover:bg-gray-700">ANNULER</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isCustomDomain && (
|
||||
<div className="mt-4 border-t border-gray-900 pt-3 flex justify-between items-center">
|
||||
<p className="text-[11px] text-gray-500">Domaine personnalisé détecté. Sécurisez après pointage DNS.</p>
|
||||
<button onClick={handleGenerateSSL} disabled={sslStatus === 'loading'} className="text-xs bg-purple-600/20 hover:bg-purple-600 text-purple-400 hover:text-white border border-purple-500/30 px-3 py-1 rounded transition flex items-center gap-1">
|
||||
{sslStatus === 'loading' ? 'GÉNÉRATION...' : <><Lock className="w-3 h-3" /> GÉNÉRER SSL</>}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
|
||||
<div>
|
||||
<label className="block text-xs uppercase tracking-widest text-emerald-500 mb-1">Visiter le Site</label>
|
||||
<button onClick={() => window.open(siteUrl, '_blank')} className="w-full bg-gray-950 hover:bg-gray-900 border border-gray-800 hover:border-emerald-500/50 rounded p-3 flex justify-between items-center transition group">
|
||||
<span className="font-mono text-gray-300 text-sm flex items-center gap-2"><Globe className="w-4 h-4 text-emerald-500" /> Ouvrir dans le navigateur</span>
|
||||
<ExternalLink className="w-4 h-4 text-gray-600 group-hover:text-emerald-400" />
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs uppercase tracking-widest text-emerald-500 mb-1">Panel de Contrôle</label>
|
||||
<button onClick={handleOpenPanel} disabled={isGeneratingSso} className="w-full bg-emerald-900/20 hover:bg-emerald-900/40 border border-emerald-800/50 hover:border-emerald-500 rounded p-3 flex justify-between items-center transition group disabled:opacity-50">
|
||||
{isGeneratingSso ? (
|
||||
<span className="font-mono text-emerald-400 text-sm flex items-center gap-2"><Loader className="w-4 h-4 animate-spin" /> GÉNÉRATION SSO...</span>
|
||||
) : (
|
||||
<>
|
||||
<span className="font-mono text-emerald-400 text-sm flex items-center gap-2"><Lock className="w-4 h-4" /> Gérer l'hébergement</span>
|
||||
<ExternalLink className="w-4 h-4 text-emerald-600 group-hover:text-emerald-400" />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// COMPOSANT PRINCIPAL : SERVICES
|
||||
// ============================================================================
|
||||
export default function Services() {
|
||||
const [services, setServices] = useState([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [newVpcName, setNewVpcName] = useState("");
|
||||
const [isConnecting, setIsConnecting] = useState(null);
|
||||
const [ssoVault, setSsoVault] = useState(null);
|
||||
const [activeServiceModal, setActiveServiceModal] = useState(null);
|
||||
const [customAlert, setCustomAlert] = useState(null);
|
||||
|
||||
const { vpcs, createVPC, deleteVPC, assignToVPC, removeFromVPC } = useVPC();
|
||||
|
||||
const triggerAlert = (title, message, type = "info") => {
|
||||
setCustomAlert({ title, message, type });
|
||||
};
|
||||
const triggerAlert = (title, message, type = "info") => setCustomAlert({ title, message, type });
|
||||
|
||||
const fetchServices = useCallback(async () => {
|
||||
try {
|
||||
const data = await getMyServices();
|
||||
if (data.list && data.list.length > 0) {
|
||||
const detailedServices = await Promise.all(
|
||||
data.list.map(async (order) => {
|
||||
let hDetails = null;
|
||||
if (order.status === 'active' && (order.plugin === 'hosting' || (order.title || '').toLowerCase().includes('vps'))) {
|
||||
try { hDetails = await getHostingServiceDetails(order.id); }
|
||||
catch (e) { console.warn(`Détails inaccessibles pour ${order.id}:`, e); }
|
||||
}
|
||||
return { ...order, hostingDetails: hDetails };
|
||||
})
|
||||
);
|
||||
const detailedServices = await Promise.all(data.list.map(async (order) => {
|
||||
let hDetails = null;
|
||||
if (order.status === 'active' && (order.plugin === 'hosting' || (order.title || '').toLowerCase().includes('vps'))) {
|
||||
try { hDetails = await getHostingServiceDetails(order.id); } catch (e) { }
|
||||
}
|
||||
return { ...order, hostingDetails: hDetails };
|
||||
}));
|
||||
|
||||
const filteredServices = detailedServices.filter(s => {
|
||||
const type = (s.type || '').toLowerCase();
|
||||
@@ -498,19 +41,13 @@ export default function Services() {
|
||||
const isGhostProduct = type === 'domain' || title.includes('domain') || title.includes('domaine') || title.includes('enregistrement');
|
||||
return (s.status === 'active' || s.status === 'pending_setup') && !isGhostProduct;
|
||||
});
|
||||
|
||||
setServices(filteredServices);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err.message || "Impossible de charger la télémétrie des services.");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
} catch (err) { setError(err.message || "Impossible de charger la télémétrie."); }
|
||||
finally { setIsLoading(false); }
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchServices();
|
||||
}, [fetchServices]);
|
||||
useEffect(() => { fetchServices(); }, [fetchServices]);
|
||||
|
||||
const handleOpenConsole = async (service) => {
|
||||
const titleLower = (service.title || '').toLowerCase();
|
||||
@@ -520,7 +57,6 @@ export default function Services() {
|
||||
const isWeb = !isVPS && !isCloud && !isDB;
|
||||
|
||||
setIsConnecting(service.id);
|
||||
|
||||
try {
|
||||
if (isCloud) {
|
||||
const titleMatch = (service.title || '').match(/(?: for | pour )(.+)$/i);
|
||||
@@ -532,53 +68,33 @@ export default function Services() {
|
||||
const freshDetails = await getHostingServiceDetails(service.id);
|
||||
setActiveServiceModal({ type: isVPS ? 'vps' : 'web', data: service, details: freshDetails });
|
||||
}
|
||||
} catch (err) {
|
||||
triggerAlert("Erreur d'Orchestration", "Échec du protocole : " + err.message, "error");
|
||||
} finally {
|
||||
setIsConnecting(null);
|
||||
}
|
||||
} catch (err) { triggerAlert("Erreur", err.message, "error"); }
|
||||
finally { setIsConnecting(null); }
|
||||
};
|
||||
|
||||
const assignedServiceIds = vpcs.flatMap(vpc => vpc.services);
|
||||
const freeServices = services.filter(s => !assignedServiceIds.includes(s.id));
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-6xl p-6 mx-auto"> {/* Alignement avec le Dashboard */}
|
||||
<div className="w-full max-w-6xl p-6 mx-auto">
|
||||
<div className="flex flex-col md:flex-row justify-between items-start md:items-end mb-8 gap-4">
|
||||
<div>
|
||||
<header>
|
||||
<h1 className="text-3xl font-black text-white tracking-wider"> {/* Typographie Dashboard */}
|
||||
TERMINAL <span className="text-cyan-400">NEXUS</span>
|
||||
</h1>
|
||||
<h1 className="text-3xl font-black text-white tracking-wider">TERMINAL <span className="text-cyan-400">NEXUS</span></h1>
|
||||
<p className="text-gray-400 mt-2">Orchestration des environnements et des Virtual Private Clouds.</p>
|
||||
</header>
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-2 bg-gray-900 p-2 rounded-xl border border-gray-800">
|
||||
<input type="text" placeholder="Nom du nouveau VPC..." value={newVpcName} onChange={(e) => setNewVpcName(e.target.value)} className="bg-black border border-gray-800 rounded-lg px-4 py-2 text-sm text-white focus:outline-none focus:border-cyan-400 w-64" />
|
||||
<button onClick={() => { createVPC(newVpcName); setNewVpcName(""); triggerAlert("VPC", "Nouveau groupe de routage VPC créé avec succès !", "success"); }} disabled={!newVpcName.trim()} className="bg-cyan-400 text-gray-900 px-4 py-2 rounded-lg font-bold text-sm disabled:opacity-50 hover:bg-cyan-300 flex items-center">
|
||||
<button onClick={() => { createVPC(newVpcName); setNewVpcName(""); triggerAlert("VPC", "Nouveau groupe créé !", "success"); }} disabled={!newVpcName.trim()} className="bg-cyan-400 text-gray-900 px-4 py-2 rounded-lg font-bold text-sm disabled:opacity-50 hover:bg-cyan-300 flex items-center">
|
||||
<Plus className="w-4 h-4 mr-1" /> CRÉER GROUPE
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* GESTION DU CHARGEMENT IDENTIQUE AU DASHBOARD */}
|
||||
{isLoading && (
|
||||
<div className="flex items-center space-x-3 text-cyan-400 mb-8">
|
||||
<Loader className="w-6 h-6 animate-spin" />
|
||||
<span>Analyse du réseau et des instances en cours...</span>
|
||||
</div>
|
||||
)}
|
||||
{isLoading && <div className="flex items-center space-x-3 text-cyan-400 mb-8"><Loader className="w-6 h-6 animate-spin" /><span>Analyse en cours...</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-8"><AlertCircle className="w-6 h-6" /><span>{error}</span></div>}
|
||||
|
||||
{/* GESTION DES ERREURS IDENTIQUE AU DASHBOARD */}
|
||||
{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-8">
|
||||
<AlertCircle className="w-6 h-6" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* CONTENU (Caché pendant le chargement pour rester propre) */}
|
||||
{!isLoading && !error && (
|
||||
<>
|
||||
<div className="space-y-8 mb-12">
|
||||
@@ -588,118 +104,47 @@ export default function Services() {
|
||||
<div key={vpc.id} className="bg-gray-900/40 border border-gray-800 rounded-2xl p-6">
|
||||
<div className="flex justify-between items-center mb-6 border-b border-gray-800 pb-4">
|
||||
<div className="flex items-center space-x-3 text-white">
|
||||
<Folder className="w-6 h-6 text-cyan-400" />
|
||||
<h2 className="text-xl font-bold tracking-wider">{vpc.name.toUpperCase()}</h2>
|
||||
<span className="bg-gray-800 text-gray-400 px-2 py-0.5 rounded text-xs font-mono">{vpcServices.length} INSTANCES</span>
|
||||
<Folder className="w-6 h-6 text-cyan-400" /><h2 className="text-xl font-bold tracking-wider">{vpc.name.toUpperCase()}</h2><span className="bg-gray-800 text-gray-400 px-2 py-0.5 rounded text-xs font-mono">{vpcServices.length} INSTANCES</span>
|
||||
</div>
|
||||
<button onClick={() => { deleteVPC(vpc.id); triggerAlert("VPC Démantelé", "Le groupe de routage a été dissous. Les instances ont été reversées dans le pool libre.", "info"); }} className="text-gray-500 hover:text-red-400 transition-colors flex items-center text-sm">
|
||||
<Trash2 className="w-4 h-4 mr-1" /> Démanteler VPC
|
||||
</button>
|
||||
<button onClick={() => { deleteVPC(vpc.id); triggerAlert("VPC Démantelé", "Instances reversées.", "info"); }} className="text-gray-500 hover:text-red-400 transition-colors flex items-center text-sm"><Trash2 className="w-4 h-4 mr-1" /> Démanteler</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{vpcServices.length === 0 ? (
|
||||
<div className="col-span-full text-gray-600 text-sm border border-dashed border-gray-800 rounded-xl p-6 text-center font-mono">
|
||||
Réseau virtuel vide. Assigner des instances depuis le pool libre.
|
||||
</div>
|
||||
) : (
|
||||
vpcServices.map(service => (
|
||||
<InstanceCard
|
||||
key={service.id} service={service} vpcs={vpcs}
|
||||
onAssignVpc={assignToVPC} onRemoveVpc={removeFromVPC}
|
||||
onOpenConsole={handleOpenConsole} isConnecting={isConnecting}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
{vpcServices.length === 0 ? <div className="col-span-full text-gray-600 text-sm border border-dashed border-gray-800 rounded-xl p-6 text-center font-mono">Vide.</div> : vpcServices.map(service => <InstanceCard key={service.id} service={service} vpcs={vpcs} onAssignVpc={assignToVPC} onRemoveVpc={removeFromVPC} onOpenConsole={handleOpenConsole} isConnecting={isConnecting} />)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-gray-500 tracking-wider mb-6 flex items-center">
|
||||
<Server className="w-5 h-5 mr-2" /> POOL D'INSTANCES LIBRES
|
||||
</h2>
|
||||
<h2 className="text-lg font-bold text-gray-500 tracking-wider mb-6 flex items-center"><Server className="w-5 h-5 mr-2" /> POOL LIBRE</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{freeServices.length === 0 ? (
|
||||
<div className="col-span-full text-gray-600 text-sm border border-gray-900 bg-gray-900/20 rounded-xl p-6 text-center font-mono">
|
||||
Aucune instance libre. Toutes vos accréditations sont assignées à des VPC.
|
||||
</div>
|
||||
) : (
|
||||
freeServices.map(service => (
|
||||
<InstanceCard
|
||||
key={service.id} service={service} vpcs={vpcs}
|
||||
onAssignVpc={assignToVPC} onRemoveVpc={removeFromVPC}
|
||||
onOpenConsole={handleOpenConsole} isConnecting={isConnecting}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
{freeServices.length === 0 ? <div className="col-span-full text-gray-600 text-sm border border-gray-900 bg-gray-900/20 rounded-xl p-6 text-center font-mono">Aucune instance libre.</div> : freeServices.map(service => <InstanceCard key={service.id} service={service} vpcs={vpcs} onAssignVpc={assignToVPC} onRemoveVpc={removeFromVPC} onOpenConsole={handleOpenConsole} isConnecting={isConnecting} />)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* MODAL 1 : HESTIACP SSO VAULT */}
|
||||
{ssoVault && (
|
||||
<div className="fixed inset-0 bg-black/80 backdrop-blur-sm z-[60] flex items-center justify-center p-4">
|
||||
<div className="bg-gray-900 border border-emerald-500/50 rounded-lg shadow-2xl shadow-emerald-500/20 max-w-md w-full p-6 text-gray-200">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h3 className="text-xl font-bold text-emerald-400 font-mono tracking-wider">ACCÈS AUTORISÉ</h3>
|
||||
<button onClick={() => setSsoVault(null)} className="text-gray-400 hover:text-white transition text-lg">✖</button>
|
||||
</div>
|
||||
<p className="text-sm text-gray-400 mb-6">Le pare-feu HestiaCP bloque les injections directes. Un mot de passe <strong>jetable</strong> a été généré. Copiez-le et connectez-vous.</p>
|
||||
<div className="space-y-4 mb-8">
|
||||
<div>
|
||||
<label className="block text-xs uppercase tracking-widest text-emerald-500 mb-1">Utilisateur</label>
|
||||
<div className="flex bg-gray-950 border border-gray-800 rounded p-3 justify-between items-center">
|
||||
<span className="font-mono text-white">{ssoVault.username}</span>
|
||||
<button onClick={() => navigator.clipboard.writeText(ssoVault.username)} className="text-xs bg-gray-800 hover:bg-gray-700 text-white px-3 py-1 rounded transition">Copier</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs uppercase tracking-widest text-emerald-500 mb-1">Clé Éphémère</label>
|
||||
<div className="flex bg-gray-950 border border-gray-800 rounded p-3 justify-between items-center">
|
||||
<span className="font-mono text-emerald-400">{ssoVault.password}</span>
|
||||
<button onClick={() => navigator.clipboard.writeText(ssoVault.password)} className="text-xs bg-emerald-600 hover:bg-emerald-500 text-white px-3 py-1 rounded transition">Copier</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<a href={ssoVault.url} target="_blank" rel="noopener noreferrer" className="block w-full bg-emerald-500 hover:bg-emerald-400 text-gray-950 font-bold py-3 text-center rounded transition font-mono tracking-widest uppercase">
|
||||
Ouvrir le Panel Web ↗
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* MODAL 2 : GESTIONNAIRE DE SERVICE GÉNÉRIQUE (VPS & WEB) */}
|
||||
{activeServiceModal && (
|
||||
<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 rounded-lg shadow-2xl max-w-2xl w-full p-6 text-gray-200 ${activeServiceModal.type === 'vps' ? 'border-cyan-500/50 shadow-cyan-500/20' : 'border-emerald-500/50 shadow-emerald-500/20'}`}>
|
||||
<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">
|
||||
{activeServiceModal.type === 'vps' ? (
|
||||
<><Server className="w-6 h-6 text-cyan-400" /> {activeServiceModal.details?.ip && activeServiceModal.details.ip !== '127.0.0.1' ? "GESTION DE L'INSTANCE" : "PHASE D'INITIALISATION"}</>
|
||||
) : (
|
||||
<><Globe className="w-6 h-6 text-emerald-400" /> GESTION DE L'HÉBERGEMENT 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>
|
||||
<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} />
|
||||
)
|
||||
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} />
|
||||
) : (
|
||||
<WebManager details={activeServiceModal.details} orderId={activeServiceModal.data.id} onRefresh={fetchServices} onOpenSso={setSsoVault} onAlert={triggerAlert} />
|
||||
<WebManager details={activeServiceModal.details} orderId={activeServiceModal.data.id} onRefresh={fetchServices} onAlert={triggerAlert} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* VRAI MODAL DE NOTIFICATION REACT SURCHARGE */}
|
||||
<NotificationModal notification={customAlert} onClose={() => setCustomAlert(null)} />
|
||||
</div>
|
||||
);
|
||||
|
||||
+5
-283
@@ -1,288 +1,16 @@
|
||||
// src/pages/app/Store.jsx
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import { Server, Database, Cloud, Globe, Loader, AlertCircle } from 'lucide-react';
|
||||
import { getProductList } from '../../services/api';
|
||||
import { Server, Database, Cloud, Globe, ShoppingCart, Loader, AlertCircle, ChevronDown, CheckCircle2 } from 'lucide-react';
|
||||
import CategorySection from '../../components/store/CategorySection'; // L'import magique
|
||||
|
||||
// ==========================================
|
||||
// MOTEUR TEMPOREL : Poids et Traductions
|
||||
// ==========================================
|
||||
const parsePeriod = (code) => {
|
||||
if (!code) return { label: '', weight: 0, factorToYear: 1, billingPhrase: '' };
|
||||
const value = parseInt(code);
|
||||
if (code.includes('W')) return {
|
||||
label: `${value} Semaine${value > 1 ? 's' : ''}`,
|
||||
weight: value * 7,
|
||||
factorToYear: 52 / value,
|
||||
billingPhrase: value === 1 ? 'par semaine' : `toutes les ${value} semaines`
|
||||
};
|
||||
if (code.includes('M')) return {
|
||||
label: `${value} Mois`,
|
||||
weight: value * 30,
|
||||
factorToYear: 12 / value,
|
||||
billingPhrase: value === 1 ? 'par mois' : `tous les ${value} mois`
|
||||
};
|
||||
if (code.includes('Y')) return {
|
||||
label: `${value} An${value > 1 ? 's' : ''}`,
|
||||
weight: value * 365,
|
||||
factorToYear: 1 / value,
|
||||
billingPhrase: value === 1 ? 'par an' : `tous les ${value} ans`
|
||||
};
|
||||
return { label: code, weight: 999, factorToYear: 1, billingPhrase: `pour ${code}` };
|
||||
};
|
||||
|
||||
// ==========================================
|
||||
// GÉNÉRATEUR DE BADGES COURTS (Mis à jour pour l'anglais)
|
||||
// ==========================================
|
||||
const getCategoryBadge = (categoryName) => {
|
||||
const t = (categoryName || '').toLowerCase();
|
||||
if (t.includes('web') || t.includes('hosting')) return 'WEB';
|
||||
if (t.includes('vps')) return 'VPS';
|
||||
if (t.includes('data') || t.includes('db')) return 'DB';
|
||||
if (t.includes('cloud')) return 'CLOUD';
|
||||
return 'SRV';
|
||||
};
|
||||
|
||||
// ==========================================
|
||||
// SOUS-COMPOSANT : LA CARTE PRODUIT
|
||||
// ==========================================
|
||||
const ProductCard = ({ product, selectedPeriod, categoryName }) => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const getPricingData = () => {
|
||||
// 1. Gestion des produits payables une seule fois
|
||||
if (product.pricing?.type !== 'recurrent' || !product.pricing?.recurrent) {
|
||||
const oncePrice = product.pricing?.once?.price ? parseFloat(product.pricing.once.price).toFixed(2) : '0.00';
|
||||
return { isAvailable: true, displayPrice: oncePrice, suffix: '(Une fois)', originalPrice: null, savingsPercent: 0, isOnce: true };
|
||||
}
|
||||
|
||||
const recurrentPrices = product.pricing.recurrent;
|
||||
const availablePeriods = Object.keys(recurrentPrices).filter(
|
||||
period => recurrentPrices[period].enabled == 1 || recurrentPrices[period].enabled === true
|
||||
);
|
||||
|
||||
if (!recurrentPrices[selectedPeriod] || !availablePeriods.includes(selectedPeriod)) {
|
||||
return { isAvailable: false };
|
||||
}
|
||||
|
||||
const currentPrice = parseFloat(recurrentPrices[selectedPeriod].price);
|
||||
const currentPeriodInfo = parsePeriod(selectedPeriod);
|
||||
|
||||
// LA MAGIE : On convertit le prix de la période choisie en coût mensuel lissé
|
||||
const currentYearlyCost = currentPrice * currentPeriodInfo.factorToYear;
|
||||
const currentMonthlyEquivalent = currentYearlyCost / 12;
|
||||
|
||||
let savingsPercent = 0;
|
||||
let originalPrice = null;
|
||||
|
||||
// On cherche le forfait le plus court (ex: 1W) pour s'en servir de base de comparaison
|
||||
const sortedAvailablePeriods = [...availablePeriods].sort((a, b) => parsePeriod(a).weight - parsePeriod(b).weight);
|
||||
const basePeriodCode = sortedAvailablePeriods[0];
|
||||
|
||||
if (basePeriodCode !== selectedPeriod) {
|
||||
const basePrice = parseFloat(recurrentPrices[basePeriodCode].price);
|
||||
const basePeriodInfo = parsePeriod(basePeriodCode);
|
||||
// On calcule aussi le prix mensuel lissé de ce forfait de base
|
||||
const baseYearlyCost = basePrice * basePeriodInfo.factorToYear;
|
||||
const baseMonthlyEquivalent = baseYearlyCost / 12;
|
||||
|
||||
if (baseYearlyCost > currentYearlyCost) {
|
||||
savingsPercent = Math.round((1 - (currentYearlyCost / baseYearlyCost)) * 100);
|
||||
originalPrice = baseMonthlyEquivalent.toFixed(2);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isAvailable: true,
|
||||
displayPrice: currentMonthlyEquivalent.toFixed(2), // Le GROS texte principal
|
||||
suffix: '/ mois',
|
||||
originalPrice, // Le texte BARRÉ (null si on est sur la période de base)
|
||||
savingsPercent,
|
||||
billingPrice: currentPrice.toFixed(2), // Ce que la banque va vraiment prélever
|
||||
billingPhrase: currentPeriodInfo.billingPhrase, // "par an", "par semaine", etc.
|
||||
isOnce: false
|
||||
};
|
||||
};
|
||||
|
||||
const priceData = getPricingData();
|
||||
|
||||
return (
|
||||
<div className={`bg-gray-900 border border-gray-800 rounded-2xl overflow-hidden hover:border-cyan-400/50 transition-all duration-300 flex flex-col relative group ${!priceData.isAvailable ? 'opacity-50 grayscale' : ''}`}>
|
||||
|
||||
{/* ENCART ACRO-BADGE */}
|
||||
<div className="absolute top-4 left-4 z-20">
|
||||
<span className="bg-black/60 backdrop-blur-sm text-gray-300 text-xs font-black px-3 py-1 rounded border border-gray-800 tracking-widest shadow-sm">
|
||||
{getCategoryBadge(categoryName)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Badge d'économie */}
|
||||
{priceData.savingsPercent > 0 && priceData.isAvailable && (
|
||||
<div className="absolute top-4 right-4 z-20 bg-emerald-500/10 border border-emerald-500/20 text-emerald-400 text-xs font-bold px-3 py-1 rounded-full animate-pulse shadow-[0_0_15px_rgba(16,185,129,0.2)]">
|
||||
ÉCONOMIE {priceData.savingsPercent}%
|
||||
</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">
|
||||
<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">
|
||||
{/* PRIX PRINCIPAL (Toujours ramené au mois) */}
|
||||
<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>
|
||||
|
||||
{/* BLOC DES PETITES LIGNES BANCAIRES */}
|
||||
<div className="mt-3 min-h-[44px] flex flex-col justify-end">
|
||||
{!priceData.isOnce && (
|
||||
<>
|
||||
{priceData.originalPrice ? (
|
||||
<div className="text-sm text-gray-500">
|
||||
Au lieu de <span className="line-through">{priceData.originalPrice} €</span> / mois
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-gray-600 italic">
|
||||
Tarif de base équivalent
|
||||
</div>
|
||||
)}
|
||||
<div className="text-xs text-cyan-500 mt-1 font-semibold uppercase tracking-wider bg-cyan-500/10 inline-block px-2 py-1 rounded w-max">
|
||||
Facturé {priceData.billingPrice} € {priceData.billingPhrase}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{priceData.isOnce && (
|
||||
<div className="text-sm text-gray-500">Paiement unique</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-red-400 font-medium mt-auto">Non disponible pour cette durée.</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-8 flex-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} />
|
||||
}}
|
||||
>
|
||||
{product.description || "Aucune description technique."}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
|
||||
<button
|
||||
disabled={!priceData.isAvailable}
|
||||
onClick={() => navigate(`/checkout/${product.id}?period=${selectedPeriod}`)}
|
||||
className={`w-full py-3 rounded-lg font-bold tracking-widest transition-all flex justify-center items-center space-x-2
|
||||
${priceData.isAvailable
|
||||
? "bg-cyan-400/10 hover:bg-cyan-400 text-cyan-400 hover:text-gray-900 border border-cyan-400 group-hover:shadow-[0_0_20px_rgba(34,211,238,0.2)]"
|
||||
: "bg-gray-800 text-gray-600 border border-gray-800 cursor-not-allowed"}`}
|
||||
>
|
||||
<ShoppingCart className="w-5 h-5" />
|
||||
<span>COMMANDER</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ==========================================
|
||||
// SOUS-COMPOSANT : LA SECTION
|
||||
// ==========================================
|
||||
const CategorySection = ({ categoryName, products, getCategoryIcon }) => {
|
||||
const availablePeriods = new Set();
|
||||
products.forEach(p => {
|
||||
if (p.pricing?.type === 'recurrent' && p.pricing.recurrent) {
|
||||
Object.keys(p.pricing.recurrent).forEach(period => {
|
||||
// FILTRE DE SÉCURITÉ : On ne garde que les périodes actives (enabled = 1 ou true)
|
||||
const periodData = p.pricing.recurrent[period];
|
||||
if (periodData.enabled == 1 || periodData.enabled === true) {
|
||||
availablePeriods.add(period);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const sortedPeriods = Array.from(availablePeriods).sort((a, b) => parsePeriod(a).weight - parsePeriod(b).weight);
|
||||
const defaultPeriod = sortedPeriods.includes('1M') ? '1M' : sortedPeriods[0];
|
||||
const [sectionPeriod, setSectionPeriod] = useState(defaultPeriod);
|
||||
|
||||
return (
|
||||
<section className="mb-16 bg-black/20 p-6 rounded-3xl border border-gray-800/50">
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between mb-8 pb-6 border-b border-gray-800 space-y-4 md:space-y-0">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="p-3 bg-gray-900 rounded-xl border border-gray-800 shadow-[0_0_15px_rgba(0,0,0,0.5)]">
|
||||
{getCategoryIcon(categoryName)}
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-3xl font-black text-white tracking-wider">{categoryName}</h2>
|
||||
<div className="text-gray-500 text-sm mt-1">
|
||||
{products.length} instance{products.length > 1 ? 's' : ''} disponible{products.length > 1 ? 's' : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{sortedPeriods.length > 0 && (
|
||||
<div className="flex items-center space-x-3 bg-gray-900 p-2 rounded-xl border border-gray-800">
|
||||
<span className="text-sm font-medium text-gray-400 pl-2">Facturation :</span>
|
||||
<div className="relative">
|
||||
<select
|
||||
value={sectionPeriod}
|
||||
onChange={(e) => setSectionPeriod(e.target.value)}
|
||||
className="appearance-none bg-black border border-gray-700 text-cyan-400 font-bold py-2 pl-4 pr-10 rounded-lg outline-none focus:border-cyan-400 transition-colors cursor-pointer hover:bg-gray-950"
|
||||
>
|
||||
{sortedPeriods.map(p => (
|
||||
<option key={p} value={p}>{parsePeriod(p).label}</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown className="absolute right-3 top-2.5 w-5 h-5 text-cyan-400 pointer-events-none" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
{products.map((product) => (
|
||||
<ProductCard
|
||||
key={product.id}
|
||||
product={product}
|
||||
selectedPeriod={sectionPeriod}
|
||||
categoryName={categoryName}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
// ==========================================
|
||||
// COMPOSANT PRINCIPAL : LE MAGASIN (STORE)
|
||||
// ==========================================
|
||||
export default function Store() {
|
||||
const [groupedProducts, setGroupedProducts] = useState({});
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
// ALIGNEMENT PARFAIT SUR TES CATÉGORIES FOSSBILLING
|
||||
const CATEGORY_ORDER = [
|
||||
"Web Hosting",
|
||||
"VPS",
|
||||
"Database",
|
||||
"Cloud"
|
||||
];
|
||||
|
||||
const CATEGORY_MAP = {
|
||||
1: "Web Hosting",
|
||||
4: "VPS",
|
||||
3: "Database",
|
||||
2: "Cloud"
|
||||
};
|
||||
const CATEGORY_ORDER = ["Web Hosting", "VPS", "Database", "Cloud"];
|
||||
const CATEGORY_MAP = { 1: "Web Hosting", 4: "VPS", 3: "Database", 2: "Cloud" };
|
||||
|
||||
useEffect(() => {
|
||||
const fetchCatalog = async () => {
|
||||
@@ -291,10 +19,7 @@ export default function Store() {
|
||||
const products = data.list || [];
|
||||
|
||||
const grid = products.reduce((acc, product) => {
|
||||
// EXTRACTION DE L'ID : On lit le product_category_id envoyé par FOSSBilling
|
||||
const catId = product.product_category_id;
|
||||
|
||||
// TRADUCTION : On cherche le nom dans notre dictionnaire. Si inconnu -> Autres.
|
||||
const catName = CATEGORY_MAP[catId] || 'Autres Services';
|
||||
|
||||
if (!acc[catName]) acc[catName] = [];
|
||||
@@ -320,14 +45,11 @@ export default function Store() {
|
||||
return <Globe className="w-8 h-8 text-emerald-400" />;
|
||||
};
|
||||
|
||||
// MOTEUR DE TRI SANS RISK DE CRASH INDICE
|
||||
const sortedCategoryNames = Object.keys(groupedProducts).sort((a, b) => {
|
||||
let indexA = CATEGORY_ORDER.indexOf(a);
|
||||
let indexB = CATEGORY_ORDER.indexOf(b);
|
||||
|
||||
if (indexA === -1) indexA = 999;
|
||||
if (indexB === -1) indexB = 999;
|
||||
|
||||
return indexA - indexB;
|
||||
});
|
||||
|
||||
|
||||
+53
-292
@@ -1,77 +1,38 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useOutletContext } from 'react-router-dom';
|
||||
import { getClientTickets, createTicket, getHelpdesks, getTicketDetails, replyTicket } from '../../services/api'; // Ajout des routes de détails et réponses
|
||||
import { LifeBuoy, Plus, MessageSquare, Clock, CheckCircle2, Send, AlertCircle, Loader, Lock } from 'lucide-react';
|
||||
import { getClientTickets, createTicket, getHelpdesks, getTicketDetails, replyTicket } from '../../services/api';
|
||||
import { Plus, AlertCircle, Loader } from 'lucide-react';
|
||||
|
||||
// ============================================================================
|
||||
// COMPOSANT 0 : MODAL DE NOTIFICATION (Design System)
|
||||
// ============================================================================
|
||||
const NotificationModal = ({ notification, onClose }) => {
|
||||
if (!notification) return null;
|
||||
const isError = notification.type === 'error';
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/80 backdrop-blur-sm z-[100] flex items-center justify-center p-4">
|
||||
<div className={`bg-gray-900 border ${isError ? 'border-red-500/50 shadow-red-500/10' : 'border-cyan-500/50 shadow-cyan-500/10'} rounded-lg shadow-2xl max-w-sm w-full p-6 text-gray-200`}>
|
||||
<h4 className={`text-lg font-mono font-bold tracking-wider mb-2 ${isError ? 'text-red-400' : 'text-cyan-400'}`}>
|
||||
{notification.title.toUpperCase()}
|
||||
</h4>
|
||||
<p className="text-sm text-gray-400 mb-6">{notification.message}</p>
|
||||
<button onClick={onClose} className={`w-full font-mono py-2 rounded text-xs font-bold transition ${isError ? 'bg-red-600 hover:bg-red-500 text-white' : 'bg-cyan-500 hover:bg-cyan-400 text-gray-950'}`}>
|
||||
COMPRIS
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
// Importation des composants isolés
|
||||
import NotificationModal from '../../components/ui/NotificationModal';
|
||||
import TicketCard from '../../components/support/TicketCard';
|
||||
import CreateTicketModal from '../../components/support/CreateTicketModal';
|
||||
import TicketThreadModal from '../../components/support/TicketThreadModal';
|
||||
|
||||
// ============================================================================
|
||||
// COMPOSANT PRINCIPAL : SUPPORT TICKETS
|
||||
// ============================================================================
|
||||
export default function Support() {
|
||||
const { refreshNavbarTickets } = useOutletContext();
|
||||
|
||||
const [tickets, setTickets] = useState([]);
|
||||
const [helpdesks, setHelpdesks] = useState([]);
|
||||
const [defaultHelpdesk, setDefaultHelpdesk] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isLoadingConversation, setIsLoadingConversation] = useState(false); // Loader spécifique pour le fil de discussion
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
// États pour les formulaires
|
||||
const [subject, setSubject] = useState('');
|
||||
const [message, setMessage] = useState('');
|
||||
const [replyMessage, setReplyMessage] = useState(''); // Stocke le texte de la réponse
|
||||
const [selectedHelpdesk, setSelectedHelpdesk] = useState('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
// États pour les modaux
|
||||
const [customAlert, setCustomAlert] = useState(null);
|
||||
const [isCreatingTicket, setIsCreatingTicket] = useState(false);
|
||||
const [activeTicket, setActiveTicket] = useState(null);
|
||||
const [isLoadingConversation, setIsLoadingConversation] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const messagesEndRef = useRef(null);
|
||||
const scrollToBottom = () => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
};
|
||||
useEffect(() => {
|
||||
if (activeTicket && activeTicket.messages && !isLoadingConversation) {
|
||||
scrollToBottom();
|
||||
}
|
||||
}, [activeTicket?.messages, isLoadingConversation]);
|
||||
const triggerAlert = (title, message, type = "info") => setCustomAlert({ title, message, type });
|
||||
|
||||
const triggerAlert = (title, message, type = "info") => {
|
||||
setCustomAlert({ title, message, type });
|
||||
};
|
||||
|
||||
// Chargement synchrone des tickets et des helpdesks
|
||||
const loadSupportData = useCallback(async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const ticketsData = await getClientTickets();
|
||||
if (ticketsData && ticketsData.list) {
|
||||
setTickets(ticketsData.list.map(tkt => ({
|
||||
id: `TKT-${tkt.id}`,
|
||||
db_id: tkt.id,
|
||||
subject: tkt.subject,
|
||||
id: `TKT-${tkt.id}`, db_id: tkt.id, subject: tkt.subject,
|
||||
department: tkt.helpdesk?.name || 'Support Technique',
|
||||
status: ['open', 'closed', 'on_hold'].includes(tkt.status) ? tkt.status : 'pending',
|
||||
lastUpdate: tkt.updated_at || tkt.created_at || 'Récemment',
|
||||
@@ -83,112 +44,68 @@ export default function Support() {
|
||||
if (hdeskPairs) {
|
||||
const formattedDesks = Object.entries(hdeskPairs).map(([id, name]) => ({ id, name }));
|
||||
setHelpdesks(formattedDesks);
|
||||
|
||||
const nexusDesk = formattedDesks.find(hd => hd.name.toLowerCase().includes('nexus'));
|
||||
if (nexusDesk) {
|
||||
setSelectedHelpdesk(nexusDesk.id);
|
||||
} else if (formattedDesks.length > 0) {
|
||||
setSelectedHelpdesk(formattedDesks[0].id);
|
||||
}
|
||||
if (nexusDesk) setDefaultHelpdesk(nexusDesk.id);
|
||||
else if (formattedDesks.length > 0) setDefaultHelpdesk(formattedDesks[0].id);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err.message || "Impossible de synchroniser le centre de support.");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
} catch (err) { setError(err.message || "Impossible de synchroniser le centre de support."); }
|
||||
finally { setIsLoading(false); }
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadSupportData();
|
||||
}, [loadSupportData]);
|
||||
useEffect(() => { loadSupportData(); }, [loadSupportData]);
|
||||
|
||||
// ACTION CLIC : Charger les messages du ticket depuis FOSSBilling
|
||||
// Ouvre le fil de discussion
|
||||
const handleOpenTicket = async (ticket) => {
|
||||
try {
|
||||
setIsLoadingConversation(true);
|
||||
// On ouvre immédiatement le modal avec une liste de messages vide pour la fluidité
|
||||
setActiveTicket({ ...ticket, messages: [] });
|
||||
|
||||
const details = await getTicketDetails(ticket.db_id);
|
||||
|
||||
if (details && details.messages) {
|
||||
// FOSSBilling renvoie l'auteur dans msg.author.role ('client', 'staff', 'admin')
|
||||
const formattedMessages = details.messages.map(msg => ({
|
||||
sender: msg.author?.role === 'client' ? 'client' : 'staff',
|
||||
text: msg.content,
|
||||
date: msg.created_at || 'Récemment'
|
||||
text: msg.content, date: msg.created_at || 'Récemment'
|
||||
}));
|
||||
|
||||
// Injection dynamique des messages dans le modal actif
|
||||
setActiveTicket(prev => prev ? { ...prev, messages: formattedMessages } : null);
|
||||
}
|
||||
} catch (err) {
|
||||
triggerAlert("Erreur Réseau", "Impossible de récupérer l'historique : " + err.message, "error");
|
||||
} finally {
|
||||
setIsLoadingConversation(false);
|
||||
}
|
||||
} catch (err) { triggerAlert("Erreur", "Impossible de récupérer l'historique : " + err.message, "error"); }
|
||||
finally { setIsLoadingConversation(false); }
|
||||
};
|
||||
|
||||
// ACTION RÉPONSE : Envoyer un message dans le thread actuel
|
||||
const handleReplySubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!replyMessage.trim() || !activeTicket) return;
|
||||
|
||||
// Soumet une réponse au fil de discussion
|
||||
const handleReplySubmit = async (replyText) => {
|
||||
if (!activeTicket) return;
|
||||
try {
|
||||
await replyTicket(activeTicket.db_id, replyMessage);
|
||||
await replyTicket(activeTicket.db_id, replyText);
|
||||
loadSupportData();
|
||||
setReplyMessage(''); // Nettoyer l'input
|
||||
|
||||
// Rechargement instantané du fil de discussion pour afficher le message soumis
|
||||
const details = await getTicketDetails(activeTicket.db_id);
|
||||
if (details && details.messages) {
|
||||
const formattedMessages = details.messages.map(msg => ({
|
||||
sender: msg.author?.role === 'client' ? 'client' : 'staff',
|
||||
text: msg.content,
|
||||
date: msg.created_at || 'Récemment'
|
||||
text: msg.content, date: msg.created_at || 'Récemment'
|
||||
}));
|
||||
setActiveTicket(prev => prev ? { ...prev, messages: formattedMessages } : null);
|
||||
}
|
||||
if (refreshNavbarTickets) refreshNavbarTickets();
|
||||
} catch (err) {
|
||||
triggerAlert("Échec d'envoi", "Votre réponse n'a pas pu être transmise : " + err.message, "error");
|
||||
}
|
||||
} catch (err) { triggerAlert("Échec d'envoi", "Votre réponse n'a pas pu être transmise : " + err.message, "error"); }
|
||||
};
|
||||
|
||||
// Soumission du nouveau ticket
|
||||
const handleCreateTicket = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!subject.trim() || !message.trim() || !selectedHelpdesk) return;
|
||||
|
||||
// Crée un nouveau ticket
|
||||
const handleCreateTicket = async (subject, message, targetHelpdesk) => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await createTicket(subject, message, selectedHelpdesk);
|
||||
await createTicket(subject, message, targetHelpdesk);
|
||||
setIsCreatingTicket(false);
|
||||
setSubject('');
|
||||
setMessage('');
|
||||
loadSupportData();
|
||||
if (refreshNavbarTickets) refreshNavbarTickets();
|
||||
triggerAlert("Ticket Ouvert", "Votre demande a bien été enregistrée sur le Service Desk.", "success");
|
||||
} catch (err) {
|
||||
triggerAlert("Échec", "Erreur lors de la création du ticket : " + err.message, "error");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusConfig = (status) => {
|
||||
switch (status) {
|
||||
case 'open': return { color: 'text-cyan-400', bg: 'bg-cyan-500/10', border: 'border-cyan-500/20', label: 'SUPPORT', icon: <MessageSquare className="w-3 h-3 mr-1" /> };
|
||||
case 'on_hold': return { color: 'text-yellow-400', bg: 'bg-yellow-500/10', border: 'border-yellow-500/20', label: 'RÉPONSE REÇUE', icon: <AlertCircle className="w-3 h-3 mr-1" /> };
|
||||
case 'pending': return { color: 'text-orange-400', bg: 'bg-orange-500/10', border: 'border-orange-500/20', label: 'EN ATTENTE', icon: <Clock className="w-3 h-3 mr-1" /> };
|
||||
case 'closed': return { color: 'text-gray-400', bg: 'bg-gray-500/10', border: 'border-gray-500/20', label: 'RÉSOLU', icon: <CheckCircle2 className="w-3 h-3 mr-1" /> };
|
||||
default: return { color: 'text-gray-400', bg: 'bg-gray-800', border: 'border-gray-700', label: status.toUpperCase() };
|
||||
}
|
||||
} catch (err) { triggerAlert("Échec", "Erreur lors de la création du ticket : " + err.message, "error"); }
|
||||
finally { setIsSubmitting(false); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-6xl p-6 mx-auto">
|
||||
|
||||
{/* HEADER */}
|
||||
<div className="flex flex-col md:flex-row justify-between items-start md:items-end mb-8 gap-4">
|
||||
<div>
|
||||
<header>
|
||||
@@ -198,7 +115,6 @@ export default function Support() {
|
||||
<p className="text-gray-400 mt-2">Canal de communication sécurisé avec les ingénieurs GISE.</p>
|
||||
</header>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setIsCreatingTicket(true)}
|
||||
className="bg-cyan-500 hover:bg-cyan-400 text-gray-900 px-5 py-2.5 rounded-lg font-bold text-sm transition flex items-center font-mono tracking-widest shadow-[0_0_15px_rgba(0,229,255,0.2)]"
|
||||
@@ -207,22 +123,9 @@ export default function Support() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* CHARGEMENT & ERREURS GLOBALES */}
|
||||
{isLoading && (
|
||||
<div className="flex items-center space-x-3 text-cyan-400 mb-8 font-mono">
|
||||
<Loader className="w-6 h-6 animate-spin" />
|
||||
<span>Déchiffrement de la matrice de support...</span>
|
||||
</div>
|
||||
)}
|
||||
{isLoading && <div className="flex items-center space-x-3 text-cyan-400 mb-8 font-mono"><Loader className="w-6 h-6 animate-spin" /><span>Déchiffrement de la matrice de support...</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-8"><AlertCircle className="w-6 h-6" /><span>{error}</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-8">
|
||||
<AlertCircle className="w-6 h-6" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* GRILLE DES TICKETS */}
|
||||
{!isLoading && !error && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{tickets.length === 0 ? (
|
||||
@@ -230,171 +133,29 @@ export default function Support() {
|
||||
Aucun ticket de support actif. L'infrastructure est nominale.
|
||||
</div>
|
||||
) : (
|
||||
tickets.map(ticket => {
|
||||
const conf = getStatusConfig(ticket.status);
|
||||
return (
|
||||
<div
|
||||
key={ticket.id}
|
||||
onClick={() => handleOpenTicket(ticket)} // Modification ici : Appel de la fonction de chargement au lieu du setter direct
|
||||
className="bg-gray-900 border border-gray-800 rounded-xl p-5 hover:border-cyan-400/50 hover:bg-gray-800/50 transition-all cursor-pointer group flex flex-col justify-between min-h-[160px]"
|
||||
>
|
||||
<div>
|
||||
<div className="flex justify-between items-start mb-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-gray-500 font-mono text-xs">{ticket.id}</span>
|
||||
|
||||
{/* LE RADAR : Pastille clignotante en cas de réponse */}
|
||||
{ticket.status === 'on_hold' && (
|
||||
<span className="flex h-2.5 w-2.5 relative" title="Nouveau message en attente de lecture">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-cyan-400 opacity-75"></span>
|
||||
<span className="relative inline-flex rounded-full h-2.5 w-2.5 bg-cyan-500 shadow-[0_0_8px_#00E5FF]"></span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className={`flex items-center px-2 py-1 rounded text-[10px] font-bold border tracking-wider ${conf.color} ${conf.bg} ${conf.border}`}>
|
||||
{conf.icon} {conf.label}
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="text-white font-bold text-lg mb-1 group-hover:text-cyan-400 transition-colors line-clamp-2">
|
||||
{ticket.subject}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="flex justify-between items-center text-xs text-gray-500 font-mono border-t border-gray-800 pt-3 mt-4">
|
||||
<span>Département: {ticket.department}</span>
|
||||
<span className="truncate max-w-[120px]">MàJ: {ticket.lastUpdate}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
tickets.map(ticket => (
|
||||
<TicketCard key={ticket.id} ticket={ticket} onClick={() => handleOpenTicket(ticket)} />
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* MODAL 1 : CRÉATION DE TICKET */}
|
||||
{isCreatingTicket && (
|
||||
<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/30 rounded-lg shadow-2xl max-w-lg 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-2">
|
||||
<Plus className="w-5 h-5 text-cyan-400" /> OUVRIR UNE REQUÊTE
|
||||
</h3>
|
||||
<button onClick={() => setIsCreatingTicket(false)} className="text-gray-400 hover:text-white transition text-xl">✖</button>
|
||||
</div>
|
||||
<CreateTicketModal
|
||||
isOpen={isCreatingTicket}
|
||||
onClose={() => setIsCreatingTicket(false)}
|
||||
onSubmit={handleCreateTicket}
|
||||
helpdesks={helpdesks}
|
||||
defaultHelpdesk={defaultHelpdesk}
|
||||
isSubmitting={isSubmitting}
|
||||
/>
|
||||
|
||||
<form onSubmit={handleCreateTicket} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs uppercase tracking-widest text-cyan-500 mb-1">Sujet de la demande</label>
|
||||
<input type="text" value={subject} onChange={(e) => setSubject(e.target.value)} required placeholder="Ex: Problème d'accès sur l'API" className="w-full bg-black border border-gray-800 text-white p-3 rounded text-sm focus:border-cyan-500 outline-none transition" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs uppercase tracking-widest text-cyan-500 mb-1">
|
||||
Département (Cible Réseau)
|
||||
</label>
|
||||
{/* CHAMP VERROUILLÉ (Read-Only) */}
|
||||
<div className="w-full bg-gray-950 border border-gray-800 text-gray-500 p-3 rounded text-sm font-mono flex items-center justify-between select-none">
|
||||
<span>
|
||||
{helpdesks.find(h => h.id === selectedHelpdesk)?.name || 'Service Desk Nexus'}
|
||||
</span>
|
||||
<Lock className="w-4 h-4 text-gray-700" />
|
||||
</div>
|
||||
<p className="text-[10px] text-gray-600 mt-1">
|
||||
Routage automatique vers les ingénieurs d'infrastructure.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs uppercase tracking-widest text-cyan-500 mb-1">Description détaillée</label>
|
||||
<textarea value={message} onChange={(e) => setMessage(e.target.value)} required rows="4" placeholder="Décrivez votre problème technique ici..." className="w-full bg-black border border-gray-800 text-white p-3 rounded text-sm focus:border-cyan-500 outline-none transition resize-none"></textarea>
|
||||
</div>
|
||||
<button type="submit" disabled={isSubmitting} className="w-full bg-cyan-600 hover:bg-cyan-500 text-white font-bold font-mono tracking-widest uppercase py-3 rounded mt-2 transition shadow-lg shadow-cyan-500/20 disabled:opacity-50">
|
||||
{isSubmitting ? 'CHIFFREMENT ET TRANSMISSION...' : 'TRANSMETTRE AU SUPPORT NEXUS'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<TicketThreadModal
|
||||
ticket={activeTicket}
|
||||
onClose={() => setActiveTicket(null)}
|
||||
onReply={handleReplySubmit}
|
||||
isLoadingConversation={isLoadingConversation}
|
||||
/>
|
||||
|
||||
{/* MODAL 2 : FIL DE DISCUSSION INTERACTIF (THREAD NATIVE) */}
|
||||
{activeTicket && (
|
||||
<div className="fixed inset-0 bg-black/80 backdrop-blur-sm z-50 flex items-center justify-center p-2 sm:p-4">
|
||||
<div className="bg-gray-950 border border-gray-800 rounded-lg shadow-2xl max-w-3xl w-full h-[85vh] flex flex-col">
|
||||
|
||||
{/* Thread Header */}
|
||||
<div className="bg-gray-900 border-b border-gray-800 p-4 sm:p-6 flex justify-between items-start rounded-t-lg">
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<span className="text-cyan-500 font-mono text-sm">{activeTicket.id}</span>
|
||||
<span className="bg-gray-800 text-gray-300 text-[10px] uppercase font-bold tracking-wider px-2 py-0.5 rounded border border-gray-700">
|
||||
{activeTicket.department}
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-white line-clamp-1">{activeTicket.subject}</h3>
|
||||
</div>
|
||||
<button onClick={() => setActiveTicket(null)} className="text-gray-400 hover:text-white transition text-xl bg-black/50 p-2 rounded-lg">✖</button>
|
||||
</div>
|
||||
|
||||
{/* Thread Body (Messages avec état de chargement) */}
|
||||
<div className="flex-1 overflow-y-auto p-4 sm:p-6 space-y-6 scrollbar-thin scrollbar-thumb-gray-800">
|
||||
{isLoadingConversation ? (
|
||||
<div className="flex flex-col items-center justify-center h-full text-cyan-400 gap-2 font-mono text-xs">
|
||||
<Loader className="w-6 h-6 animate-spin" />
|
||||
<span>Téléchargement des paquets de discussion sécurisés...</span>
|
||||
</div>
|
||||
) : activeTicket.messages.length === 0 ? (
|
||||
<div className="text-center text-gray-600 font-mono text-xs pt-10">
|
||||
Aucun message trouvé dans ce fil.
|
||||
</div>
|
||||
) : (
|
||||
activeTicket.messages.map((msg, idx) => (
|
||||
<div key={idx} className={`flex flex-col ${msg.sender === 'client' ? 'items-end' : 'items-start'}`}>
|
||||
<div className="flex items-baseline gap-2 mb-1 px-1">
|
||||
<span className={`text-[10px] font-bold uppercase tracking-widest ${msg.sender === 'client' ? 'text-gray-500' : 'text-cyan-400'}`}>
|
||||
{msg.sender === 'client' ? 'VOUS' : 'INGÉNIEUR GISE'}
|
||||
</span>
|
||||
<span className="text-[10px] text-gray-600 font-mono">{msg.date}</span>
|
||||
</div>
|
||||
<div className={`p-4 rounded-xl max-w-[85%] text-sm leading-relaxed ${msg.sender === 'client'
|
||||
? 'bg-gray-800 text-gray-200 rounded-tr-none'
|
||||
: 'bg-cyan-900/20 border border-cyan-800/30 text-cyan-50 rounded-tl-none'
|
||||
}`}>
|
||||
{msg.text}
|
||||
</div>
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Thread Footer (Formulaire de réponse branché) */}
|
||||
{activeTicket.status !== 'closed' ? (
|
||||
<form onSubmit={handleReplySubmit} className="bg-gray-900 border-t border-gray-800 p-4 rounded-b-lg">
|
||||
<div className="flex gap-2">
|
||||
<textarea
|
||||
rows="2"
|
||||
value={replyMessage}
|
||||
onChange={(e) => setReplyMessage(e.target.value)}
|
||||
required
|
||||
placeholder="Taper une réponse sécurisée..."
|
||||
className="flex-1 bg-black border border-gray-800 text-white p-3 rounded-lg text-sm focus:border-cyan-500 outline-none transition resize-none"
|
||||
></textarea>
|
||||
<button
|
||||
type="submit"
|
||||
className="bg-cyan-600 hover:bg-cyan-500 text-white px-6 rounded-lg font-bold font-mono tracking-widest transition flex flex-col items-center justify-center gap-1 shadow-lg shadow-cyan-500/20"
|
||||
>
|
||||
<Send className="w-4 h-4" />
|
||||
<span className="text-[10px]">ENVOYER</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<div className="bg-gray-900 border-t border-gray-800 p-4 rounded-b-lg text-center font-mono text-sm text-gray-500">
|
||||
[ CE TICKET EST VERROUILLÉ ET ARCHIVÉ ]
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* VRAI MODAL DE NOTIFICATION */}
|
||||
<NotificationModal notification={customAlert} onClose={() => setCustomAlert(null)} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
const VpsDeployer = ({ serviceId }) => {
|
||||
// Nouveaux états pour le Domaine et le Mot de passe
|
||||
const [domain, setDomain] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [isDeploying, setIsDeploying] = useState(false);
|
||||
const [result, setResult] = useState(null);
|
||||
|
||||
const handleDeploy = async () => {
|
||||
if (password.length < 8) {
|
||||
alert("Le mot de passe doit faire au moins 8 caractères.");
|
||||
return;
|
||||
}
|
||||
if (!domain.includes('.')) {
|
||||
alert("Veuillez entrer un nom de domaine valide (ex: mon-projet.fr).");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsDeploying(true);
|
||||
|
||||
try {
|
||||
// L'appel vers ton Pont PHP avec les nouvelles données
|
||||
const response = await fetch('https://web.gise.be/custom_api/proxmox_create_vps.php', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
service_id: serviceId, // L'ID du produit dans FOSSBilling
|
||||
domain: domain, // Le domaine choisi par le client
|
||||
password: password
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.status === 'success') {
|
||||
setResult(data);
|
||||
} else {
|
||||
alert("Erreur de déploiement : " + data.message);
|
||||
}
|
||||
} catch (err) {
|
||||
alert("Erreur de connexion au serveur de provisionnement.");
|
||||
} finally {
|
||||
setIsDeploying(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-gray-900 border border-cyan-500/30 p-6 rounded-lg text-gray-200 mt-4 shadow-xl">
|
||||
<h3 className="text-xl font-mono text-cyan-400 mb-4">Enregistrement & Instanciation</h3>
|
||||
|
||||
{!result ? (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-gray-400">
|
||||
Configuration système : <span className="text-white">Ubuntu 26.04 LTS (Docker Ready)</span>
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs uppercase tracking-widest text-cyan-500 mb-1">Nom de domaine (Hostname) :</label>
|
||||
<input
|
||||
type="text"
|
||||
value={domain}
|
||||
onChange={(e) => setDomain(e.target.value.toLowerCase())}
|
||||
className="w-full bg-gray-950 border border-gray-700 text-white p-3 rounded font-mono focus:border-cyan-500 outline-none"
|
||||
placeholder="ex: srv1.mon-domaine.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs uppercase tracking-widest text-cyan-500 mb-1">Mot de passe ROOT :</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full bg-gray-950 border border-gray-700 text-white p-3 rounded font-mono focus:border-cyan-500 outline-none"
|
||||
placeholder="Créez un mot de passe fort"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleDeploy}
|
||||
disabled={isDeploying}
|
||||
className={`w-full py-3 mt-4 rounded font-bold font-mono tracking-widest uppercase transition ${isDeploying ? 'bg-gray-700 text-gray-400 cursor-not-allowed' : 'bg-cyan-600 hover:bg-cyan-500 text-white shadow-lg shadow-cyan-500/20'}`}
|
||||
>
|
||||
{isDeploying ? 'Synchronisation & Déploiement...' : 'Déployer le Serveur'}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="bg-green-900/20 border border-green-500/50 p-4 rounded text-green-400 text-center font-mono font-bold">
|
||||
✓ INSTANCE ENREGISTRÉE ET OPÉRATIONNELLE
|
||||
</div>
|
||||
<div className="flex justify-between bg-gray-950 p-3 rounded border border-gray-800">
|
||||
<span className="text-gray-400">Domaine lié:</span>
|
||||
<span className="text-white">{result.domain}</span>
|
||||
</div>
|
||||
<div className="flex justify-between bg-gray-950 p-3 rounded border border-gray-800">
|
||||
<span className="text-gray-400">Adresse IP Allouée:</span>
|
||||
<span className="text-cyan-400 font-bold">{result.ip}</span>
|
||||
</div>
|
||||
<p className="text-xs text-center text-gray-500 mt-4">
|
||||
Ce serveur est maintenant rattaché à votre compte. Vous pouvez vous y connecter en SSH via l'IP ou le domaine (une fois les DNS propagés).
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default VpsDeployer;
|
||||
@@ -1,112 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
// Ce composant reçoit les détails du service depuis ton API FOSSBilling habituelle
|
||||
const VpsManager = ({ serviceDetails }) => {
|
||||
const [isActionPending, setIsActionPending] = useState(false);
|
||||
|
||||
// Fonction pour envoyer des ordres d'alimentation à Proxmox (via ton API)
|
||||
const handlePowerAction = async (action) => {
|
||||
if (!window.confirm(`Êtes-vous sûr de vouloir ${action} ce VPS ?`)) return;
|
||||
setIsActionPending(true);
|
||||
|
||||
try {
|
||||
// Tu devras créer un petit fichier 'proxmox_power.php' plus tard pour gérer ça
|
||||
alert(`Signal d'alimentation "${action}" envoyé à l'hyperviseur.`);
|
||||
// const response = await fetch('https://web.gise.be/custom_api/proxmox_power.php', { ... })
|
||||
} catch (error) {
|
||||
alert("Erreur de communication avec l'infrastructure.");
|
||||
} finally {
|
||||
setIsActionPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Si Proxmox n'a pas encore fini de configurer, on affiche un loader
|
||||
if (!serviceDetails.ip) {
|
||||
return (
|
||||
<div className="bg-gray-900 border border-cyan-500/30 p-6 rounded-lg text-gray-200 mt-4 text-center">
|
||||
<div className="animate-pulse flex flex-col items-center">
|
||||
<div className="h-8 w-8 border-4 border-cyan-500 border-t-transparent rounded-full animate-spin mb-4"></div>
|
||||
<p className="text-cyan-400 font-mono tracking-widest text-sm">PROVISIONNEMENT EN COURS...</p>
|
||||
<p className="text-xs text-gray-500 mt-2">Votre serveur est en cours d'assemblage sur le VLAN 60. Cela prend environ 10 secondes.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-gray-900 border border-cyan-500/50 shadow-xl shadow-cyan-500/10 p-6 rounded-lg text-gray-200 mt-4">
|
||||
<div className="flex justify-between items-center mb-6 border-b border-gray-800 pb-4">
|
||||
<h3 className="text-xl font-mono text-white flex items-center gap-3">
|
||||
<span className="h-3 w-3 rounded-full bg-green-500 shadow-[0_0_10px_#22c55e]"></span>
|
||||
NEXUS COMPUTE INSTANCE
|
||||
</h3>
|
||||
<span className="bg-gray-950 border border-gray-700 px-3 py-1 rounded text-xs text-gray-400 font-mono">
|
||||
ID: {serviceDetails.id}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
|
||||
{/* BLOC RÉSEAU */}
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs uppercase tracking-widest text-cyan-500 mb-1">Adresse IP (VLAN 60)</label>
|
||||
<div className="bg-gray-950 border border-gray-800 rounded p-3 font-mono text-green-400 font-bold">
|
||||
{serviceDetails.ip}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs uppercase tracking-widest text-cyan-500 mb-1">Hostname Interne</label>
|
||||
<div className="bg-gray-950 border border-gray-800 rounded p-3 font-mono text-gray-300 text-sm">
|
||||
{serviceDetails.domain}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* BLOC ACCÈS */}
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs uppercase tracking-widest text-cyan-500 mb-1">Utilisateur SSH</label>
|
||||
<div className="bg-gray-950 border border-gray-800 rounded p-3 font-mono text-white">
|
||||
root
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs uppercase tracking-widest text-cyan-500 mb-1">Mot de passe Root</label>
|
||||
<div className="flex bg-gray-950 border border-gray-800 rounded p-3 justify-between items-center">
|
||||
<span className="font-mono text-gray-400 text-sm">
|
||||
{serviceDetails.password || serviceDetails.pass}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => navigator.clipboard.writeText(serviceDetails.password || serviceDetails.pass)}
|
||||
className="text-xs bg-gray-800 hover:bg-gray-700 text-white px-3 py-1 rounded transition"
|
||||
>Copier</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* CONTRÔLES D'ALIMENTATION */}
|
||||
<div className="bg-gray-950 rounded border border-gray-800 p-4">
|
||||
<label className="block text-xs uppercase tracking-widest text-gray-500 mb-3">Contrôles d'Alimentation</label>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => handlePowerAction('redémarrer')}
|
||||
disabled={isActionPending}
|
||||
className="flex-1 bg-gray-800 hover:bg-gray-700 text-white text-sm py-2 rounded transition font-mono border border-gray-600"
|
||||
>
|
||||
↻ Reboot
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handlePowerAction('éteindre')}
|
||||
disabled={isActionPending}
|
||||
className="flex-1 bg-red-900/30 hover:bg-red-900/50 text-red-400 text-sm py-2 rounded transition font-mono border border-red-900/50"
|
||||
>
|
||||
■ Stop
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default VpsManager;
|
||||
@@ -1,34 +0,0 @@
|
||||
// composant VpsServiceDetail.jsx
|
||||
import React, { useState } from 'react';
|
||||
// Tu as déjà ces composants de la discussion précédente :
|
||||
// import VpsDeployer from './VpsDeployer';
|
||||
// import VpsManager from './VpsManager';
|
||||
|
||||
const VpsServiceDetail = ({ serviceDetails }) => {
|
||||
|
||||
// Le test logique : Si le service a une IP, il est déjà créé sur Proxmox.
|
||||
const isProvisioned = serviceDetails.ip && serviceDetails.ip !== '';
|
||||
|
||||
return (
|
||||
<div className="p-4">
|
||||
<h2 className="text-2xl font-bold text-white mb-4">Gestion VPS</h2>
|
||||
|
||||
{isProvisioned ? (
|
||||
// Étape 4 (après création) : Le tableau de bord
|
||||
<VpsManager serviceDetails={serviceDetails} />
|
||||
) : (
|
||||
// Étape 3 (avant création) : Le formulaire de déploiement
|
||||
<div>
|
||||
<div className="bg-yellow-900/30 border border-yellow-600/50 p-4 rounded mb-6 text-yellow-500">
|
||||
<p className="font-bold">Votre VPS est prêt à être instancié !</p>
|
||||
<p className="text-sm">La facturation est activée. Veuillez configurer les accès initiaux pour lancer la création sur l'infrastructure (VLAN 60).</p>
|
||||
</div>
|
||||
{/* On passe le service_id au composant Deployer */}
|
||||
<VpsDeployer serviceId={serviceDetails.id} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default VpsServiceDetail;
|
||||
+10
-50
@@ -2,68 +2,28 @@ import { Link } from 'react-router-dom';
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div style={{ textAlign: 'center', marginTop: '12vh' }}>
|
||||
|
||||
<h1 style={{
|
||||
color: '#FFF',
|
||||
fontSize: '4rem',
|
||||
letterSpacing: '6px',
|
||||
display: 'flex',
|
||||
alignItems: 'baseline',
|
||||
justifyContent: 'center',
|
||||
gap: '12px',
|
||||
margin: '0 0 20px 0'
|
||||
}}>
|
||||
<div className="text-center mt-[12vh]">
|
||||
<h1 className="text-white text-5xl md:text-6xl tracking-[6px] flex items-baseline justify-center gap-3 mb-5 font-black">
|
||||
NEXUS
|
||||
<span style={{
|
||||
color: '#00E5FF',
|
||||
fontSize: '1.4rem',
|
||||
letterSpacing: '2px',
|
||||
fontWeight: 'normal'
|
||||
}}>
|
||||
<span className="text-cyan-400 text-xl md:text-2xl tracking-[2px] font-normal">
|
||||
by GISE
|
||||
</span>
|
||||
</h1>
|
||||
|
||||
<h2 style={{
|
||||
color: '#FFFFFF',
|
||||
fontSize: '1.5rem',
|
||||
letterSpacing: '2px',
|
||||
textTransform: 'uppercase',
|
||||
marginBottom: '20px',
|
||||
fontWeight: 'normal'
|
||||
}}>
|
||||
<h2 className="text-white text-xl md:text-2xl tracking-[2px] uppercase mb-5 font-normal">
|
||||
Bienvenue dans l'infrastructure
|
||||
</h2>
|
||||
|
||||
<p style={{
|
||||
color: '#A0A0A0',
|
||||
fontSize: '1.2rem',
|
||||
maxWidth: '600px',
|
||||
margin: '0 auto',
|
||||
lineHeight: '1.6'
|
||||
}}>
|
||||
<p className="text-[#A0A0A0] text-lg max-w-150 mx-auto leading-relaxed">
|
||||
Hébergement web, instances VPS et Stockage Cloud en Belgique.
|
||||
<br />Propulsé par une architecture bare-metal locale.
|
||||
</p>
|
||||
|
||||
{/* LE BOUTON D'ACTION PRINCIPAL REDIRIGE VERS /REGISTER */}
|
||||
<div style={{ marginTop: '50px' }}>
|
||||
<Link to="/register" style={{
|
||||
display: 'inline-block',
|
||||
backgroundColor: 'transparent',
|
||||
color: '#00E5FF',
|
||||
border: '1px solid #00E5FF',
|
||||
padding: '12px 24px',
|
||||
fontSize: '1rem',
|
||||
fontFamily: 'monospace',
|
||||
textDecoration: 'none',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '1px',
|
||||
fontWeight: 'bold',
|
||||
transition: 'all 0.3s ease',
|
||||
boxShadow: '0 0 15px rgba(0, 229, 255, 0.1)',
|
||||
}}>
|
||||
<div className="mt-12">
|
||||
<Link
|
||||
to="/register"
|
||||
className="inline-block bg-transparent text-cyan-400 border border-cyan-400 px-6 py-3 font-mono text-base uppercase tracking-[1px] font-bold transition-all duration-300 shadow-[0_0_15px_rgba(0,229,255,0.1)] hover:bg-cyan-400/10 hover:shadow-[0_0_20px_rgba(0,229,255,0.3)] no-underline rounded-sm"
|
||||
>
|
||||
Démarrer le déploiement
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
+19
-49
@@ -15,23 +15,9 @@ export default function Login() {
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
// 1. APPEL À TON API BACKEND (FOSSBilling / PHP)
|
||||
// Ici, tu mettras ton vrai 'fetch' vers ton serveur pour vérifier le mot de passe.
|
||||
// Pour l'instant, on simule un délai réseau d'une seconde.
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
// --- SIMULATION D'AUTHENTIFICATION ---
|
||||
// (À remplacer par la vraie validation de ton serveur)
|
||||
await loginClient(email, password);
|
||||
|
||||
// 2. LA CLÉ DU PROBLÈME EST ICI : L'ATTRIBUTION DU BADGE
|
||||
// On sauvegarde le token (généralement renvoyé par ton API) dans le navigateur
|
||||
localStorage.setItem('gise_token', 'secure_token_alphanumerique_factice');
|
||||
|
||||
// 3. AUTORISATION ET REDIRECTION
|
||||
// Maintenant que le token est en poche, ProtectedRoute nous laissera passer !
|
||||
navigate('/dashboard');
|
||||
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
@@ -39,72 +25,56 @@ export default function Login() {
|
||||
}
|
||||
};
|
||||
|
||||
// --- DESIGN SYSTEM "BUNKER" ---
|
||||
const inputStyle = {
|
||||
width: '100%', padding: '10px', marginBottom: '20px',
|
||||
backgroundColor: '#1A1A1A', color: '#00E5FF',
|
||||
border: '1px solid #333', fontFamily: 'monospace', outline: 'none',
|
||||
boxSizing: 'border-box'
|
||||
};
|
||||
|
||||
const buttonStyle = {
|
||||
width: '100%', padding: '12px', backgroundColor: loading ? '#333' : '#00E5FF',
|
||||
color: loading ? '#888' : '#000', border: 'none', cursor: loading ? 'not-allowed' : 'pointer',
|
||||
fontFamily: 'monospace', fontWeight: 'bold', textTransform: 'uppercase', letterSpacing: '1px',
|
||||
marginTop: '10px'
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', marginTop: '10vh', padding: '0 20px' }}>
|
||||
<div style={{
|
||||
width: '100%', maxWidth: '400px', backgroundColor: '#242424',
|
||||
padding: '30px', borderTop: '4px solid #00E5FF', boxShadow: '0 10px 30px rgba(0,0,0,0.5)'
|
||||
}}>
|
||||
<h2 style={{ color: '#FFF', textTransform: 'uppercase', marginBottom: '5px', fontSize: '1.5rem', textAlign: 'center' }}>
|
||||
<div className="flex justify-center mt-[10vh] px-5">
|
||||
<div className="w-full max-w-100 bg-[#242424] p-8 border-t-4 border-cyan-400 shadow-[0_10px_30px_rgba(0,0,0,0.5)]">
|
||||
<h2 className="text-white uppercase mb-1 text-2xl text-center font-sans font-bold">
|
||||
Connexion au Nexus
|
||||
</h2>
|
||||
<p style={{ color: '#888', fontFamily: 'monospace', fontSize: '0.85rem', marginBottom: '25px', textAlign: 'center' }}>
|
||||
<p className="text-[#888] font-mono text-sm mb-6 text-center">
|
||||
[ IDENTIFICATION REQUISE ]
|
||||
</p>
|
||||
|
||||
{error && (
|
||||
<div style={{
|
||||
color: '#ff003c', border: '1px solid #ff003c', backgroundColor: 'rgba(255, 0, 60, 0.1)',
|
||||
padding: '10px', marginBottom: '20px', fontFamily: 'monospace', fontSize: '0.85rem'
|
||||
}}>
|
||||
<div className="text-[#ff003c] border border-[#ff003c] bg-[#ff003c]/10 p-3 mb-5 font-mono text-sm">
|
||||
[ ALERTE ] : {error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleLogin}>
|
||||
<label style={{ display: 'block', color: '#888', marginBottom: '5px', fontSize: '0.8rem' }}>IDENTIFIANT (E-MAIL)</label>
|
||||
<form onSubmit={handleLogin} className="font-mono">
|
||||
<label className="block text-[#888] mb-1 text-xs tracking-widest uppercase">Identifiant (E-mail)</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
style={inputStyle}
|
||||
required
|
||||
placeholder="admin@gise.be"
|
||||
className="w-full p-3 mb-5 bg-[#1A1A1A] text-cyan-400 border border-[#333] outline-none focus:border-cyan-400 transition-colors"
|
||||
/>
|
||||
|
||||
<label style={{ display: 'block', color: '#888', marginBottom: '5px', fontSize: '0.8rem' }}>CLÉ D'ACCÈS SÉCURISÉE</label>
|
||||
<label className="block text-[#888] mb-1 text-xs tracking-widest uppercase">Clé d'accès sécurisée</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
style={inputStyle}
|
||||
required
|
||||
placeholder="••••••••"
|
||||
className="w-full p-3 mb-5 bg-[#1A1A1A] text-cyan-400 border border-[#333] outline-none focus:border-cyan-400 transition-colors"
|
||||
/>
|
||||
|
||||
<button type="submit" style={buttonStyle} disabled={loading}>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full p-3 mt-2 font-bold uppercase tracking-widest transition-colors disabled:bg-[#333] disabled:text-[#888] disabled:cursor-not-allowed bg-cyan-400 hover:bg-cyan-300 text-black shadow-[0_0_15px_rgba(0,229,255,0.2)]"
|
||||
>
|
||||
{loading ? 'VÉRIFICATION...' : 'OUVRIR LE SAS'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div style={{ marginTop: '20px', textAlign: 'center', fontSize: '0.85rem' }}>
|
||||
<Link to="/register" style={{ color: '#888', textDecoration: 'none' }}>
|
||||
Aucun accès réseau ? <span style={{ color: '#00E5FF' }}>S'enregistrer ></span>
|
||||
<div className="mt-5 text-center text-sm font-mono text-[#888]">
|
||||
Aucun accès réseau ?{' '}
|
||||
<Link to="/register" className="text-cyan-400 hover:text-cyan-300 transition-colors no-underline">
|
||||
S'enregistrer >
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+35
-115
@@ -1,50 +1,8 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate, Link } from 'react-router-dom';
|
||||
import { registerUnifiedClient } from '../../services/api';
|
||||
import NotificationModal from '../../components/ui/NotificationModal';
|
||||
|
||||
// ============================================================================
|
||||
// COMPOSANT : MODAL DE NOTIFICATION (Succès / Erreur)
|
||||
// ============================================================================
|
||||
const NotificationModal = ({ notification, onClose }) => {
|
||||
if (!notification) return null;
|
||||
const isError = notification.type === 'error';
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/80 backdrop-blur-sm z-[100] flex items-center justify-center p-4" style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, backgroundColor: 'rgba(0,0,0,0.8)', backdropFilter: 'blur(4px)', zIndex: 100, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<div style={{
|
||||
backgroundColor: '#1A1A1A',
|
||||
border: `1px solid ${isError ? '#ff003c' : '#00E5FF'}`,
|
||||
boxShadow: `0 10px 30px ${isError ? 'rgba(255, 0, 60, 0.2)' : 'rgba(0, 229, 255, 0.2)'}`,
|
||||
borderRadius: '8px',
|
||||
maxWidth: '400px',
|
||||
width: '100%',
|
||||
padding: '24px',
|
||||
color: '#FFF',
|
||||
fontFamily: 'monospace'
|
||||
}}>
|
||||
<h4 style={{ fontSize: '1.1rem', fontWeight: 'bold', letterSpacing: '1px', marginBottom: '10px', color: isError ? '#ff003c' : '#00E5FF' }}>
|
||||
{notification.title.toUpperCase()}
|
||||
</h4>
|
||||
<p style={{ fontSize: '0.9rem', color: '#AAA', marginBottom: '24px', whiteSpace: 'pre-line', lineHeight: '1.5' }}>
|
||||
{notification.message}
|
||||
</p>
|
||||
<button onClick={onClose} style={{
|
||||
width: '100%', padding: '10px',
|
||||
backgroundColor: isError ? '#ff003c' : '#00E5FF',
|
||||
color: isError ? '#FFF' : '#000',
|
||||
border: 'none', borderRadius: '4px',
|
||||
fontFamily: 'monospace', fontWeight: 'bold', cursor: 'pointer', letterSpacing: '1px'
|
||||
}}>
|
||||
COMPRIS
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// COMPOSANT PRINCIPAL : REGISTER
|
||||
// ============================================================================
|
||||
export default function Register() {
|
||||
const [firstName, setFirstName] = useState('');
|
||||
const [lastName, setLastName] = useState('');
|
||||
@@ -54,7 +12,7 @@ export default function Register() {
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [customAlert, setCustomAlert] = useState(null); // Remplace l'état "error" et "alert()"
|
||||
const [customAlert, setCustomAlert] = useState(null);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleRegister = async (e) => {
|
||||
@@ -62,119 +20,81 @@ export default function Register() {
|
||||
setCustomAlert(null);
|
||||
|
||||
const passwordPolicy = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[\W_]).{8,}$/;
|
||||
|
||||
// VÉRIFICATIONS FRONTEND (Affiche le Modal d'Erreur)
|
||||
if (!passwordPolicy.test(password)) {
|
||||
setCustomAlert({ type: 'error', title: 'Sécurité compromise', message: "Le mot de passe doit contenir 8 caractères min, une majuscule, une minuscule, un chiffre et un caractère spécial." });
|
||||
return;
|
||||
}
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
setCustomAlert({ type: 'error', title: 'Erreur de saisie', message: "Les clés d'accès (mots de passe) ne correspondent pas." });
|
||||
setCustomAlert({ type: 'error', title: 'Erreur de saisie', message: "Les clés d'accès ne correspondent pas." });
|
||||
return;
|
||||
}
|
||||
|
||||
const validUsername = /^[a-zA-Z0-9]{3,12}$/.test(username);
|
||||
if (!validUsername) {
|
||||
setCustomAlert({ type: 'error', title: 'Identifiant invalide', message: "Le nom d'utilisateur doit contenir uniquement des lettres ou chiffres (entre 3 et 12 caractères, sans espace)." });
|
||||
if (!/^[a-zA-Z0-9]{3,12}$/.test(username)) {
|
||||
setCustomAlert({ type: 'error', title: 'Identifiant invalide', message: "Le nom d'utilisateur doit contenir uniquement des lettres ou chiffres (entre 3 et 12 caractères)." });
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
// API BACKEND
|
||||
await registerUnifiedClient(email, username, password, firstName, lastName);
|
||||
|
||||
// SUCCÈS (Affiche le Modal de Succès)
|
||||
setCustomAlert({
|
||||
type: 'success',
|
||||
title: 'PROVISIONNEMENT RÉUSSI',
|
||||
message: "Vos comptes FOSSBilling, HestiaCP et Nextcloud ont été initialisés.\n\nVous pouvez maintenant vous connecter à l'infrastructure."
|
||||
});
|
||||
|
||||
setCustomAlert({ type: 'success', title: 'PROVISIONNEMENT RÉUSSI', message: "Vos comptes FOSSBilling, HestiaCP et Nextcloud ont été initialisés.\n\nVous pouvez maintenant vous connecter." });
|
||||
} catch (err) {
|
||||
// ERREUR API (Affiche le Modal d'Erreur API)
|
||||
setCustomAlert({ type: 'error', title: 'Échec du Déploiement', message: err.message || "Échec de l'initialisation de l'infrastructure." });
|
||||
setCustomAlert({ type: 'error', title: 'Échec du Déploiement', message: err.message || "Échec de l'initialisation." });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Fermeture du Modal : Redirige vers le Login SI c'était un succès
|
||||
const handleCloseModal = () => {
|
||||
if (customAlert?.type === 'success') {
|
||||
navigate('/login');
|
||||
} else {
|
||||
setCustomAlert(null);
|
||||
}
|
||||
};
|
||||
|
||||
// Styles
|
||||
const inputStyle = {
|
||||
width: '100%', padding: '10px', marginBottom: '15px',
|
||||
backgroundColor: '#1A1A1A', color: '#00E5FF',
|
||||
border: '1px solid #333', fontFamily: 'monospace', outline: 'none',
|
||||
boxSizing: 'border-box'
|
||||
};
|
||||
|
||||
const buttonStyle = {
|
||||
width: '100%', padding: '12px', backgroundColor: loading ? '#333' : '#00E5FF',
|
||||
color: loading ? '#888' : '#000', border: 'none', cursor: loading ? 'not-allowed' : 'pointer',
|
||||
fontFamily: 'monospace', fontWeight: 'bold', textTransform: 'uppercase', letterSpacing: '1px',
|
||||
marginTop: '10px'
|
||||
if (customAlert?.type === 'success') navigate('/login');
|
||||
else setCustomAlert(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', marginTop: '5vh', padding: '0 20px' }}>
|
||||
<div style={{
|
||||
width: '100%', maxWidth: '500px', backgroundColor: '#242424',
|
||||
padding: '30px', borderTop: '4px solid #00E5FF', boxShadow: '0 10px 30px rgba(0,0,0,0.5)'
|
||||
}}>
|
||||
<h2 style={{ color: '#FFF', textTransform: 'uppercase', marginBottom: '5px', fontSize: '1.5rem' }}>
|
||||
<div className="flex justify-center mt-[5vh] px-5">
|
||||
<div className="w-full max-w-125 bg-[#242424] p-8 border-t-4 border-cyan-400 shadow-[0_10px_30px_rgba(0,0,0,0.5)]">
|
||||
<h2 className="text-white uppercase mb-1 text-2xl font-sans font-bold">
|
||||
Créer un accès réseau
|
||||
</h2>
|
||||
<p style={{ color: '#888', fontFamily: 'monospace', fontSize: '0.85rem', marginBottom: '25px' }}>
|
||||
[ INITIALISATION DU PROVISIONNEMENT TRIPLE EN CASCADE ]
|
||||
<p className="text-[#888] font-mono text-sm mb-6">
|
||||
[ INITIALISATION DU PROVISIONNEMENT TRIPLE ]
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleRegister}>
|
||||
<div style={{ display: 'flex', gap: '15px' }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<label style={{ display: 'block', color: '#888', marginBottom: '5px', fontSize: '0.8rem' }}>PRÉNOM</label>
|
||||
<input type="text" value={firstName} onChange={(e) => setFirstName(e.target.value)} style={inputStyle} required placeholder="John" />
|
||||
<form onSubmit={handleRegister} className="font-mono">
|
||||
<div className="flex gap-4">
|
||||
<div className="flex-1">
|
||||
<label className="block text-[#888] mb-1 text-xs tracking-widest uppercase">Prénom</label>
|
||||
<input type="text" value={firstName} onChange={(e) => setFirstName(e.target.value)} required placeholder="John" className="w-full p-3 mb-4 bg-[#1A1A1A] text-cyan-400 border border-[#333] outline-none focus:border-cyan-400 transition-colors" />
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<label style={{ display: 'block', color: '#888', marginBottom: '5px', fontSize: '0.8rem' }}>NOM</label>
|
||||
<input type="text" value={lastName} onChange={(e) => setLastName(e.target.value)} style={inputStyle} required placeholder="Doe" />
|
||||
<div className="flex-1">
|
||||
<label className="block text-[#888] mb-1 text-xs tracking-widest uppercase">Nom</label>
|
||||
<input type="text" value={lastName} onChange={(e) => setLastName(e.target.value)} required placeholder="Doe" className="w-full p-3 mb-4 bg-[#1A1A1A] text-cyan-400 border border-[#333] outline-none focus:border-cyan-400 transition-colors" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label style={{ display: 'block', color: '#888', marginBottom: '5px', fontSize: '0.8rem' }}>ADRESSE E-MAIL (IDENTIFIANT FACTURATION)</label>
|
||||
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} style={inputStyle} required placeholder="john.doe@gise.be" />
|
||||
<label className="block text-[#888] mb-1 text-xs tracking-widest uppercase">E-mail (Facturation)</label>
|
||||
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} required placeholder="john.doe@gise.be" className="w-full p-3 mb-4 bg-[#1A1A1A] text-cyan-400 border border-[#333] outline-none focus:border-cyan-400 transition-colors" />
|
||||
|
||||
<label style={{ display: 'block', color: '#888', marginBottom: '5px', fontSize: '0.8rem' }}>NOM D'UTILISATEUR SÉCURISÉ (ACCÈS HESTIA & CLOUD)</label>
|
||||
<input type="text" value={username} onChange={(e) => setUsername(e.target.value)} style={inputStyle} required placeholder="ex: jdoe42" />
|
||||
<label className="block text-[#888] mb-1 text-xs tracking-widest uppercase">Identifiant sécurisé (Cloud)</label>
|
||||
<input type="text" value={username} onChange={(e) => setUsername(e.target.value)} required placeholder="ex: jdoe42" className="w-full p-3 mb-4 bg-[#1A1A1A] text-cyan-400 border border-[#333] outline-none focus:border-cyan-400 transition-colors" />
|
||||
|
||||
<label style={{ display: 'block', color: '#888', marginBottom: '5px', fontSize: '0.8rem' }}>CLÉ D'ACCÈS (MOT DE PASSE UNIQUE)</label>
|
||||
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} style={inputStyle} required placeholder="••••••••" />
|
||||
<label className="block text-[#888] mb-1 text-xs tracking-widest uppercase">Clé d'accès unique</label>
|
||||
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} required placeholder="••••••••" className="w-full p-3 mb-4 bg-[#1A1A1A] text-cyan-400 border border-[#333] outline-none focus:border-cyan-400 transition-colors" />
|
||||
|
||||
<label style={{ display: 'block', color: '#888', marginBottom: '5px', fontSize: '0.8rem' }}>CONFIRMER LA CLÉ D'ACCÈS</label>
|
||||
<input type="password" value={confirmPassword} onChange={(e) => setConfirmPassword(e.target.value)} style={inputStyle} required placeholder="••••••••" />
|
||||
<label className="block text-[#888] mb-1 text-xs tracking-widest uppercase">Confirmer la clé</label>
|
||||
<input type="password" value={confirmPassword} onChange={(e) => setConfirmPassword(e.target.value)} required placeholder="••••••••" className="w-full p-3 mb-5 bg-[#1A1A1A] text-cyan-400 border border-[#333] outline-none focus:border-cyan-400 transition-colors" />
|
||||
|
||||
<button type="submit" style={buttonStyle} disabled={loading}>
|
||||
<button type="submit" disabled={loading} className="w-full p-3 mt-2 font-bold uppercase tracking-widest transition-colors disabled:bg-[#333] disabled:text-[#888] disabled:cursor-not-allowed bg-cyan-400 hover:bg-cyan-300 text-black shadow-[0_0_15px_rgba(0,229,255,0.2)]">
|
||||
{loading ? 'DÉPLOIEMENT EN COURS...' : 'LANCER LE PROVISIONNEMENT'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div style={{ marginTop: '20px', textAlign: 'center', fontSize: '0.85rem' }}>
|
||||
<Link to="/login" style={{ color: '#888', textDecoration: 'none' }}>
|
||||
Déjà enregistré ? <span style={{ color: '#00E5FF' }}>Se connecter au terminal ></span>
|
||||
<div className="mt-5 text-center text-sm font-mono text-[#888]">
|
||||
Déjà enregistré ?{' '}
|
||||
<Link to="/login" className="text-cyan-400 hover:text-cyan-300 transition-colors no-underline">
|
||||
Se connecter au terminal >
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Affichage du Modal par-dessus le formulaire */}
|
||||
<NotificationModal notification={customAlert} onClose={handleCloseModal} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { loginClient } from '../../services/api';
|
||||
|
||||
export default function Login() {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleLogin = async (e) => {
|
||||
e.preventDefault(); // Empêche la page de se rafraîchir
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
// On envoie la requête à FOSSBilling
|
||||
await loginClient(email, password);
|
||||
|
||||
// Si on arrive ici, le login est un succès !
|
||||
// On redirige vers l'espace client sécurisé
|
||||
navigate('/dashboard');
|
||||
|
||||
} catch (err) {
|
||||
setError(err.message || "Accès refusé. Vérifiez vos identifiants.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Styles CSS en variables pour garder le code lisible
|
||||
const inputStyle = {
|
||||
width: '100%', padding: '12px', marginBottom: '15px',
|
||||
backgroundColor: '#1A1A1A', color: '#00E5FF',
|
||||
border: '1px solid #333', fontFamily: 'monospace', outline: 'none'
|
||||
};
|
||||
|
||||
const buttonStyle = {
|
||||
width: '100%', padding: '12px', backgroundColor: loading ? '#333' : '#00E5FF',
|
||||
color: loading ? '#888' : '#000', border: 'none', cursor: loading ? 'not-allowed' : 'pointer',
|
||||
fontFamily: 'monospace', fontWeight: 'bold', textTransform: 'uppercase', letterSpacing: '1px'
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', marginTop: '10vh' }}>
|
||||
<div style={{
|
||||
width: '100%', maxWidth: '400px', backgroundColor: '#242424',
|
||||
padding: '30px', borderTop: '4px solid #00E5FF', boxShadow: '0 10px 30px rgba(0,0,0,0.5)'
|
||||
}}>
|
||||
<h2 style={{ color: '#FFF', textTransform: 'uppercase', marginBottom: '20px', fontSize: '1.5rem' }}>
|
||||
Authentification
|
||||
</h2>
|
||||
|
||||
{/* Zone d'erreur qui s'affiche si le mot de passe est faux */}
|
||||
{error && (
|
||||
<div style={{ backgroundColor: 'rgba(255, 68, 68, 0.1)', color: '#FF4444', padding: '10px', marginBottom: '15px', border: '1px solid #FF4444', fontSize: '0.9rem' }}>
|
||||
[ ERREUR ] : {error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleLogin}>
|
||||
<label style={{ display: 'block', color: '#888', marginBottom: '5px', fontSize: '0.8rem' }}>IDENTIFIANT (EMAIL)</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
style={inputStyle}
|
||||
required
|
||||
placeholder="admin@gise.be"
|
||||
/>
|
||||
|
||||
<label style={{ display: 'block', color: '#888', marginBottom: '5px', fontSize: '0.8rem' }}>CLÉ D'ACCÈS (MOT DE PASSE)</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
style={inputStyle}
|
||||
required
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
|
||||
<button type="submit" style={buttonStyle} disabled={loading}>
|
||||
{loading ? 'VÉRIFICATION...' : 'INITIALISER LA CONNEXION'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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