feat: Anzeigenamen konsequent genutzt, Tag-Management, Tags und Besitzer in Packlisten-Editor
All checks were successful
Docker Build & Push / build-and-push (push) Successful in 8s

This commit is contained in:
AI Bot
2026-07-04 17:40:46 +00:00
parent 1fa7050bbe
commit 5b5f0e0a3c
7 changed files with 220 additions and 17 deletions

View File

@@ -233,6 +233,8 @@ Das Projekt basiert auf bewährten Web-Standards:
* **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.
* **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. Im Hauptmenü gibt es nun den Punkt "Tags", um diese zentral umzubenennen oder zu löschen.
* **Besitzer-Zuweisung:** Die Option "Für den gesamten Haushalt freigeben" wurde entfernt, da nun standardmäßig alle Artikel im Haushalt geteilt werden. Stattdessen kann nun jedem Artikel ein expliziter Besitzer (einzelnes Familienmitglied oder "Gemeinsam") zugewiesen werden. Die Artikelliste zeigt den Besitzer übersichtlich an.
* **Anzeigename:** In sämtlichen Bereichen der Anwendung (Listen, Dropdowns, Haushalt etc.) wird nun konsistent der Anzeigename (Display Name) statt des Login-Namens verwendet, sofern einer vergeben wurde.
* **Packlisten Editor:** In der Bearbeitungsansicht von Packlisten (Phase 1) werden im Lagerbestand nun ebenfalls die zugewiesenen Tags und Besitzer angezeigt. Zudem kann nach Tags gefiltert werden.
* **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.

View File

@@ -46,7 +46,7 @@ $placeholders = implode(',', array_fill(0, count($household_member_ids), '?'));
$types = str_repeat('i', count($household_member_ids));
// Lade alle Haushaltsmitglieder für das Besitzer-Dropdown
$stmt_all_members = $conn->prepare("SELECT id, username FROM users WHERE id IN ($placeholders) ORDER BY username ASC");
$stmt_all_members = $conn->prepare("SELECT id, COALESCE(NULLIF(display_name, ''), username) AS username FROM users WHERE id IN ($placeholders) ORDER BY username ASC");
$stmt_all_members->bind_param($types, ...$household_member_ids);
$stmt_all_members->execute();
$all_household_members = $stmt_all_members->get_result()->fetch_all(MYSQLI_ASSOC);

View File

@@ -82,7 +82,7 @@ $placeholders = implode(',', array_fill(0, count($household_member_ids), '?'));
$types = str_repeat('i', count($household_member_ids));
$sql = "SELECT
a.id, a.name, a.weight_grams, a.quantity_owned, a.product_url, a.consumable, a.image_url, a.user_id, a.parent_article_id,
u.username as creator_name, a.household_id, a.product_designation, a.owner_id, o.username as owner_name,
COALESCE(NULLIF(u.display_name, ''), u.username) as creator_name, a.household_id, a.product_designation, a.owner_id, COALESCE(NULLIF(o.display_name, ''), o.username) as owner_name,
c.id AS category_id, c.name AS category_name, c.color AS category_color,
m.id AS manufacturer_id, m.name AS manufacturer_name,
a.storage_location_id, l2.parent_id AS loc_parent_id, l2.name AS location_level2_name, l1.name AS location_level1_name

View File

