Browse Source

Checkpoint #1 Bugfixes and feature

Medowar 6 days ago
parent
commit
00415e5d07
5 changed files with 499 additions and 28 deletions
  1. 124 0
      admin/backorders.php
  2. 122 14
      admin/orders.php
  3. 25 0
      assets/css/style.css
  4. 14 14
      data/orders.json
  5. 214 0
      includes/functions.php

+ 124 - 0
admin/backorders.php

@@ -13,6 +13,32 @@ $pageTitle = "Nachbestellungen";
 $message = "";
 $messageType = "";
 
+if ($_SERVER['REQUEST_METHOD'] === "POST" && isset($_POST['add_manual_backorder'])) {
+    if (!validateCsrfToken($_POST['csrf_token'] ?? "")) {
+        $message = "Ungültiges Token. Bitte versuchen Sie es erneut.";
+        $messageType = "error";
+    } else {
+        $result = addManualBackorderItems(
+            $_POST['product_id'] ?? 0,
+            $_POST['size'] ?? "",
+            $_POST['quantity'] ?? 0,
+        );
+        $message = $result["success"]
+            ? ($result["added"] ?? 0) . " Position(en) zur Nachbestellung hinzugefügt."
+            : $result["message"];
+        $messageType = $result["success"] ? "success" : "error";
+
+        if ($result["success"]) {
+            logAccess("Admin added manual backorder items", [
+                "admin" => $_SESSION['admin_username'] ?? "unknown",
+                "product_id" => $_POST['product_id'] ?? 0,
+                "size" => $_POST['size'] ?? "",
+                "quantity" => $_POST['quantity'] ?? 0,
+            ]);
+        }
+    }
+}
+
 if ($_SERVER['REQUEST_METHOD'] === "POST" && isset($_POST['mark_ordered'])) {
     if (!validateCsrfToken($_POST['csrf_token'] ?? "")) {
         $message = "Ungültiges Token. Bitte versuchen Sie es erneut.";
@@ -68,6 +94,15 @@ if ($_SERVER['REQUEST_METHOD'] === "POST" && isset($_POST['mark_delivered'])) {
 }
 
 $groups = getBackorderGroups();
+$products = getProducts();
+$productSizeMap = [];
+foreach ($products as $product) {
+    $sizes = getProductSizes($product);
+    if (empty($sizes)) {
+        $sizes = ["Standard"];
+    }
+    $productSizeMap[(int) $product["id"]] = $sizes;
+}
 
 $bodyClass = "admin-page";
 include __DIR__ . "/../includes/header.php";
@@ -91,6 +126,53 @@ include __DIR__ . "/../includes/header.php";
     Artikel werden nach Produkt und Größe zusammengefasst. Aktionen bearbeiten die ältesten Bestellungen zuerst (FIFO).
 </p>
 
+<div class="admin-panel backorder-add-panel">
+    <h3>Artikel manuell hinzufügen</h3>
+    <p class="text-muted">
+        Für Bedarf ohne zugehörige Kundenbestellung (z.&nbsp;B. Lagerauffüllung).
+    </p>
+    <?php if (empty($products)): ?>
+        <p>Keine Artikel im Katalog vorhanden.</p>
+    <?php else: ?>
+        <form method="POST" class="admin-filter-form backorder-add-form">
+            <?php echo csrfField(); ?>
+            <div class="admin-filter-field">
+                <label for="manual_backorder_product">Artikel</label>
+                <select id="manual_backorder_product" name="product_id" required>
+                    <option value="">Bitte wählen</option>
+                    <?php foreach ($products as $product): ?>
+                        <option value="<?php echo (int) $product["id"]; ?>">
+                            <?php echo escape($product["name"]); ?>
+                        </option>
+                    <?php endforeach; ?>
+                </select>
+            </div>
+            <div class="admin-filter-field">
+                <label for="manual_backorder_size">Größe</label>
+                <select id="manual_backorder_size" name="size" required disabled>
+                    <option value="">Zuerst Artikel wählen</option>
+                </select>
+            </div>
+            <div class="admin-filter-field">
+                <label for="manual_backorder_quantity">Anzahl</label>
+                <input
+                    type="number"
+                    id="manual_backorder_quantity"
+                    name="quantity"
+                    min="1"
+                    max="100"
+                    value="1"
+                    required
+                    class="backorder-qty-input"
+                >
+            </div>
+            <button type="submit" name="add_manual_backorder" class="btn">
+                Zur Nachbestellung hinzufügen
+            </button>
+        </form>
+    <?php endif; ?>
+</div>
+
 <?php if (empty($groups)): ?>
     <div class="alert alert-info">
         <p>Keine Nachbestellungen vorhanden.</p>
@@ -206,4 +288,46 @@ include __DIR__ . "/../includes/header.php";
     </div>
 <?php endif; ?>
 
+<?php if (!empty($productSizeMap)): ?>
+<script>
+(function () {
+    const sizesByProduct = <?php echo json_encode(
+        $productSizeMap,
+        JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT,
+    ); ?>;
+    const productSelect = document.getElementById("manual_backorder_product");
+    const sizeSelect = document.getElementById("manual_backorder_size");
+
+    if (!productSelect || !sizeSelect) {
+        return;
+    }
+
+    function updateSizeOptions() {
+        const productId = productSelect.value;
+        const sizes = sizesByProduct[productId] || [];
+        sizeSelect.innerHTML = "";
+
+        if (sizes.length === 0) {
+            sizeSelect.disabled = true;
+            const option = document.createElement("option");
+            option.value = "";
+            option.textContent = "Zuerst Artikel wählen";
+            sizeSelect.appendChild(option);
+            return;
+        }
+
+        sizes.forEach(function (size) {
+            const option = document.createElement("option");
+            option.value = size;
+            option.textContent = size;
+            sizeSelect.appendChild(option);
+        });
+        sizeSelect.disabled = false;
+    }
+
+    productSelect.addEventListener("change", updateSizeOptions);
+})();
+</script>
+<?php endif; ?>
+
 <?php include __DIR__ . "/../includes/footer.php"; ?>

+ 122 - 14
admin/orders.php

@@ -94,6 +94,27 @@ if ($_SERVER['REQUEST_METHOD'] === "POST" && isset($_POST['cancel_order'])) {
     }
 }
 
+if ($_SERVER['REQUEST_METHOD'] === "POST" && isset($_POST['uncancel_order'])) {
+    if (!validateCsrfToken($_POST['csrf_token'] ?? "")) {
+        $message = "Ungültiges Token. Bitte versuchen Sie es erneut.";
+        $messageType = "error";
+    } else {
+        $adminUsername = $_SESSION['admin_username'] ?? "";
+        $result = uncancelOrder($_POST['order_id'] ?? "");
+        $message = $result["success"]
+            ? "Stornierung wurde aufgehoben."
+            : $result["message"];
+        $messageType = $result["success"] ? "success" : "error";
+
+        if ($result["success"]) {
+            logAccess("Admin uncancelled order", [
+                "admin" => $adminUsername,
+                "order_id" => $_POST['order_id'] ?? "",
+            ]);
+        }
+    }
+}
+
 $orders = getOrders();
 usort($orders, function ($left, $right) {
     return strcmp($right["created_at"], $left["created_at"]);
@@ -307,6 +328,19 @@ include __DIR__ . "/../includes/header.php";
                     ? nl2br(escape($selectedOrder["cancellation_reason"]))
                     : "Kein Grund angegeben"; ?></p>
             </div>
+            <form
+                method="POST"
+                class="inline-form"
+                onsubmit="return confirm('Stornierung wirklich aufheben? Die Bestellung kann danach wieder bearbeitet werden.');"
+            >
+                <?php echo csrfField(); ?>
+                <input type="hidden" name="order_id" value="<?php echo escape(
+                    $selectedOrder["id"],
+                ); ?>">
+                <button type="submit" name="uncancel_order" class="btn btn-small">
+                    Stornierung aufheben
+                </button>
+            </form>
         <?php endif; ?>
 
         <h4>Positionen</h4>
@@ -388,8 +422,9 @@ include __DIR__ . "/../includes/header.php";
                                     </form>
                                     <?php
                                     $canToggleBackorder =
-                                        $backorderStatus === "" ||
-                                        $backorderStatus === "to_be_backordered";
+                                        $backorderStatus === "to_be_backordered" ||
+                                        ($backorderStatus === "" &&
+                                            empty($item["is_processed"]));
                                     if ($canToggleBackorder): ?>
                                     <form method="POST" class="inline-form">
                                         <?php echo csrfField(); ?>
@@ -414,19 +449,92 @@ include __DIR__ . "/../includes/header.php";
             </table>
         </div>
 
-        <?php if ($selectedOrder["status"] !== "cancelled"): ?>
-            <h4>Bestellung stornieren</h4>
-            <form method="POST" onsubmit="return confirm('Bestellung wirklich stornieren?');">
-                <?php echo csrfField(); ?>
-                <input type="hidden" name="order_id" value="<?php echo escape(
-                    $selectedOrder["id"],
-                ); ?>">
-                <div class="form-group">
-                    <label for="cancellation_reason">Stornogrund</label>
-                    <textarea id="cancellation_reason" name="cancellation_reason" rows="3" placeholder="Optionaler Grund"></textarea>
+        <?php if (
+            $selectedOrder["status"] !== "cancelled" &&
+            $selectedOrder["status"] !== "processed"
+        ): ?>
+            <button
+                type="button"
+                class="btn btn-secondary btn-small"
+                id="cancel-order-open"
+            >
+                Bestellung stornieren
+            </button>
+
+            <div
+                id="cancel-order-modal"
+                class="modal"
+                role="dialog"
+                aria-labelledby="cancel-order-title"
+                aria-hidden="true"
+            >
+                <div class="modal-content modal-content-compact">
+                    <button
+                        type="button"
+                        class="modal-close btn btn-secondary btn-small"
+                        id="cancel-order-close"
+                        aria-label="Schließen"
+                    >
+                        &times;
+                    </button>
+                    <h4 id="cancel-order-title">Bestellung stornieren</h4>
+                    <form method="POST" id="cancel-order-form">
+                        <?php echo csrfField(); ?>
+                        <input type="hidden" name="order_id" value="<?php echo escape(
+                            $selectedOrder["id"],
+                        ); ?>">
+                        <div class="form-group">
+                            <label for="cancellation_reason">Stornogrund</label>
+                            <textarea
+                                id="cancellation_reason"
+                                name="cancellation_reason"
+                                rows="3"
+                                placeholder="Optionaler Grund"
+                            ></textarea>
+                        </div>
+                        <button type="submit" name="cancel_order" class="btn">
+                            Stornierung bestätigen
+                        </button>
+                    </form>
                 </div>
