From c18f8c410fa5fee1fde7516036a5d11511d84ff9 Mon Sep 17 00:00:00 2001 From: AI Bot Date: Sat, 4 Jul 2026 17:07:00 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20Artikel=20k=C3=B6nnen=20jetzt=20mit=20T?= =?UTF-8?q?ags=20versehen=20werden?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 7 ++-- src/add_article.php | 81 ++++++++++++++++++++++++++++++++++++ src/articles.php | 88 +++++++++++++++++++++++++++++++++++---- src/db_connect.php | 19 +++++++++ src/edit_article.php | 98 ++++++++++++++++++++++++++++++++++++++++++-- 5 files changed, 280 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 334ba29..f5dfdd6 100644 --- a/README.md +++ b/README.md @@ -230,7 +230,8 @@ Das Projekt basiert auf bewährten Web-Standards: * Die störende JavaScript `confirm()`-Meldung beim Löschen von Artikeln in der Übersicht (`articles.php`) wurde durch ein einheitliches, modernes Bootstrap-Modal ersetzt. ### 19.06.2026 -* **Features:** - * **Notizen:** Neue Funktion "Notizen" hinzugefügt inkl. Rich-Text-Editor. - * **Haushaltsfreigabe:** Notizen können mit dem Haushalt geteilt werden. + * **Features:** + * **Notizen:** Neue Funktion "Notizen" hinzugefügt inkl. Rich-Text-Editor. + * **Haushaltsfreigabe:** Notizen können mit dem Haushalt geteilt werden. + * **Tags:** Artikel können nun mit Tags versehen werden, die im modernen Badge-Design angezeigt werden und nach denen in der Übersicht gefiltert werden kann. * **Design-Update:** Die Notizen-Übersichtsseite wurde an das Design der ToDo-Listen angepasst (Split-View, List-Groups, weiße Notizhintergründe). Der Texteditor bietet nun deutsche Tooltips. Beim Speichern einer Notiz wird nun besser zurück in die Übersicht navigiert. Das Layout der Metainformationen sowie der Buttons wurde weiter optimiert. Die Notizen werden in der Liste alphabetisch sortiert. Der Editor wurde aktualisiert: Er besitzt nun eine feste Höhe mit internem Scrollbereich, sodass die Formatierungsleiste immer sichtbar bleibt. Zudem wurde eine Tabellen-Funktion hinzugefügt. diff --git a/src/add_article.php b/src/add_article.php index e788e40..3bd9996 100644 --- a/src/add_article.php +++ b/src/add_article.php @@ -58,6 +58,15 @@ $stmt_man_load->execute(); $manufacturers = $stmt_man_load->get_result()->fetch_all(MYSQLI_ASSOC); $stmt_man_load->close(); +// Lade Tags +$stmt_tags_load = $conn->prepare("SELECT name FROM tags WHERE household_id = ? ORDER BY name ASC"); +$stmt_tags_load->bind_param("i", $household_id_for_user); +$stmt_tags_load->execute(); +$existing_tags = []; +$result_tags = $stmt_tags_load->get_result(); +while($row = $result_tags->fetch_assoc()) { $existing_tags[] = $row['name']; } +$stmt_tags_load->close(); + // Lade Lagerorte $stmt_loc_load = $conn->prepare("SELECT id, name, parent_id FROM storage_locations WHERE user_id IN ($placeholders) ORDER BY parent_id, name"); $stmt_loc_load->bind_param($types, ...$household_member_ids); @@ -188,6 +197,40 @@ if ($_SERVER["REQUEST_METHOD"] == "POST") { if ($stmt_insert_article) { $stmt_insert_article->bind_param("iisiiisssisii", $current_user_id, $is_household_item, $name, $weight_grams, $quantity_owned, $category_id, $consumable, $image_url_for_db, $product_url_for_db, $manufacturer_id, $product_designation_for_db, $storage_location_id, $parent_article_id); if ($stmt_insert_article->execute()) { + $new_article_id = $conn->insert_id; + + // Tags verarbeiten + $tags_json = $_POST['tags'] ?? ''; + if (!empty($tags_json)) { + $tags_arr = json_decode($tags_json, true); + if (is_array($tags_arr)) { + foreach ($tags_arr as $t) { + $tag_name = trim($t['value']); + if (empty($tag_name)) continue; + + $stmt_check_tag = $conn->prepare("SELECT id FROM tags WHERE household_id = ? AND name = ?"); + $stmt_check_tag->bind_param("is", $household_id_for_user, $tag_name); + $stmt_check_tag->execute(); + $res = $stmt_check_tag->get_result(); + if ($res->num_rows > 0) { + $tag_id = $res->fetch_assoc()['id']; + } else { + $stmt_add_tag = $conn->prepare("INSERT INTO tags (household_id, name) VALUES (?, ?)"); + $stmt_add_tag->bind_param("is", $household_id_for_user, $tag_name); + $stmt_add_tag->execute(); + $tag_id = $conn->insert_id; + $stmt_add_tag->close(); + } + $stmt_check_tag->close(); + + $stmt_link_tag = $conn->prepare("INSERT IGNORE INTO article_tags (article_id, tag_id) VALUES (?, ?)"); + $stmt_link_tag->bind_param("ii", $new_article_id, $tag_id); + $stmt_link_tag->execute(); + $stmt_link_tag->close(); + } + } + } + if ($household_id_for_user) { $log_message = htmlspecialchars($_SESSION['username']) . " hat den Artikel '" . htmlspecialchars($name) . "' hinzugefügt."; log_household_action($conn, $household_id_for_user, $current_user_id, $log_message); @@ -209,6 +252,29 @@ $conn->close(); + + + + +

