Parcourir la source

consolidating orgs, categories, faq in settings panel. Fixing some bugs and docs

Medowar il y a 6 jours
Parent
commit
10f1a06163

+ 1 - 1
README.md

@@ -38,7 +38,7 @@ Dieses Projekt ist ein internes Bestellsystem für persönliche Schutzausrüstun
 3. Statische Dateien bereitstellen: `favicon.png` (Document Root), `assets/branding/`, `assets/fonts/`, `assets/no-image.jpg`.
 4. Adminzugänge in `data/admins.json` auf dem Server pflegen (nicht aus dem Repo übernehmen).
 5. Empfängeradresse, PDF-Anhang und Backups im Admin unter `Einstellungen` prüfen.
-6. Organisationen im Admin unter `Organisationen verwalten` pflegen.
+6. Kategorien, FAQ und Organisationen im Admin unter `Einstellungen` pflegen.
 7. Apache: `.htaccess` aktiv (schützt `config.php` und JSON unter `data/`).
 
 Weitere Konstanten: [docs/CONFIG_REFERENCE.md](docs/CONFIG_REFERENCE.md).

+ 0 - 254
admin/categories.php

@@ -1,254 +0,0 @@
-<?php
-require_once __DIR__ . "/../config.php";
-require_once __DIR__ . "/../includes/functions.php";
-
-// Check admin login
-if (!isset($_SESSION['admin_logged_in']) || !$_SESSION['admin_logged_in']) {
-    header("Location: login.php");
-    exit();
-}
-
-$pageTitle = "Kategorien verwalten";
-$message = "";
-$messageType = "";
-$categories = getCategories();
-$products = getProducts();
-
-if ($_SERVER['REQUEST_METHOD'] === "POST") {
-    // Validate CSRF token
-    if (!validateCsrfToken($_POST['csrf_token'] ?? "")) {
-        $message = "Ungültiges Token. Bitte versuchen Sie es erneut.";
-        $messageType = "error";
-    } else {
-        if (isset($_POST['add_category'])) {
-            $label = normalizeCategoryLabel($_POST['label'] ?? "");
-
-            if (!isValidCategoryLabel($label)) {
-                $message =
-                    "Bitte geben Sie einen Kategorienamen mit maximal 80 Zeichen ein.";
-                $messageType = "error";
-            } else {
-                $categoryId = generateCategoryIdFromLabel($label, $categories);
-                $categories[] = [
-                    "id" => $categoryId,
-                    "label" => $label,
-                ];
-                if (saveCategories($categories)) {
-                    logAccess("Admin added category", [
-                        "category_id" => $categoryId,
-                        "label" => $label,
-                    ]);
-                    $message = "Kategorie wurde erfolgreich angelegt.";
-                    $messageType = "success";
-                } else {
-                    $message = "Kategorie konnte nicht gespeichert werden.";
-                    $messageType = "error";
-                }
-            }
-        }
-
-        if (isset($_POST['update_category'])) {
-            $categoryId = normalizeCategoryId($_POST['category_id'] ?? "");
-            $label = normalizeCategoryLabel($_POST['label'] ?? "");
-            $found = false;
-
-            if (!isValidCategoryLabel($label)) {
-                $message =
-                    "Bitte geben Sie einen Kategorienamen mit maximal 80 Zeichen ein.";
-                $messageType = "error";
-            } else {
-                foreach ($categories as &$category) {
-                    if ($category["id"] === $categoryId) {
-                        $category["label"] = $label;
-                        $found = true;
-                        break;
-                    }
-                }
-                unset($category);
-
-                if (!$found) {
-                    $message = "Kategorie nicht gefunden.";
-                    $messageType = "error";
-                } else {
-                    if (saveCategories($categories)) {
-                        logAccess("Admin updated category", [
-                            "category_id" => $categoryId,
-                            "label" => $label,
-                        ]);
-                        $message = "Kategorie wurde erfolgreich aktualisiert.";
-                        $messageType = "success";
-                    } else {
-                        $message =
-                            "Kategorie konnte nicht gespeichert werden.";
-                        $messageType = "error";
-                    }
-                }
-            }
-        }
-
-        if (isset($_POST['delete_category'])) {
-            $categoryId = normalizeCategoryId($_POST['category_id'] ?? "");
-            $label = "";
-            $found = false;
-
-            if (isCategoryInUse($categoryId)) {
-                $message =
-                    "Diese Kategorie wird noch von Produkten verwendet und kann nicht gelöscht werden.";
-                $messageType = "error";
-            } else {
-                $categories = array_values(
-                    array_filter($categories, function ($category) use (
-                        $categoryId,
-                        &$found,
-                        &$label,
-                    ) {
-                        if ($category["id"] === $categoryId) {
-                            $found = true;
-                            $label = $category["label"];
-                            return false;
-                        }
-                        return true;
-                    }),
-                );
-
-                if (!$found) {
-                    $message = "Kategorie nicht gefunden.";
-                    $messageType = "error";
-                } else {
-                    if (saveCategories($categories)) {
-                        logAccess("Admin deleted category", [
-                            "category_id" => $categoryId,
-                            "label" => $label,
-                        ]);
-                        $message = "Kategorie wurde gelöscht.";
-                        $messageType = "success";
-                    } else {
-                        $message = "Kategorie konnte nicht gelöscht werden.";
-                        $messageType = "error";
-                    }
-                }
-            }
-        }
-
-        $categories = getCategories();
-        $products = getProducts();
-    }
-}
-
-$editCategoryId = normalizeCategoryId($_GET['edit'] ?? "");
-$editingCategory = null;
-if ($editCategoryId !== "") {
-    $editingCategory = getCategoryById($editCategoryId);
-    if ($editingCategory === null && $message === "") {
-        $message = "Ausgewählte Kategorie wurde nicht gefunden.";
-        $messageType = "error";
-    }
-}
-
-$bodyClass = "admin-page";
-include __DIR__ . "/../includes/header.php";
-?>
-
-<div class="admin-header">
-    <h2>Kategorien verwalten</h2>
-    <div>
-        <a href="index.php" class="btn btn-secondary">Zurück zum Dashboard</a>
-    </div>
-</div>
-
-<?php if ($message !== ""): ?>
-    <div class="alert alert-<?php echo $messageType; ?>">
-        <?php echo htmlspecialchars($message); ?>
-    </div>
-<?php endif; ?>
-
-<?php if ($editingCategory !== null): ?>
-    <div class="panel panel-lg">
-        <h3>Kategorie bearbeiten</h3>
-        <form method="POST">
-            <?php echo csrfField(); ?>
-            <input type="hidden" name="category_id" value="<?php echo htmlspecialchars(
-                $editingCategory["id"],
-            ); ?>">
-            <div class="form-group">
-                <label for="category_id_display">ID</label>
-                <input type="text" id="category_id_display" value="<?php echo htmlspecialchars(
-                    $editingCategory["id"],
-                ); ?>" readonly>
-                <small>Die ID sollte gleichbleiben, damit Produktzuordnungen und Filter-URLs gültig bleiben.</small>
-            </div>
-            <div class="form-group">
-                <label for="label_edit">Name *</label>
-                <input type="text" id="label_edit" name="label" maxlength="80" required value="<?php echo htmlspecialchars(
-                    $editingCategory["label"],
-                ); ?>">
-            </div>
-            <button type="submit" name="update_category" class="btn">Kategorie aktualisieren</button>
-            <a href="categories.php" class="btn btn-secondary">Abbrechen</a>
-        </form>
-    </div>
-<?php else: ?>
-    <div class="panel panel-lg">
-        <h3>Neue Kategorie anlegen</h3>
-        <form method="POST">
-            <?php echo csrfField(); ?>
-            <div class="form-group">
-                <label for="label">Name *</label>
-                <input type="text" id="label" name="label" maxlength="80" required placeholder="z.B. Accessoires">
-                <small>Die technische ID wird einmalig automatisch aus dem Namen erzeugt.</small>
-            </div>
-            <button type="submit" name="add_category" class="btn">Kategorie anlegen</button>
-        </form>
-    </div>
-<?php endif; ?>
-
-<div class="panel">
-    <h3>Kategorien</h3>
-    <div class="table-responsive">
-        <table class="responsive-table">
-            <thead>
-                <tr>
-                    <th>Name</th>
-                    <th>ID</th>
-                    <th>Produkte</th>
-                    <th>Aktionen</th>
-                </tr>
-            </thead>
-            <tbody>
-                <?php foreach ($categories as $category): ?>
-                    <?php
-                    $productCount = 0;
-                    foreach ($products as $product) {
-                        if (productHasCategory($product, $category["id"])) {
-                            $productCount++;
-                        }
-                    }
-                    ?>
-                    <tr>
-                        <td data-label="Name"><?php echo htmlspecialchars(
-                            $category["label"],
-                        ); ?></td>
-                        <td data-label="ID"><code><?php echo htmlspecialchars(
-                            $category["id"],
-                        ); ?></code></td>
-                        <td data-label="Produkte"><?php echo $productCount; ?></td>
-                        <td data-label="Aktionen">
-                            <a href="categories.php?edit=<?php echo urlencode(
-                                $category["id"],
-                            ); ?>" class="btn btn-small btn-secondary">Bearbeiten</a>
-                            <form method="POST" class="inline-form" onsubmit="return confirm('Kategorie wirklich löschen?');">
-                                <?php echo csrfField(); ?>
-                                <input type="hidden" name="category_id" value="<?php echo htmlspecialchars(
-                                    $category["id"],
-                                ); ?>">
-                                <button type="submit" name="delete_category" class="btn btn-small">Löschen</button>
-                            </form>
-                        </td>
-                    </tr>
-                <?php endforeach; ?>
-            </tbody>
-        </table>
-    </div>
-</div>
-
-<?php include __DIR__ . "/../includes/footer.php"; ?>

+ 0 - 82
admin/faq.php