-                <button type="submit" name="cancel_order" class="btn">Bestellung stornieren</button>
-            </form>
+            </div>
+            <script>
+            (function () {
+                const modal = document.getElementById("cancel-order-modal");
+                const openBtn = document.getElementById("cancel-order-open");
+                const closeBtn = document.getElementById("cancel-order-close");
+                if (!modal || !openBtn || !closeBtn) {
+                    return;
+                }
+
+                function openModal() {
+                    modal.classList.add("is-open");
+                    modal.setAttribute("aria-hidden", "false");
+                    const reason = document.getElementById("cancellation_reason");
+                    if (reason) {
+                        reason.focus();
+                    }
+                }
+
+                function closeModal() {
+                    modal.classList.remove("is-open");
+                    modal.setAttribute("aria-hidden", "true");
+                }
+
+                openBtn.addEventListener("click", openModal);
+                closeBtn.addEventListener("click", closeModal);
+                modal.addEventListener("click", function (event) {
+                    if (event.target === modal) {
+                        closeModal();
+                    }
+                });
+                document.addEventListener("keydown", function (event) {
+                    if (event.key === "Escape" && modal.classList.contains("is-open")) {
+                        closeModal();
+                    }
+                });
+            })();
+            </script>
         <?php endif; ?>
     </div>
 <?php endif; ?>

