Feature: Editierbare ToDo-Listen, Artikel-Kachelansicht, gefilterter Export und Haushalts-Namen Fix
All checks were successful
Docker Build & Push / build-and-push (push) Successful in 38s

This commit is contained in:
Gemini
2026-05-14 17:58:38 +00:00
parent ca79ab41b5
commit 1847528363
5 changed files with 225 additions and 26 deletions

View File

@@ -206,4 +206,7 @@ Das Projekt basiert auf bewährten Web-Standards:
* JavaScript-Standardmeldungen (`confirm()`) für das Löschen von Artikeln in Phase 1 durch moderne Bootstrap-Modals ersetzt.
* Styling der aktiven ToDo-Liste verbessert (grüner Hintergrund statt schwarzem Rand).
* Fatal Error auf der Hilfe-Seite (`help.php`) beim Aufruf ohne bestehende DB-Verbindung behoben.
* CSV-Export Funktion für alle Artikel im Bestand hinzugefügt.
* CSV-Export Funktion für alle Artikel im Bestand hinzugefügt (mit Abfrage, ob alle oder nur aktuell gefilterte exportiert werden sollen).
* Neue Kachelansicht für die Artikel-Übersicht inklusive Toggle-Button zwischen Listen- und Kachelansicht.
* Bearbeiten-Funktion für bestehende ToDo-Listen Titel und deren Einträge implementiert.
* Anzeige in der Haushaltsverwaltung nutzt nun den Anzeigenamen (Display Name) anstatt des Loginnamens, sofern vorhanden.

View File

