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);
+});