Files
charles/frontend/src/components/Dashboard.jsx
T

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">&times;</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">&times;</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>
)
}