Versie 1.0

This commit is contained in:
2026-08-09 19:03:26 +02:00
parent c70293405f
commit f3da7dfb8d
18 changed files with 877 additions and 3 deletions
+103
View File
@@ -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();
+151
View File
@@ -2,6 +2,7 @@ const ProductService =
require("../services/ProductService"); require("../services/ProductService");
const db = require("../config/database"); const db = require("../config/database");
const parseCsv = require("../utils/parseCsv");
class ProductController { 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");
}
} }
Binary file not shown.
Binary file not shown.
+81
View File
@@ -16,6 +16,87 @@ body {
font-weight: 700; 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 { .alert {
animation: fadeIn .3s ease; animation: fadeIn .3s ease;
+9
View File
@@ -1,7 +1,16 @@
(() => { (() => {
const logoutLink = document.querySelector('a[href="/logout"]');
if (!logoutLink) return;
const socket = window.io && window.io(); const socket = window.io && window.io();
if (!socket) return; if (!socket) return;
logoutLink.addEventListener("click", (event) => {
event.preventDefault();
socket.disconnect();
window.location.assign(logoutLink.href);
});
const listMatch = window.location.pathname.match(/^\/lists\/(\d+)$/); const listMatch = window.location.pathname.match(/^\/lists\/(\d+)$/);
if (listMatch) socket.emit("list:join", listMatch[1]); if (listMatch) socket.emit("list:join", listMatch[1]);
+90
View File
@@ -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();
+77
View File
@@ -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);
}
} }
+75
View File
@@ -1,4 +1,21 @@
const router = require("express").Router(); 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"); const auth = require("../middleware/auth");
@@ -11,6 +28,9 @@ require("../controllers/HouseholdController");
const ProductController = const ProductController =
require("../controllers/ProductController"); require("../controllers/ProductController");
const CategoryController =
require("../controllers/CategoryController");
const ShoppingListController = const ShoppingListController =
require("../controllers/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( router.post(
"/products", "/products",
auth, auth,
ProductController.create.bind(ProductController) 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( router.get(
"/lists", "/lists",
auth, auth,
+52
View File
@@ -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();
+35
View File
@@ -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);
}
} }
BIN
View File
Binary file not shown.
+36
View File
@@ -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;
};
+35
View File
@@ -0,0 +1,35 @@
<div class="page-header mb-4">
<h1>Categorie wijzigen</h1>
</div>
<div class="row">
<div class="col-md-8">
<div class="card">
<div class="card-header">
<h2 class="card-title">Categoriegegevens</h2>
</div>
<div class="card-body">
<form id="category-edit-form" method="post" action="/categories/<%= category.id %>">
<label class="form-label" for="category-name">Naam</label>
<input id="category-name" class="form-control mb-3" type="text" name="name"
value="<%= category.name %>" maxlength="80" required>
<label class="form-label" for="category-icon">Icoon</label>
<input id="category-icon" class="form-control mb-4" type="text" name="icon"
value="<%= category.icon || "" %>" maxlength="8">
</form>
<div class="product-actions">
<button class="btn btn-primary" type="submit" form="category-edit-form">Wijzigingen opslaan</button>
<form method="post" action="/categories/<%= category.id %>/delete"
onsubmit="return confirm('Weet je zeker dat je deze categorie wilt verwijderen?');">
<button class="btn btn-outline-danger" type="submit">Categorie verwijderen</button>
</form>
<a class="btn btn-link" href="/categories">Annuleren</a>
</div>
</div>
</div>
</div>
</div>
+52
View File
@@ -0,0 +1,52 @@
<div class="page-header mb-4">
<h1>🏷️ Categorieën</h1>
</div>
<div class="row">
<div class="col-md-8">
<div class="card">
<div class="card-header">
<h2 class="card-title">Categorieën beheren</h2>
</div>
<div class="list-group list-group-flush">
<% if (categories.length === 0) { %>
<div class="list-group-item text-secondary">Nog geen categorieën.</div>
<% } %>
<% categories.forEach(category => { %>
<div class="list-group-item">
<div class="d-flex align-items-start justify-content-between gap-3">
<div>
<strong><%= category.icon || "" %> <%= category.name %></strong>
<div class="text-secondary"><%= category.product_count %> product(en)</div>
</div>
<a class="btn btn-sm btn-outline-primary" href="/categories/<%= category.id %>/edit">
Wijzigen
</a>
</div>
</div>
<% }); %>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card">
<div class="card-header"><h2 class="card-title">Nieuwe categorie</h2></div>
<div class="card-body">
<form method="post" action="/categories">
<label class="form-label" for="category-name">Naam</label>
<input id="category-name" class="form-control mb-3" type="text" name="name"
placeholder="bijv. Zuivel" maxlength="80" required>
<label class="form-label" for="category-icon">Icoon</label>
<input id="category-icon" class="form-control mb-3" type="text" name="icon"
placeholder="bijv. 🥛" maxlength="8">
<button class="btn btn-primary w-100" type="submit">Categorie toevoegen</button>
</form>
</div>
</div>
</div>
</div>
+17 -1
View File
@@ -9,12 +9,28 @@
</a> </a>
<% if (user) { %> <% if (user) { %>
<div class="navbar-nav flex-row ms-auto"> <button
class="navbar-toggler"
type="button"
data-bs-toggle="collapse"
data-bs-target="#pantryhub-navigation"
aria-controls="pantryhub-navigation"
aria-expanded="false"
aria-label="Navigatiemenu openen">
<span class="navbar-toggler-icon"></span>
</button>
<% } %>
<% if (user) { %>
<div class="collapse navbar-collapse" id="pantryhub-navigation">
<nav class="navbar-nav ms-auto">
<a class="nav-link" href="/">Dashboard</a> <a class="nav-link" href="/">Dashboard</a>
<a class="nav-link" href="/lists">Lijsten</a> <a class="nav-link" href="/lists">Lijsten</a>
<a class="nav-link" href="/products">Producten</a> <a class="nav-link" href="/products">Producten</a>
<a class="nav-link" href="/categories">Categorieën</a>
<a class="nav-link" href="/household">Huishouden</a> <a class="nav-link" href="/household">Huishouden</a>
<a class="nav-link text-danger" href="/logout">Uitloggen</a> <a class="nav-link text-danger" href="/logout">Uitloggen</a>
</nav>
</div> </div>
<% } %> <% } %>
+42
View File
@@ -0,0 +1,42 @@
<div class="page-header mb-4">
<h1>Product wijzigen</h1>
</div>
<div class="row">
<div class="col-md-8">
<div class="card">
<div class="card-header">
<h2 class="card-title">Productgegevens</h2>
</div>
<div class="card-body">
<form id="product-edit-<%= product.id %>" method="post" action="/products/<%= product.id %>">
<label class="form-label" for="product-name">Productnaam</label>
<input id="product-name" class="form-control mb-3" type="text" name="name"
value="<%= product.name %>" maxlength="120" required>
<label class="form-label" for="category-id">Categorie</label>
<select id="category-id" class="form-select mb-4" name="category_id">
<option value="">Geen categorie</option>
<% (categories || []).forEach(category => { %>
<option value="<%= category.id %>" <%= product.category_id === category.id ? "selected" : "" %>>
<%= category.icon || "" %> <%= category.name %>
</option>
<% }); %>
</select>
</form>
<div class="product-actions">
<button class="btn btn-primary" type="submit" form="product-edit-<%= product.id %>">Wijzigingen opslaan</button>
<form method="post" action="/products/<%= product.id %>/delete"
onsubmit="return confirm('Weet je zeker dat je dit product wilt verwijderen?');">
<button class="btn btn-outline-danger" type="submit">Product verwijderen</button>
</form>
<a class="btn btn-link" href="/products">Annuleren</a>
</div>
</div>
</div>
</div>
</div>
+22 -2
View File
@@ -18,8 +18,15 @@
<div class="list-group"> <div class="list-group">
<% products.forEach(product => { %> <% products.forEach(product => { %>
<div class="list-group-item"> <div class="list-group-item">
<strong><%= product.category_icon || "" %> <%= product.name %></strong> <div class="d-flex align-items-start justify-content-between gap-3">
<div class="text-secondary"><%= product.category_name || "Geen categorie" %></div> <div>
<strong><%= product.category_icon || "" %> <%= product.name %></strong>
<div class="text-secondary"><%= product.category_name || "Geen categorie" %></div>
</div>
<a class="btn btn-sm btn-outline-primary" href="/products/<%= product.id %>/edit">
Wijzigen
</a>
</div>
</div> </div>
<% }); %> <% }); %>
</div> </div>
@@ -49,5 +56,18 @@
</form> </form>
</div> </div>
</div> </div>
<div class="card mt-3">
<div class="card-header"><h3 class="card-title">Producten in bulk laden</h3></div>
<div class="card-body">
<form method="post" action="/products/import" enctype="multipart/form-data">
<label class="form-label" for="product-import-file">CSV-bestand</label>
<input id="product-import-file" class="form-control mb-3" type="file" name="file"
accept=".csv,text/csv" required>
<div class="text-secondary small mb-3">Kolommen: name, category, barcode. Alleen name is verplicht.</div>
<button class="btn btn-outline-primary w-100" type="submit">CSV importeren</button>
</form>
</div>
</div>
</div> </div>
</div> </div>