@@ -1,82 +0,0 @@
-<?php
-require_once __DIR__ . "/../config.php";
-require_once __DIR__ . "/../includes/functions.php";
-
-// Check admin login
-if (!isset($_SESSION['admin_logged_in']) || !$_SESSION['admin_logged_in']) {
-    header("Location: login.php");
-    exit();
-}
-
-$pageTitle = "FAQ bearbeiten";
-$message = "";
-$messageType = "";
-$settings = getSystemSettings();
-
-if ($_SERVER['REQUEST_METHOD'] === "POST" && isset($_POST['save_faq'])) {
-    // Validate CSRF token
-    if (!validateCsrfToken($_POST['csrf_token'] ?? "")) {
-        $message = "Ungültiges Token. Bitte versuchen Sie es erneut.";
-        $messageType = "error";
-    } else {
-        $content = isset($_POST['content']) ? (string) $_POST['content'] : "";
-        $settings["startpage_intro_text"] = isset($_POST['startpage_intro_text'])
-            ? (string) $_POST['startpage_intro_text']
-            : "";
-
-        if (saveFaqContent($content) && saveSystemSettings($settings)) {
-            logAccess("Admin updated FAQ content");
-            $message = "FAQ-Inhalt und Startseitentext wurden gespeichert.";
-            $messageType = "success";
-        } else {
-            $message = "FAQ-Inhalt und/oder Startseitentext konnten nicht gespeichert werden.";
-            $messageType = "error";
-        }
-    }
-}
-
-$faqContent = getFaqContent();
-$settings = getSystemSettings();
-$startpageIntroText = (string) ($settings["startpage_intro_text"] ?? "");
-
-$bodyClass = "admin-page";
-include __DIR__ . "/../includes/header.php";
-?>
-
-<div class="admin-header">
-    <h2>FAQ bearbeiten</h2>
-    <div>
-        <a href="index.php" class="btn btn-secondary">Zurück zum Dashboard</a>
-    </div>
-</div>
-
-<?php if ($message): ?>
-    <div class="alert alert-<?php echo $messageType; ?>">
-        <?php echo htmlspecialchars($message); ?>
-    </div>
-<?php endif; ?>
-
-<div class="panel panel-lg">
-    <p class="mb-2">
-        Unterstützte Markdown-Syntax: <code>#</code>, <code>##</code>, <code>###</code>, <code>**fett**</code>, <code>*kursiv*</code>, Listen mit <code>-</code> oder <code>1.</code>, Links mit <code>[Text](https://example.com)</code>
-    </p>
-
-    <form method="POST">
-        <?php echo csrfField(); ?>
-        <div class="form-group">
-            <label for="content">FAQ-Inhalt (Markdown)</label>
-            <textarea id="content" name="content" rows="18"><?php echo htmlspecialchars(
-                $faqContent,
-            ); ?></textarea>
-        </div>
-        <div class="form-group">
-            <label for="startpage_intro_text">Startseitentext</label>
-            <textarea id="startpage_intro_text" name="startpage_intro_text" rows="6"><?php echo htmlspecialchars(
-                $startpageIntroText,
-            ); ?></textarea>
-        </div>
-        <button type="submit" name="save_faq" class="btn">Speichern</button>
-    </form>
-</div>
-
-<?php include __DIR__ . "/../includes/footer.php"; ?>

+ 1 - 4
admin/index.php

@@ -126,12 +126,9 @@ include __DIR__ . '/../includes/header.php';
             <summary class="btn btn-secondary">Verwaltung</summary>
             <div class="admin-actions-menu">
                 <a href="products.php">Produkte verwalten</a>
-                <a href="categories.php">Kategorien verwalten</a>
-                <a href="organizations.php">Organisationen verwalten</a>
-                <a href="settings.php">Einstellungen</a>
-                <a href="faq.php">FAQ bearbeiten</a>
                 <a href="backorders.php">Nachbestellungen verwalten</a>
                 <a href="nametags.php">Namensschilder verwalten</a>
+                <a href="settings.php">Einstellungen</a>
                 <a href="admins.php">Admins verwalten</a>
                 <form method="POST" action="login.php" class="inline-form">
                     <?php echo csrfField(); ?>

+ 0 - 264
admin/organizations.php

@@ -1,264 +0,0 @@
-<?php
-require_once __DIR__ . "/../config.php";
-require_once __DIR__ . "/../includes/functions.php";
-
-if (empty($_SESSION['admin_logged_in'])) {
-    header("Location: login.php");
-    exit();
-}
-
-$pageTitle = "Organisationen verwalten";
-$message = "";
-$messageType = "";
-$organizations = getOrganizations(false);
-
-if ($_SERVER['REQUEST_METHOD'] === "POST") {
-    // Validate CSRF token
-    if (!validateCsrfToken($_POST['csrf_token'] ?? "")) {
-        $message = "Ungültiges Token. Bitte versuchen Sie es erneut.";
-        $messageType = "error";
-    } else {
-        if (isset($_POST['add_organization'])) {
-            $label = normalizeOrganizationLabel($_POST['label'] ?? "");
-            $sortOrder = (int) ($_POST['sort_order'] ?? 0);
-            $active = isset($_POST['active']);
-
-            if (!isValidOrganizationLabel($label)) {
-                $message =
-                    "Bitte einen Organisationsnamen mit maximal 120 Zeichen eingeben.";
-                $messageType = "error";
-            } else {
-                $orgId = generateOrganizationIdFromLabel(
-                    $label,
-                    $organizations,
-                );
-                $organizations[] = [
-                    "id" => $orgId,
-                    "label" => $label,
-                    "sort_order" => $sortOrder,
-                    "active" => $active,
-                ];
-                if (saveOrganizations($organizations)) {
-                    logAccess("Admin added organization", [
-                        "org_id" => $orgId,
-                        "label" => $label,
-                    ]);
-                    $message = "Organisation wurde angelegt.";
-                    $messageType = "success";
-                } else {
-                    $message = "Organisation konnte nicht gespeichert werden.";
-                    $messageType = "error";
-                }
-            }
-        }
-
-        if (isset($_POST['update_organization'])) {
-            $organizationId = normalizeOrganizationId(
-                $_POST['organization_id'] ?? "",
-            );
-            $label = normalizeOrganizationLabel($_POST['label'] ?? "");
-            $sortOrder = (int) ($_POST['sort_order'] ?? 0);
-            $active = isset($_POST['active']);
-            $updated = false;
-
-            if (!isValidOrganizationLabel($label)) {
-                $message =
-                    "Bitte einen Organisationsnamen mit maximal 120 Zeichen eingeben.";
-                $messageType = "error";
-            } else {
-                foreach ($organizations as &$organization) {
-                    if ($organization["id"] !== $organizationId) {
-                        continue;
-                    }
-
-                    $organization["label"] = $label;
-                    $organization["sort_order"] = $sortOrder;
-                    $organization["active"] = $active;
-                    $updated = true;
-                    break;
-                }
-                unset($organization);
-
-                if ($updated) {
-                    if (saveOrganizations($organizations)) {
-                        logAccess("Admin updated organization", [
-                            "org_id" => $organizationId,
-                            "label" => $label,
-                        ]);
-                        $message = "Organisation wurde aktualisiert.";
-                        $messageType = "success";
-                    } else {
-                        $message =
-                            "Organisation konnte nicht gespeichert werden.";
-                        $messageType = "error";
-                    }
-                } else {
-                    $message = "Organisation nicht gefunden.";
-                    $messageType = "error";
-                }
-            }
-        }
-
-        if (isset($_POST['delete_organization'])) {
-            $organizationId = normalizeOrganizationId(
-                $_POST['organization_id'] ?? "",
-            );
-            $orgLabel = "";
-            $found = false;
-            foreach ($organizations as $organization) {
-                if ($organization["id"] === $organizationId) {
-                    $orgLabel = $organization["label"];
-                    break;
-                }
-            }
-            $organizations = array_values(
-                array_filter($organizations, function ($organization) use (
-                    $organizationId,
-                ) {
-                    return $organization["id"] !== $organizationId;
-                }),
-            );
-            if (saveOrganizations($organizations)) {
-                logAccess("Admin deleted organization", [
-                    "org_id" => $organizationId,
-                    "label" => $orgLabel,
-                ]);
-                $message = "Organisation wurde gelöscht.";
-                $messageType = "success";
-            } else {
-                $message = "Organisation konnte nicht gelöscht werden.";
-                $messageType = "error";
-            }
-        }
-
-        $organizations = getOrganizations(false);
-    }
-}
-
-$editingOrganization = isset($_GET['edit'])
-    ? getOrganizationById($_GET['edit'])
-    : null;
-
-$bodyClass = "admin-page";
-include __DIR__ . "/../includes/header.php";
-?>
-
-<div class="admin-header">
-    <h2>Organisationen verwalten</h2>
-    <div>
-        <a href="index.php" class="btn btn-secondary">Zurück zum Dashboard</a>
-    </div>
-</div>
-
-<?php if ($message !== ""): ?>
-    <div class="alert alert-<?php echo escape($messageType); ?>">
-        <?php echo escape($message); ?>
-    </div>
-<?php endif; ?>
-
-<div class="panel panel-lg">
-    <h3><?php echo $editingOrganization
-        ? "Organisation bearbeiten"
-        : "Neue Organisation"; ?></h3>
-    <form method="POST">
-        <?php echo csrfField(); ?>
-        <?php if ($editingOrganization): ?>
-            <input type="hidden" name="organization_id" value="<?php echo escape(
-                $editingOrganization["id"],
-            ); ?>">
-        <?php endif; ?>
-
-        <div class="form-group">
-            <label for="label">Name *</label>
-            <input type="text" id="label" name="label" required maxlength="120" value="<?php echo escape(
-                $editingOrganization["label"] ?? "",
-            ); ?>">
-        </div>
-
-        <div class="form-group">
-            <label for="sort_order">Sortierung</label>
-            <input type="number" id="sort_order" name="sort_order" value="<?php echo escape(
-                (string) ($editingOrganization["sort_order"] ?? 0),
-            ); ?>">
-        </div>
-
-        <div class="form-group">
-            <label>
-                <input type="checkbox" name="active" value="1" <?php echo !isset(
-                    $editingOrganization["active"],
-                ) || !empty($editingOrganization["active"])
-                    ? "checked"
-                    : ""; ?>>
-                Organisation ist auswählbar
-            </label>
-        </div>
-
-        <button type="submit" name="<?php echo $editingOrganization
-            ? "update_organization"
-            : "add_organization"; ?>" class="btn">
-            <?php echo $editingOrganization
-                ? "Speichern"
-                : "Organisation anlegen"; ?>
-        </button>
-        <?php if ($editingOrganization): ?>
-            <a href="organizations.php" class="btn btn-secondary">Abbrechen</a>
-        <?php endif; ?>
-    </form>
-</div>
-
-<div class="panel">
-    <h3>Organisationen</h3>
-    <div class="table-responsive">
-        <table class="responsive-table">
-            <thead>
-                <tr>
-                    <th>Name</th>
-                    <th>ID</th>
-                    <th>Sortierung</th>
-                    <th>Status</th>
-                    <th>Aktionen</th>
-                </tr>
-            </thead>
-            <tbody>
-                <?php foreach ($organizations as $organization): ?>
-                    <tr>
-                        <td data-label="Name"><?php echo escape(
-                            $organization["label"],
-                        ); ?></td>
-                        <td data-label="ID"><code><?php echo escape(
-                            $organization["id"],
-                        ); ?></code></td>
-                        <td data-label="Sortierung"><?php echo (int) $organization[
-                            "sort_order"
-                        ]; ?></td>
-                        <td data-label="Status">
-                            <span class="status <?php echo !empty(
-                                $organization["active"]
-                            )
-                                ? "status-open"
-                                : "status-cancelled"; ?>">
-                                <?php echo !empty($organization["active"])
-                                    ? "Aktiv"
-                                    : "Inaktiv"; ?>
-                            </span>
-                        </td>
-                        <td data-label="Aktionen">
-                            <a href="organizations.php?edit=<?php echo urlencode(
-                                $organization["id"],
-                            ); ?>" class="btn btn-small">Bearbeiten</a>
-                             <form method="POST" class="inline-form" onsubmit="return confirm('Organisation wirklich löschen?');">
-                                <?php echo csrfField(); ?>
-                                <input type="hidden" name="organization_id" value="<?php echo escape(
-                                    $organization["id"],
-                                ); ?>">
-                                <button type="submit" name="delete_organization" class="btn btn-secondary btn-small">Löschen</button>
-                            </form>
-                        </td>
-                    </tr>
-                <?php endforeach; ?>
-            </tbody>
-        </table>
-    </div>
-</div>
-
-<?php include __DIR__ . "/../includes/footer.php"; ?>