Neuen Artikel erstellen

@@ -251,6 +317,7 @@ $conn->close();
+

@@ -464,6 +531,20 @@ document.addEventListener('DOMContentLoaded', function() { } if(document.getElementById('parent_article_id')) new TomSelect('#parent_article_id', tsOptionsBase); if(document.getElementById('storage_location_id')) new TomSelect('#storage_location_id', tsOptionsBase); + + // Initialize Tagify + const tagInput = document.querySelector('input[name="tags"]'); + if(tagInput) { + new Tagify(tagInput, { + whitelist: , + dropdown: { + maxItems: 20, + classname: "tags-look", + enabled: 0, + closeOnSelect: false + } + }); + } }); \ No newline at end of file diff --git a/src/articles.php b/src/articles.php index a8b44ca..71ba4c6 100644 --- a/src/articles.php +++ b/src/articles.php @@ -109,12 +109,40 @@ if ($stmt === false) { $stmt->close(); } +// Lade Tags für die geladenen Artikel +$tags_by_article = []; +$article_ids = array_column($articles, 'id'); +if (!empty($article_ids)) { + $id_placeholders = implode(',', array_fill(0, count($article_ids), '?')); + $stmt_tags = $conn->prepare("SELECT at.article_id, t.name, t.color FROM tags t JOIN article_tags at ON t.id = at.tag_id WHERE at.article_id IN ($id_placeholders) ORDER BY t.name ASC"); + if ($stmt_tags) { + $types_tags = str_repeat('i', count($article_ids)); + $stmt_tags->bind_param($types_tags, ...$article_ids); + $stmt_tags->execute(); + $res_tags = $stmt_tags->get_result(); + while($row = $res_tags->fetch_assoc()) { + $tags_by_article[$row['article_id']][] = ['name' => $row['name'], 'color' => $row['color']]; + } + $stmt_tags->close(); + } +} +foreach ($articles as &$article) { + $article['tags'] = $tags_by_article[$article['id']] ?? []; +} +unset($article); + $stmt_cat_load = $conn->prepare("SELECT id, name FROM categories WHERE user_id IN ($placeholders) ORDER BY name ASC"); $stmt_cat_load->bind_param($types, ...$household_member_ids); $stmt_cat_load->execute(); $categories_for_filter = $stmt_cat_load->get_result()->fetch_all(MYSQLI_ASSOC); $stmt_cat_load->close(); +$stmt_all_tags = $conn->prepare("SELECT id, name FROM tags WHERE household_id = ? ORDER BY name ASC"); +$stmt_all_tags->bind_param("i", $current_user_household_id); +$stmt_all_tags->execute(); +$all_filter_tags = $stmt_all_tags->get_result()->fetch_all(MYSQLI_ASSOC); +$stmt_all_tags->close(); + $stmt_man_load = $conn->prepare("SELECT id, name FROM manufacturers WHERE user_id IN ($placeholders) ORDER BY name ASC"); $stmt_man_load->bind_param($types, ...$household_member_ids); $stmt_man_load->execute(); @@ -172,6 +200,28 @@ if ($_SERVER["REQUEST_METHOD"] == "POST" && isset($_POST['bulk_move_articles'])) $articles[] = $row; } $stmt->close(); + + // Fetch tags for reloaded articles + $tags_by_article = []; + $article_ids = array_column($articles, 'id'); + if (!empty($article_ids)) { + $id_placeholders = implode(',', array_fill(0, count($article_ids), '?')); + $stmt_tags = $conn->prepare("SELECT at.article_id, t.name, t.color FROM tags t JOIN article_tags at ON t.id = at.tag_id WHERE at.article_id IN ($id_placeholders) ORDER BY t.name ASC"); + if ($stmt_tags) { + $types_tags = str_repeat('i', count($article_ids)); + $stmt_tags->bind_param($types_tags, ...$article_ids); + $stmt_tags->execute(); + $res_tags = $stmt_tags->get_result(); + while($row = $res_tags->fetch_assoc()) { + $tags_by_article[$row['article_id']][] = ['name' => $row['name'], 'color' => $row['color']]; + } + $stmt_tags->close(); + } + } + foreach ($articles as &$article) { + $article['tags'] = $tags_by_article[$article['id']] ?? []; + } + unset($article); } else { $message = ''; } @@ -196,10 +246,11 @@ $conn->close();
-
+
+
-
+
@@ -435,20 +486,22 @@ document.addEventListener('DOMContentLoaded', function () { function renderTable() { const textValue = filterText.value.toLowerCase(); const categoryValue = filterCategory.value; + const tagValue = document.getElementById('filter-tag') ? document.getElementById('filter-tag').value : ''; const manufacturerValue = filterManufacturer.value; const locationValue = filterLocation ? filterLocation.value : ''; - const isSearching = textValue.length > 0 || categoryValue || manufacturerValue || locationValue; + const isSearching = textValue.length > 0 || categoryValue || tagValue || manufacturerValue || locationValue; function articleMatchesFilter(article) { const matchesText = article.name.toLowerCase().includes(textValue) || (article.manufacturer_name && article.manufacturer_name.toLowerCase().includes(textValue)) || (article.product_designation && article.product_designation.toLowerCase().includes(textValue)); const matchesCategory = !categoryValue || article.category_id == categoryValue; + const matchesTag = !tagValue || (article.tags && article.tags.some(t => t.name === tagValue)); const matchesManufacturer = !manufacturerValue || article.manufacturer_id == manufacturerValue; const locId = article.storage_location_id; const locParentId = article.loc_parent_id; const matchesLocation = !locationValue || locId == locationValue || locParentId == locationValue; - return matchesText && matchesCategory && matchesManufacturer && matchesLocation; + return matchesText && matchesCategory && matchesTag && matchesManufacturer && matchesLocation; } const filteredList = articlesHierarchical.filter(article => { @@ -588,6 +641,15 @@ document.addEventListener('DOMContentLoaded', function () { const metaTextDisplay = metaTextRaw || ' '; const catName = article.category_name || 'Ohne Kat.'; const catColor = article.category_color || '#e2e8f0'; + + let tagsHtml = ''; + if (article.tags && article.tags.length > 0) { + tagsHtml = '
'; + article.tags.forEach(t => { + tagsHtml += `${t.name}`; + }); + tagsHtml += '
'; + } return `
@@ -600,8 +662,9 @@ document.addEventListener('DOMContentLoaded', function () {
${article.name}
${metaTextDisplay}
${new Intl.NumberFormat('de-DE').format(article.weight_grams)} g | ${quantityBadge}
-
${catName}
-
+
${catName}
+ ${tagsHtml} +
${productLink} ${actionButtons}
@@ -631,7 +694,17 @@ document.addEventListener('DOMContentLoaded', function () { const indentStyle = level > 0 ? `padding-left: ${1.5 * level}rem;` : ''; const treePrefix = level > 0 ? `` : ''; - const nameCellContent = `
${treePrefix}${article.name}
`; + + let tagsHtml = ''; + if (article.tags && article.tags.length > 0) { + tagsHtml = '
'; + article.tags.forEach(t => { + tagsHtml += `${t.name}`; + }); + tagsHtml += '
'; + } + + const nameCellContent = `
${treePrefix}
${article.name}${tagsHtml}
`; const checkboxHtml = (isOwner || isHouseholdArticle) ? `` : ''; html += ` @@ -774,6 +847,7 @@ document.addEventListener('DOMContentLoaded', function () { filterText.addEventListener('input', renderTable); filterCategory.addEventListener('change', renderTable); + if(document.getElementById('filter-tag')) document.getElementById('filter-tag').addEventListener('change', renderTable); filterManufacturer.addEventListener('change', renderTable); if(filterLocation) filterLocation.addEventListener('change', renderTable); diff --git a/src/db_connect.php b/src/db_connect.php index 75232ff..db45217 100644 --- a/src/db_connect.php +++ b/src/db_connect.php @@ -82,4 +82,23 @@ $conn->query("CREATE TABLE IF NOT EXISTS notes ( FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, FOREIGN KEY (household_id) REFERENCES households(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci"); + +// Ensure tags table exists +$conn->query("CREATE TABLE IF NOT EXISTS tags ( + id INT AUTO_INCREMENT PRIMARY KEY, + household_id INT DEFAULT NULL, + name VARCHAR(255) NOT NULL, + color VARCHAR(20) DEFAULT 'bg-secondary', + FOREIGN KEY (household_id) REFERENCES households(id) ON DELETE CASCADE, + UNIQUE KEY unique_tag (household_id, name) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci"); + +// Ensure article_tags table exists +$conn->query("CREATE TABLE IF NOT EXISTS article_tags ( + article_id INT NOT NULL, + tag_id INT NOT NULL, + PRIMARY KEY (article_id, tag_id), + FOREIGN KEY (article_id) REFERENCES articles(id) ON DELETE CASCADE, + FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci"); ?> diff --git a/src/edit_article.php b/src/edit_article.php index e116a48..75b515b 100644 --- a/src/edit_article.php +++ b/src/edit_article.php @@ -170,6 +170,26 @@ if ($can_edit) { $stmt_parent_articles->execute(); $parent_articles = $stmt_parent_articles->get_result()->fetch_all(MYSQLI_ASSOC); $stmt_parent_articles->close(); + + // Lade Tags für Haushalt + $stmt_tags_load = $conn->prepare("SELECT name FROM tags WHERE household_id = ? ORDER BY name ASC"); + $stmt_tags_load->bind_param("i", $household_id_for_user); + $stmt_tags_load->execute(); + $existing_tags = []; + $result_tags = $stmt_tags_load->get_result(); + while($row = $result_tags->fetch_assoc()) { $existing_tags[] = $row['name']; } + $stmt_tags_load->close(); + + // Lade aktuelle Tags des Artikels + $stmt_current_tags = $conn->prepare("SELECT t.name FROM tags t JOIN article_tags at ON t.id = at.tag_id WHERE at.article_id = ?"); + $stmt_current_tags->bind_param("i", $article_id); + $stmt_current_tags->execute(); + $current_article_tags = []; + $result_ct = $stmt_current_tags->get_result(); + while($row = $result_ct->fetch_assoc()) { $current_article_tags[] = $row['name']; } + $stmt_current_tags->close(); + + $current_tags_value = implode(',', $current_article_tags); } if ($_SERVER["REQUEST_METHOD"] == "POST" && $can_edit) { @@ -244,6 +264,44 @@ if ($_SERVER["REQUEST_METHOD"] == "POST" && $can_edit) { if ($stmt_update) { $stmt_update->bind_param("siiisssisiiii", $name, $weight_grams, $quantity_owned, $category_id, $consumable, $image_url_for_db, $product_url_for_db, $manufacturer_id, $product_designation_for_db, $is_household_item, $storage_location_id, $parent_article_id, $article_id); if ($stmt_update->execute()) { + // Alte Tags löschen + $stmt_del_tags = $conn->prepare("DELETE FROM article_tags WHERE article_id = ?"); + $stmt_del_tags->bind_param("i", $article_id); + $stmt_del_tags->execute(); + $stmt_del_tags->close(); + + // Neue Tags speichern + $tags_json = $_POST['tags'] ?? ''; + if (!empty($tags_json)) { + $tags_arr = json_decode($tags_json, true); + if (is_array($tags_arr)) { + foreach ($tags_arr as $t) { + $tag_name = trim($t['value']); + if (empty($tag_name)) continue; + + $stmt_check_tag = $conn->prepare("SELECT id FROM tags WHERE household_id = ? AND name = ?"); + $stmt_check_tag->bind_param("is", $household_id_for_user, $tag_name); + $stmt_check_tag->execute(); + $res = $stmt_check_tag->get_result(); + if ($res->num_rows > 0) { + $tag_id = $res->fetch_assoc()['id']; + } else { + $stmt_add_tag = $conn->prepare("INSERT INTO tags (household_id, name) VALUES (?, ?)"); + $stmt_add_tag->bind_param("is", $household_id_for_user, $tag_name); + $stmt_add_tag->execute(); + $tag_id = $conn->insert_id; + $stmt_add_tag->close(); + } + $stmt_check_tag->close(); + + $stmt_link_tag = $conn->prepare("INSERT IGNORE INTO article_tags (article_id, tag_id) VALUES (?, ?)"); + $stmt_link_tag->bind_param("ii", $article_id, $tag_id); + $stmt_link_tag->execute(); + $stmt_link_tag->close(); + } + } + } + if ($household_id_for_user) { $log_message = htmlspecialchars($_SESSION['username']) . " hat den Artikel '" . htmlspecialchars($name) . "' bearbeitet."; log_household_action($conn, $household_id_for_user, $current_user_id, $log_message); @@ -264,9 +322,28 @@ $conn->close(); - - - + + + +
@@ -310,6 +387,7 @@ $conn->close();
>
+

>
@@ -445,6 +523,20 @@ document.addEventListener('DOMContentLoaded', function() { if(document.getElementById('category_id')) new TomSelect('#category_id', tsOptions); if(document.getElementById('parent_article_id')) new TomSelect('#parent_article_id', tsOptions); if(document.getElementById('storage_location_id')) new TomSelect('#storage_location_id', tsOptions); + + // Initialize Tagify + const tagInput = document.querySelector('input[name="tags"]'); + if(tagInput) { + new Tagify(tagInput, { + whitelist: , + dropdown: { + maxItems: 20, + classname: "tags-look", + enabled: 0, + closeOnSelect: false + } + }); + } });