Home view: tableau recap + onglets centres, nettoyage affichage
This commit is contained in:
@@ -1,15 +1,14 @@
|
|||||||
import { useState, useEffect, useRef } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
|
|
||||||
const KSUITE_PRICE = 6.58
|
const KSUITE_PRICE = 6.58
|
||||||
const SITE_PRICE = 69
|
const SITE_PRICE = 69
|
||||||
|
|
||||||
export default function Dashboard({ token, user }) {
|
export default function Dashboard({ token, user }) {
|
||||||
const [domains, setDomains] = useState([])
|
const [domains, setDomains] = useState([])
|
||||||
const [activeTab, setActiveTab] = useState(0)
|
const [activeTab, setActiveTab] = useState(-1)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [saved, setSaved] = useState(false)
|
const [saved, setSaved] = useState(false)
|
||||||
const saveTimer = useRef(null)
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch('/api/domains', { headers: { Authorization: 'Bearer ' + token } })
|
fetch('/api/domains', { headers: { Authorization: 'Bearer ' + token } })
|
||||||
@@ -20,13 +19,6 @@ export default function Dashboard({ token, user }) {
|
|||||||
})
|
})
|
||||||
}, [token])
|
}, [token])
|
||||||
|
|
||||||
const update = (id, field, val) => {
|
|
||||||
setDomains(prev => prev.map(d => d.id === id ? { ...d, [field]: val } : d))
|
|
||||||
setSaved(false)
|
|
||||||
if (saveTimer.current) clearTimeout(saveTimer.current)
|
|
||||||
saveTimer.current = setTimeout(() => saveDomain(id), 1500)
|
|
||||||
}
|
|
||||||
|
|
||||||
const saveDomain = async (id) => {
|
const saveDomain = async (id) => {
|
||||||
const d = domains.find(x => x.id === id)
|
const d = domains.find(x => x.id === id)
|
||||||
if (!d) return
|
if (!d) return
|
||||||
@@ -41,7 +33,11 @@ export default function Dashboard({ token, user }) {
|
|||||||
setTimeout(() => setSaved(false), 2000)
|
setTimeout(() => setSaved(false), 2000)
|
||||||
}
|
}
|
||||||
|
|
||||||
const saveNow = () => { if (saveTimer.current) clearTimeout(saveTimer.current); saveDomain(domains[activeTab]?.id) }
|
const update = (id, field, val) => {
|
||||||
|
setDomains(prev => prev.map(d => d.id === id ? { ...d, [field]: val } : d))
|
||||||
|
const timeout = setTimeout(() => saveDomain(id), 1200)
|
||||||
|
return () => clearTimeout(timeout)
|
||||||
|
}
|
||||||
|
|
||||||
const addToList = (id, field) => {
|
const addToList = (id, field) => {
|
||||||
const d = domains.find(x => x.id === id)
|
const d = domains.find(x => x.id === id)
|
||||||
@@ -67,105 +63,113 @@ export default function Dashboard({ token, user }) {
|
|||||||
if (loading) return <div className="text-slate-400 text-center py-12">Chargement...</div>
|
if (loading) return <div className="text-slate-400 text-center py-12">Chargement...</div>
|
||||||
if (domains.length === 0) return <div className="text-slate-400 text-center py-12">Aucun domaine</div>
|
if (domains.length === 0) return <div className="text-slate-400 text-center py-12">Aucun domaine</div>
|
||||||
|
|
||||||
const d = domains[activeTab]
|
|
||||||
const team = JSON.parse(d.team_members || '[]')
|
|
||||||
const nbUsers = d.has_team ? team.length : 0
|
|
||||||
const doms = JSON.parse(d.domains_list || '[]')
|
|
||||||
const isAdmin = user.role === 'admin'
|
const isAdmin = user.role === 'admin'
|
||||||
|
|
||||||
const ksuiteCost = nbUsers * KSUITE_PRICE
|
// HOME VIEW
|
||||||
const siteCost = d.has_website ? SITE_PRICE / 12 : 0
|
const HomeView = () => {
|
||||||
const extraCost = (d.extra_email_count || 0) * (d.extra_email_cost || 0)
|
const allDomains = []
|
||||||
const domsCost = doms.reduce((s, dm) => s + (dm.cost_per_year || 0) / 12, 0)
|
domains.forEach(d => {
|
||||||
const total = ksuiteCost + siteCost + extraCost + domsCost
|
const doms = JSON.parse(d.domains_list || '[]')
|
||||||
|
doms.forEach(dm => allDomains.push({ ...dm, society: d.company_name }))
|
||||||
|
})
|
||||||
|
|
||||||
const Row = ({ label, children }) => (
|
const getColor = (expiry) => {
|
||||||
<div className="flex items-center gap-4 py-2.5 border-b border-slate-700/20">
|
if (!expiry) return ''
|
||||||
<span className="text-slate-400 text-sm w-44 shrink-0">{label}</span>
|
const diff = (new Date(expiry) - new Date()) / (1000 * 60 * 60 * 24)
|
||||||
<div className="flex-1">{children}</div>
|
if (diff < 0) return 'text-red-400'
|
||||||
</div>
|
if (diff < 30) return 'text-amber-400'
|
||||||
)
|
if (diff < 90) return 'text-orange-300'
|
||||||
|
return 'text-emerald-400'
|
||||||
|
}
|
||||||
|
|
||||||
const Txt = ({ value, onChange, placeholder }) => (
|
const hosterColors = {
|
||||||
<input type="text" value={value || ''} onChange={e => onChange(e.target.value)} placeholder={placeholder}
|
'OVH': 'bg-sky-500/20 text-sky-300',
|
||||||
className="w-full px-3 py-2 bg-slate-900/40 border border-slate-700/50 rounded-lg text-white text-sm placeholder-slate-600 focus:outline-none focus:ring-1 focus:ring-blue-500/50" />
|
'Infomaniak': 'bg-emerald-500/20 text-emerald-300',
|
||||||
)
|
'Other': 'bg-slate-500/20 text-slate-300'
|
||||||
|
}
|
||||||
const Num = ({ value, onChange, placeholder, step }) => (
|
|
||||||
<input type="number" step={step || '1'} value={value || ''} onChange={e => onChange(parseFloat(e.target.value) || 0)} placeholder={placeholder}
|
|
||||||
className="w-28 px-3 py-2 bg-slate-900/40 border border-slate-700/50 rounded-lg text-white text-sm text-right placeholder-slate-600 focus:outline-none focus:ring-1 focus:ring-blue-500/50 font-mono" />
|
|
||||||
)
|
|
||||||
|
|
||||||
const Toggle = ({ checked, onChange }) => (
|
|
||||||
<button type="button" onClick={() => onChange(!checked ? 1 : 0)}
|
|
||||||
className={'relative w-10 h-5.5 rounded-full transition-colors ' + (checked ? 'bg-blue-600' : 'bg-slate-600')}>
|
|
||||||
<div className={'absolute top-0.5 left-0.5 w-4.5 h-4.5 rounded-full bg-white transition-transform ' + (checked ? 'translate-x-4.5' : '')} />
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
|
|
||||||
const Select = ({ value, onChange, options }) => (
|
|
||||||
<select value={value || ''} onChange={e => onChange(e.target.value)}
|
|
||||||
className="px-3 py-2 bg-slate-900/40 border border-slate-700/50 rounded-lg text-white text-sm focus:outline-none focus:ring-1 focus:ring-blue-500/50">
|
|
||||||
{options.map(o => <option key={o.v} value={o.v}>{o.l}</option>)}
|
|
||||||
</select>
|
|
||||||
)
|
|
||||||
|
|
||||||
// Global summary for admin
|
|
||||||
const GlobalSummary = () => {
|
|
||||||
if (!isAdmin) return null
|
|
||||||
const all = domains.reduce((acc, dom) => {
|
|
||||||
const t = dom.has_team ? JSON.parse(dom.team_members || '[]').length : 0
|
|
||||||
const dm = JSON.parse(dom.domains_list || '[]')
|
|
||||||
return {
|
|
||||||
users: acc.users + t,
|
|
||||||
sites: acc.sites + (dom.has_website ? 1 : 0),
|
|
||||||
doms: acc.doms + dm.length,
|
|
||||||
ksuite: acc.ksuite + t * KSUITE_PRICE,
|
|
||||||
site: acc.site + (dom.has_website ? SITE_PRICE / 12 : 0),
|
|
||||||
extra: acc.extra + (dom.extra_email_count || 0) * (dom.extra_email_cost || 0),
|
|
||||||
domCost: acc.domCost + dm.reduce((s, x) => s + (x.cost_per_year || 0) / 12, 0)
|
|
||||||
}
|
|
||||||
}, { users: 0, sites: 0, doms: 0, ksuite: 0, site: 0, extra: 0, domCost: 0 })
|
|
||||||
const gTotal = all.ksuite + all.site + all.extra + all.domCost
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-6 p-4 bg-slate-800/30 rounded-xl border border-slate-700/20">
|
<div>
|
||||||
<h4 className="text-white text-sm font-semibold mb-3">Resume global</h4>
|
<h2 className="text-lg font-bold text-white mb-4">Tableau de bord</h2>
|
||||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-3 text-center text-xs">
|
<div className="bg-slate-800/30 rounded-2xl border border-slate-700/20 overflow-hidden">
|
||||||
<div><p className="text-lg font-bold text-white">{all.users}</p><p className="text-slate-500">Users</p></div>
|
<table className="w-full text-sm">
|
||||||
<div><p className="text-lg font-bold text-sky-400">{all.ksuite.toFixed(0)}</p><p className="text-slate-500">Ksuite EUR/mois</p></div>
|
<thead>
|
||||||
<div><p className="text-lg font-bold text-violet-400">{all.sites}</p><p className="text-slate-500">Sites</p></div>
|
<tr className="border-b border-slate-700/30">
|
||||||
<div><p className="text-lg font-bold text-teal-400">{all.doms}</p><p className="text-slate-500">Domaines</p></div>
|
<th className="text-left px-4 py-3 text-slate-400 font-medium">Societe</th>
|
||||||
<div><p className="text-lg font-bold text-emerald-400">{gTotal.toFixed(2)}</p><p className="text-slate-500">Total EUR/mois HT</p></div>
|
<th className="text-left px-4 py-3 text-slate-400 font-medium">Domaine</th>
|
||||||
|
<th className="text-left px-4 py-3 text-slate-400 font-medium">Hebergeur</th>
|
||||||
|
<th className="text-left px-4 py-3 text-slate-400 font-medium">Expiration</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{allDomains.length === 0 && (
|
||||||
|
<tr><td colSpan={4} className="px-4 py-6 text-slate-500 text-center">Aucun domaine declare</td></tr>
|
||||||
|
)}
|
||||||
|
{allDomains.map((dm, i) => (
|
||||||
|
<tr key={i} className="border-b border-slate-700/20 hover:bg-slate-800/40 transition">
|
||||||
|
<td className="px-4 py-3 text-white">{dm.society}</td>
|
||||||
|
<td className="px-4 py-3 text-slate-300 font-mono text-xs">{dm.name || '—'}</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className={'px-2 py-0.5 rounded-md text-xs ' + (hosterColors[dm.hoster] || hosterColors['Other'])}>
|
||||||
|
{dm.hoster || '—'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className={'px-4 py-3 font-mono text-xs ' + getColor(dm.expiry)}>
|
||||||
|
{dm.expiry || '—'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
// DOMAIN FORM VIEW
|
||||||
<div>
|
const DomainForm = ({ d }) => {
|
||||||
{/* Tabs */}
|
const team = JSON.parse(d.team_members || '[]')
|
||||||
<div className="flex gap-1 pb-1 mb-6 justify-center">
|
const nbUsers = d.has_team ? team.length : 0
|
||||||
{domains.map((dom, i) => (
|
const doms = JSON.parse(d.domains_list || '[]')
|
||||||
<button key={dom.id} onClick={() => { setActiveTab(i); if (isAdmin) saveNow() }}
|
|
||||||
className={'px-3 py-2 rounded-lg text-sm whitespace-nowrap transition-colors shrink-0 ' +
|
|
||||||
(i === activeTab ? 'bg-blue-600 text-white font-medium' : 'bg-slate-800/50 text-slate-400 hover:text-white hover:bg-slate-800')}>
|
|
||||||
{dom.company_name}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Save indicator */}
|
const ksuiteCost = nbUsers * KSUITE_PRICE
|
||||||
<div className="flex items-center justify-between mb-4">
|
const siteCost = d.has_website ? SITE_PRICE / 12 : 0
|
||||||
<h2 className="text-lg font-bold text-white">{d.company_name} <span className="text-slate-500 text-sm font-normal">{d.name}</span></h2>
|
const extraCost = (d.extra_email_count || 0) * (d.extra_email_cost || 0)
|
||||||
<div className="flex items-center gap-3">
|
const domsCost = doms.reduce((s, dm) => s + (dm.cost_per_year || 0) / 12, 0)
|
||||||
{saving && <span className="text-slate-500 text-xs">Sauvegarde...</span>}
|
const total = ksuiteCost + siteCost + extraCost + domsCost
|
||||||
{saved && <span className="text-emerald-400 text-xs">Sauvegarde</span>}
|
|
||||||
{isAdmin && <button onClick={saveNow} className="px-3 py-1.5 bg-blue-600 hover:bg-blue-500 text-white text-xs rounded-lg transition">Sauvegarder</button>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Form */}
|
const Row = ({ label, children }) => (
|
||||||
|
<div className="flex items-center gap-4 py-2.5 border-b border-slate-700/20">
|
||||||
|
<span className="text-slate-400 text-sm w-44 shrink-0">{label}</span>
|
||||||
|
<div className="flex-1">{children}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
const Txt = ({ value, onChange, placeholder }) => (
|
||||||
|
<input type="text" value={value || ''} onChange={e => onChange(e.target.value)} placeholder={placeholder}
|
||||||
|
className="w-full px-3 py-2 bg-slate-900/40 border border-slate-700/50 rounded-lg text-white text-sm placeholder-slate-600 focus:outline-none focus:ring-1 focus:ring-blue-500/50" />
|
||||||
|
)
|
||||||
|
|
||||||
|
const Num = ({ value, onChange, placeholder, step }) => (
|
||||||
|
<input type="number" step={step || '1'} value={value || ''} onChange={e => onChange(parseFloat(e.target.value) || 0)} placeholder={placeholder}
|
||||||
|
className="w-28 px-3 py-2 bg-slate-900/40 border border-slate-700/50 rounded-lg text-white text-sm text-right placeholder-slate-600 focus:outline-none focus:ring-1 focus:ring-blue-500/50 font-mono" />
|
||||||
|
)
|
||||||
|
|
||||||
|
const Toggle = ({ checked, onChange, activeText, inactiveText, color }) => (
|
||||||
|
<button type="button" onClick={() => onChange(!checked ? 1 : 0)}
|
||||||
|
className={'text-xs px-2 py-1 rounded-md transition ' + (checked ? 'bg-' + color + '-500/20 text-' + color + '-400' : 'bg-slate-700/50 text-slate-500')}>
|
||||||
|
{checked ? activeText : inactiveText}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
|
||||||
|
const Select = ({ value, onChange, options }) => (
|
||||||
|
<select value={value || ''} onChange={e => onChange(e.target.value)}
|
||||||
|
className="px-3 py-2 bg-slate-900/40 border border-slate-700/50 rounded-lg text-white text-sm focus:outline-none focus:ring-1 focus:ring-blue-500/50">
|
||||||
|
{options.map(o => <option key={o.v} value={o.v}>{o.l}</option>)}
|
||||||
|
</select>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
<div className="bg-slate-800/30 rounded-2xl border border-slate-700/20 p-5">
|
<div className="bg-slate-800/30 rounded-2xl border border-slate-700/20 p-5">
|
||||||
{/* Utilisateurs */}
|
{/* Utilisateurs */}
|
||||||
<div className="mb-5">
|
<div className="mb-5">
|
||||||
@@ -311,8 +315,42 @@ export default function Dashboard({ token, user }) {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
<GlobalSummary />
|
return (
|
||||||
|
<div>
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="flex gap-1 pb-1 mb-6 justify-center flex-wrap">
|
||||||
|
<button onClick={() => setActiveTab(-1)}
|
||||||
|
className={'px-3 py-2 rounded-lg text-sm transition-colors shrink-0 ' +
|
||||||
|
(activeTab === -1 ? 'bg-white text-slate-900 font-medium' : 'bg-slate-800/50 text-slate-400 hover:text-white hover:bg-slate-800')}>
|
||||||
|
Home
|
||||||
|
</button>
|
||||||
|
{domains.map((dom, i) => (
|
||||||
|
<button key={dom.id} onClick={() => { setActiveTab(i); if (isAdmin) saveDomain(dom.id) }}
|
||||||
|
className={'px-3 py-2 rounded-lg text-sm whitespace-nowrap transition-colors shrink-0 ' +
|
||||||
|
(i === activeTab ? 'bg-blue-600 text-white font-medium' : 'bg-slate-800/50 text-slate-400 hover:text-white hover:bg-slate-800')}>
|
||||||
|
{dom.company_name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Save indicator */}
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
{activeTab === -1 ? (
|
||||||
|
<h2 className="text-lg font-bold text-white">Tableau de bord</h2>
|
||||||
|
) : (
|
||||||
|
<h2 className="text-lg font-bold text-white">{domains[activeTab].company_name}</h2>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{saving && <span className="text-slate-500 text-xs">Sauvegarde...</span>}
|
||||||
|
{saved && <span className="text-emerald-400 text-xs">Sauvegarde</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
{activeTab === -1 ? <HomeView /> : <DomainForm d={domains[activeTab]} key={domains[activeTab].id} />}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user