+ 493 - 21
admin/settings.php

@@ -152,6 +152,12 @@ function settingsWriteLocalConfig(string $content): void
         throw new RuntimeException('Die Konfiguration muss mit "<?php" beginnen.');
     }
 
+    try {
+        token_get_all($content, TOKEN_PARSE);
+    } catch (ParseError $parseError) {
+        throw new RuntimeException("PHP-Syntaxfehler: " . $parseError->getMessage());
+    }
+
     $path = settingsGetLocalConfigPath();
     $tmpFile = $path . ".tmp";
 
@@ -159,21 +165,8 @@ function settingsWriteLocalConfig(string $content): void
         throw new RuntimeException("Konfiguration konnte nicht geschrieben werden.");
     }
 
-    $output = [];
-    $exitCode = 0;
-    exec(
-        escapeshellarg(PHP_BINARY) . " -l " . escapeshellarg($tmpFile) . " 2>&1",
-        $output,
-        $exitCode,
-    );
-
-    if ($exitCode !== 0) {
-        @unlink($tmpFile);
-        throw new RuntimeException("PHP-Syntaxfehler: " . trim(implode("\n", $output)));
-    }
-
     $backupDir = DATA_DIR . "backups/config/";
-    if (!is_dir($backupDir) && !mkdir($backupDir, 02775, true) && !is_dir($backupDir)) {
+    if (!is_dir($backupDir) && !mkdir($backupDir, 0775, true) && !is_dir($backupDir)) {
         @unlink($tmpFile);
         throw new RuntimeException("Sicherungsverzeichnis konnte nicht angelegt werden.");
     }
@@ -325,6 +318,252 @@ if ($_SERVER['REQUEST_METHOD'] === "POST" && isset($_POST['save_settings'])) {
             $messageType = "error";
         }
     }
+} elseif ($_SERVER['REQUEST_METHOD'] === "POST" && isset($_POST['add_category'])) {
+    if (!validateCsrfToken($_POST['csrf_token'] ?? "")) {
+        $message = "Ungültiges Token. Bitte versuchen Sie es erneut.";
+        $messageType = "error";
+    } else {
+        $categories = getCategories();
+        $label = normalizeCategoryLabel($_POST['category_label'] ?? "");
+
+        if (!isValidCategoryLabel($label)) {
+            $message = "Bitte geben Sie einen Kategorienamen mit maximal 80 Zeichen ein.";
+            $messageType = "error";
+        } else {
+            $categoryId = generateCategoryIdFromLabel($label, $categories);
+            $categories[] = [
+                "id" => $categoryId,
+                "label" => $label,
+            ];
+            if (saveCategories($categories)) {
+                logAccess("Admin added category", [
+                    "category_id" => $categoryId,
+                    "label" => $label,
+                ]);
+                $message = "Kategorie wurde erfolgreich angelegt.";
+                $messageType = "success";
+            } else {
+                $message = "Kategorie konnte nicht gespeichert werden.";
+                $messageType = "error";
+            }
+        }
+    }
+} elseif ($_SERVER['REQUEST_METHOD'] === "POST" && isset($_POST['update_category'])) {
+    if (!validateCsrfToken($_POST['csrf_token'] ?? "")) {
+        $message = "Ungültiges Token. Bitte versuchen Sie es erneut.";
+        $messageType = "error";
+    } else {
+        $categories = getCategories();
+        $categoryId = normalizeCategoryId($_POST['category_id'] ?? "");
+        $label = normalizeCategoryLabel($_POST['category_label'] ?? "");
+        $found = false;
+
+        if (!isValidCategoryLabel($label)) {
+            $message = "Bitte geben Sie einen Kategorienamen mit maximal 80 Zeichen ein.";
+            $messageType = "error";
+        } else {
+            foreach ($categories as &$category) {
+                if ($category["id"] === $categoryId) {
+                    $category["label"] = $label;
+                    $found = true;
+                    break;
+                }
+            }
+            unset($category);
+
+            if (!$found) {
+                $message = "Kategorie nicht gefunden.";
+                $messageType = "error";
+            } elseif (saveCategories($categories)) {
+                logAccess("Admin updated category", [
+                    "category_id" => $categoryId,
+                    "label" => $label,
+                ]);
+                $message = "Kategorie wurde erfolgreich aktualisiert.";
+                $messageType = "success";
+            } else {
+                $message = "Kategorie konnte nicht gespeichert werden.";
+                $messageType = "error";
+            }
+        }
+    }
+} elseif ($_SERVER['REQUEST_METHOD'] === "POST" && isset($_POST['delete_category'])) {
+    if (!validateCsrfToken($_POST['csrf_token'] ?? "")) {
+        $message = "Ungültiges Token. Bitte versuchen Sie es erneut.";
+        $messageType = "error";
+    } else {
+        $categoryId = normalizeCategoryId($_POST['category_id'] ?? "");
+        $label = "";
+        $found = false;
+
+        if (isCategoryInUse($categoryId)) {
+            $message =
+                "Diese Kategorie wird noch von Produkten verwendet und kann nicht gelöscht werden.";
+            $messageType = "error";
+        } else {
+            $categories = array_values(
+                array_filter(getCategories(), function ($category) use (
+                    $categoryId,
+                    &$found,
+                    &$label,
+                ) {
+                    if ($category["id"] === $categoryId) {
+                        $found = true;
+                        $label = $category["label"];
+                        return false;
+                    }
+                    return true;
+                }),
+            );
+
+            if (!$found) {
+                $message = "Kategorie nicht gefunden.";
+                $messageType = "error";
+            } elseif (saveCategories($categories)) {
+                logAccess("Admin deleted category", [
+                    "category_id" => $categoryId,
+                    "label" => $label,
+                ]);
+                $message = "Kategorie wurde gelöscht.";
+                $messageType = "success";
+            } else {
+                $message = "Kategorie konnte nicht gelöscht werden.";
+                $messageType = "error";
+            }
+        }
+    }
+} elseif ($_SERVER['REQUEST_METHOD'] === "POST" && isset($_POST['save_faq'])) {
+    if (!validateCsrfToken($_POST['csrf_token'] ?? "")) {
+        $message = "Ungültiges Token. Bitte versuchen Sie es erneut.";
+        $messageType = "error";
+    } else {
+        $content = isset($_POST['content']) ? (string) $_POST['content'] : "";
+        $faqSettings = array_merge(getSystemSettings(), [
+            "startpage_intro_text" => isset($_POST['startpage_intro_text'])
+                ? (string) $_POST['startpage_intro_text']
+                : "",
+        ]);
+
+        if (saveFaqContent($content) && saveSystemSettings($faqSettings)) {
+            logAccess("Admin updated FAQ content");
+            $message = "FAQ-Inhalt und Startseitentext wurden gespeichert.";
+            $messageType = "success";
+        } else {
+            $message = "FAQ-Inhalt und/oder Startseitentext konnten nicht gespeichert werden.";
+            $messageType = "error";
+        }
+    }
+} elseif ($_SERVER['REQUEST_METHOD'] === "POST" && isset($_POST['add_organization'])) {
+    if (!validateCsrfToken($_POST['csrf_token'] ?? "")) {
+        $message = "Ungültiges Token. Bitte versuchen Sie es erneut.";
+        $messageType = "error";
+    } else {
+        $organizations = getOrganizations(false);
+        $label = normalizeOrganizationLabel($_POST['organization_label'] ?? "");
+        $sortOrder = (int) ($_POST['sort_order'] ?? 0);
+        $active = isset($_POST['active']);
+
+        if (!isValidOrganizationLabel($label)) {
+            $message = "Bitte einen Organisationsnamen mit maximal 120 Zeichen eingeben.";
+            $messageType = "error";
+        } else {
+            $orgId = generateOrganizationIdFromLabel($label, $organizations);
+            $organizations[] = [
+                "id" => $orgId,
+                "label" => $label,
+                "sort_order" => $sortOrder,
+                "active" => $active,
+            ];
+            if (saveOrganizations($organizations)) {
+                logAccess("Admin added organization", [
+                    "org_id" => $orgId,
+                    "label" => $label,
+                ]);
+                $message = "Organisation wurde angelegt.";
+                $messageType = "success";
+            } else {
+                $message = "Organisation konnte nicht gespeichert werden.";
+                $messageType = "error";
+            }
+        }
+    }
+} elseif ($_SERVER['REQUEST_METHOD'] === "POST" && isset($_POST['update_organization'])) {
+    if (!validateCsrfToken($_POST['csrf_token'] ?? "")) {
+        $message = "Ungültiges Token. Bitte versuchen Sie es erneut.";
+        $messageType = "error";
+    } else {
+        $organizations = getOrganizations(false);
+        $organizationId = normalizeOrganizationId($_POST['organization_id'] ?? "");
+        $label = normalizeOrganizationLabel($_POST['organization_label'] ?? "");
+        $sortOrder = (int) ($_POST['sort_order'] ?? 0);
+        $active = isset($_POST['active']);
+        $updated = false;
+
+        if (!isValidOrganizationLabel($label)) {
+            $message = "Bitte einen Organisationsnamen mit maximal 120 Zeichen eingeben.";
+            $messageType = "error";
+        } else {
+            foreach ($organizations as &$organization) {
+                if ($organization["id"] !== $organizationId) {
+                    continue;
+                }
+
+                $organization["label"] = $label;
+                $organization["sort_order"] = $sortOrder;
+                $organization["active"] = $active;
+                $updated = true;
+                break;
+            }
+            unset($organization);
+
+            if (!$updated) {
+                $message = "Organisation nicht gefunden.";
+                $messageType = "error";
+            } elseif (saveOrganizations($organizations)) {
+                logAccess("Admin updated organization", [
+                    "org_id" => $organizationId,
+                    "label" => $label,
+                ]);
+                $message = "Organisation wurde aktualisiert.";
+                $messageType = "success";
+            } else {
+                $message = "Organisation konnte nicht gespeichert werden.";
+                $messageType = "error";
+            }
+        }
+    }
+} elseif ($_SERVER['REQUEST_METHOD'] === "POST" && isset($_POST['delete_organization'])) {
+    if (!validateCsrfToken($_POST['csrf_token'] ?? "")) {
+        $message = "Ungültiges Token. Bitte versuchen Sie es erneut.";
+        $messageType = "error";
+    } else {
+        $organizationId = normalizeOrganizationId($_POST['organization_id'] ?? "");
+        $orgLabel = "";
+        foreach (getOrganizations(false) as $organization) {
+            if ($organization["id"] === $organizationId) {
+                $orgLabel = $organization["label"];
+                break;
+            }
+        }
+        $organizations = array_values(
+            array_filter(getOrganizations(false), function ($organization) use (
+                $organizationId,
+            ) {
+                return $organization["id"] !== $organizationId;
+            }),
+        );
+        if (saveOrganizations($organizations)) {
+            logAccess("Admin deleted organization", [
+                "org_id" => $organizationId,
+                "label" => $orgLabel,
+            ]);
+            $message = "Organisation wurde gelöscht.";
+            $messageType = "success";
+        } else {
+            $message = "Organisation konnte nicht gelöscht werden.";
+            $messageType = "error";
+        }
+    }
 }
 
 $settings = getSystemSettings();
