diff --git a/controllers/CategoryController.js b/controllers/CategoryController.js new file mode 100644 index 0000000..b9bd5db --- /dev/null +++ b/controllers/CategoryController.js @@ -0,0 +1,103 @@ +const CategoryService = require("../services/CategoryService"); + + +class CategoryController { + + + index(req, res) { + + res.render("categories/index", { + title: "Categorieën", + categories: CategoryService.getCategories() + }); + + } + + + editForm(req, res) { + const categoryId = Number.parseInt(req.params.id, 10); + const category = Number.isInteger(categoryId) + ? CategoryService.getCategory(categoryId) + : null; + + if (!category) { + req.flash("error", "Categorie niet gevonden."); + return res.redirect("/categories"); + } + + res.render("categories/edit", { + title: "Categorie wijzigen", + category + }); + } + + + create(req, res) { + const name = (req.body.name || "").trim(); + const icon = (req.body.icon || "").trim(); + + if (!name) { + req.flash("error", "Geef een categorienaam op."); + return res.redirect("/categories"); + } + + CategoryService.createCategory({ name, icon }); + req.flash("success", "Categorie toegevoegd."); + res.redirect("/categories"); + } + + + update(req, res) { + const categoryId = Number.parseInt(req.params.id, 10); + const name = (req.body.name || "").trim(); + const icon = (req.body.icon || "").trim(); + + if (!Number.isInteger(categoryId) || !name) { + req.flash("error", "Geef een geldige categorienaam op."); + return res.redirect("/categories"); + } + + const result = CategoryService.updateCategory(categoryId, { name, icon }); + + if (result.changes === 0) { + req.flash("error", "Categorie niet gevonden."); + return res.redirect("/categories"); + } + + req.flash("success", "Categorie gewijzigd."); + res.redirect("/categories"); + } + + + remove(req, res) { + const categoryId = Number.parseInt(req.params.id, 10); + + if (!Number.isInteger(categoryId)) { + req.flash("error", "Categorie niet gevonden."); + return res.redirect("/categories"); + } + + if (CategoryService.categoryHasProducts(categoryId)) { + req.flash( + "error", + "Deze categorie is nog aan producten gekoppeld en kan niet worden verwijderd." + ); + return res.redirect("/categories"); + } + + const result = CategoryService.deleteCategory(categoryId); + + if (result.changes === 0) { + req.flash("error", "Categorie niet gevonden."); + return res.redirect("/categories"); + } + + req.flash("success", "Categorie verwijderd."); + res.redirect("/categories"); + } + + +} + + +module.exports = new CategoryController(); diff --git a/controllers/ProductController.js b/controllers/ProductController.js index c359175..e9c2d3c 100644 --- a/controllers/ProductController.js +++ b/controllers/ProductController.js @@ -2,6 +2,7 @@ const ProductService = require("../services/ProductService"); const db = require("../config/database"); +const parseCsv = require("../utils/parseCsv"); class ProductController { @@ -48,6 +49,156 @@ class ProductController { } + import(req, res) { + if (!req.file) { + req.flash("error", "Kies eerst een CSV-bestand."); + return res.redirect("/products"); + } + + let rows; + try { + rows = parseCsv(req.file.buffer.toString("utf8").replace(/^\uFEFF/, "")); + } catch (error) { + req.flash("error", `CSV kan niet worden gelezen: ${error.message}`); + return res.redirect("/products"); + } + + if (rows.length < 2) { + req.flash("error", "Het CSV-bestand bevat geen producten."); + return res.redirect("/products"); + } + + const headers = rows[0].map(header => header.trim().toLowerCase()); + const nameIndex = headers.indexOf("name"); + const categoryIndex = headers.includes("category") + ? headers.indexOf("category") + : headers.indexOf("category_name"); + const barcodeIndex = headers.indexOf("barcode"); + + if (nameIndex === -1) { + req.flash("error", "De CSV moet een kolom 'name' bevatten."); + return res.redirect("/products"); + } + + const categories = db.prepare("SELECT id, name FROM categories").all(); + const categoryIds = new Map( + categories.map(category => [category.name.trim().toLowerCase(), category.id]) + ); + const products = []; + const errors = []; + + rows.slice(1).forEach((row, rowIndex) => { + const lineNumber = rowIndex + 2; + const name = (row[nameIndex] || "").trim(); + const categoryName = categoryIndex === -1 + ? "" + : (row[categoryIndex] || "").trim(); + const categoryId = categoryName + ? categoryIds.get(categoryName.toLowerCase()) + : null; + + if (!name) errors.push(`regel ${lineNumber}: productnaam ontbreekt`); + if (categoryName && !categoryId) { + errors.push(`regel ${lineNumber}: categorie '${categoryName}' bestaat niet`); + } + + products.push({ + name, + category_id: categoryId, + barcode: barcodeIndex === -1 ? "" : (row[barcodeIndex] || "").trim() + }); + }); + + if (errors.length > 0) { + req.flash("error", `Import afgebroken: ${errors.slice(0, 5).join("; ")}`); + return res.redirect("/products"); + } + + ProductService.createProducts(products); + req.flash("success", `${products.length} producten geïmporteerd.`); + res.redirect("/products"); + } + + + editForm(req, res) { + const productId = Number.parseInt(req.params.id, 10); + const product = Number.isInteger(productId) + ? ProductService.getProduct(productId) + : null; + + if (!product) { + req.flash("error", "Product niet gevonden."); + return res.redirect("/products"); + } + + const categories = db.prepare( + "SELECT * FROM categories ORDER BY name" + ).all(); + + res.render("products/edit", { + title: "Product wijzigen", + product, + categories + }); + } + + + update(req, res) { + const productId = Number.parseInt(req.params.id, 10); + const name = (req.body.name || "").trim(); + const categoryId = req.body.category_id + ? Number.parseInt(req.body.category_id, 10) + : null; + + if (!Number.isInteger(productId) || !name || + (categoryId !== null && !Number.isInteger(categoryId))) { + req.flash("error", "Geef een geldige productnaam en categorie op."); + return res.redirect("/products"); + } + + const result = ProductService.updateProduct(productId, { + name, + category_id: categoryId + }); + + if (result.changes === 0) { + req.flash("error", "Product niet gevonden."); + return res.redirect("/products"); + } + + req.flash("success", "Product gewijzigd."); + res.redirect("/products"); + } + + + remove(req, res) { + const productId = Number.parseInt(req.params.id, 10); + + if (!Number.isInteger(productId)) { + req.flash("error", "Product niet gevonden."); + return res.redirect("/products"); + } + + if (ProductService.productHasListItems(productId)) { + req.flash( + "error", + "Dit product staat nog op een boodschappenlijst en kan niet worden verwijderd." + ); + return res.redirect(`/products/${productId}/edit`); + } + + const result = ProductService.deleteProduct(productId); + + if (result.changes === 0) { + req.flash("error", "Product niet gevonden."); + return res.redirect("/products"); + } + + req.flash("success", "Product verwijderd."); + res.redirect("/products"); + } + + } diff --git a/database.sqlite-shm b/database.sqlite-shm index 2039af5..f2db13e 100644 Binary files a/database.sqlite-shm and b/database.sqlite-shm differ diff --git a/database.sqlite-wal b/database.sqlite-wal index e990415..94a2c09 100644 Binary files a/database.sqlite-wal and b/database.sqlite-wal differ diff --git a/public/css/style.css b/public/css/style.css index c16a7da..32aeb69 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -16,6 +16,87 @@ body { font-weight: 700; } +.category-item { + display: grid; + gap: 1rem; +} + +.category-edit-form { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 1rem; + align-items: end; +} + +.category-fields { + display: grid; + grid-template-columns: 5rem minmax(0, 1fr); + gap: .75rem; +} + +.category-icon-input { + text-align: center; +} + +.category-actions button { + white-space: nowrap; +} + +.product-actions { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto; + gap: .5rem; + margin-top: 1.5rem; + align-items: stretch; +} + +.product-actions form, +.product-actions button, +.product-actions a { + width: 100%; +} + +.product-actions a { + display: inline-flex; + align-items: center; + justify-content: center; +} + +.category-actions { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: .5rem; +} + +.category-actions form, +.category-actions button { + width: 100%; +} + +@media (max-width: 767.98px) { + .category-item { + padding: 1rem; + } + + .category-edit-form, + .category-fields { + grid-template-columns: 1fr; + } + + .category-edit-form { + display: contents; + } + + .product-actions { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .product-actions a { + grid-column: 1 / -1; + } + +} + .alert { animation: fadeIn .3s ease; diff --git a/public/js/app.js b/public/js/app.js index 9d68a52..b39d0a4 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -1,7 +1,16 @@ (() => { + const logoutLink = document.querySelector('a[href="/logout"]'); + if (!logoutLink) return; + const socket = window.io && window.io(); if (!socket) return; + logoutLink.addEventListener("click", (event) => { + event.preventDefault(); + socket.disconnect(); + window.location.assign(logoutLink.href); + }); + const listMatch = window.location.pathname.match(/^\/lists\/(\d+)$/); if (listMatch) socket.emit("list:join", listMatch[1]); diff --git a/repositories/CategoryRepository.js b/repositories/CategoryRepository.js new file mode 100644 index 0000000..981f7e9 --- /dev/null +++ b/repositories/CategoryRepository.js @@ -0,0 +1,90 @@ +const db = require("../config/database"); + + +class CategoryRepository { + + + getAll() { + + return db.prepare(` + + SELECT categories.*, COUNT(products.id) AS product_count + + FROM categories + + LEFT JOIN products ON products.category_id = categories.id + + GROUP BY categories.id + + ORDER BY categories.name + + `).all(); + + } + + + findById(id) { + + return db.prepare(` + + SELECT * FROM categories WHERE id = ? + + `).get(id); + + } + + + create(data) { + + return db.prepare(` + + INSERT INTO categories (name, icon) + + VALUES (?, ?) + + `).run(data.name, data.icon || null); + + } + + + update(id, data) { + + return db.prepare(` + + UPDATE categories + + SET name = ?, icon = ? + + WHERE id = ? + + `).run(data.name, data.icon || null, id); + + } + + + hasProducts(id) { + + return db.prepare(` + + SELECT 1 FROM products WHERE category_id = ? LIMIT 1 + + `).get(id) !== undefined; + + } + + + delete(id) { + + return db.prepare(` + + DELETE FROM categories WHERE id = ? + + `).run(id); + + } + + +} + + +module.exports = new CategoryRepository(); diff --git a/repositories/ProductRepository.js b/repositories/ProductRepository.js index 8208b1c..69cd21a 100644 --- a/repositories/ProductRepository.js +++ b/repositories/ProductRepository.js @@ -78,6 +78,83 @@ class ProductRepository { } + createMany(products) { + const insert = db.prepare(` + + INSERT INTO products (name, category_id, barcode) + + VALUES (?, ?, ?) + + `); + + const insertMany = db.transaction((items) => { + for (const product of items) { + insert.run( + product.name, + product.category_id || null, + product.barcode || null + ); + } + }); + + insertMany(products); + return products.length; + } + + + update(id, data) { + + return db.prepare(` + + UPDATE products + + SET name = ?, category_id = ? + + WHERE id = ? + + `).run( + + data.name, + + data.category_id || null, + + id + + ); + + } + + + hasListItems(id) { + + return db.prepare(` + + SELECT 1 + + FROM shopping_list_items + + WHERE product_id = ? + + LIMIT 1 + + `).get(id) !== undefined; + + } + + + delete(id) { + + return db.prepare(` + + DELETE FROM products + + WHERE id = ? + + `).run(id); + + } + + } diff --git a/routes/web.js b/routes/web.js index 94ae778..6b518b5 100644 --- a/routes/web.js +++ b/routes/web.js @@ -1,4 +1,21 @@ const router = require("express").Router(); +const multer = require("multer"); + +const upload = multer({ + storage: multer.memoryStorage(), + limits: { fileSize: 2 * 1024 * 1024 } +}); + +function uploadCsv(req, res, next) { + upload.single("file")(req, res, (error) => { + if (error) { + req.flash("error", "Het CSV-bestand is te groot of ongeldig."); + return res.redirect("/products"); + } + + next(); + }); +} const auth = require("../middleware/auth"); @@ -11,6 +28,9 @@ require("../controllers/HouseholdController"); const ProductController = require("../controllers/ProductController"); +const CategoryController = +require("../controllers/CategoryController"); + const ShoppingListController = require("../controllers/ShoppingListController"); @@ -35,12 +55,67 @@ router.get( ); +router.get( + "/categories", + auth, + CategoryController.index.bind(CategoryController) +); + +router.post( + "/categories", + auth, + CategoryController.create.bind(CategoryController) +); + +router.get( + "/categories/:id/edit", + auth, + CategoryController.editForm.bind(CategoryController) +); + +router.post( + "/categories/:id", + auth, + CategoryController.update.bind(CategoryController) +); + +router.post( + "/categories/:id/delete", + auth, + CategoryController.remove.bind(CategoryController) +); + router.post( "/products", auth, ProductController.create.bind(ProductController) ); +router.post( + "/products/import", + auth, + uploadCsv, + ProductController.import.bind(ProductController) +); + +router.get( + "/products/:id/edit", + auth, + ProductController.editForm.bind(ProductController) +); + +router.post( + "/products/:id", + auth, + ProductController.update.bind(ProductController) +); + +router.post( + "/products/:id/delete", + auth, + ProductController.remove.bind(ProductController) +); + router.get( "/lists", auth, diff --git a/services/CategoryService.js b/services/CategoryService.js new file mode 100644 index 0000000..85b7708 --- /dev/null +++ b/services/CategoryService.js @@ -0,0 +1,52 @@ +const CategoryRepository = require("../repositories/CategoryRepository"); + + +class CategoryService { + + + getCategories() { + + return CategoryRepository.getAll(); + + } + + + getCategory(id) { + + return CategoryRepository.findById(id); + + } + + + createCategory(data) { + + return CategoryRepository.create(data); + + } + + + updateCategory(id, data) { + + return CategoryRepository.update(id, data); + + } + + + categoryHasProducts(id) { + + return CategoryRepository.hasProducts(id); + + } + + + deleteCategory(id) { + + return CategoryRepository.delete(id); + + } + + +} + + +module.exports = new CategoryService(); diff --git a/services/ProductService.js b/services/ProductService.js index b7a2cec..29b53ed 100644 --- a/services/ProductService.js +++ b/services/ProductService.js @@ -19,6 +19,41 @@ class ProductService { } + createProducts(data) { + + return ProductRepository.createMany(data); + + } + + + getProduct(id) { + + return ProductRepository.findById(id); + + } + + + updateProduct(id, data) { + + return ProductRepository.update(id, data); + + } + + + productHasListItems(id) { + + return ProductRepository.hasListItems(id); + + } + + + deleteProduct(id) { + + return ProductRepository.delete(id); + + } + + } diff --git a/sessions.sqlite b/sessions.sqlite index a62a3af..97304ae 100644 Binary files a/sessions.sqlite and b/sessions.sqlite differ diff --git a/utils/parseCsv.js b/utils/parseCsv.js new file mode 100644 index 0000000..a0d706f --- /dev/null +++ b/utils/parseCsv.js @@ -0,0 +1,36 @@ +module.exports = function parseCsv(input) { + const rows = []; + let row = []; + let field = ""; + let quoted = false; + + for (let index = 0; index < input.length; index += 1) { + const character = input[index]; + const nextCharacter = input[index + 1]; + + if (character === '"' && quoted && nextCharacter === '"') { + field += '"'; + index += 1; + } else if (character === '"') { + quoted = !quoted; + } else if (character === "," && !quoted) { + row.push(field); + field = ""; + } else if ((character === "\n" || character === "\r") && !quoted) { + if (character === "\r" && nextCharacter === "\n") index += 1; + row.push(field); + if (row.some(value => value.trim() !== "")) rows.push(row); + row = []; + field = ""; + } else { + field += character; + } + } + + if (quoted) throw new Error("CSV bevat een niet afgesloten tekstveld."); + + row.push(field); + if (row.some(value => value.trim() !== "")) rows.push(row); + + return rows; +}; diff --git a/views/categories/edit.ejs b/views/categories/edit.ejs new file mode 100644 index 0000000..41567d1 --- /dev/null +++ b/views/categories/edit.ejs @@ -0,0 +1,35 @@ + + +
+
+
+
+

Categoriegegevens

+
+
+
+ + + + + " maxlength="8"> +
+ +
+ +
+ +
+ Annuleren +
+
+
+
+
\ No newline at end of file diff --git a/views/categories/index.ejs b/views/categories/index.ejs new file mode 100644 index 0000000..a10325c --- /dev/null +++ b/views/categories/index.ejs @@ -0,0 +1,52 @@ + + +
+
+
+
+

Categorieën beheren

+
+
+ <% if (categories.length === 0) { %> +
Nog geen categorieën.
+ <% } %> + <% categories.forEach(category => { %> +
+
+
+ <%= category.icon || "" %> <%= category.name %> +
<%= category.product_count %> product(en)
+
+ + Wijzigen + +
+
+ <% }); %> +
+
+
+ +
+
+

Nieuwe categorie

+
+
+ + + + + + + +
+
+
+
+
diff --git a/views/partials/navbar.ejs b/views/partials/navbar.ejs index ed803ff..2c2b7d9 100644 --- a/views/partials/navbar.ejs +++ b/views/partials/navbar.ejs @@ -9,12 +9,28 @@ <% if (user) { %> - + +
+

Producten in bulk laden

+
+
+ + +
Kolommen: name, category, barcode. Alleen name is verplicht.
+ +
+
+
\ No newline at end of file