Update: ToDos, Namen und Bugfixes Live (Tisch-Artikel, UI, Modals)
All checks were successful
Docker Build & Push / build-and-push (push) Successful in 36s
67
GEMINI.md
Normal file
@@ -0,0 +1,67 @@
|
||||
# GEMINI.md - Context for AI Agents
|
||||
|
||||
## Project Overview
|
||||
**Name:** Trekking Packliste
|
||||
**Type:** PHP Web Application (Native)
|
||||
**Purpose:** A modern, web-based application for managing packing lists for hiking, trekking, and travel. It focuses on weight optimization, hierarchical packing (containers/nesting), and household collaboration.
|
||||
|
||||
## Architecture & Technology
|
||||
- **Backend:** Native PHP 8.2 (No framework).
|
||||
- **Frontend:** HTML5, CSS3 (Custom Theme), Bootstrap 5, Vanilla JavaScript.
|
||||
- **Database:** MariaDB / MySQL.
|
||||
- **Containerization:** Docker (Apache image).
|
||||
- **CI/CD:** Gitea Actions (`.gitea/workflows/build-push.yaml`).
|
||||
|
||||
## Directory Structure
|
||||
- `/src/`: Source code root.
|
||||
- `*.php`: Application logic and views.
|
||||
- `assets/`: CSS, JS, and static images.
|
||||
- `uploads/`: User-uploaded images (mounted volume in Docker).
|
||||
- `Dockerfile`: Defines the application environment.
|
||||
- `packliste.sql`: Database schema and initial data.
|
||||
- `.gitea/`: Gitea-specific configurations (workflows).
|
||||
|
||||
## Key Files
|
||||
- `src/db_connect.php`: Handles database connection. Prioritizes environment variables (`DB_HOST`, etc.) over `config.ini`.
|
||||
- `src/index.php`: Application dashboard.
|
||||
- `src/api_packing_list_handler.php`: AJAX handler for packing list operations.
|
||||
- `README.md`: Comprehensive documentation on features and usage.
|
||||
|
||||
## Database Schema (Key Tables)
|
||||
- `articles`: Inventory items (weight, category, image).
|
||||
- `backpacks`: Transport containers (weight, volume).
|
||||
- `backpack_compartments`: Sub-sections of backpacks (e.g., "Lid", "Main").
|
||||
- `packing_lists`: The main lists.
|
||||
- `packing_list_items`: Link between lists and articles (supports nesting/hierarchy).
|
||||
- `users` & `households`: User management and sharing scope.
|
||||
|
||||
## Development & Deployment
|
||||
|
||||
### Local Development (Docker)
|
||||
1. **Build:** `docker build -t packliste-app .`
|
||||
2. **Run:**
|
||||
```bash
|
||||
docker run -d \
|
||||
-p 8080:80 \
|
||||
-e DB_HOST="host.docker.internal" \
|
||||
-e DB_USER="root" \
|
||||
-e DB_PASSWORD="password" \
|
||||
-e DB_NAME="packliste" \
|
||||
-v $(pwd)/src/uploads:/var/www/html/uploads \
|
||||
packliste-app
|
||||
```
|
||||
|
||||
### Manual Installation
|
||||
- Configure `config.ini` in the parent directory of the web root (or use env vars).
|
||||
- Import `packliste.sql` into the database.
|
||||
|
||||
### Conventions
|
||||
- **Code Style:** Native PHP, procedural/functional mix.
|
||||
- **Frontend:** Bootstrap 5 classes for layout. Custom CSS in `assets/css/style.css`.
|
||||
- **Database:** Use `mysqli` with prepared statements for security.
|
||||
- **Security:** Always validate user input. Passwords hashed with `password_hash()`.
|
||||
|
||||
## Core Features
|
||||
- **Weight Calculation:** Real-time summation of weights in lists.
|
||||
- **Households:** Shared inventory and lists between users.
|
||||
- **Drag & Drop:** `Sortable.js` used for organizing packing lists.
|
||||
11
README.md
@@ -193,3 +193,14 @@ Das Projekt basiert auf bewährten Web-Standards:
|
||||
* Fehler bei der Artikelanzahl behoben: Wenn ein Artikel mehrfach im Bestand vorhanden ist (z.B. 5 Stück), verschwindet er nicht mehr aus der Auswahlliste, nachdem das erste Stück in einen Rucksack gezogen wurde.
|
||||
* Bugfix für Session-Timeouts ("Headers already sent"): Die PHP-Sitzung wird nun korrekt überprüft und verarbeitet, bevor HTML-Header gesendet werden (z.B. in `backpacks.php`).
|
||||
* Session Timeout (Ausloggen) auf 24 Stunden verlängert (per `.htaccess`).
|
||||
|
||||
### 13.05.2026
|
||||
* **Features:**
|
||||
* ToDo-Listen können nun direkt bei der Erstellung einer neuen Packliste verknüpft werden.
|
||||
* **Fixes:**
|
||||
* "Tisch"-Artikel (nicht zugewiesene Artikel) werden nun konsequent aus Statistiken, Druckansichten und Detailansichten ausgeblendet. Es wird stattdessen ein prominenter Warnhinweis angezeigt.
|
||||
* Verschieben von Artikeln zurück auf den Tisch (Phase 2) fasst diese nun mit bestehenden Artikeln desselben Typs auf dem Tisch zusammen.
|
||||
* Die Überprüfung auf doppelte Benutzernamen beim Ändern des Benutzernamens (Groß-/Kleinschreibung) wurde korrigiert.
|
||||
* Das Layout der ToDo-Listen-Verwaltung wurde an das restliche moderne App-Design (Cards) angeglichen.
|
||||
* PHP-Warning beim Deaktivieren von Checkboxen in ToDo-Listen behoben.
|
||||
* JavaScript-Standardmeldungen (`confirm()`) für das Löschen von Artikeln in Phase 1 durch moderne Bootstrap-Modals ersetzt.
|
||||
|
||||
18
check_headers.php
Normal file
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
$files = glob("src/*.php");
|
||||
foreach ($files as $f) {
|
||||
$content = file($f);
|
||||
$headerLine = -1;
|
||||
$loginLine = -1;
|
||||
foreach ($content as $index => $line) {
|
||||
if (strpos($line, "require_once 'header.php'") !== false) {
|
||||
$headerLine = $index;
|
||||
}
|
||||
if (strpos($line, "Location: login.php") !== false) {
|
||||
$loginLine = $index;
|
||||
}
|
||||
}
|
||||
if ($headerLine !== -1 && $loginLine !== -1 && $headerLine < $loginLine) {
|
||||
echo $f . "\n";
|
||||
}
|
||||
}
|
||||
@@ -523,3 +523,8 @@ UNLOCK TABLES;
|
||||
/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */;
|
||||
|
||||
-- Dump completed on 2025-12-05 18:10:10
|
||||
ALTER TABLE `users` ADD COLUMN `display_name` varchar(255) DEFAULT NULL;
|
||||
CREATE TABLE `todo_lists` ( `id` int(11) NOT NULL AUTO_INCREMENT, `user_id` int(11) NOT NULL, `household_id` int(11) DEFAULT NULL, `name` varchar(255) NOT NULL, `created_at` timestamp NOT NULL DEFAULT current_timestamp(), PRIMARY KEY (`id`), KEY `user_id` (`user_id`), KEY `household_id` (`household_id`), CONSTRAINT `fk_todo_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE, CONSTRAINT `fk_todo_household` FOREIGN KEY (`household_id`) REFERENCES `households` (`id`) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
|
||||
CREATE TABLE `todo_items` ( `id` int(11) NOT NULL AUTO_INCREMENT, `todo_list_id` int(11) NOT NULL, `title` varchar(255) NOT NULL, `is_completed` tinyint(1) DEFAULT 0, `order_index` int(11) DEFAULT 0, PRIMARY KEY (`id`), KEY `todo_list_id` (`todo_list_id`), CONSTRAINT `fk_todo_items_list` FOREIGN KEY (`todo_list_id`) REFERENCES `todo_lists` (`id`) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
|
||||
ALTER TABLE `packing_lists` ADD COLUMN `todo_list_id` int(11) DEFAULT NULL;
|
||||
ALTER TABLE `packing_lists` ADD CONSTRAINT `fk_packing_list_todo` FOREIGN KEY (`todo_list_id`) REFERENCES `todo_lists` (`id`) ON DELETE SET NULL;
|
||||
|
||||
29
setup_test.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
$_SESSION['user_id'] = 1;
|
||||
$_SESSION['username'] = 'test';
|
||||
require_once 'src/db_connect.php';
|
||||
|
||||
// Prepare a mock packing list
|
||||
$conn->query("INSERT IGNORE INTO users (id, username, password) VALUES (1, 'test', 'test')");
|
||||
$conn->query("INSERT IGNORE INTO packing_lists (id, user_id, name) VALUES (22, 1, 'Test List')");
|
||||
$conn->query("INSERT IGNORE INTO articles (id, name, user_id) VALUES (12, 'Test Article', 1)");
|
||||
|
||||
$payload = '{
|
||||
"action": "sync_list",
|
||||
"packing_list_id": 22,
|
||||
"list": [
|
||||
{
|
||||
"pli_id": "1",
|
||||
"article_id": "12",
|
||||
"carrier_id": null,
|
||||
"parent_pli_id": null,
|
||||
"backpack_id": null,
|
||||
"backpack_compartment_id": null
|
||||
}
|
||||
]
|
||||
}';
|
||||
|
||||
// Overwrite php://input using a stream wrapper or just modify the script directly
|
||||
$script = file_get_contents('src/api_packing_list_handler.php');
|
||||
$script = str_replace("json_decode(file_get_contents('php://input'), true)", "json_decode('$payload', true)", $script);
|
||||
file_put_contents('test_api.php', $script);
|
||||
@@ -66,6 +66,8 @@ if ($_SERVER["REQUEST_METHOD"] == "POST") {
|
||||
$template_id = isset($_POST['template_id']) ? intval($_POST['template_id']) : 0;
|
||||
$household_id = isset($_POST['is_household_list']) && $household_id_for_user ? $household_id_for_user : NULL;
|
||||
|
||||
$todo_list_id = isset($_POST['todo_list_id']) && intval($_POST['todo_list_id']) > 0 ? intval($_POST['todo_list_id']) : NULL;
|
||||
|
||||
// Server-Side Validation for duplicate backpacks (ONLY if no template is selected)
|
||||
$has_duplicate_backpacks = false;
|
||||
|
||||
@@ -92,11 +94,11 @@ if ($_SERVER["REQUEST_METHOD"] == "POST") {
|
||||
} elseif ($has_duplicate_backpacks) {
|
||||
$message = '<div class="alert alert-danger" role="alert">Fehler: Ein Rucksack kann nicht mehreren Personen zugewiesen werden. Bitte korrigieren Sie die Auswahl.</div>';
|
||||
} else {
|
||||
$stmt = $conn->prepare("INSERT INTO packing_lists (user_id, household_id, name, description, is_template) VALUES (?, ?, ?, ?, 0)");
|
||||
$stmt = $conn->prepare("INSERT INTO packing_lists (user_id, household_id, name, description, is_template, todo_list_id) VALUES (?, ?, ?, ?, 0, ?)");
|
||||
if ($stmt === false) {
|
||||
$message .= '<div class="alert alert-danger" role="alert">SQL Prepare-Fehler: ' . $conn->error . '</div>';
|
||||
} else {
|
||||
$stmt->bind_param("isss", $current_user_id, $household_id, $name, $description);
|
||||
$stmt->bind_param("isssi", $current_user_id, $household_id, $name, $description, $todo_list_id);
|
||||
if ($stmt->execute()) {
|
||||
$new_list_id = $conn->insert_id;
|
||||
|
||||
@@ -330,4 +332,39 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
|
||||
<?php echo render_backpack_modal_script($user_backpacks_json, $all_assigned_backpack_ids); ?>
|
||||
|
||||
<?php require_once 'footer.php'; ?> if (val > 0) {
|
||||
if (selected.includes(val)) {
|
||||
hasDuplicate = true;
|
||||
}
|
||||
selected.push(val);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (hasDuplicate) {
|
||||
warning.classList.remove('d-none');
|
||||
submitBtn.disabled = true;
|
||||
} else {
|
||||
warning.classList.add('d-none');
|
||||
submitBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Also listen to participation checkboxes
|
||||
document.querySelectorAll('.participation-check').forEach(chk => {
|
||||
chk.addEventListener('change', validateBackpacks);
|
||||
});
|
||||
|
||||
document.body.addEventListener('hidden.bs.modal', function (event) {
|
||||
validateBackpacks();
|
||||
});
|
||||
|
||||
// Initial check
|
||||
toggleParticipants();
|
||||
});
|
||||
</script>
|
||||
|
||||
<?php echo render_backpack_modal_script($user_backpacks_json, $all_assigned_backpack_ids); ?>
|
||||
|
||||
<?php require_once 'footer.php'; ?>
|
||||
@@ -29,7 +29,7 @@ if ($packing_list_id > 0) {
|
||||
$current_user_household_id = $stmt_household_check->get_result()->fetch_assoc()['household_id'];
|
||||
$stmt_household_check->close();
|
||||
|
||||
$stmt_list_check = $conn->prepare("SELECT id, name, description, user_id, household_id, is_template FROM packing_lists WHERE id = ?");
|
||||
$stmt_list_check = $conn->prepare("SELECT id, name, description, user_id, household_id, is_template, todo_list_id FROM packing_lists WHERE id = ?");
|
||||
$stmt_list_check->bind_param("i", $packing_list_id);
|
||||
$stmt_list_check->execute();
|
||||
$result = $stmt_list_check->get_result();
|
||||
@@ -54,6 +54,7 @@ if ($packing_list_id > 0) {
|
||||
|
||||
// --- 2. Fetch Data for Dropdowns ---
|
||||
$available_users = [];
|
||||
$available_todo_lists = [];
|
||||
if ($can_edit) {
|
||||
// Owners: Creator + Household Members (if shared)
|
||||
if ($packing_list['household_id']) {
|
||||
@@ -69,6 +70,16 @@ if ($can_edit) {
|
||||
$available_users[] = $row;
|
||||
}
|
||||
|
||||
// Fetch Todo Lists
|
||||
$stmt_tl = $conn->prepare("SELECT id, name FROM todo_lists WHERE user_id = ? OR (household_id IS NOT NULL AND household_id = ?)");
|
||||
$stmt_tl->bind_param("ii", $current_user_id, $current_user_household_id);
|
||||
$stmt_tl->execute();
|
||||
$res_tl = $stmt_tl->get_result();
|
||||
while ($row = $res_tl->fetch_assoc()) {
|
||||
$available_todo_lists[] = $row;
|
||||
}
|
||||
$stmt_tl->close();
|
||||
|
||||
// Current Assignments
|
||||
$current_assignments = [];
|
||||
$stmt_ca = $conn->prepare("SELECT user_id, backpack_id FROM packing_list_carriers WHERE packing_list_id = ?");
|
||||
@@ -92,12 +103,15 @@ if ($_SERVER["REQUEST_METHOD"] == "POST" && $can_edit) {
|
||||
$new_household_id = $current_user_household_id;
|
||||
}
|
||||
|
||||
$stmt_update = $conn->prepare("UPDATE packing_lists SET name = ?, description = ?, household_id = ? WHERE id = ?");
|
||||
$stmt_update->bind_param("ssii", $name, $description, $new_household_id, $packing_list_id);
|
||||
$todo_list_id = !empty($_POST['todo_list_id']) ? intval($_POST['todo_list_id']) : NULL;
|
||||
|
||||
$stmt_update = $conn->prepare("UPDATE packing_lists SET name = ?, description = ?, household_id = ?, todo_list_id = ? WHERE id = ?");
|
||||
$stmt_update->bind_param("sssii", $name, $description, $new_household_id, $todo_list_id, $packing_list_id);
|
||||
$stmt_update->execute();
|
||||
$packing_list['name'] = $name;
|
||||
$packing_list['description'] = $description;
|
||||
$packing_list['household_id'] = $new_household_id;
|
||||
$packing_list['todo_list_id'] = $todo_list_id;
|
||||
|
||||
// Handle Participation & Backpacks
|
||||
// Get all potential users to check if they were unchecked
|
||||
@@ -213,6 +227,19 @@ $page_headline = !empty($packing_list['is_template']) ? 'Vorlage bearbeiten' : '
|
||||
<textarea class="form-control" name="description" rows="3"><?php echo htmlspecialchars($packing_list['description'] ?: ''); ?></textarea>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label for="todo_list_id" class="form-label fw-bold">ToDo-Liste (optional)</label>
|
||||
<select class="form-select" id="todo_list_id" name="todo_list_id">
|
||||
<option value="">-- Keine ToDo-Liste --</option>
|
||||
<?php foreach ($available_todo_lists as $tl): ?>
|
||||
<option value="<?php echo $tl['id']; ?>" <?php if (($packing_list['todo_list_id'] ?? null) == $tl['id']) echo 'selected'; ?>>
|
||||
<?php echo htmlspecialchars($tl['name']); ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<div class="form-text">Verknüpfe eine ToDo-Liste mit dieser Packliste.</div>
|
||||
</div>
|
||||
|
||||
<?php if ($current_user_household_id): ?>
|
||||
<div class="form-check form-switch mb-3">
|
||||
<input class="form-check-input" type="checkbox" role="switch" id="is_household_list" name="is_household_list" value="1" <?php echo !empty($packing_list['household_id']) ? 'checked' : ''; ?>>
|
||||
|
||||
@@ -307,6 +307,24 @@ $conn->close();
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal fade" id="deleteItemModal" tabindex="-1" aria-labelledby="deleteItemModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="deleteItemModalLabel">Artikel löschen</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
Diesen Artikel wirklich aus der Liste entfernen?
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Abbrechen</button>
|
||||
<button type="button" class="btn btn-danger" id="btn-delete-confirm">Löschen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal fade" id="removeItemModal" tabindex="-1" aria-labelledby="removeItemModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
@@ -1035,4 +1053,14 @@ $conn->close();
|
||||
}
|
||||
</script>
|
||||
|
||||
<?php require_once 'footer.php'; ?>city = '0'; }, 1500);
|
||||
}
|
||||
return json;
|
||||
} catch(error) {
|
||||
console.error(error);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<?php require_once 'footer.php'; ?>
|
||||
613
src/manage_packing_list_items.php.bak
Normal file
@@ -0,0 +1,613 @@
|
||||
<?php
|
||||
// manage_packing_list_items.php - Interaktive Verwaltung der Packlisten-Artikel
|
||||
// FINALE, REKURSIVE NESTED-SORTABLE VERSION
|
||||
|
||||
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 'header.php';
|
||||
|
||||
$current_user_id = $_SESSION['user_id'];
|
||||
$packing_list_id = isset($_GET['id']) ? intval($_GET['id']) : 0;
|
||||
$can_edit = false;
|
||||
|
||||
// Lade Packliste und prüfe Berechtigung
|
||||
$stmt_list = $conn->prepare("SELECT * FROM packing_lists WHERE id = ?");
|
||||
$stmt_list->bind_param("i", $packing_list_id);
|
||||
$stmt_list->execute();
|
||||
$result = $stmt_list->get_result();
|
||||
if ($result->num_rows === 0) die("Packliste nicht gefunden.");
|
||||
$packing_list = $result->fetch_assoc();
|
||||
$stmt_list->close();
|
||||
|
||||
$stmt_household = $conn->prepare("SELECT household_id FROM users WHERE id = ?");
|
||||
$stmt_household->bind_param("i", $current_user_id);
|
||||
$stmt_household->execute();
|
||||
$current_user_household_id = $stmt_household->get_result()->fetch_assoc()['household_id'];
|
||||
$stmt_household->close();
|
||||
|
||||
$is_owner = ($packing_list['user_id'] == $current_user_id);
|
||||
$is_household_list = !empty($packing_list['household_id']);
|
||||
$is_in_same_household = ($is_household_list && $packing_list['household_id'] == $current_user_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']);
|
||||
|
||||
$household_member_ids = [$current_user_id];
|
||||
if ($current_user_household_id) {
|
||||
$stmt_members = $conn->prepare("SELECT id FROM users WHERE household_id = ?");
|
||||
$stmt_members->bind_param("i", $current_user_household_id);
|
||||
$stmt_members->execute();
|
||||
$result_members = $stmt_members->get_result();
|
||||
while ($row = $result_members->fetch_assoc()) { if (!in_array($row['id'], $household_member_ids)) { $household_member_ids[] = $row['id']; } }
|
||||
$stmt_members->close();
|
||||
}
|
||||
$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
|
||||
FROM articles a
|
||||
LEFT JOIN categories c ON a.category_id = c.id
|
||||
LEFT JOIN manufacturers m ON a.manufacturer_id = m.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);
|
||||
$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();
|
||||
|
||||
$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,
|
||||
bc.name,
|
||||
CASE WHEN b.name IS NOT NULL THEN CONCAT('Rucksack: ', b.name) ELSE NULL END,
|
||||
pli.name
|
||||
) as name,
|
||||
a.weight_grams, a.product_designation, a.consumable, m.name as manufacturer_name
|
||||
FROM packing_list_items pli
|
||||
LEFT JOIN articles a ON pli.article_id = a.id
|
||||
LEFT JOIN manufacturers m ON a.manufacturer_id = m.id
|
||||
LEFT JOIN backpacks b ON pli.backpack_id = b.id
|
||||
LEFT JOIN backpack_compartments bc ON pli.backpack_compartment_id = bc.id
|
||||
WHERE pli.packing_list_id = ?
|
||||
ORDER BY pli.order_index ASC";
|
||||
|
||||
$stmt_items = $conn->prepare($sql_items);
|
||||
$stmt_items->bind_param("i", $packing_list_id);
|
||||
$stmt_items->execute();
|
||||
$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);
|
||||
$manufacturers = array_unique(array_filter(array_column($all_articles_raw, 'manufacturer_name')));
|
||||
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">
|
||||
<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>
|
||||
</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">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="includeChildrenModalLabel">Komponenten hinzufügen</h5>
|
||||
<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>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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 childrenModal = 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'));
|
||||
}
|
||||
};
|
||||
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');
|
||||
if (url && !url.endsWith('assets/images/keinbild.png')) {
|
||||
tooltip.style.backgroundImage = `url('${url}')`;
|
||||
tooltip.style.display = 'block';
|
||||
}
|
||||
}
|
||||
});
|
||||
document.body.addEventListener('mousemove', e => {
|
||||
if (tooltip.style.display === 'block') {
|
||||
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';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
['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('carriers-container').addEventListener('click', handlePackedItemActions);
|
||||
document.getElementById('carriers-container').addEventListener('change', handleQuantityChange);
|
||||
fullRender();
|
||||
});
|
||||
|
||||
function fullRender() {
|
||||
renderAvailableItems();
|
||||
renderCarriersAndPackedItems();
|
||||
}
|
||||
|
||||
function renderAvailableItems() {
|
||||
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 = {};
|
||||
packedItems.forEach(item => {
|
||||
const aid = String(item.article_id);
|
||||
packedQuantities[aid] = (packedQuantities[aid] || 0) + parseInt(item.quantity || 1, 10);
|
||||
});
|
||||
|
||||
let html = '';
|
||||
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>`;
|
||||
}
|
||||
}
|
||||
});
|
||||
availableListEl.innerHTML = html;
|
||||
|
||||
new Sortable(availableListEl, {
|
||||
group: { name: 'nested', pull: 'clone', put: false },
|
||||
animation: 150,
|
||||
sort: false
|
||||
});
|
||||
}
|
||||
|
||||
function renderCarriersAndPackedItems() {
|
||||
const container = document.getElementById('carriers-container');
|
||||
container.innerHTML = '';
|
||||
|
||||
sortableInstances.forEach(s => s.destroy());
|
||||
sortableInstances = [];
|
||||
|
||||
carriers.forEach(carrier => {
|
||||
const carrierId = carrier.id === null ? 'null' : carrier.id;
|
||||
const carrierDiv = document.createElement('div');
|
||||
carrierDiv.className = 'carrier-box';
|
||||
carrierDiv.innerHTML = `<div class="carrier-header"><h6>${carrier.username}</h6></div>`;
|
||||
|
||||
const carrierRootList = document.createElement('div');
|
||||
carrierRootList.className = 'carrier-list nested-sortable';
|
||||
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 itemsById = itemsForCarrier.reduce((acc, item) => ({...acc, [item.id]: {...item, children: []}}), {});
|
||||
const rootItems = [];
|
||||
|
||||
Object.values(itemsById).forEach(item => {
|
||||
if (item.parent_packing_list_item_id && itemsById[item.parent_packing_list_item_id]) {
|
||||
itemsById[item.parent_packing_list_item_id].children.push(item);
|
||||
} else {
|
||||
rootItems.push(item);
|
||||
}
|
||||
});
|
||||
|
||||
renderRecursive(rootItems, carrierRootList);
|
||||
initNestedSortable(carrierRootList);
|
||||
});
|
||||
}
|
||||
|
||||
function renderRecursive(items, container) {
|
||||
items.forEach(item => {
|
||||
const itemEl = createPackedItemDOM(item);
|
||||
container.appendChild(itemEl);
|
||||
const nestedContainer = itemEl.querySelector('.nested-sortable');
|
||||
if (item.children && item.children.length > 0) {
|
||||
renderRecursive(item.children, nestedContainer);
|
||||
}
|
||||
initNestedSortable(nestedContainer);
|
||||
});
|
||||
}
|
||||
|
||||
function createPackedItemDOM(item) {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'packed-item-container';
|
||||
div.dataset.itemId = item.id;
|
||||
div.dataset.articleId = item.article_id;
|
||||
if(item.backpack_id) div.dataset.backpackId = item.backpack_id;
|
||||
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 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">
|
||||
<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';
|
||||
nameDisplay = `<strong>${item.name}</strong>`;
|
||||
metaDisplay = '';
|
||||
// Disable controls for structural items
|
||||
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 = '';
|
||||
}
|
||||
|
||||
div.innerHTML = `
|
||||
<div class="${contentClass}">
|
||||
<i class="${iconClass}"></i>
|
||||
<span class="item-name ms-2">${nameDisplay} ${metaDisplay}</span>
|
||||
<div class="item-controls">
|
||||
${controls}
|
||||
</div>
|
||||
</div>
|
||||
<div class="nested-sortable"></div>
|
||||
`;
|
||||
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: [] };
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
sendApiRequest(payload).then(newItems => {
|
||||
packedItems = newItems;
|
||||
// Save scroll positions
|
||||
const scrollPos = {
|
||||
avail: document.getElementById('available-items-list').scrollTop,
|
||||
carrier: document.getElementById('carriers-container').scrollTop
|
||||
};
|
||||
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];
|
||||
});
|
||||
}).catch(err => {
|
||||
console.error("Sync failed:", err);
|
||||
alert("Fehler beim Speichern! Bitte Seite neu laden.");
|
||||
});
|
||||
}
|
||||
|
||||
function handlePackedItemActions(e) {
|
||||
const button = e.target.closest('button');
|
||||
if (!button) return;
|
||||
const itemEl = button.closest('.packed-item-container');
|
||||
if (!itemEl) return;
|
||||
const itemId = itemEl.dataset.itemId;
|
||||
|
||||
if (button.classList.contains('remove-item-btn')) {
|
||||
if (confirm('Diesen Artikel wirklich aus der Packliste entfernen?')) {
|
||||
itemEl.remove();
|
||||
sendApiRequest({ action: 'delete_item', item_id: itemId });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleQuantityChange(e) {
|
||||
const input = e.target;
|
||||
if (input.classList.contains('quantity-input')) {
|
||||
const itemEl = input.closest('.packed-item-container');
|
||||
const itemId = itemEl.dataset.itemId;
|
||||
const newQuantity = input.value;
|
||||
clearTimeout(input.dataset.timeout);
|
||||
input.dataset.timeout = setTimeout(() => {
|
||||
sendApiRequest({ action: 'update_quantity', item_id: itemId, quantity: newQuantity });
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
|
||||
async function sendApiRequest(data) {
|
||||
data.packing_list_id = packingListId;
|
||||
try {
|
||||
const response = await fetch('api_packing_list_handler.php', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({error: 'Serverfehler'}));
|
||||
console.error(errorData);
|
||||
return Promise.reject(errorData);
|
||||
}
|
||||
const json = await response.json();
|
||||
if (data.action !== 'update_quantity') {
|
||||
const fb = document.getElementById('save-feedback');
|
||||
fb.style.opacity = '1';
|
||||
setTimeout(() => { fb.style.opacity = '0'; }, 1500);
|
||||
}
|
||||
return json;
|
||||
} catch(error) {
|
||||
console.error(error);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<?php require_once 'footer.php'; ?>
|
||||
@@ -302,6 +302,16 @@ function render_item_row($item, $level, $items_by_parent) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php if ($table_items_count > 0): ?>
|
||||
<div class="alert alert-warning print-hide">
|
||||
<i class="fas fa-exclamation-triangle me-2"></i><strong>Achtung:</strong> Auf dem Tisch liegen noch Artikel, die keinem Rucksack und keinem Träger zugewiesen sind! Diese fließen nicht in die Statistik oder den Druck ein.
|
||||
<?php if ($packing_list['user_id'] == $current_user_id || (!empty($packing_list['household_id']) && $packing_list['household_id'] == $current_user_household_id)): ?>
|
||||
<a href="manage_packing_list_items.php?id=<?php echo $packing_list_id; ?>&phase=2" class="alert-link ms-2">Jetzt zuweisen <i class="fas fa-arrow-right"></i></a>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-lg-8 mb-4 mb-lg-0">
|
||||
<div class="card h-100">
|
||||
|
||||
@@ -50,7 +50,7 @@ if ($packing_list) {
|
||||
a.weight_grams, c.name AS category_name, a.consumable, a.image_url,
|
||||
a.product_designation, m.name AS manufacturer_name,
|
||||
bp.manufacturer AS bp_manufacturer, bp.model AS bp_model,
|
||||
u.username AS carrier_name
|
||||
COALESCE(u.display_name, u.username) AS carrier_name
|
||||
FROM packing_list_items pli
|
||||
LEFT JOIN articles a ON pli.article_id = a.id
|
||||
LEFT JOIN manufacturers m ON a.manufacturer_id = m.id
|
||||
@@ -59,6 +59,7 @@ if ($packing_list) {
|
||||
LEFT JOIN backpack_compartments bpc ON pli.backpack_compartment_id = bpc.id
|
||||
LEFT JOIN users u ON pli.carrier_user_id = u.id
|
||||
WHERE pli.packing_list_id = ?
|
||||
AND NOT (pli.carrier_user_id IS NULL AND pli.backpack_id IS NULL AND pli.backpack_compartment_id IS NULL AND pli.parent_packing_list_item_id IS NULL)
|
||||
ORDER BY pli.order_index ASC";
|
||||
|
||||
$stmt = $conn->prepare($sql);
|
||||
|
||||
@@ -60,7 +60,7 @@ if ($_SERVER["REQUEST_METHOD"] == "POST") {
|
||||
$stmt->close();
|
||||
} elseif (isset($_POST['toggle_item'])) {
|
||||
$item_id = intval($_POST['item_id']);
|
||||
$status = intval($_POST['status']);
|
||||
$status = isset($_POST['status']) ? intval($_POST['status']) : 0;
|
||||
$stmt = $conn->prepare("UPDATE todo_items SET is_completed = ? WHERE id = ?");
|
||||
$stmt->bind_param("ii", $status, $item_id);
|
||||
$stmt->execute();
|
||||
@@ -130,9 +130,9 @@ $active_list_id = isset($_GET['list_id']) ? intval($_GET['list_id']) : (!empty($
|
||||
$active_list_name = '';
|
||||
foreach ($todo_lists as $l) if ($l['id'] == $active_list_id) $active_list_name = $l['name'];
|
||||
?>
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header bg-white d-flex justify-content-between align-items-center">
|
||||
<h6 class="m-0 font-weight-bold text-primary"><?php echo htmlspecialchars($active_list_name); ?></h6>
|
||||
<div class="card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0"><i class="fas fa-tasks me-2"></i><?php echo htmlspecialchars($active_list_name); ?></h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<ul class="list-group list-group-flush mb-3">
|
||||
|
||||
BIN
src/uploads/6852fc1190bc2.png
Executable file
|
After Width: | Height: | Size: 29 KiB |
BIN
src/uploads/685306028b828.png
Executable file
|
After Width: | Height: | Size: 768 KiB |
BIN
src/uploads/685306a5be4af.png
Executable file
|
After Width: | Height: | Size: 768 KiB |
BIN
src/uploads/68530b5956db3.png
Executable file
|
After Width: | Height: | Size: 768 KiB |
BIN
src/uploads/685314e648206.png
Executable file
|
After Width: | Height: | Size: 768 KiB |
BIN
src/uploads/6853157b136ce.png
Executable file
|
After Width: | Height: | Size: 580 KiB |
BIN
src/uploads/images/bp_img_69323e81d72cb8.63112841.jpg
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
src/uploads/images/bp_img_6932403035e899.09967829.jpg
Normal file
|
After Width: | Height: | Size: 902 KiB |
BIN
src/uploads/images/bp_img_6932403756cb16.99209873.png
Normal file
|
After Width: | Height: | Size: 1.0 MiB |
BIN
src/uploads/images/bp_img_69324148109174.55311184.jpg
Normal file
|
After Width: | Height: | Size: 87 KiB |
BIN
src/uploads/images/img_68519d40cefc91.06373491.png
Executable file
|
After Width: | Height: | Size: 236 KiB |
BIN
src/uploads/images/img_68528c880b1401.14908112.jpg
Executable file
|
After Width: | Height: | Size: 79 KiB |
BIN
src/uploads/images/img_6852aa2e9a9ac8.18122970.png
Executable file
|
After Width: | Height: | Size: 768 KiB |
BIN
src/uploads/images/img_6852aae52a8590.99789219.png
Executable file
|
After Width: | Height: | Size: 768 KiB |
BIN
src/uploads/images/img_6852d1707d0af4.43646571.png
Executable file
|
After Width: | Height: | Size: 80 KiB |
BIN
src/uploads/images/img_68530888b01da3.01042792.png
Executable file
|
After Width: | Height: | Size: 768 KiB |
BIN
src/uploads/images/img_685308945b37e1.08844156.png
Executable file
|
After Width: | Height: | Size: 768 KiB |
BIN
src/uploads/images/img_6853093a3b0fb7.10685249.png
Executable file
|
After Width: | Height: | Size: 768 KiB |
BIN
src/uploads/images/img_68530974a15736.57910512.png
Executable file
|
After Width: | Height: | Size: 768 KiB |
BIN
src/uploads/images/img_685309b775df63.28364476.png
Executable file
|
After Width: | Height: | Size: 768 KiB |
BIN
src/uploads/images/img_68530a6f2c08f5.96614673.png
Executable file
|
After Width: | Height: | Size: 768 KiB |
BIN
src/uploads/images/img_68530aff6faf10.53187686.png
Executable file
|
After Width: | Height: | Size: 768 KiB |
BIN
src/uploads/images/img_68530ba765f0c1.87888941.png
Executable file
|
After Width: | Height: | Size: 768 KiB |
BIN
src/uploads/images/img_68530bdb75d1a9.18204952.png
Executable file
|
After Width: | Height: | Size: 768 KiB |
BIN
src/uploads/images/img_68530c8fc7ce78.25162975.png
Executable file
|
After Width: | Height: | Size: 768 KiB |
BIN
src/uploads/images/img_68530d38ce6cd7.76753737.png
Executable file
|
After Width: | Height: | Size: 768 KiB |
BIN
src/uploads/images/img_68530e3263a8f3.11354541.png
Executable file
|
After Width: | Height: | Size: 768 KiB |
BIN
src/uploads/images/img_68530fbf34fd66.94667834.png
Executable file
|
After Width: | Height: | Size: 768 KiB |
BIN
src/uploads/images/img_685310c0348007.20030955.png
Executable file
|
After Width: | Height: | Size: 768 KiB |
BIN
src/uploads/images/img_68531110a66323.47909040.png
Executable file
|
After Width: | Height: | Size: 768 KiB |
BIN
src/uploads/images/img_685311fd9dc421.95301293.png
Executable file
|
After Width: | Height: | Size: 768 KiB |
BIN
src/uploads/images/img_685312afc6c000.67281690.png
Executable file
|
After Width: | Height: | Size: 768 KiB |
BIN
src/uploads/images/img_685313c68df985.82769092.png
Executable file
|
After Width: | Height: | Size: 768 KiB |
BIN
src/uploads/images/img_685314c336de58.35409547.png
Executable file
|
After Width: | Height: | Size: 768 KiB |
BIN
src/uploads/images/img_68531517362e81.34960172.png
Executable file
|
After Width: | Height: | Size: 236 KiB |
BIN
src/uploads/images/img_685316a6098399.74821974.png
Executable file
|
After Width: | Height: | Size: 768 KiB |
BIN
src/uploads/images/img_68531a8befbae4.75954207.png
Executable file
|
After Width: | Height: | Size: 768 KiB |
BIN
src/uploads/images/img_68555c338e1915.37648028.png
Executable file
|
After Width: | Height: | Size: 236 KiB |
BIN
src/uploads/images/img_68555e40030f77.87225795.png
Executable file
|
After Width: | Height: | Size: 668 KiB |
BIN
src/uploads/images/img_68692cad8256a4.61815158.png
Executable file
|
After Width: | Height: | Size: 22 KiB |
BIN
src/uploads/images/img_686eade3358e94.97676456.jpg
Executable file
|
After Width: | Height: | Size: 87 KiB |
BIN
src/uploads/images/img_686eb198525ec7.50536364.jpg
Executable file
|
After Width: | Height: | Size: 862 KiB |
BIN
src/uploads/images/img_686eb1ef18f034.45725063.png
Executable file
|
After Width: | Height: | Size: 422 KiB |
BIN
src/uploads/images/img_686eb212d64645.26126428.png
Executable file
|
After Width: | Height: | Size: 422 KiB |
BIN
src/uploads/images/img_686eb271e0c6a9.39415484.jpg
Executable file
|
After Width: | Height: | Size: 46 KiB |
BIN
src/uploads/images/img_686eb3086df945.36369833.png
Executable file
|
After Width: | Height: | Size: 196 KiB |
BIN
src/uploads/images/img_686eb33807fd63.00035931.png
Executable file
|
After Width: | Height: | Size: 218 KiB |
BIN
src/uploads/images/img_686eb5bc838ae5.80233818.png
Executable file
|
After Width: | Height: | Size: 211 KiB |
BIN
src/uploads/images/img_686ebd1e6af955.32076521.png
Executable file
|
After Width: | Height: | Size: 188 KiB |
BIN
src/uploads/images/img_686ebdb32de4d3.21230679.jpg
Executable file
|
After Width: | Height: | Size: 54 KiB |
BIN
src/uploads/images/img_686ebe5846ea42.68414437.jpg
Executable file
|
After Width: | Height: | Size: 61 KiB |
BIN
src/uploads/images/img_686ebeb3473cf8.68015324.jpg
Executable file
|
After Width: | Height: | Size: 59 KiB |
BIN
src/uploads/images/img_686ebeec251778.89470573.jpg
Executable file
|
After Width: | Height: | Size: 71 KiB |
BIN
src/uploads/images/img_686ebfa1bee290.81344086.png
Executable file
|
After Width: | Height: | Size: 203 KiB |
BIN
src/uploads/images/img_686ec0042d3ad8.71473135.jpg
Executable file
|
After Width: | Height: | Size: 98 KiB |
BIN
src/uploads/images/img_686ec04d316485.12649368.png
Executable file
|
After Width: | Height: | Size: 144 KiB |
BIN
src/uploads/images/img_686ec070bc29e1.89910936.jpg
Executable file
|
After Width: | Height: | Size: 77 KiB |
BIN
src/uploads/images/img_686f30ac792db4.81124214.jpg
Executable file
|
After Width: | Height: | Size: 63 KiB |
BIN
src/uploads/images/img_686f30d181b9d3.27666927.jpg
Executable file
|
After Width: | Height: | Size: 61 KiB |
BIN
src/uploads/images/img_686f30fc1384a1.47381841.jpg
Executable file
|
After Width: | Height: | Size: 46 KiB |
BIN
src/uploads/images/img_686f31607f1ef4.88047468.jpg
Executable file
|
After Width: | Height: | Size: 81 KiB |
BIN
src/uploads/images/img_686f317edd6d15.06826823.jpg
Executable file
|
After Width: | Height: | Size: 66 KiB |
BIN
src/uploads/images/img_686f31c24a1eb8.68836662.png
Executable file
|
After Width: | Height: | Size: 118 KiB |
BIN
src/uploads/images/img_686f31e47abfd0.63425175.jpg
Executable file
|
After Width: | Height: | Size: 83 KiB |
BIN
src/uploads/images/img_686f3279b68214.98565257.jpg
Executable file
|
After Width: | Height: | Size: 72 KiB |
BIN
src/uploads/images/img_686f3303e805a2.81536340.png
Executable file
|
After Width: | Height: | Size: 344 KiB |
BIN
src/uploads/images/img_686f333fee46c9.36367057.jpg
Executable file
|
After Width: | Height: | Size: 73 KiB |
BIN
src/uploads/images/img_686f338c360455.17276035.png
Executable file
|
After Width: | Height: | Size: 50 KiB |
BIN
src/uploads/images/img_686f33cdb1c4e2.11987545.png
Executable file
|
After Width: | Height: | Size: 209 KiB |
BIN
src/uploads/images/img_686f33f86534c1.78395780.jpg
Executable file
|
After Width: | Height: | Size: 55 KiB |
BIN
src/uploads/images/img_686f34baadbfe5.18832378.png
Executable file
|
After Width: | Height: | Size: 280 KiB |
BIN
src/uploads/images/img_686f34e7636910.40820361.png
Executable file
|
After Width: | Height: | Size: 60 KiB |
BIN
src/uploads/images/img_686f356cb64139.39735084.jpg
Executable file
|
After Width: | Height: | Size: 50 KiB |
BIN
src/uploads/images/img_686f35958b7936.67005045.png
Executable file
|
After Width: | Height: | Size: 311 KiB |
BIN
src/uploads/images/img_686f35ae363884.64628771.png
Executable file
|
After Width: | Height: | Size: 305 KiB |
BIN
src/uploads/images/img_686f370f1976e2.93287285.jpg
Executable file
|
After Width: | Height: | Size: 37 KiB |
BIN
src/uploads/images/img_686f3730560947.83615495.jpg
Executable file
|
After Width: | Height: | Size: 42 KiB |
BIN
src/uploads/images/img_686f3749a170b4.13348961.jpg
Executable file
|
After Width: | Height: | Size: 58 KiB |
BIN
src/uploads/images/img_686f37a6ba9275.53457633.jpg
Executable file
|
After Width: | Height: | Size: 38 KiB |
BIN
src/uploads/images/img_686f37caede896.58256974.jpg
Executable file
|
After Width: | Height: | Size: 39 KiB |
BIN
src/uploads/images/img_686f37ed51ac20.50338733.jpg
Executable file
|
After Width: | Height: | Size: 42 KiB |
BIN
src/uploads/images/img_686f380db80c43.30914740.jpg
Executable file
|
After Width: | Height: | Size: 39 KiB |
BIN
src/uploads/images/img_686f38899806b0.95257065.jpg
Executable file
|
After Width: | Height: | Size: 44 KiB |
BIN
src/uploads/images/img_686f394288dcd3.19768162.jpg
Executable file
|
After Width: | Height: | Size: 45 KiB |
BIN
src/uploads/images/img_686f39aa04eae3.04553530.jpg
Executable file
|
After Width: | Height: | Size: 52 KiB |
BIN
src/uploads/images/img_686f39cf707ac1.84361658.jpg
Executable file
|
After Width: | Height: | Size: 58 KiB |
BIN
src/uploads/images/img_686f39f4215f93.08395251.jpg
Executable file
|
After Width: | Height: | Size: 74 KiB |
BIN
src/uploads/images/img_686f3a1a2a0037.73633683.jpg
Executable file
|
After Width: | Height: | Size: 108 KiB |