@@ -333,6 +572,26 @@ $backupCapabilities = backupRemoteCapabilities();
 $backups = backupListBackups();
 $localConfigContent = $isSuperAdmin ? settingsReadLocalConfig() : "";
 
+$categories = getCategories();
+$products = getProducts();
+$organizations = getOrganizations(false);
+$faqContent = getFaqContent();
+$startpageIntroText = (string) ($settings["startpage_intro_text"] ?? "");
+
+$editCategoryId = normalizeCategoryId($_GET['edit_category'] ?? "");
+$editingCategory = null;
+if ($editCategoryId !== "") {
+    $editingCategory = getCategoryById($editCategoryId);
+    if ($editingCategory === null && $message === "") {
+        $message = "Ausgewählte Kategorie wurde nicht gefunden.";
+        $messageType = "error";
+    }
+}
+
+$editingOrganization = isset($_GET['edit_organization'])
+    ? getOrganizationById($_GET['edit_organization'])
+    : null;
+
 $bodyClass = "admin-page";
 include __DIR__ . "/../includes/header.php";
 ?>
@@ -350,8 +609,221 @@ include __DIR__ . "/../includes/header.php";
     </div>
 <?php endif; ?>
 
-<div class="panel panel-lg">
-    <form method="POST">
+<div class="panel panel-lg" id="kategorien">
+    <h3>Kategorien</h3>
+    <?php if ($editingCategory !== null): ?>
+        <h4>Kategorie bearbeiten</h4>
+        <form method="POST" action="settings.php#kategorien">
+            <?php echo csrfField(); ?>
+            <input type="hidden" name="category_id" value="<?php echo escape(
+                $editingCategory["id"],
+            ); ?>">
+            <div class="form-group">
+                <label for="category_id_display">ID</label>
+                <input type="text" id="category_id_display" value="<?php echo escape(
+                    $editingCategory["id"],
+                ); ?>" readonly>
+                <small>Die ID sollte gleichbleiben, damit Produktzuordnungen und Filter-URLs gültig bleiben.</small>
+            </div>
+            <div class="form-group">
+                <label for="category_label_edit">Name *</label>
+                <input type="text" id="category_label_edit" name="category_label" maxlength="80" required value="<?php echo escape(
+                    $editingCategory["label"],
+                ); ?>">
+            </div>
+            <button type="submit" name="update_category" class="btn">Kategorie aktualisieren</button>
+            <a href="settings.php#kategorien" class="btn btn-secondary">Abbrechen</a>
+        </form>
+    <?php else: ?>
+        <h4>Neue Kategorie anlegen</h4>
+        <form method="POST" action="settings.php#kategorien">
+            <?php echo csrfField(); ?>
+            <div class="form-group">
+                <label for="category_label">Name *</label>
+                <input type="text" id="category_label" name="category_label" maxlength="80" required placeholder="z.B. Accessoires">
+                <small>Die technische ID wird einmalig automatisch aus dem Namen erzeugt.</small>
+            </div>
+            <button type="submit" name="add_category" class="btn">Kategorie anlegen</button>
+        </form>
+    <?php endif; ?>
+
+    <div class="table-responsive mt-4">
+        <table class="responsive-table">
+            <thead>
+                <tr>
+                    <th>Name</th>
+                    <th>ID</th>
+                    <th>Produkte</th>
+                    <th>Aktionen</th>
+                </tr>
+            </thead>
+            <tbody>
+                <?php foreach ($categories as $category): ?>
+                    <?php
+                    $productCount = 0;
+                    foreach ($products as $product) {
+                        if (productHasCategory($product, $category["id"])) {
+                            $productCount++;
+                        }
+                    }
+                    ?>
+                    <tr>
+                        <td data-label="Name"><?php echo escape(
+                            $category["label"],
+                        ); ?></td>
+                        <td data-label="ID"><code><?php echo escape(
+                            $category["id"],
+                        ); ?></code></td>
+                        <td data-label="Produkte"><?php echo $productCount; ?></td>
+                        <td data-label="Aktionen">
+                            <a href="settings.php?edit_category=<?php echo urlencode(
+                                $category["id"],
+                            ); ?>#kategorien" class="btn btn-small btn-secondary">Bearbeiten</a>
+                            <form method="POST" action="settings.php#kategorien" class="inline-form" onsubmit="return confirm('Kategorie wirklich löschen?');">
+                                <?php echo csrfField(); ?>
+                                <input type="hidden" name="category_id" value="<?php echo escape(
+                                    $category["id"],
+                                ); ?>">
+                                <button type="submit" name="delete_category" class="btn btn-small">Löschen</button>
+                            </form>
+                        </td>
+                    </tr>
+                <?php endforeach; ?>
+            </tbody>
+        </table>
+    </div>
+</div>
+
+<div class="panel panel-lg mt-4" id="faq">
+    <h3>FAQ</h3>
+    <p class="mb-2">
+        Unterstützte Markdown-Syntax: <code>#</code>, <code>##</code>, <code>###</code>, <code>**fett**</code>, <code>*kursiv*</code>, Listen mit <code>-</code> oder <code>1.</code>, Links mit <code>[Text](https://example.com)</code>
+    </p>
+
+    <form method="POST" action="settings.php#faq">
+        <?php echo csrfField(); ?>
+        <div class="form-group">
+            <label for="content">FAQ-Inhalt (Markdown)</label>
+            <textarea id="content" name="content" rows="18"><?php echo escape(
+                $faqContent,
+            ); ?></textarea>
+        </div>
+        <div class="form-group">
+            <label for="startpage_intro_text">Startseitentext</label>
+            <textarea id="startpage_intro_text" name="startpage_intro_text" rows="6"><?php echo escape(
+                $startpageIntroText,
+            ); ?></textarea>
+        </div>
+        <button type="submit" name="save_faq" class="btn">Speichern</button>
+    </form>
+</div>
+
+<div class="panel panel-lg mt-4" id="organisationen">
+    <h3>Organisationen</h3>
+    <h4><?php echo $editingOrganization
+        ? "Organisation bearbeiten"
+        : "Neue Organisation"; ?></h4>
+    <form method="POST" action="settings.php#organisationen">
+        <?php echo csrfField(); ?>
+        <?php if ($editingOrganization): ?>
+            <input type="hidden" name="organization_id" value="<?php echo escape(
+                $editingOrganization["id"],
+            ); ?>">
+        <?php endif; ?>
+
+        <div class="form-group">
+            <label for="organization_label">Name *</label>
+            <input type="text" id="organization_label" name="organization_label" required maxlength="120" value="<?php echo escape(
+                $editingOrganization["label"] ?? "",
+            ); ?>">
+        </div>
+
+        <div class="form-group">
+            <label for="sort_order">Sortierung</label>
+            <input type="number" id="sort_order" name="sort_order" value="<?php echo escape(
+                (string) ($editingOrganization["sort_order"] ?? 0),
+            ); ?>">
+        </div>
+
+        <div class="form-group">
+            <label>
+                <input type="checkbox" name="active" value="1" <?php echo !isset(
+                    $editingOrganization["active"],
+                ) || !empty($editingOrganization["active"])
+                    ? "checked"
+                    : ""; ?>>
+                Organisation ist auswählbar
+            </label>
+        </div>
+
+        <button type="submit" name="<?php echo $editingOrganization
+            ? "update_organization"
+            : "add_organization"; ?>" class="btn">
+            <?php echo $editingOrganization
+                ? "Speichern"
+                : "Organisation anlegen"; ?>
+        </button>
+        <?php if ($editingOrganization): ?>
+            <a href="settings.php#organisationen" class="btn btn-secondary">Abbrechen</a>
+        <?php endif; ?>
+    </form>
+
+    <div class="table-responsive mt-4">
+        <table class="responsive-table">
+            <thead>
+                <tr>
+                    <th>Name</th>
+                    <th>ID</th>
+                    <th>Sortierung</th>
+                    <th>Status</th>
+                    <th>Aktionen</th>
+                </tr>
+            </thead>
+            <tbody>
+                <?php foreach ($organizations as $organization): ?>
+                    <tr>
+                        <td data-label="Name"><?php echo escape(
+                            $organization["label"],
+                        ); ?></td>
+                        <td data-label="ID"><code><?php echo escape(
+                            $organization["id"],
+                        ); ?></code></td>
+                        <td data-label="Sortierung"><?php echo (int) $organization[
+                            "sort_order"
+                        ]; ?></td>
+                        <td data-label="Status">
+                            <span class="status <?php echo !empty(
+                                $organization["active"]
+                            )
+                                ? "status-open"
+                                : "status-cancelled"; ?>">
+                                <?php echo !empty($organization["active"])
+                                    ? "Aktiv"
+                                    : "Inaktiv"; ?>
+                            </span>
+                        </td>
+                        <td data-label="Aktionen">
+                            <a href="settings.php?edit_organization=<?php echo urlencode(
+                                $organization["id"],
+                            ); ?>#organisationen" class="btn btn-small">Bearbeiten</a>
+                            <form method="POST" action="settings.php#organisationen" class="inline-form" onsubmit="return confirm('Organisation wirklich löschen?');">
+                                <?php echo csrfField(); ?>
+                                <input type="hidden" name="organization_id" value="<?php echo escape(
+                                    $organization["id"],
+                                ); ?>">
+                                <button type="submit" name="delete_organization" class="btn btn-secondary btn-small">Löschen</button>
+                            </form>
+                        </td>
+                    </tr>
+                <?php endforeach; ?>
+            </tbody>
+        </table>
+    </div>
+</div>
+
+<div class="panel panel-lg mt-4" id="allgemein">
+    <h3>Allgemein</h3>
+    <form method="POST" action="settings.php#allgemein">
         <?php echo csrfField(); ?>
         <div class="form-group">
             <label for="order_recipient_email">Empfängeradresse für interne Bestellungen *</label>
