Versie 1.0
This commit is contained in:
@@ -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();
|
||||||
@@ -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.
@@ -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;
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
@@ -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);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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();
|
||||||
@@ -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);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
@@ -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;
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
<% } %>
|
<% } %>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
Reference in New Issue
Block a user