diff --git a/backend/src/db.js b/backend/src/db.js index 9b21446..bfb17bf 100644 --- a/backend/src/db.js +++ b/backend/src/db.js @@ -22,39 +22,25 @@ db.exec(` company_name TEXT NOT NULL, parent_id INTEGER, sort_order INTEGER DEFAULT 0, - has_cloud INTEGER DEFAULT 0, - cloud_details TEXT DEFAULT '', has_website INTEGER DEFAULT 0, website_wordpress INTEGER DEFAULT 0, website_managed_by TEXT DEFAULT '', website_details TEXT DEFAULT '', - has_emails INTEGER DEFAULT 0, - email_count INTEGER DEFAULT 0, - email_addresses TEXT DEFAULT '[]', has_team INTEGER DEFAULT 0, team_members TEXT DEFAULT '[]', + has_office INTEGER DEFAULT 0, + has_calendar INTEGER DEFAULT 0, + domains_list TEXT DEFAULT '[]', + extra_email_count INTEGER DEFAULT 0, + extra_email_cost REAL DEFAULT 0, notes TEXT DEFAULT '', monthly_price REAL DEFAULT 0, - cloud_cost REAL DEFAULT 0, - website_cost REAL DEFAULT 0, - email_cost REAL DEFAULT 0, - user_cost REAL DEFAULT 0, created_at TEXT DEFAULT (datetime('now')), updated_at TEXT DEFAULT (datetime('now')), FOREIGN KEY (parent_id) REFERENCES domains(id) ); `); -// Migration: add cost columns if missing -const columns = db.prepare('PRAGMA table_info(domains)').all().map(c => c.name); -const addCostCol = (col) => { - if (!columns.includes(col)) db.exec('ALTER TABLE domains ADD COLUMN ' + col + ' REAL DEFAULT 0'); -}; -addCostCol('cloud_cost'); -addCostCol('website_cost'); -addCostCol('email_cost'); -addCostCol('user_cost'); - const userCount = db.prepare('SELECT COUNT(*) as count FROM users').get().count; if (userCount === 0) { const insert = db.prepare('INSERT INTO users (username, password_hash, name, role) VALUES (?, ?, ?, ?)'); diff --git a/backend/src/index.js b/backend/src/index.js index 7987384..348de65 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -8,43 +8,43 @@ const app = express(); app.use(cors()); app.use(express.json()); +const DOMAIN_FIELDS = [ + 'has_website', 'website_wordpress', 'website_managed_by', 'website_details', + 'has_team', 'team_members', 'has_office', 'has_calendar', + 'domains_list', 'extra_email_count', 'extra_email_cost', 'notes' +]; +const ADMIN_FIELDS = ['monthly_price']; + app.post('/api/login', (req, res) => { const { username, password } = req.body; const user = db.prepare('SELECT * FROM users WHERE username = ?').get(username); if (!user || !bcrypt.compareSync(password, user.password_hash)) { return res.status(401).json({ error: 'Identifiants incorrects' }); } - const token = generateToken(user); - res.json({ token, user: { id: user.id, name: user.name, role: user.role } }); + res.json({ token: generateToken(user), user: { id: user.id, name: user.name, role: user.role } }); }); app.get('/api/domains', authMiddleware, (req, res) => { const domains = db.prepare('SELECT * FROM domains ORDER BY sort_order, id').all(); const parent = domains.find(d => !d.parent_id); const children = domains.filter(d => d.parent_id); - if (req.user.role === 'client') { - domains.forEach(d => { delete d.monthly_price; delete d.cloud_cost; delete d.website_cost; delete d.email_cost; delete d.user_cost; }); - } + if (req.user.role === 'client') domains.forEach(d => { delete d.monthly_price; }); res.json({ parent, children }); }); app.get('/api/domains/:id', authMiddleware, (req, res) => { - const domain = db.prepare('SELECT * FROM domains WHERE id = ?').get(req.params.id); - if (!domain) return res.status(404).json({ error: 'Domaine non trouve' }); - if (req.user.role === 'client') { delete domain.monthly_price; delete domain.cloud_cost; delete domain.website_cost; delete domain.email_cost; delete domain.user_cost; } - res.json(domain); + const d = db.prepare('SELECT * FROM domains WHERE id = ?').get(req.params.id); + if (!d) return res.status(404).json({ error: 'Domaine non trouve' }); + if (req.user.role === 'client') delete d.monthly_price; + res.json(d); }); app.put('/api/domains/:id', authMiddleware, (req, res) => { const domain = db.prepare('SELECT * FROM domains WHERE id = ?').get(req.params.id); if (!domain) return res.status(404).json({ error: 'Domaine non trouve' }); - const fields = [ - 'has_cloud', 'cloud_details', 'has_website', 'website_wordpress', - 'website_managed_by', 'website_details', 'has_emails', 'email_count', - 'email_addresses', 'has_team', 'team_members', 'notes' - ]; - if (req.user.role === 'admin') fields.push('monthly_price', 'cloud_cost', 'website_cost', 'email_cost', 'user_cost'); + const fields = [...DOMAIN_FIELDS]; + if (req.user.role === 'admin') fields.push(...ADMIN_FIELDS); const updates = {}; fields.forEach(f => { if (req.body[f] !== undefined) updates[f] = req.body[f]; }); @@ -56,7 +56,7 @@ app.put('/api/domains/:id', authMiddleware, (req, res) => { db.prepare('UPDATE domains SET ' + setClause + ' WHERE id = ?').run(...values); const updated = db.prepare('SELECT * FROM domains WHERE id = ?').get(req.params.id); - if (req.user.role === 'client') { delete updated.monthly_price; delete updated.cloud_cost; delete updated.website_cost; delete updated.email_cost; delete updated.user_cost; } + if (req.user.role === 'client') delete updated.monthly_price; res.json(updated); }); diff --git a/frontend/src/components/Dashboard.jsx b/frontend/src/components/Dashboard.jsx index 89d3a7b..06d8fe4 100644 --- a/frontend/src/components/Dashboard.jsx +++ b/frontend/src/components/Dashboard.jsx @@ -1,6 +1,9 @@ import { useState, useEffect } from 'react' import { Link } from 'react-router-dom' +const KSUITE_PRICE = 6.58 +const SITE_PRICE = 69 + export default function Dashboard({ token, user }) { const [domains, setDomains] = useState({ parent: null, children: [] }) const [loading, setLoading] = useState(true) @@ -12,17 +15,48 @@ export default function Dashboard({ token, user }) { if (loading) return
Chargement...
- const count = (d) => [d.has_cloud, d.has_website, d.has_emails, d.has_team].filter(Boolean).length - const color = (n) => n === 0 ? 'bg-slate-600' : n < 3 ? 'bg-amber-500' : 'bg-emerald-500' + 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 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 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 DomainCard = ({ d, badge }) => { const n = count(d) + const team = d.has_team ? JSON.parse(d.team_members || '[]').length : 0 const tags = [] - if (d.has_cloud) tags.push(Cloud) + if (team > 0) tags.push({team} user{team > 1 ? 's' : ''}) if (d.has_website) tags.push(Site{d.website_wordpress ? ' WP' : ''}) - if (d.has_emails) tags.push({d.email_count} mail{d.email_count > 1 ? 's' : ''}) - if (d.has_team) { const m = JSON.parse(d.team_members || '[]'); tags.push({m.length} user{m.length > 1 ? 's' : ''}) } - if (n === 0) tags.push(Aucun besoin renseigne) + 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 cost = calcDomain(d) return (
{tags}
- {user.role === 'admin' && d.monthly_price > 0 && ( + {user.role === 'admin' && cost.total > 0 && (
- {d.monthly_price} EUR/mois HT + {cost.total.toFixed(2)} EUR/mois HT
)} ) } - // Admin cost summary - const allDomains = [domains.parent, ...domains.children].filter(Boolean) - const totalCloud = allDomains.reduce((s, d) => s + (d.has_cloud ? (d.cloud_cost || 0) : 0), 0) - const totalWebsite = allDomains.reduce((s, d) => s + (d.has_website ? (d.website_cost || 0) : 0), 0) - const totalEmail = allDomains.reduce((s, d) => s + (d.has_emails ? (d.email_count || 0) * (d.email_cost || 0) : 0), 0) - const totalUsers = allDomains.reduce((s, d) => s + (d.has_team ? JSON.parse(d.team_members || '[]').length * (d.user_cost || 0) : 0), 0) - const totalAll = totalCloud + totalWebsite + totalEmail + totalUsers - return (
@@ -72,56 +98,57 @@ export default function Dashboard({ token, user }) { {user.role === 'admin' && (
- {/* Vue d'ensemble */}

Resume

-
+

{allDomains.length}

Domaines

-

{allDomains.filter(d => d.has_cloud).length}

Cloud

-

{allDomains.filter(d => d.has_website).length}

Sites web

-

{allDomains.filter(d => d.has_emails).length}

Avec mails

+

{totals.users}

Utilisateurs

+

{totals.ksuite.toFixed(0)}

Licences Ksuite

+

{totals.site}

Sites web

+

{totals.doms}

Noms de domaine

- {/* Detail des couts */}

Detail des couts mensuels HT

-
+
- Cloud - ({allDomains.filter(d => d.has_cloud).length}) + Ksuite Pro ({totals.users} user{totals.users > 1 ? 's' : ''}) + 6,58 EUR/user
- {totalCloud.toFixed(2)} EUR + {totals.ksuite.toFixed(2)} EUR
- Sites web - ({allDomains.filter(d => d.has_website).length}) + Sites web ({totals.site}) + 69 EUR/an
- {totalWebsite.toFixed(2)} EUR + {totals.siteCost.toFixed(2)} EUR
-
-
-
- Adresses mail - ({allDomains.reduce((s, d) => s + (d.has_emails ? d.email_count || 0 : 0), 0)}) + {totals.extra > 0 && ( +
+
+
+ Emails extras ({totals.extra}) +
+ {totals.extraCost.toFixed(2)} EUR
- {totalEmail.toFixed(2)} EUR -
-
-
-
- Utilisateurs - ({allDomains.reduce((s, d) => s + (d.has_team ? JSON.parse(d.team_members || '[]').length : 0), 0)}) + )} + {totals.doms > 0 && ( +
+
+
+ Noms de domaine ({totals.doms}) +
+ {totals.domsCost.toFixed(2)} EUR
- {totalUsers.toFixed(2)} EUR -
-
+ )} +
Total mensuel HT - {totalAll.toFixed(2)} EUR/mois + {totals.total.toFixed(2)} EUR/mois
diff --git a/frontend/src/components/DomainForm.jsx b/frontend/src/components/DomainForm.jsx index 61b7f7b..810d6b5 100644 --- a/frontend/src/components/DomainForm.jsx +++ b/frontend/src/components/DomainForm.jsx @@ -1,6 +1,9 @@ import { useState, useEffect } from 'react' import { useParams, useNavigate } from 'react-router-dom' +const KSUITE_PRICE = 6.58 +const SITE_PRICE = 69 + export default function DomainForm({ token, user }) { const { id } = useParams() const navigate = useNavigate() @@ -29,17 +32,31 @@ export default function DomainForm({ token, user }) { setTimeout(() => setSaved(false), 2000) } - const addEmail = () => { const e = JSON.parse(d.email_addresses || '[]'); e.push(''); u('email_addresses', JSON.stringify(e)) } - const updateEmail = (i, v) => { const e = JSON.parse(d.email_addresses || '[]'); e[i] = v; u('email_addresses', JSON.stringify(e)) } - const removeEmail = (i) => { const e = JSON.parse(d.email_addresses || '[]'); e.splice(i, 1); u('email_addresses', JSON.stringify(e)); u('email_count', e.length) } const addMember = () => { const m = JSON.parse(d.team_members || '[]'); m.push({ first_name: '', last_name: '' }); u('team_members', JSON.stringify(m)) } const updateMember = (i, f, v) => { const m = JSON.parse(d.team_members || '[]'); m[i][f] = v; u('team_members', JSON.stringify(m)) } const removeMember = (i) => { const m = JSON.parse(d.team_members || '[]'); m.splice(i, 1); u('team_members', JSON.stringify(m)) } + const addDomain = () => { const l = JSON.parse(d.domains_list || '[]'); l.push({ name: '', cost_per_year: 0, expiry: '', hoster: '' }); u('domains_list', JSON.stringify(l)) } + const updateDomain = (i, f, v) => { const l = JSON.parse(d.domains_list || '[]'); l[i][f] = v; u('domains_list', JSON.stringify(l)) } + const removeDomain = (i) => { const l = JSON.parse(d.domains_list || '[]'); l.splice(i, 1); u('domains_list', JSON.stringify(l)) } + if (loading) return
Chargement...
if (!d) return
Domaine non trouve
const isAdmin = user.role === 'admin' + const teamMembers = JSON.parse(d.team_members || '[]') + const nbUsers = d.has_team ? teamMembers.length : 0 + const includedEmails = nbUsers * 5 + const extraEmails = d.extra_email_count || 0 + const totalEmails = includedEmails + extraEmails + const domainsList = JSON.parse(d.domains_list || '[]') + + // Cost calculations + const ksuiteCost = nbUsers * KSUITE_PRICE + const siteCost = d.has_website ? SITE_PRICE / 12 : 0 + const extraEmailCost = extraEmails * (d.extra_email_cost || 0) + const domainsCost = domainsList.reduce((s, dom) => s + (dom.cost_per_year || 0) / 12, 0) + const totalMonthly = ksuiteCost + siteCost + extraEmailCost + domainsCost const Toggle = ({ checked, onChange, label }) => (
@@ -51,34 +68,38 @@ export default function DomainForm({ token, user }) {
) - const Section = ({ title, color, children }) => ( + const Section = ({ title, color, children, badge }) => (
-

{title}

+
+

{title}

+ {badge} +
{children}
) - const Input = ({ label, value, onChange, placeholder, type = 'text' }) => ( + const Input = ({ label, value, onChange, placeholder, type = 'text', className: cls }) => (
onChange(e.target.value)} placeholder={placeholder} - className="w-full px-3 py-2.5 bg-slate-900/50 border border-slate-600 rounded-xl text-white text-sm placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" /> + className={'w-full px-3 py-2.5 bg-slate-900/50 border border-slate-600 rounded-xl text-white text-sm placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent ' + (cls || '')} />
) - const CostInput = ({ label, field }) => ( -
- -
- u(field, parseFloat(e.target.value) || 0)} placeholder="0" - className="w-24 px-3 py-2 bg-slate-900/50 border border-slate-600 rounded-lg text-emerald-400 text-sm text-right placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent font-mono" /> - EUR/mois HT + const CostLine = ({ label, count, unitCost, total, color }) => ( +
+
+
+ {label} + {count !== undefined && ({count})} +
+
+ {total.toFixed(2)} EUR + {unitCost !== undefined && {unitCost.toFixed(2)} EUR/mois}
) - const nbMembers = d.has_team ? JSON.parse(d.team_members || '[]').length : 0 - return (
-
- u('has_cloud', v ? 1 : 0)} label="Besoin d'un cloud" /> - {d.has_cloud ? ( - <> -