diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c839b8e --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +# Dependencies +node_modules + +# Environment / secrets +.env + +# OS / editor files +.DS_Store diff --git a/app.js b/app.js index fb4e22f..a8651a8 100644 --- a/app.js +++ b/app.js @@ -60,6 +60,7 @@ const persistKey = "devis-generator:v1"; const savedListKey = "devis-generator:saved:v1"; + const globalSavesEndpoint = "/api/saves"; const save = () => { try { localStorage.setItem(persistKey, JSON.stringify(state)); @@ -85,6 +86,35 @@ localStorage.setItem(savedListKey, JSON.stringify(arr)); } catch {} }; + const canUseGlobalSaves = () => + typeof fetch === "function" && (location?.protocol || "") !== "file:"; + const fetchGlobalSaves = async () => { + if (!canUseGlobalSaves()) throw new Error("Sauvegardes globales indisponibles"); + const res = await fetch(globalSavesEndpoint, { credentials: "same-origin" }); + if (!res.ok) throw new Error("HTTP " + res.status); + const json = await res.json(); + if (!Array.isArray(json)) throw new Error("Réponse inattendue"); + return json; + }; + const pushGlobalSave = async (entry) => { + if (!canUseGlobalSaves()) throw new Error("Sauvegardes globales indisponibles"); + const res = await fetch(globalSavesEndpoint, { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "same-origin", + body: JSON.stringify(entry), + }); + if (!res.ok) throw new Error("HTTP " + res.status); + return res.json(); + }; + const deleteGlobalSave = async (id) => { + if (!canUseGlobalSaves()) throw new Error("Sauvegardes globales indisponibles"); + const res = await fetch(`${globalSavesEndpoint}/${encodeURIComponent(id)}`, { + method: "DELETE", + credentials: "same-origin", + }); + if (!res.ok) throw new Error("HTTP " + res.status); + }; const computeQuickTotal = (data) => { const items = Array.isArray(data.items) ? data.items : []; const subtotal = items.reduce( @@ -109,7 +139,7 @@ num || [client, date].filter(Boolean).join(" - ") || "Devis sans titre" ); }; - const saveCurrentQuote = () => { + const saveCurrentQuote = async () => { const data = buildExportJson(); const input = prompt("Nom du devis", buildDefaultSaveTitle()); if (input === null) return; // Annulé => ne rien faire @@ -126,14 +156,43 @@ const list = getSavedList(); list.unshift(entry); setSavedList(list); - alert("Devis enregistré."); + let savedGlobally = false; + try { + if (canUseGlobalSaves()) { + const saved = await pushGlobalSave(entry); + if (saved && typeof saved === "object") { + const cur = getSavedList(); + cur[0] = saved; // synchroniser id/horodatage renvoyés + setSavedList(cur); + } + savedGlobally = true; + } + } catch (e) { + console.warn("Sauvegarde globale impossible:", e); + } + alert( + savedGlobally + ? "Devis enregistré pour tout le monde (et en local)." + : "Devis enregistré uniquement sur cet appareil." + ); }; - const openLibrary = () => { + const openLibrary = async () => { const modal = document.getElementById("libraryModal"); const listEl = document.getElementById("libraryList"); const emptyEl = document.getElementById("libraryEmpty"); if (!modal || !listEl || !emptyEl) return; - const list = getSavedList(); + listEl.innerHTML = `
Chargement...
`; + let list = []; + let usedGlobal = false; + if (canUseGlobalSaves()) { + try { + list = await fetchGlobalSaves(); + usedGlobal = true; + } catch (e) { + console.warn("Lecture globale impossible, fallback local:", e); + } + } + if (!usedGlobal) list = getSavedList(); listEl.innerHTML = ""; if (!list.length) { emptyEl.style.display = ""; @@ -161,11 +220,23 @@ const btnDel = document.createElement("button"); btnDel.className = "btn btn-danger"; btnDel.textContent = "Supprimer"; - btnDel.addEventListener("click", () => { + btnDel.addEventListener("click", async () => { if (!confirm("Supprimer ce devis enregistré ?")) return; - const cur = getSavedList().filter((x) => x.id !== e.id); - setSavedList(cur); - openLibrary(); + if (usedGlobal && canUseGlobalSaves()) { + try { + await deleteGlobalSave(e.id); + } catch (err) { + alert( + "Suppression impossible côté serveur: " + + (err.message || err) + ); + return; + } + } else { + const cur = getSavedList().filter((x) => x.id !== e.id); + setSavedList(cur); + } + await openLibrary(); }); row.appendChild(title); row.appendChild(price); @@ -865,9 +936,17 @@ window.print(); }); // Save current quote - $("#saveQuoteBtn")?.addEventListener("click", () => saveCurrentQuote()); + $("#saveQuoteBtn")?.addEventListener("click", () => + saveCurrentQuote().catch((e) => + alert("Impossible d'enregistrer: " + (e.message || e)) + ) + ); // Library open/close - $("#openLibraryBtn")?.addEventListener("click", () => openLibrary()); + $("#openLibraryBtn")?.addEventListener("click", () => + openLibrary().catch((e) => + alert("Impossible d'ouvrir la bibliothèque: " + (e.message || e)) + ) + ); $("#closeLibraryBtn")?.addEventListener("click", () => closeLibrary()); document .querySelector("#libraryModal .modal-backdrop") @@ -1113,6 +1192,9 @@ }; const loadFromJson = (obj) => { + if (obj && obj.data && typeof obj.data === "object") { + obj = obj.data; + } if (!obj || typeof obj !== "object") throw new Error("JSON invalide"); // Merge top-level simple keys keysAllowed.forEach((k) => { diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..6755230 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,174 @@ +{ + "name": "devis-generator", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "devis-generator", + "version": "1.0.0", + "dependencies": { + "dotenv": "^16.4.5", + "pg": "^8.12.0" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/pg": { + "version": "8.16.3", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz", + "integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.9.1", + "pg-pool": "^3.10.1", + "pg-protocol": "^1.10.3", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.2.7" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.2.7.tgz", + "integrity": "sha512-YgCtzMH0ptvZJslLM1ffsY4EuGaU0cx4XSdXLRFae8bPP4dS5xL1tNB3k2o/N64cHJpwU7dxKli/nZ2lUa5fLg==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.9.1.tgz", + "integrity": "sha512-nkc6NpDcvPVpZXxrreI/FOtX3XemeLl8E0qFr6F2Lrm/I8WOnaWNhIPK2Z7OHpw7gh5XJThi6j6ppgNoaT1w4w==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.10.1.tgz", + "integrity": "sha512-Tu8jMlcX+9d8+QVzKIvM/uJtp07PKr82IUOYEphaWcoBhIYkoHpLXN3qO59nAI11ripznDsEzEv8nUxBVWajGg==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.10.3.tgz", + "integrity": "sha512-6DIBgBQaTKDJyxnXaLiLR8wBpQQcGWuAESkRBX/t6OwA8YsqP+iVSiond2EDy6Y/dsGk8rh/jtax3js5NeV7JQ==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.0.tgz", + "integrity": "sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..523eb22 --- /dev/null +++ b/package.json @@ -0,0 +1,14 @@ +{ + "name": "devis-generator", + "version": "1.0.0", + "description": "Générateur de devis avec sauvegardes partagées via PostgreSQL", + "main": "server.js", + "scripts": { + "start": "node server.js", + "dev": "node server.js" + }, + "dependencies": { + "dotenv": "^16.4.5", + "pg": "^8.12.0" + } +} diff --git a/server.js b/server.js new file mode 100644 index 0000000..9503359 --- /dev/null +++ b/server.js @@ -0,0 +1,249 @@ +#!/usr/bin/env node +/** + * Serveur simple (Node HTTP) pour partager les devis via PostgreSQL. + * Routes: + * GET /api/saves -> liste des devis + * POST /api/saves -> ajoute un devis { name, data, number?, clientName?, date?, total? } + * DELETE /api/saves/:id -> supprime un devis + * Sert aussi les fichiers statiques du dossier courant. + */ +require("dotenv").config(); +const http = require("http"); +const fs = require("fs"); +const fsp = require("fs/promises"); +const path = require("path"); +const { Pool } = require("pg"); + +const PORT = Number(process.env.PORT || 3000); +const HOST = process.env.HOST || "0.0.0.0"; +const ROOT = __dirname; + +const pool = new Pool({ + connectionString: process.env.DATABASE_URL, + host: process.env.PGHOST, + port: process.env.PGPORT ? Number(process.env.PGPORT) : undefined, + user: process.env.PGUSER, + password: process.env.PGPASSWORD, + database: process.env.PGDATABASE, + ssl: + process.env.PGSSL === "true" || process.env.PGSSLMODE === "require" + ? { rejectUnauthorized: false } + : undefined, +}); + +const MIME = { + ".html": "text/html; charset=utf-8", + ".js": "text/javascript; charset=utf-8", + ".css": "text/css; charset=utf-8", + ".json": "application/json; charset=utf-8", + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".svg": "image/svg+xml", +}; + +const ensureSchema = async () => { + await pool.query(` + CREATE TABLE IF NOT EXISTS quotes ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + number TEXT, + client_name TEXT, + date TEXT, + total NUMERIC, + saved_at TIMESTAMPTZ NOT NULL DEFAULT now(), + data JSONB NOT NULL + ); + `); + await pool.query(`CREATE INDEX IF NOT EXISTS quotes_saved_at_idx ON quotes (saved_at DESC);`); +}; + +const readBodyJson = async (req) => { + const chunks = []; + for await (const chunk of req) { + chunks.push(chunk); + if (chunks.reduce((s, c) => s + c.length, 0) > 1e6) { + throw new Error("Payload trop volumineux"); + } + } + if (!chunks.length) return null; + return JSON.parse(Buffer.concat(chunks).toString("utf8")); +}; + +const quickTotal = (data) => { + const items = Array.isArray(data?.items) ? data.items : []; + const subtotal = items.reduce((acc, it) => { + const qty = Number(it.days ?? it.qty ?? 0); + const price = Number(it.unitPrice ?? it.unit_price ?? 0); + return acc + qty * price; + }, 0); + const discountRate = Number(data?.discountRate || 0); + const vatRate = Number(data?.vatRate || 0); + const discount = subtotal * (discountRate / 100); + const base = Math.max(0, subtotal - discount); + const vat = base * (vatRate / 100); + return base + vat; +}; + +const sendJson = (res, status, payload) => { + res.writeHead(status, { + "Content-Type": "application/json; charset=utf-8", + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET,POST,DELETE,OPTIONS", + "Access-Control-Allow-Headers": "Content-Type", + }); + res.end(JSON.stringify(payload)); +}; + +const notFound = (res) => { + res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" }); + res.end("Not found"); +}; + +const rowToEntry = (row) => ({ + id: row.id, + name: row.name || "", + number: row.number || "", + clientName: row.client_name || "", + date: row.date || "", + total: Number(row.total || 0), + savedAt: + row.saved_at instanceof Date ? row.saved_at.toISOString() : row.saved_at, + data: row.data || {}, +}); + +const handleApi = async (req, res, url) => { + if (req.method === "OPTIONS") { + res.writeHead(204, { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET,POST,DELETE,OPTIONS", + "Access-Control-Allow-Headers": "Content-Type", + }); + res.end(); + return true; + } + + if (url.pathname === "/api/saves" && req.method === "GET") { + const { rows } = await pool.query( + "SELECT id, name, number, client_name, date, total, saved_at, data FROM quotes ORDER BY saved_at DESC;" + ); + sendJson(res, 200, rows.map(rowToEntry)); + return true; + } + + if (url.pathname === "/api/saves" && req.method === "POST") { + try { + const body = await readBodyJson(req); + if (!body || typeof body !== "object") { + sendJson(res, 400, { error: "Corps vide" }); + return true; + } + if (!body.data || typeof body.data !== "object") { + sendJson(res, 400, { error: "Champ 'data' manquant" }); + return true; + } + const id = + body.id || + Date.now().toString(36) + Math.random().toString(36).slice(2, 8); + const total = Number.isFinite(body.total) + ? Number(body.total) + : quickTotal(body.data); + const savedAt = new Date(); + await pool.query( + ` + INSERT INTO quotes (id, name, number, client_name, date, total, saved_at, data) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + ON CONFLICT (id) DO UPDATE + SET name = EXCLUDED.name, + number = EXCLUDED.number, + client_name = EXCLUDED.client_name, + date = EXCLUDED.date, + total = EXCLUDED.total, + saved_at = EXCLUDED.saved_at, + data = EXCLUDED.data; + `, + [ + id, + (body.name || "Devis sans titre").toString(), + (body.number || "").toString(), + (body.clientName || "").toString(), + (body.date || "").toString(), + total, + savedAt, + body.data, + ] + ); + sendJson(res, 201, { + id, + name: body.name || "Devis sans titre", + number: body.number || "", + clientName: body.clientName || "", + date: body.date || "", + total, + savedAt: savedAt.toISOString(), + data: body.data, + }); + } catch (e) { + console.error("POST /api/saves error:", e); + sendJson(res, 400, { error: e.message || "Erreur" }); + } + return true; + } + + if (url.pathname.startsWith("/api/saves/") && req.method === "DELETE") { + const id = url.pathname.split("/").pop(); + await pool.query("DELETE FROM quotes WHERE id = $1;", [id]); + sendJson(res, 200, { ok: true }); + return true; + } + + return false; +}; + +const serveStatic = async (req, res, url) => { + let pathname = decodeURIComponent(url.pathname); + if (pathname === "/") pathname = "/index.html"; + const filePath = path.join(ROOT, pathname); + + if (!filePath.startsWith(ROOT)) { + notFound(res); + return; + } + + try { + const stat = await fsp.stat(filePath); + if (stat.isDirectory()) { + notFound(res); + return; + } + const ext = path.extname(filePath); + const type = MIME[ext] || "application/octet-stream"; + res.writeHead(200, { "Content-Type": type }); + fs.createReadStream(filePath).pipe(res); + } catch { + notFound(res); + } +}; + +const start = async () => { + await ensureSchema(); + const server = http.createServer(async (req, res) => { + const url = new URL(req.url, `http://${req.headers.host}`); + try { + const handled = await handleApi(req, res, url); + if (!handled) await serveStatic(req, res, url); + } catch (e) { + console.error("Erreur serveur:", e); + res.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" }); + res.end("Erreur serveur"); + } + }); + server.listen(PORT, HOST, () => { + console.log(`Serveur sur http://${HOST}:${PORT}`); + }); +}; + +start().catch((e) => { + console.error("Impossible de démarrer:", e); + process.exit(1); +});