@@ -147,7 +147,7 @@ if ($can_edit) {
$types = str_repeat('i', count($household_member_ids));
// Lade alle Haushaltsmitglieder für das Besitzer-Dropdown
$stmt_all_members = $conn->prepare("SELECT id, username FROM users WHERE id IN ($placeholders) ORDER BY username ASC");
$stmt_all_members = $conn->prepare("SELECT id, COALESCE(NULLIF(display_name, ''), username) AS username FROM users WHERE id IN ($placeholders) ORDER BY username ASC");
$stmt_all_members->bind_param($types, ...$household_member_ids);
$stmt_all_members->execute();
$all_household_members = $stmt_all_members->get_result()->fetch_all(MYSQLI_ASSOC);

View File

@@ -73,7 +73,8 @@ if (isset($_SESSION['user_id'])) {
<li class="nav-item"><a class="nav-link" href="storage_locations.php"><i class="fas fa-archive fa-fw"></i>Lagerorte</a></li>
<li class="nav-item"><a class="nav-link" href="todo_lists.php"><i class="fas fa-list-check fa-fw"></i>ToDo-Listen</a></li>
<li class="nav-item"><a class="nav-link" href="notes.php"><i class="fas fa-sticky-note fa-fw"></i>Notizen</a></li>
<li class="nav-item"><a class="nav-link" href="categories.php"><i class="fas fa-tags fa-fw"></i>Kategorien</a></li>
<li class="nav-item"><a class="nav-link" href="categories.php"><i class="fas fa-list fa-fw"></i>Kategorien</a></li>
<li class="nav-item"><a class="nav-link" href="tags.php"><i class="fas fa-hashtag fa-fw"></i>Tags</a></li>
<li class="nav-item"><a class="nav-link" href="manufacturers.php"><i class="fas fa-industry fa-fw"></i>Hersteller</a></li>
<li class="nav-item"><a class="nav-link" href="household.php"><i class="fas fa-users-cog fa-fw"></i>Haushalt</a></li>
<li class="nav-item"><a class="nav-link" href="user_profile.php"><i class="fas fa-user-cog fa-fw"></i>Profil</a></li>

View File

@@ -69,25 +69,46 @@ if ($current_user_household_id) {
$placeholders = implode(',', array_fill(0, count($household_member_ids), '?'));
$types = str_repeat('i', count($household_member_ids));
$sql_all_articles = "SELECT a.id, a.name, a.weight_grams, a.quantity_owned, a.product_designation, a.consumable, a.parent_article_id, a.image_url, c.name as category_name, m.name as manufacturer_name, sl.name as storage_location_name
$sql_all_articles = "SELECT a.id, a.name, a.weight_grams, a.quantity_owned, a.product_designation, a.consumable, a.parent_article_id, a.image_url, a.owner_id, c.name as category_name, m.name as manufacturer_name, sl.name as storage_location_name, COALESCE(NULLIF(o.display_name, ''), o.username) as owner_name
FROM articles a
LEFT JOIN categories c ON a.category_id = c.id
LEFT JOIN manufacturers m ON a.manufacturer_id = m.id
LEFT JOIN storage_locations sl ON a.storage_location_id = sl.id
WHERE a.user_id IN ($placeholders)";
$all_params = $household_member_ids;
$all_types = $types;
if ($current_user_household_id) {
$sql_all_articles .= " OR a.household_id = ?";
$all_params[] = $current_user_household_id;
$all_types .= 'i';
}
$stmt_all_articles = $conn->prepare($sql_all_articles);
LEFT JOIN users o ON a.owner_id = o.id
WHERE (a.user_id IN ($placeholders)
$stmt_all_articles->bind_param($all_types, ...$all_params);
$stmt_all_articles->execute();
$all_articles_raw = $stmt_all_articles->get_result()->fetch_all(MYSQLI_ASSOC);
$stmt_all_articles->close();
// Lade Tags für all_articles
$tags_by_article = [];
$article_ids = array_column($all_articles_raw, '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 ($all_articles_raw as &$article) {
$article['tags'] = $tags_by_article[$article['id']] ?? [];
}
unset($article);
$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();
$sql_items = "SELECT pli.id, pli.article_id, pli.quantity, pli.parent_packing_list_item_id, pli.carrier_user_id, pli.backpack_id, pli.backpack_compartment_id,
COALESCE(
a.name,
@@ -171,7 +192,8 @@ $conn->close();
border: 1px solid #eee; border-radius: 8px; padding: 6px;
text-align: center; background: #fff; display: flex; flex-direction: column;
transition: transform 0.1s, box-shadow 0.1s;
height: 205px;
height: 235px;
position: relative;
}
.lager-card:hover { transform: translateY(-2px); box-shadow: 0 4px 8px rgba(0,0,0,0.05); }
.lager-img-wrapper {
@@ -263,6 +285,7 @@ $conn->close();
<div class="row g-2">
<div class="col-12"><input type="text" id="filter-text" class="form-control form-control-sm" placeholder="Artikel suchen..."></div>
<div class="col-sm-6 col-md-3"><select id="filter-category" class="form-select form-select-sm"><option value="">-- Kategorien --</option><?php foreach($categories as $c) echo '<option>'.htmlspecialchars($c).'</option>'; ?></select></div>
<div class="col-sm-6 col-md-3"><select id="filter-tag" class="form-select form-select-sm"><option value="">-- Tags --</option><?php foreach($all_filter_tags as $t) echo '<option value="'.htmlspecialchars($t['name']).'">'.htmlspecialchars($t['name']).'</option>'; ?></select></div>
<div class="col-sm-6 col-md-3"><select id="filter-manufacturer" class="form-select form-select-sm"><option value="">-- Hersteller --</option><?php foreach($manufacturers as $m) echo '<option>'.htmlspecialchars($m).'</option>'; ?></select></div>
<div class="col-sm-6 col-md-3"><select id="group-by" class="form-select form-select-sm"><option value="">Keine Gruppe</option><option value="category_name" selected>Kategorie</option><option value="manufacturer_name">Hersteller</option><option value="storage_location_name">Lagerort</option></select></div>
<div class="col-sm-6 col-md-3"><select id="sort-by" class="form-select form-select-sm"><option value="name_asc">Alphabet (A-Z)</option><option value="weight_asc" selected>Gewicht (Leicht->Schwer)</option><option value="weight_desc">Gewicht (Schwer->Leicht)</option><option value="quantity_desc">Anzahl (Viele->Wenige)</option></select></div>
@@ -416,6 +439,7 @@ $conn->close();
document.addEventListener('DOMContentLoaded', () => {
const tsOptions = { create: false, sortField: { field: "text", direction: "asc" }, allowEmptyOption: true, onChange: function() { this.input.dispatchEvent(new Event('change')); } };
new TomSelect('#filter-category', tsOptions);
if (document.getElementById('filter-tag')) new TomSelect('#filter-tag', tsOptions);
new TomSelect('#filter-manufacturer', tsOptions);
new TomSelect('#group-by', tsOptions);
new TomSelect('#sort-by', { create: false, allowEmptyOption: false, onChange: function() { this.input.dispatchEvent(new Event('change')); } });
@@ -462,6 +486,7 @@ $conn->close();
['input', 'change'].forEach(evt => {
document.getElementById('filter-text').addEventListener(evt, renderLager);
document.getElementById('filter-category').addEventListener(evt, renderLager);
if (document.getElementById('filter-tag')) document.getElementById('filter-tag').addEventListener(evt, renderLager);
document.getElementById('filter-manufacturer').addEventListener(evt, renderLager);
document.getElementById('group-by').addEventListener(evt, renderLager);
document.getElementById('sort-by').addEventListener(evt, renderLager);
@@ -515,6 +540,7 @@ $conn->close();
function renderLager() {
const filterText = document.getElementById('filter-text').value.toLowerCase();
const filterCategory = document.getElementById('filter-category').value;
const filterTag = document.getElementById('filter-tag') ? document.getElementById('filter-tag').value : '';
const filterManufacturer = document.getElementById('filter-manufacturer').value;
const groupBy = document.getElementById('group-by').value;
const sortBy = document.getElementById('sort-by').value;
@@ -549,9 +575,10 @@ $conn->close();
const matchesText = article.name.toLowerCase().includes(filterText) || (article.manufacturer_name && article.manufacturer_name.toLowerCase().includes(filterText)) || (article.product_designation && article.product_designation.toLowerCase().includes(filterText));
const matchesCategory = !filterCategory || article.category_name === filterCategory;
const matchesTag = !filterTag || (article.tags && article.tags.some(t => t.name === filterTag));
const matchesManufacturer = !filterManufacturer || article.manufacturer_name === filterManufacturer;
if (matchesText && matchesCategory && matchesManufacturer) {
if (matchesText && matchesCategory && matchesTag && matchesManufacturer) {
const aid = String(article.id);
const qtyTable = tableQuantities[aid] || 0;
const qtyBackpack = backpackQuantities[aid] || 0;
@@ -564,16 +591,30 @@ $conn->close();
const imgUrl = article.image_url ? article.image_url : 'assets/images/keinbild.png';
const plusBtnClass = qtyTable > 0 ? 'btn-success' : 'btn-outline-primary';
const ownerBadge = article.owner_id ? `<span class="badge bg-primary px-1 py-0"><i class="fas fa-user"></i> ${article.owner_name}</span>` : `<span class="badge bg-success px-1 py-0"><i class="fas fa-users"></i></span>`;
let tagsHtml = '';
if (article.tags && article.tags.length > 0) {
tagsHtml = '<div class="mt-1 d-flex flex-wrap justify-content-center gap-1" style="max-height: 20px; overflow: hidden;" title="' + article.tags.map(t=>t.name).join(', ') + '">';
article.tags.forEach(t => {
tagsHtml += `<span class="badge rounded-pill bg-light text-dark border border-secondary" style="font-size: 0.6em;">${t.name}</span>`;
});
tagsHtml += '</div>';
}
const metaText = [article.manufacturer_name, article.product_designation].filter(Boolean).join(' - ');
const cardHtml = `
<div class="lager-card" title="${article.name}${metaText ? ' (' + metaText + ')' : ''}">
<div class="position-absolute top-0 end-0 p-1" style="z-index: 10;">
${ownerBadge}
</div>
<div class="lager-img-wrapper">
<img src="${imgUrl}" class="lager-card-img">
</div>
<div class="lager-title">${article.name}</div>
<div class="lager-meta">${metaText}</div>
<small class="text-muted d-block mb-1" style="font-size:0.75em;">${article.weight_grams}g</small>
${tagsHtml}
<div class="lager-controls">
<button class="btn btn-sm btn-outline-secondary" onclick="adjustTable(${aid}, -1)" ${disableMinus ? 'disabled' : ''}><i class="fas fa-minus"></i></button>
<button class="btn btn-sm ${plusBtnClass}" onclick="triggerAddTable(${aid})" ${disablePlus ? 'disabled' : ''}><i class="fas fa-plus"></i></button>

159
src/tags.php Normal file
View File

@@ -0,0 +1,159 @@
<?php
// tags.php - Tags verwalten
$page_title = "Tags verwalten";
if (session_status() == PHP_SESSION_NONE) {
session_start();
}
if (!isset($_SESSION['user_id'])) {
header("Location: login.php");
exit;
}
require_once 'db_connect.php';
require_once 'household_actions.php';
require_once 'header.php';
$current_user_id = $_SESSION['user_id'];
$message = '';
$stmt_household = $conn->prepare("SELECT household_id FROM users WHERE id = ?");
$stmt_household->bind_param("i", $current_user_id);
$stmt_household->execute();
$household_id = $stmt_household->get_result()->fetch_assoc()['household_id'];
$stmt_household->close();
if (!$household_id) {
echo '<div class="container mt-4"><div class="alert alert-warning">Tags können nur in einem Haushalt verwaltet werden. Bitte gründe oder trete einem Haushalt bei.</div></div>';
require_once 'footer.php';
exit;
}
if ($_SERVER["REQUEST_METHOD"] == "POST") {
if (isset($_POST['edit_tag'])) {
$tag_id = intval($_POST['tag_id']);
$tag_name = trim($_POST['tag_name']);
if (!empty($tag_name) && $tag_id > 0) {
$stmt = $conn->prepare("UPDATE tags SET name = ? WHERE id = ? AND household_id = ?");
$stmt->bind_param("sii", $tag_name, $tag_id, $household_id);
if ($stmt->execute()) {
$log_message = htmlspecialchars($_SESSION['username']) . " hat den Tag '" . htmlspecialchars($tag_name) . "' bearbeitet.";
log_household_action($conn, $household_id, $current_user_id, $log_message);
$message = '<div class="alert alert-success" role="alert">Tag erfolgreich aktualisiert!</div>';
} else {
$message = '<div class="alert alert-danger" role="alert">Fehler beim Aktualisieren des Tags: ' . $stmt->error . '</div>';
}
$stmt->close();
}
}
}
elseif (isset($_GET['action']) && $_GET['action'] == 'delete' && isset($_GET['id'])) {
$tag_id = intval($_GET['id']);
$stmt_check = $conn->prepare("SELECT name FROM tags WHERE id = ? AND household_id = ?");
$stmt_check->bind_param("ii", $tag_id, $household_id);
$stmt_check->execute();
$res_check = $stmt_check->get_result();
if ($res_check->num_rows > 0) {
$tag_name = $res_check->fetch_assoc()['name'];
$stmt_delete = $conn->prepare("DELETE FROM tags WHERE id = ?");
$stmt_delete->bind_param("i", $tag_id);
if ($stmt_delete->execute()) {
$log_message = htmlspecialchars($_SESSION['username']) . " hat den Tag '" . htmlspecialchars($tag_name) . "' gelöscht.";
log_household_action($conn, $household_id, $current_user_id, $log_message);
$message = '<div class="alert alert-success" role="alert">Tag erfolgreich gelöscht!</div>';
} else {
$message = '<div class="alert alert-danger" role="alert">Fehler beim Löschen des Tags.</div>';
}
$stmt_delete->close();
} else {
$message = '<div class="alert alert-danger" role="alert">Sie sind nicht berechtigt, diesen Tag zu löschen.</div>';
}
$stmt_check->close();
}
$tags_query = $conn->prepare("SELECT id, name FROM tags WHERE household_id = ? ORDER BY name ASC");
$tags_query->bind_param("i", $household_id);
$tags_query->execute();
$tags_list = $tags_query->get_result()->fetch_all(MYSQLI_ASSOC);
$tags_query->close();
$conn->close();
?>
<div class="card">
<div class="card-header">
<h2 class="h4 mb-0"><i class="fas fa-hashtag me-2"></i>Tags im Haushalt</h2>
</div>
<div class="card-body p-4">
<?php if(!empty($message)) echo $message; ?>
<h5 class="mb-3">Bestehende Tags</h5>
<?php if (empty($tags_list)): ?>
<div class="alert alert-info text-center">Keine Tags gefunden. Tags können direkt beim Erstellen oder Bearbeiten von Artikeln hinzugefügt werden.</div>
<?php else: ?>
<div class="list-group shadow-sm">
<?php foreach ($tags_list as $tag): ?>
<div class="list-group-item d-flex justify-content-between align-items-center">
<div>
<span class="badge rounded-pill bg-light text-dark border border-secondary px-3 py-2 fw-normal" style="font-size: 0.9em;"><?php echo htmlspecialchars($tag['name']); ?></span>
</div>
<div class="btn-group">
<button type="button" class="btn btn-sm btn-outline-primary" title="Bearbeiten" data-bs-toggle="modal" data-bs-target="#editTagModal"
data-id="<?php echo htmlspecialchars($tag['id']); ?>" data-name="<?php echo htmlspecialchars($tag['name']); ?>">
<i class="fas fa-edit"></i>
</button>
<a href="tags.php?action=delete&id=<?php echo htmlspecialchars($tag['id']); ?>" class="btn btn-sm btn-outline-danger" title="Löschen" onclick="return confirm('Sind Sie sicher, dass Sie diesen Tag löschen möchten? Er wird von allen verknüpften Artikeln entfernt.')">
<i class="fas fa-trash-alt"></i>
</a>
</div>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
</div>
<div class="modal fade" id="editTagModal" tabindex="-1" aria-labelledby="editTagModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="editTagModalLabel">Tag bearbeiten</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<form action="<?php echo htmlspecialchars($_SERVER["PHP_SELF"]); ?>" method="post">
<div class="modal-body">
<input type="hidden" name="tag_id" id="edit_tag_id">
<div class="mb-3">
<label for="edit_tag_name" class="form-label">Tag Name</label>
<input type="text" class="form-control" id="edit_tag_name" name="tag_name" required>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Abbrechen</button>
<button type="submit" name="edit_tag" class="btn btn-primary">Änderungen speichern</button>
</div>
</form>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
var editModal = document.getElementById('editTagModal');
if (editModal) {
editModal.addEventListener('show.bs.modal', function (event) {
var button = event.relatedTarget;
var id = button.getAttribute('data-id');
var name = button.getAttribute('data-name');
var modalIdInput = editModal.querySelector('#edit_tag_id');
var modalNameInput = editModal.querySelector('#edit_tag_name');
if (modalIdInput) modalIdInput.value = id;
if (modalNameInput) modalNameInput.value = name;
});
}
});
</script>
<?php require_once 'footer.php'; ?>