+ 25 - 0
assets/css/style.css

@@ -796,6 +796,15 @@ body.admin-page .container {
     justify-content: center;
 }
 
+.modal.is-open {
+    display: flex;
+}
+
+.modal-content-compact {
+    max-width: 32rem;
+    width: calc(100% - 2rem);
+}
+
 .modal-content {
     background: var(--brand-surface);
     color: var(--brand-text);
@@ -916,6 +925,22 @@ body.admin-page .container {
     width: 4rem;
 }
 
+.backorder-add-panel {
+    margin-bottom: 1.5rem;
+    padding: 1rem 1.25rem;
+    border: 1px solid var(--border-color, #ddd);
+    border-radius: 4px;
+    background: var(--surface-muted, #f8f9fa);
+}
+
+.backorder-add-panel h3 {
+    margin-top: 0;
+}
+
+.backorder-add-form {
+    margin-top: 1rem;
+}
+
 .sr-only {
     position: absolute;
     width: 1px;

+ 14 - 14
data/orders.json

@@ -95,7 +95,7 @@
                     "product_name": "Beil",
                     "size": "Universalgröße",
                     "availability_label": "",
-                    "is_processed": false,
+                    "is_processed": true,
                     "backorder_status": "",
                     "backordered_at": "",
                     "ordered_at": ""
@@ -105,19 +105,19 @@
                     "product_name": "Fleece",
                     "size": "M",
                     "availability_label": "",
-                    "is_processed": false,
+                    "is_processed": true,
                     "backorder_status": "",
                     "backordered_at": "",
                     "ordered_at": ""
                 }
             ],
-            "status": "open",
+            "status": "processed",
             "confirmation_status": "not_required",
             "confirmation_token": "",
             "confirmation_expires_at": "",
             "confirmed_at": "2026-05-11 22:17:07",
             "created_at": "2026-05-11 22:17:07",
-            "updated_at": "2026-05-30 09:57:29",
+            "updated_at": "2026-05-30 10:53:57",
             "cancelled_at": "",
             "cancelled_by": "",
             "cancellation_reason": "",
@@ -136,7 +136,7 @@
                     "product_name": "Handschuhe AT",
                     "size": "9",
                     "availability_label": "",
-                    "is_processed": false,
+                    "is_processed": true,
                     "backorder_status": "",
                     "backordered_at": "",
                     "ordered_at": ""
@@ -146,7 +146,7 @@
                     "product_name": "Beil",
                     "size": "Universalgröße",
                     "availability_label": "",
-                    "is_processed": false,
+                    "is_processed": true,
                     "backorder_status": "",
                     "backordered_at": "",
                     "ordered_at": ""
@@ -162,13 +162,13 @@
                     "ordered_at": ""
                 }
             ],
