Refonte: Ksuite 6.58E/user + site 69E/an + domaines + Office + Calendrier + emails extras
This commit is contained in:
+5
-19
@@ -22,39 +22,25 @@ db.exec(`
|
|||||||
company_name TEXT NOT NULL,
|
company_name TEXT NOT NULL,
|
||||||
parent_id INTEGER,
|
parent_id INTEGER,
|
||||||
sort_order INTEGER DEFAULT 0,
|
sort_order INTEGER DEFAULT 0,
|
||||||
has_cloud INTEGER DEFAULT 0,
|
|
||||||
cloud_details TEXT DEFAULT '',
|
|
||||||
has_website INTEGER DEFAULT 0,
|
has_website INTEGER DEFAULT 0,
|
||||||
website_wordpress INTEGER DEFAULT 0,
|
website_wordpress INTEGER DEFAULT 0,
|
||||||
website_managed_by TEXT DEFAULT '',
|
website_managed_by TEXT DEFAULT '',
|
||||||
website_details 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,
|
has_team INTEGER DEFAULT 0,
|
||||||
team_members TEXT DEFAULT '[]',
|
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 '',
|
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 (?, ?, ?, ?)');
|
||||||
|
|||||||
+16
-16
@@ -8,43 +8,43 @@ const app = express();
|
|||||||
app.use(cors());
|
app.use(cors());
|
||||||
app.use(express.json());
|
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) => {
|
app.post('/api/login', (req, res) => {
|
||||||
const { username, password } = req.body;
|
const { username, password } = req.body;
|
||||||
const user = db.prepare('SELECT * FROM users WHERE username = ?').get(username);
|
const user = db.prepare('SELECT * FROM users WHERE username = ?').get(username);
|
||||||
if (!user || !bcrypt.compareSync(password, user.password_hash)) {
|
if (!user || !bcrypt.compareSync(password, user.password_hash)) {
|
||||||
return res.status(401).json({ error: 'Identifiants incorrects' });
|
return res.status(401).json({ error: 'Identifiants incorrects' });
|
||||||
}
|
}
|
||||||
const token = generateToken(user);
|
res.json({ token: generateToken(user), user: { id: user.id, name: user.name, role: user.role } });
|
||||||
res.json({ token, user: { id: user.id, name: user.name, role: user.role } });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/api/domains', authMiddleware, (req, res) => {
|
app.get('/api/domains', authMiddleware, (req, res) => {
|
||||||
const domains = db.prepare('SELECT * FROM domains ORDER BY sort_order, id').all();
|
const domains = db.prepare('SELECT * FROM domains ORDER BY sort_order, id').all();
|
||||||
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 });
|
||||||
});
|
});
|
||||||
|
|
||||||
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 d = db.prepare('SELECT * FROM domains WHERE id = ?').get(req.params.id);
|
||||||
if (!domain) return res.status(404).json({ error: 'Domaine non trouve' });
|
if (!d) 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; }
|
if (req.user.role === 'client') delete d.monthly_price;
|
||||||
res.json(domain);
|
res.json(d);
|
||||||
});
|
});
|
||||||
|
|
||||||
app.put('/api/domains/:id', authMiddleware, (req, res) => {
|
app.put('/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' });
|
||||||
|
|
||||||
const fields = [
|
const fields = [...DOMAIN_FIELDS];
|
||||||
'has_cloud', 'cloud_details', 'has_website', 'website_wordpress',
|
if (req.user.role === 'admin') fields.push(...ADMIN_FIELDS);
|
||||||
'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 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; 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);
|
res.json(updated);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
|
|
||||||
|
const KSUITE_PRICE = 6.58
|
||||||
|
const SITE_PRICE = 69
|
||||||
|
|
||||||
export default function Dashboard({ token, user }) {
|
export default function Dashboard({ token, user }) {
|
||||||
const [domains, setDomains] = useState({ parent: null, children: [] })
|
const [domains, setDomains] = useState({ parent: null, children: [] })
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
@@ -12,17 +15,48 @@ export default function Dashboard({ 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>
|
||||||
|
|
||||||
const count = (d) => [d.has_cloud, d.has_website, d.has_emails, d.has_team].filter(Boolean).length
|
const allDomains = [domains.parent, ...domains.children].filter(Boolean)
|
||||||
const color = (n) => n === 0 ? 'bg-slate-600' : n < 3 ? 'bg-amber-500' : 'bg-emerald-500'
|
|
||||||
|
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 DomainCard = ({ d, badge }) => {
|
||||||
const n = count(d)
|
const n = count(d)
|
||||||
|
const team = d.has_team ? JSON.parse(d.team_members || '[]').length : 0
|
||||||
const tags = []
|
const tags = []
|
||||||
if (d.has_cloud) tags.push(<span key="c" className="px-2 py-0.5 bg-sky-500/10 text-sky-400 text-xs rounded-md">Cloud</span>)
|
if (team > 0) tags.push(<span key="t" className="px-2 py-0.5 bg-emerald-500/10 text-emerald-400 text-xs rounded-md">{team} user{team > 1 ? 's' : ''}</span>)
|
||||||
if (d.has_website) tags.push(<span key="w" className="px-2 py-0.5 bg-violet-500/10 text-violet-400 text-xs rounded-md">Site{d.website_wordpress ? ' WP' : ''}</span>)
|
if (d.has_website) tags.push(<span key="w" className="px-2 py-0.5 bg-violet-500/10 text-violet-400 text-xs rounded-md">Site{d.website_wordpress ? ' WP' : ''}</span>)
|
||||||
if (d.has_emails) tags.push(<span key="e" className="px-2 py-0.5 bg-amber-500/10 text-amber-400 text-xs rounded-md">{d.email_count} mail{d.email_count > 1 ? 's' : ''}</span>)
|
if (d.has_office) tags.push(<span key="o" className="px-2 py-0.5 bg-rose-500/10 text-rose-400 text-xs rounded-md">Office</span>)
|
||||||
if (d.has_team) { const m = JSON.parse(d.team_members || '[]'); tags.push(<span key="t" className="px-2 py-0.5 bg-emerald-500/10 text-emerald-400 text-xs rounded-md">{m.length} user{m.length > 1 ? 's' : ''}</span>) }
|
if (d.has_calendar) tags.push(<span key="c" className="px-2 py-0.5 bg-teal-500/10 text-teal-400 text-xs rounded-md">Calendrier</span>)
|
||||||
if (n === 0) tags.push(<span key="x" className="text-slate-500 text-xs italic">Aucun besoin renseigne</span>)
|
const doms = JSON.parse(d.domains_list || '[]')
|
||||||
|
if (doms.length > 0) tags.push(<span key="d" className="px-2 py-0.5 bg-sky-500/10 text-sky-400 text-xs rounded-md">{doms.length} domaine{doms.length > 1 ? 's' : ''}</span>)
|
||||||
|
if (n === 0 && team === 0) tags.push(<span key="x" className="text-slate-500 text-xs italic">Aucun besoin renseigne</span>)
|
||||||
|
|
||||||
|
const cost = calcDomain(d)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link to={'/domain/' + d.id}
|
<Link to={'/domain/' + d.id}
|
||||||
@@ -38,23 +72,15 @@ export default function Dashboard({ token, user }) {
|
|||||||
<div className={'w-2.5 h-2.5 rounded-full mt-1 ' + color(n)} />
|
<div className={'w-2.5 h-2.5 rounded-full mt-1 ' + color(n)} />
|
||||||
</div>
|
</div>
|
||||||
<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' && cost.total > 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 HT</span>
|
<span className="text-emerald-400 font-semibold text-sm">{cost.total.toFixed(2)} 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">
|
||||||
@@ -72,56 +98,57 @@ export default function Dashboard({ token, user }) {
|
|||||||
|
|
||||||
{user.role === 'admin' && (
|
{user.role === 'admin' && (
|
||||||
<div className="mt-8 space-y-4">
|
<div className="mt-8 space-y-4">
|
||||||
{/* Vue d'ensemble */}
|
|
||||||
<div className="p-5 bg-slate-800/30 rounded-2xl border border-slate-700/30">
|
<div className="p-5 bg-slate-800/30 rounded-2xl border border-slate-700/30">
|
||||||
<h3 className="text-white font-semibold mb-3">Resume</h3>
|
<h3 className="text-white font-semibold mb-3">Resume</h3>
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-center">
|
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 text-center">
|
||||||
<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-white">{allDomains.length}</p><p className="text-slate-400 text-xs">Domaines</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-emerald-400">{totals.users}</p><p className="text-slate-400 text-xs">Utilisateurs</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-sky-400">{totals.ksuite.toFixed(0)}</p><p className="text-slate-400 text-xs">Licences Ksuite</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><p className="text-2xl font-bold text-violet-400">{totals.site}</p><p className="text-slate-400 text-xs">Sites web</p></div>
|
||||||
|
<div><p className="text-2xl font-bold text-teal-400">{totals.doms}</p><p className="text-slate-400 text-xs">Noms de domaine</p></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Detail des couts */}
|
|
||||||
<div className="p-5 bg-slate-800/30 rounded-2xl border border-slate-700/30">
|
<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>
|
<h3 className="text-white font-semibold mb-4">Detail des couts mensuels HT</h3>
|
||||||
<div className="space-y-3">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center justify-between py-2 border-b border-slate-700/30">
|
<div className="flex items-center justify-between py-2 border-b border-slate-700/30">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="w-2 h-2 rounded-full bg-sky-400" />
|
<div className="w-2 h-2 rounded-full bg-sky-400" />
|
||||||
<span className="text-slate-300 text-sm">Cloud</span>
|
<span className="text-slate-300 text-sm">Ksuite Pro ({totals.users} user{totals.users > 1 ? 's' : ''})</span>
|
||||||
<span className="text-slate-500 text-xs">({allDomains.filter(d => d.has_cloud).length})</span>
|
<span className="text-slate-500 text-xs">6,58 EUR/user</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-white font-mono text-sm">{totalCloud.toFixed(2)} EUR</span>
|
<span className="text-white font-mono text-sm">{totals.ksuite.toFixed(2)} EUR</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between py-2 border-b border-slate-700/30">
|
<div className="flex items-center justify-between py-2 border-b border-slate-700/30">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="w-2 h-2 rounded-full bg-violet-400" />
|
<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-300 text-sm">Sites web ({totals.site})</span>
|
||||||
<span className="text-slate-500 text-xs">({allDomains.filter(d => d.has_website).length})</span>
|
<span className="text-slate-500 text-xs">69 EUR/an</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-white font-mono text-sm">{totalWebsite.toFixed(2)} EUR</span>
|
<span className="text-white font-mono text-sm">{totals.siteCost.toFixed(2)} EUR</span>
|
||||||
</div>
|
</div>
|
||||||
|
{totals.extra > 0 && (
|
||||||
<div className="flex items-center justify-between py-2 border-b border-slate-700/30">
|
<div className="flex items-center justify-between py-2 border-b border-slate-700/30">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="w-2 h-2 rounded-full bg-amber-400" />
|
<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-300 text-sm">Emails extras ({totals.extra})</span>
|
||||||
<span className="text-slate-500 text-xs">({allDomains.reduce((s, d) => s + (d.has_emails ? d.email_count || 0 : 0), 0)})</span>
|
|
||||||
</div>
|
</div>
|
||||||
<span className="text-white font-mono text-sm">{totalEmail.toFixed(2)} EUR</span>
|
<span className="text-white font-mono text-sm">{totals.extraCost.toFixed(2)} EUR</span>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
{totals.doms > 0 && (
|
||||||
<div className="flex items-center justify-between py-2 border-b border-slate-700/30">
|
<div className="flex items-center justify-between py-2 border-b border-slate-700/30">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="w-2 h-2 rounded-full bg-emerald-400" />
|
<div className="w-2 h-2 rounded-full bg-teal-400" />
|
||||||
<span className="text-slate-300 text-sm">Utilisateurs</span>
|
<span className="text-slate-300 text-sm">Noms de domaine ({totals.doms})</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>
|
</div>
|
||||||
<span className="text-white font-mono text-sm">{totalUsers.toFixed(2)} EUR</span>
|
<span className="text-white font-mono text-sm">{totals.domsCost.toFixed(2)} EUR</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between py-3 border-t border-slate-600/50">
|
)}
|
||||||
|
<div className="flex items-center justify-between pt-3 border-t border-slate-600/50">
|
||||||
<span className="text-white font-semibold">Total mensuel HT</span>
|
<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>
|
<span className="text-emerald-400 font-bold font-mono text-lg">{totals.total.toFixed(2)} EUR/mois</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { useParams, useNavigate } from 'react-router-dom'
|
import { useParams, useNavigate } from 'react-router-dom'
|
||||||
|
|
||||||
|
const KSUITE_PRICE = 6.58
|
||||||
|
const SITE_PRICE = 69
|
||||||
|
|
||||||
export default function DomainForm({ token, user }) {
|
export default function DomainForm({ token, user }) {
|
||||||
const { id } = useParams()
|
const { id } = useParams()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
@@ -29,17 +32,31 @@ export default function DomainForm({ token, user }) {
|
|||||||
setTimeout(() => setSaved(false), 2000)
|
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 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 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 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 <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 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 }) => (
|
const Toggle = ({ checked, onChange, label }) => (
|
||||||
<div className="flex items-center justify-between py-3">
|
<div className="flex items-center justify-between py-3">
|
||||||
@@ -51,34 +68,38 @@ export default function DomainForm({ token, user }) {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
const Section = ({ title, color, children }) => (
|
const Section = ({ title, color, children, badge }) => (
|
||||||
<div className="bg-slate-800/40 rounded-2xl border border-slate-700/30 p-6">
|
<div className="bg-slate-800/40 rounded-2xl border border-slate-700/30 p-6">
|
||||||
<h3 className={'font-semibold mb-4 ' + color}>{title}</h3>
|
<div className="flex items-center gap-2 mb-4">
|
||||||
|
<h3 className={'font-semibold ' + color}>{title}</h3>
|
||||||
|
{badge}
|
||||||
|
</div>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
const Input = ({ label, value, onChange, placeholder, type = 'text' }) => (
|
const Input = ({ label, value, onChange, placeholder, type = 'text', className: cls }) => (
|
||||||
<div className="mb-3">
|
<div className="mb-3">
|
||||||
<label className="block text-slate-400 text-xs mb-1.5">{label}</label>
|
<label className="block text-slate-400 text-xs mb-1.5">{label}</label>
|
||||||
<input type={type} value={value || ''} onChange={e => onChange(e.target.value)} placeholder={placeholder}
|
<input type={type} value={value || ''} onChange={e => 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 || '')} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
const CostInput = ({ label, field }) => (
|
const CostLine = ({ label, count, unitCost, total, color }) => (
|
||||||
<div className="flex items-center gap-3 mt-3 pt-3 border-t border-slate-700/30">
|
<div className="flex items-center justify-between py-2 border-b border-slate-700/30">
|
||||||
<label className="text-slate-400 text-xs flex-1">{label}</label>
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex items-center gap-1">
|
<div className={'w-2 h-2 rounded-full ' + color} />
|
||||||
<input type="number" step="0.01" value={d[field] || ''} onChange={e => u(field, parseFloat(e.target.value) || 0)} placeholder="0"
|
<span className="text-slate-300 text-sm">{label}</span>
|
||||||
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" />
|
{count !== undefined && <span className="text-slate-500 text-xs">({count})</span>}
|
||||||
<span className="text-slate-500 text-xs">EUR/mois HT</span>
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<span className="text-white font-mono text-sm">{total.toFixed(2)} EUR</span>
|
||||||
|
{unitCost !== undefined && <span className="text-slate-500 text-xs block">{unitCost.toFixed(2)} EUR/mois</span>}
|
||||||
</div>
|
</div>
|
||||||
</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">
|
||||||
@@ -98,72 +119,12 @@ export default function DomainForm({ token, user }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<Section title="Cloud" color="text-sky-400">
|
{/* Utilisateurs */}
|
||||||
<Toggle checked={!!d.has_cloud} onChange={v => u('has_cloud', v ? 1 : 0)} label="Besoin d'un 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}
|
|
||||||
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>}
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
<Section title="Site internet" color="text-violet-400">
|
|
||||||
<Toggle checked={!!d.has_website} onChange={v => u('has_website', v ? 1 : 0)} label="Besoin d'un site internet" />
|
|
||||||
{d.has_website ? (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<Toggle checked={!!d.website_wordpress} onChange={v => u('website_wordpress', v ? 1 : 0)} label="WordPress" />
|
|
||||||
<div className="mb-3">
|
|
||||||
<label className="block text-slate-400 text-xs mb-1.5">Qui gere le site ?</label>
|
|
||||||
<select value={d.website_managed_by || ''} onChange={e => u('website_managed_by', 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 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent">
|
|
||||||
<option value="">Non precise</option>
|
|
||||||
<option value="charles">L'equipe de Charles</option>
|
|
||||||
<option value="pierre">Pierre (SecourIT)</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<textarea value={d.website_details} onChange={e => u('website_details', e.target.value)}
|
|
||||||
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" />
|
|
||||||
{isAdmin && <CostInput label="Cout licence site" field="website_cost" />}
|
|
||||||
</div>
|
|
||||||
) : <p className="text-slate-500 text-xs italic">Aucun site web renseigne</p>}
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
<Section title="Adresses mail" color="text-amber-400">
|
|
||||||
<Toggle checked={!!d.has_emails} onChange={v => u('has_emails', v ? 1 : 0)} label="Besoin d'adresses mail" />
|
|
||||||
{d.has_emails ? (
|
|
||||||
<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" />
|
|
||||||
{isAdmin && <CostInput label="Cout licence mail (par adresse)" field="email_cost" />}
|
|
||||||
<div>
|
|
||||||
<label className="block text-slate-400 text-xs mb-1.5">Adresses mail</label>
|
|
||||||
{JSON.parse(d.email_addresses || '[]').map((email, i) => (
|
|
||||||
<div key={i} className="flex gap-2 mb-2">
|
|
||||||
<input type="text" value={email} onChange={e => 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" />
|
|
||||||
<button onClick={() => removeEmail(i)} className="p-2.5 text-red-400 hover:bg-red-500/10 rounded-xl transition">
|
|
||||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /></svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
<button onClick={addEmail} className="text-blue-400 hover:text-blue-300 text-xs flex items-center gap-1 transition">
|
|
||||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" /></svg>
|
|
||||||
Ajouter une adresse
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : <p className="text-slate-500 text-xs italic">Aucune adresse mail renseignee</p>}
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
<Section title="Utilisateurs" color="text-emerald-400">
|
<Section title="Utilisateurs" color="text-emerald-400">
|
||||||
<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" />}
|
{teamMembers.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"
|
||||||
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" />
|
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 }) {
|
|||||||
) : <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>
|
||||||
|
|
||||||
{isAdmin && (
|
{/* Ksuite Pro */}
|
||||||
<Section title="Tarif mensuel HT" color="text-emerald-400">
|
<Section title="Ksuite Pro" color="text-sky-400" badge={<span className="px-2 py-0.5 bg-sky-500/10 text-sky-400 text-[10px] font-medium rounded-md">6,58 EUR/mois HT/user</span>}>
|
||||||
<Input label="Total mensuel EUR HT" type="number" value={d.monthly_price} onChange={v => u('monthly_price', parseFloat(v) || 0)} placeholder="0" />
|
{d.has_team && nbUsers > 0 ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-slate-400">{nbUsers} licence{nbUsers > 1 ? 's' : ''} x {KSUITE_PRICE} EUR</span>
|
||||||
|
<span className="text-white font-mono">{ksuiteCost.toFixed(2)} EUR/mois</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-slate-500 text-xs">Inclut : 3 To de cloud + 5 adresses mail par utilisateur ({includedEmails} mails inclus)</p>
|
||||||
|
</div>
|
||||||
|
) : <p className="text-slate-500 text-xs italic">Ajoutez des utilisateurs pour activer les licences</p>}
|
||||||
</Section>
|
</Section>
|
||||||
)}
|
|
||||||
|
|
||||||
|
{/* Emails supplementaires */}
|
||||||
|
<Section title="Emails supplementaires" color="text-amber-400">
|
||||||
|
{d.has_team && extraEmails > 0 ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-slate-400 text-sm">Au-dela des {includedEmails} adresses incluses dans Ksuite ({nbUsers} users x 5)</p>
|
||||||
|
<Input label="Adresses mail supplementaires" type="number" value={d.extra_email_count} onChange={v => u('extra_email_count', parseInt(v) || 0)} placeholder="0" />
|
||||||
|
<Input label="Cout par mail supplementaire (EUR/mois HT)" type="number" step="0.01" value={d.extra_email_cost} onChange={v => u('extra_email_cost', parseFloat(v) || 0)} placeholder="0" />
|
||||||
|
<div className="flex justify-between text-sm pt-2 border-t border-slate-700/30">
|
||||||
|
<span className="text-slate-400">{extraEmails} mail{extraEmails > 1 ? 's' : ''} x {d.extra_email_cost || 0} EUR</span>
|
||||||
|
<span className="text-white font-mono">{extraEmailCost.toFixed(2)} EUR/mois</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<Input label="Adresses mail supplementaires" type="number" value={d.extra_email_count} onChange={v => u('extra_email_count', parseInt(v) || 0)} placeholder="0" />
|
||||||
|
<Input label="Cout par mail supplementaire (EUR/mois HT)" type="number" step="0.01" value={d.extra_email_cost} onChange={v => u('extra_email_cost', parseFloat(v) || 0)} placeholder="0" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* Site internet */}
|
||||||
|
<Section title="Site internet" color="text-violet-400" badge={<span className="px-2 py-0.5 bg-violet-500/10 text-violet-400 text-[10px] font-medium rounded-md">69 EUR/an HT</span>}>
|
||||||
|
<Toggle checked={!!d.has_website} onChange={v => u('has_website', v ? 1 : 0)} label="Besoin d'un site internet" />
|
||||||
|
{d.has_website ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Toggle checked={!!d.website_wordpress} onChange={v => u('website_wordpress', v ? 1 : 0)} label="WordPress" />
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="block text-slate-400 text-xs mb-1.5">Qui gere le site ?</label>
|
||||||
|
<select value={d.website_managed_by || ''} onChange={e => u('website_managed_by', 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 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent">
|
||||||
|
<option value="">Non precise</option>
|
||||||
|
<option value="charles">L'equipe de Charles</option>
|
||||||
|
<option value="pierre">Pierre (SecourIT)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<textarea value={d.website_details} onChange={e => u('website_details', e.target.value)}
|
||||||
|
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" />
|
||||||
|
<div className="flex justify-between text-sm pt-2 border-t border-slate-700/30">
|
||||||
|
<span className="text-slate-400">1 site x 69 EUR/an</span>
|
||||||
|
<span className="text-white font-mono">{siteCost.toFixed(2)} EUR/mois</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : <p className="text-slate-500 text-xs italic">Aucun site web renseigne</p>}
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* Pack Office + Calendrier */}
|
||||||
|
<Section title="Pack Office & Calendrier" color="text-rose-400">
|
||||||
|
<Toggle checked={!!d.has_office} onChange={v => u('has_office', v ? 1 : 0)} label="Pack Office (Excel, Outlook, Word)" />
|
||||||
|
<Toggle checked={!!d.has_calendar} onChange={v => u('has_calendar', v ? 1 : 0)} label="Calendrier partage" />
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* Noms de domaine */}
|
||||||
|
<Section title="Noms de domaine" color="text-teal-400">
|
||||||
|
{domainsList.map((dom, i) => (
|
||||||
|
<div key={i} className="bg-slate-900/30 rounded-xl p-4 mb-3 border border-slate-700/20">
|
||||||
|
<div className="flex gap-2 mb-2">
|
||||||
|
<input type="text" value={dom.name} onChange={e => 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" />
|
||||||
|
<button onClick={() => removeDomain(i)} className="p-2.5 text-red-400 hover:bg-red-500/10 rounded-xl transition">
|
||||||
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
<div>
|
||||||
|
<label className="block text-slate-500 text-[10px] mb-1">Cout/an HT</label>
|
||||||
|
<input type="number" step="0.01" value={dom.cost_per_year || ''} onChange={e => updateDomain(i, 'cost_per_year', parseFloat(e.target.value) || 0)} placeholder="0"
|
||||||
|
className="w-full px-2 py-2 bg-slate-900/50 border border-slate-600 rounded-lg text-white text-xs placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-slate-500 text-[10px] mb-1">Expiration</label>
|
||||||
|
<input type="date" value={dom.expiry || ''} onChange={e => updateDomain(i, 'expiry', e.target.value)}
|
||||||
|
className="w-full px-2 py-2 bg-slate-900/50 border border-slate-600 rounded-lg text-white text-xs placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-slate-500 text-[10px] mb-1">Hebergeur</label>
|
||||||
|
<input type="text" value={dom.hoster || ''} onChange={e => updateDomain(i, 'hoster', e.target.value)} placeholder="Infomaniak"
|
||||||
|
className="w-full px-2 py-2 bg-slate-900/50 border border-slate-600 rounded-lg text-white text-xs placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button onClick={addDomain} className="text-blue-400 hover:text-blue-300 text-xs flex items-center gap-1 transition">
|
||||||
|
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" /></svg>
|
||||||
|
Ajouter un nom de domaine
|
||||||
|
</button>
|
||||||
|
{domainsList.length > 0 && (
|
||||||
|
<div className="flex justify-between text-sm pt-3 mt-3 border-t border-slate-700/30">
|
||||||
|
<span className="text-slate-400">{domainsList.length} domaine{domainsList.length > 1 ? 's' : ''}</span>
|
||||||
|
<span className="text-white font-mono">{domainsCost.toFixed(2)} EUR/mois</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* Notes */}
|
||||||
<Section title="Notes" color="text-slate-400">
|
<Section title="Notes" color="text-slate-400">
|
||||||
<textarea value={d.notes} onChange={e => u('notes', e.target.value)} placeholder="Notes additionnelles..." rows={4}
|
<textarea value={d.notes} onChange={e => u('notes', e.target.value)} placeholder="Notes additionnelles..." rows={4}
|
||||||
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" />
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
|
{/* Resume admin */}
|
||||||
|
{isAdmin && (
|
||||||
|
<div className="bg-slate-800/40 rounded-2xl border border-emerald-500/30 p-6">
|
||||||
|
<h3 className="text-emerald-400 font-semibold mb-4">Resume des couts mensuels HT</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{nbUsers > 0 && <CostLine label="Ksuite Pro" count={nbUsers + ' user' + (nbUsers > 1 ? 's' : '')} unitCost={ksuiteCost} total={ksuiteCost} color="bg-sky-400" />}
|
||||||
|
{d.has_website && <CostLine label="Site internet" count="1 an" unitCost={siteCost} total={siteCost} color="bg-violet-400" />}
|
||||||
|
{extraEmails > 0 && <CostLine label="Emails supplementaires" count={extraEmails} unitCost={extraEmailCost} total={extraEmailCost} color="bg-amber-400" />}
|
||||||
|
{domainsList.length > 0 && <CostLine label="Noms de domaine" count={domainsList.length} unitCost={domainsCost} total={domainsCost} color="bg-teal-400" />}
|
||||||
|
<div className="flex items-center justify-between pt-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">{totalMonthly.toFixed(2)} EUR/mois</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user