@@ -383,7 +855,7 @@ include __DIR__ . "/../includes/header.php";
     </form>
 </div>
 
-<div class="panel panel-lg mt-4">
+<div class="panel panel-lg mt-4" id="backups">
     <h3>Backups</h3>
     <p>Lokale Aufbewahrung: <?php echo (int) backupGetRetentionLimit(); ?> Backups</p>
     <p>Automatisches Intervall: <?php echo (int) floor(((int) BACKUP_AUTO_INTERVAL_SECONDS) / 86400); ?> Tage</p>
@@ -394,7 +866,7 @@ include __DIR__ . "/../includes/header.php";
         Managed: <?php echo escape(settingsGetBackupCapabilityLabel($backupCapabilities["managed"])); ?>
     </p>
 
-    <form method="POST" class="inline-form">
+    <form method="POST" action="settings.php#backups" class="inline-form">
         <?php echo csrfField(); ?>
         <button type="submit" name="create_backup" class="btn">Backup erstellen</button>
     </form>
@@ -424,7 +896,7 @@ include __DIR__ . "/../includes/header.php";
                             <td data-label="Dateien"><?php echo (int) ($backup["file_count"] ?? 0); ?></td>
                             <td data-label="Remote"><?php echo escape(settingsGetBackupUploadLabel($backup)); ?></td>
                             <td data-label="Aktionen">
-                                <form method="POST" class="inline-form">
+                                <form method="POST" action="settings.php#backups" class="inline-form">
                                     <?php echo csrfField(); ?>
                                     <input type="hidden" name="backup_filename" value="<?php echo escape($backup["filename"] ?? ""); ?>">
                                     <button type="submit" name="download_backup" class="btn btn-secondary btn-small">Download</button>
@@ -446,11 +918,11 @@ include __DIR__ . "/../includes/header.php";
 </div>
 
 <?php if ($isSuperAdmin): ?>
-<div class="panel panel-lg mt-4">
+<div class="panel panel-lg mt-4" id="konfiguration">
     <h3>Lokale Konfiguration</h3>
     <p>Bearbeitet <code>config.php</code> direkt auf diesem Server. Diese Datei wird beim Update nicht überschrieben.</p>
     <div class="alert alert-warning">Vor dem Speichern wird die PHP-Syntax geprüft und die vorherige Version automatisch gesichert. Fehlerhafte Änderungen können die gesamte Anwendung lahmlegen.</div>
-    <form method="POST">
+    <form method="POST" action="settings.php#konfiguration">
         <?php echo csrfField(); ?>
         <div class="form-group">
             <label for="local_config_content">Inhalt von config.php</label>

+ 12 - 1
data/manual_backorders.json

@@ -1,3 +1,14 @@
 {
-    "entries": []
+    "entries": [
+        {
+            "id": "mb-20260612224444-9555dd16",
+            "product_id": 6,
+            "product_name": "Handschuhe THL",
+            "size": "10",
+            "backorder_status": "ordered",
+            "backordered_at": "2026-06-12 22:44:44",
+            "ordered_at": "2026-06-12 22:44:48",
+            "created_at": "2026-06-12 22:44:44"
+        }
+    ]
 }

+ 95 - 14
data/orders.json

@@ -145,14 +145,14 @@
                     "size": "M",
                     "availability_label": "",
                     "is_processed": false,
-                    "backorder_status": "to_be_backordered",
+                    "backorder_status": "ordered",
                     "backordered_at": "2026-05-30 13:29:32",
-                    "ordered_at": ""
+                    "ordered_at": "2026-06-12 22:44:34"
                 }
             ],
             "status": "partial",
             "created_at": "2026-05-30 09:58:30",
-            "updated_at": "2026-05-30 13:29:32",
+            "updated_at": "2026-06-12 22:44:34",
             "cancelled_at": "",
             "cancelled_by": "",
             "cancellation_reason": "",
@@ -172,14 +172,14 @@
                     "size": "L",
                     "availability_label": "",
                     "is_processed": false,
-                    "backorder_status": "ordered",
+                    "backorder_status": "",
                     "backordered_at": "2026-05-30 10:59:59",
-                    "ordered_at": "2026-05-30 11:10:19"
+                    "ordered_at": ""
                 }
             ],
             "status": "open",
             "created_at": "2026-05-30 10:11:13",
-            "updated_at": "2026-05-30 11:10:19",
+            "updated_at": "2026-06-12 22:43:48",
             "cancelled_at": "",
             "cancelled_by": "",
             "cancellation_reason": "",
@@ -198,15 +198,15 @@
                     "product_name": "Krawatte",
                     "size": "Universalgröße",
                     "availability_label": "",
-                    "is_processed": false,
+                    "is_processed": true,
                     "backorder_status": "",
                     "backordered_at": "2026-05-30 11:10:05",
                     "ordered_at": ""
                 }
             ],
-            "status": "open",
+            "status": "processed",
             "created_at": "2026-05-30 10:28:37",
-            "updated_at": "2026-05-30 11:10:21",
+            "updated_at": "2026-05-30 15:43:02",
             "cancelled_at": "",
             "cancelled_by": "",
             "cancellation_reason": "",
@@ -227,13 +227,13 @@
                     "availability_label": "",
                     "is_processed": false,
                     "backorder_status": "ordered",
-                    "backordered_at": "2026-05-30 11:00:04",
-                    "ordered_at": "2026-05-30 11:10:21"
+                    "backordered_at": "2026-06-12 22:44:24",
+                    "ordered_at": "2026-06-12 22:44:36"
                 }
             ],
             "status": "open",
             "created_at": "2026-05-30 10:44:49",
-            "updated_at": "2026-05-30 11:10:21",
+            "updated_at": "2026-06-12 22:44:36",
             "cancelled_at": "",
             "cancelled_by": "",
             "cancellation_reason": "",
@@ -254,17 +254,98 @@
                     "availability_label": "",
                     "is_processed": false,
                     "backorder_status": "",
-                    "backordered_at": "2026-05-30 13:25:18",
+                    "backordered_at": "2026-05-30 15:42:51",
                     "ordered_at": ""
                 }
             ],
             "status": "open",
             "created_at": "2026-05-30 11:11:13",
-            "updated_at": "2026-05-30 13:27:48",
+            "updated_at": "2026-06-12 22:44:59",
             "cancelled_at": "",
             "cancelled_by": "",
             "cancellation_reason": "",
             "admin_notified_at": "2026-05-30 11:11:13"
