359 lines
18 KiB
React
359 lines
18 KiB
React
import { useState, useEffect } from 'react'
|
|
|
|
const KSUITE_PRICE = 6.58
|
|
const SITE_PRICE = 69
|
|
|
|
function getCosts(d) {
|
|
const team = JSON.parse(d.team_members || '[]')
|
|
const nbUsers = d.has_team ? team.length : 0
|
|
const doms = JSON.parse(d.domains_list || '[]')
|
|
const ksuite = nbUsers * KSUITE_PRICE
|
|
const site = d.has_website ? SITE_PRICE / 12 : 0
|
|
const extra = (d.extra_email_count || 0) * (d.extra_email_cost || 0)
|
|
const domCost = doms.reduce((s, dm) => s + (dm.cost_per_year || 0) / 12, 0)
|
|
return { nbUsers, doms, ksuite, site, extra, domCost, total: ksuite + site + extra + domCost }
|
|
}
|
|
|
|
export default function Dashboard({ token, user }) {
|
|
const [domains, setDomains] = useState([])
|
|
const [activeTab, setActiveTab] = useState(-1)
|
|
const [loading, setLoading] = useState(true)
|
|
const [saving, setSaving] = useState(false)
|
|
const [saved, setSaved] = useState(false)
|
|
|
|
useEffect(() => {
|
|
fetch('/api/domains', { headers: { Authorization: 'Bearer ' + token } })
|
|
.then(r => r.json()).then(d => {
|
|
const all = d.parent ? [d.parent, ...d.children] : d.children
|
|
setDomains(all)
|
|
setLoading(false)
|
|
})
|
|
}, [token])
|
|
|
|
const saveDomain = async (id) => {
|
|
const d = domains.find(x => x.id === id)
|
|
if (!d) return
|
|
setSaving(true)
|
|
await fetch('/api/domains/' + id, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + token },
|
|
body: JSON.stringify(d)
|
|
})
|
|
setSaving(false)
|
|
setSaved(true)
|
|
setTimeout(() => setSaved(false), 2000)
|
|
}
|
|
|
|
const update = (id, field, val) => {
|
|
setDomains(prev => prev.map(d => d.id === id ? { ...d, [field]: val } : d))
|
|
const t = setTimeout(() => saveDomain(id), 1200)
|
|
return () => clearTimeout(t)
|
|
}
|
|
|
|
const addToList = (id, field) => {
|
|
const d = domains.find(x => x.id === id)
|
|
const list = JSON.parse(d[field] || '[]')
|
|
list.push(field === 'team_members' ? { first_name: '', last_name: '' } : { name: '', cost_per_year: 0, expiry: '', hoster: '' })
|
|
update(id, field, JSON.stringify(list))
|
|
}
|
|
|
|
const updateInList = (id, field, i, key, val) => {
|
|
const d = domains.find(x => x.id === id)
|
|
const list = JSON.parse(d[field] || '[]')
|
|
list[i][key] = val
|
|
update(id, field, JSON.stringify(list))
|
|
}
|
|
|
|
const removeFromList = (id, field, i) => {
|
|
const d = domains.find(x => x.id === id)
|
|
const list = JSON.parse(d[field] || '[]')
|
|
list.splice(i, 1)
|
|
update(id, field, JSON.stringify(list))
|
|
}
|
|
|
|
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>
|
|
|
|
const isAdmin = user.role === 'admin'
|
|
|
|
// HOME VIEW
|
|
const HomeView = () => {
|
|
const allDomains = []
|
|
domains.forEach(d => {
|
|
const doms = JSON.parse(d.domains_list || '[]')
|
|
doms.forEach(dm => allDomains.push({ ...dm, society: d.company_name }))
|
|
})
|
|
|
|
const grandTotal = domains.reduce((s, d) => s + getCosts(d).total, 0)
|
|
|
|
const getColor = (expiry) => {
|
|
if (!expiry) return 'text-slate-500'
|
|
const diff = (new Date(expiry) - new Date()) / (1000 * 60 * 60 * 24)
|
|
if (diff < 0) return 'text-red-400'
|
|
if (diff < 30) return 'text-amber-400'
|
|
if (diff < 90) return 'text-orange-300'
|
|
return 'text-emerald-400'
|
|
}
|
|
|
|
const hosterBadge = (h) => {
|
|
const m = { 'OVH': 'bg-sky-500/20 text-sky-300', 'Infomaniak': 'bg-emerald-500/20 text-emerald-300' }
|
|
return m[h] || 'bg-slate-500/20 text-slate-300'
|
|
}
|
|
|
|
return (
|
|
<div>
|
|
{/* Recap par societe */}
|
|
<div className="grid grid-cols-2 md:grid-cols-3 gap-3 mb-6">
|
|
{domains.map((d, i) => {
|
|
const c = getCosts(d)
|
|
return (
|
|
<button key={d.id} onClick={() => setActiveTab(i)}
|
|
className="bg-slate-800/30 rounded-xl border border-slate-700/20 p-4 text-left hover:border-blue-500/30 hover:bg-slate-800/50 transition group">
|
|
<p className="text-white font-medium text-sm group-hover:text-blue-400 transition">{d.company_name}</p>
|
|
<p className="text-slate-500 text-xs mb-3">{d.name}</p>
|
|
<p className="text-2xl font-bold text-emerald-400 font-mono">{c.total.toFixed(2)}<span className="text-xs text-slate-500 ml-1">EUR/mois</span></p>
|
|
<div className="flex gap-3 mt-2 text-[11px] text-slate-500">
|
|
{c.nbUsers > 0 && <span>{c.nbUsers} user{c.nbUsers > 1 ? 's' : ''}</span>}
|
|
{c.doms.length > 0 && <span>{c.doms.length} domaine{c.doms.length > 1 ? 's' : ''}</span>}
|
|
{d.has_website && <span>Site</span>}
|
|
</div>
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
|
|
{/* Grand total */}
|
|
{isAdmin && (
|
|
<div className="bg-slate-800/30 rounded-xl border border-emerald-500/20 p-4 mb-6 text-center">
|
|
<p className="text-slate-400 text-sm">Total general</p>
|
|
<p className="text-3xl font-bold text-emerald-400 font-mono">{grandTotal.toFixed(2)}<span className="text-sm text-slate-500 ml-1">EUR/mois HT</span></p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Tableau domaines */}
|
|
{allDomains.length > 0 && (
|
|
<div>
|
|
<h3 className="text-white text-sm font-semibold mb-3">Domaines</h3>
|
|
<div className="bg-slate-800/30 rounded-2xl border border-slate-700/20 overflow-hidden">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="border-b border-slate-700/30">
|
|
<th className="text-left px-4 py-3 text-slate-400 font-medium">Societe</th>
|
|
<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.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 ' + hosterBadge(dm.hoster)}>{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>
|
|
)
|
|
}
|
|
|
|
// DOMAIN FORM
|
|
const DomainForm = ({ d }) => {
|
|
const c = getCosts(d)
|
|
|
|
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 }) => (
|
|
<button type="button" onClick={() => onChange(!checked ? 1 : 0)}
|
|
className={'relative inline-flex h-5 w-9 items-center rounded-full transition-colors ' + (checked ? 'bg-blue-600' : 'bg-slate-600')}>
|
|
<span className={'inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform ' + (checked ? 'translate-x-4' : 'translate-x-1')} />
|
|
</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="space-y-6">
|
|
{/* Utilisateurs */}
|
|
<Section title="Utilisateurs" desc={c.nbUsers > 0 ? c.nbUsers + ' licence' + (c.nbUsers > 1 ? 's' : '') + ' Ksuite' : ''}>
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-slate-300 text-sm">Activer les utilisateurs</span>
|
|
<Toggle checked={d.has_team} onChange={v => update(d.id, 'has_team', v)} />
|
|
</div>
|
|
{d.has_team && (
|
|
<div className="mt-3 space-y-2">
|
|
{team.map((m, i) => (
|
|
<div key={i} className="flex gap-2 items-center">
|
|
<input type="text" value={m.first_name} onChange={e => updateInList(d.id, 'team_members', i, 'first_name', e.target.value)} placeholder="Prenom"
|
|
className="flex-1 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" />
|
|
<input type="text" value={m.last_name} onChange={e => updateInList(d.id, 'team_members', i, 'last_name', e.target.value)} placeholder="Nom"
|
|
className="flex-1 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" />
|
|
<button onClick={() => removeFromList(d.id, 'team_members', i)} className="text-slate-500 hover:text-red-400 text-lg px-1 transition">×</button>
|
|
</div>
|
|
))}
|
|
<button onClick={() => addToList(d.id, 'team_members')} className="text-blue-400 hover:text-blue-300 text-sm">+ Ajouter un utilisateur</button>
|
|
</div>
|
|
)}
|
|
</Section>
|
|
|
|
{/* Site internet */}
|
|
<Section title="Site internet" desc={d.has_website ? SITE_PRICE + ' EUR/an' : ''}>
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-slate-300 text-sm">Site actif</span>
|
|
<Toggle checked={d.has_website} onChange={v => update(d.id, 'has_website', v)} />
|
|
</div>
|
|
{d.has_website && (
|
|
<div className="mt-3 space-y-3">
|
|
<div className="flex items-center gap-4">
|
|
<span className="text-slate-400 text-sm">WordPress</span>
|
|
<Toggle checked={d.website_wordpress} onChange={v => update(d.id, 'website_wordpress', v)} />
|
|
<span className="text-slate-400 text-sm ml-2">Gere par</span>
|
|
<Select value={d.website_managed_by} onChange={v => update(d.id, 'website_managed_by', v)}
|
|
options={[{v:'',l:'—'},{v:'charles',l:'Charles'},{v:'pierre',l:'Pierre'}]} />
|
|
</div>
|
|
<Txt value={d.website_details} onChange={v => update(d.id, 'website_details', v)} placeholder="Details du site..." />
|
|
</div>
|
|
)}
|
|
</Section>
|
|
|
|
{/* Emails */}
|
|
<Section title="Emails" desc={c.nbUsers * 5 + ' inclus via Ksuite'}>
|
|
<div className="flex items-center gap-4">
|
|
<span className="text-slate-400 text-sm">Supplementaires</span>
|
|
<Num value={d.extra_email_count} onChange={v => update(d.id, 'extra_email_count', v)} placeholder="0" />
|
|
<span className="text-slate-400 text-sm">Cout/unit EUR/mois</span>
|
|
<Num value={d.extra_email_cost} onChange={v => update(d.id, 'extra_email_cost', v)} placeholder="0" step="0.01" />
|
|
</div>
|
|
</Section>
|
|
|
|
{/* Office + Calendrier */}
|
|
<Section title="Services complementaires" desc="">
|
|
<div className="flex items-center gap-8">
|
|
<div className="flex items-center gap-3">
|
|
<span className="text-slate-300 text-sm">Pack Office</span>
|
|
<Toggle checked={d.has_office} onChange={v => update(d.id, 'has_office', v)} />
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
<span className="text-slate-300 text-sm">Calendrier partage</span>
|
|
<Toggle checked={d.has_calendar} onChange={v => update(d.id, 'has_calendar', v)} />
|
|
</div>
|
|
</div>
|
|
</Section>
|
|
|
|
{/* Domaines */}
|
|
<Section title="Noms de domaine" desc={c.doms.length + ' domaine' + (c.doms.length > 1 ? 's' : '')}>
|
|
<button onClick={() => addToList(d.id, 'domains_list')} className="text-blue-400 hover:text-blue-300 text-sm mb-3">+ Ajouter un domaine</button>
|
|
{c.doms.length > 0 && (
|
|
<div className="space-y-2">
|
|
<div className="flex gap-2 text-[10px] text-slate-600 uppercase tracking-wider px-1">
|
|
<span className="flex-1">Domaine</span><span className="w-24 text-right">Cout/an</span><span className="w-28">Expiration</span><span className="w-24">Hebergeur</span><span className="w-6"></span>
|
|
</div>
|
|
{c.doms.map((dm, i) => (
|
|
<div key={i} className="flex gap-2 items-center">
|
|
<input type="text" value={dm.name} onChange={e => updateInList(d.id, 'domains_list', i, 'name', e.target.value)} placeholder="domaine.fr"
|
|
className="flex-1 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" />
|
|
<input type="number" step="0.01" value={dm.cost_per_year || ''} onChange={e => updateInList(d.id, 'domains_list', i, 'cost_per_year', parseFloat(e.target.value) || 0)} placeholder="0"
|
|
className="w-24 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" />
|
|
<input type="date" value={dm.expiry || ''} onChange={e => updateInList(d.id, 'domains_list', i, 'expiry', e.target.value)}
|
|
className="w-28 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" />
|
|
<input type="text" value={dm.hoster || ''} onChange={e => updateInList(d.id, 'domains_list', i, 'hoster', e.target.value)} placeholder="Hebergeur"
|
|
className="w-24 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" />
|
|
<button onClick={() => removeFromList(d.id, 'domains_list', i)} className="text-slate-500 hover:text-red-400 text-lg px-1 transition">×</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</Section>
|
|
|
|
{/* Notes */}
|
|
<Section title="Notes" desc="">
|
|
<textarea value={d.notes} onChange={e => update(d.id, 'notes', e.target.value)} placeholder="Notes..." rows={3}
|
|
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 resize-none" />
|
|
</Section>
|
|
|
|
{/* Couts admin */}
|
|
{isAdmin && (
|
|
<div className="bg-slate-800/30 rounded-2xl border border-emerald-500/20 p-5">
|
|
<h4 className="text-white text-sm font-semibold mb-3">Couts mensuels HT</h4>
|
|
<div className="space-y-1.5 text-sm">
|
|
{c.nbUsers > 0 && <CostRow label={'Ksuite (' + c.nbUsers + ' x 6,58)'} value={c.ksuite} />}
|
|
{d.has_website && <CostRow label='Site (69/an)' value={c.site} />}
|
|
{(d.extra_email_count || 0) > 0 && <CostRow label={'Emails extras (' + d.extra_email_count + ')'} value={c.extra} />}
|
|
{c.doms.length > 0 && <CostRow label={'Domaines (' + c.doms.length + ')'} value={c.domCost} />}
|
|
<div className="flex justify-between pt-2 border-t border-slate-700/30 font-semibold"><span className="text-white">Total</span><span className="text-emerald-400 font-mono">{c.total.toFixed(2)} EUR/mois</span></div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div>
|
|
{/* Tabs */}
|
|
<div className="flex gap-1 pb-1 mb-6 justify-center flex-wrap">
|
|
<button onClick={() => setActiveTab(-1)}
|
|
className={'px-4 py-2 rounded-lg text-sm transition-colors shrink-0 ' +
|
|
(activeTab === -1 ? 'bg-white text-slate-900 font-semibold' : 'bg-slate-800/50 text-slate-400 hover:text-white hover:bg-slate-800')}>
|
|
Tableau de bord
|
|
</button>
|
|
{domains.map((dom, i) => (
|
|
<button key={dom.id} onClick={() => { setActiveTab(i); if (isAdmin) saveDomain(dom.id) }}
|
|
className={'px-4 py-2 rounded-lg text-sm whitespace-nowrap transition-colors shrink-0 ' +
|
|
(i === activeTab ? 'bg-blue-600 text-white font-semibold' : 'bg-slate-800/50 text-slate-400 hover:text-white hover:bg-slate-800')}>
|
|
{dom.company_name}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between mb-4">
|
|
{activeTab === -1 ? null : <h2 className="text-lg font-bold text-white">{domains[activeTab].company_name}</h2>}
|
|
<div className="flex items-center gap-3 ml-auto">
|
|
{saving && <span className="text-slate-500 text-xs">Sauvegarde...</span>}
|
|
{saved && <span className="text-emerald-400 text-xs">Sauvegarde</span>}
|
|
</div>
|
|
</div>
|
|
|
|
{activeTab === -1 ? <HomeView /> : <DomainForm d={domains[activeTab]} key={domains[activeTab].id} />}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// Reusable section
|
|
function Section({ title, desc, children }) {
|
|
return (
|
|
<div className="bg-slate-800/30 rounded-2xl border border-slate-700/20 p-5">
|
|
<div className="flex items-baseline gap-2 mb-3">
|
|
<h3 className="text-white text-sm font-semibold">{title}</h3>
|
|
{desc && <span className="text-slate-500 text-xs">{desc}</span>}
|
|
</div>
|
|
{children}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function CostRow({ label, value }) {
|
|
return (
|
|
<div className="flex justify-between">
|
|
<span className="text-slate-400">{label}</span>
|
|
<span className="text-white font-mono">{value.toFixed(2)} EUR</span>
|
|
</div>
|
|
)
|
|
}
|