diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index d353fb4..745a8ae 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -3,7 +3,6 @@ import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
import Login from './components/Login'
import Layout from './components/Layout'
import Dashboard from './components/Dashboard'
-import DomainForm from './components/DomainForm'
function App() {
const [token, setToken] = useState(localStorage.getItem('charles_token'))
@@ -29,7 +28,6 @@ function App() {
} />
- } />
} />
diff --git a/frontend/src/components/Dashboard.jsx b/frontend/src/components/Dashboard.jsx
index 06d8fe4..a48b501 100644
--- a/frontend/src/components/Dashboard.jsx
+++ b/frontend/src/components/Dashboard.jsx
@@ -1,159 +1,318 @@
-import { useState, useEffect } from 'react'
-import { Link } from 'react-router-dom'
+import { useState, useEffect, useRef } from 'react'
const KSUITE_PRICE = 6.58
const SITE_PRICE = 69
export default function Dashboard({ token, user }) {
- const [domains, setDomains] = useState({ parent: null, children: [] })
+ const [domains, setDomains] = useState([])
+ const [activeTab, setActiveTab] = useState(0)
const [loading, setLoading] = useState(true)
+ const [saving, setSaving] = useState(false)
+ const [saved, setSaved] = useState(false)
+ const saveTimer = useRef(null)
useEffect(() => {
fetch('/api/domains', { headers: { Authorization: 'Bearer ' + token } })
- .then(r => r.json()).then(d => { setDomains(d); setLoading(false) })
+ .then(r => r.json()).then(d => {
+ const all = d.parent ? [d.parent, ...d.children] : d.children
+ setDomains(all)
+ setLoading(false)
+ })
}, [token])
- if (loading) return
Chargement...
-
- const allDomains = [domains.parent, ...domains.children].filter(Boolean)
-
- const calcDomain = (d) => {
- const team = d.has_team ? JSON.parse(d.team_members || '[]').length : 0
- const ksuite = team * KSUITE_PRICE
- const site = d.has_website ? SITE_PRICE / 12 : 0
- const extra = (d.extra_email_count || 0) * (d.extra_email_cost || 0)
- const doms = JSON.parse(d.domains_list || '[]').reduce((s, dm) => s + (dm.cost_per_year || 0) / 12, 0)
- return { team, ksuite, site, extra, doms, total: ksuite + site + extra + doms }
+ 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 totals = allDomains.reduce((acc, d) => {
- const c = calcDomain(d)
- return {
- users: acc.users + c.team,
- ksuite: acc.ksuite + c.ksuite,
- site: acc.site + (d.has_website ? 1 : 0),
- siteCost: acc.siteCost + c.site,
- extra: acc.extra + (d.extra_email_count || 0),
- extraCost: acc.extraCost + c.extra,
- doms: acc.doms + JSON.parse(d.domains_list || '[]').length,
- domsCost: acc.domsCost + c.doms,
- total: acc.total + c.total
- }
- }, { users: 0, ksuite: 0, site: 0, siteCost: 0, extra: 0, extraCost: 0, doms: 0, domsCost: 0, total: 0 })
+ 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 count = (d) => [d.has_website, d.has_team, d.has_office, d.has_calendar].filter(Boolean).length
- const color = (n) => n === 0 ? 'bg-slate-600' : n < 2 ? 'bg-amber-500' : 'bg-emerald-500'
+ const saveNow = () => { if (saveTimer.current) clearTimeout(saveTimer.current); saveDomain(domains[activeTab]?.id) }
- const DomainCard = ({ d, badge }) => {
- const n = count(d)
- const team = d.has_team ? JSON.parse(d.team_members || '[]').length : 0
- const tags = []
- if (team > 0) tags.push({team} user{team > 1 ? 's' : ''})
- if (d.has_website) tags.push(Site{d.website_wordpress ? ' WP' : ''})
- if (d.has_office) tags.push(Office)
- if (d.has_calendar) tags.push(Calendrier)
- const doms = JSON.parse(d.domains_list || '[]')
- if (doms.length > 0) tags.push({doms.length} domaine{doms.length > 1 ? 's' : ''})
- if (n === 0 && team === 0) tags.push(Aucun besoin renseigne)
+ 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 cost = calcDomain(d)
+ 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 Chargement...
+ if (domains.length === 0) return Aucun domaine
+
+ 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 ksuiteCost = nbUsers * KSUITE_PRICE
+ const siteCost = d.has_website ? SITE_PRICE / 12 : 0
+ const extraCost = (d.extra_email_count || 0) * (d.extra_email_cost || 0)
+ const domsCost = doms.reduce((s, dm) => s + (dm.cost_per_year || 0) / 12, 0)
+ const total = ksuiteCost + siteCost + extraCost + domsCost
+
+ const Row = ({ label, children }) => (
+
+ )
+
+ const Txt = ({ value, onChange, placeholder }) => (
+ 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 }) => (
+ 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 }) => (
+
+ )
+
+ const Select = ({ value, onChange, options }) => (
+
+ )
+
+ // 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 (
-
-
-
- {badge &&
Maitre}
-
-
{d.company_name}
-
{d.name}
-
-
-
+
+
Resume global
+
+
+
{all.ksuite.toFixed(0)}
Ksuite EUR/mois
+
+
+
{gTotal.toFixed(2)}
Total EUR/mois HT
-
{tags}
- {user.role === 'admin' && cost.total > 0 && (
-
- {cost.total.toFixed(2)} EUR/mois HT
-
- )}
-
+
)
}
return (
-
-
Domaines & Services
-
- {user.role === 'admin' ? 'Gerez les besoins de Charles pour chaque societe.' : 'Renseignez vos besoins pour chaque domaine.'}
-
+ {/* Tabs */}
+
+ {domains.map((dom, i) => (
+
+ ))}
- {domains.parent &&
}
-
-
- {domains.children.map(d =>
)}
+ {/* Save indicator */}
+
+
{d.company_name} {d.name}
+
+ {saving && Sauvegarde...}
+ {saved && Sauvegarde}
+ {isAdmin && }
+
- {user.role === 'admin' && (
-
-
-
Resume
-
-
{allDomains.length}
Domaines
-
{totals.users}
Utilisateurs
-
{totals.ksuite.toFixed(0)}
Licences Ksuite
-
-
{totals.doms}
Noms de domaine
-
+ {/* Form */}
+
+ {/* Utilisateurs */}
+
+
+ Utilisateurs
+
+ {d.has_team && (
+
+ {team.map((m, i) => (
+
+ 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" />
+ 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" />
+
+
+ ))}
+
+
+ )}
+
-
-
Detail des couts mensuels HT
-
-
-
-
-
Ksuite Pro ({totals.users} user{totals.users > 1 ? 's' : ''})
-
6,58 EUR/user
-
-
{totals.ksuite.toFixed(2)} EUR
-
-
-
-
-
Sites web ({totals.site})
-
69 EUR/an
-
-
{totals.siteCost.toFixed(2)} EUR
-
- {totals.extra > 0 && (
-
-
-
-
Emails extras ({totals.extra})
-
-
{totals.extraCost.toFixed(2)} EUR
-
- )}
- {totals.doms > 0 && (
-
-
-
-
Noms de domaine ({totals.doms})
-
-
{totals.domsCost.toFixed(2)} EUR
-
- )}
-
-
Total mensuel HT
-
{totals.total.toFixed(2)} EUR/mois
+
+
+ {/* Site internet */}
+
+
+ Site internet 69 EUR/an
+
+
+ {d.has_website && (
+
+
+ WordPress
+
+ Gere par
+
+
update(d.id, 'website_details', v)} placeholder="Details du site..." />
+
+ )}
+
+
+
+
+ {/* Emails */}
+
+
Emails {nbUsers * 5} inclus via Ksuite
+
+ Supplementaires
+ update(d.id, 'extra_email_count', v)} placeholder="0" />
+ Cout/unit EUR/mois
+ update(d.id, 'extra_email_cost', v)} placeholder="0" step="0.01" />
+
+
+
+
+
+ {/* Office + Calendrier */}
+
+
+
+ Pack Office
+
+
+
+ Calendrier partage
+
- )}
+
+
+
+ {/* Noms de domaine */}
+
+
+ Noms de domaine
+
+
+ {doms.length > 0 && (
+
+
+ DomaineCout/anExpirationHebergeur
+
+ {doms.map((dm, i) => (
+
+ 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" />
+ 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" />
+ 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" />
+ 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" />
+
+
+ ))}
+
+ )}
+
+
+
+
+ {/* Notes */}
+
+ Notes
+
+
+ {/* Admin: couts */}
+ {isAdmin && (
+ <>
+
+
+
Couts mensuels HT
+
+ {nbUsers > 0 &&
Ksuite ({nbUsers} x 6,58){ksuiteCost.toFixed(2)} EUR
}
+ {d.has_website &&
Site (69/an){siteCost.toFixed(2)} EUR
}
+ {(d.extra_email_count || 0) > 0 &&
Emails extras ({d.extra_email_count}){extraCost.toFixed(2)} EUR
}
+ {doms.length > 0 &&
Domaines ({doms.length}){domsCost.toFixed(2)} EUR
}
+
Total{total.toFixed(2)} EUR/mois
+
+
+ >
+ )}
+
+
+
)
}