+        },
+        {
+            "id": "FS-2026-005",
+            "customer_name": "Josef Namensschild",
+            "customer_email": "mailpit@mailpit.medowar.de",
+            "organization_id": "feuerwehr-freising-wache-2",
+            "organization_label": "Feuerwehr Freising Wache 2",
+            "comment": "",
+            "items": [
+                {
+                    "product_id": 14,
+                    "product_name": "Namensschild",
+                    "size": "1 Stück",
+                    "availability_label": "",
+                    "is_processed": false,
+                    "backorder_status": "",
+                    "backordered_at": "",
+                    "ordered_at": ""
+                }
+            ],
+            "status": "open",
+            "created_at": "2026-08-02 00:47:45",
+            "updated_at": "2026-08-02 11:14:23",
+            "cancelled_at": "",
+            "cancelled_by": "",
+            "cancellation_reason": "",
+            "admin_notified_at": "2026-08-02 00:47:46"
+        },
+        {
+            "id": "FS-2026-006",
+            "customer_name": "Josef Namensschild",
+            "customer_email": "test123@mailpit.medowar.de",
+            "organization_id": "feuerwehr-freising",
+            "organization_label": "Feuerwehr Freising TEST",
+            "comment": "",
+            "items": [
+                {
+                    "product_id": 14,
+                    "product_name": "Namensschild",
+                    "size": "2 Stück",
+                    "availability_label": "",
+                    "is_processed": false,
+                    "backorder_status": "",
+                    "backordered_at": "2026-08-02 01:25:26",
+                    "ordered_at": ""
+                }
+            ],
+            "status": "open",
+            "created_at": "2026-08-02 01:23:29",
+            "updated_at": "2026-08-02 11:13:39",
+            "cancelled_at": "",
+            "cancelled_by": "",
+            "cancellation_reason": "",
+            "admin_notified_at": "2026-08-02 01:23:31"
+        },
+        {
+            "id": "FS-2026-007",
+            "customer_name": "Josef Test name",
+            "customer_email": "nasjdn@ksndanj.de",
+            "organization_id": "feuerwehr-freising",
+            "organization_label": "Feuerwehr Freising TEST",
+            "comment": "",
+            "items": [
+                {
+                    "product_id": 14,
+                    "product_name": "Namensschild",
+                    "size": "2 Stück",
+                    "availability_label": "",
+                    "is_processed": false,
+                    "backorder_status": "",
+                    "backordered_at": "",
+                    "ordered_at": ""
+                }
+            ],
+            "status": "open",
+            "created_at": "2026-08-02 11:15:00",
+            "updated_at": "2026-08-02 11:15:00",
+            "cancelled_at": "",
+            "cancelled_by": "",
+            "cancellation_reason": "",
+            "admin_notified_at": "2026-08-02 11:15:00"
         }
     ]
 }

+ 54 - 7
docs/ADMIN_BUSINESS_LOGIC.md

