Initial commit: charles.secourit.fr - App de suivi maintenance 360 Partners
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
data/
|
||||
.env
|
||||
dist/
|
||||
.git/
|
||||
@@ -0,0 +1,8 @@
|
||||
FROM node:20-alpine
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm install --omit=dev
|
||||
COPY . .
|
||||
RUN mkdir -p data
|
||||
EXPOSE 3008
|
||||
CMD ["node", "src/index.js"]
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "charles-backend",
|
||||
"version": "1.0.0",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
"start": "node src/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.21.0",
|
||||
"better-sqlite3": "^11.6.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"cors": "^2.8.5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'charles-secourit-2026-secret-key';
|
||||
|
||||
function generateToken(user) {
|
||||
return jwt.sign({ id: user.id, username: user.username, role: user.role }, JWT_SECRET, { expiresIn: '7d' });
|
||||
}
|
||||
|
||||
function authMiddleware(req, res, next) {
|
||||
const token = req.headers.authorization?.replace('Bearer ', '');
|
||||
if (!token) return res.status(401).json({ error: 'Token manquant' });
|
||||
try {
|
||||
req.user = jwt.verify(token, JWT_SECRET);
|
||||
next();
|
||||
} catch { return res.status(401).json({ error: 'Token invalide' }); }
|
||||
}
|
||||
|
||||
function adminOnly(req, res, next) {
|
||||
if (req.user.role !== 'admin') return res.status(403).json({ error: 'Acces refuse' });
|
||||
next();
|
||||
}
|
||||
|
||||
module.exports = { generateToken, authMiddleware, adminOnly };
|
||||
@@ -0,0 +1,65 @@
|
||||
const Database = require('better-sqlite3');
|
||||
const path = require('path');
|
||||
const bcrypt = require('bcryptjs');
|
||||
|
||||
const dbDir = path.join(__dirname, '..', 'data');
|
||||
const db = new Database(path.join(dbDir, 'charles.db'));
|
||||
|
||||
db.pragma('journal_mode = WAL');
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
role TEXT NOT NULL CHECK(role IN ('admin', 'client'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS domains (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
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 '[]',
|
||||
notes TEXT DEFAULT '',
|
||||
monthly_price REAL DEFAULT 0,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (parent_id) REFERENCES domains(id)
|
||||
);
|
||||
`);
|
||||
|
||||
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 (?, ?, ?, ?)');
|
||||
insert.run('pierre', bcrypt.hashSync('Charles2026!', 10), 'Pierre', 'admin');
|
||||
insert.run('charles', bcrypt.hashSync('360partners!', 10), 'Charles', 'client');
|
||||
}
|
||||
|
||||
const domainCount = db.prepare('SELECT COUNT(*) as count FROM domains').get().count;
|
||||
if (domainCount === 0) {
|
||||
const insert = db.prepare('INSERT INTO domains (name, company_name, parent_id, sort_order) VALUES (?, ?, ?, ?)');
|
||||
const result = insert.run('360-partners.fr', '360 Partners', null, 0);
|
||||
const pid = result.lastInsertRowid;
|
||||
insert.run('monsieur-edgar.fr', 'Monsieur Edgar', pid, 1);
|
||||
insert.run('monsieur-edgar.com', 'Monsieur Edgar', pid, 2);
|
||||
insert.run('qwarter.fr', 'Qwarter', pid, 3);
|
||||
insert.run('agenceredo.fr', 'Agence Redo', pid, 4);
|
||||
insert.run('hub-grade.com', 'HUB-GRADE', pid, 5);
|
||||
insert.run('momentz.online', 'MOMENTZ', pid, 6);
|
||||
insert.run('momentz.paris', 'MOMENTZ', pid, 7);
|
||||
}
|
||||
|
||||
module.exports = db;
|
||||
@@ -0,0 +1,80 @@
|
||||
const express = require('express');
|
||||
const cors = require('cors');
|
||||
const bcrypt = require('bcryptjs');
|
||||
const db = require('./db');
|
||||
const { generateToken, authMiddleware, adminOnly } = require('./auth');
|
||||
|
||||
const app = express();
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
|
||||
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 } });
|
||||
});
|
||||
|
||||
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; });
|
||||
}
|
||||
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;
|
||||
res.json(domain);
|
||||
});
|
||||
|
||||
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');
|
||||
|
||||
const updates = {};
|
||||
fields.forEach(f => { if (req.body[f] !== undefined) updates[f] = req.body[f]; });
|
||||
updates.updated_at = new Date().toISOString();
|
||||
|
||||
const setClause = Object.keys(updates).map(k => k + ' = ?').join(', ');
|
||||
const values = Object.values(updates);
|
||||
values.push(req.params.id);
|
||||
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;
|
||||
res.json(updated);
|
||||
});
|
||||
|
||||
app.post('/api/domains', authMiddleware, adminOnly, (req, res) => {
|
||||
const { name, company_name, parent_id } = req.body;
|
||||
if (!name || !company_name) return res.status(400).json({ error: 'Nom requis' });
|
||||
const maxOrder = db.prepare('SELECT COALESCE(MAX(sort_order), 0) as m FROM domains').get().m;
|
||||
const result = db.prepare('INSERT INTO domains (name, company_name, parent_id, sort_order) VALUES (?, ?, ?, ?)')
|
||||
.run(name, company_name, parent_id || null, maxOrder + 1);
|
||||
res.json(db.prepare('SELECT * FROM domains WHERE id = ?').get(result.lastInsertRowid));
|
||||
});
|
||||
|
||||
app.delete('/api/domains/:id', authMiddleware, adminOnly, (req, res) => {
|
||||
db.prepare('DELETE FROM domains WHERE id = ?').run(req.params.id);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
app.get('/api/health', (req, res) => res.json({ ok: true }));
|
||||
|
||||
const PORT = process.env.PORT || 3008;
|
||||
app.listen(PORT, '0.0.0.0', () => console.log('Charles backend on port ' + PORT));
|
||||
@@ -0,0 +1,26 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
backend:
|
||||
build: ./backend
|
||||
container_name: charles-backend
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
networks:
|
||||
- charles-net
|
||||
|
||||
frontend:
|
||||
build: ./frontend
|
||||
container_name: charles-frontend
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "3008:80"
|
||||
depends_on:
|
||||
- backend
|
||||
networks:
|
||||
- charles-net
|
||||
|
||||
networks:
|
||||
charles-net:
|
||||
driver: bridge
|
||||
@@ -0,0 +1,11 @@
|
||||
FROM node:20-alpine AS build
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:alpine
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
EXPOSE 80
|
||||
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Charles - SecourIT</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,18 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location /api {
|
||||
proxy_pass http://charles-backend:3008;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "charles-frontend",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.28.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.4.49",
|
||||
"tailwindcss": "^3.4.15",
|
||||
"vite": "^5.4.11"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export default { plugins: { tailwindcss: {}, autoprefixer: {} } }
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useState } from 'react'
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
|
||||
import Login from './components/Login'
|
||||
import Layout from './components/Layout'
|
||||
import Dashboard from './components/Dashboard'
|
||||
import DomainForm from './components/DomainForm'
|
||||
|
||||
function App() {
|
||||
const [token, setToken] = useState(localStorage.getItem('charles_token'))
|
||||
const [user, setUser] = useState(() => { try { return JSON.parse(localStorage.getItem('charles_user')) } catch { return null } })
|
||||
|
||||
const handleLogin = (t, u) => {
|
||||
localStorage.setItem('charles_token', t)
|
||||
localStorage.setItem('charles_user', JSON.stringify(u))
|
||||
setToken(t)
|
||||
setUser(u)
|
||||
}
|
||||
const handleLogout = () => {
|
||||
localStorage.removeItem('charles_token')
|
||||
localStorage.removeItem('charles_user')
|
||||
setToken(null)
|
||||
setUser(null)
|
||||
}
|
||||
|
||||
if (!token) return <Login onLogin={handleLogin} />
|
||||
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<Layout user={user} onLogout={handleLogout}>
|
||||
<Routes>
|
||||
<Route path='/' element={<Dashboard token={token} user={user} />} />
|
||||
<Route path='/domain/:id' element={<DomainForm token={token} user={user} />} />
|
||||
<Route path='*' element={<Navigate to='/' />} />
|
||||
</Routes>
|
||||
</Layout>
|
||||
</BrowserRouter>
|
||||
)
|
||||
}
|
||||
export default App
|
||||
@@ -0,0 +1,87 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
export default function Dashboard({ token, user }) {
|
||||
const [domains, setDomains] = useState({ parent: null, children: [] })
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/domains', { headers: { Authorization: 'Bearer ' + token } })
|
||||
.then(r => r.json()).then(d => { setDomains(d); setLoading(false) })
|
||||
}, [token])
|
||||
|
||||
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 color = (n) => n === 0 ? 'bg-slate-600' : n < 3 ? 'bg-amber-500' : 'bg-emerald-500'
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-8">
|
||||
<h2 className="text-xl font-bold text-white mb-1">Domaines & Services</h2>
|
||||
<p className="text-slate-400 text-sm">
|
||||
{user.role === 'admin' ? 'Gerez les besoins de Charles pour chaque societe.' : 'Renseignez vos besoins pour chaque domaine.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{domains.parent && (
|
||||
<div className="mb-6 p-5 bg-slate-800/30 rounded-2xl border border-slate-700/30">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-blue-600/20 flex items-center justify-center">
|
||||
<svg className="w-5 h-5 text-blue-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-white font-semibold">{domains.parent.company_name}</h3>
|
||||
<p className="text-slate-400 text-xs">{domains.parent.name} — Entreprise maitre</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{domains.children.map(d => {
|
||||
const n = count(d)
|
||||
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 (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_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 (n === 0) tags.push(<span key="x" className="text-slate-500 text-xs italic">Aucun besoin renseigne</span>)
|
||||
|
||||
return (
|
||||
<Link key={d.id} to={'/domain/' + d.id}
|
||||
className="group p-5 bg-slate-800/40 hover:bg-slate-800/60 rounded-2xl border border-slate-700/30 hover:border-slate-600/50 transition-all duration-200 hover:shadow-lg hover:shadow-black/20">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div>
|
||||
<h3 className="text-white font-semibold group-hover:text-blue-400 transition-colors">{d.company_name}</h3>
|
||||
<p className="text-slate-400 text-xs mt-0.5">{d.name}</p>
|
||||
</div>
|
||||
<div className={'w-2.5 h-2.5 rounded-full mt-1 ' + color(n)} />
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">{tags}</div>
|
||||
{user.role === 'admin' && d.monthly_price > 0 && (
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{user.role === 'admin' && (
|
||||
<div className="mt-8 p-5 bg-slate-800/30 rounded-2xl border border-slate-700/30">
|
||||
<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><p className="text-2xl font-bold text-white">{domains.children.length}</p><p className="text-slate-400 text-xs">Domaines</p></div>
|
||||
<div><p className="text-2xl font-bold text-sky-400">{domains.children.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">{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-emerald-400">{domains.children.filter(d => d.has_emails).length}</p><p className="text-slate-400 text-xs">Avec mails</p></div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
|
||||
export default function DomainForm({ token, user }) {
|
||||
const { id } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const [d, setD] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [saved, setSaved] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/domains/' + id, { headers: { Authorization: 'Bearer ' + token } })
|
||||
.then(r => r.json()).then(data => { setD(data); setLoading(false) })
|
||||
}, [id, token])
|
||||
|
||||
const u = (field, val) => { setD(p => ({ ...p, [field]: val })); setSaved(false) }
|
||||
|
||||
const save = async () => {
|
||||
setSaving(true)
|
||||
const res = await fetch('/api/domains/' + id, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + token },
|
||||
body: JSON.stringify(d)
|
||||
})
|
||||
setD(await res.json())
|
||||
setSaving(false)
|
||||
setSaved(true)
|
||||
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)) }
|
||||
|
||||
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>
|
||||
|
||||
const Toggle = ({ checked, onChange, label }) => (
|
||||
<div className="flex items-center justify-between py-3">
|
||||
<span className="text-slate-300 text-sm">{label}</span>
|
||||
<button type="button" onClick={() => onChange(!checked)}
|
||||
className={'relative w-11 h-6 rounded-full transition-colors ' + (checked ? 'bg-blue-600' : 'bg-slate-600')}>
|
||||
<div className={'absolute top-0.5 left-0.5 w-5 h-5 rounded-full bg-white transition-transform ' + (checked ? 'translate-x-5' : '')} />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
|
||||
const Section = ({ title, color, children }) => (
|
||||
<div className="bg-slate-800/40 rounded-2xl border border-slate-700/30 p-6">
|
||||
<h3 className={'font-semibold mb-4 ' + color}>{title}</h3>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
|
||||
const Input = ({ label, value, onChange, placeholder, type = 'text' }) => (
|
||||
<div className="mb-3">
|
||||
<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}
|
||||
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" />
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button onClick={() => navigate('/')} className="mb-6 text-slate-400 hover:text-white text-sm flex items-center gap-1 transition">
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" /></svg>
|
||||
Retour
|
||||
</button>
|
||||
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-white">{d.company_name}</h2>
|
||||
<p className="text-slate-400 text-sm">{d.name}</p>
|
||||
</div>
|
||||
<button onClick={save} disabled={saving}
|
||||
className="px-5 py-2.5 bg-blue-600 hover:bg-blue-500 disabled:bg-blue-600/50 text-white text-sm font-medium rounded-xl transition-colors">
|
||||
{saving ? 'Sauvegarde...' : saved ? '\u2713 Sauvegarde' : 'Sauvegarder'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<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" />
|
||||
{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" />
|
||||
) : <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" />
|
||||
</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" />
|
||||
<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">
|
||||
<Toggle checked={!!d.has_team} onChange={v => u('has_team', v ? 1 : 0)} label="Besoin d'utilisateurs" />
|
||||
{d.has_team ? (
|
||||
<div className="space-y-3">
|
||||
{JSON.parse(d.team_members || '[]').map((m, i) => (
|
||||
<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"
|
||||
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" />
|
||||
<input type="text" value={m.last_name} onChange={e => updateMember(i, 'last_name', e.target.value)} placeholder="Nom"
|
||||
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={() => removeMember(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={addMember} 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 utilisateur
|
||||
</button>
|
||||
</div>
|
||||
) : <p className="text-slate-500 text-xs italic">Aucun utilisateur renseigne</p>}
|
||||
</Section>
|
||||
|
||||
{user.role === 'admin' && (
|
||||
<Section title="Tarif mensuel" 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" />
|
||||
</Section>
|
||||
)}
|
||||
|
||||
<Section title="Notes" color="text-slate-400">
|
||||
<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" />
|
||||
</Section>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
export default function Layout({ children, user, onLogout }) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900">
|
||||
<header className="border-b border-slate-700/50 bg-slate-900/50 backdrop-blur-sm sticky top-0 z-50">
|
||||
<div className="max-w-6xl mx-auto px-4 py-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-9 h-9 rounded-lg bg-blue-600/20 flex items-center justify-center">
|
||||
<svg className="w-5 h-5 text-blue-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 13.255A23.931 23.931 0 0112 15c-3.183 0-6.22-.62-9-1.745M16 6V4a2 2 0 00-2-2h-4a2 2 0 00-2 2v2m4 6h.01M5 20h14a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-white font-semibold text-lg leading-tight">Charles Maintenance</h1>
|
||||
<p className="text-slate-400 text-xs">360 Partners</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-right">
|
||||
<p className="text-white text-sm font-medium">{user.name}</p>
|
||||
<p className="text-slate-400 text-xs">{user.role === 'admin' ? 'Administrateur' : 'Client'}</p>
|
||||
</div>
|
||||
<button onClick={onLogout} className="p-2 text-slate-400 hover:text-white hover:bg-slate-700/50 rounded-lg transition" title="Deconnexion">
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<main className="max-w-6xl mx-auto px-4 py-8">{children}</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useState } from 'react'
|
||||
|
||||
export default function Login({ onLogin }) {
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await fetch('/api/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password })
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) throw new Error(data.error || 'Erreur')
|
||||
onLogin(data.token, data.user)
|
||||
} catch (err) { setError(err.message) }
|
||||
finally { setLoading(false) }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="text-center mb-8">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-blue-600/20 mb-4">
|
||||
<svg className="w-8 h-8 text-blue-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 13.255A23.931 23.931 0 0112 15c-3.183 0-6.22-.62-9-1.745M16 6V4a2 2 0 00-2-2h-4a2 2 0 00-2 2v2m4 6h.01M5 20h14a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-white">Espace maintenance</h1>
|
||||
<p className="text-slate-400 mt-1">360 Partners & sous-traitance</p>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit} className="bg-slate-800/50 backdrop-blur-sm rounded-2xl p-8 border border-slate-700/50 shadow-xl">
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-300 mb-2">Identifiant</label>
|
||||
<input type="text" value={username} onChange={e => setUsername(e.target.value)}
|
||||
className="w-full px-4 py-3 bg-slate-900/50 border border-slate-600 rounded-xl text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition"
|
||||
placeholder="Votre identifiant" required />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-300 mb-2">Mot de passe</label>
|
||||
<input type="password" value={password} onChange={e => setPassword(e.target.value)}
|
||||
className="w-full px-4 py-3 bg-slate-900/50 border border-slate-600 rounded-xl text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition"
|
||||
placeholder="Votre mot de passe" required />
|
||||
</div>
|
||||
</div>
|
||||
{error && <div className="mt-4 p-3 bg-red-500/10 border border-red-500/30 rounded-xl text-red-400 text-sm text-center">{error}</div>}
|
||||
<button type="submit" disabled={loading}
|
||||
className="mt-6 w-full py-3 bg-blue-600 hover:bg-blue-500 disabled:bg-blue-600/50 text-white font-medium rounded-xl transition-colors">
|
||||
{loading ? 'Connexion...' : 'Se connecter'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
body { font-family: 'Inter', system-ui, -apple-system, sans-serif; }
|
||||
@@ -0,0 +1,5 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App'
|
||||
import './index.css'
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(<React.StrictMode><App /></React.StrictMode>)
|
||||
@@ -0,0 +1,6 @@
|
||||
/** @type {import("tailwindcss").Config} */
|
||||
export default {
|
||||
content: ["./index.html", "./src/**/*.{js,jsx}"],
|
||||
theme: { extend: {} },
|
||||
plugins: [],
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: { proxy: { '/api': 'http://localhost:3008' } }
|
||||
})
|
||||
Reference in New Issue
Block a user