Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c4fb70fbfa | |||
| 3120211909 | |||
| a09377eb4e | |||
| 9aeff38e7e | |||
| 5b5a02cf66 | |||
| b30a16b8a4 | |||
| 2706b3d5a3 | |||
| 15213bba21 | |||
| 074e3ab195 | |||
| a4f9663f60 |
+2
-2
@@ -4,8 +4,8 @@
|
||||
<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>
|
||||
<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.">
|
||||
<title>NEXUS by gise | Portail d'Infrastructure Centralisée</title>
|
||||
<meta name="description" content="Accédez au portail NEXUS by gise. Pilotez votre hébergement web, vos instances serveurs et votre stockage Cloud depuis une console unifiée.">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -20,6 +20,7 @@ import Store from './pages/app/Store';
|
||||
import Checkout from './pages/app/Checkout';
|
||||
import Services from './pages/app/Services';
|
||||
import Support from './pages/app/Support';
|
||||
import BillingHistory from './pages/app/BillingHistory';
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
@@ -49,6 +50,7 @@ export default function App() {
|
||||
<Route path="/checkout/:productId" element={<Checkout />} />
|
||||
<Route path="/services" element={<Services />} />
|
||||
<Route path="/support" element={<Support />} />
|
||||
<Route path="/facturation" element={<BillingHistory />} />
|
||||
</Route>
|
||||
|
||||
</Route>
|
||||
|
||||
@@ -4,7 +4,7 @@ export default function NewServiceCard({ onClick }) {
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
className="bg-transparent border-2 border-dashed border-gray-700 hover:border-cyan-400 p-6 rounded-xl transition-colors cursor-pointer flex flex-col items-center justify-center text-center group min-h-[200px]"
|
||||
className="bg-transparent border-2 border-dashed border-gray-700 hover:border-cyan-400 p-6 rounded-xl transition-colors cursor-pointer flex flex-col items-center justify-center text-center group min-h-50"
|
||||
>
|
||||
<div className="p-3 bg-gray-800/50 rounded-full group-hover:bg-cyan-400/20 transition-colors mb-4">
|
||||
<Plus className="w-8 h-8 text-gray-400 group-hover:text-cyan-400" />
|
||||
@@ -13,7 +13,7 @@ export default function NewServiceCard({ onClick }) {
|
||||
Demander une accréditation
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 mt-2">
|
||||
Déployer un nouveau serveur Web, VPS ou Cloud.
|
||||
Déployer un nouveau serveur Web, VPS, DB ou Cloud.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,557 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { CreditCard, Check, Globe, Loader, ToggleLeft, ShieldAlert, Calendar, TrendingUp, TrendingDown, RefreshCcw } from 'lucide-react';
|
||||
import { getProductList, getServiceDetails, cancelOrder } from '../../services/billing_api';
|
||||
const CUSTOM_API_BASE_URL = import.meta.env.VITE_CUSTOM_API_BASE_URL || '';
|
||||
|
||||
export default function WebServiceSubscriptionManager({ 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');
|
||||
|
||||
// 🎯 NOUVEAU : État pour la modale de résiliation
|
||||
const [isCancelModalOpen, setIsCancelModalOpen] = useState(false);
|
||||
|
||||
// 1. Récupération des détails techniques (Domaine/IP)
|
||||
useEffect(() => {
|
||||
const fetchDetails = async () => {
|
||||
setIsDetailsLoading(true);
|
||||
try {
|
||||
const data = await getServiceDetails(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?.config?.hostname || serviceDetails?.config?.sld && serviceDetails?.config?.tld) {
|
||||
extractedDomain = serviceDetails.config.hostname || serviceDetails.config.sld+serviceDetails.config.tld;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
const currentPlanFromCatalog = catalog.find(p => p.id === currentPlanId);
|
||||
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) {
|
||||
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) {
|
||||
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);
|
||||
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
|
||||
})
|
||||
});
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
// ==========================================
|
||||
// 🛡️ NOUVELLE LOGIQUE DE RÉSILIATION (US 2.1 / 2.2)
|
||||
// ==========================================
|
||||
const calculateRefundEligibility = () => {
|
||||
if (!order || !order.activated_at) return false;
|
||||
const orderDate = new Date(order.activated_at);
|
||||
console.log("Order Date:", orderDate);
|
||||
const now = new Date();
|
||||
const diffDays = Math.ceil(Math.abs(now - orderDate) / (1000 * 60 * 60 * 24));
|
||||
return diffDays <= 14;
|
||||
};
|
||||
|
||||
const isEligibleForRefund = calculateRefundEligibility();
|
||||
|
||||
const handleConfirmCancel = async () => {
|
||||
setCancelLoading(true);
|
||||
try {
|
||||
const data = await cancelOrder(order.id);
|
||||
|
||||
// Succès normal
|
||||
if (data.status === 'success') {
|
||||
if (onAlert) onAlert("Résilié", data.message, "success");
|
||||
setIsCancelModalOpen(false);
|
||||
if (onRefresh) onRefresh();
|
||||
if (onClose) onClose();
|
||||
} else {
|
||||
if (onAlert) onAlert("Erreur", data.error, "error");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Erreur API Annulation:", err);
|
||||
|
||||
// 🛡️ LE FILET DE SÉCURITÉ : Si on détecte l'erreur HTML 502 (Unexpected token)
|
||||
// Cela signifie que le serveur a bien redémarré HestiaCP et coupé la connexion.
|
||||
if (err.message && err.message.includes('Unexpected token')) {
|
||||
if (onAlert) onAlert("Succès", "L'abonnement a été annulé et les services mis à jour.", "success");
|
||||
setIsCancelModalOpen(false);
|
||||
// On met un petit délai avant de rafraîchir, le temps qu'HestiaCP finisse de recharger
|
||||
if (onRefresh) setTimeout(() => onRefresh(), 2000);
|
||||
if (onClose) setTimeout(() => onClose(), 2000);
|
||||
} else {
|
||||
if (onAlert) onAlert("Erreur", "Problème réseau lors de la résiliation.", "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 relative">
|
||||
<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">
|
||||
{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>
|
||||
)}
|
||||
|
||||
{/* 🎯 NOUVEAU BOUTON : Ouvre la modale au lieu de faire un confirm() */}
|
||||
<button
|
||||
onClick={() => setIsCancelModalOpen(true)}
|
||||
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>
|
||||
|
||||
{/* ========================================================================= */}
|
||||
{/* 🛡️ MODALE DE CONFIRMATION (Thème Sombre Intégré) */}
|
||||
{/* ========================================================================= */}
|
||||
{isCancelModalOpen && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm p-4">
|
||||
<div className="bg-[#0d1527] border border-gray-800 rounded-2xl shadow-2xl max-w-md w-full p-6 transform transition-all">
|
||||
|
||||
<h3 className="text-xl font-bold text-white mb-4 flex items-center gap-2">
|
||||
<ShieldAlert className="w-5 h-5 text-red-500" />
|
||||
Confirmer la résiliation
|
||||
</h3>
|
||||
|
||||
{/* Condition d'affichage : Rétractation J+14 vs Résiliation standard */}
|
||||
{isEligibleForRefund ? (
|
||||
<div className="bg-emerald-950/30 border border-emerald-900/50 p-4 mb-5 text-emerald-400 text-sm rounded-xl">
|
||||
<p className="font-bold mb-1 flex items-center gap-2">
|
||||
<Check className="w-4 h-4" /> Droit de rétractation (≤ 14 jours)
|
||||
</p>
|
||||
<p className="text-emerald-500/80 mt-2">
|
||||
Votre abonnement a été activé il y a moins de 14 jours.
|
||||
<strong className="text-emerald-400"> Vous allez être intégralement remboursé</strong>.
|
||||
Vos services seront supprimés immédiatement.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-amber-950/30 border border-amber-900/50 p-4 mb-5 text-amber-400 text-sm rounded-xl">
|
||||
<p className="font-bold mb-1">📅 Résiliation standard (> 14 jours)</p>
|
||||
<p className="text-amber-500/80 mt-2">
|
||||
La période de rétractation est dépassée. Votre abonnement restera actif jusqu'à sa date d'échéance officielle.
|
||||
<strong className="text-amber-400"> Aucun renouvellement ni prélèvement n'aura lieu.</strong>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-gray-400 mb-6 text-sm">
|
||||
Êtes-vous absolument sûr de vouloir procéder ? Cette action est irréversible une fois validée par nos serveurs.
|
||||
</p>
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<button
|
||||
onClick={() => setIsCancelModalOpen(false)}
|
||||
disabled={cancelLoading}
|
||||
className="px-5 py-2.5 bg-[#090f1c] text-gray-300 border border-gray-800 rounded-xl hover:bg-gray-800 hover:text-white disabled:opacity-50 transition-colors text-sm font-bold tracking-wide"
|
||||
>
|
||||
Retour
|
||||
</button>
|
||||
<button
|
||||
onClick={handleConfirmCancel}
|
||||
disabled={cancelLoading}
|
||||
className="px-5 py-2.5 bg-red-600/90 hover:bg-red-500 text-white border border-red-500/50 rounded-xl flex items-center disabled:opacity-50 transition-colors text-sm font-bold tracking-wide shadow-[0_0_15px_rgba(220,38,38,0.3)]"
|
||||
>
|
||||
{cancelLoading ? (
|
||||
<>
|
||||
<Loader className="w-4 h-4 animate-spin mr-2" />
|
||||
Traitement...
|
||||
</>
|
||||
) : (
|
||||
"Confirmer"
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -66,16 +66,16 @@ export default function ProductCard({ product, selectedPeriod, categoryName }) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="p-8 pt-14 border-b border-gray-800 relative bg-gradient-to-b from-gray-800/30 to-transparent min-h-[190px] flex flex-col">
|
||||
<div className="p-8 pt-14 border-b border-gray-800 relative bg-linear-to-b from-gray-800/30 to-transparent min-h-47.5 flex flex-col">
|
||||
<h3 className="text-2xl font-bold text-white mb-4 relative z-10">{product.title}</h3>
|
||||
|
||||
{priceData.isAvailable ? (
|
||||
<div className="flex-grow flex flex-col justify-end">
|
||||
<div className="grow flex flex-col justify-end">
|
||||
<div className="flex items-baseline space-x-2">
|
||||
<span className="text-4xl font-black text-cyan-400">{priceData.displayPrice} €</span>
|
||||
<span className="text-gray-500">{priceData.suffix}</span>
|
||||
</div>
|
||||
<div className="mt-3 min-h-[44px] flex flex-col justify-end">
|
||||
<div className="mt-3 min-h-11 flex flex-col justify-end">
|
||||
{!priceData.isOnce && (
|
||||
<>
|
||||
{priceData.originalPrice ? (
|
||||
@@ -96,9 +96,9 @@ export default function ProductCard({ product, selectedPeriod, categoryName }) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-8 flex-grow flex flex-col justify-between">
|
||||
<div className="p-8 grow flex flex-col justify-between">
|
||||
<div className="text-gray-400 text-sm mb-8 space-y-3 prose prose-invert max-w-none">
|
||||
<ReactMarkdown components={{ ul: ({node, ...props}) => <ul className="space-y-2" {...props} />, li: ({node, ...props}) => <li className="flex items-start space-x-2"><CheckCircle2 className="w-4 h-4 text-cyan-400 mt-0.5 flex-shrink-0"/> <span>{props.children}</span></li>, p: ({node, ...props}) => <p className="mb-2 text-gray-300" {...props} />, strong: ({node, ...props}) => <strong className="text-white font-semibold" {...props} /> }}>
|
||||
<ReactMarkdown components={{ ul: ({node, ...props}) => <ul className="space-y-2" {...props} />, li: ({node, ...props}) => <li className="flex items-start space-x-2"><CheckCircle2 className="w-4 h-4 text-cyan-400 mt-0.5 shrink-0"/> <span>{props.children}</span></li>, p: ({node, ...props}) => <p className="mb-2 text-gray-300" {...props} />, strong: ({node, ...props}) => <strong className="text-white font-semibold" {...props} /> }}>
|
||||
{product.description || "Aucune description technique."}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
|
||||
@@ -74,10 +74,7 @@ export default function AppLayout() {
|
||||
</div>
|
||||
</NavLink>
|
||||
|
||||
<div className="flex justify-between items-center px-6 py-4 text-[#444] border-l-4 border-transparent uppercase tracking-widest text-sm font-mono cursor-not-allowed select-none">
|
||||
<span>Facturation</span>
|
||||
<span className="text-[9px] text-[#333] border border-[#333] px-1.5 py-0.5 rounded font-bold">WIP</span>
|
||||
</div>
|
||||
<NavLink className={navLinkClass} to="/facturation">Facturation</NavLink>
|
||||
<div className="flex justify-between items-center px-6 py-4 text-[#444] border-l-4 border-transparent uppercase tracking-widest text-sm font-mono cursor-not-allowed select-none">
|
||||
<span>Profil & Sécurité</span>
|
||||
<span className="text-[9px] text-[#333] border border-[#333] px-1.5 py-0.5 rounded font-bold">WIP</span>
|
||||
|
||||
@@ -0,0 +1,336 @@
|
||||
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 { getClient, getInvoiceList } from '../../services/billing_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 getClient();
|
||||
if (!profileData || !profileData.id) {
|
||||
throw new Error("Impossible de vérifier votre identité ou session expirée.");
|
||||
}
|
||||
const invoicesData = await getInvoiceList(profileData.id);
|
||||
setInvoices(invoicesData.list || []);
|
||||
} 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.lines
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams, useSearchParams, useNavigate } from 'react-router-dom';
|
||||
import { resetCart, addToCart, checkoutCart, getProductList, getClientProfile } from '../../services/api';
|
||||
import { resetCart, addToCart, checkoutCart, getProductList, getClient } from '../../services/billing_api';
|
||||
|
||||
export default function Checkout() {
|
||||
const { productId } = useParams();
|
||||
@@ -18,7 +18,7 @@ export default function Checkout() {
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
try {
|
||||
// 1. On charge d'abord le produit (Requête Publique)
|
||||
// 1. On charge d'abord le produit
|
||||
const productData = await getProductList();
|
||||
const foundProduct = productData.list?.find(p => p.id === parseInt(productId));
|
||||
|
||||
@@ -29,15 +29,14 @@ export default function Checkout() {
|
||||
}
|
||||
setProduct(foundProduct);
|
||||
|
||||
// 2. Ensuite, on tente de charger le profil (Requête Privée)
|
||||
// 2. Ensuite, on tente de charger le profil
|
||||
try {
|
||||
const profileData = await getClientProfile();
|
||||
const profileData = await getClient();
|
||||
setUserProfile(profileData);
|
||||
} catch (profileErr) {
|
||||
// Si on tombe ici, c'est que FOSSBilling refuse l'accès au profil.
|
||||
console.error("Rejet API Profil :", profileErr);
|
||||
setError("Accès refusé. Vous devez être connecté à votre compte pour provisionner une instance.");
|
||||
// Tu pourras décommenter la ligne suivante plus tard pour forcer la redirection :
|
||||
// navigate('/login');
|
||||
setIsLoading(false);
|
||||
return;
|
||||
|
||||
+85
-21
@@ -1,11 +1,13 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { getClientOrders } from '../../services/api';
|
||||
import { AlertCircle, Loader } from 'lucide-react';
|
||||
import { getOrdersList } from '../../services/billing_api';
|
||||
import { AlertCircle, Loader, FileText, X } from 'lucide-react';
|
||||
|
||||
// Importation des composants isolés
|
||||
// Importation des composants
|
||||
import DashboardServiceCard from '../../components/dashboard/DashboardServiceCard';
|
||||
import NewServiceCard from '../../components/dashboard/NewServiceCard';
|
||||
import NotificationModal from '../../components/ui/NotificationModal';
|
||||
import WebServiceSubscriptionManager from '../../components/dashboard/WebServiceSubscriptionManager';
|
||||
|
||||
export default function Dashboard() {
|
||||
const navigate = useNavigate();
|
||||
@@ -13,11 +15,17 @@ 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 () => {
|
||||
const [activeSubscriptionModal, setActiveSubscriptionModal] = useState(null);
|
||||
const [customAlert, setCustomAlert] = useState(null);
|
||||
|
||||
const triggerAlert = (title, message, type = "info") => setCustomAlert({ title, message, type });
|
||||
|
||||
const fetchInventory = async (retryCount = 0) => {
|
||||
// On n'affiche le loader global que lors du tout premier essai
|
||||
if (retryCount === 0) setIsLoading(true);
|
||||
|
||||
try {
|
||||
const data = await getClientOrders();
|
||||
const data = await getOrdersList();
|
||||
|
||||
if (data.list) {
|
||||
// LE FILTRE CHIRURGICAL PAR PREFIXE
|
||||
@@ -31,36 +39,72 @@ export default function Dashboard() {
|
||||
title.startsWith('domaine ') ||
|
||||
title.startsWith('enregistrement ');
|
||||
|
||||
return !isGhostProduct;
|
||||
return (order.status === 'active' || order.status === 'pending_setup') && !isGhostProduct;
|
||||
});
|
||||
|
||||
setOrders(filteredOrders);
|
||||
} else {
|
||||
setOrders([]);
|
||||
}
|
||||
|
||||
setError(null); // On efface l'erreur s'il y en avait une
|
||||
setIsLoading(false); // Le chargement est terminé
|
||||
|
||||
} catch (err) {
|
||||
// 🛡️ SYSTÈME D'AUTO-GUÉRISON : Si on détecte le redémarrage d'HestiaCP (Erreur HTML/JSON)
|
||||
if (err.message && err.message.includes('Unexpected token') && retryCount < 3) {
|
||||
console.warn(`Redémarrage d'infrastructure détecté. Nouvelle tentative... (Essai ${retryCount + 1}/3)`);
|
||||
// On attend 2,5 secondes, puis la fonction s'appelle elle-même (récursivité)
|
||||
setTimeout(() => fetchInventory(retryCount + 1), 2500);
|
||||
} else {
|
||||
// S'il y a une vraie erreur persistante, on l'affiche au client
|
||||
setError(err.message || "Impossible de récupérer la télémétrie des services.");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchInventory();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-6xl p-6 mx-auto">
|
||||
// 🌟 Plus besoin de charger les IPs, on ouvre juste la modale comptable !
|
||||
const handleManageSubscription = (order) => {
|
||||
console.log("Gestion de l'abonnement pour la commande :", order);
|
||||
// 1. Détection du type de service
|
||||
const titleLower = (order.title || '').toLowerCase();
|
||||
const isVPS = titleLower.includes('vps') || titleLower.includes('compute');
|
||||
const isCloud = titleLower.includes('cloud') || titleLower.includes('nextcloud');
|
||||
const isDB = titleLower.includes('db') || titleLower.includes('base') || titleLower.includes('sql');
|
||||
|
||||
// C'est un hébergement web s'il ne correspond à aucun des cas ci-dessus
|
||||
const isWeb = !isVPS && !isCloud && !isDB;
|
||||
|
||||
// 2. Logique de blocage
|
||||
if (isWeb) {
|
||||
// On ouvre la modale comptable uniquement pour le WEB
|
||||
setActiveSubscriptionModal(order);
|
||||
} else {
|
||||
// On affiche une notification pour les autres types d'instances
|
||||
triggerAlert(
|
||||
"Action non disponible",
|
||||
"La modification autonome d'abonnement est actuellement exclusive aux Hébergements Web. Pour restructurer cette instance, veuillez contacter les ingénieurs via le Centre de Support.",
|
||||
"info"
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-6xl p-6 mx-auto relative">
|
||||
<header className="mb-8">
|
||||
<h1 className="text-3xl font-black text-white tracking-wider">TABLEAU DE <span className="text-cyan-400">BORD</span></h1>
|
||||
<p className="text-gray-400 mt-2">Aperçu de vos accréditations réseau et infrastructures.</p>
|
||||
<p className="text-gray-400 mt-2">Gestion financière et accréditations de vos infrastructures.</p>
|
||||
</header>
|
||||
|
||||
{/* GESTION DES ERREURS & CHARGEMENT */}
|
||||
{isLoading && (
|
||||
<div className="flex items-center space-x-3 text-cyan-400">
|
||||
<Loader className="w-6 h-6 animate-spin" />
|
||||
<span>Synchronisation avec l'orchestrateur en cours...</span>
|
||||
<span>Synchronisation avec le registre comptable...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -71,24 +115,44 @@ export default function Dashboard() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* GRILLE DES SERVICES */}
|
||||
{!isLoading && !error && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||
|
||||
{/* Boucle sur les composants isolés */}
|
||||
{orders.map((order) => (
|
||||
<DashboardServiceCard
|
||||
key={order.id}
|
||||
order={order}
|
||||
onClick={() => navigate(`/services/${order.id}`)}
|
||||
onClick={() => handleManageSubscription(order)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Le composant carte d'ajout */}
|
||||
<NewServiceCard onClick={() => navigate('/store')} />
|
||||
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 🌟 LA MODALE DE GESTION D'ABONNEMENT */}
|
||||
{activeSubscriptionModal && (
|
||||
<div className="fixed inset-0 bg-black/80 backdrop-blur-sm z-50 flex items-center justify-center p-4">
|
||||
<div className="bg-gray-900 border border-cyan-500/50 shadow-2xl shadow-cyan-500/10 rounded-lg max-w-xxl w-full p-6 text-gray-200">
|
||||
<div className="flex justify-between items-center mb-6 border-b border-gray-800 pb-4">
|
||||
<h3 className="text-xl font-bold text-white tracking-wider flex items-center gap-3">
|
||||
<FileText className="w-6 h-6 text-cyan-400" />
|
||||
GESTION DE L'ABONNEMENT
|
||||
</h3>
|
||||
<button onClick={() => setActiveSubscriptionModal(null)} className="p-2 text-gray-500 hover:text-white bg-gray-900 hover:bg-gray-800 rounded-lg transition-colors">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<WebServiceSubscriptionManager
|
||||
order={activeSubscriptionModal}
|
||||
onClose={() => setActiveSubscriptionModal(null)}
|
||||
onRefresh={fetchInventory}
|
||||
onAlert={triggerAlert}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<NotificationModal notification={customAlert} onClose={() => setCustomAlert(null)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { getMyServices, getHostingServiceDetails } from '../../services/api';
|
||||
import { getOrdersList, getOrderDetails } from '../../services/billing_api';
|
||||
import { useVPC } from '../../services/useVPC';
|
||||
import { Server, Globe, Folder, Trash2, Loader, Plus, AlertCircle } from 'lucide-react';
|
||||
|
||||
@@ -25,12 +25,12 @@ export default function Services() {
|
||||
|
||||
const fetchServices = useCallback(async () => {
|
||||
try {
|
||||
const data = await getMyServices();
|
||||
const data = await getOrdersList();
|
||||
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) {}
|
||||
try { hDetails = await getOrderDetails(order.id); } catch (e) {}
|
||||
}
|
||||
return { ...order, hostingDetails: hDetails };
|
||||
}));
|
||||
@@ -65,7 +65,7 @@ export default function Services() {
|
||||
} else if (isDB) {
|
||||
window.open('https://pma.gise.be/', '_blank');
|
||||
} else if (isVPS || isWeb) {
|
||||
const freshDetails = await getHostingServiceDetails(service.id);
|
||||
const freshDetails = await getOrderDetails(service.id);
|
||||
setActiveServiceModal({ type: isVPS ? 'vps' : 'web', data: service, details: freshDetails });
|
||||
}
|
||||
} catch (err) { triggerAlert("Erreur", err.message, "error"); }
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// src/pages/app/Store.jsx
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Server, Database, Cloud, Globe, Loader, AlertCircle } from 'lucide-react';
|
||||
import { getProductList } from '../../services/api';
|
||||
import CategorySection from '../../components/store/CategorySection'; // L'import magique
|
||||
import { getProductList } from '../../services/billing_api';
|
||||
import CategorySection from '../../components/store/CategorySection';
|
||||
|
||||
export default function Store() {
|
||||
const [groupedProducts, setGroupedProducts] = useState({});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate, Link } from 'react-router-dom';
|
||||
import { loginClient } from '../../services/api';
|
||||
import { loginClient } from '../../services/billing_api';
|
||||
|
||||
export default function Login() {
|
||||
const [email, setEmail] = useState('');
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate, Link } from 'react-router-dom';
|
||||
import { registerUnifiedClient } from '../../services/api';
|
||||
import { createNewClient } from '../../services/billing_api';
|
||||
import NotificationModal from '../../components/ui/NotificationModal';
|
||||
|
||||
export default function Register() {
|
||||
@@ -28,15 +28,15 @@ export default function Register() {
|
||||
setCustomAlert({ type: 'error', title: 'Erreur de saisie', message: "Les clés d'accès ne correspondent pas." });
|
||||
return;
|
||||
}
|
||||
if (!/^[a-zA-Z0-9]{3,12}$/.test(username)) {
|
||||
if (!/^[a-zA-Z0-9]{3,20}$/.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 {
|
||||
await registerUnifiedClient(email, username, password, firstName, lastName);
|
||||
setCustomAlert({ type: 'success', title: 'PROVISIONNEMENT RÉUSSI', message: "Vos comptes FOSSBilling, HestiaCP et Nextcloud ont été initialisés.\n\nVous pouvez maintenant vous connecter." });
|
||||
await createNewClient(email, firstName, lastName, password, confirmPassword, "individual", username);
|
||||
setCustomAlert({ type: 'success', title: 'PROVISIONNEMENT RÉUSSI', message: "Votre compte a été initialisé.\n\nVous pouvez maintenant vous connecter." });
|
||||
} catch (err) {
|
||||
setCustomAlert({ type: 'error', title: 'Échec du Déploiement', message: err.message || "Échec de l'initialisation." });
|
||||
} finally {
|
||||
@@ -56,7 +56,7 @@ export default function Register() {
|
||||
Créer un accès réseau
|
||||
</h2>
|
||||
<p className="text-[#888] font-mono text-sm mb-6">
|
||||
[ INITIALISATION DU PROVISIONNEMENT TRIPLE ]
|
||||
[ INITIALISATION DU PROVISIONNEMENT ]
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleRegister} className="font-mono">
|
||||
|
||||
+185
-60
@@ -1,7 +1,7 @@
|
||||
const BASE_URL = import.meta.env.VITE_API_BASE_URL || '';
|
||||
const CUSTOM_API_BASE_URL = import.meta.env.VITE_CUSTOM_API_BASE_URL || '';
|
||||
|
||||
// Le moteur de requête unifié et intelligent
|
||||
// Le moteur de requête
|
||||
const apiCall = async (endpoint, param2 = 'GET', param3 = null) => {
|
||||
let method = 'GET';
|
||||
let body = null;
|
||||
@@ -64,24 +64,6 @@ const apiCall = async (endpoint, param2 = 'GET', param3 = null) => {
|
||||
}
|
||||
};
|
||||
|
||||
// ==========================================
|
||||
// ROUTES FOSSBILLING NATIVES (BACKTICKS INTÉGRÉS)
|
||||
// ==========================================
|
||||
|
||||
export const loginClient = (email, password) =>
|
||||
apiCall(`${BASE_URL}/api/guest/client/login`, { email, password });
|
||||
|
||||
// Récupère la liste des services/commandes du client
|
||||
export const getClientOrders = () =>
|
||||
apiCall(`${BASE_URL}/api/client/order/get_list`);
|
||||
|
||||
// Récupère les détails techniques du service rattaché à une commande
|
||||
export const getOrderService = (order_id) =>
|
||||
apiCall(`${BASE_URL}/api/client/order/service`, { id: order_id });
|
||||
|
||||
// Récupère le catalogue public des produits FOSSBilling
|
||||
export const getProductList = () =>
|
||||
apiCall(`${BASE_URL}/api/guest/product/get_list`);
|
||||
|
||||
// Vide le panier (Action PUBLIQUE : on passe par l'API Guest)
|
||||
export const resetCart = async () => {
|
||||
@@ -100,22 +82,6 @@ export const resetCart = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
// Ajoute un produit au panier avec ses options étalées à la racine
|
||||
export const addToCart = (productId, period, additionalData = {}) =>
|
||||
apiCall(`${BASE_URL}/api/guest/cart/add_item`, 'POST', {
|
||||
id: productId,
|
||||
period: period,
|
||||
...additionalData // Les 3 petits points "étalent" le contenu de l'objet
|
||||
});
|
||||
|
||||
// Valide le panier (Action PRIVÉE : on reste sur l'API Client pour générer la facture)
|
||||
export const checkoutCart = () =>
|
||||
apiCall(`${BASE_URL}/api/client/cart/checkout`, 'POST');
|
||||
|
||||
// Récupère les informations du client connecté
|
||||
export const getClientProfile = () =>
|
||||
apiCall(`${BASE_URL}/api/client/profile/get`, 'GET');
|
||||
|
||||
// ==========================================
|
||||
// ROUTES PERSONNALISÉES (CUSTOM API)
|
||||
// ==========================================
|
||||
@@ -144,18 +110,6 @@ export const registerUnifiedClient = async (email, username, password, firstName
|
||||
}
|
||||
}
|
||||
|
||||
// Récupère la liste de toutes les commandes actives du client
|
||||
export const getMyServices = () =>
|
||||
apiCall(`${BASE_URL}/api/client/order/get_list`, 'GET');
|
||||
|
||||
// Récupère les détails secrets d'un service (dont le mot de passe HestiaCP/VPS)
|
||||
export const getServiceDetails = (orderId) =>
|
||||
apiCall(`${BASE_URL}/api/client/order/get`, 'POST', { id: orderId });
|
||||
|
||||
// Récupère les secrets spécifiques du service physique attaché à une commande
|
||||
export const getHostingServiceDetails = (orderId) =>
|
||||
apiCall(`${BASE_URL}/api/client/order/service`, 'POST', { id: orderId });
|
||||
|
||||
// Force la réinitialisation du mot de passe sur le serveur distant (HestiaCP)
|
||||
export const resetHostingPassword = (orderId, newPassword) =>
|
||||
apiCall(`${BASE_URL}/api/client/servicehosting/change_password`, 'POST', {
|
||||
@@ -191,33 +145,204 @@ export const launchSSOGateway = (username, password) => {
|
||||
document.body.removeChild(form);
|
||||
};
|
||||
|
||||
// #######################################################################################
|
||||
|
||||
// ==========================================
|
||||
// ROUTES SUPPORT FOSSBILLING (NATIVES)
|
||||
// GESTION DES ACCES
|
||||
// ==========================================
|
||||
|
||||
// Récupère la liste de tous les tickets du client
|
||||
export const getClientTickets = () =>
|
||||
apiCall(`${BASE_URL}/api/client/support/ticket_get_list`, 'GET');
|
||||
// Connexion
|
||||
export const loginClient = (email, password) =>
|
||||
apiCall(`${BASE_URL}/api/guest/client/login`, { email, password });
|
||||
|
||||
// Récupère le contenu et les messages d'un ticket spécifique
|
||||
export const getTicketDetails = (ticketId) =>
|
||||
apiCall(`${BASE_URL}/api/client/support/ticket_get`, 'POST', { id: ticketId });
|
||||
// Deconnexion
|
||||
export const logoutClient = () =>
|
||||
apiCall(`${BASE_URL}/api/client/profile/logout`);
|
||||
|
||||
// Crée un nouveau ticket de support
|
||||
|
||||
// ==========================================
|
||||
// GESTION DU CLIENT
|
||||
// ==========================================
|
||||
|
||||
// Creer un nouveau profil client
|
||||
export const createClientProfile = (email, last_name, aid, gender, country, city, birthday, company, company_vat, company_number, type, address_1, address_2, postcode, state, phone, phone_cc, document_type, document_nr, notes, lang, custom_1, custom_2, custom_3, custom_4, custom_5, custom_6, custom_7, custom_8, custom_9, custom_10) =>
|
||||
apiCall(`${BASE_URL}/api/guest/profile/create`, {
|
||||
email: email,
|
||||
last_name: last_name,
|
||||
aid: aid,
|
||||
gender: gender,
|
||||
country: country,
|
||||
city: city,
|
||||
birthday: birthday,
|
||||
company: company,
|
||||
company_vat: company_vat,
|
||||
company_number: company_number,
|
||||
type: type,
|
||||
address_1: address_1,
|
||||
address_2: address_2,
|
||||
postcode: postcode,
|
||||
state: state,
|
||||
phone: phone,
|
||||
phone_cc: phone_cc,
|
||||
document_type: document_type,
|
||||
document_nr: document_nr,
|
||||
notes: notes,
|
||||
lang: lang,
|
||||
custom_1: custom_1,
|
||||
custom_2: custom_2,
|
||||
custom_3: custom_3,
|
||||
custom_4: custom_4,
|
||||
custom_5: custom_5,
|
||||
custom_6: custom_6,
|
||||
custom_7: custom_7,
|
||||
custom_8: custom_8,
|
||||
custom_9: custom_9,
|
||||
custom_10: custom_10
|
||||
});
|
||||
|
||||
// Recuperer les informations du client
|
||||
export const getClientProfile = () =>
|
||||
apiCall(`${BASE_URL}/api/client/profile/get`, 'GET');
|
||||
|
||||
// Update les informations du client
|
||||
export const updateClientProfile = (email, last_name, aid, gender, country, city, birthday, company, company_vat, company_number, type, address_1, address_2, postcode, state, phone, phone_cc, document_type, document_nr, notes, lang, custom_1, custom_2, custom_3, custom_4, custom_5, custom_6, custom_7, custom_8, custom_9, custom_10) =>
|
||||
apiCall(`${BASE_URL}/api/client/profile/update`, {
|
||||
email: email,
|
||||
last_name: last_name,
|
||||
aid: aid,
|
||||
gender: gender,
|
||||
country: country,
|
||||
city: city,
|
||||
birthday: birthday,
|
||||
company: company,
|
||||
company_vat: company_vat,
|
||||
company_number: company_number,
|
||||
type: type,
|
||||
address_1: address_1,
|
||||
address_2: address_2,
|
||||
postcode: postcode,
|
||||
state: state,
|
||||
phone: phone,
|
||||
phone_cc: phone_cc,
|
||||
document_type: document_type,
|
||||
document_nr: document_nr,
|
||||
notes: notes,
|
||||
lang: lang,
|
||||
custom_1: custom_1,
|
||||
custom_2: custom_2,
|
||||
custom_3: custom_3,
|
||||
custom_4: custom_4,
|
||||
custom_5: custom_5,
|
||||
custom_6: custom_6,
|
||||
custom_7: custom_7,
|
||||
custom_8: custom_8,
|
||||
custom_9: custom_9,
|
||||
custom_10: custom_10
|
||||
});
|
||||
|
||||
// Changer le mot de passe
|
||||
export const changeClientPassword = (current_password, new_password, confirm_password) =>
|
||||
apiCall(`${BASE_URL}/api/client/profile/change_password`, {current_password:current_password, new_password:new_password, confirm_password:confirm_password});
|
||||
|
||||
|
||||
|
||||
// ==========================================
|
||||
// GESTION DES ABONNEMENTS
|
||||
// ==========================================
|
||||
|
||||
// Souscription
|
||||
|
||||
// Resiliation
|
||||
|
||||
// Suppression
|
||||
export const deleteWrongOrder = (order_id) =>
|
||||
apiCall(`${BASE_URL}/api/client/order/delete`, { id: order_id });
|
||||
|
||||
// Promotion (Surclassement)
|
||||
// Lister
|
||||
export const getUpgrades = (order_id) =>
|
||||
apiCall(`${BASE_URL}/api/client/order/upgradables`, { id: order_id });
|
||||
|
||||
// Demotion (Declassement)
|
||||
|
||||
// Lister les abonnements du client
|
||||
export const getClientOrders = () =>
|
||||
apiCall(`${BASE_URL}/api/client/order/get_list`);
|
||||
|
||||
// Lister les produits disponibles
|
||||
export const getProductList = () =>
|
||||
apiCall(`${BASE_URL}/api/guest/product/get_list`);
|
||||
|
||||
// Recuperer les details d'un abonnement
|
||||
export const getOrderDetails = (order_id) =>
|
||||
apiCall(`${BASE_URL}/api/client/order/service`, { id: order_id });
|
||||
|
||||
// Recuperer les details secrets d'un abonnement
|
||||
export const getServiceDetails = (orderId) =>
|
||||
apiCall(`${BASE_URL}/api/client/order/get`, 'POST', { id: orderId });
|
||||
|
||||
|
||||
// ==========================================
|
||||
// GESTION DU PANIER
|
||||
// ==========================================
|
||||
|
||||
// Ajouter au panier
|
||||
export const addToCart = (productId, period, additionalData = {}) =>
|
||||
apiCall(`${BASE_URL}/api/guest/cart/add_item`, 'POST', {
|
||||
id: productId,
|
||||
period: period,
|
||||
...additionalData // Les 3 petits points "étalent" le contenu de l'objet
|
||||
});
|
||||
|
||||
// Checkout
|
||||
export const checkoutCart = () =>
|
||||
apiCall(`${BASE_URL}/api/client/cart/checkout`, 'POST');
|
||||
|
||||
|
||||
// ==========================================
|
||||
// GESTION DES FACTURES
|
||||
// ==========================================
|
||||
|
||||
// Creation
|
||||
// Suppression
|
||||
// Modification
|
||||
// Lister
|
||||
export const getInvoiceList = () =>
|
||||
apiCall(`${BASE_URL}/api/client/invoice/get_list`, 'GET');
|
||||
|
||||
// Lire
|
||||
export const getInvoiceDetails = (InvoiceHash) =>
|
||||
apiCall(`${BASE_URL}/api/client/invoice/get`, 'GET');
|
||||
|
||||
|
||||
// ==========================================
|
||||
// GESTION DES TICKETS
|
||||
// ==========================================
|
||||
|
||||
// Creation
|
||||
export const createTicket = (subject, message, helpdesk_id) =>
|
||||
apiCall(`${BASE_URL}/api/client/support/ticket_create`, 'POST', {
|
||||
support_helpdesk_id: helpdesk_id,
|
||||
subject: subject,
|
||||
content: message
|
||||
});
|
||||
// Suppression
|
||||
|
||||
// Répond à un ticket existant
|
||||
// Lister les tickets
|
||||
export const getClientTickets = () =>
|
||||
apiCall(`${BASE_URL}/api/client/support/ticket_get_list`, 'GET');
|
||||
|
||||
// Lister les helpdesks
|
||||
export const getHelpdesks = () =>
|
||||
apiCall(`${BASE_URL}/api/client/support/helpdesk_get_pairs`, 'GET');
|
||||
|
||||
// Lire
|
||||
export const getTicketDetails = (ticketId) =>
|
||||
apiCall(`${BASE_URL}/api/client/support/ticket_get`, 'POST', { id: ticketId });
|
||||
|
||||
// Repondre
|
||||
export const replyTicket = (ticketId, message) =>
|
||||
apiCall(`${BASE_URL}/api/client/support/ticket_reply`, 'POST', {
|
||||
id: ticketId,
|
||||
content: 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');
|
||||
@@ -0,0 +1,346 @@
|
||||
const BASE_URL = import.meta.env.VITE_API_BASE_URL || '';
|
||||
const CUSTOM_API_BASE_URL = import.meta.env.VITE_CUSTOM_API_BASE_URL || '';
|
||||
|
||||
// Le moteur de requête
|
||||
const apiCall = async (endpoint, param2 = 'GET', param3 = null) => {
|
||||
let method = 'GET';
|
||||
let body = null;
|
||||
|
||||
// DÉTECTION DE SIGNATURE (Le bouclier anti-crash)
|
||||
if (typeof param2 === 'string') {
|
||||
// Cas 1 : On a bien envoyé (URL, "POST", {données})
|
||||
method = param2.toUpperCase();
|
||||
body = param3;
|
||||
} else if (typeof param2 === 'object' && param2 !== null) {
|
||||
// Cas 2 : L'ancienne méthode a envoyé (URL, {données})
|
||||
// On redirige l'objet vers le body, et on force en POST
|
||||
body = param2;
|
||||
method = (typeof param3 === 'string') ? param3.toUpperCase() : 'POST';
|
||||
}
|
||||
|
||||
// 1. Récupération du sésame
|
||||
const token = localStorage.getItem('token');
|
||||
|
||||
// 2. Préparation de l'enveloppe
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json'
|
||||
};
|
||||
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
// 3. Configuration finale garantie sans objets égarés
|
||||
const options = {
|
||||
method: method,
|
||||
headers: headers,
|
||||
credentials: 'include'
|
||||
};
|
||||
|
||||
if (body) {
|
||||
options.body = JSON.stringify(body);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(endpoint, options);
|
||||
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
throw new Error("Accès refusé. Session expirée ou non valide.");
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Gestion des erreurs internes de l'API
|
||||
if (data.error) {
|
||||
throw new Error(data.error.message || "Erreur renvoyée par le serveur de facturation.");
|
||||
}
|
||||
|
||||
return data.result || data;
|
||||
|
||||
} catch (error) {
|
||||
console.error(`[API FAIL] ${method} ${endpoint} :`, error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// Vide le panier (Action PUBLIQUE : on passe par l'API Guest)
|
||||
export const resetCart = async () => {
|
||||
try {
|
||||
const cart = await apiCall(`${BASE_URL}/api/guest/cart/get`);
|
||||
|
||||
if (cart && cart.items && cart.items.length > 0) {
|
||||
for (const item of cart.items) {
|
||||
await apiCall(`${BASE_URL}/api/guest/cart/remove_item`, { id: item.id });
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("Nettoyage du panier ignoré :", err);
|
||||
}
|
||||
};
|
||||
|
||||
// ==========================================
|
||||
// ROUTES PERSONNALISÉES (CUSTOM API)
|
||||
// ==========================================
|
||||
|
||||
export const registerUnifiedClient = async (email, username, password, firstName, lastName) => {
|
||||
try {
|
||||
// Utilisation des backticks ici aussi !
|
||||
const response = await fetch(`${CUSTOM_API_BASE_URL}/custom_api/signup.php`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
email: email,
|
||||
username: username,
|
||||
password: password,
|
||||
first_name: firstName,
|
||||
last_name: lastName
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (data.error) throw new Error(data.error.message);
|
||||
return data.result;
|
||||
} catch (err) {
|
||||
console.error("Erreur lors de l'inscription unifiée :", err);
|
||||
throw new Error("Échec de l'inscription. Veuillez réessayer plus tard.");
|
||||
}
|
||||
}
|
||||
|
||||
// #######################################################################################
|
||||
|
||||
// ==========================================
|
||||
// GESTION DES ACCES
|
||||
// ==========================================
|
||||
|
||||
// Connexion
|
||||
export const loginClient = (email, password) =>
|
||||
apiCall(`${BASE_URL}/api/guest/client/login`, { email, password });
|
||||
|
||||
// Deconnexion
|
||||
export const logoutClient = () =>
|
||||
apiCall(`${BASE_URL}/api/client/profile/logout`);
|
||||
|
||||
|
||||
// ==========================================
|
||||
// GESTION DU CLIENT
|
||||
// ==========================================
|
||||
|
||||
// Creer un nouveau profil client
|
||||
export const createNewClient = (
|
||||
email,
|
||||
first_name,
|
||||
last_name,
|
||||
password,
|
||||
password_confirm,
|
||||
type,
|
||||
custom_1) =>
|
||||
apiCall(`${BASE_URL}/api/guest/client/create`, {
|
||||
email: email,
|
||||
first_name: first_name,
|
||||
password: password,
|
||||
password_confirm: password_confirm,
|
||||
last_name: last_name,
|
||||
type: type,
|
||||
custom_1: custom_1
|
||||
});
|
||||
|
||||
// Recuperer les informations du client
|
||||
export const getClient = () =>
|
||||
apiCall(`${BASE_URL}/api/client/profile/get`);
|
||||
|
||||
// Update les informations du client
|
||||
export const updateClient = (email, last_name, aid, gender, country, city, birthday, company, company_vat, company_number, type, address_1, address_2, postcode, state, phone, phone_cc, document_type, document_nr, notes, lang, custom_1, custom_2, custom_3, custom_4, custom_5, custom_6, custom_7, custom_8, custom_9, custom_10) =>
|
||||
apiCall(`${BASE_URL}/api/client/profile/update`, {
|
||||
email: email,
|
||||
last_name: last_name,
|
||||
aid: aid,
|
||||
gender: gender,
|
||||
country: country,
|
||||
city: city,
|
||||
birthday: birthday,
|
||||
company: company,
|
||||
company_vat: company_vat,
|
||||
company_number: company_number,
|
||||
type: type,
|
||||
address_1: address_1,
|
||||
address_2: address_2,
|
||||
postcode: postcode,
|
||||
state: state,
|
||||
phone: phone,
|
||||
phone_cc: phone_cc,
|
||||
document_type: document_type,
|
||||
document_nr: document_nr,
|
||||
notes: notes,
|
||||
lang: lang,
|
||||
custom_1: custom_1,
|
||||
custom_2: custom_2,
|
||||
custom_3: custom_3,
|
||||
custom_4: custom_4,
|
||||
custom_5: custom_5,
|
||||
custom_6: custom_6,
|
||||
custom_7: custom_7,
|
||||
custom_8: custom_8,
|
||||
custom_9: custom_9,
|
||||
custom_10: custom_10
|
||||
|
||||
});
|
||||
|
||||
// Changer le mot de passe
|
||||
export const changeClientPassword = (current_password, new_password) =>
|
||||
apiCall(`${BASE_URL}/api/client/profile/change_password`, {
|
||||
current_password: current_password,
|
||||
new_password: new_password,
|
||||
confirm_password: new_password
|
||||
});
|
||||
|
||||
|
||||
|
||||
// ==========================================
|
||||
// GESTION DES ABONNEMENTS
|
||||
// ==========================================
|
||||
|
||||
// Souscription
|
||||
|
||||
// Resiliation
|
||||
// Action : Demande d'annulation (S'occupe de vérifier les 14 jours côté serveur)
|
||||
export const cancelOrder = (orderId) => {
|
||||
return apiCall(`${CUSTOM_API_BASE_URL}/custom_api/cancel_subscription.php`, 'POST', {
|
||||
order_id: orderId
|
||||
});
|
||||
};
|
||||
|
||||
// Suppression
|
||||
export const deleteOrder = (order_id) =>
|
||||
apiCall(`${BASE_URL}/api/client/order/delete`, { id: order_id });
|
||||
|
||||
// Promotion (Surclassement)
|
||||
// Lister
|
||||
export const getUpgrades = (order_id) =>
|
||||
apiCall(`${BASE_URL}/api/client/order/upgradables`, { id: order_id });
|
||||
|
||||
// Demotion (Declassement)
|
||||
|
||||
// Lister les abonnements du client
|
||||
export const getOrdersList = () =>
|
||||
apiCall(`${BASE_URL}/api/client/order/get_list`);
|
||||
|
||||
// Lister les produits disponibles
|
||||
export const getProductList = () =>
|
||||
apiCall(`${BASE_URL}/api/guest/product/get_list`);
|
||||
|
||||
// Recuperer les details d'un abonnement
|
||||
export const getOrderDetails = (order_id) =>
|
||||
apiCall(`${BASE_URL}/api/client/order/service`, { id: order_id });
|
||||
|
||||
// Recuperer les details secrets d'un abonnement
|
||||
export const getServiceDetails = (orderId) =>
|
||||
apiCall(`${BASE_URL}/api/client/order/get`, { id: orderId });
|
||||
|
||||
|
||||
// ==========================================
|
||||
// GESTION DU PANIER
|
||||
// ==========================================
|
||||
|
||||
// Ajouter au panier
|
||||
export const addToCart = (productId, period, additionalData = {}) =>
|
||||
apiCall(`${BASE_URL}/api/guest/cart/add_item`, {
|
||||
id: productId,
|
||||
period: period,
|
||||
...additionalData // Les 3 petits points "étalent" le contenu de l'objet
|
||||
});
|
||||
|
||||
// Checkout
|
||||
export const checkoutCart = () =>
|
||||
apiCall(`${BASE_URL}/api/client/cart/checkout`, 'POST');
|
||||
|
||||
|
||||
|
||||
// ==========================================
|
||||
// GESTION DES SERVICES D'HEBERGEMENT
|
||||
// ==========================================
|
||||
|
||||
// Modification du username
|
||||
export const updateHostingUsername = (order_id, username) =>
|
||||
apiCall(`${BASE_URL}/api/client/servicehosting/change_username`, {
|
||||
order_id: order_id,
|
||||
username: username
|
||||
});
|
||||
|
||||
// Modification du mot de passe
|
||||
export const updateHostingPassword = (order_id, new_password) =>
|
||||
apiCall(`${BASE_URL}/api/client/servicehosting/change_password`, {
|
||||
order_id: order_id,
|
||||
password: new_password,
|
||||
password_confirm: new_password
|
||||
});
|
||||
|
||||
// Modification du domaine principal
|
||||
export const updateHostingDomain = (order_id, new_domain) =>
|
||||
apiCall(`${BASE_URL}/api/client/servicehosting/change_domain`, {
|
||||
order_id: order_id,
|
||||
domain: new_domain
|
||||
});
|
||||
|
||||
// Login URL (SSO)
|
||||
export const getHostingLoginUrl = (order_id) =>
|
||||
apiCall(`${BASE_URL}/api/client/servicehosting/get_login_url`, {
|
||||
order_id: order_id
|
||||
});
|
||||
|
||||
// Modification du plan d'hébergement
|
||||
export const updateHostingPlan = (order_id, new_plan_id) =>
|
||||
apiCall(`${BASE_URL}/api/admin/servicehosting/change_plan`, {
|
||||
order_id: order_id,
|
||||
plan_id: new_plan_id
|
||||
});
|
||||
|
||||
|
||||
// ==========================================
|
||||
// GESTION DES FACTURES
|
||||
// ==========================================
|
||||
|
||||
// Creation
|
||||
// Suppression
|
||||
// Modification
|
||||
// Lister
|
||||
export const getInvoiceList = (client_id) =>
|
||||
apiCall(`${BASE_URL}/api/client/invoice/get_list`, { client_id: client_id });
|
||||
|
||||
// Lire
|
||||
export const getInvoiceDetails = (invoiceHash) =>
|
||||
apiCall(`${BASE_URL}/api/client/invoice/get`, { hash: invoiceHash });
|
||||
|
||||
|
||||
// ==========================================
|
||||
// GESTION DES TICKETS
|
||||
// ==========================================
|
||||
|
||||
// Creation
|
||||
export const createTicket = (subject, message, helpdesk_id) =>
|
||||
apiCall(`${BASE_URL}/api/client/support/ticket_create`, {
|
||||
support_helpdesk_id: helpdesk_id,
|
||||
subject: subject,
|
||||
content: message
|
||||
});
|
||||
// Suppression
|
||||
|
||||
// Lister les tickets
|
||||
export const getClientTickets = () =>
|
||||
apiCall(`${BASE_URL}/api/client/support/ticket_get_list`);
|
||||
|
||||
// Lister les helpdesks
|
||||
export const getHelpdesks = () =>
|
||||
apiCall(`${BASE_URL}/api/client/support/helpdesk_get_pairs`);
|
||||
|
||||
// Lire
|
||||
export const getTicketDetails = (ticketId) =>
|
||||
apiCall(`${BASE_URL}/api/client/support/ticket_get`, { id: ticketId });
|
||||
|
||||
// Repondre
|
||||
export const replyTicket = (ticketId, message) =>
|
||||
apiCall(`${BASE_URL}/api/client/support/ticket_reply`, {
|
||||
id: ticketId,
|
||||
content: message
|
||||
});
|
||||
Reference in New Issue
Block a user