-            "status": "open",
+            "status": "partial",
             "confirmation_status": "not_required",
             "confirmation_token": "",
             "confirmation_expires_at": "",
             "confirmed_at": "2026-05-30 09:58:30",
             "created_at": "2026-05-30 09:58:30",
-            "updated_at": "2026-05-30 09:58:30",
+            "updated_at": "2026-05-30 10:59:35",
             "cancelled_at": "",
             "cancelled_by": "",
             "cancellation_reason": "",
@@ -188,8 +188,8 @@
                     "size": "L",
                     "availability_label": "",
                     "is_processed": false,
-                    "backorder_status": "",
-                    "backordered_at": "2026-05-30 10:44:18",
+                    "backorder_status": "to_be_backordered",
+                    "backordered_at": "2026-05-30 10:59:59",
                     "ordered_at": ""
                 }
             ],
@@ -199,7 +199,7 @@
             "confirmation_expires_at": "",
             "confirmed_at": "2026-05-30 10:11:13",
             "created_at": "2026-05-30 10:11:13",
-            "updated_at": "2026-05-30 10:46:52",
+            "updated_at": "2026-05-30 10:59:59",
             "cancelled_at": "",
             "cancelled_by": "",
             "cancellation_reason": "",
@@ -250,8 +250,8 @@
                     "size": "Universalgröße",
                     "availability_label": "",
                     "is_processed": false,
-                    "backorder_status": "",
-                    "backordered_at": "2026-05-30 10:45:30",
+                    "backorder_status": "to_be_backordered",
+                    "backordered_at": "2026-05-30 11:00:04",
                     "ordered_at": ""
                 }
             ],
@@ -261,7 +261,7 @@
             "confirmation_expires_at": "",
             "confirmed_at": "2026-05-30 10:44:49",
             "created_at": "2026-05-30 10:44:49",
-            "updated_at": "2026-05-30 10:46:52",
+            "updated_at": "2026-05-30 11:00:04",
             "cancelled_at": "",
             "cancelled_by": "",
             "cancellation_reason": "",

