Refactor packing list editor to Virtual Table workflow
All checks were successful
Docker Build & Push / build-and-push (push) Successful in 15s

- Created grid view for available articles (Lager) with +/- buttons.

- Added middle pane for staging items (Auf dem Tisch).

- Updated drag and drop sync to handle three phases.

- Added direct links to Phase 1 and Phase 2 from the packing list overview.
This commit is contained in:
Gemini Agent
2026-05-11 16:04:47 +00:00
parent a666f9ddb2
commit 862b567284
3 changed files with 385 additions and 288 deletions

View File

@@ -150,8 +150,7 @@ try {
case 'delete_item':
$item_id = intval($data['item_id']);
// Recursive Delete Logic using CTE if MySQL 8+ or manual recursion
// Manual recursion to be safe on older DBs:
// Recursive Delete Logic
$ids_to_delete = [$item_id];
$i = 0;
while($i < count($ids_to_delete)) {
@@ -167,27 +166,80 @@ try {
$i++;
}
// Delete all gathered IDs
$in_query = implode(',', array_fill(0, count($ids_to_delete), '?'));
$types = str_repeat('i', count($ids_to_delete));
$stmt_delete = $conn->prepare("DELETE FROM packing_list_items WHERE id IN ($in_query) AND packing_list_id = ?");
// Combine ID params and list ID
$delete_params = array_merge($ids_to_delete, [$packing_list_id]);
$delete_types = $types . 'i';
$stmt_delete->bind_param($delete_types, ...$delete_params);
$stmt_delete->execute();
$stmt_delete->close();
break;
case 'adjust_table_quantity':
$article_id = intval($data['article_id']);
$delta = intval($data['delta']); // 1 or -1
$include_children = !empty($data['include_children']);
function adjust_single($conn, $packing_list_id, $art_id, $delta) {
$stmt_find = $conn->prepare("SELECT id, quantity FROM packing_list_items WHERE packing_list_id = ? AND article_id = ? AND carrier_user_id IS NULL AND backpack_id IS NULL AND backpack_compartment_id IS NULL AND parent_packing_list_item_id IS NULL LIMIT 1");
$stmt_find->bind_param("ii", $packing_list_id, $art_id);
$stmt_find->execute();
$res = $stmt_find->get_result();
if ($row = $res->fetch_assoc()) {
$new_quantity = $row['quantity'] + $delta;
if ($new_quantity > 0) {
$stmt_update = $conn->prepare("UPDATE packing_list_items SET quantity = ? WHERE id = ?");
$stmt_update->bind_param("ii", $new_quantity, $row['id']);
$stmt_update->execute();
$stmt_update->close();
} else {
$stmt_del = $conn->prepare("DELETE FROM packing_list_items WHERE id = ?");
$stmt_del->bind_param("i", $row['id']);
$stmt_del->execute();
$stmt_del->close();
}
} else {
if ($delta > 0) {
$idx_res = $conn->query("SELECT MAX(order_index) as max_idx FROM packing_list_items WHERE packing_list_id = $packing_list_id");
$next_idx = ($idx_res->fetch_assoc()['max_idx'] ?? 0) + 1;
$stmt_insert = $conn->prepare("INSERT INTO packing_list_items (packing_list_id, article_id, quantity, order_index) VALUES (?, ?, ?, ?)");
$stmt_insert->bind_param("iiii", $packing_list_id, $art_id, $delta, $next_idx);
$stmt_insert->execute();
$stmt_insert->close();
}
}
$stmt_find->close();
}
adjust_single($conn, $packing_list_id, $article_id, $delta);
if ($include_children && $delta > 0) {
$stmt_children = $conn->prepare("SELECT id FROM articles WHERE parent_article_id = ?");
$stmt_children->bind_param("i", $article_id);
$stmt_children->execute();
$res_child = $stmt_children->get_result();
while($child = $res_child->fetch_assoc()) {
adjust_single($conn, $packing_list_id, $child['id'], $delta);
}
$stmt_children->close();
}
break;
case 'update_quantity':
$item_id = intval($data['item_id']);
$quantity = intval($data['quantity']);
if ($quantity > 0) {
$stmt = $conn->prepare("UPDATE packing_list_items SET quantity = ? WHERE id = ?");
$stmt->bind_param("ii", $quantity, $item_id); $stmt->execute(); $stmt->close();
$conn->commit(); echo json_encode(['success' => true]); exit;
$conn->commit(); echo json_encode(get_all_items($conn, $packing_list_id)); exit;
} else { throw new Exception('Ungültige Mengenangabe.'); }
break;
case 'get_items':
// Just returns the current state (handled at the end of script)
break;
default:
throw new Exception('Unbekannte Aktion angefordert.', 400);
}

View File

@@ -1,6 +1,6 @@
<?php
// manage_packing_list_items.php - Interaktive Verwaltung der Packlisten-Artikel
// FINALE, REKURSIVE NESTED-SORTABLE VERSION
// Refactored to "Virtual Table" workflow
if (session_status() == PHP_SESSION_NONE) {
session_start();
@@ -37,7 +37,13 @@ $is_in_same_household = ($is_household_list && $packing_list['household_id'] ==
if ($is_owner || $is_in_same_household) { $can_edit = true; }
if (!$can_edit) { die("Zugriff verweigert."); }
$page_title = "Packliste bearbeiten: " . htmlspecialchars($packing_list['name']);
$phase = isset($_GET['phase']) ? intval($_GET['phase']) : 0;
$col_class_lager = ($phase == 1) ? 'col-lg-6' : (($phase == 2) ? 'd-none' : 'col-lg-4');
$col_class_table = ($phase == 1 || $phase == 2) ? 'col-lg-6' : 'col-lg-4';
$col_class_rucksack = ($phase == 2) ? 'col-lg-6' : (($phase == 1) ? 'd-none' : 'col-lg-4');
$phase_title_suffix = ($phase == 1) ? " - Auswahl (Lager)" : (($phase == 2) ? " - Packen (Rucksack)" : "");
$page_title = "Packliste bearbeiten: " . htmlspecialchars($packing_list['name']) . $phase_title_suffix;
$household_member_ids = [$current_user_id];
if ($current_user_household_id) {
@@ -92,14 +98,12 @@ $packed_items_raw = $stmt_items->get_result()->fetch_all(MYSQLI_ASSOC);
$stmt_items->close();
$carriers_data = [];
// KORREKTUR: Nur Träger anzeigen, die dieser Liste zugewiesen sind
$stmt_carriers = $conn->prepare("SELECT u.id, u.username FROM users u JOIN packing_list_carriers plc ON u.id = plc.user_id WHERE plc.packing_list_id = ? ORDER BY u.username");
$stmt_carriers->bind_param("i", $packing_list_id);
$stmt_carriers->execute();
$carriers_result = $stmt_carriers->get_result();
while ($row = $carriers_result->fetch_assoc()) { $carriers_data[] = $row; }
$stmt_carriers->close();
$carriers_data[] = ['id' => null, 'username' => 'Sonstiges'];
$categories = array_unique(array_filter(array_column($all_articles_raw, 'category_name')));
sort($categories);
@@ -109,37 +113,131 @@ sort($manufacturers);
$conn->close();
?>
<script src="https://cdn.jsdelivr.net/npm/sortablejs@latest/Sortable.min.js"></script>
<!-- Tom Select CSS/JS -->
<link href="https://cdn.jsdelivr.net/npm/tom-select@2.2.2/dist/css/tom-select.bootstrap5.min.css" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/tom-select@2.2.2/dist/js/tom-select.complete.min.js"></script>
<div class="card">
<style>
.nested-sortable {
min-height: 10px;
padding-left: 20px;
border-left: 2px solid rgba(0,0,0,0.05);
margin-top: 5px;
}
.table-nested-sortable {
min-height: 100px;
padding: 10px;
background: rgba(0,0,0,0.02);
border: 1px dashed rgba(0,0,0,0.1);
border-radius: 6px;
}
.packed-item-container { margin-bottom: 5px; }
.packed-item-content {
display: flex; align-items: center; padding: 8px;
background-color: #fff; border: 1px solid rgba(0,0,0,0.1);
border-radius: 6px; transition: box-shadow 0.2s;
}
.packed-item-content:hover { box-shadow: 0 2px 5px rgba(0,0,0,0.05); }
.nested-sortable.sortable-ghost {
background-color: rgba(59, 74, 35, 0.1); border: 1px dashed var(--color-primary);
}
.nested-sortable:empty {
min-height: 30px; background-color: rgba(0,0,0,0.02);
border: 1px dashed rgba(0,0,0,0.1); border-radius: 4px; margin-top: 5px;
}
.backpack-root-item { background-color: #e8f5e9; border: 1px solid #2e7d32; }
.compartment-item { background-color: #f1f8e9; border-left: 3px solid #7cb342; }
/* Lager Grid */
.lager-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
gap: 12px; padding: 5px;
}
.lager-card {
border: 1px solid #eee; border-radius: 8px; padding: 12px;
text-align: center; background: #fff; display: flex; flex-direction: column;
transition: transform 0.1s, box-shadow 0.1s;
}
.lager-card:hover { transform: translateY(-2px); box-shadow: 0 4px 8px rgba(0,0,0,0.05); }
.lager-img-wrapper {
height: 80px; margin-bottom: 8px; display: flex;
align-items: center; justify-content: center;
}
.lager-img-wrapper img {
max-height: 100%; max-width: 100%; object-fit: contain; border-radius: 4px; cursor: pointer;
}
.lager-title {
font-size: 0.85em; font-weight: 600; margin-bottom: 4px;
line-height: 1.2; flex-grow: 1; word-wrap: break-word;
}
.lager-controls {
display: flex; justify-content: center; align-items: center; gap: 8px; margin-top: 8px;
}
.table-status-text {
font-size: 0.75em; color: #2e7d32; font-weight: 600;
min-height: 1.2em; margin-top: 6px; visibility: hidden;
}
.table-status-text.visible { visibility: visible; }
.editor-pane { display: flex; flex-direction: column; height: 100%; }
.pane-content { flex-grow: 1; overflow-y: auto; padding: 10px; }
</style>
<div class="card mb-3">
<div class="card-header d-flex justify-content-between align-items-center">
<h2 class="h4 mb-0"><i class="fas fa-edit me-2"></i>Packliste bearbeiten: <?php echo htmlspecialchars($packing_list['name']); ?></h2>
<a href="packing_list_detail.php?id=<?php echo $packing_list_id; ?>" class="btn btn-secondary"><i class="fas fa-eye me-2"></i>Zur Ansicht</a>
</div>
<div class="card-body">
<div class="editor-container">
<div class="editor-pane">
<div class="pane-header">
<h5 class="mb-0">Verfügbare Artikel</h5>
<div class="row g-2 mt-2">
<div class="col-12"><input type="text" id="filter-text" class="form-control form-control-sm" placeholder="Artikel, Hersteller, Modell suchen..."></div>
<div class="col-md-6"><select id="filter-category" class="form-select form-select-sm"><option value="">-- Alle Kategorien --</option><?php foreach($categories as $c) echo '<option>'.htmlspecialchars($c).'</option>'; ?></select></div>
<div class="col-md-6"><select id="filter-manufacturer" class="form-select form-select-sm"><option value="">-- Alle Hersteller --</option><?php foreach($manufacturers as $m) echo '<option>'.htmlspecialchars($m).'</option>'; ?></select></div>
</div>
</div>
<div class="pane-content" id="available-items-list"></div>
</div>
<div class="editor-pane">
<div class="pane-header"><h5 class="mb-0">Gepackte Artikel</h5></div>
<div class="pane-content" id="carriers-container"></div>
</div>
<h2 class="h4 mb-0"><i class="fas fa-edit me-2"></i><?php echo $page_title; ?></h2>
<div>
<?php if($phase == 1): ?>
<a href="manage_packing_list_items.php?id=<?php echo $packing_list_id; ?>&phase=2" class="btn btn-success"><i class="fas fa-arrow-right me-2"></i>Weiter zu Phase 2 (Packen)</a>
<?php elseif($phase == 2): ?>
<a href="manage_packing_list_items.php?id=<?php echo $packing_list_id; ?>&phase=1" class="btn btn-info text-white"><i class="fas fa-arrow-left me-2"></i>Zurück zu Phase 1 (Auswahl)</a>
<a href="packing_list_detail.php?id=<?php echo $packing_list_id; ?>" class="btn btn-secondary ms-2"><i class="fas fa-check me-2"></i>Fertig</a>
<?php else: ?>
<a href="packing_list_detail.php?id=<?php echo $packing_list_id; ?>" class="btn btn-secondary"><i class="fas fa-eye me-2"></i>Zur Ansicht</a>
<?php endif; ?>
</div>
</div>
</div>
<div class="row g-3" style="height: 75vh; min-height: 600px;">
<!-- LAGER -->
<div class="<?php echo $col_class_lager; ?> h-100">
<div class="card h-100">
<div class="card-header bg-light">
<h5 class="mb-2"><i class="fas fa-boxes me-2"></i>Lagerbestand</h5>
<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-6"><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-6"><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>
</div>
<div class="card-body pane-content" id="lager-container"></div>
</div>
</div>
<!-- TISCH -->
<div class="<?php echo $col_class_table; ?> h-100">
<div class="card h-100 border-info">
<div class="card-header bg-info text-white">
<h5 class="mb-0"><i class="fas fa-table me-2"></i>Auf dem Tisch</h5>
</div>
<div class="card-body pane-content table-nested-sortable" id="table-container" data-carrier-id="null">
<!-- Items dropped here get carrier_id=null -->
</div>
</div>
</div>
<!-- RUCKSACK -->
<div class="<?php echo $col_class_rucksack; ?> h-100">
<div class="card h-100 border-success">
<div class="card-header bg-success text-white">
<h5 class="mb-0"><i class="fas fa-backpack me-2"></i>Rucksäcke</h5>
</div>
<div class="card-body pane-content" id="carriers-container"></div>
</div>
</div>
</div>
<!-- Modals and Feedback remain unchanged -->
<div class="modal fade" id="includeChildrenModal" tabindex="-1" aria-labelledby="includeChildrenModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
@@ -148,12 +246,12 @@ $conn->close();
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
Dieser Artikel hat zugehörige Komponenten. Sollen diese ebenfalls zur Packliste hinzugefügt werden?
<ul id="children-list" class="list-group mt-2"></ul>
Dieser Artikel hat zugehörige Komponenten. Sollen diese ebenfalls auf den Tisch gelegt werden?
<ul id="children-list" class="list-group mt-2 mb-2"></ul>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" id="add-without-children">Nein, nur diesen Artikel</button>
<button type="button" class="btn btn-primary" id="add-with-children">Ja, alle hinzufügen</button>
<button type="button" class="btn btn-primary" id="add-with-children">Ja, alle auf den Tisch</button>
</div>
</div>
</div>
@@ -162,88 +260,22 @@ $conn->close();
<div id="save-feedback" class="save-feedback">Änderungen gespeichert!</div>
<div id="image-preview-tooltip" class="image-preview-tooltip"></div>
<style>
/* Nested Sortable Specific Styles */
.nested-sortable {
min-height: 10px;
padding-left: 20px; /* Indent child items */
border-left: 2px solid rgba(0,0,0,0.05);
margin-top: 5px;
}
.packed-item-container {
margin-bottom: 5px;
}
.packed-item-content {
display: flex;
align-items: center;
padding: 8px;
background-color: #fff;
border: 1px solid rgba(0,0,0,0.1);
border-radius: 6px;
transition: box-shadow 0.2s;
}
.packed-item-content:hover {
box-shadow: 0 2px 5px rgba(0,0,0,0.05);
}
/* Highlight drop targets */
.nested-sortable.sortable-ghost {
background-color: rgba(59, 74, 35, 0.1);
border: 1px dashed var(--color-primary);
}
/* Empty nested containers should have a hint of height to be droppable */
.nested-sortable:empty {
min-height: 30px;
background-color: rgba(0,0,0,0.02);
border: 1px dashed rgba(0,0,0,0.1);
border-radius: 4px;
margin-top: 5px;
}
.nested-sortable:empty::after {
content: 'Hier ablegen';
display: block;
text-align: center;
color: #aaa;
font-size: 0.8em;
padding-top: 5px;
}
/* Backpack & Compartment Styles */
.backpack-root-item {
background-color: #e8f5e9;
border: 1px solid #2e7d32;
}
.compartment-item {
background-color: #f1f8e9;
border-left: 3px solid #7cb342;
}
</style>
<script>
let packedItems = <?php echo json_encode($packed_items_raw); ?>;
const allArticles = <?php echo json_encode($all_articles_raw); ?>;
const carriers = <?php echo json_encode($carriers_data); ?>;
const packingListId = <?php echo $packing_list_id; ?>;
let sortableInstances = [];
let sortableInstances = {};
let childrenModal = null;
let pendingArticleIdForTable = null;
document.addEventListener('DOMContentLoaded', () => {
// Initialize Tom Select for filters with allowEmptyOption
const tsOptions = {
create: false,
sortField: { field: "text", direction: "asc" },
allowEmptyOption: true,
onChange: function(value) {
// Propagate change
this.input.dispatchEvent(new Event('change'));
}
};
const tsOptions = { create: false, sortField: { field: "text", direction: "asc" }, allowEmptyOption: true, onChange: function() { this.input.dispatchEvent(new Event('change')); } };
new TomSelect('#filter-category', tsOptions);
new TomSelect('#filter-manufacturer', tsOptions);
// Tooltip Logic
const tooltip = document.getElementById('image-preview-tooltip');
if (tooltip) {
// Delegation for dynamically added items
document.body.addEventListener('mouseover', e => {
if (e.target.classList.contains('article-image-trigger')) {
const url = e.target.getAttribute('data-preview-url');
@@ -255,77 +287,155 @@ $conn->close();
});
document.body.addEventListener('mousemove', e => {
if (tooltip.style.display === 'block') {
tooltip.style.left = e.pageX + 15 + 'px';
tooltip.style.top = e.pageY + 15 + 'px';
tooltip.style.left = e.pageX + 15 + 'px'; tooltip.style.top = e.pageY + 15 + 'px';
}
});
document.body.addEventListener('mouseout', e => {
if (e.target.classList.contains('article-image-trigger')) {
tooltip.style.display = 'none';
}
if (e.target.classList.contains('article-image-trigger')) tooltip.style.display = 'none';
});
}
['input', 'change'].forEach(evt => {
document.getElementById('filter-text').addEventListener(evt, renderAvailableItems);
document.getElementById('filter-category').addEventListener(evt, renderAvailableItems);
document.getElementById('filter-manufacturer').addEventListener(evt, renderAvailableItems);
document.getElementById('filter-text').addEventListener(evt, renderLager);
document.getElementById('filter-category').addEventListener(evt, renderLager);
document.getElementById('filter-manufacturer').addEventListener(evt, renderLager);
});
document.getElementById('table-container').addEventListener('click', handlePackedItemActions);
document.getElementById('carriers-container').addEventListener('click', handlePackedItemActions);
document.getElementById('table-container').addEventListener('change', handleQuantityChange);
document.getElementById('carriers-container').addEventListener('change', handleQuantityChange);
fullRender();
});
function fullRender() {
renderAvailableItems();
renderLager();
renderTable();
renderCarriersAndPackedItems();
}
function renderAvailableItems() {
function renderLager() {
const filterText = document.getElementById('filter-text').value.toLowerCase();
const filterCategory = document.getElementById('filter-category').value;
const filterManufacturer = document.getElementById('filter-manufacturer').value;
const availableListEl = document.getElementById('available-items-list');
const packedQuantities = {};
const tableQuantities = {};
const backpackQuantities = {};
packedItems.forEach(item => {
const aid = String(item.article_id);
packedQuantities[aid] = (packedQuantities[aid] || 0) + parseInt(item.quantity || 1, 10);
const qty = parseInt(item.quantity || 1, 10);
const isTable = (item.carrier_user_id == null && !item.backpack_id && !item.backpack_compartment_id && !item.parent_packing_list_item_id);
if (isTable) {
tableQuantities[aid] = (tableQuantities[aid] || 0) + qty;
} else {
backpackQuantities[aid] = (backpackQuantities[aid] || 0) + qty;
}
});
let html = '';
let html = '<div class="lager-grid">';
allArticles.forEach(article => {
if (article.parent_article_id) return;
const aid = String(article.id);
const packedQty = packedQuantities[aid] || 0;
const ownedQty = parseInt(article.quantity_owned || 1, 10);
const isConsumable = article.consumable == 1;
if (isConsumable || packedQty < ownedQty) {
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 matchesManufacturer = !filterManufacturer || article.manufacturer_name === filterManufacturer;
if (matchesText && matchesCategory && matchesManufacturer) {
const details = [article.manufacturer_name, article.product_designation].filter(Boolean).join(' - ');
const imgUrl = article.image_url ? article.image_url : 'assets/images/keinbild.png';
html += `
<div class="available-item-card d-flex align-items-center" data-article-id="${article.id}">
<img src="${imgUrl}" class="article-image-trigger me-2 rounded" style="width: 30px; height: 30px; object-fit: cover; cursor: pointer;" data-preview-url="${imgUrl}">
<div class="flex-grow-1">
${article.name}
<small class="text-muted d-block" style="line-height: 1;">(${details || '---'} | ${article.weight_grams}g)</small>
</div>
</div>`;
}
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 matchesManufacturer = !filterManufacturer || article.manufacturer_name === filterManufacturer;
if (matchesText && matchesCategory && matchesManufacturer) {
const aid = String(article.id);
const qtyTable = tableQuantities[aid] || 0;
const qtyBackpack = backpackQuantities[aid] || 0;
const qtyTotal = qtyTable + qtyBackpack;
const ownedQty = parseInt(article.quantity_owned || 1, 10);
const isConsumable = article.consumable == 1;
const disablePlus = !isConsumable && qtyTotal >= ownedQty;
const disableMinus = qtyTable <= 0;
const imgUrl = article.image_url ? article.image_url : 'assets/images/keinbild.png';
const plusBtnClass = qtyTable > 0 ? 'btn-success' : 'btn-outline-primary';
html += `
<div class="lager-card">
<div class="lager-img-wrapper">
<img src="${imgUrl}" class="article-image-trigger" data-preview-url="${imgUrl}">
</div>
<div class="lager-title">${article.name}</div>
<small class="text-muted d-block mb-1">${article.weight_grams}g</small>
<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>
</div>
<div class="table-status-text ${qtyTable > 0 ? 'visible' : ''}">
${qtyTable} auf dem Tisch
</div>
</div>`;
}
});
availableListEl.innerHTML = html;
html += '</div>';
document.getElementById('lager-container').innerHTML = html;
}
function triggerAddTable(articleId) {
const aidStr = String(articleId);
const childArticles = allArticles.filter(a => String(a.parent_article_id) === aidStr);
if (childArticles.length > 0) {
pendingArticleIdForTable = articleId;
if (!childrenModal) childrenModal = new bootstrap.Modal(document.getElementById('includeChildrenModal'));
document.getElementById('children-list').innerHTML = childArticles.map(c => `<li class="list-group-item py-1">${c.name} <small class="text-muted">(${c.weight_grams}g)</small></li>`).join('');
document.getElementById('add-with-children').onclick = () => {
childrenModal.hide();
adjustTable(pendingArticleIdForTable, 1, true);
};
document.getElementById('add-without-children').onclick = () => {
childrenModal.hide();
adjustTable(pendingArticleIdForTable, 1, false);
};
childrenModal.show();
} else {
adjustTable(articleId, 1, false);
}
}
function adjustTable(articleId, delta, includeChildren = false) {
sendApiRequest({ action: 'adjust_table_quantity', article_id: articleId, delta: delta, include_children: includeChildren })
.then(() => {
return fetch('api_packing_list_handler.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'get_items', packing_list_id: packingListId }) // Fallback to get items
});
})
.then(res => res.json())
.then(newItems => {
if(Array.isArray(newItems)) packedItems = newItems;
// Restore scroll positions
const panes = document.querySelectorAll('.pane-content');
const scrollMap = Array.from(panes).map(p => p.scrollTop);
fullRender();
Array.from(panes).forEach((p, i) => { if (scrollMap[i] !== undefined) p.scrollTop = scrollMap[i]; });
});
}
function renderTable() {
const container = document.getElementById('table-container');
container.innerHTML = '';
new Sortable(availableListEl, {
group: { name: 'nested', pull: 'clone', put: false },
const tableItems = packedItems.filter(item => item.carrier_user_id == null && !item.backpack_id && !item.backpack_compartment_id && !item.parent_packing_list_item_id);
renderRecursive(tableItems, container, packedItems);
if(sortableInstances['table']) sortableInstances['table'].destroy();
sortableInstances['table'] = new Sortable(container, {
group: 'nested',
animation: 150,
sort: false
handle: '.handle',
fallbackOnBody: true,
swapThreshold: 0.65,
ghostClass: 'sortable-ghost',
onEnd: function() { syncListState(); }
});
}
@@ -333,22 +443,21 @@ $conn->close();
const container = document.getElementById('carriers-container');
container.innerHTML = '';
sortableInstances.forEach(s => s.destroy());
sortableInstances = [];
Object.keys(sortableInstances).forEach(k => { if(k !== 'table') sortableInstances[k].destroy(); });
carriers.forEach(carrier => {
const carrierId = carrier.id === null ? 'null' : carrier.id;
const carrierId = carrier.id; // Sonstiges removed, so carrier.id is never null
const carrierDiv = document.createElement('div');
carrierDiv.className = 'carrier-box';
carrierDiv.innerHTML = `<div class="carrier-header"><h6>${carrier.username}</h6></div>`;
carrierDiv.className = 'carrier-box mb-3';
carrierDiv.innerHTML = `<div class="p-2 bg-light border rounded mb-2 fw-bold text-dark"><i class="fas fa-user me-2"></i>${carrier.username}</div>`;
const carrierRootList = document.createElement('div');
carrierRootList.className = 'carrier-list nested-sortable';
carrierRootList.className = 'carrier-list nested-sortable pb-3';
carrierRootList.dataset.carrierId = carrierId;
carrierDiv.appendChild(carrierRootList);
container.appendChild(carrierDiv);
const itemsForCarrier = packedItems.filter(item => (item.carrier_user_id === null ? 'null' : String(item.carrier_user_id)) == String(carrierId));
const itemsForCarrier = packedItems.filter(item => String(item.carrier_user_id) === String(carrierId));
const itemsById = itemsForCarrier.reduce((acc, item) => ({...acc, [item.id]: {...item, children: []}}), {});
const rootItems = [];
@@ -360,20 +469,41 @@ $conn->close();
}
});
renderRecursive(rootItems, carrierRootList);
initNestedSortable(carrierRootList);
renderRecursive(rootItems, carrierRootList, itemsById);
sortableInstances['carrier_'+carrierId] = new Sortable(carrierRootList, {
group: 'nested',
animation: 150,
handle: '.handle',
fallbackOnBody: true,
swapThreshold: 0.65,
ghostClass: 'sortable-ghost',
onEnd: function() { syncListState(); }
});
});
}
function renderRecursive(items, container) {
function renderRecursive(items, container, contextMap) {
items.forEach(item => {
// For flat items array from table, build children tree if needed, but table shouldn't have nested items naturally unless dragged.
// But if they are dragged out, they might keep children.
let children = item.children || [];
if (!item.children && Array.isArray(contextMap)) {
children = contextMap.filter(i => i.parent_packing_list_item_id === item.id);
}
const itemEl = createPackedItemDOM(item);
container.appendChild(itemEl);
const nestedContainer = itemEl.querySelector('.nested-sortable');
if (item.children && item.children.length > 0) {
renderRecursive(item.children, nestedContainer);
if (children && children.length > 0) {
renderRecursive(children, nestedContainer, contextMap);
}
initNestedSortable(nestedContainer);
sortableInstances['nested_'+item.id] = new Sortable(nestedContainer, {
group: 'nested', animation: 150, handle: '.handle',
fallbackOnBody: true, swapThreshold: 0.65, ghostClass: 'sortable-ghost',
onEnd: function() { syncListState(); }
});
});
}
@@ -386,36 +516,31 @@ $conn->close();
if(item.backpack_compartment_id) div.dataset.backpackCompartmentId = item.backpack_compartment_id;
let contentClass = 'packed-item-content';
let iconClass = 'fas fa-grip-vertical handle';
let iconClass = 'fas fa-grip-vertical handle text-secondary';
let nameDisplay = item.name;
let metaDisplay = item.manufacturer_name ? `<small class="text-muted">(${item.manufacturer_name})</small>` : '';
let controls = `
<input type="number" class="form-control form-control-sm quantity-input text-center mx-2" value="${item.quantity}" min="1">
<input type="number" class="form-control form-control-sm quantity-input text-center mx-2" value="${item.quantity}" min="1" style="width:60px;">
<button class="btn btn-sm btn-outline-danger remove-item-btn"><i class="fas fa-trash"></i></button>
`;
// Special styling for Containers
if (item.backpack_id) {
contentClass += ' backpack-root-item';
iconClass = 'fas fa-hiking handle me-2';
iconClass = 'fas fa-hiking handle me-2 text-success';
nameDisplay = `<strong>${item.name}</strong>`;
metaDisplay = '';
// Disable controls for structural items
controls = '';
metaDisplay = ''; controls = '';
} else if (item.backpack_compartment_id) {
contentClass += ' compartment-item';
iconClass = 'fas fa-folder-open handle me-2 text-success';
nameDisplay = `<span class="fw-bold text-dark">${item.name}</span>`;
metaDisplay = '';
// Disable controls for structural items
controls = '';
metaDisplay = ''; controls = '';
}
div.innerHTML = `
<div class="${contentClass}">
<i class="${iconClass}"></i>
<i class="${iconClass}" style="cursor:grab;"></i>
<span class="item-name ms-2">${nameDisplay} ${metaDisplay}</span>
<div class="item-controls">
<div class="item-controls ms-auto d-flex align-items-center">
${controls}
</div>
</div>
@@ -424,131 +549,43 @@ $conn->close();
return div;
}
function initNestedSortable(element) {
const s = new Sortable(element, {
group: 'nested',
animation: 150,
handle: '.handle',
fallbackOnBody: true,
swapThreshold: 0.65,
ghostClass: 'sortable-ghost',
onAdd: function (evt) {
if (evt.from.id === 'available-items-list') {
const articleId = evt.item.dataset.articleId;
const originalArticleData = allArticles.find(a => String(a.id) === articleId);
const newItemData = {
id: 'new-' + Date.now(),
article_id: articleId,
name: originalArticleData.name,
manufacturer_name: originalArticleData.manufacturer_name,
quantity: 1,
consumable: originalArticleData.consumable,
children: []
};
const newDOM = createPackedItemDOM(newItemData);
evt.item.replaceWith(newDOM);
initNestedSortable(newDOM.querySelector('.nested-sortable'));
const childArticles = allArticles.filter(a => String(a.parent_article_id) === articleId);
if (childArticles.length > 0) {
handleChildArticlesPrompt(childArticles, newDOM);
} else {
syncListState();
}
} else {
syncListState();
}
},
onEnd: function (evt) {
if (evt.from.id !== 'available-items-list') {
syncListState();
}
}
});
sortableInstances.push(s);
}
function handleChildArticlesPrompt(childArticles, parentElement) {
if (!childrenModal) childrenModal = new bootstrap.Modal(document.getElementById('includeChildrenModal'));
document.getElementById('children-list').innerHTML = childArticles.map(c => `<li class="list-group-item">${c.name}</li>`).join('');
document.getElementById('add-with-children').onclick = () => {
childrenModal.hide();
const nestedContainer = parentElement.querySelector('.nested-sortable');
childArticles.forEach(child => {
const childItemData = {
id: 'new-child-' + Date.now() + Math.random(),
article_id: child.id,
name: child.name,
manufacturer_name: child.manufacturer_name,
quantity: 1,
consumable: child.consumable,
children: []
};
const childDOM = createPackedItemDOM(childItemData);
nestedContainer.appendChild(childDOM);
initNestedSortable(childDOM.querySelector('.nested-sortable'));
});
syncListState();
};
document.getElementById('add-without-children').onclick = () => {
childrenModal.hide();
syncListState();
};
childrenModal.show();
}
function syncListState() {
const payload = { action: 'sync_list', list: [] };
function traverse(container, carrierId, parentId) {
Array.from(container.children).forEach(child => {
if (!child.classList.contains('packed-item-container')) return;
const pliId = child.dataset.itemId;
const articleId = child.dataset.articleId;
payload.list.push({
pli_id: pliId,
article_id: articleId,
carrier_id: carrierId,
parent_pli_id: parentId,
backpack_id: child.dataset.backpackId || null,
backpack_compartment_id: child.dataset.backpackCompartmentId || null
});
const nestedContainer = child.querySelector('.nested-sortable');
if (nestedContainer) traverse(nestedContainer, carrierId, pliId);
});
}
const tableList = document.getElementById('table-container');
if (tableList) traverse(tableList, 'null', null);
document.querySelectorAll('.carrier-list').forEach(rootList => {
const carrierId = rootList.dataset.carrierId;
function traverse(container, parentId) {
Array.from(container.children).forEach(child => {
if (!child.classList.contains('packed-item-container')) return;
const pliId = child.dataset.itemId;
const articleId = child.dataset.articleId;
payload.list.push({
pli_id: pliId,
article_id: articleId,
carrier_id: carrierId,
parent_pli_id: parentId,
backpack_id: child.dataset.backpackId || null,
backpack_compartment_id: child.dataset.backpackCompartmentId || null
});
const nestedContainer = child.querySelector('.nested-sortable');
if (nestedContainer) {
traverse(nestedContainer, pliId);
}
});
}
traverse(rootList, null);
traverse(rootList, carrierId, null);
});
sendApiRequest(payload).then(newItems => {
packedItems = newItems;
// Save scroll positions
const scrollPos = {
avail: document.getElementById('available-items-list').scrollTop,
carrier: document.getElementById('carriers-container').scrollTop
};
if(Array.isArray(newItems)) packedItems = newItems;
const panes = document.querySelectorAll('.pane-content');
const scrollMap = Array.from(panes).map(p => p.scrollTop);
fullRender();
// Restore scroll positions
Array.from(panes).forEach((p, i) => {
if (scrollMap[i] !== undefined) p.scrollTop = scrollMap[i];
});
Array.from(panes).forEach((p, i) => { if (scrollMap[i] !== undefined) p.scrollTop = scrollMap[i]; });
}).catch(err => {
console.error("Sync failed:", err);
alert("Fehler beim Speichern! Bitte Seite neu laden.");
@@ -563,9 +600,12 @@ $conn->close();
const itemId = itemEl.dataset.itemId;
if (button.classList.contains('remove-item-btn')) {
if (confirm('Diesen Artikel wirklich aus der Packliste entfernen?')) {
if (confirm('Diesen Artikel wirklich aus der Liste entfernen?')) {
itemEl.remove();
sendApiRequest({ action: 'delete_item', item_id: itemId });
sendApiRequest({ action: 'delete_item', item_id: itemId }).then(newItems => {
if(Array.isArray(newItems)) packedItems = newItems;
fullRender();
});
}
}
}
@@ -578,7 +618,10 @@ $conn->close();
const newQuantity = input.value;
clearTimeout(input.dataset.timeout);
input.dataset.timeout = setTimeout(() => {
sendApiRequest({ action: 'update_quantity', item_id: itemId, quantity: newQuantity });
sendApiRequest({ action: 'update_quantity', item_id: itemId, quantity: newQuantity }).then(newItems => {
if(Array.isArray(newItems)) packedItems = newItems;
fullRender();
});
}, 500);
}
}
@@ -597,7 +640,7 @@ $conn->close();
return Promise.reject(errorData);
}
const json = await response.json();
if (data.action !== 'update_quantity') {
if (data.action !== 'update_quantity' && data.action !== 'get_items') {
const fb = document.getElementById('save-feedback');
fb.style.opacity = '1';
setTimeout(() => { fb.style.opacity = '0'; }, 1500);

View File

@@ -156,7 +156,9 @@ $conn->close();
<a href="packing_list_detail.php?id=<?php echo $list['id']; ?>" class="btn btn-sm btn-outline-success" title="Details ansehen"><i class="fas fa-eye"></i></a>
<?php if ($is_owner || $can_edit_household_list): ?>
<a href="edit_packing_list_details.php?id=<?php echo $list['id']; ?>" class="btn btn-sm btn-outline-primary" title="Details bearbeiten"><i class="fas fa-pencil-alt"></i></a>
<a href="manage_packing_list_items.php?id=<?php echo $list['id']; ?>" class="btn btn-sm btn-outline-secondary" title="Artikel verwalten"><i class="fas fa-boxes"></i></a>
<a href="manage_packing_list_items.php?id=<?php echo $list['id']; ?>&phase=1" class="btn btn-sm btn-outline-info" title="Phase 1: Artikel aufsammeln (Lager -> Tisch)"><i class="fas fa-hand-pointer"></i> 1. Aufsammeln</a>
<a href="manage_packing_list_items.php?id=<?php echo $list['id']; ?>&phase=2" class="btn btn-sm btn-outline-success" title="Phase 2: Rucksack packen (Tisch -> Rucksack)"><i class="fas fa-backpack"></i> 2. Packen</a>
<a href="manage_packing_list_items.php?id=<?php echo $list['id']; ?>" class="btn btn-sm btn-outline-secondary" title="Alle 3 Spalten anzeigen"><i class="fas fa-columns"></i></a>
<?php endif; ?>
<?php if ($is_owner): ?>