@@ -8,7 +8,7 @@ Technische Details zum Kundenprozess (Checkout) stehen in [ORDER_PROCESS.md](ORD
 
 ## Admin-Seiten im Überblick
 
-Vier Seiten sind für die Bestellbearbeitung relevant:
+Fünf Seiten sind für die Bestellbearbeitung relevant:
 
 | Seite | Datei | Rolle für Operatoren |
 | --- | --- | --- |
@@ -16,8 +16,9 @@ Vier Seiten sind für die Bestellbearbeitung relevant:
 | Bestellliste | `admin/orders.php` | Suchen, filtern, alle Bestellungen finden |
 | Bestelldetail | `admin/order.php` | Positionen bearbeiten, stornieren |
 | Nachbestellungen | `admin/backorders.php` | Externe Bestellung und Lieferung verwalten |
+| Namensschilder | `admin/nametags.php` | Sammelübersicht aller Namensschild-Positionen |
 
-Weitere Admin-Seiten (Produkte, Kategorien, Organisationen, FAQ, Einstellungen, Admins) haben **keinen Bezug** zu Bestellstatus oder -anzeige.
+Weitere Admin-Seiten (Produkte, Einstellungen, Admins) haben **keinen Bezug** zu Bestellstatus oder -anzeige. Kategorien, FAQ und Organisationen werden als Abschnitte innerhalb von **Einstellungen** (`admin/settings.php`) gepflegt.
 
 ---
 
@@ -163,7 +164,11 @@ Jede Bestellung ist immer über die Bestellliste oder Dashboard-Links erreichbar
 
 Zusätzliches Badge **„Nachbestellung"**, wenn mindestens eine Position einen Nachbestell-Status hat (nicht bei stornierten Bestellungen, sofern beim Stornieren zurückgesetzt).
 
-**Bearbeitet + Nachbestellung:** Eine Position kann als **bearbeitet** markiert sein und gleichzeitig einen Nachbestell-Status behalten (z. B. Retoure deckt die Bestellung ab, während die Nachbestellung auf Lieferantenware wartet). „Als bearbeitet markieren" löscht den Nachbestell-Status **nicht**. Umgekehrt: **Nachzubestellen** kann nicht auf bereits bearbeitete Positionen gesetzt werden.
+**Bearbeitet + Nachbestellung:** „Als bearbeitet markieren" **löscht** den Nachbestell-Status der Position sowie `backordered_at` und `ordered_at`. Eine bearbeitete Position gilt als ausgeliefert und darf nicht länger in den **Nachbestellungen** hängen. Umgekehrt: **Nachzubestellen** kann nicht auf bereits bearbeitete Positionen gesetzt werden.
+
+> **Bewusste Entscheidung (2026-08-02):** Bis v1.3.14 blieb der Nachbestell-Status beim Bearbeiten erhalten, damit z. B. eine Retoure die Kundenbestellung abdecken konnte, während die Nachbestellung weiter auf Lieferantenware wartete. Dieser Fall wird nicht mehr unterstützt — die Kombination *bearbeitet + Nachbestell-Status* erzeugte Einträge in den **Nachbestellungen**, die von der Namensschilder-Seite aus nicht mehr auflösbar waren. Wer Lieferantenware ohne Kundenbezug nachverfolgen will, nutzt eine **manuelle Nachbestellung**.
+>
+> Das Zurückschalten auf **offen** stellt den vorherigen Nachbestell-Status **nicht** wieder her; er muss neu gesetzt werden.
 
 Positionen im Status **Wartet auf Lieferung** (`ordered`): Kein Nachbestell-Toggle auf der Detailseite — Weiterbearbeitung nur über **Nachbestellungen** („Lieferung eingetroffen").
 
@@ -174,7 +179,7 @@ Positionen im Status **Wartet auf Lieferung** (`ordered`): Kein Nachbestell-Togg
 #### Was erscheint in der Liste?
 
 - Alle **manuellen** Nachbestell-Einträge mit Status Nachzubestellen oder Wartet auf Lieferung.
-- Bestellpositionen mit Nachbestell-Status, **ausgenommen** stornierte Bestellungen.
+- Bestellpositionen mit Nachbestell-Status, **ausgenommen** stornierte Bestellungen und **ausgenommen** bereits bearbeitete Positionen.
 
 Positionen werden nach **Produkt und Größe** gruppiert. Spalten **Nachzubestellen** und **Wartet auf Lieferung** werden getrennt gezählt.
 
@@ -191,6 +196,31 @@ Manuelle Nachbestellungen können über ein Formular auf derselben Seite hinzuge
 
 ---
 
+### Namensschilder (`admin/nametags.php`)
+
+Seit v1.3.13 gibt es **kein eigenes Datenmodell** für Namensschilder mehr (das frühere `data/nametags.json` und die Konstante `NAMETAGS_FILE` sind entfallen). Namensschilder sind **ganz normale Bestellpositionen**; die Seite ist reine Sammelübersicht, damit Operatoren nicht jede Bestellung einzeln nach offenen Namensschildern durchsuchen müssen.
+
+**Erkennung:** Eine Position gilt als Namensschild, wenn ihr `product_name` **exakt** dem Einstellungswert `nametag_product_name` entspricht (Standard: „Namensschild"). Der Personenname in der Tabelle ist der `customer_name` der Bestellung.
+
+Die beiden Tabellen lesen denselben `backorder_status` wie die Nachbestellungen:
+
+| Tabelle | Bedingung |
+| --- | --- |
+| **Ausstehende Bestellungen** | `backorder_status` ist *(leer)* oder **Nachzubestellen** |
+| **Bestellt, wartet auf Lieferung** | `backorder_status` ist **Wartet auf Lieferung** |
+
+Bearbeitete Positionen (`is_processed`) erscheinen in **keiner** der beiden Tabellen.
+
+#### Bewusste Design-Entscheidungen — bitte nicht „reparieren"
+
+Diese drei Punkte sehen bei jedem frischen Lesen wie Fehler aus, sind aber geprüft und so gewollt (Stand 2026-08-02):
+
+1. **Überschneidung mit den Nachbestellungen ist beabsichtigt.** Ein als bestellt markiertes Namensschild erscheint zusätzlich unter **Nachbestellungen** und kann dort auch per Massenaktion weiterbearbeitet werden. Beide Seiten arbeiten bewusst auf demselben Feld.
+2. **Zuordnung über den Produktnamen ist ein akzeptierter Kompromiss.** Wird das Produkt im Katalog umbenannt, ohne `nametag_product_name` mitzuziehen, verschwindet die gesamte Namensschild-Historie aus der Übersicht. Kein Wechsel auf `product_id` ohne Rücksprache.
+3. **„Lieferung eingetroffen" setzt das Namensschild zurück in die Tabelle „Ausstehende Bestellungen".** Grund: gelieferte und nie bestellte Positionen haben denselben leeren `backorder_status`. Der **abschließende** Schritt ist **„Als bearbeitet markieren"** — erst dann verschwindet die Position aus beiden Tabellen und aus den Nachbestellungen. Kein zusätzlicher Zwischenstatus („liegt zur Ausgabe bereit") ohne Rücksprache.
+
+---
+
 ## Sichtbarkeits-Matrix
 
 Übersicht: Wo erscheint ein Zustand?
@@ -199,7 +229,7 @@ Manuelle Nachbestellungen können über ein Formular auf derselben Seite hinzuge
 | --- | --- | --- | --- | --- | --- | --- |
 | **Offen** | Offen | Ja*, wenn offene Positionen ohne NB | Ja*, nur Positionen ohne NB | Offen, Alle | Ja, bearbeitbar | Ja, wenn NB markiert |
 | **Teilweise bearbeitet** | Teilweise | Ja*, wenn offene Positionen ohne NB | Ja*, nur Positionen ohne NB | Teilweise, Alle | Ja, bearbeitbar | Ja, wenn NB markiert |
-| **Bearbeitet** | Bearbeitet | — | — | Bearbeitet, Alle | Ja (nur Ansehen) | Ja, wenn NB markiert |
+| **Bearbeitet** | Bearbeitet | — | — | Bearbeitet, Alle | Ja (nur Ansehen) | — (NB-Status wird beim Bearbeiten gelöscht) |
 | **Storniert** | Storniert | — | — | Storniert, Alle | Ja (Stornierung aufheben) | — |
 | Position **Nachzubestellen** | In NB-Statistik | Position ausgeblendet | Position ausgeblendet | Bestellung in Liste | Toggle auf Detail | Ja |
 | Position **Wartet auf Lieferung** | In NB-Statistik | Position ausgeblendet | Position ausgeblendet | Bestellung in Liste | Kein Toggle auf Detail | Ja |
@@ -235,6 +265,8 @@ stateDiagram-v2
         toBeBackordered --> ordered: Nachbestellungen bestellt
         ordered --> none: Lieferung eingetroffen
         toBeBackordered --> none: Detail aufheben
+        ordered --> none: Als bearbeitet markieren
+        toBeBackordered --> none: Als bearbeitet markieren
     }
 ```
 
@@ -254,20 +286,35 @@ stateDiagram-v2
 
 ## Einstellungen mit Einfluss auf neue Bestellungen
 
-Unter **Einstellungen** (`admin/settings.php`):
+Unter **Einstellungen** (`admin/settings.php`) im Abschnitt **Allgemein**:
 
 - **Empfängeradresse für interne Bestellmails**
 - **PDF an interne Bestell-E-Mails anhängen**
+- **Artikelname für Namensschilder**
+
+Die Seite ist in Abschnitte gegliedert (in dieser Reihenfolge): **Kategorien**, **FAQ**, **Organisationen**, **Allgemein**, **Backups**, **Updater** und — nur für das Konto `admin` — **Lokale Konfiguration**. Kategorien, FAQ und Organisationen haben keine eigenen Seiten mehr.
 
 ---
 
 ## Bekannte Unstimmigkeiten
 
-Die folgenden Punkte sind **keine Bedienanleitung**, sondern dokumentierte bewusste Einschränkungen.
+Die folgenden Punkte sind **keine Bedienanleitung**, sondern dokumentierte bewusste Einschränkungen. Sie wirken beim Lesen des Codes wie Fehler, sind aber geprüft und so gewollt — bitte **nicht ohne Rücksprache „korrigieren"**.
 
 1. **Status „Wartet auf Lieferung" nicht auf Detailseite änderbar**  
    Bewusste Trennung: Weiterführung nur über **Nachbestellungen**. Operatoren, die nur die Detailseite nutzen, finden keinen Button dafür.
 
+2. **Namensschilder erscheinen zusätzlich unter Nachbestellungen**  
+   Beide Seiten arbeiten bewusst auf demselben `backorder_status`. `admin/nametags.php` ist nur eine Sammelübersicht, kein eigenes Datenmodell.
+
+3. **„Lieferung eingetroffen" stellt ein Namensschild zurück in „Ausstehende Bestellungen"**  
+   Geliefert und nie bestellt teilen sich denselben leeren `backorder_status`. Abschließender Schritt ist **„Als bearbeitet markieren"**. Kein zusätzlicher Zwischenstatus ohne Rücksprache.
+
+4. **Namensschilder werden über den Produktnamen erkannt, nicht über `product_id`**  
+   Akzeptierter Kompromiss. Ein Umbenennen des Katalogprodukts ohne Anpassung von `nametag_product_name` blendet die gesamte Historie aus.
+
+5. **„Als bearbeitet markieren" löscht den Nachbestell-Status unwiderruflich**  
+   Das Zurückschalten auf offen stellt ihn nicht wieder her. Siehe Abschnitt [Bestelldetail](#bestelldetail-adminorderphp).
+
 ---
 
 ## Querverweise

+ 5 - 2
docs/CONFIG_REFERENCE.md

@@ -45,7 +45,9 @@
 
 ## Bearbeitung im Admin
 
-`config.php` kann direkt im Admin unter **Einstellungen** bearbeitet werden — dieser Abschnitt ist nur sichtbar, wenn mit dem Benutzerkonto `admin` angemeldet ist (siehe `data/admins.json`). Vor dem Speichern wird die PHP-Syntax geprüft (`php -l`); die vorherige Version wird automatisch nach `data/backups/config/` gesichert.
+`config.php` kann direkt im Admin unter **Einstellungen > Lokale Konfiguration** bearbeitet werden — dieser Abschnitt ist nur sichtbar, wenn mit dem Benutzerkonto `admin` angemeldet ist (siehe `data/admins.json`). Vor dem Speichern wird die PHP-Syntax im laufenden Prozess geprüft (`token_get_all()` mit `TOKEN_PARSE`, da `PHP_BINARY` unter mod_php/php-fpm nicht auf die PHP-CLI zeigt); die vorherige Version wird automatisch nach `data/backups/config/` gesichert.
+
+Die Syntaxprüfung erkennt nur Parse-Fehler. Eine syntaktisch gültige Konfiguration, in der benötigte Konstanten (z. B. `DATA_DIR`) fehlen, legt die Anwendung inklusive dieser Bearbeitungsmaske lahm — dann muss `config.php` per Dateizugriff aus `data/backups/config/` wiederhergestellt werden.
 
 ## Runtime (`data/settings.json`)
 
@@ -53,8 +55,9 @@ Im Admin unter **Einstellungen** überschreibbar (schreibt `data/settings.json`)
 
 - `order_recipient_email` — Empfänger interner Bestellmails (hat Vorrang vor `ORDER_RECIPIENT_EMAIL`)
 - `attach_order_pdf_to_admin_email` — PDF-Anhang an interne Bestellmails
+- `nametag_product_name` — Produktname, über den `admin/nametags.php` Namensschild-Positionen erkennt (Standard: `Namensschild`). Muss **exakt** dem Produktnamen im Katalog entsprechen; siehe [ADMIN_BUSINESS_LOGIC.md](ADMIN_BUSINESS_LOGIC.md#namensschilder-adminnametagsphp)
 
-Der Startseiten-Introtext wird unter **FAQ** gepflegt (`startpage_intro_text` in derselben Datei). Beim Speichern der Einstellungen bleibt der Introtext erhalten.
+Der Startseiten-Introtext wird unter **Einstellungen > FAQ** gepflegt (`startpage_intro_text` in derselben Datei). Beim Speichern des Abschnitts **Allgemein** bleibt der Introtext erhalten.
 
 ## Rate limits und Logging
 

+ 12 - 2
docs/ORDER_PROCESS.md

@@ -9,6 +9,7 @@ Das System bildet keinen klassischen E-Commerce-Checkout ab, sondern einen inter
 - Kernlogik: `includes/functions.php`
 - Interne Bearbeitung: `admin/orders.php`
 - Nachbestellungen: `admin/backorders.php`
+- Namensschilder (Sammelübersicht): `admin/nametags.php`
 - Einstellungen für Weiterleitung: `admin/settings.php`
 
 Hinweis:
@@ -69,13 +70,22 @@ Jede Bestellposition kann optional folgende Felder haben:
 | --- | --- | --- |
 | Als Nachbestellung markieren | `admin/orders.php` (pro Position) | Setzt `to_be_backordered` |
 | Nachbestellung aufheben | `admin/orders.php` | Setzt `backorder_status` zurück auf `""` (nur aus Zustand 1) |
-| Als bestellt markieren | `admin/backorders.php` | Verschiebt N Positionen von `to_be_backordered` nach `ordered` (FIFO) |
-| Lieferung eingetroffen | `admin/backorders.php` | Setzt N Positionen von `ordered` zurück auf `""` (FIFO); Position bleibt offen zur normalen Abarbeitung |
+| Als bestellt markieren | `admin/backorders.php`, `admin/nametags.php` | Verschiebt N Positionen von `to_be_backordered` nach `ordered` (FIFO) |
+| Lieferung eingetroffen | `admin/backorders.php`, `admin/nametags.php` | Setzt N Positionen von `ordered` zurück auf `""` (FIFO); Position bleibt offen zur normalen Abarbeitung |
+| Als bearbeitet markieren | `admin/order.php`, `admin/nametags.php` | Setzt `is_processed` **und löscht** `backorder_status`, `backordered_at`, `ordered_at` |
 
 In `admin/backorders.php` werden Positionen nach Produkt und Größe gruppiert. Die Spalten **Nachzubestellen** und **Wartet auf Lieferung** werden getrennt gezählt, damit neu markierte Artikel nicht mit bereits bestellten vermischt werden.
 
 Positionen mit Nachbestell-Status (**Nachzubestellen** oder **Wartet auf Lieferung**) erscheinen nicht in **Offene Positionen** auf dem Dashboard; sie werden über **Nachbestellungen** verwaltet.
 
+Bereits bearbeitete Positionen (`is_processed`) erscheinen **nicht** in `admin/backorders.php` — beim Bearbeiten wird der Nachbestell-Status gelöscht (siehe [ADMIN_BUSINESS_LOGIC.md](ADMIN_BUSINESS_LOGIC.md#bestelldetail-adminorderphp)).
+
+### Namensschilder (`admin/nametags.php`)
+
+Namensschilder haben **kein eigenes Datenmodell** (seit v1.3.13; `data/nametags.json` und `NAMETAGS_FILE` entfallen). Es sind normale Bestellpositionen, deren `product_name` exakt dem Einstellungswert `nametag_product_name` entspricht; die Seite ist eine reine Sammelübersicht über denselben `backorder_status`.
+
+Beabsichtigt und **nicht** zu „korrigieren": die Überschneidung mit `admin/backorders.php`, die Zuordnung über den Produktnamen statt `product_id`, und dass „Lieferung eingetroffen" die Position zurück in die Liste **Ausstehende Bestellungen** stellt (abschließender Schritt ist „Als bearbeitet markieren"). Details: [ADMIN_BUSINESS_LOGIC.md](ADMIN_BUSINESS_LOGIC.md#namensschilder-adminnametagsphp).
+
 ## Abweichungen zum Standard-Webshop
 
 - Kein Payment-Schritt (kein Warenwert, keine Zahlungsarten, keine Zahlungsfreigabe).

+ 18 - 8
docs/SHOP_LOGIC.md

@@ -15,10 +15,11 @@ Für technische Details siehe [ADMIN_BUSINESS_LOGIC.md](ADMIN_BUSINESS_LOGIC.md)
 | **Bestellliste**     | Alle Bestellungen suchen und filtern                                |
 | **Bestelldetail**    | Einzelne Bestellung öffnen, Positionen abhaken, stornieren          |
 | **Nachbestellungen** | Artikel, die extern nachbestellt werden müssen                      |
+| **Namensschilder**   | Sammelübersicht aller bestellten Namensschilder mit Personennamen   |
 | **Einstellungen**    | E-Mail-Adresse für interne Bestellbenachrichtigungen, PDF-Anhang    |
 
 
-Produkte, Kategorien, Organisationen, FAQ und Admin-Konten haben **nichts direkt mit dem Bestellstatus** zu tun — dort pflegen Sie Inhalte und Zugänge.
+Produkte und Admin-Konten haben **nichts direkt mit dem Bestellstatus** zu tun — dort pflegen Sie Inhalte und Zugänge. Kategorien, FAQ und Organisationen pflegen Sie als Abschnitte auf der Seite **Einstellungen**.
 
 ---
 
@@ -31,7 +32,7 @@ Jede Bestellung hat **zwei getrennte Informationen**:
 2. **Braucht ein einzelner Artikel eine Nachbestellung beim Lieferanten?**
   Das gilt **pro Position**, nicht für die ganze Bestellung.
 
-Beides läuft unabhängig voneinander. Eine Position kann z. B. schon als „ausgegeben“ markiert sein, während gleichzeitig noch auf Lieferantenware gewartet wird.
+Beides läuft weitgehend unabhängig voneinander — mit einer Ausnahme: Sobald Sie eine Position **als bearbeitet markieren**, gilt sie als ausgegeben und ein eventueller Nachbestell-Hinweis wird dabei **gelöscht**.
 
 ---
 
@@ -117,7 +118,7 @@ Hier bearbeiten Sie **eine konkrete Bestellung**:
 
 **Hinweise:**
 
-- „Als bearbeitet markieren“ **entfernt nicht** automatisch einen Nachbestell-Hinweis.
+- „Als bearbeitet markieren“ **entfernt** einen vorhandenen Nachbestell-Hinweis. Die Position verschwindet damit auch aus **Nachbestellungen** und **Namensschilder**. Setzen Sie sie wieder auf offen, ist der Nachbestell-Hinweis **nicht** zurück — Sie müssen ihn neu setzen.
 - „Als Nachbestellung markieren“ geht **nicht** bei schon ausgegebenen Positionen.
 - Status **„Wartet auf Lieferung“** ändern Sie **nur** auf der Seite **Nachbestellungen** (nicht auf der Detailseite).
 
@@ -136,6 +137,14 @@ Hier bearbeiten Sie **eine konkrete Bestellung**:
 | **Lieferung eingetroffen** | Ware ist da → Position ist wieder normal offen zur Ausgabe |
 
 
+### Namensschilder
+
+Sammelübersicht **aller** Namensschild-Positionen aus allen Bestellungen, mit dem Namen der Person — damit Sie nicht jede Bestellung einzeln durchsuchen müssen. Welcher Artikel als Namensschild zählt, stellen Sie unter **Einstellungen → Artikelname für Namensschilder** ein; der Name muss **exakt** dem Produktnamen im Katalog entsprechen.
+
+Namensschilder sind normale Bestellpositionen. Sie erscheinen deshalb **zusätzlich** unter **Nachbestellungen**, sobald sie bestellt sind — das ist so gewollt, Sie können sie auf beiden Seiten weiterbearbeiten.
+
+**Wichtig zum Ablauf:** „Lieferung eingetroffen“ schiebt das Namensschild zurück in die Liste **Ausstehende Bestellungen** — die Seite kann „geliefert“ und „noch nicht bestellt“ nicht unterscheiden. Der **letzte** Schritt ist immer **„Als bearbeitet markieren“**, sobald das Schild ausgegeben ist. Erst dann verschwindet es aus allen Listen.
+
 ---
 
 ## Typische Abläufe — Schritt für Schritt
@@ -174,10 +183,11 @@ Hier bearbeiten Sie **eine konkrete Bestellung**:
 
 Unter **Einstellungen** können Sie anpassen:
 
-- **Empfängeradresse für interne Bestellmails** — wohin Benachrichtigungen über neue Bestellungen gehen
-- **PDF an interne Bestell-E-Mails anhängen** — ja/nein
-
-Der Text auf der Startseite wird separat unter **FAQ** gepflegt.
+- **Kategorien** — Produktkategorien anlegen, umbenennen, löschen (Abschnitt ganz oben)
+- **FAQ** — FAQ-Inhalt (Markdown) und Text auf der Startseite
+- **Organisationen** — auswählbare Organisationen inkl. Sortierung und Aktiv-Status
+- **Allgemein** — Empfängeradresse für interne Bestellmails, PDF-Anhang ja/nein, Artikelname für Namensschilder
+- **Backups**, **Updater** und (nur für das Konto `admin`) **Lokale Konfiguration**
 
 ---
 
@@ -189,7 +199,7 @@ Der Text auf der Startseite wird separat unter **FAQ** gepflegt.
 | Normal offen, noch nicht ausgegeben | Ja                            | Nein                                             |
 | Nachzubestellen                     | Nein                          | Ja                                               |
 | Wartet auf Lieferung                | Nein                          | Ja                                               |
-| Schon ausgegeben                    | Nein                          | Nein (außer Nachbestell-Hinweis bleibt bestehen) |
+| Schon ausgegeben                    | Nein                          | Nein (Nachbestell-Hinweis wird dabei gelöscht)   |
 | Bestellung storniert                | Nein                          | Nein                                             |
 
 

+ 31 - 3
includes/functions.php

@@ -1858,9 +1858,21 @@ function toggleOrderItemProcessed($orderId, $itemIndex)
         $order["items"][$itemIndex]["is_processed"] = empty(
             $order["items"][$itemIndex]["is_processed"]
         );
+        if ($order["items"][$itemIndex]["is_processed"]) {
+            // Eine bearbeitete Position ist ausgeliefert und darf nicht als
+            // offene Nachbestellung in der Uebersicht haengen bleiben.
+            $order["items"][$itemIndex]["backorder_status"] = backorderStatusEmpty();
+            $order["items"][$itemIndex]["backordered_at"] = "";
+            $order["items"][$itemIndex]["ordered_at"] = "";
+        }
         $order["updated_at"] = $now;
         $order = refreshOrderState($order);
-        saveOrders($orders);
+        if (!saveOrders($orders)) {
+            return [
+                "success" => false,
+                "message" => "Bestellung konnte nicht gespeichert werden.",
+            ];
+        }
 
         return ["success" => true, "order" => $order];
     }
@@ -2178,6 +2190,10 @@ function collectBackorderItemRefs()
         }
 
         foreach ($order["items"] as $itemIndex => $item) {
+            if (!empty($item["is_processed"])) {
+                continue;
+            }
+
             $status = (string) ($item["backorder_status"] ?? "");
             if ($status === "") {
                 continue;
@@ -2523,7 +2539,13 @@ function markNametagOrderItemOrdered($orderId, $itemIndex)
         $order["items"][$itemIndex]["backorder_status"] = backorderStatusOrdered();
         $order["items"][$itemIndex]["ordered_at"] = $now;
         $order["updated_at"] = $now;
-        saveOrders($orders);
+        if (!saveOrders($orders)) {
+            return [
+                "success" => false,
+                "message" =>
+                    "Namensschild-Bestellung konnte nicht gespeichert werden.",
+            ];
+        }
 
         return ["success" => true];
     }
@@ -2565,7 +2587,13 @@ function markNametagOrderItemDelivered($orderId, $itemIndex)
         $order["items"][$itemIndex]["backorder_status"] = backorderStatusEmpty();
         $order["items"][$itemIndex]["ordered_at"] = "";
         $order["updated_at"] = $now;
-        saveOrders($orders);
+        if (!saveOrders($orders)) {
+            return [
+                "success" => false,
+                "message" =>
+                    "Namensschild-Bestellung konnte nicht gespeichert werden.",
+            ];
+        }
 
         return ["success" => true];
     }

+ 34 - 5
scripts/create-update-zip.sh

@@ -21,20 +21,49 @@ is_excluded_path() {
 }
 
 read_current_version() {
-    sed -nE 's/.*define\("APP_VERSION",\s*"([^"]+)"\).*/\1/p' "$version_file" | head -n 1
+    sed -nE 's/.*define\(["'\'']APP_VERSION["'\''],[[:space:]]*["'\'']([^"'\'']+)["'\'']\).*/\1/p' "$version_file" | head -n 1
 }
 
 write_current_version() {
     local version="$1"
+    local tmp_version_file
+
+    # No "sed -i": GNU requires no argument, BSD/macOS requires one.
+    tmp_version_file="$(mktemp)"
+    sed -E "s/define\\([\"']APP_VERSION[\"'],[[:space:]]*[\"'][^\"']+[\"']\\);/define(\"APP_VERSION\", \"${version}\");/" \
+        "$version_file" > "$tmp_version_file" ||
+        { rm -f "$tmp_version_file"; die "Version string could not be updated in $version_file"; }
+
+    cat "$tmp_version_file" > "$version_file" ||
+        { rm -f "$tmp_version_file"; die "Version string could not be written to $version_file"; }
+    rm -f "$tmp_version_file"
 
-    sed -i -E "s/define\(\"APP_VERSION\", \"[^\"]+\"\);/define(\"APP_VERSION\", \"$version\");/" "$version_file"
     [[ "$(read_current_version)" == "$version" ]] || die "Version string could not be updated in $version_file"
 }
 
+sha256_file() {
+    local file="$1"
+
+    if command -v sha256sum >/dev/null 2>&1; then
+        sha256sum "$file" | awk '{print $1}'
+    elif command -v shasum >/dev/null 2>&1; then
+        shasum -a 256 "$file" | awk '{print $1}'
+    else
+        die "Required command not found: sha256sum or shasum"
+    fi
+}
+
+file_size() {
+    local file="$1"
+
+    # GNU stat uses -c, BSD/macOS stat uses -f.
+    stat -c '%s' "$file" 2>/dev/null || stat -f '%z' "$file"
+}
+
 require_command git
 require_command zip
 require_command sed
-require_command sha256sum
+require_command awk
 
 repo_root="$(git rev-parse --show-toplevel 2>/dev/null)" || die "Not inside a git repository."
 current_dir="$(pwd -P)"
@@ -88,8 +117,8 @@ rm -f "$output_file"
 zip -q -@ "$output_file" < "$tmp_file_list"
 [[ -f "$output_file" ]] || die "ZIP archive was not created."
 
-checksum="$(sha256sum "$output_file" | awk '{print $1}')"
-size="$(stat -c '%s' "$output_file")"
+checksum="$(sha256_file "$output_file")"
+size="$(file_size "$output_file")"
 
 printf 'Update ZIP created\n'
 printf 'Version: %s\n' "$version"