Ajout couts par service (cloud, site, mail, user) + resume admin detaille HT
This commit is contained in:
@@ -35,12 +35,26 @@ db.exec(`
|
|||||||
team_members TEXT DEFAULT '[]',
|
team_members TEXT DEFAULT '[]',
|
||||||
notes TEXT DEFAULT '',
|
notes TEXT DEFAULT '',
|
||||||
monthly_price REAL DEFAULT 0,
|
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')),
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
updated_at TEXT DEFAULT (datetime('now')),
|
updated_at TEXT DEFAULT (datetime('now')),
|
||||||
FOREIGN KEY (parent_id) REFERENCES domains(id)
|
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;
|
const userCount = db.prepare('SELECT COUNT(*) as count FROM users').get().count;
|
||||||
if (userCount === 0) {
|
if (userCount === 0) {
|
||||||
const insert = db.prepare('INSERT INTO users (username, password_hash, name, role) VALUES (?, ?, ?, ?)');
|
const insert = db.prepare('INSERT INTO users (username, password_hash, name, role) VALUES (?, ?, ?, ?)');
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ app.get('/api/domains', authMiddleware, (req, res) => {
|
|||||||
const parent = domains.find(d => !d.parent_id);
|
const parent = domains.find(d => !d.parent_id);
|
||||||
const children = domains.filter(d => d.parent_id);
|
const children = domains.filter(d => d.parent_id);
|
||||||
if (req.user.role === 'client') {
|
if (req.user.role === 'client') {
|
||||||
domains.forEach(d => { delete d.monthly_price; });
|
domains.forEach(d => { delete d.monthly_price; delete d.cloud_cost; delete d.website_cost; delete d.email_cost; delete d.user_cost; });
|
||||||
}
|
}
|
||||||
res.json({ parent, children });
|
res.json({ parent, children });
|
||||||
});
|
});
|
||||||
@@ -31,7 +31,7 @@ app.get('/api/domains', authMiddleware, (req, res) => {
|
|||||||
app.get('/api/domains/:id', authMiddleware, (req, res) => {
|
app.get('/api/domains/:id', authMiddleware, (req, res) => {
|
||||||
const domain = db.prepare('SELECT * FROM domains WHERE id = ?').get(req.params.id);
|
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 (!domain) return res.status(404).json({ error: 'Domaine non trouve' });
|
||||||
if (req.user.role === 'client') delete domain.monthly_price;
|
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);
|
res.json(domain);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -44,7 +44,7 @@ app.put('/api/domains/:id', authMiddleware, (req, res) => {
|
|||||||
'website_managed_by', 'website_details', 'has_emails', 'email_count',
|
'website_managed_by', 'website_details', 'has_emails', 'email_count',
|
||||||
'email_addresses', 'has_team', 'team_members', 'notes'
|
'email_addresses', 'has_team', 'team_members', 'notes'
|
||||||
];
|
];
|
||||||
if (req.user.role === 'admin') fields.push('monthly_price');
|
if (req.user.role === 'admin') fields.push('monthly_price', 'cloud_cost', 'website_cost', 'email_cost', 'user_cost');
|
||||||
|
|
||||||
const updates = {};
|
const updates = {};
|
||||||
fields.forEach(f => { if (req.body[f] !== undefined) updates[f] = req.body[f]; });
|
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);
|
db.prepare('UPDATE domains SET ' + setClause + ' WHERE id = ?').run(...values);
|
||||||
|
|
||||||
const updated = db.prepare('SELECT * FROM domains WHERE id = ?').get(req.params.id);
|
const updated = db.prepare('SELECT * FROM domains WHERE id = ?').get(req.params.id);
|
||||||
if (req.user.role === 'client') delete updated.monthly_price;
|
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; }
|
||||||
res.json(updated);
|
res.json(updated);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -40,13 +40,21 @@ export default function Dashboard({ token, user }) {
|
|||||||
<div className="flex flex-wrap gap-1.5">{tags}</div>
|
<div className="flex flex-wrap gap-1.5">{tags}</div>
|
||||||
{user.role === 'admin' && d.monthly_price > 0 && (
|
{user.role === 'admin' && d.monthly_price > 0 && (
|
||||||
<div className="mt-3 pt-3 border-t border-slate-700/30">
|
<div className="mt-3 pt-3 border-t border-slate-700/30">
|
||||||
<span className="text-emerald-400 font-semibold text-sm">{d.monthly_price} EUR/mois</span>
|
<span className="text-emerald-400 font-semibold text-sm">{d.monthly_price} EUR/mois HT</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
@@ -63,13 +71,59 @@ export default function Dashboard({ token, user }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{user.role === 'admin' && (
|
{user.role === 'admin' && (
|
||||||
<div className="mt-8 p-5 bg-slate-800/30 rounded-2xl border border-slate-700/30">
|
<div className="mt-8 space-y-4">
|
||||||
<h3 className="text-white font-semibold mb-3">Resume</h3>
|
{/* Vue d'ensemble */}
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-center">
|
<div className="p-5 bg-slate-800/30 rounded-2xl border border-slate-700/30">
|
||||||
<div><p className="text-2xl font-bold text-white">{domains.children.length + 1}</p><p className="text-slate-400 text-xs">Domaines</p></div>
|
<h3 className="text-white font-semibold mb-3">Resume</h3>
|
||||||
<div><p className="text-2xl font-bold text-sky-400">{[domains.parent, ...domains.children].filter(d => d.has_cloud).length}</p><p className="text-slate-400 text-xs">Cloud</p></div>
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-center">
|
||||||
<div><p className="text-2xl font-bold text-violet-400">{[domains.parent, ...domains.children].filter(d => d.has_website).length}</p><p className="text-slate-400 text-xs">Sites web</p></div>
|
<div><p className="text-2xl font-bold text-white">{allDomains.length}</p><p className="text-slate-400 text-xs">Domaines</p></div>
|
||||||
<div><p className="text-2xl font-bold text-emerald-400">{[domains.parent, ...domains.children].filter(d => d.has_emails).length}</p><p className="text-slate-400 text-xs">Avec mails</p></div>
|
<div><p className="text-2xl font-bold text-sky-400">{allDomains.filter(d => d.has_cloud).length}</p><p className="text-slate-400 text-xs">Cloud</p></div>
|
||||||
|
<div><p className="text-2xl font-bold text-violet-400">{allDomains.filter(d => d.has_website).length}</p><p className="text-slate-400 text-xs">Sites web</p></div>
|
||||||
|
<div><p className="text-2xl font-bold text-emerald-400">{allDomains.filter(d => d.has_emails).length}</p><p className="text-slate-400 text-xs">Avec mails</p></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Detail des couts */}
|
||||||
|
<div className="p-5 bg-slate-800/30 rounded-2xl border border-slate-700/30">
|
||||||
|
<h3 className="text-white font-semibold mb-4">Detail des couts mensuels HT</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between py-2 border-b border-slate-700/30">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-2 h-2 rounded-full bg-sky-400" />
|
||||||
|
<span className="text-slate-300 text-sm">Cloud</span>
|
||||||
|
<span className="text-slate-500 text-xs">({allDomains.filter(d => d.has_cloud).length})</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-white font-mono text-sm">{totalCloud.toFixed(2)} EUR</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between py-2 border-b border-slate-700/30">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-2 h-2 rounded-full bg-violet-400" />
|
||||||
|
<span className="text-slate-300 text-sm">Sites web</span>
|
||||||
|
<span className="text-slate-500 text-xs">({allDomains.filter(d => d.has_website).length})</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-white font-mono text-sm">{totalWebsite.toFixed(2)} EUR</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between py-2 border-b border-slate-700/30">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-2 h-2 rounded-full bg-amber-400" />
|
||||||
|
<span className="text-slate-300 text-sm">Adresses mail</span>
|
||||||
|
<span className="text-slate-500 text-xs">({allDomains.reduce((s, d) => s + (d.has_emails ? d.email_count || 0 : 0), 0)})</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-white font-mono text-sm">{totalEmail.toFixed(2)} EUR</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between py-2 border-b border-slate-700/30">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-2 h-2 rounded-full bg-emerald-400" />
|
||||||
|
<span className="text-slate-300 text-sm">Utilisateurs</span>
|
||||||
|
<span className="text-slate-500 text-xs">({allDomains.reduce((s, d) => s + (d.has_team ? JSON.parse(d.team_members || '[]').length : 0), 0)})</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-white font-mono text-sm">{totalUsers.toFixed(2)} EUR</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between py-3 border-t border-slate-600/50">
|
||||||
|
<span className="text-white font-semibold">Total mensuel HT</span>
|
||||||
|
<span className="text-emerald-400 font-bold font-mono text-lg">{totalAll.toFixed(2)} EUR/mois</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ export default function DomainForm({ token, user }) {
|
|||||||
if (loading) return <div className="text-slate-400 text-center py-12">Chargement...</div>
|
if (loading) return <div className="text-slate-400 text-center py-12">Chargement...</div>
|
||||||
if (!d) return <div className="text-red-400 text-center py-12">Domaine non trouve</div>
|
if (!d) return <div className="text-red-400 text-center py-12">Domaine non trouve</div>
|
||||||
|
|
||||||
|
const isAdmin = user.role === 'admin'
|
||||||
|
|
||||||
const Toggle = ({ checked, onChange, label }) => (
|
const Toggle = ({ checked, onChange, label }) => (
|
||||||
<div className="flex items-center justify-between py-3">
|
<div className="flex items-center justify-between py-3">
|
||||||
<span className="text-slate-300 text-sm">{label}</span>
|
<span className="text-slate-300 text-sm">{label}</span>
|
||||||
@@ -64,6 +66,19 @@ export default function DomainForm({ token, user }) {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const CostInput = ({ label, field }) => (
|
||||||
|
<div className="flex items-center gap-3 mt-3 pt-3 border-t border-slate-700/30">
|
||||||
|
<label className="text-slate-400 text-xs flex-1">{label}</label>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<input type="number" step="0.01" value={d[field] || ''} onChange={e => 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" />
|
||||||
|
<span className="text-slate-500 text-xs">EUR/mois HT</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
const nbMembers = d.has_team ? JSON.parse(d.team_members || '[]').length : 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<button onClick={() => navigate('/')} className="mb-6 text-slate-400 hover:text-white text-sm flex items-center gap-1 transition">
|
<button onClick={() => navigate('/')} className="mb-6 text-slate-400 hover:text-white text-sm flex items-center gap-1 transition">
|
||||||
@@ -86,9 +101,12 @@ export default function DomainForm({ token, user }) {
|
|||||||
<Section title="Cloud" color="text-sky-400">
|
<Section title="Cloud" color="text-sky-400">
|
||||||
<Toggle checked={!!d.has_cloud} onChange={v => u('has_cloud', v ? 1 : 0)} label="Besoin d'un cloud" />
|
<Toggle checked={!!d.has_cloud} onChange={v => u('has_cloud', v ? 1 : 0)} label="Besoin d'un cloud" />
|
||||||
{d.has_cloud ? (
|
{d.has_cloud ? (
|
||||||
<textarea value={d.cloud_details} onChange={e => u('cloud_details', e.target.value)}
|
<>
|
||||||
placeholder="Precisez le besoin (espace, usage, type de stockage...)" rows={3}
|
<textarea value={d.cloud_details} onChange={e => u('cloud_details', e.target.value)}
|
||||||
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 resize-none" />
|
placeholder="Precisez le besoin (espace, usage, type de stockage...)" rows={3}
|
||||||
|
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 resize-none" />
|
||||||
|
{isAdmin && <CostInput label="Cout licence cloud" field="cloud_cost" />}
|
||||||
|
</>
|
||||||
) : <p className="text-slate-500 text-xs italic">Aucun besoin cloud renseigne</p>}
|
) : <p className="text-slate-500 text-xs italic">Aucun besoin cloud renseigne</p>}
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
@@ -109,6 +127,7 @@ export default function DomainForm({ token, user }) {
|
|||||||
<textarea value={d.website_details} onChange={e => u('website_details', e.target.value)}
|
<textarea value={d.website_details} onChange={e => u('website_details', e.target.value)}
|
||||||
placeholder="Details (nombre de pages, fonctionnalites specifiques...)" rows={3}
|
placeholder="Details (nombre de pages, fonctionnalites specifiques...)" rows={3}
|
||||||
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 resize-none" />
|
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 resize-none" />
|
||||||
|
{isAdmin && <CostInput label="Cout licence site" field="website_cost" />}
|
||||||
</div>
|
</div>
|
||||||
) : <p className="text-slate-500 text-xs italic">Aucun site web renseigne</p>}
|
) : <p className="text-slate-500 text-xs italic">Aucun site web renseigne</p>}
|
||||||
</Section>
|
</Section>
|
||||||
@@ -118,6 +137,7 @@ export default function DomainForm({ token, user }) {
|
|||||||
{d.has_emails ? (
|
{d.has_emails ? (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Input label="Nombre d'adresses" type="number" value={d.email_count} onChange={v => u('email_count', parseInt(v) || 0)} placeholder="0" />
|
<Input label="Nombre d'adresses" type="number" value={d.email_count} onChange={v => u('email_count', parseInt(v) || 0)} placeholder="0" />
|
||||||
|
{isAdmin && <CostInput label="Cout licence mail (par adresse)" field="email_cost" />}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-slate-400 text-xs mb-1.5">Adresses mail</label>
|
<label className="block text-slate-400 text-xs mb-1.5">Adresses mail</label>
|
||||||
{JSON.parse(d.email_addresses || '[]').map((email, i) => (
|
{JSON.parse(d.email_addresses || '[]').map((email, i) => (
|
||||||
@@ -142,6 +162,7 @@ export default function DomainForm({ token, user }) {
|
|||||||
<Toggle checked={!!d.has_team} onChange={v => u('has_team', v ? 1 : 0)} label="Besoin d'utilisateurs" />
|
<Toggle checked={!!d.has_team} onChange={v => u('has_team', v ? 1 : 0)} label="Besoin d'utilisateurs" />
|
||||||
{d.has_team ? (
|
{d.has_team ? (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
{isAdmin && <CostInput label="Cout licence (par utilisateur)" field="user_cost" />}
|
||||||
{JSON.parse(d.team_members || '[]').map((m, i) => (
|
{JSON.parse(d.team_members || '[]').map((m, i) => (
|
||||||
<div key={i} className="flex gap-2">
|
<div key={i} className="flex gap-2">
|
||||||
<input type="text" value={m.first_name} onChange={e => updateMember(i, 'first_name', e.target.value)} placeholder="Prenom"
|
<input type="text" value={m.first_name} onChange={e => updateMember(i, 'first_name', e.target.value)} placeholder="Prenom"
|
||||||
@@ -161,9 +182,9 @@ export default function DomainForm({ token, user }) {
|
|||||||
) : <p className="text-slate-500 text-xs italic">Aucun utilisateur renseigne</p>}
|
) : <p className="text-slate-500 text-xs italic">Aucun utilisateur renseigne</p>}
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
{user.role === 'admin' && (
|
{isAdmin && (
|
||||||
<Section title="Tarif mensuel" color="text-emerald-400">
|
<Section title="Tarif mensuel HT" color="text-emerald-400">
|
||||||
<Input label="Prix mensuel (EUR)" type="number" value={d.monthly_price} onChange={v => u('monthly_price', parseFloat(v) || 0)} placeholder="0" />
|
<Input label="Total mensuel EUR HT" type="number" value={d.monthly_price} onChange={v => u('monthly_price', parseFloat(v) || 0)} placeholder="0" />
|
||||||
</Section>
|
</Section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user