+ 214 - 0
includes/functions.php

@@ -1755,6 +1755,38 @@ function cancelOrder($orderId, $adminUsername, $reason = "")
     return ["success" => false, "message" => "Bestellung nicht gefunden."];
 }
 
+function uncancelOrder($orderId)
+{
+    $orders = getOrders();
+    $now = date("Y-m-d H:i:s");
+
+    foreach ($orders as &$order) {
+        if ($order["id"] !== $orderId) {
+            continue;
+        }
+
+        if ($order["status"] !== "cancelled") {
+            return [
+                "success" => false,
+                "message" => "Die Bestellung ist nicht storniert.",
+            ];
+        }
+
+        $order["cancelled_at"] = "";
+        $order["cancelled_by"] = "";
+        $order["cancellation_reason"] = "";
+        $order["status"] = "open";
+        $order["updated_at"] = $now;
+        $order = refreshOrderState($order);
+        saveOrders($orders);
+
+        return ["success" => true, "order" => $order];
+    }
+    unset($order);
+
+    return ["success" => false, "message" => "Bestellung nicht gefunden."];
+}
+
 function orderItemCanBeManaged($order)
 {
     if (($order["status"] ?? "") === "cancelled") {
@@ -1810,6 +1842,13 @@ function setOrderItemBackorderStatus($orderId, $itemIndex, $status)
         }
 
         if ($status === "to_be_backordered") {
+            if (!empty($order["items"][$itemIndex]["is_processed"])) {
+                return [
+                    "success" => false,
+                    "message" =>
+                        "Bearbeitete Positionen können nicht als Nachbestellung markiert werden.",
+                ];
+            }
             $order["items"][$itemIndex]["backorder_status"] = "to_be_backordered";
             $order["items"][$itemIndex]["backordered_at"] = $now;
             $order["items"][$itemIndex]["ordered_at"] = "";
@@ -1854,10 +1893,146 @@ function toggleOrderItemBackorder($orderId, $itemIndex)
     return ["success" => false, "message" => "Bestellung nicht gefunden."];
 }
 
+function getManualBackorders()
+{
+    $data = readJsonFile(MANUAL_BACKORDERS_FILE);
+    $entries =
+        isset($data["entries"]) && is_array($data["entries"])
+            ? $data["entries"]
+            : [];
+    $normalized = [];
+
+    foreach ($entries as $entry) {
+        if (!is_array($entry)) {
+            continue;
+        }
+
+        $id = trim((string) ($entry["id"] ?? ""));
+        $productId = (int) ($entry["product_id"] ?? 0);
+        $productName = trim((string) ($entry["product_name"] ?? ""));
+        $size = trim((string) ($entry["size"] ?? ""));
+        $status = trim((string) ($entry["backorder_status"] ?? ""));
+        if ($id === "" || $productId <= 0 || $productName === "" || $status === "") {
+            continue;
+        }
+
+        if (!in_array($status, ["to_be_backordered", "ordered"], true)) {
+            continue;
+        }
+
+        $normalized[] = [
+            "id" => $id,
+            "product_id" => $productId,
+            "product_name" => $productName,
+            "size" => $size,
+            "backorder_status" => $status,
+            "backordered_at" => trim((string) ($entry["backordered_at"] ?? "")),
+            "ordered_at" => trim((string) ($entry["ordered_at"] ?? "")),
+            "created_at" => trim((string) ($entry["created_at"] ?? "")),
+        ];
+    }
+
+    return $normalized;
+}
+
+function saveManualBackorders($entries)
+{
+    return writeJsonFile(MANUAL_BACKORDERS_FILE, [
+        "entries" => array_values($entries),
+    ]);
+}
+
+function generateManualBackorderId()
+{
+    return "mb-" . date("YmdHis") . "-" . bin2hex(random_bytes(4));
+}
+
+function addManualBackorderItems($productId, $size, $quantity)
+{
+    $productId = (int) $productId;
+    $size = trim((string) $size);
+    $quantity = (int) $quantity;
+
+    if ($productId <= 0 || $quantity <= 0) {
+        return [
+            "success" => false,
+            "message" => "Ungültige Menge oder Artikel.",
+        ];
+    }
+
+    if ($quantity > 100) {
+        return [
+            "success" => false,
+            "message" => "Maximal 100 Positionen auf einmal.",
+        ];
+    }
+
+    $product = getProductById($productId);
+    if ($product === null) {
+        return ["success" => false, "message" => "Artikel nicht gefunden."];
+    }
+
+    $allowedSizes = getProductSizes($product);
+    if (empty($allowedSizes)) {
+        $allowedSizes = ["Standard"];
+    }
+
+    if (!in_array($size, $allowedSizes, true)) {
+        return ["success" => false, "message" => "Ungültige Größe für diesen Artikel."];
+    }
+
+    $entries = getManualBackorders();
+    $now = date("Y-m-d H:i:s");
+
+    for ($i = 0; $i < $quantity; $i++) {
+        $entries[] = [
+            "id" => generateManualBackorderId(),
+            "product_id" => $productId,
+            "product_name" => $product["name"],
+            "size" => $size,
+            "backorder_status" => "to_be_backordered",
+            "backordered_at" => $now,
+            "ordered_at" => "",
+            "created_at" => $now,
+        ];
+    }
+
+    if (!saveManualBackorders($entries)) {
+        return [
+            "success" => false,
+            "message" => "Nachbestellungen konnten nicht gespeichert werden.",
+        ];
+    }
+
+    return ["success" => true, "added" => $quantity];
+}
+
 function collectBackorderItemRefs()
 {
     $refs = [];
 
+    foreach (getManualBackorders() as $entry) {
+        $refs[] = [
+            "order_id" => "",
+            "item_index" => 0,
+            "manual_id" => $entry["id"],
+            "is_manual" => true,
+            "product_id" => $entry["product_id"],
+            "product_name" => $entry["product_name"],
+            "size" => $entry["size"],
+            "backorder_status" => $entry["backorder_status"],
+            "created_at" => $entry["created_at"] !== "" ? $entry["created_at"] : $entry["backordered_at"],
+            "sort_at" =>
+                $entry["backorder_status"] === "ordered"
+                    ? ($entry["ordered_at"] !== ""
+                        ? $entry["ordered_at"]
+                        : $entry["created_at"])
+                    : ($entry["backordered_at"] !== ""
+                        ? $entry["backordered_at"]
+                        : $entry["created_at"]),
+        ];
+    }
+
     foreach (getOrders() as $order) {
         if (($order["status"] ?? "") === "cancelled") {
             continue;
@@ -2002,10 +2177,35 @@ function applyBackorderBulkUpdate($productId, $size, $fromStatus, $toStatus, $qu
 
     $targets = array_slice($candidates, 0, $quantity);
     $orders = getOrders();
+    $manualEntries = getManualBackorders();
+    $manualChanged = false;
     $now = date("Y-m-d H:i:s");
     $updated = 0;
 
     foreach ($targets as $target) {
+        if (!empty($target["is_manual"])) {
+            $manualId = (string) ($target["manual_id"] ?? "");
+            foreach ($manualEntries as &$entry) {
+                if ($entry["id"] !== $manualId) {
+                    continue;
+                }
+
+                if ($toStatus === "ordered") {
+                    $entry["backorder_status"] = "ordered";
+                    $entry["ordered_at"] = $now;
+                } else {
+                    $entry["backorder_status"] = "";
+                    $entry["ordered_at"] = "";
+                }
+
+                $manualChanged = true;
+                $updated++;
+                break;
+            }
+            unset($entry);
+            continue;
+        }
+
         foreach ($orders as &$order) {
             if ($order["id"] !== $target["order_id"]) {
                 continue;
@@ -2038,6 +2238,20 @@ function applyBackorderBulkUpdate($productId, $size, $fromStatus, $toStatus, $qu
         ];
     }
 
+    if ($manualChanged) {
+        $manualEntries = array_values(
+            array_filter($manualEntries, function ($entry) {
+                return ($entry["backorder_status"] ?? "") !== "";
+            }),
+        );
+        if (!saveManualBackorders($manualEntries)) {
+            return [
+                "success" => false,
+                "message" => "Manuelle Nachbestellungen konnten nicht gespeichert werden.",
+            ];
+        }
+    }
+
     saveOrders($orders);
 
     return ["success" => true, "updated" => $updated];