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.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 ? (
- <>
-
-
-
- u('has_website', v ? 1 : 0)} label="Besoin d'un site internet" />
- {d.has_website ? (
-
-
u('website_wordpress', v ? 1 : 0)} label="WordPress" />
-
-
-
-
-
- ) : Aucun site web renseigne
}
-
-
-
- u('has_emails', v ? 1 : 0)} label="Besoin d'adresses mail" />
- {d.has_emails ? (
-
-
u('email_count', parseInt(v) || 0)} placeholder="0" />
- {isAdmin &&
}
-
-
- {JSON.parse(d.email_addresses || '[]').map((email, i) => (
-
-
updateEmail(i, e.target.value)} placeholder="prenom@domaine.fr"
- className="flex-1 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" />
-
-
- ))}
-
-
-
- ) : Aucune adresse mail renseignee
}
-
-
+ {/* Utilisateurs */}
u('has_team', v ? 1 : 0)} label="Besoin d'utilisateurs" />
{d.has_team ? (
- {isAdmin &&
}
- {JSON.parse(d.team_members || '[]').map((m, i) => (
+ {teamMembers.map((m, i) => (
updateMember(i, 'first_name', e.target.value)} placeholder="Prenom"
className="flex-1 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" />
@@ -182,16 +143,135 @@ export default function DomainForm({ token, user }) {
) :
Aucun utilisateur renseigne
}
- {isAdmin && (
-
- )}
+ {/* Ksuite Pro */}
+
6,58 EUR/mois HT/user}>
+ {d.has_team && nbUsers > 0 ? (
+
+
+ {nbUsers} licence{nbUsers > 1 ? 's' : ''} x {KSUITE_PRICE} EUR
+ {ksuiteCost.toFixed(2)} EUR/mois
+
+
Inclut : 3 To de cloud + 5 adresses mail par utilisateur ({includedEmails} mails inclus)
+
+ ) : Ajoutez des utilisateurs pour activer les licences
}
+
+ {/* Emails supplementaires */}
+
+
+ {/* Site internet */}
+
69 EUR/an HT}>
+ u('has_website', v ? 1 : 0)} label="Besoin d'un site internet" />
+ {d.has_website ? (
+
+
u('website_wordpress', v ? 1 : 0)} label="WordPress" />
+
+
+
+
+
+ ) : Aucun site web renseigne
}
+
+
+ {/* Pack Office + Calendrier */}
+
+ u('has_office', v ? 1 : 0)} label="Pack Office (Excel, Outlook, Word)" />
+ u('has_calendar', v ? 1 : 0)} label="Calendrier partage" />
+
+
+ {/* Noms de domaine */}
+
+ {domainsList.map((dom, i) => (
+
+
+
updateDomain(i, 'name', e.target.value)} placeholder="domaine.fr"
+ className="flex-1 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" />
+
+
+
+
+ ))}
+
+ {domainsList.length > 0 && (
+
+ {domainsList.length} domaine{domainsList.length > 1 ? 's' : ''}
+ {domainsCost.toFixed(2)} EUR/mois
+
+ )}
+
+
+ {/* Notes */}
+
+ {/* Resume admin */}
+ {isAdmin && (
+
+
Resume des couts mensuels HT
+
+ {nbUsers > 0 &&
1 ? 's' : '')} unitCost={ksuiteCost} total={ksuiteCost} color="bg-sky-400" />}
+ {d.has_website && }
+ {extraEmails > 0 && }
+ {domainsList.length > 0 && }
+
+ Total mensuel HT
+ {totalMonthly.toFixed(2)} EUR/mois
+
+
+
+ )}
)