add billing system
This commit is contained in:
@@ -20,6 +20,7 @@ import Store from './pages/app/Store';
|
|||||||
import Checkout from './pages/app/Checkout';
|
import Checkout from './pages/app/Checkout';
|
||||||
import Services from './pages/app/Services';
|
import Services from './pages/app/Services';
|
||||||
import Support from './pages/app/Support';
|
import Support from './pages/app/Support';
|
||||||
|
import BillingHistory from './pages/app/BillingHistory';
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
@@ -49,6 +50,7 @@ export default function App() {
|
|||||||
<Route path="/checkout/:productId" element={<Checkout />} />
|
<Route path="/checkout/:productId" element={<Checkout />} />
|
||||||
<Route path="/services" element={<Services />} />
|
<Route path="/services" element={<Services />} />
|
||||||
<Route path="/support" element={<Support />} />
|
<Route path="/support" element={<Support />} />
|
||||||
|
<Route path="/facturation" element={<BillingHistory />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
</Route>
|
</Route>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ export default function NewServiceCard({ onClick }) {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className="bg-transparent border-2 border-dashed border-gray-700 hover:border-cyan-400 p-6 rounded-xl transition-colors cursor-pointer flex flex-col items-center justify-center text-center group min-h-[200px]"
|
className="bg-transparent border-2 border-dashed border-gray-700 hover:border-cyan-400 p-6 rounded-xl transition-colors cursor-pointer flex flex-col items-center justify-center text-center group min-h-50"
|
||||||
>
|
>
|
||||||
<div className="p-3 bg-gray-800/50 rounded-full group-hover:bg-cyan-400/20 transition-colors mb-4">
|
<div className="p-3 bg-gray-800/50 rounded-full group-hover:bg-cyan-400/20 transition-colors mb-4">
|
||||||
<Plus className="w-8 h-8 text-gray-400 group-hover:text-cyan-400" />
|
<Plus className="w-8 h-8 text-gray-400 group-hover:text-cyan-400" />
|
||||||
@@ -13,7 +13,7 @@ export default function NewServiceCard({ onClick }) {
|
|||||||
Demander une accréditation
|
Demander une accréditation
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-sm text-gray-500 mt-2">
|
<p className="text-sm text-gray-500 mt-2">
|
||||||
Déployer un nouveau serveur Web, VPS ou Cloud.
|
Déployer un nouveau serveur Web, VPS, DB ou Cloud.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,20 +1,47 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { CreditCard, Check, Globe, Loader, ToggleLeft, ShieldAlert, Calendar } from 'lucide-react';
|
import { CreditCard, Check, Globe, Loader, ToggleLeft, ShieldAlert, Calendar, TrendingUp, TrendingDown, RefreshCcw } from 'lucide-react';
|
||||||
|
import { getProductList, getHostingServiceDetails } from '../../services/api';
|
||||||
|
const CUSTOM_API_BASE_URL = import.meta.env.VITE_CUSTOM_API_BASE_URL || '';
|
||||||
|
|
||||||
export default function SubscriptionManager({ order, onClose, onRefresh, onAlert }) {
|
export default function SubscriptionManager({ order, onClose, onRefresh, onAlert }) {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [cancelLoading, setCancelLoading] = useState(false);
|
||||||
const [catalog, setCatalog] = useState([]);
|
const [catalog, setCatalog] = useState([]);
|
||||||
const [selectedPlan, setSelectedPlan] = useState(null);
|
const [selectedPlan, setSelectedPlan] = useState(null);
|
||||||
const [isCatalogLoading, setIsCatalogLoading] = useState(true);
|
const [isCatalogLoading, setIsCatalogLoading] = useState(true);
|
||||||
|
|
||||||
|
const [serviceDetails, setServiceDetails] = useState(null);
|
||||||
|
const [isDetailsLoading, setIsDetailsLoading] = useState(true);
|
||||||
|
|
||||||
const [billingPeriod, setBillingPeriod] = useState(order.period || '1M');
|
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 rawOrderTitle = order.title || "";
|
||||||
const titleParts = rawOrderTitle.split(' pour ');
|
const titleParts = rawOrderTitle.split(' pour ');
|
||||||
const currentPlanName = titleParts[0].trim();
|
const currentPlanName = titleParts[0].trim();
|
||||||
const currentPlanId = order.product_id;
|
const currentPlanId = order.product_id;
|
||||||
const currentBillingPeriod = order.period;
|
const currentBillingPeriod = order.period;
|
||||||
const extractedDomain = titleParts[1] ? titleParts[1].trim() : (order.sld ? `${order.sld}.${order.tld}` : 'Non configuré');
|
|
||||||
|
// 2. Extraction robuste du domaine
|
||||||
|
let extractedDomain = "Aucun domaine lié";
|
||||||
|
if (serviceDetails?.domain || serviceDetails?.config?.domain) {
|
||||||
|
extractedDomain = serviceDetails.domain || serviceDetails.config.domain;
|
||||||
|
}
|
||||||
|
|
||||||
const renderMarkdownFeatures = (text) => {
|
const renderMarkdownFeatures = (text) => {
|
||||||
if (!text) return <span className="text-gray-500 italic">Aucune spécification disponible.</span>;
|
if (!text) return <span className="text-gray-500 italic">Aucune spécification disponible.</span>;
|
||||||
@@ -24,7 +51,7 @@ export default function SubscriptionManager({ order, onClose, onRefresh, onAlert
|
|||||||
return (
|
return (
|
||||||
<div key={index} className="flex items-center gap-3 text-sm font-semibold text-white my-2">
|
<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">
|
<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]" />
|
<Check className="w-3 h-3 text-cyan-400 stroke-3" />
|
||||||
</div>
|
</div>
|
||||||
<span>{parseBold(cleanLine.substring(2))}</span>
|
<span>{parseBold(cleanLine.substring(2))}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -36,7 +63,6 @@ export default function SubscriptionManager({ order, onClose, onRefresh, onAlert
|
|||||||
|
|
||||||
const parseBold = (text) => text.split('**').map((part, i) => i % 2 === 1 ? <strong key={i} className="text-white font-bold">{part}</strong> : part);
|
const parseBold = (text) => text.split('**').map((part, i) => i % 2 === 1 ? <strong key={i} className="text-white font-bold">{part}</strong> : part);
|
||||||
|
|
||||||
// 🎯 Nouveau Moteur Financier (Mensualisation de l'affichage)
|
|
||||||
const getProductCardMetrics = (pricing, period) => {
|
const getProductCardMetrics = (pricing, period) => {
|
||||||
if (!pricing || !pricing.recurrent) {
|
if (!pricing || !pricing.recurrent) {
|
||||||
return { displayPrice: "0.00", billingPrice: "0.00", oldDisplayPrice: "0.00", saving: 0, isAvailable: false };
|
return { displayPrice: "0.00", billingPrice: "0.00", oldDisplayPrice: "0.00", saving: 0, isAvailable: false };
|
||||||
@@ -51,35 +77,22 @@ export default function SubscriptionManager({ order, onClose, onRefresh, onAlert
|
|||||||
let displayPrice = 0, oldDisplayPrice = 0;
|
let displayPrice = 0, oldDisplayPrice = 0;
|
||||||
|
|
||||||
if (period === '1W') {
|
if (period === '1W') {
|
||||||
billingPrice = priceW;
|
billingPrice = priceW; oldBillingPrice = priceW;
|
||||||
oldBillingPrice = priceW;
|
displayPrice = priceW * 4.333; oldDisplayPrice = displayPrice;
|
||||||
displayPrice = priceW * 4.333; // Mensualisation estimée
|
|
||||||
oldDisplayPrice = displayPrice;
|
|
||||||
if (!priceW) isAvailable = false;
|
if (!priceW) isAvailable = false;
|
||||||
} else if (period === '1M') {
|
} else if (period === '1M') {
|
||||||
billingPrice = priceM;
|
billingPrice = priceM; oldBillingPrice = priceW > 0 ? priceW * 4.333 : priceM;
|
||||||
oldBillingPrice = priceW > 0 ? priceW * 4.333 : priceM;
|
displayPrice = priceM; oldDisplayPrice = oldBillingPrice;
|
||||||
displayPrice = priceM; // Déjà au mois
|
|
||||||
oldDisplayPrice = oldBillingPrice;
|
|
||||||
if (!priceM) isAvailable = false;
|
if (!priceM) isAvailable = false;
|
||||||
} else if (period === '1Y') {
|
} else if (period === '1Y') {
|
||||||
billingPrice = priceY;
|
billingPrice = priceY; oldBillingPrice = priceW > 0 ? priceW * 52 : (priceM > 0 ? priceM * 12 : priceY);
|
||||||
oldBillingPrice = priceW > 0 ? priceW * 52 : (priceM > 0 ? priceM * 12 : priceY);
|
displayPrice = priceY / 12; oldDisplayPrice = oldBillingPrice / 12;
|
||||||
displayPrice = priceY / 12; // Mensualisation exacte
|
|
||||||
oldDisplayPrice = oldBillingPrice / 12;
|
|
||||||
if (!priceY) isAvailable = false;
|
if (!priceY) isAvailable = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Le pourcentage d'économie se calcule toujours sur le montant global facturé
|
|
||||||
savingPercent = (oldBillingPrice > billingPrice && billingPrice > 0) ? Math.round(((oldBillingPrice - billingPrice) / oldBillingPrice) * 100) : 0;
|
savingPercent = (oldBillingPrice > billingPrice && billingPrice > 0) ? Math.round(((oldBillingPrice - billingPrice) / oldBillingPrice) * 100) : 0;
|
||||||
|
|
||||||
return {
|
return { displayPrice: displayPrice.toFixed(2), billingPrice: billingPrice.toFixed(2), oldDisplayPrice: oldDisplayPrice.toFixed(2), saving: savingPercent, isAvailable };
|
||||||
displayPrice: displayPrice.toFixed(2),
|
|
||||||
billingPrice: billingPrice.toFixed(2),
|
|
||||||
oldDisplayPrice: oldDisplayPrice.toFixed(2),
|
|
||||||
saving: savingPercent,
|
|
||||||
isAvailable
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const getPlanBadge = (index) => {
|
const getPlanBadge = (index) => {
|
||||||
@@ -92,30 +105,183 @@ export default function SubscriptionManager({ order, onClose, onRefresh, onAlert
|
|||||||
const fetchCatalog = async () => {
|
const fetchCatalog = async () => {
|
||||||
setIsCatalogLoading(true);
|
setIsCatalogLoading(true);
|
||||||
try {
|
try {
|
||||||
const resp = await fetch('https://web.gise.be/custom_api/nexus_subscription.php', {
|
const data = await getProductList();
|
||||||
method: 'POST',
|
const rawProducts = data.list || data.catalog || (Array.isArray(data) ? data : []);
|
||||||
body: new URLSearchParams({ action: 'get_catalog', order_id: order.id })
|
const webProducts = rawProducts.filter(p => p.product_category_id === 1);
|
||||||
});
|
|
||||||
const data = await resp.json();
|
if (webProducts.length > 0) {
|
||||||
if (data.status === 'success') {
|
setCatalog(webProducts);
|
||||||
setCatalog(data.catalog);
|
const current = webProducts.find(p => currentPlanName.toLowerCase() === p.title.toLowerCase());
|
||||||
const current = data.catalog.find(p => currentPlanName.toLowerCase() === p.title.toLowerCase());
|
|
||||||
if (current) setSelectedPlan(current);
|
if (current) setSelectedPlan(current);
|
||||||
|
} else {
|
||||||
|
console.warn("Aucun produit de type 'Web Service' n'a été trouvé.");
|
||||||
|
setCatalog([]);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (onAlert) onAlert("Erreur", "Impossible de mapper les offres.", "error");
|
console.error("Erreur API Catalogue:", err);
|
||||||
} finally {
|
} finally {
|
||||||
setIsCatalogLoading(false);
|
setIsCatalogLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
fetchCatalog();
|
fetchCatalog();
|
||||||
}, [order.id, currentPlanName]);
|
}, [currentPlanName]);
|
||||||
|
|
||||||
const isNoChange = selectedPlan && (currentPlanId === selectedPlan.id) && (currentBillingPeriod === billingPeriod);
|
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.")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setCancelLoading(true);
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`${CUSTOM_API_BASE_URL}/custom_api/nexus_subscription.php`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: new URLSearchParams({
|
||||||
|
action: 'cancel',
|
||||||
|
order_id: order.id
|
||||||
|
})
|
||||||
|
});
|
||||||
|
const data = await resp.json();
|
||||||
|
|
||||||
|
if (data.status === 'success') {
|
||||||
|
if (onAlert) onAlert("Résilié", data.message || "L'abonnement a été annulé avec succès.", "success");
|
||||||
|
if (onRefresh) onRefresh();
|
||||||
|
if (onClose) onClose();
|
||||||
|
} else {
|
||||||
|
if (onAlert) onAlert("Erreur", data.error || "Impossible de résilier l'abonnement.", "error");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (onAlert) onAlert("Erreur", "Problème réseau ou serveur.", "error");
|
||||||
|
} finally {
|
||||||
|
setCancelLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
// 🎯 Ligne modifiée : Retrait du "bg-[#060b14]" pour s'adapter à ta fenêtre Modal
|
<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="flex flex-col w-full h-[90vh] md:h-[85vh] max-w-6xl mx-auto text-white">
|
|
||||||
|
|
||||||
<div className="shrink-0 space-y-4 mb-4 pr-2">
|
<div className="shrink-0 space-y-4 mb-4 pr-2">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
@@ -151,10 +317,10 @@ export default function SubscriptionManager({ order, onClose, onRefresh, onAlert
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<a
|
<a
|
||||||
href={`/invoice`}
|
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"
|
className="text-xs font-bold text-cyan-400 hover:text-cyan-300 transition-colors flex items-center gap-1.5 bg-cyan-950/20 px-4 py-2 rounded-lg border border-cyan-900/30"
|
||||||
>
|
>
|
||||||
Voir la facture ↗
|
Voir les factures
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -171,7 +337,8 @@ export default function SubscriptionManager({ order, onClose, onRefresh, onAlert
|
|||||||
<button
|
<button
|
||||||
key={p.id}
|
key={p.id}
|
||||||
onClick={() => setBillingPeriod(p.id)}
|
onClick={() => setBillingPeriod(p.id)}
|
||||||
className={`px-5 py-2 rounded-lg text-xs font-black tracking-wide transition-all ${billingPeriod === p.id ? 'bg-cyan-500 text-black shadow-lg shadow-cyan-500/20' : 'text-gray-400 hover:text-white hover:bg-gray-900'}`}
|
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}
|
{p.label}
|
||||||
</button>
|
</button>
|
||||||
@@ -197,9 +364,9 @@ export default function SubscriptionManager({ order, onClose, onRefresh, onAlert
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={plan.id}
|
key={plan.id}
|
||||||
onClick={() => metrics.isAvailable && setSelectedPlan(plan)}
|
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
|
className={`relative h-full p-6 rounded-2xl border transition-all duration-300 flex flex-col justify-between bg-[#0d1527] mt-3
|
||||||
${!metrics.isAvailable ? 'opacity-50 grayscale cursor-not-allowed border-gray-900' : 'cursor-pointer'}
|
${!metrics.isAvailable || loading || cancelLoading ? 'opacity-50 grayscale cursor-not-allowed border-gray-900' : 'cursor-pointer'}
|
||||||
${isSelected && metrics.isAvailable ? 'border-cyan-500 shadow-[0_0_20px_rgba(6,182,212,0.15)] scale-[1.02] z-10' : 'border-gray-800/80 hover:border-gray-700'}
|
${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' : ''}`}
|
${isCurrentActive ? 'ring-1 ring-gray-700' : ''}`}
|
||||||
>
|
>
|
||||||
@@ -228,7 +395,6 @@ export default function SubscriptionManager({ order, onClose, onRefresh, onAlert
|
|||||||
|
|
||||||
{metrics.isAvailable ? (
|
{metrics.isAvailable ? (
|
||||||
<>
|
<>
|
||||||
{/* 🎯 L'affichage dynamique toujours converti au mois */}
|
|
||||||
<div className="flex items-baseline gap-1.5">
|
<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-3xl font-black text-cyan-400 tracking-tighter">{metrics.displayPrice} €</span>
|
||||||
<span className="text-xs text-gray-400 font-medium">/ mois</span>
|
<span className="text-xs text-gray-400 font-medium">/ mois</span>
|
||||||
@@ -240,7 +406,6 @@ export default function SubscriptionManager({ order, onClose, onRefresh, onAlert
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 🎯 L'engagement de facturation reste avec le vrai montant et le vrai cycle */}
|
|
||||||
<div className="mt-3.5 inline-block bg-[#0e2238] border border-cyan-950/40 text-cyan-400 text-[9px] font-black tracking-widest uppercase px-3 py-1.5 rounded-md">
|
<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'}
|
Facturé {metrics.billingPrice} € par {billingPeriod === '1W' ? 'semaine' : billingPeriod === '1M' ? 'mois' : 'an'}
|
||||||
</div>
|
</div>
|
||||||
@@ -253,7 +418,7 @@ export default function SubscriptionManager({ order, onClose, onRefresh, onAlert
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-t border-gray-800/60 pt-4 flex-grow space-y-1.5">
|
<div className="border-t border-gray-800/60 pt-4 grow space-y-1.5">
|
||||||
{renderMarkdownFeatures(plan.description)}
|
{renderMarkdownFeatures(plan.description)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -264,26 +429,57 @@ export default function SubscriptionManager({ order, onClose, onRefresh, onAlert
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="shrink-0 mt-auto pt-4 border-t border-gray-800/40 space-y-3">
|
<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 && (
|
{selectedPlan && (
|
||||||
<button
|
<button
|
||||||
disabled={loading || isNoChange}
|
onClick={handleMigration}
|
||||||
className={`w-full py-4 rounded-xl font-black uppercase text-xs tracking-widest transition-all duration-300
|
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
|
${isNoChange
|
||||||
? 'bg-gray-900 text-gray-500 border border-gray-800 cursor-not-allowed shadow-none'
|
? 'bg-gray-900 text-gray-500 border border-gray-800 cursor-not-allowed shadow-none'
|
||||||
: 'bg-cyan-500 hover:bg-cyan-400 text-black shadow-[0_0_20px_rgba(6,182,212,0.2)] hover:shadow-[0_0_25px_rgba(6,182,212,0.4)]'
|
: buttonColor
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
{loading ? <Loader className="w-4 h-4 animate-spin" /> : (!isNoChange && ActionIcon)}
|
||||||
|
|
||||||
{loading
|
{loading
|
||||||
? 'Traitement en cours...'
|
? 'Traitement en cours...'
|
||||||
: isNoChange
|
: isNoChange
|
||||||
? 'Abonnement actuel (Aucune modification)'
|
? 'Abonnement actuel (Aucune modification)'
|
||||||
: `Valider la modification`
|
: migrationLabel
|
||||||
}
|
}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<button className="w-full flex items-center justify-center gap-2 text-red-400/80 hover:text-red-400 border border-red-950/30 bg-red-950/5 hover:bg-red-950/15 p-3 rounded-xl text-xs font-bold uppercase tracking-wider transition-all">
|
<button
|
||||||
<ShieldAlert className="w-4 h-4" /> Résilier l'abonnement réseau
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,19 +1,55 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
import { Server, Database, Cloud, Globe, ExternalLink, Play, Loader } from 'lucide-react';
|
import { Server, Database, Cloud, Globe, ExternalLink, Play, Loader } from 'lucide-react';
|
||||||
|
import { getHostingServiceDetails } from '../../services/api';
|
||||||
|
|
||||||
export default function InstanceCard({ service, vpcs, onAssignVpc, onRemoveVpc, onOpenConsole, isConnecting }) {
|
export default function InstanceCard({ service, vpcs, onAssignVpc, onRemoveVpc, onOpenConsole, isConnecting }) {
|
||||||
|
// 1. États pour les détails techniques chargés via API
|
||||||
|
const [serviceDetails, setServiceDetails] = useState(null);
|
||||||
|
const [isLoadingDetails, setIsLoadingDetails] = useState(true);
|
||||||
|
|
||||||
|
// 2. Chargement des détails au montage du composant
|
||||||
|
useEffect(() => {
|
||||||
|
let isMounted = true;
|
||||||
|
const fetchDetails = async () => {
|
||||||
|
try {
|
||||||
|
const data = await getHostingServiceDetails(service.id);
|
||||||
|
if (isMounted) {
|
||||||
|
setServiceDetails(data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Erreur lors du chargement des détails du service:", error);
|
||||||
|
} finally {
|
||||||
|
if (isMounted) setIsLoadingDetails(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchDetails();
|
||||||
|
return () => { isMounted = false; };
|
||||||
|
}, [service.id]);
|
||||||
|
|
||||||
|
// 3. Logique d'affichage (Titre, Type de service)
|
||||||
const titleLower = (service.title || '').toLowerCase();
|
const titleLower = (service.title || '').toLowerCase();
|
||||||
const isVPS = titleLower.includes('vps') || titleLower.includes('compute');
|
const isVPS = titleLower.includes('vps') || titleLower.includes('compute');
|
||||||
const isCloud = titleLower.includes('cloud') || titleLower.includes('nextcloud');
|
const isCloud = titleLower.includes('cloud') || titleLower.includes('nextcloud');
|
||||||
const isDB = titleLower.includes('db') || titleLower.includes('base') || titleLower.includes('sql');
|
const isDB = titleLower.includes('db') || titleLower.includes('base') || titleLower.includes('sql');
|
||||||
const isWeb = !isVPS && !isCloud && !isDB;
|
const isWeb = !isVPS && !isCloud && !isDB;
|
||||||
const hasIP = service.hostingDetails?.ip && service.hostingDetails.ip !== '127.0.0.1' && service.hostingDetails.ip !== '';
|
|
||||||
|
|
||||||
|
// 4. Extraction intelligente du domaine et de l'IP
|
||||||
|
// Priorité : API > Titre de la commande
|
||||||
|
const rawTitle = service.title || '';
|
||||||
|
const titleMatch = rawTitle.match(/(?: for | pour )(.+)$/i);
|
||||||
|
const domainFromTitle = titleMatch ? titleMatch[1].trim() : null;
|
||||||
|
|
||||||
|
const displayDomain = serviceDetails?.domain || serviceDetails?.config?.domain || domainFromTitle || null;
|
||||||
|
const displayIP = serviceDetails?.ip || serviceDetails?.config?.ip || null;
|
||||||
|
|
||||||
|
// Logique des boutons
|
||||||
let buttonText = "CONSOLE D'ADMINISTRATION";
|
let buttonText = "CONSOLE D'ADMINISTRATION";
|
||||||
let ButtonIcon = ExternalLink;
|
let ButtonIcon = ExternalLink;
|
||||||
let buttonStyle = "bg-cyan-400/10 hover:bg-cyan-400 text-cyan-400 hover:text-gray-900 border-cyan-400";
|
let buttonStyle = "bg-cyan-400/10 hover:bg-cyan-400 text-cyan-400 hover:text-gray-900 border-cyan-400";
|
||||||
|
|
||||||
if (isVPS) {
|
if (isVPS) {
|
||||||
if (hasIP) { buttonText = "GÉRER L'INSTANCE"; ButtonIcon = ExternalLink; }
|
if (displayIP) { buttonText = "GÉRER L'INSTANCE"; ButtonIcon = ExternalLink; }
|
||||||
else { buttonText = "INITIALISATION"; ButtonIcon = Play; buttonStyle = "bg-yellow-500/10 text-yellow-500 hover:bg-yellow-500 hover:text-gray-900 border-yellow-500/50"; }
|
else { buttonText = "INITIALISATION"; ButtonIcon = Play; buttonStyle = "bg-yellow-500/10 text-yellow-500 hover:bg-yellow-500 hover:text-gray-900 border-yellow-500/50"; }
|
||||||
} else if (isWeb) { buttonText = "GÉRER L'HÉBERGEMENT"; ButtonIcon = Globe; buttonStyle = "bg-emerald-400/10 hover:bg-emerald-400 text-emerald-400 hover:text-gray-900 border-emerald-400"; }
|
} else if (isWeb) { buttonText = "GÉRER L'HÉBERGEMENT"; ButtonIcon = Globe; buttonStyle = "bg-emerald-400/10 hover:bg-emerald-400 text-emerald-400 hover:text-gray-900 border-emerald-400"; }
|
||||||
else if (isCloud) { buttonText = "ACCÉDER AU CLOUD"; buttonStyle = "bg-blue-400/10 hover:bg-blue-400 text-blue-400 hover:text-gray-900 border-blue-400"; }
|
else if (isCloud) { buttonText = "ACCÉDER AU CLOUD"; buttonStyle = "bg-blue-400/10 hover:bg-blue-400 text-blue-400 hover:text-gray-900 border-blue-400"; }
|
||||||
@@ -26,12 +62,8 @@ export default function InstanceCard({ service, vpcs, onAssignVpc, onRemoveVpc,
|
|||||||
return <Globe className="w-8 h-8 text-emerald-400" />;
|
return <Globe className="w-8 h-8 text-emerald-400" />;
|
||||||
};
|
};
|
||||||
|
|
||||||
const baseTitle = (service.title || '').split(/(?: for | pour )/i)[0].trim();
|
const baseTitle = rawTitle.split(/(?: for | pour )/i)[0].trim();
|
||||||
const shortTitle = baseTitle.split(' ').slice(0, 2).join(' ');
|
const shortTitle = baseTitle.split(' ').slice(0, 2).join(' ');
|
||||||
const titleMatch = (service.title || '').match(/(?: for | pour )(.+)$/i);
|
|
||||||
const domainFromTitle = titleMatch ? titleMatch[1].trim() : null;
|
|
||||||
let displayDomain = service.hostingDetails?.domain && service.hostingDetails.domain !== '127.0.0.1' ? service.hostingDetails.domain : domainFromTitle || service.domain;
|
|
||||||
if (!displayDomain || displayDomain === '127.0.0.1') displayDomain = null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-gray-900 border border-gray-800 rounded-xl p-5 flex flex-col justify-between hover:border-gray-700 transition-all shadow-lg">
|
<div className="bg-gray-900 border border-gray-800 rounded-xl p-5 flex flex-col justify-between hover:border-gray-700 transition-all shadow-lg">
|
||||||
@@ -40,27 +72,63 @@ export default function InstanceCard({ service, vpcs, onAssignVpc, onRemoveVpc,
|
|||||||
<div className="p-2 bg-black/40 rounded-lg">{getServiceIcon()}</div>
|
<div className="p-2 bg-black/40 rounded-lg">{getServiceIcon()}</div>
|
||||||
{service.status === 'active' ? (
|
{service.status === 'active' ? (
|
||||||
<span className="bg-emerald-500/10 text-emerald-400 px-2 py-1 rounded text-xs border border-emerald-500/20 font-bold tracking-widest">
|
<span className="bg-emerald-500/10 text-emerald-400 px-2 py-1 rounded text-xs border border-emerald-500/20 font-bold tracking-widest">
|
||||||
{isVPS && !hasIP ? 'AWAITING INIT' : 'ONLINE'}
|
{isVPS && !displayIP ? 'AWAITING INIT' : 'ONLINE'}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="bg-orange-500/10 text-orange-400 px-2 py-1 rounded text-xs border border-orange-500/20 animate-pulse">DEPLOYING</span>
|
<span className="bg-orange-500/10 text-orange-400 px-2 py-1 rounded text-xs border border-orange-500/20 animate-pulse">DEPLOYING</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h3 className="font-bold text-white text-lg truncate" title={baseTitle}>{shortTitle}</h3>
|
<h3 className="font-bold text-white text-lg truncate" title={baseTitle}>{shortTitle}</h3>
|
||||||
<div className="flex flex-col gap-1 mt-2 mb-4 h-8 justify-center">
|
|
||||||
{displayDomain ? ( <p className={`${isVPS ? 'text-cyan-400' : 'text-emerald-400'} text-xs font-mono truncate`} title={displayDomain}>{displayDomain}</p> ) : ( <p className="text-gray-600 text-xs font-mono italic">En attente de déploiement</p> )}
|
{/* Zone Domaine & IP */}
|
||||||
|
<div className="flex flex-col gap-1 mt-2 mb-4 h-12 justify-center">
|
||||||
|
{isLoadingDetails ? (
|
||||||
|
<div className="flex items-center text-gray-500 text-xs gap-2">
|
||||||
|
<Loader className="w-3 h-3 animate-spin" /> Chargement...
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{displayDomain ? (
|
||||||
|
<p className={`${isVPS ? 'text-cyan-400' : 'text-emerald-400'} text-xs font-mono truncate`} title={displayDomain}>
|
||||||
|
{displayDomain}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-gray-600 text-xs font-mono italic">Aucun domaine</p>
|
||||||
|
)}
|
||||||
|
{displayIP && (
|
||||||
|
<p className="text-gray-500 text-[10px] font-mono mt-0.5" title={`IP: ${displayIP}`}>
|
||||||
|
IP: {displayIP}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-auto space-y-3">
|
<div className="mt-auto space-y-3">
|
||||||
<div className="flex items-center justify-between text-sm border-t border-gray-800 pt-3">
|
<div className="flex items-center justify-between text-sm border-t border-gray-800 pt-3">
|
||||||
<span className="text-gray-500 text-xs">Projet VPC:</span>
|
<span className="text-gray-500 text-xs">Projet VPC:</span>
|
||||||
<select onChange={(e) => { const val = e.target.value; if (val === "free") onRemoveVpc(service.id); else if (val) onAssignVpc(service.id, val); }} className="bg-black border border-gray-700 text-gray-300 rounded px-2 py-1 outline-none focus:border-cyan-400 text-xs w-32.5" defaultValue={vpcs.find(v => v.services.includes(service.id))?.id || "free"}>
|
<select
|
||||||
|
onChange={(e) => { const val = e.target.value; if (val === "free") onRemoveVpc(service.id); else if (val) onAssignVpc(service.id, val); }}
|
||||||
|
className="bg-black border border-gray-700 text-gray-300 rounded px-2 py-1 outline-none focus:border-cyan-400 text-xs w-32.5"
|
||||||
|
defaultValue={vpcs.find(v => v.services.includes(service.id))?.id || "free"}
|
||||||
|
>
|
||||||
<option value="free">-- Libre --</option>
|
<option value="free">-- Libre --</option>
|
||||||
{vpcs.map(vpc => ( <option key={vpc.id} value={vpc.id}>{vpc.name}</option> ))}
|
{vpcs.map(vpc => ( <option key={vpc.id} value={vpc.id}>{vpc.name}</option> ))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={() => onOpenConsole(service)} disabled={isConnecting === service.id || service.status !== 'active'} className={`w-full flex items-center justify-center space-x-2 py-2.5 rounded-lg font-bold text-sm tracking-wide transition-all disabled:opacity-50 border ${buttonStyle}`}>
|
|
||||||
{isConnecting === service.id ? ( <><Loader className="w-4 h-4 animate-spin" /><span>CONNEXION...</span></> ) : ( <><ButtonIcon className="w-4 h-4" /><span>{buttonText}</span></> )}
|
<button
|
||||||
|
onClick={() => onOpenConsole(service)}
|
||||||
|
disabled={isConnecting === service.id || service.status !== 'active'}
|
||||||
|
className={`w-full flex items-center justify-center space-x-2 py-2.5 rounded-lg font-bold text-sm tracking-wide transition-all disabled:opacity-50 border ${buttonStyle}`}
|
||||||
|
>
|
||||||
|
{isConnecting === service.id ? (
|
||||||
|
<><Loader className="w-4 h-4 animate-spin" /><span>CONNEXION...</span></>
|
||||||
|
) : (
|
||||||
|
<><ButtonIcon className="w-4 h-4" /><span>{buttonText}</span></>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -66,16 +66,16 @@ export default function ProductCard({ product, selectedPeriod, categoryName }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="p-8 pt-14 border-b border-gray-800 relative bg-gradient-to-b from-gray-800/30 to-transparent min-h-[190px] flex flex-col">
|
<div className="p-8 pt-14 border-b border-gray-800 relative bg-linear-to-b from-gray-800/30 to-transparent min-h-47.5 flex flex-col">
|
||||||
<h3 className="text-2xl font-bold text-white mb-4 relative z-10">{product.title}</h3>
|
<h3 className="text-2xl font-bold text-white mb-4 relative z-10">{product.title}</h3>
|
||||||
|
|
||||||
{priceData.isAvailable ? (
|
{priceData.isAvailable ? (
|
||||||
<div className="flex-grow flex flex-col justify-end">
|
<div className="grow flex flex-col justify-end">
|
||||||
<div className="flex items-baseline space-x-2">
|
<div className="flex items-baseline space-x-2">
|
||||||
<span className="text-4xl font-black text-cyan-400">{priceData.displayPrice} €</span>
|
<span className="text-4xl font-black text-cyan-400">{priceData.displayPrice} €</span>
|
||||||
<span className="text-gray-500">{priceData.suffix}</span>
|
<span className="text-gray-500">{priceData.suffix}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 min-h-[44px] flex flex-col justify-end">
|
<div className="mt-3 min-h-11 flex flex-col justify-end">
|
||||||
{!priceData.isOnce && (
|
{!priceData.isOnce && (
|
||||||
<>
|
<>
|
||||||
{priceData.originalPrice ? (
|
{priceData.originalPrice ? (
|
||||||
@@ -96,9 +96,9 @@ export default function ProductCard({ product, selectedPeriod, categoryName }) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-8 flex-grow flex flex-col justify-between">
|
<div className="p-8 grow flex flex-col justify-between">
|
||||||
<div className="text-gray-400 text-sm mb-8 space-y-3 prose prose-invert max-w-none">
|
<div className="text-gray-400 text-sm mb-8 space-y-3 prose prose-invert max-w-none">
|
||||||
<ReactMarkdown components={{ ul: ({node, ...props}) => <ul className="space-y-2" {...props} />, li: ({node, ...props}) => <li className="flex items-start space-x-2"><CheckCircle2 className="w-4 h-4 text-cyan-400 mt-0.5 flex-shrink-0"/> <span>{props.children}</span></li>, p: ({node, ...props}) => <p className="mb-2 text-gray-300" {...props} />, strong: ({node, ...props}) => <strong className="text-white font-semibold" {...props} /> }}>
|
<ReactMarkdown components={{ ul: ({node, ...props}) => <ul className="space-y-2" {...props} />, li: ({node, ...props}) => <li className="flex items-start space-x-2"><CheckCircle2 className="w-4 h-4 text-cyan-400 mt-0.5 shrink-0"/> <span>{props.children}</span></li>, p: ({node, ...props}) => <p className="mb-2 text-gray-300" {...props} />, strong: ({node, ...props}) => <strong className="text-white font-semibold" {...props} /> }}>
|
||||||
{product.description || "Aucune description technique."}
|
{product.description || "Aucune description technique."}
|
||||||
</ReactMarkdown>
|
</ReactMarkdown>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Plus, Lock } from 'lucide-react';
|
import { Plus, Lock, X } from 'lucide-react';
|
||||||
|
|
||||||
export default function CreateTicketModal({ isOpen, onClose, onSubmit, helpdesks, defaultHelpdesk, isSubmitting }) {
|
export default function CreateTicketModal({ isOpen, onClose, onSubmit, helpdesks, defaultHelpdesk, isSubmitting }) {
|
||||||
const [subject, setSubject] = useState('');
|
const [subject, setSubject] = useState('');
|
||||||
@@ -30,7 +30,9 @@ export default function CreateTicketModal({ isOpen, onClose, onSubmit, helpdesks
|
|||||||
<h3 className="text-xl font-bold text-white tracking-wider flex items-center gap-2">
|
<h3 className="text-xl font-bold text-white tracking-wider flex items-center gap-2">
|
||||||
<Plus className="w-5 h-5 text-cyan-400" /> OUVRIR UNE REQUÊTE
|
<Plus className="w-5 h-5 text-cyan-400" /> OUVRIR UNE REQUÊTE
|
||||||
</h3>
|
</h3>
|
||||||
<button onClick={onClose} className="text-gray-400 hover:text-white transition text-xl">✖</button>
|
<button onClick={onClose} className="p-2 text-gray-500 hover:text-white bg-gray-900 hover:bg-gray-800 rounded-lg transition-colors">
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import { Send, Loader } from 'lucide-react';
|
import { Send, Loader, X } from 'lucide-react';
|
||||||
|
|
||||||
export default function TicketThreadModal({ ticket, onClose, onReply, isLoadingConversation }) {
|
export default function TicketThreadModal({ ticket, onClose, onReply, isLoadingConversation }) {
|
||||||
const [replyMessage, setReplyMessage] = useState('');
|
const [replyMessage, setReplyMessage] = useState('');
|
||||||
@@ -32,7 +32,9 @@ export default function TicketThreadModal({ ticket, onClose, onReply, isLoadingC
|
|||||||
</div>
|
</div>
|
||||||
<h3 className="text-xl font-bold text-white line-clamp-1">{ticket.subject}</h3>
|
<h3 className="text-xl font-bold text-white line-clamp-1">{ticket.subject}</h3>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={onClose} className="text-gray-400 hover:text-white transition text-xl bg-black/50 p-2 rounded-lg">✖</button>
|
<button onClick={onClose} className="p-2 text-gray-500 hover:text-white bg-gray-900 hover:bg-gray-800 rounded-lg transition-colors">
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto p-4 sm:p-6 space-y-6 scrollbar-thin scrollbar-thumb-gray-800">
|
<div className="flex-1 overflow-y-auto p-4 sm:p-6 space-y-6 scrollbar-thin scrollbar-thumb-gray-800">
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Outlet, NavLink, useNavigate } from 'react-router-dom';
|
import { Outlet, NavLink, useNavigate } from 'react-router-dom';
|
||||||
import { getClientTickets } from '../services/api';
|
import { getClientTickets } from '../services/api';
|
||||||
|
import { LayoutDashboard, Globe, Settings, FileText } from 'lucide-react';
|
||||||
import ConfirmLogoutModal from '../components/ui/ConfirmLogoutModal'; // Le modal extrait !
|
import ConfirmLogoutModal from '../components/ui/ConfirmLogoutModal'; // Le modal extrait !
|
||||||
|
|
||||||
export default function AppLayout() {
|
export default function AppLayout() {
|
||||||
@@ -15,7 +16,7 @@ export default function AppLayout() {
|
|||||||
const unread = data.list.filter(t => t.status === 'on_hold' || t.unread).length;
|
const unread = data.list.filter(t => t.status === 'on_hold' || t.unread).length;
|
||||||
setUnreadCount(unread);
|
setUnreadCount(unread);
|
||||||
}
|
}
|
||||||
} catch (err) {}
|
} catch (err) { }
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -31,10 +32,9 @@ export default function AppLayout() {
|
|||||||
|
|
||||||
// 🌟 La fonction magique Tailwind pour les liens du menu
|
// 🌟 La fonction magique Tailwind pour les liens du menu
|
||||||
const navLinkClass = ({ isActive }) =>
|
const navLinkClass = ({ isActive }) =>
|
||||||
`block px-6 py-4 no-underline border-l-4 transition-all uppercase tracking-widest text-sm font-mono ${
|
`block px-6 py-4 no-underline border-l-4 transition-all uppercase tracking-widest text-sm font-mono ${isActive
|
||||||
isActive
|
? 'text-cyan-400 bg-cyan-400/5 border-cyan-400'
|
||||||
? 'text-cyan-400 bg-cyan-400/5 border-cyan-400'
|
: 'text-[#888] border-transparent hover:text-gray-300 hover:bg-white/5'
|
||||||
: 'text-[#888] border-transparent hover:text-gray-300 hover:bg-white/5'
|
|
||||||
}`;
|
}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -74,10 +74,8 @@ export default function AppLayout() {
|
|||||||
</div>
|
</div>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
|
||||||
<div className="flex justify-between items-center px-6 py-4 text-[#444] border-l-4 border-transparent uppercase tracking-widest text-sm font-mono cursor-not-allowed select-none">
|
<NavLink className={navLinkClass} to="/facturation">Facturation</NavLink>
|
||||||
<span>Facturation</span>
|
|
||||||
<span className="text-[9px] text-[#333] border border-[#333] px-1.5 py-0.5 rounded font-bold">WIP</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between items-center px-6 py-4 text-[#444] border-l-4 border-transparent uppercase tracking-widest text-sm font-mono cursor-not-allowed select-none">
|
<div className="flex justify-between items-center px-6 py-4 text-[#444] border-l-4 border-transparent uppercase tracking-widest text-sm font-mono cursor-not-allowed select-none">
|
||||||
<span>Profil & Sécurité</span>
|
<span>Profil & Sécurité</span>
|
||||||
<span className="text-[9px] text-[#333] border border-[#333] px-1.5 py-0.5 rounded font-bold">WIP</span>
|
<span className="text-[9px] text-[#333] border border-[#333] px-1.5 py-0.5 rounded font-bold">WIP</span>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
// On s'assure de bien cibler le tableau (correction précédente)
|
||||||
|
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':
|
||||||
|
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
|
||||||
|
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" /> };
|
||||||
|
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);
|
||||||
|
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 MATHÉMATIQUES (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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { getClientOrders } from '../../services/api';
|
import { getClientOrders } from '../../services/api';
|
||||||
import { AlertCircle, Loader, FileText } from 'lucide-react';
|
import { AlertCircle, Loader, FileText, X } from 'lucide-react';
|
||||||
|
|
||||||
// Importation des composants
|
// Importation des composants
|
||||||
import DashboardServiceCard from '../../components/dashboard/DashboardServiceCard';
|
import DashboardServiceCard from '../../components/dashboard/DashboardServiceCard';
|
||||||
@@ -123,7 +123,9 @@ export default function Dashboard() {
|
|||||||
<FileText className="w-6 h-6 text-cyan-400" />
|
<FileText className="w-6 h-6 text-cyan-400" />
|
||||||
GESTION DE L'ABONNEMENT
|
GESTION DE L'ABONNEMENT
|
||||||
</h3>
|
</h3>
|
||||||
<button onClick={() => setActiveSubscriptionModal(null)} className="text-gray-400 hover:text-white transition text-xl">✖</button>
|
<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>
|
</div>
|
||||||
|
|
||||||
<SubscriptionManager
|
<SubscriptionManager
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { getMyServices, getHostingServiceDetails } from '../../services/api';
|
import { getMyServices, getHostingServiceDetails } from '../../services/api';
|
||||||
import { useVPC } from '../../services/useVPC';
|
import { useVPC } from '../../services/useVPC';
|
||||||
import { Server, Globe, Folder, Trash2, Loader, Plus, AlertCircle } from 'lucide-react';
|
import { Server, Globe, Folder, Trash2, Loader, Plus, AlertCircle, X } from 'lucide-react';
|
||||||
|
|
||||||
// Importation de tes nouveaux composants modulaires !
|
// Importation de tes nouveaux composants modulaires !
|
||||||
import NotificationModal from '../../components/ui/NotificationModal';
|
import NotificationModal from '../../components/ui/NotificationModal';
|
||||||
@@ -30,7 +30,7 @@ export default function Services() {
|
|||||||
const detailedServices = await Promise.all(data.list.map(async (order) => {
|
const detailedServices = await Promise.all(data.list.map(async (order) => {
|
||||||
let hDetails = null;
|
let hDetails = null;
|
||||||
if (order.status === 'active' && (order.plugin === 'hosting' || (order.title || '').toLowerCase().includes('vps'))) {
|
if (order.status === 'active' && (order.plugin === 'hosting' || (order.title || '').toLowerCase().includes('vps'))) {
|
||||||
try { hDetails = await getHostingServiceDetails(order.id); } catch (e) {}
|
try { hDetails = await getHostingServiceDetails(order.id); } catch (e) { }
|
||||||
}
|
}
|
||||||
return { ...order, hostingDetails: hDetails };
|
return { ...order, hostingDetails: hDetails };
|
||||||
}));
|
}));
|
||||||
@@ -132,7 +132,9 @@ export default function Services() {
|
|||||||
<h3 className="text-xl font-bold text-white tracking-wider flex items-center gap-3">
|
<h3 className="text-xl font-bold text-white tracking-wider flex items-center gap-3">
|
||||||
{activeServiceModal.type === 'vps' ? <><Server className="w-6 h-6 text-cyan-400" /> GESTION VPS</> : <><Globe className="w-6 h-6 text-emerald-400" /> GESTION WEB</>}
|
{activeServiceModal.type === 'vps' ? <><Server className="w-6 h-6 text-cyan-400" /> GESTION VPS</> : <><Globe className="w-6 h-6 text-emerald-400" /> GESTION WEB</>}
|
||||||
</h3>
|
</h3>
|
||||||
<button onClick={() => setActiveServiceModal(null)} className="text-gray-400 hover:text-white transition text-xl">✖</button>
|
<button onClick={() => setActiveServiceModal(null)} className="p-2 text-gray-500 hover:text-white bg-gray-900 hover:bg-gray-800 rounded-lg transition-colors">
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{activeServiceModal.type === 'vps' ? (
|
{activeServiceModal.type === 'vps' ? (
|
||||||
activeServiceModal.details?.ip && activeServiceModal.details.ip !== '127.0.0.1' ? <VpsManager details={activeServiceModal.details} orderId={activeServiceModal.data.id} onRefresh={fetchServices} onAlert={triggerAlert} /> : <VpsDeployer serviceId={activeServiceModal.data.id} onDeploySuccess={() => { setActiveServiceModal(null); fetchServices(); }} onAlert={triggerAlert} />
|
activeServiceModal.details?.ip && activeServiceModal.details.ip !== '127.0.0.1' ? <VpsManager details={activeServiceModal.details} orderId={activeServiceModal.data.id} onRefresh={fetchServices} onAlert={triggerAlert} /> : <VpsDeployer serviceId={activeServiceModal.data.id} onDeploySuccess={() => { setActiveServiceModal(null); fetchServices(); }} onAlert={triggerAlert} />
|
||||||
|
|||||||
@@ -221,3 +221,18 @@ export const replyTicket = (ticketId, message) =>
|
|||||||
// Récupère la liste dynamique des départements (Helpdesks) configurés sur FOSSBilling
|
// Récupère la liste dynamique des départements (Helpdesks) configurés sur FOSSBilling
|
||||||
export const getHelpdesks = () =>
|
export const getHelpdesks = () =>
|
||||||
apiCall(`${BASE_URL}/api/client/support/helpdesk_get_pairs`, 'GET');
|
apiCall(`${BASE_URL}/api/client/support/helpdesk_get_pairs`, 'GET');
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// ROUTES FACTURATION & REÇUS
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
// Récupère l'historique des factures via notre script Custom PHP
|
||||||
|
export const getInvoicesHistory = (clientId) =>
|
||||||
|
apiCall(`${CUSTOM_API_BASE_URL}/custom_api/nexus_subscription.php`, 'POST', {
|
||||||
|
action: 'get_invoices',
|
||||||
|
client_id: clientId
|
||||||
|
});
|
||||||
|
|
||||||
|
// (Optionnel) FOSSBilling Native : Récupère les détails complets d'une facture
|
||||||
|
export const getInvoiceDetails = (invoiceHash) =>
|
||||||
|
apiCall(`${BASE_URL}/api/client/invoice/get`, 'POST', { hash: invoiceHash });
|
||||||
Reference in New Issue
Block a user