@@ -128,7 +128,7 @@ $conn->close();
<div class="card-header d-flex justify-content-between align-items-center">
<h2 class="h4 mb-0"><i class="fas fa-boxes me-2"></i>Artikel im Haushalt</h2>
<div>
<a href="export_articles.php" class="btn btn-sm btn-outline-success me-2"><i class="fas fa-file-export me-2"></i>Export (CSV)</a>
<button type="button" class="btn btn-sm btn-outline-success me-2" data-bs-toggle="modal" data-bs-target="#exportModal"><i class="fas fa-file-export me-2"></i>Export (CSV)</button>
<a href="add_article.php" class="btn btn-sm btn-outline-light"><i class="fas fa-plus me-2"></i>Neuen Artikel hinzufügen</a>
</div>
</div>
@@ -141,6 +141,10 @@ $conn->close();
<div class="col-md-3"><select id="filter-category" class="form-select form-select-sm"><option value="">Alle Kategorien</option><?php foreach($categories_for_filter as $cat) echo '<option value="'.$cat['id'].'">'.htmlspecialchars($cat['name']).'</option>'; ?></select></div>
<div class="col-md-3"><select id="filter-manufacturer" class="form-select form-select-sm"><option value="">Alle Hersteller</option><?php foreach($manufacturers_for_filter as $man) echo '<option value="'.$man['id'].'">'.htmlspecialchars($man['name']).'</option>'; ?></select></div>
<div class="col-md-2 text-end">
<div class="btn-group btn-group-sm me-2" role="group">
<button type="button" class="btn btn-outline-secondary active" id="btn-view-list" title="Listenansicht"><i class="fas fa-list"></i></button>
<button type="button" class="btn btn-outline-secondary" id="btn-view-grid" title="Kachelansicht"><i class="fas fa-th"></i></button>
</div>
<div class="btn-group btn-group-sm" role="group">
<button type="button" class="btn btn-outline-secondary" id="btn-expand-all" title="Alle ausklappen"><i class="fas fa-expand-alt"></i></button>
<button type="button" class="btn btn-outline-secondary" id="btn-collapse-all" title="Alle einklappen"><i class="fas fa-compress-alt"></i></button>
@@ -149,7 +153,7 @@ $conn->close();
</div>
</div>
<div class="table-responsive" style="max-height: 75vh;">
<div id="view-container-list" class="table-responsive" style="max-height: 75vh;">
<table class="table table-hover mb-0">
<thead>
<tr>
@@ -171,9 +175,34 @@ $conn->close();
</tbody>
</table>
</div>
<div id="view-container-grid" class="p-3" style="max-height: 75vh; overflow-y: auto; display: none;">
<div class="row g-3" id="articlesGridBody"></div>
</div>
</div>
</div>
<div class="modal fade" id="exportModal" tabindex="-1" aria-labelledby="exportModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="exportModalLabel">Artikel exportieren</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
Möchten Sie alle Artikel im Haushalt exportieren oder nur die aktuell gefilterten Ergebnisse?
</div>
<div class="modal-footer">
<form action="export_articles.php" method="POST" id="exportForm" class="m-0">
<input type="hidden" name="export_ids" id="export_ids_input" value="">
<button type="submit" name="export_type" value="all" class="btn btn-outline-secondary" onclick="document.getElementById('exportModal').querySelector('.btn-close').click();">Alle Artikel</button>
<button type="submit" name="export_type" value="filtered" class="btn btn-success" onclick="document.getElementById('exportModal').querySelector('.btn-close').click();">Nur Gefilterte</button>
</form>
</div>
</div>
</div>
</div>
<div class="modal fade" id="imageModal" tabindex="-1" aria-labelledby="imageModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-centered">
<div class="modal-content">
@@ -216,6 +245,7 @@ document.addEventListener('DOMContentLoaded', function () {
const filterManufacturer = document.getElementById('filter-manufacturer');
const collapsedCategories = new Set();
let currentView = 'list';
// Initialize collapsed state based on user setting
if (collapseDefault) {
@@ -226,6 +256,23 @@ document.addEventListener('DOMContentLoaded', function () {
uniqueCategories.forEach(c => collapsedCategories.add(c));
}
document.getElementById('btn-view-list').addEventListener('click', function() {
currentView = 'list';
this.classList.add('active');
document.getElementById('btn-view-grid').classList.remove('active');
document.getElementById('view-container-list').style.display = '';
document.getElementById('view-container-grid').style.display = 'none';
renderTable();
});
document.getElementById('btn-view-grid').addEventListener('click', function() {
currentView = 'grid';
this.classList.add('active');
document.getElementById('btn-view-list').classList.remove('active');
document.getElementById('view-container-list').style.display = 'none';
document.getElementById('view-container-grid').style.display = '';
renderTable();
});
function renderTable() {
const textValue = filterText.value.toLowerCase();
const categoryValue = filterCategory.value;
@@ -247,6 +294,16 @@ document.addEventListener('DOMContentLoaded', function () {
return false;
});
// Update Export Modal
const exportIds = filteredList.flatMap(a => {
const ids = [a.id];
if (a.children) {
a.children.forEach(c => ids.push(c.id));
}
return ids;
});
document.getElementById('export_ids_input').value = exportIds.join(',');
const groupedArticles = {};
filteredList.forEach(article => {
const catName = article.category_name || 'Ohne Kategorie';
@@ -261,9 +318,11 @@ document.addEventListener('DOMContentLoaded', function () {
});
let tableHTML = '';
let gridHTML = '';
if (sortedCategories.length === 0) {
tableHTML = '<tr><td colspan="12" class="text-center text-muted p-4">Keine Artikel gefunden.</td></tr>';
gridHTML = '<div class="col-12 text-center text-muted p-4">Keine Artikel gefunden.</div>';
} else {
sortedCategories.forEach(catName => {
const items = groupedArticles[catName];
@@ -281,15 +340,70 @@ document.addEventListener('DOMContentLoaded', function () {
if (!isCollapsed) {
items.forEach(article => {
tableHTML += generateRowHTML(article);
gridHTML += generateCardHTML(article);
if (article.children.length > 0) {
article.children.forEach(child => {
gridHTML += generateCardHTML(child);
});
}
});
}
});
}
tableBody.innerHTML = tableHTML;
document.getElementById('articlesGridBody').innerHTML = gridHTML;
initializeInteractivity();
}
function generateCardHTML(article) {
const imagePath = article.image_url ? article.image_url : 'assets/images/keinbild.png';
const productLink = article.product_url ? `<a href="${article.product_url}" target="_blank" class="btn btn-sm btn-outline-secondary" data-bs-toggle="tooltip" title="Produktseite öffnen"><i class="fas fa-external-link-alt"></i></a>` : '';
const householdBadge = article.household_id ? `<span class="badge bg-success" data-bs-toggle="tooltip" title="Für den Haushalt freigegeben"><i class="fas fa-users"></i></span>` : `<span class="badge bg-secondary" data-bs-toggle="tooltip" title="Privater Artikel"><i class="fas fa-user"></i></span>`;
const consumableIcon = article.consumable == 1 ? ` <i class="fas fa-cookie-bite text-warning" data-bs-toggle="tooltip" title="Verbrauchsartikel"></i>` : '';
const quantityBadge = article.consumable == 1 ? `<span class="badge bg-secondary rounded-pill" data-bs-toggle="tooltip" title="Verbrauchsartikel">&infin;</span>` : `<span class="badge bg-secondary rounded-pill">${article.quantity_owned} x</span>`;
let actionButtons = '';
const isOwner = (article.user_id == currentUserId);
const isHouseholdArticle = (article.household_id && article.household_id == currentUserHouseholdId);
if (isOwner || isHouseholdArticle) {
actionButtons = `<div class="btn-group">
<a href="edit_article.php?id=${article.id}" class="btn btn-sm btn-outline-primary" data-bs-toggle="tooltip" title="Bearbeiten"><i class="fas fa-edit"></i></a>`;
actionButtons += `<form action="articles.php" method="post" class="d-inline" onsubmit="return confirm('Sind Sie sicher?');"><input type="hidden" name="delete_article_id" value="${article.id}"><button type="submit" class="btn btn-sm btn-outline-danger" data-bs-toggle="tooltip" title="Löschen"><i class="fas fa-trash"></i></button></form>`;
actionButtons += `</div>`;
}
return `
<div class="col-12 col-sm-6 col-md-4 col-lg-3">
<div class="card h-100 shadow-sm position-relative border-0 article-card">
<img src="${imagePath}" class="card-img-top article-image-trigger" data-preview-url="${imagePath}" alt="${article.name}" style="height: 180px; object-fit: cover; cursor: pointer; border-radius: calc(.25rem - 1px) calc(.25rem - 1px) 0 0;">
<div class="position-absolute top-0 end-0 p-2">
${householdBadge} ${consumableIcon}
</div>
<div class="card-body d-flex flex-column p-3">
<h5 class="card-title text-truncate mb-2" title="${article.name}">${article.name}</h5>
<div class="mb-2">
<span class="badge bg-light text-dark border"><i class="fas fa-weight-hanging me-1"></i> ${new Intl.NumberFormat('de-DE').format(article.weight_grams)} g</span>
${quantityBadge}
</div>
<div class="mb-3">
<span class="badge bg-info text-dark border">${article.category_name || 'Ohne Kat.'}</span>
</div>
<p class="card-text small text-muted mb-1 mt-auto" style="line-height: 1.2;">
${article.manufacturer_name ? `<strong>Hersteller:</strong> ${article.manufacturer_name}<br>` : ''}
${article.product_designation ? `<strong>Modell:</strong> ${article.product_designation}` : ''}
</p>
</div>
<div class="card-footer bg-light border-top d-flex justify-content-between align-items-center p-2">
${productLink}
${actionButtons}
</div>
</div>
</div>
`;
}
function generateRowHTML(article, level = 0) {
let html = '';
const imagePath = article.image_url ? article.image_url : 'assets/images/keinbild.png';

View File

@@ -37,6 +37,21 @@ if ($current_user_household_id) {
$placeholders = implode(',', array_fill(0, count($household_member_ids), '?'));
$types = str_repeat('i', count($household_member_ids));
$where_clause = "(a.user_id IN ($placeholders) OR a.household_id = ?)";
$all_params = array_merge($household_member_ids, [$current_user_household_id]);
$all_types = $types . 'i';
if (isset($_POST['export_type']) && $_POST['export_type'] == 'filtered' && !empty($_POST['export_ids'])) {
$ids = explode(',', $_POST['export_ids']);
$valid_ids = array_map('intval', $ids);
if (count($valid_ids) > 0) {
$id_placeholders = implode(',', array_fill(0, count($valid_ids), '?'));
$where_clause .= " AND a.id IN ($id_placeholders)";
$all_params = array_merge($all_params, $valid_ids);
$all_types .= str_repeat('i', count($valid_ids));
}
}
$sql = "SELECT
a.name AS 'Artikelname',
a.product_designation AS 'Produktbezeichnung',
@@ -55,7 +70,7 @@ $sql = "SELECT
LEFT JOIN manufacturers m ON a.manufacturer_id = m.id
LEFT JOIN storage_locations l2 ON a.storage_location_id = l2.id
LEFT JOIN storage_locations l1 ON l2.parent_id = l1.id
WHERE a.user_id IN ($placeholders) OR a.household_id = ?
WHERE $where_clause
ORDER BY c.name ASC, a.name ASC";
$stmt = $conn->prepare($sql);
@@ -63,8 +78,6 @@ if ($stmt === false) {
die("Datenbankfehler.");
}
$all_params = array_merge($household_member_ids, [$current_user_household_id]);
$all_types = $types . 'i';
$stmt->bind_param($all_types, ...$all_params);
$stmt->execute();
$result = $stmt->get_result();

View File

@@ -46,12 +46,12 @@ if ($household_id) {
// Mitglieder und deren Statistiken laden
$stmt_members = $conn->prepare("
SELECT
u.id, u.username,
u.id, COALESCE(NULLIF(u.display_name, ''), u.username) AS username,
(SELECT COUNT(*) FROM articles WHERE user_id = u.id) as article_count,
(SELECT COUNT(*) FROM packing_lists WHERE user_id = u.id) as list_count
FROM users u
WHERE u.household_id = ?
ORDER BY u.username
ORDER BY username
");
$stmt_members->bind_param("i", $household_id);
$stmt_members->execute();
@@ -59,7 +59,7 @@ if ($household_id) {
$stmt_members->close();
// Eingeladene Mitglieder laden
$stmt_pending = $conn->prepare("SELECT u.username FROM household_invitations hi JOIN users u ON hi.invited_user_id = u.id WHERE hi.household_id = ? AND hi.status = 'pending'");
$stmt_pending = $conn->prepare("SELECT COALESCE(NULLIF(u.display_name, ''), u.username) AS username FROM household_invitations hi JOIN users u ON hi.invited_user_id = u.id WHERE hi.household_id = ? AND hi.status = 'pending'");
$stmt_pending->bind_param("i", $household_id);
$stmt_pending->execute();
$pending_invitations = $stmt_pending->get_result()->fetch_all(MYSQLI_ASSOC);

View File

@@ -58,6 +58,24 @@ if ($_SERVER["REQUEST_METHOD"] == "POST") {
$stmt->bind_param("i", $item_id);
$stmt->execute();
$stmt->close();
} elseif (isset($_POST['edit_list'])) {
$list_id = intval($_POST['list_id']);
$name = trim($_POST['list_name']);
if (!empty($name)) {
$stmt = $conn->prepare("UPDATE todo_lists SET name = ? WHERE id = ? AND (user_id = ? OR household_id = ?)");
$stmt->bind_param("siii", $name, $list_id, $current_user_id, $current_user_household_id);
$stmt->execute();
$stmt->close();
}
} elseif (isset($_POST['edit_item'])) {
$item_id = intval($_POST['item_id']);
$title = trim($_POST['item_title']);
if (!empty($title)) {
$stmt = $conn->prepare("UPDATE todo_items SET title = ? WHERE id = ?");
$stmt->bind_param("si", $title, $item_id);
$stmt->execute();
$stmt->close();
}
} elseif (isset($_POST['toggle_item'])) {
$item_id = intval($_POST['item_id']);
$status = isset($_POST['status']) ? intval($_POST['status']) : 0;
@@ -131,28 +149,57 @@ $active_list_id = isset($_GET['list_id']) ? intval($_GET['list_id']) : (!empty($
foreach ($todo_lists as $l) if ($l['id'] == $active_list_id) $active_list_name = $l['name'];
?>
<div class="card h-100 border-0 shadow-sm">
<div class="card-header bg-white border-bottom">
<h5 class="mb-0 py-2"><i class="fas fa-tasks me-2 text-muted"></i><?php echo htmlspecialchars($active_list_name); ?></h5>
<div class="card-header bg-white border-bottom" id="list-title-container-<?php echo $active_list_id; ?>">
<h5 class="mb-0 py-2 d-flex justify-content-between align-items-center w-100">
<span><i class="fas fa-tasks me-2 text-muted"></i><span id="list-name-text-<?php echo $active_list_id; ?>"><?php echo htmlspecialchars($active_list_name); ?></span></span>
<button class="btn btn-sm btn-link text-secondary" onclick="editListTitle(<?php echo $active_list_id; ?>)"><i class="fas fa-edit"></i></button>
</h5>
</div>
<div class="card-header bg-white border-bottom" id="edit-list-form-container-<?php echo $active_list_id; ?>" style="display:none;">
<form method="post" id="edit-list-form-<?php echo $active_list_id; ?>" class="w-100 py-1 m-0">
<div class="input-group">
<input type="hidden" name="list_id" value="<?php echo $active_list_id; ?>">
<input type="text" name="list_name" class="form-control" id="edit-list-input-<?php echo $active_list_id; ?>" value="<?php echo htmlspecialchars($active_list_name); ?>">
<button type="submit" name="edit_list" class="btn btn-success"><i class="fas fa-check"></i></button>
<button type="button" class="btn btn-secondary" onclick="cancelEditListTitle(<?php echo $active_list_id; ?>)"><i class="fas fa-times"></i></button>
</div>
</form>
</div>
<div class="card-body p-4">
<ul class="list-group shadow-sm mb-4">
<?php foreach ($items as $item): ?>
<li class="list-group-item d-flex justify-content-between align-items-center py-3 px-2 border-bottom">
<form method="post" style="display:inline; margin:0;" class="flex-grow-1">
<input type="hidden" name="item_id" value="<?php echo $item['id']; ?>">
<input type="hidden" name="list_id" value="<?php echo $active_list_id; ?>">
<div class="form-check d-flex align-items-center mb-0">
<input class="form-check-input me-3" type="checkbox" onChange="this.form.submit()" name="status" value="<?php echo $item['is_completed'] ? '0' : '1'; ?>" <?php echo $item['is_completed'] ? 'checked' : ''; ?> style="width:1.5em; height:1.5em; cursor:pointer;">
<input type="hidden" name="toggle_item" value="1">
<label class="form-check-label <?php echo $item['is_completed'] ? 'text-decoration-line-through text-muted' : ''; ?>" style="cursor:pointer; font-size:1.1em; width:100%; margin-top:2px;">
<?php echo htmlspecialchars($item['title']); ?>
</label>
<li class="list-group-item py-3 px-2 border-bottom" id="item-container-<?php echo $item['id']; ?>">
<div class="d-flex justify-content-between align-items-center w-100">
<form method="post" style="display:inline; margin:0;" class="flex-grow-1">
<input type="hidden" name="item_id" value="<?php echo $item['id']; ?>">
<input type="hidden" name="list_id" value="<?php echo $active_list_id; ?>">
<div class="form-check d-flex align-items-center mb-0">
<input class="form-check-input me-3" type="checkbox" onChange="this.form.submit()" name="status" value="<?php echo $item['is_completed'] ? '0' : '1'; ?>" <?php echo $item['is_completed'] ? 'checked' : ''; ?> style="width:1.5em; height:1.5em; cursor:pointer;">
<input type="hidden" name="toggle_item" value="1">
<label class="form-check-label <?php echo $item['is_completed'] ? 'text-decoration-line-through text-muted' : ''; ?>" style="cursor:pointer; font-size:1.1em; width:100%; margin-top:2px;">
<?php echo htmlspecialchars($item['title']); ?>
</label>
</div>
</form>
<div>
<button class="btn btn-sm btn-link text-secondary" onclick="editItemTitle(<?php echo $item['id']; ?>)"><i class="fas fa-edit"></i></button>
<form method="post" style="display:inline;" onsubmit="return confirm('Eintrag wirklich löschen?');">
<input type="hidden" name="item_id" value="<?php echo $item['id']; ?>">
<input type="hidden" name="list_id" value="<?php echo $active_list_id; ?>">
<button type="submit" name="delete_item" class="btn btn-sm btn-link text-danger p-0 ms-2" title="Punkt entfernen"><i class="fas fa-times"></i></button>
</form>
</div>
</div>
</li>
<li class="list-group-item py-3 px-2 border-bottom" id="edit-item-container-<?php echo $item['id']; ?>" style="display:none;">
<form method="post" class="w-100 m-0">
<div class="input-group">
<input type="hidden" name="item_id" value="<?php echo $item['id']; ?>">
<input type="hidden" name="list_id" value="<?php echo $active_list_id; ?>">
<input type="text" name="item_title" class="form-control" id="edit-item-input-<?php echo $item['id']; ?>" value="<?php echo htmlspecialchars($item['title']); ?>">
<button type="submit" name="edit_item" class="btn btn-success"><i class="fas fa-check"></i></button>
<button type="button" class="btn btn-secondary" onclick="cancelEditItemTitle(<?php echo $item['id']; ?>)"><i class="fas fa-times"></i></button>
</div>
</form>
<form method="post" style="display:inline;" class="ms-3">
<input type="hidden" name="item_id" value="<?php echo $item['id']; ?>">
<input type="hidden" name="list_id" value="<?php echo $active_list_id; ?>">
<button type="submit" name="delete_item" class="btn btn-sm btn-outline-danger" title="Punkt entfernen"><i class="fas fa-times"></i></button>
</form>
</li>
<?php endforeach; ?>
@@ -183,6 +230,28 @@ $active_list_id = isset($_GET['list_id']) ? intval($_GET['list_id']) : (!empty($
</div>
</div>
<script>
function editListTitle(id) {
document.getElementById('list-title-container-' + id).style.display = 'none';
document.getElementById('edit-list-form-container-' + id).style.display = 'block';
document.getElementById('edit-list-input-' + id).focus();
}
function cancelEditListTitle(id) {
document.getElementById('edit-list-form-container-' + id).style.display = 'none';
document.getElementById('list-title-container-' + id).style.display = 'block'; // Or flex, depending on original
document.getElementById('list-title-container-' + id).classList.add('d-flex');
}
function editItemTitle(id) {
document.getElementById('item-container-' + id).style.display = 'none';
document.getElementById('edit-item-container-' + id).style.display = 'block';
document.getElementById('edit-item-input-' + id).focus();
}
function cancelEditItemTitle(id) {
document.getElementById('edit-item-container-' + id).style.display = 'none';
document.getElementById('item-container-' + id).style.display = 'block';
}
</script>
<?php
require_once 'footer.php';
if (isset($conn) && $conn instanceof mysqli) {