Compare commits

...
Author SHA1 Message Date
gitea_admin ae311e4612 Use compact document removal controls 2026-09-08 13:45:49 +02:00
gitea_admin 485882536f Use compact file removal buttons 2026-09-08 13:42:49 +02:00
gitea_admin f1631f5755 Complete lot document selection state 2026-09-08 13:39:52 +02:00
gitea_admin 99ec2667da Show selected lot document files 2026-09-08 13:38:52 +02:00
gitea_admin b3c84fae4c Fix lot document upload completion 2026-09-08 13:32:02 +02:00
gitea_admin fdcea56c26 Fix document file picker trigger 2026-09-08 13:27:38 +02:00
gitea_admin 608d91f530 Fix lot document file picker trigger 2026-09-08 13:25:51 +02:00
gitea_admin 1b752c0830 Reset document segments after lot save 2026-09-08 13:22:52 +02:00
gitea_admin a1f6e2edee Separate lot document card segment 2026-09-08 13:21:56 +02:00
gitea_admin 0867ffa0fa Resolve lot drawer PHP parse error 2026-09-08 13:13:15 +02:00
gitea_admin 8356b545c4 Fix PHP escaping in lot drawer script 2026-09-08 13:12:18 +02:00
gitea_admin 4dda727f03 Restore full-width lot document segment 2026-09-08 13:10:44 +02:00
gitea_admin 7491e0b7c5 Save lot documents with edit form 2026-09-08 13:05:37 +02:00
gitea_admin 3a3de9da22 Remove lot document comments 2026-09-08 12:17:49 +02:00
gitea_admin f130092d40 Align document comment label column 2026-09-08 12:14:29 +02:00
gitea_admin e9903eec19 Prevent lot document upload overflow 2026-09-08 12:12:26 +02:00
gitea_admin 0826560373 Align lot document upload fields 2026-09-08 12:10:44 +02:00
gitea_admin c75e308f45 Hide lot document editor outside edit mode 2026-09-08 12:07:34 +02:00
gitea_admin 0d03de8ff2 Hide lot document edit segment in view mode 2026-09-08 12:05:13 +02:00
gitea_admin 683327c240 Separate lot document view and edit segments 2026-09-08 12:03:11 +02:00
gitea_admin 382d9fe9df Fix lot document card segment structure 2026-09-08 11:58:01 +02:00
gitea_admin b8136764eb Fix open lot deletion audit payload 2026-09-08 11:54:08 +02:00
gitea_admin a31057f0c0 Align lot document upload layout 2026-09-08 11:51:04 +02:00
gitea_admin 5b0a623f4b Sync styleguide 2026.05.18.1 2026-09-08 11:49:28 +02:00
gitea_admin b3f52f2810 Fix document download environment path 2026-09-08 11:38:32 +02:00
gitea_admin 2bec934732 Add documents to stock lots 2026-09-08 11:38:10 +02:00
gitea_admin e47990dfe0 Sync styleguide 2026.05.18.1 2026-09-08 11:28:53 +02:00
gitea_admin 9d55a6d213 Forecast-Warnung mit sprechender Leerdatum-Bezeichnung anzeigen 2026-09-08 10:47:06 +02:00
gitea_admin 7c7c6da344 MHD-Warngrund in der Forecast-Anzeige ergaenzen 2026-09-08 10:45:44 +02:00
gitea_admin 6837ebfa6b Warnungsanzeige nach Forecast-Speicherung aktualisieren 2026-09-08 10:44:36 +02:00
gitea_admin 939c96f62d Chargendialog strukturell an Overlay-Card-Pattern angleichen 2026-09-08 10:42:27 +02:00
gitea_admin 82162d5dff Forecast-Anzeige direkt nach Chargenspeicherung synchronisieren 2026-09-08 10:30:51 +02:00
gitea_admin e98c5e6aa4 Forecast nach jeder Chargenspeicherung aktualisieren 2026-09-08 10:25:30 +02:00
gitea_admin 6bd2bf1b3f Offene-Charge-Overlay an Styleguide-Pattern angleichen 2026-09-08 10:21:44 +02:00
gitea_admin e62c7e11c2 Drawer-Bestand auf das geoeffnete Produkt begrenzen 2026-09-08 10:18:32 +02:00
gitea_admin 9382647a87 Offene Chargen im Drawer vollbreit und im Lagerbestand beruecksichtigen 2026-09-08 10:14:11 +02:00
gitea_admin 668ebbc64a Offene Chargen mit Sicherheitsabfrage loeschen 2026-09-08 09:56:34 +02:00
gitea_admin 44faf75e28 Sync styleguide 2026.05.18.1 2026-09-08 09:53:25 +02:00
gitea_admin 49ecbfa17c NULL-Chargennummer beim Speichern korrekt behandeln 2026-09-08 09:38:46 +02:00
gitea_admin aeab22b48a Chargen-Speicherfehler durch PDO-Platzhalter beheben 2026-09-08 09:38:09 +02:00
gitea_admin c076ec9889 Wording der Leerfeld-Bestaetigung korrigieren 2026-09-08 09:36:34 +02:00
gitea_admin de2df1a13b Leere Chargenfelder vor dem Speichern bestätigen 2026-09-08 09:30:22 +02:00
gitea_admin c8d9f03d7d Formular nach Chargen-Speichern ausblenden 2026-09-08 09:21:34 +02:00
gitea_admin 28fa29f294 Offene Chargen mit Zielmenge korrigieren 2026-09-08 09:18:31 +02:00
gitea_admin 584d941c03 Charge-Mengen beim Bearbeiten ersetzen 2026-09-08 09:16:18 +02:00
gitea_admin 3a56a24b00 Bestellungen mobil denselben Drawer wie Desktop verwenden 2026-09-06 21:18:37 +02:00
gitea_admin e6d4276445 Drawer-Schliessen-Button aus Produktdetail entfernen 2026-09-06 18:01:52 +02:00
gitea_admin 19ec3d66d9 Produktdetail mobil als denselben Drawer verwenden 2026-09-06 18:00:02 +02:00
gitea_admin 42f02d9441 Virtuelle Charge mobil identisch zum Desktop anzeigen 2026-09-06 17:54:47 +02:00
gitea_admin 16569a2357 Neue Charge im mobilen Produktdetail geschlossen starten 2026-09-06 17:52:21 +02:00
gitea_admin 1b783b6417 Mobile Produktdetail mit vollständigem Drawer-Inhalt anzeigen 2026-09-06 17:50:07 +02:00
gitea_admin f6ff1d0f78 Revert "Mobile Lagerdetails mit Desktop-Drawer-Inhalt anzeigen"
This reverts commit 2e78162e93.
2026-09-06 17:46:38 +02:00
gitea_admin 36c66eb0cc Revert "Mobile Produktdetail per DOM-Klon befüllen"
This reverts commit 2ee52b3672.
2026-09-06 17:46:38 +02:00
gitea_admin 4fbd944ece Revert "Mobile Lagerdetail ausserhalb ausgeblendeter Inhalte rendern"
This reverts commit 20670b4666.
2026-09-06 17:46:38 +02:00
gitea_admin 20670b4666 Mobile Lagerdetail ausserhalb ausgeblendeter Inhalte rendern 2026-09-06 17:44:55 +02:00
gitea_admin 2ee52b3672 Mobile Produktdetail per DOM-Klon befüllen 2026-09-06 17:41:14 +02:00
gitea_admin 2e78162e93 Mobile Lagerdetails mit Desktop-Drawer-Inhalt anzeigen 2026-09-06 17:38:44 +02:00
gitea_admin ca5cf5a03f Erlaube mehrere offene Lagerchargen 2026-09-06 17:37:01 +02:00
gitea_admin e656841de2 Beruecksichtige offene Chargenmengen im Forecast 2026-09-06 17:33:23 +02:00
gitea_admin 7b25f4da0a OTC-Produktfelder mobil am Styleguide ausrichten 2026-09-06 16:26:46 +02:00
gitea_admin 501f32c35e OTC-Bestandsanzeige aus Produktzeilen entfernen 2026-09-06 15:52:47 +02:00
gitea_admin af58b813ca OTC-Produktlabels und Mengenfelder ausrichten 2026-09-06 15:51:55 +02:00
gitea_admin a5ef8a4f43 OTC-Produktmengen kompakter darstellen 2026-09-06 15:50:53 +02:00
gitea_admin 89057c0c26 OTC-Formular vollständig am Styleguide ausrichten 2026-09-06 15:49:10 +02:00
gitea_admin f21bf84e80 OTC-Pulldowns mit Styleguide-Chevron anzeigen 2026-09-06 15:47:28 +02:00
gitea_admin b1fdf99e91 OTC-Fenster nicht durch Hintergrundklick schliessen 2026-09-06 15:46:02 +02:00
gitea_admin 88dbb008d2 OTC-Weiter-Button ausserhalb des Formulars aktivieren 2026-09-06 15:44:51 +02:00
gitea_admin 1cc35e9b5a Sync styleguide 2026.05.18.1 2026-09-06 15:43:46 +02:00
gitea_admin 1362e94948 Scrollable-Card-Styles ins Portal synchronisieren 2026-09-06 15:40:05 +02:00
gitea_admin a526cb9f60 Close recovered Shopify events already imported 2026-09-06 15:38:06 +02:00
gitea_admin ba796f0a3f Recover pending Shopify orders by stable order ID 2026-09-06 15:36:58 +02:00
gitea_admin f739106ab9 OTC-Card nur im Inhaltsbereich scrollbar machen 2026-09-06 15:36:24 +02:00
gitea_admin 307affbb67 Allow Shopify worker reuse from central cron 2026-09-06 15:35:25 +02:00
gitea_admin 85617b5b34 OTC-Formular als Scrollable Card darstellen 2026-09-06 15:34:35 +02:00
gitea_admin bd2ef4e799 Merge Shopify daily reconciliation 2026-09-06 15:33:19 +02:00
gitea_admin bf875f6dea Automate Shopify reconciliation in daily cron 2026-09-06 15:31:57 +02:00
gitea_admin e24394866f OTC-Verkauf vor dem Absenden bestätigen 2026-09-06 15:12:53 +02:00
gitea_admin 23d670acca OTC-Formular nach erfolgreicher Erfassung schliessen 2026-09-06 15:07:51 +02:00
gitea_admin 109df4f1f9 Bestellungen-Realtime vorerst deaktivieren 2026-09-06 14:55:51 +02:00
gitea_admin bc40316fdd Blockierenden Bestellungen-Realtime-Stream entfernen 2026-09-06 14:55:08 +02:00
gitea_admin e0cd3a456a OTC auf Einzelartikel je Produkt begrenzen 2026-09-06 14:48:59 +02:00
gitea_admin d5d428e09c Verschiebe Artikeltabelle in Artikelbereich 2026-09-06 14:26:29 +02:00
gitea_admin 2a31a88c1d OTC-Zahlungssummen als Verkaufspreis verwenden 2026-09-06 14:21:58 +02:00
gitea_admin 3957d57a3a Entferne Hintergrund der Neue-Charge-Aktion 2026-09-06 14:19:36 +02:00
gitea_admin 24eec23dba Passe Bearbeitungsmodus offener Chargen an 2026-09-06 14:18:11 +02:00
gitea_admin 10758c51b9 Korrigiere Neue-Charge-Formular und Notification 2026-09-06 14:16:48 +02:00
gitea_admin 0e7c5e51d5 Ermoegliche Bearbeitung offener Chargen 2026-09-06 14:14:40 +02:00
gitea_admin 60e94c7560 Blende technische INIT-Chargen aus Historie aus 2026-09-06 14:12:28 +02:00
gitea_admin 43cb4b9885 Ergaenze offene Chargen und Fehlbestands-Hinweis 2026-09-06 14:09:32 +02:00
gitea_admin 0401a7ae2a Begrenze Bestandskorrekturen auf ganze Mengen 2026-09-06 13:51:44 +02:00
gitea_admin 5908f71a39 Ergaenze Chargenformular um Lagerbewegungen 2026-09-06 13:49:10 +02:00
gitea_admin 1e5ededa00 Chargenspeichern im offenen Drawer abschliessen 2026-09-06 13:40:47 +02:00
gitea_admin 105a7df1ed Chargenspeichern ohne Seitenwechsel ausführen 2026-09-06 13:39:37 +02:00
gitea_admin c5b7b731ec Chargenmengen im Drawer korrekt befüllen 2026-09-06 13:36:55 +02:00
gitea_admin 81b657f53a Naurua-Chargennummer im Formular anzeigen 2026-09-06 13:36:13 +02:00
gitea_admin d14b533b06 Initialen Lesemodus der Charge sicherstellen 2026-09-06 13:34:19 +02:00
gitea_admin a5a1a44177 Aktive Charge mit Formularmodus bearbeiten 2026-09-06 13:32:17 +02:00
gitea_admin 28fd56f258 Lagerabfrage um fehlende Gruppierung korrigieren 2026-09-06 10:53:35 +02:00
gitea_admin 14f8c7d433 Warnungstitel je Warnfall unterscheiden 2026-09-06 10:52:37 +02:00
gitea_admin d747600e1c Historische Chargen im Produkt-Drawer anzeigen 2026-09-06 10:51:26 +02:00
gitea_admin ad5d23f6be Warnkarten im Produkt-Drawer sprechend einfärben 2026-09-06 10:45:56 +02:00
gitea_admin 28c4d8374f Merge feature/bestellungen-drawer-green-cards 2026-09-06 10:32:38 +02:00
gitea_admin caa80b0505 Bestellungen-Drawer an Produkte-Card-Stil angleichen 2026-09-06 10:32:21 +02:00
gitea_admin efdbba144f Merge feature/bestellungen-drawer-cards 2026-09-06 10:29:53 +02:00
gitea_admin 0581b7aead Bestellungen-Drawer in drei getrennte Cards aufteilen 2026-09-06 10:29:37 +02:00
gitea_admin f4cad97d39 Merge feature/bestellungen-drawer 2026-09-06 10:28:23 +02:00
gitea_admin 575650a7f0 Bestellungen-Drawer mit Artikeln und Chargen erweitern 2026-09-06 10:27:54 +02:00
gitea_admin e43b5d34e0 Prevent duplicate manufacturer parties 2026-09-06 10:21:25 +02:00
gitea_admin 19ce29c3d0 Add manufacturer lot traceability 2026-09-06 10:16:47 +02:00
gitea_admin 2aadce17bd Merge Klick-Cursor fuer mobile Zurueck-Cards 2026-09-06 10:10:48 +02:00
gitea_admin f021aec239 Klick-Cursor fuer mobile Zurueck-Cards setzen 2026-09-06 10:09:42 +02:00
gitea_admin d3fa2937a2 Mobile Lager-Drawer als Detailseite anzeigen 2026-09-06 10:07:37 +02:00
gitea_admin d16696a6fe Document Shopify and OTC forecast triggers 2026-09-06 09:38:31 +02:00
gitea_admin 03ea914d04 Refresh stock forecast after each sale 2026-09-06 09:37:27 +02:00
gitea_admin 5dd41a4bb2 Merge Drawer-Overlay und mobile Zurueck-Navigation 2026-09-06 09:34:33 +02:00
43 changed files with 2792 additions and 298 deletions

No files matched your search

+4
View File
@@ -14,6 +14,10 @@
10. `db/migrations/0014_require_human_lot_activation.sql`
11. `db/migrations/0015_sales_order_payments.sql`
12. `db/migrations/0016_stock_lot_forecast_history.sql`
13. `db/migrations/0017_manufacturer_lot_traceability.sql`
14. `db/migrations/0018_shopify_delta_sync_state.sql`
15. `db/migrations/0019_allow_multiple_open_stock_lots.sql`
16. `db/migrations/0020_include_stock_adjustments_in_balance.sql`
## Beispielausfuehrung
@@ -0,0 +1,53 @@
BEGIN;
CREATE TABLE party_role (
party_id BIGINT NOT NULL REFERENCES party(id) ON DELETE CASCADE,
role_code TEXT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
CONSTRAINT pk_party_role PRIMARY KEY (party_id, role_code),
CONSTRAINT chk_party_role_code CHECK (role_code IN ('manufacturer'))
);
CREATE INDEX idx_party_role_code ON party_role(role_code);
ALTER TABLE stock_lot
RENAME COLUMN supplier_lot_number TO manufacturer_lot_number;
ALTER TABLE stock_lot
ADD COLUMN manufacturer_party_id BIGINT REFERENCES party(id) ON DELETE RESTRICT;
INSERT INTO party (type, name, status, created_at, updated_at)
SELECT 'supplier', MIN(TRIM(supplier_name)), 'active', NOW(), NOW()
FROM stock_lot
WHERE supplier_name IS NOT NULL
AND TRIM(supplier_name) <> ''
AND NOT EXISTS (
SELECT 1
FROM party p
WHERE LOWER(TRIM(p.name)) = LOWER(TRIM(stock_lot.supplier_name))
)
GROUP BY LOWER(TRIM(supplier_name));
INSERT INTO party_role (party_id, role_code)
SELECT p.id, 'manufacturer'
FROM party p
WHERE EXISTS (
SELECT 1
FROM stock_lot sl
WHERE sl.supplier_name IS NOT NULL
AND LOWER(TRIM(sl.supplier_name)) = LOWER(TRIM(p.name))
)
ON CONFLICT (party_id, role_code) DO NOTHING;
UPDATE stock_lot sl
SET manufacturer_party_id = p.id
FROM party p
WHERE sl.supplier_name IS NOT NULL
AND LOWER(TRIM(sl.supplier_name)) = LOWER(TRIM(p.name));
ALTER TABLE stock_lot
DROP COLUMN supplier_name;
CREATE INDEX idx_stock_lot_manufacturer_party ON stock_lot(manufacturer_party_id);
COMMIT;
@@ -0,0 +1,16 @@
BEGIN;
CREATE TABLE shopify_delta_sync_state (
process_name TEXT PRIMARY KEY,
last_successful_cutoff TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
INSERT INTO shopify_delta_sync_state (process_name, last_successful_cutoff)
SELECT
'erp.import-integration.shopify_delta_order_import',
COALESCE(MAX(shopify_created_at), NOW())
FROM sales_order
WHERE shopify_order_gid IS NOT NULL;
COMMIT;
@@ -0,0 +1,5 @@
BEGIN;
DROP INDEX IF EXISTS uq_stock_lot_one_open_per_product;
COMMIT;
@@ -0,0 +1,22 @@
BEGIN;
CREATE OR REPLACE VIEW v_stock_lot_balance AS
SELECT
sl.id AS stock_lot_id,
sl.product_id,
COALESCE(SUM(CASE WHEN sm.move_type = 'in' THEN sm.qty ELSE 0 END), 0) AS qty_in,
COALESCE(SUM(CASE WHEN sm.move_type = 'out' THEN sm.qty ELSE 0 END), 0) AS qty_out,
COALESCE(SUM(
CASE
WHEN sm.move_type = 'in' THEN sm.qty
WHEN sm.move_type = 'out' THEN -sm.qty
WHEN sm.move_type = 'adjustment' AND sm.to_location_id IS NOT NULL AND sm.from_location_id IS NULL THEN sm.qty
WHEN sm.move_type = 'adjustment' AND sm.from_location_id IS NOT NULL AND sm.to_location_id IS NULL THEN -sm.qty
ELSE 0
END
), 0) AS qty_net
FROM stock_lot sl
LEFT JOIN stock_move sm ON sm.lot_id = sl.id
GROUP BY sl.id, sl.product_id;
COMMIT;
@@ -0,0 +1,18 @@
BEGIN;
CREATE TABLE stock_lot_document (
id BIGSERIAL PRIMARY KEY,
stock_lot_id BIGINT NOT NULL REFERENCES stock_lot(id) ON DELETE CASCADE,
original_filename TEXT NOT NULL,
stored_filename TEXT NOT NULL,
mime_type TEXT NOT NULL,
size_bytes BIGINT NOT NULL,
comment TEXT,
uploaded_by TEXT NOT NULL,
uploaded_at TIMESTAMP NOT NULL DEFAULT NOW(),
CONSTRAINT chk_stock_lot_document_size CHECK (size_bytes > 0 AND size_bytes <= 52428800)
);
CREATE INDEX idx_stock_lot_document_lot ON stock_lot_document(stock_lot_id, id);
COMMIT;
@@ -11,6 +11,7 @@ Diese Referenz ordnet die aktuellen Tabellen, Views und technischen DB-Artefakte
### `kontakte`
- `party`
- `party_role`
- `address`
- `contact`
- `party_external_identity`
@@ -8,18 +8,19 @@ Read-only-Abgleich von Shopify-Bestellungen nach dem ERP-Stichtag und identifier
## 2. Prozess-Einbettung
Manuell startbarer Initial-Deltalauf und später wiederverwendbarer Reconciliation-Prozess im Submodul `erp/import-integration`. Er ist vom Webhook-Empfang getrennt.
Manuell startbarer Initial-Deltalauf und täglich über den zentralen ERP-Cron gestarteter Reconciliation-Prozess im Submodul `erp/import-integration`. Er ist vom Webhook-Empfang getrennt.
## 3. Input
- `cutoff_timestamp`
- optional `dry_run`
- beim täglichen Cron der persistierte letzte erfolgreiche Wasserstand sowie der älteste noch nicht erfolgreich verarbeitete Webhook-Eingang
Es werden keine vorbereiteten Shopify-Geschäftsdaten an nachgelagerte Module übergeben.
## 4. Exakter Prozessablauf
1. `cutoff_timestamp` und `dry_run` validieren.
1. `cutoff_timestamp` und `dry_run` validieren; beim Cron den kleineren Wert aus Wasserstand und ältestem offenen Webhook-Eingang verwenden und offene Webhook-Order-GIDs zusätzlich direkt zur Recovery aufnehmen.
2. Read-only Shopify-Bestellungen mit `createdAt` nach dem Stichtag in stabiler Reihenfolge laden.
3. Stornierte oder vollständig erstattete Bestellungen als nicht lagerwirksam klassifizieren und nur technisch protokollieren.
4. Für jede qualifizierte Bestellung die Shopify Order-GID idempotent gegen `sales_order.shopify_order_gid` prüfen.
@@ -42,7 +43,7 @@ Write targets:
## 5. Batch, Betriebsmodell und Einbettung
Der Initiallauf verarbeitet den festen Zeitraum nach dem bestehenden ERP-Stichtag seriell. Spätere Reconciliation-Läufe nutzen denselben Prozess mit einem definierten Wasserzeichen. Keine Parallelisierung.
Der Initiallauf verarbeitet den festen Zeitraum nach dem bestehenden ERP-Stichtag seriell. Spätere tägliche Reconciliation-Läufe nutzen den persistierten Wasserstand und ziehen offene oder fehlgeschlagene Webhook-Eingänge sowohl über den Prüfbereich als auch über ihre stabile Order-GID wieder in die Verarbeitung. Ein technisches Event wird erst nach erfolgreicher Projektion als verarbeitet markiert. Der Wasserstand wird nur nach einem vollständig erfolgreichen Lauf fortgeschrieben. Keine Parallelisierung.
## 6. Output
+1
View File
@@ -14,6 +14,7 @@ Zentraler Kontaktstamm fuer Kunden, Lieferanten und sonstige Parteien.
## Writes
- Kontaktstamm
- Herstellerrollen im Parteien-Stamm
- Adressen
- Kommunikationsdaten
+3
View File
@@ -18,6 +18,7 @@ Bestand, Charge, MHD und Bewegungen.
- Warenabgang
- Umlagerung
- Chargenpflege
- Verknüpfung von Naurua- und Hersteller-Chargen
- Virtuelle Fehlbestandschargen und menschlich bestaetigte Nachallokation
## Reads
@@ -42,4 +43,6 @@ Bestand, Charge, MHD und Bewegungen.
- `modules/erp/lager/service.php`
Hersteller-Stammdaten gehören zum Modul `kontakte`. Eine reale Lagercharge kann dort einen Hersteller und dessen Hersteller-Chargennummer referenzieren; die interne Naurua-Chargennummer bleibt Eigentum des Moduls `lager`. Virtuelle Fehlbestandschargen haben keinen Herstellerbezug.
Virtuelle Fehlbestandschargen tragen den Status `virtual_shortage`, sind nicht bestandswirksam und werden bei einer menschlich bestaetigten Nachallokation durch eine neue reale Chargenzuordnung ersetzt. Die virtuelle Ausgangszuordnung bleibt als Historie erhalten.
@@ -8,27 +8,28 @@ Tägliche Berechnung und Speicherung des voraussichtlichen Leer-Datums jeder akt
## 2. Prozess-Einbettung
Direkt ausgeführter, täglich um 06:00 Uhr gestarteter Lager-Prozess über den zentralen ERP-Cron-Einstieg.
Direkt ausgeführter Lager-Prozess mit zwei Triggern: synchron nach jeder erfolgreichen Verkaufsallokation in der zentralen Lagerschnittstelle für Shopify- und OTC-Verkäufe sowie täglich um 06:00 Uhr über den zentralen ERP-Cron-Einstieg.
## 3. Input
- keine externen Prozessparameter
- aktive Produkte, aktuelle Chargen, physischer Chargenbestand und gültige Verkaufsallokationen aus dem eigenen Datenbestand
- optional eine einzelne `product_id` bei synchronem Verkaufstrigger
- aktive Produkte, aktuelle Chargen, eingetragene Mengen aller offenen Folgechargen, physischer Chargenbestand und gültige Verkaufsallokationen aus dem eigenen Datenbestand
## 4. Exakter Prozessablauf
1. Aktive Produkte und ihre aktuelle Charge aus `product`, `stock_lot` und `v_stock_lot_balance` laden.
2. Verkaufsallokationen der letzten 365 Tage aus `sales_order_line_lot_allocation`, `sales_order_line` und `sales_order` laden.
3. `allocated`- und `reserved`-Mengen nach Bestell- beziehungsweise OTC-Verkaufsdatum je Produkt und Kalendertag aggregieren.
4. Stornierte Auftrags- und Positionsmengen aus der Nachfrage herausrechnen.
5. Den gewichteten Tagesabsatz mit einer Halbwertszeit von 60 Tagen berechnen.
6. Für jede aktuelle Charge das Leer-Datum aus physischem Bestand und gewichteter Nachfrage berechnen.
7. Jede erfolgreiche Berechnung in `stock_lot_forecast_history` speichern und den aktuellen Snapshot in `stock_lot` aktualisieren.
8. Fehler einzelner Produkte sammeln und mit den übrigen Produkten fortfahren.
1. Bei einem Verkaufstrigger die betroffenen Produkt-IDs aus der erfolgreichen Allokation übernehmen; beim Cron-Trigger alle aktiven Produkte bestimmen.
2. Aktive Produkte, ihre aktuelle Charge und die Nettobestände aller bereits angelegten offenen Folgechargen aus `product`, `stock_lot` und `v_stock_lot_balance` laden. Je offene Folgecharge wird nur eine positive Menge berücksichtigt.
3. Verkaufsallokationen der letzten 365 Tage aus `sales_order_line_lot_allocation`, `sales_order_line` und `sales_order` laden.
4. `allocated`- und `reserved`-Mengen nach Bestell- beziehungsweise OTC-Verkaufsdatum je Produkt und Kalendertag aggregieren.
5. Stornierte Auftrags- und Positionsmengen aus der Nachfrage herausrechnen.
6. Den gewichteten Tagesabsatz mit einer Halbwertszeit von 60 Tagen berechnen.
7. Für jede aktuelle Charge das Leer-Datum aus ihrem physischen Bestand zuzüglich der eingetragenen Mengen aller offenen Folgechargen und der gewichteten Nachfrage berechnen.
8. Jede erfolgreiche Berechnung in `stock_lot_forecast_history` speichern und den aktuellen Snapshot in `stock_lot` aktualisieren.
9. Im Cron Fehler einzelner Produkte sammeln und mit den übrigen Produkten fortfahren.
## 5. Batch, Betriebsmodell und Einbettung
Ein täglicher Lauf um 06:00 Uhr Europe/Zurich verarbeitet alle aktiven Produkte deterministisch nach `product.id`. Produktfehler sind batch-tolerant; technische Gesamtfehler schlagen den Lauf hart fehl.
Der Cron läuft täglich um 06:00 Uhr Europe/Zurich und verarbeitet alle aktiven Produkte deterministisch nach `product.id`. Verkaufstrigger aktualisieren die betroffenen Produkte synchron innerhalb der laufenden Verkaufs-Transaktion. Produktfehler im Cron sind batch-tolerant; technische Gesamtfehler schlagen den jeweiligen Lauf hart fehl.
## 6. Output
@@ -36,7 +37,7 @@ Ein normalisiertes JSON-Ergebnis mit Gesamtzahl, erfolgreich verarbeiteten Produ
## 7. Erfolgskriterien
- Jede aktuelle Charge erhält täglich einen aktuellen Prognosesnapshot.
- Jede aktuelle Charge erhält täglich einen aktuellen Prognosesnapshot, der eingetragene Mengen aller offenen Folgechargen berücksichtigt.
- Verkäufe ohne Bestand über virtuelle `reserved`-Allokationen beeinflussen die Nachfrage.
- Die vollständige Berechnungshistorie bleibt erhalten.
- Ein einzelner Produktfehler verhindert keine weiteren Berechnungen.
@@ -26,6 +26,10 @@
<button class="sg-interaction-element sg-button sg-button--active" type="button" data-component="button" data-component-state="active">
Link-Button aktiv
</button>
<button class="sg-interaction-element sg-button sg-button--warning" type="button" data-component="button" data-component-variant="warning">
Link Button Warning
</button>
</div>
</section>
@@ -843,7 +847,28 @@
</section>
<script src="../scripts/help-icon-overlays.js"></script>
<section id="component-file-input">
<p class="sg-preview-label">Component: File Input</p>
<div class="sg-form-preview-area">
<div class="sg-file-input" data-component="file-input">
<div class="sg-file-input__row sg-labeled-input-row">
<label class="sg-label" for="component-file-input-control">Datei auswählen</label>
<input class="sg-file-input__control" id="component-file-input-control" type="file" multiple>
<button class="sg-interaction-element sg-button sg-button--active sg-file-input__trigger" type="button">Dateien auswählen</button>
</div>
</div>
</div>
</section>
<script>
document.querySelectorAll('[data-component="file-input"]').forEach((fileInput) => {
const input = fileInput.querySelector('.sg-file-input__control');
const trigger = fileInput.querySelector('.sg-file-input__trigger');
if (input && trigger) {
trigger.addEventListener('click', () => input.click());
}
});
document.querySelectorAll('.sg-mode-toggle:not(.sg-activation-mode-toggle)').forEach((toggle) => {
toggle.addEventListener('click', () => {
const nextState = toggle.dataset.active === 'relative' ? 'absolute' : 'relative';
+2
View File
@@ -158,6 +158,8 @@
<tr><td>input-field-max-width</td><td>600px</td><td>Maximale Breite von ein- und mehrzeiligen Eingabefeldern (ohne Suchfeld).</td></tr>
<tr><td>email-body-max-width</td><td>600px</td><td>Maximale Breite des Inhaltsbereichs von E-Mail-Templates innerhalb des E-Mail-Viewports.</td></tr>
<tr><td>object-card-height</td><td>600px</td><td>Fixe Höhe der Object Card im Desktop-Layout (u. a. für Company Card).</td></tr>
<tr><td>scrollable-card-width</td><td>600px</td><td>Standardbreite der eigenständigen Scrollable Card; kann im konsumierenden Portal über den semantischen Layout-Token angepasst werden.</td></tr>
<tr><td>scrollable-card-height</td><td>600px</td><td>Standardhöhe der eigenständigen Scrollable Card; kann im konsumierenden Portal über den semantischen Layout-Token angepasst werden.</td></tr>
<tr><td>content-card-margin-top-desktop</td><td>100px</td><td>Oberer Außenabstand der Content Card auf Desktop.</td></tr>
<tr><td>content-card-margin-top-mobile</td><td>1rem</td><td>Oberer Außenabstand der Content Card auf Mobile; entspricht spacing-large.</td></tr>
<tr><td>notification-card-width</td><td>50%</td><td>Breite der Notification Card relativ zum übergeordneten Layout.</td></tr>
+2
View File
@@ -43,6 +43,7 @@
<li><a href="./patterns/portal-header.html">Portal Header</a></li>
<li><a href="./patterns/options-row.html">Options Row</a></li>
<li><a href="./patterns/object-card.html">Object Card</a></li>
<li><a href="./patterns/scrollable-card.html">Scrollable Card</a></li>
<li><a href="./patterns/object-group-card.html">Object Group Card</a></li>
<li><a href="./patterns/navigation-card.html">Navigation Card</a></li>
<li><a href="./patterns/left-navigation.html">Left Navigation</a></li>
@@ -50,6 +51,7 @@
<li><a href="./patterns/content-cards-group.html">Content Cards Group</a></li>
<li><a href="./patterns/card-gruppe-mit-tastennavigation.html">Card Gruppe mit Tastennavigation</a></li>
<li><a href="./patterns/formular-mit-abschnitten.html">Formular mit Abschnitten</a></li>
<li><a href="./patterns/file-upload.html">Datei-Upload</a></li>
<li><a href="./patterns/multiselektions-pulldown.html">Multiselektions-Pulldown</a></li>
<li><a href="./patterns/text-layouts.html">Text Layouts</a></li>
<li><a href="./patterns/delete-confirmation-overlay.html">Overlay Card</a></li>
+71
View File
@@ -0,0 +1,71 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Styleguide – Pattern Datei-Upload</title>
<link rel="stylesheet" href="../styleguide.css">
</head>
<body>
<h1 class="sg-main-heading">Pattern – Datei-Upload</h1>
<section id="pattern-file-upload">
<p class="sg-preview-label">Pattern: Datei-Upload</p>
<form class="sg-file-upload" data-pattern="file-upload" action="#" method="post">
<div class="sg-file-upload__dropzone sg-labeled-input-row" data-drag-over="false">
<label class="sg-label" for="file-upload-input">Datei auswählen</label>
<input class="sg-file-input__control" id="file-upload-input" name="documents" type="file" multiple aria-describedby="file-upload-status">
<button class="sg-interaction-element sg-button sg-button--active sg-file-upload__trigger" type="button">Dateien auswählen</button>
</div>
<p class="sg-body sg-file-upload__status" id="file-upload-status" aria-live="polite">Noch keine Dateien ausgewählt.</p>
<ul class="sg-file-upload__files" aria-label="Ausgewählte Dateien"></ul>
<button class="sg-interaction-element sg-button sg-button--process sg-button--process-inactive sg-file-upload__submit" type="submit" disabled aria-disabled="true">Hochladen</button>
</form>
</section>
<script>
(() => {
const upload = document.querySelector('[data-pattern="file-upload"]');
if (!upload) return;
const input = upload.querySelector('#file-upload-input');
const dropzone = upload.querySelector('.sg-file-upload__dropzone');
const trigger = upload.querySelector('.sg-file-upload__trigger');
const filesList = upload.querySelector('.sg-file-upload__files');
const status = upload.querySelector('.sg-file-upload__status');
const submit = upload.querySelector('[type="submit"]');
let selectedFiles = [];
const formatSize = (bytes) => bytes < 1024 * 1024 ? `${Math.max(1, Math.round(bytes / 1024))} KB` : `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
const render = () => {
filesList.innerHTML = '';
selectedFiles.forEach((file, index) => {
const item = document.createElement('li');
item.className = 'sg-file-upload__file';
const name = document.createElement('span');
name.className = 'sg-file-upload__file-name';
name.textContent = file.name;
const meta = document.createElement('span');
meta.className = 'sg-file-upload__file-meta';
meta.textContent = ` · ${formatSize(file.size)}`;
name.append(meta);
const remove = document.createElement('button');
remove.className = 'sg-interaction-element sg-button sg-button--active sg-file-upload__remove';
remove.type = 'button';
remove.textContent = 'Entfernen';
remove.addEventListener('click', () => { selectedFiles.splice(index, 1); render(); });
item.append(name, remove);
filesList.append(item);
});
const count = selectedFiles.length;
status.textContent = count === 0 ? 'Noch keine Dateien ausgewählt.' : `${count} Datei${count === 1 ? '' : 'en'} ausgewählt.`;
submit.disabled = count === 0;
submit.setAttribute('aria-disabled', String(count === 0));
submit.classList.toggle('sg-button--process-inactive', count === 0);
};
trigger.addEventListener('click', () => input.click());
input.addEventListener('change', () => { selectedFiles = [...selectedFiles, ...Array.from(input.files)]; input.value = ''; render(); });
['dragenter', 'dragover'].forEach((eventName) => dropzone.addEventListener(eventName, (event) => { event.preventDefault(); dropzone.dataset.dragOver = 'true'; }));
['dragleave', 'drop'].forEach((eventName) => dropzone.addEventListener(eventName, (event) => { event.preventDefault(); dropzone.dataset.dragOver = 'false'; }));
dropzone.addEventListener('drop', (event) => { selectedFiles = [...selectedFiles, ...Array.from(event.dataTransfer.files)]; render(); });
render();
})();
</script>
</body>
</html>
@@ -0,0 +1,52 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Styleguide – Pattern Scrollable Card</title>
<link rel="stylesheet" href="../styleguide.css">
</head>
<body>
<h1 class="sg-main-heading">Pattern – Scrollable Card</h1>
<section id="pattern-scrollable-card">
<p class="sg-preview-label">Pattern: Scrollable Card</p>
<p class="sg-body">Die Scrollable Card besitzt eine fixe Breite und Höhe. Header und Aktionsbereich bleiben sichtbar; ausschließlich der Inhaltsbereich ist scrollbar. Das Pattern ist unabhängig von Overlay-Patterns und kann bei Bedarf von diesen verwendet werden.</p>
<article class="sg-card sg-scrollable-card" data-pattern="scrollable-card" aria-label="Scrollable Card">
<header class="sg-card-segment sg-card-segment--header sg-card-segment--darkblue sg-scrollable-card__header" data-pattern-part="scrollable-card-header">
<div class="sg-strong">Scrollable Card</div>
</header>
<div class="sg-card-segment sg-card-segment--body sg-scrollable-card__content" data-pattern-part="scrollable-card-content" tabindex="0" aria-label="Scrollbarer Karteninhalt">
<p class="sg-body">Der Karteninhalt bleibt innerhalb der definierten Kartenhöhe. Bei längeren Inhalten entsteht hier ein eigener vertikaler Scrollbereich, während Titel und Aktionen sichtbar bleiben.</p>
<p class="sg-body">Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer luctus, turpis vel porttitor cursus, nibh justo feugiat sem, sit amet egestas lorem arcu sed augue.</p>
<p class="sg-body">Donec sed lorem in urna sagittis aliquet. Suspendisse potenti. Curabitur blandit tempus porttitor. Maecenas faucibus mollis interdum.</p>
<p class="sg-body">Praesent commodo cursus magna, vel scelerisque nisl consectetur et. Aenean lacinia bibendum nulla sed consectetur.</p>
<p class="sg-body">Zusätzlicher Inhalt zur Demonstration des unabhängigen Scrollbereichs innerhalb der Card.</p>
<p class="sg-body">Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed posuere consectetur est at lobortis. Cras mattis consectetur purus sit amet fermentum.</p>
<p class="sg-body">Maecenas sed diam eget risus varius blandit sit amet non magna. Donec ullamcorper nulla non metus auctor fringilla.</p>
<p class="sg-body">Nullam quis risus eget urna mollis ornare vel eu leo. Vestibulum id ligula porta felis euismod semper.</p>
<p class="sg-body">Aenean eu leo quam. Pellentesque ornare sem lacinia quam venenatis vestibulum. Etiam porta sem malesuada magna mollis euismod.</p>
<p class="sg-body">Integer posuere erat a ante venenatis dapibus posuere velit aliquet. Morbi leo risus, porta ac consectetur ac, vestibulum at eros.</p>
<p class="sg-body">Curabitur blandit tempus porttitor. Donec id elit non mi porta gravida at eget metus. Vestibulum ligula porta felis euismod semper.</p>
<p class="sg-body">Fusce dapibus, tellus ac cursus commodo, tortor mauris condimentum nibh, ut fermentum massa justo sit amet risus.</p>
<p class="sg-body">Cras justo odio, dapibus ac facilisis in, egestas eget quam. Etiam porta sem malesuada magna mollis euismod.</p>
<p class="sg-body">Morbi risus porta ac consectetur ac, vestibulum at eros. Sed posuere consectetur est at lobortis.</p>
<p class="sg-body">Donec sed odio dui. Aenean lacinia bibendum nulla sed consectetur. Nulla vitae elit libero, a pharetra augue.</p>
<p class="sg-body">Vestibulum id ligula porta felis euismod semper. Donec ullamcorper nulla non metus auctor fringilla.</p>
<p class="sg-body">Der untere Aktionsbereich bleibt auch am Ende des Scrollvorgangs an seiner Position sichtbar.</p>
</div>
<footer class="sg-card-segment sg-card-segment--body sg-scrollable-card__actions-segment" data-pattern-part="scrollable-card-actions">
<div class="sg-scrollable-card__actions">
<button class="sg-interaction-element sg-button sg-button--active" type="button">Abbrechen</button>
<button class="sg-interaction-element sg-button sg-button--active" type="button">Speichern</button>
</div>
</footer>
</article>
</section>
</body>
</html>
@@ -63,6 +63,12 @@
<tr><td>surface-object-card-lower-segment</td><td>color-white</td><td>Hintergrundfläche der unteren zwei Segmente der Object Card.</td></tr>
</tbody></table>
<h3 class="sg-sub-heading sg-section-h3">Scrollable Card</h3>
<table class="sg-foundation-table sg-table-label"><thead><tr><th>Semantischer Token</th><th>Verwendeter Foundation-Token</th><th>Beschreibung Verwendungszweck im Pattern</th></tr></thead><tbody>
<tr><td>layout-scrollable-card-width</td><td>dimension-scrollable-card-width</td><td>Fixe Breite der Scrollable Card; kann im konsumierenden Portal individuell gesetzt werden.</td></tr>
<tr><td>layout-scrollable-card-height</td><td>dimension-scrollable-card-height</td><td>Fixe Höhe der Scrollable Card; kann im konsumierenden Portal individuell gesetzt werden.</td></tr>
</tbody></table>
<h3 class="sg-sub-heading sg-section-h3">Object Group Card</h3>
<table class="sg-foundation-table sg-table-label"><thead><tr><th>Semantischer Token</th><th>Verwendeter Foundation-Token</th><th>Beschreibung Verwendungszweck im Pattern</th></tr></thead><tbody>
<tr><td>layout-object-group-card-min-width</td><td>dimension-object-group-card-min-width</td><td>Mindestbreite der einzelnen Karten im Pattern Object Group Card.</td></tr>
@@ -142,6 +148,14 @@
<tr><td>layout-delete-confirmation-target-dim-opacity</td><td>opacity-delete-confirmation-target-dim</td><td>Deckkraft der grauen Überlagerung auf dem zu löschenden Objekt.</td></tr>
<tr><td>layer-delete-confirmation-overlay</td><td>dimension-layer-delete-confirmation-overlay</td><td>Z-Index-Layer des schwebenden Bestätigungsfensters.</td></tr>
</tbody></table>
<h3 class="sg-sub-heading sg-section-h3">Datei-Upload</h3>
<table class="sg-foundation-table sg-table-label"><thead><tr><th>Semantischer Token</th><th>Verwendeter Foundation-Token</th><th>Beschreibung Verwendungszweck im Pattern</th></tr></thead><tbody>
<tr><td>surface-file-upload-dropzone</td><td>surface-form-preview</td><td>Hintergrundfläche des auswählbaren Upload-Bereichs.</td></tr>
<tr><td>surface-file-upload-file-item</td><td>surface-control-default</td><td>Hintergrundfläche eines Eintrags in der Dateiliste.</td></tr>
<tr><td>text-file-upload-meta</td><td>text-control-disabled</td><td>Textfarbe für Dateigröße und ergänzende Dateimetadaten.</td></tr>
<tr><td>text-file-upload-error</td><td>color-signal-red</td><td>Textfarbe für Validierungs- und Upload-Fehlermeldungen.</td></tr>
</tbody></table>
</section>
</body>
+2
View File
@@ -2,6 +2,7 @@
@import "./styles/02-base.css";
@import "./styles/03-typography-helpers.css";
@import "./styles/10-components-interactive-elements.css";
@import "./styles/47-patterns-file-upload.css";
@import "./styles/20-patterns-portal-header.css";
@import "./styles/21-patterns-options-row.css";
@import "./styles/22-patterns-object-card.css";
@@ -26,3 +27,4 @@
@import "./styles/43-components-typography.css";
@import "./styles/44-components-public-page-components.css";
@import "./styles/45-patterns-public-page-patterns.css";
@import "./styles/46-patterns-scrollable-card.css";
@@ -175,6 +175,8 @@
--layout-object-card-mobile-width: var(--dimension-object-card-mobile-width);
--layout-object-card-mobile-height: var(--dimension-object-card-mobile-height);
--layout-object-card-desktop-breakpoint: var(--dimension-object-card-desktop-breakpoint);
--layout-scrollable-card-width: var(--dimension-scrollable-card-width);
--layout-scrollable-card-height: var(--dimension-scrollable-card-height);
--layout-content-card-margin-top-desktop: var(--dimension-content-card-margin-top-desktop);
--layout-content-card-margin-top-mobile: var(--dimension-content-card-margin-top-mobile);
--layout-notification-card-width: var(--dimension-notification-card-width);
@@ -287,6 +289,8 @@
--dimension-object-card-mobile-width: 100%;
--dimension-object-card-mobile-height: auto;
--dimension-object-card-desktop-breakpoint: 768px;
--dimension-scrollable-card-width: 600px;
--dimension-scrollable-card-height: 600px;
--dimension-content-card-margin-top-desktop: 100px;
--dimension-content-card-margin-top-mobile: 1rem;
--dimension-object-group-card-min-width: 450px;
@@ -139,6 +139,13 @@
color: var(--text-control-default);
}
.sg-button--warning {
--surface-button-warning: var(--color-signal-red);
--text-button-warning: var(--color-font-light);
background: var(--surface-button-warning);
color: var(--text-button-warning);
}
.sg-button--process {
background: var(--surface-button-process);
color: var(--text-button-process);
@@ -1578,3 +1585,52 @@
color: var(--text-hyperlink);
text-decoration: none;
}
/* Component: File Input */
.sg-file-input {
width: 100%;
}
.sg-file-input__row {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-large);
width: 100%;
}
.sg-file-input__control {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.sg-file-input:focus-within .sg-file-input__trigger {
outline: 2px solid var(--text-hyperlink);
outline-offset: 2px;
}
.sg-file-input__trigger {
align-self: flex-start;
}
@media (max-width: 48rem) {
.sg-file-input__row {
align-items: flex-start;
flex-direction: column;
}
}
.sg-file-input--disabled {
opacity: var(--disabled-opacity);
}
.sg-file-input--disabled .sg-file-input__trigger {
cursor: not-allowed;
}
@@ -0,0 +1,34 @@
/* ========================================================= */
/* Patterns: Scrollable Card */
/* ========================================================= */
.sg-scrollable-card {
display: flex;
flex-direction: column;
width: var(--layout-scrollable-card-width);
max-width: 100%;
height: var(--layout-scrollable-card-height);
box-sizing: border-box;
}
.sg-scrollable-card__header,
.sg-scrollable-card__actions-segment {
flex: 0 0 auto;
}
.sg-scrollable-card__content {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
}
.sg-scrollable-card__actions {
display: flex;
gap: var(--spacing-small);
width: 100%;
}
.sg-scrollable-card__actions > .sg-button {
flex: 1 1 0;
min-width: 0;
}
@@ -0,0 +1,91 @@
/* ========================================================= */
/* Patterns: Datei-Upload */
/* ========================================================= */
.sg-file-upload {
--surface-file-upload-dropzone: var(--surface-form-preview);
--surface-file-upload-file-item: var(--surface-control-default);
--text-file-upload-meta: var(--text-control-disabled);
--text-file-upload-error: var(--color-signal-red);
display: flex;
flex-direction: column;
gap: var(--spacing-large);
width: 100%;
max-width: var(--layout-input-field-max-width);
}
.sg-file-upload__status,
.sg-file-upload__file-meta {
margin: var(--spacing-small) 0 0;
}
.sg-file-upload__dropzone {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-large);
width: 100%;
padding: var(--spacing-large);
background: var(--surface-file-upload-dropzone);
border: 1px dashed var(--surface-button-active);
border-radius: var(--radius-interaction);
}
.sg-file-upload__dropzone[data-drag-over="true"] {
outline: 2px solid var(--text-hyperlink);
outline-offset: 2px;
}
.sg-file-upload__files {
display: flex;
flex-direction: column;
gap: var(--spacing-small);
margin: 0;
padding: 0;
list-style: none;
}
.sg-file-upload__file {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-large);
padding: var(--spacing-small) var(--spacing-large);
color: var(--text-control-default);
background: var(--surface-file-upload-file-item);
border-radius: var(--radius-interaction);
}
.sg-file-upload__file-name {
min-width: 0;
overflow-wrap: anywhere;
}
.sg-file-upload__file-meta {
color: var(--text-file-upload-meta);
}
.sg-file-upload__remove {
flex: 0 0 auto;
}
.sg-file-upload__submit {
align-self: flex-end;
}
.sg-file-upload__error {
color: var(--text-file-upload-error);
}
@media (max-width: 48rem) {
.sg-file-upload__dropzone {
align-items: flex-start;
flex-direction: column;
}
.sg-file-upload__file {
align-items: flex-start;
flex-direction: column;
gap: var(--spacing-small);
}
}
+14 -4
View File
@@ -129,10 +129,20 @@ function sellable_item_exists(PDO $pdo, int $sellableItemId): bool
function find_sellable_item_for_product(PDO $pdo, int $productId): ?int
{
$stmt = $pdo->prepare(
'SELECT sellable_item_id
FROM sellable_item_component
WHERE product_id = :product_id
ORDER BY id
'SELECT sic.sellable_item_id
FROM sellable_item_component sic
INNER JOIN sellable_item si ON si.id = sic.sellable_item_id
INNER JOIN product p ON p.id = sic.product_id
WHERE sic.product_id = :product_id
AND sic.qty_per_item = 1
AND si.item_code = p.sku || \'.01\'
AND NOT EXISTS (
SELECT 1
FROM sellable_item_component other_sic
WHERE other_sic.sellable_item_id = sic.sellable_item_id
AND other_sic.id <> sic.id
)
ORDER BY sic.id
LIMIT 1'
);
$stmt->execute([':product_id' => $productId]);
+144
View File
@@ -374,6 +374,150 @@ function get_sales_order_overview(PDO $pdo, array $filters = []): array
];
}
function get_sales_order_detail(PDO $pdo, int $orderId): ?array
{
$orderStmt = $pdo->prepare(
"SELECT
so.id,
so.external_ref,
so.order_date,
so.order_source,
so.order_status,
so.payment_status,
so.amount_net,
so.amount_shipping,
so.amount_tax,
so.amount_discount,
so.total_amount,
so.currency,
COALESCE(ad.first_name, '') AS first_name,
COALESCE(ad.last_name, '') AS last_name,
COALESCE(ad.street, '') AS street,
COALESCE(ad.house_number, '') AS house_number,
COALESCE(ad.zip, '') AS zip,
COALESCE(ad.city, '') AS city,
COALESCE(ad.country_name, '') AS country_name,
COALESCE(pm.label, '') AS payment_method,
COALESCE(sm.label, '') AS shipping_method
FROM sales_order so
LEFT JOIN LATERAL (
SELECT a.first_name, a.last_name, a.street, a.house_number, a.zip, a.city, a.country_name
FROM address a
WHERE a.party_id = so.party_id AND a.type IN ('shipping', 'billing')
ORDER BY CASE WHEN a.type = 'shipping' THEN 0 ELSE 1 END, a.id DESC
LIMIT 1
) ad ON TRUE
LEFT JOIN payment_method pm ON pm.id = so.payment_method_id
LEFT JOIN shipping_method sm ON sm.id = so.shipping_method_id
WHERE so.id = :order_id"
);
$orderStmt->execute([':order_id' => $orderId]);
$order = $orderStmt->fetch();
if (!is_array($order)) {
return null;
}
$lineStmt = $pdo->prepare(
"SELECT
sol.id,
sol.line_no,
sol.sellable_item_id,
COALESCE(si.item_code, sol.raw_external_article_number, '') AS item_code,
COALESCE(si.display_name, sol.raw_external_title, '') AS item_name,
COALESCE(sol.raw_external_article_number, '') AS raw_article_number,
COALESCE(sol.raw_external_title, '') AS raw_title,
sol.sku,
sol.qty,
sol.qty_cancelled,
sol.line_status,
sol.unit_price,
sol.line_total,
sic.product_id,
p.name AS product_name,
sic.qty_per_item,
a.id AS allocation_id,
a.qty AS allocated_qty,
a.allocation_status,
sl.lot_number,
sl.status AS lot_status
FROM sales_order_line sol
LEFT JOIN sellable_item si ON si.id = sol.sellable_item_id
LEFT JOIN sellable_item_component sic ON sic.sellable_item_id = sol.sellable_item_id
LEFT JOIN product p ON p.id = sic.product_id
LEFT JOIN sales_order_line_lot_allocation a
ON a.sales_order_line_id = sol.id AND a.product_id = sic.product_id
LEFT JOIN stock_lot sl ON sl.id = a.lot_id
WHERE sol.sales_order_id = :order_id
ORDER BY sol.line_no, p.name NULLS LAST, a.id"
);
$lineStmt->execute([':order_id' => $orderId]);
$lines = [];
foreach ($lineStmt->fetchAll() as $row) {
$lineId = (int) $row['id'];
if (!isset($lines[$lineId])) {
$lines[$lineId] = [
'id' => $lineId,
'line_no' => (int) $row['line_no'],
'item_code' => (string) $row['item_code'],
'item_name' => (string) $row['item_name'],
'raw_article_number' => (string) $row['raw_article_number'],
'raw_title' => (string) $row['raw_title'],
'sku' => (string) ($row['sku'] ?? ''),
'qty' => (float) $row['qty'],
'qty_cancelled' => (float) $row['qty_cancelled'],
'line_status' => (string) $row['line_status'],
'unit_price' => $row['unit_price'] !== null ? (float) $row['unit_price'] : null,
'line_total' => $row['line_total'] !== null ? (float) $row['line_total'] : null,
'product_allocations' => [],
];
}
if ($row['product_id'] !== null) {
$lines[$lineId]['product_allocations'][] = [
'product_id' => (int) $row['product_id'],
'product_name' => (string) $row['product_name'],
'requested_qty' => (float) $row['qty'] * (float) $row['qty_per_item'],
'allocated_qty' => $row['allocated_qty'] !== null ? (float) $row['allocated_qty'] : null,
'allocation_status' => $row['allocation_status'] !== null ? (string) $row['allocation_status'] : null,
'lot_number' => $row['lot_number'] !== null ? (string) $row['lot_number'] : null,
'lot_status' => $row['lot_status'] !== null ? (string) $row['lot_status'] : null,
];
}
}
$detail = [
'id' => (int) $order['id'],
'external_ref' => (string) $order['external_ref'],
'order_date' => (string) $order['order_date'],
'order_source' => (string) $order['order_source'],
'order_status' => (string) $order['order_status'],
'payment_status' => (string) $order['payment_status'],
'payment_method' => (string) $order['payment_method'],
'shipping_method' => (string) $order['shipping_method'],
'customer' => [
'first_name' => (string) $order['first_name'],
'last_name' => (string) $order['last_name'],
'street' => (string) $order['street'],
'house_number' => (string) $order['house_number'],
'zip' => (string) $order['zip'],
'city' => (string) $order['city'],
'country_name' => (string) $order['country_name'],
],
'amounts' => [
'net' => $order['amount_net'] !== null ? (float) $order['amount_net'] : null,
'shipping' => $order['amount_shipping'] !== null ? (float) $order['amount_shipping'] : null,
'tax' => $order['amount_tax'] !== null ? (float) $order['amount_tax'] : null,
'discount' => $order['amount_discount'] !== null ? (float) $order['amount_discount'] : null,
'total' => $order['total_amount'] !== null ? (float) $order['total_amount'] : null,
'currency' => (string) $order['currency'],
],
'lines' => array_values($lines),
];
return $detail;
}
function get_sales_order_realtime_snapshot(PDO $pdo, int $limit = 1000): array
{
$limit = max(1, min(2000, $limit));
+5 -8
View File
@@ -30,7 +30,7 @@ if (!is_array($data)) {
json_response(400, ['ok' => false, 'error' => 'JSON object expected']);
}
$required = ['products', 'totalPrice', 'orderDate', 'payments', 'billing'];
$required = ['products', 'orderDate', 'payments', 'billing'];
foreach ($required as $field) {
if (!array_key_exists($field, $data)) {
json_response(422, ['ok' => false, 'error' => "Missing field: {$field}"]);
@@ -38,7 +38,6 @@ foreach ($required as $field) {
}
$products = $data['products'];
$totalPrice = parse_number($data['totalPrice']);
$orderDate = trim((string) $data['orderDate']);
$payments = $data['payments'];
$billing = is_array($data['billing']) ? $data['billing'] : [];
@@ -51,10 +50,6 @@ if (!is_array($payments) || count($payments) === 0) {
json_response(422, ['ok' => false, 'error' => 'No payment methods specified']);
}
if ($totalPrice === null || $totalPrice <= 0) {
json_response(422, ['ok' => false, 'error' => 'Invalid total price']);
}
$orderDateTime = DateTimeImmutable::createFromFormat('!Y-m-d', $orderDate);
$orderDateErrors = DateTimeImmutable::getLastErrors();
if ($orderDateTime === false) {
@@ -109,10 +104,12 @@ foreach ($payments as $payment) {
$paymentTotal = round($paymentTotal + (float) $amount, 2);
}
if (abs($paymentTotal - (float) $totalPrice) > 0.005) {
json_response(422, ['ok' => false, 'error' => 'Payment amounts must equal total price']);
if ($paymentTotal <= 0) {
json_response(422, ['ok' => false, 'error' => 'Payment total must be greater than zero']);
}
$totalPrice = $paymentTotal;
try {
$env = expand_env_values(parse_env_file(__DIR__ . '/../../../../.env'));
$pdo = connect_database($env);
@@ -33,6 +33,77 @@ function shopify_delta_start_run(PDO $pdo, string $cutoff, bool $dryRun): int
return (int) $stmt->fetchColumn();
}
function shopify_delta_reconciliation_cutoff(PDO $pdo): string
{
$stateStmt = $pdo->prepare(
'SELECT last_successful_cutoff
FROM shopify_delta_sync_state
WHERE process_name = :process_name'
);
$stateStmt->execute([':process_name' => 'erp.import-integration.shopify_delta_order_import']);
$state = $stateStmt->fetchColumn();
if ($state === false || trim((string) $state) === '') {
throw new RuntimeException('Shopify-Delta-Wasserstand fehlt');
}
$pendingStmt = $pdo->query(
"SELECT MIN(received_at)
FROM shopify_webhook_event
WHERE status IN ('received', 'processing', 'failed')
AND shopify_order_gid IS NOT NULL"
);
$pending = $pendingStmt->fetchColumn();
if ($pending === false || $pending === null) {
return (string) $state;
}
$stateDate = new DateTimeImmutable((string) $state);
$pendingDate = new DateTimeImmutable((string) $pending);
return ($pendingDate < $stateDate ? $pendingDate : $stateDate)->format(DATE_ATOM);
}
function shopify_delta_load_pending_order_gids(PDO $pdo): array
{
$stmt = $pdo->query(
"SELECT DISTINCT shopify_order_gid
FROM shopify_webhook_event
WHERE status IN ('received', 'processing', 'failed')
AND shopify_order_gid IS NOT NULL
ORDER BY shopify_order_gid"
);
return array_values(array_filter(array_map(
static fn (mixed $value): string => trim((string) $value),
$stmt->fetchAll(PDO::FETCH_COLUMN)
), static fn (string $value): bool => $value !== ''));
}
function shopify_delta_mark_order_events_processed(PDO $pdo, string $orderGid): void
{
$stmt = $pdo->prepare(
"UPDATE shopify_webhook_event
SET status = 'processed', processed_at = NOW(), error_code = NULL, error_message = NULL
WHERE shopify_order_gid = :shopify_order_gid
AND status IN ('received', 'processing', 'failed')"
);
$stmt->execute([':shopify_order_gid' => $orderGid]);
}
function shopify_delta_store_watermark(PDO $pdo, string $cutoff): void
{
$stmt = $pdo->prepare(
'UPDATE shopify_delta_sync_state
SET last_successful_cutoff = :cutoff, updated_at = NOW()
WHERE process_name = :process_name'
);
$stmt->execute([
':cutoff' => $cutoff,
':process_name' => 'erp.import-integration.shopify_delta_order_import',
]);
if ($stmt->rowCount() !== 1) {
throw new RuntimeException('Shopify-Delta-Wasserstand konnte nicht gespeichert werden');
}
}
function shopify_delta_finish_run(PDO $pdo, int $runId, string $status, array $result, ?Throwable $error = null): void
{
$stmt = $pdo->prepare(
@@ -75,7 +146,7 @@ GQL;
return $orders;
}
function run_shopify_delta(array $env, PDO $pdo, string $cutoff, bool $dryRun): array
function run_shopify_delta(array $env, PDO $pdo, string $cutoff, bool $dryRun, array $recoveryOrderGids = []): array
{
$cutoffDate = new DateTimeImmutable($cutoff);
$searchQuery = 'created_at:>' . $cutoffDate->format('Y-m-d');
@@ -90,14 +161,31 @@ function run_shopify_delta(array $env, PDO $pdo, string $cutoff, bool $dryRun):
'clarification_order_gids' => [],
];
$ordersByGid = [];
foreach (shopify_delta_load_orders($env, $searchQuery) as $summaryOrder) {
$orderGid = trim((string) ($summaryOrder['id'] ?? ''));
if ($orderGid !== '') {
$ordersByGid[$orderGid] = $summaryOrder;
}
}
foreach ($recoveryOrderGids as $recoveryOrderGid) {
if (!isset($ordersByGid[$recoveryOrderGid])) {
$ordersByGid[$recoveryOrderGid] = shopify_load_order_by_gid($env, $recoveryOrderGid);
}
}
$recoverySet = array_fill_keys($recoveryOrderGids, true);
foreach ($ordersByGid as $orderGid => $summaryOrder) {
$createdAt = new DateTimeImmutable((string) ($summaryOrder['createdAt'] ?? ''));
if ($createdAt <= $cutoffDate) {
if (!isset($recoverySet[$orderGid]) && $createdAt <= $cutoffDate) {
continue;
}
$counters['checked_orders']++;
if (($summaryOrder['cancelledAt'] ?? null) !== null || strtoupper((string) ($summaryOrder['displayFinancialStatus'] ?? '')) === 'REFUNDED') {
$counters['skipped_cancelled_or_refunded']++;
if (!$dryRun) {
shopify_delta_mark_order_events_processed($pdo, (string) $summaryOrder['id']);
}
continue;
}
@@ -121,8 +209,14 @@ function run_shopify_delta(array $env, PDO $pdo, string $cutoff, bool $dryRun):
}
if (($result['status'] ?? '') === 'already_imported') {
$counters['already_imported']++;
if (!$dryRun && isset($recoverySet[(string) $summaryOrder['id']])) {
shopify_delta_mark_order_events_processed($pdo, (string) $summaryOrder['id']);
}
} else {
$counters['handed_off']++;
if (!$dryRun) {
shopify_delta_mark_order_events_processed($pdo, (string) $summaryOrder['id']);
}
}
} catch (Throwable $error) {
$counters['status'] = 'partial_success';
@@ -132,19 +132,21 @@ function shopify_webhook_worker_run(PDO $pdo, array $env, int $limit): array
}
}
$argvLimit = 20;
foreach ($argv as $argument) {
if (str_starts_with($argument, '--limit=')) {
$argvLimit = max(1, min(100, (int) substr($argument, 8)));
if (PHP_SAPI === 'cli' && realpath($_SERVER['SCRIPT_FILENAME'] ?? '') === __FILE__) {
$argvLimit = 20;
foreach ($argv as $argument) {
if (str_starts_with($argument, '--limit=')) {
$argvLimit = max(1, min(100, (int) substr($argument, 8)));
}
}
try {
$env = expand_env_values(parse_env_file(__DIR__ . '/../../../.env'));
$pdo = connect_database($env);
echo json_encode(shopify_webhook_worker_run($pdo, $env, $argvLimit), JSON_UNESCAPED_SLASHES) . PHP_EOL;
exit(0);
} catch (Throwable $error) {
fwrite(STDERR, $error->getMessage() . PHP_EOL);
exit(1);
}
}
try {
$env = expand_env_values(parse_env_file(__DIR__ . '/../../../.env'));
$pdo = connect_database($env);
echo json_encode(shopify_webhook_worker_run($pdo, $env, $argvLimit), JSON_UNESCAPED_SLASHES) . PHP_EOL;
exit(0);
} catch (Throwable $error) {
fwrite(STDERR, $error->getMessage() . PHP_EOL);
exit(1);
}
+82
View File
@@ -103,3 +103,85 @@ function upsert_addresses(PDO $pdo, int $partyId, array $data): void
]);
}
}
function get_manufacturer_options(PDO $pdo): array
{
$stmt = $pdo->query(
"SELECT p.id, p.name, p.email, p.phone
FROM party p
INNER JOIN party_role pr ON pr.party_id = p.id AND pr.role_code = 'manufacturer'
WHERE p.status = 'active'
ORDER BY p.name ASC, p.id ASC"
);
return array_map(
static fn (array $row): array => [
'id' => (int) $row['id'],
'name' => (string) $row['name'],
'email' => (string) ($row['email'] ?? ''),
'phone' => (string) ($row['phone'] ?? ''),
],
$stmt->fetchAll()
);
}
function create_manufacturer(PDO $pdo, string $name, ?string $email = null, ?string $phone = null): array
{
$name = trim($name);
$email = $email !== null ? trim($email) : null;
$phone = $phone !== null ? trim($phone) : null;
if ($name === '') {
throw new InvalidArgumentException('Herstellername fehlt');
}
$existingStmt = $pdo->prepare(
"SELECT p.id, p.name, p.email, p.phone
FROM party p
WHERE LOWER(TRIM(p.name)) = LOWER(:name)
LIMIT 1
FOR UPDATE"
);
$existingStmt->execute([':name' => $name]);
$existing = $existingStmt->fetch();
if (is_array($existing)) {
$roleStmt = $pdo->prepare(
"INSERT INTO party_role (party_id, role_code) VALUES (:party_id, 'manufacturer')
ON CONFLICT (party_id, role_code) DO NOTHING"
);
$roleStmt->execute([':party_id' => (int) $existing['id']]);
return [
'id' => (int) $existing['id'],
'name' => (string) $existing['name'],
'email' => (string) ($existing['email'] ?? ''),
'phone' => (string) ($existing['phone'] ?? ''),
];
}
$stmt = $pdo->prepare(
"INSERT INTO party (type, name, email, phone, status, created_at, updated_at)
VALUES ('supplier', :name, :email, :phone, 'active', NOW(), NOW())
RETURNING id, name, email, phone"
);
$stmt->execute([
':name' => $name,
':email' => $email !== '' ? $email : null,
':phone' => $phone !== '' ? $phone : null,
]);
$party = $stmt->fetch();
if (!is_array($party)) {
throw new RuntimeException('Hersteller konnte nicht angelegt werden');
}
$roleStmt = $pdo->prepare(
"INSERT INTO party_role (party_id, role_code) VALUES (:party_id, 'manufacturer')"
);
$roleStmt->execute([':party_id' => (int) $party['id']]);
return [
'id' => (int) $party['id'],
'name' => (string) $party['name'],
'email' => (string) ($party['email'] ?? ''),
'phone' => (string) ($party['phone'] ?? ''),
];
}
+163
View File
@@ -0,0 +1,163 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../../../shared/db.php';
require_once __DIR__ . '/../../../shared/auth/service.php';
require_once __DIR__ . '/../../kontakte/service.php';
require_once __DIR__ . '/../service.php';
$env = expand_env_values(parse_env_file(__DIR__ . '/../../../../.env'));
try {
$pdo = connect_database($env);
auth_bootstrap_session();
$currentUser = auth_current_user($pdo);
if ($currentUser === null) {
json_response(401, ['ok' => false, 'error' => 'Unauthorized']);
}
if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
json_response(405, ['ok' => false, 'error' => 'Method Not Allowed']);
}
$contentType = (string) ($_SERVER['CONTENT_TYPE'] ?? '');
if (str_starts_with($contentType, 'multipart/form-data')) {
if (!auth_validate_csrf_token($_POST['csrfToken'] ?? null)) {
json_response(403, ['ok' => false, 'error' => 'Invalid security token']);
}
if (($_POST['action'] ?? '') !== 'upload_document') {
json_response(422, ['ok' => false, 'error' => 'Unsupported action']);
}
$file = $_FILES['document'] ?? null;
if (!is_array($file)) {
throw new InvalidArgumentException('Dokumentdatei fehlt');
}
$document = upload_stock_lot_document(
$pdo,
(int) ($_POST['lotId'] ?? 0),
$file,
(string) ($currentUser['username'] ?? $currentUser['email'] ?? 'unknown')
);
json_response(201, ['ok' => true, 'document' => [
'id' => (int) $document['id'],
'original_filename' => (string) $document['original_filename'],
]]);
}
$jsonInput = file_get_contents('php://input');
if ($jsonInput === false || trim($jsonInput) === '') {
json_response(400, ['ok' => false, 'error' => 'Empty payload']);
}
$data = json_decode($jsonInput, true, 512, JSON_THROW_ON_ERROR);
if (!is_array($data) || !auth_validate_csrf_token($data['csrfToken'] ?? null)) {
json_response(403, ['ok' => false, 'error' => 'Invalid security token']);
}
$action = trim((string) ($data['action'] ?? ''));
if ($action === 'create_manufacturer') {
$manufacturer = create_manufacturer(
$pdo,
(string) ($data['name'] ?? ''),
isset($data['email']) ? (string) $data['email'] : null,
isset($data['phone']) ? (string) $data['phone'] : null
);
json_response(201, ['ok' => true, 'manufacturer' => $manufacturer]);
}
if ($action === 'update_lot') {
$manufacturerPartyId = $data['manufacturerPartyId'] ?? null;
$manufacturerPartyId = $manufacturerPartyId === null || $manufacturerPartyId === ''
? null
: (int) $manufacturerPartyId;
$receiptQty = isset($data['receiptQty']) && $data['receiptQty'] !== '' ? (float) $data['receiptQty'] : 0.0;
$adjustmentQty = isset($data['adjustmentQty']) && $data['adjustmentQty'] !== '' ? (float) $data['adjustmentQty'] : 0.0;
$outQty = isset($data['outQty']) && $data['outQty'] !== '' ? (float) $data['outQty'] : 0.0;
$updatedLot = update_stock_lot_traceability(
$pdo,
(int) ($data['lotId'] ?? 0),
$manufacturerPartyId,
isset($data['manufacturerLotNumber']) ? (string) $data['manufacturerLotNumber'] : null,
isset($data['mfgDate']) ? (string) $data['mfgDate'] : null,
isset($data['expiryDate']) ? (string) $data['expiryDate'] : null,
isset($data['purchaseDate']) ? (string) $data['purchaseDate'] : null,
(string) ($currentUser['username'] ?? $currentUser['email'] ?? 'unknown'),
$receiptQty,
$adjustmentQty,
$outQty
);
$productId = (int) $updatedLot['product_id'];
refresh_lager_stock_lot_forecast_for_product($pdo, $productId);
$forecast = get_lager_product_forecast($pdo, $productId);
json_response(200, ['ok' => true, 'productId' => $productId, 'lot' => $updatedLot, 'balance' => get_stock_lot_balance($pdo, (int) ($data['lotId'] ?? 0)), 'forecast' => $forecast]);
}
if ($action === 'delete_open_lot') {
$productId = delete_open_stock_lot(
$pdo,
(int) ($data['lotId'] ?? 0),
(string) ($currentUser['username'] ?? $currentUser['email'] ?? 'unknown')
);
json_response(200, ['ok' => true, 'productId' => $productId, 'availableQty' => get_lager_product_available_qty($pdo, $productId)]);
}
if ($action === 'delete_document') {
delete_stock_lot_document($pdo, (int) ($data['documentId'] ?? 0));
json_response(200, ['ok' => true]);
}
if ($action === 'prepare_lot') {
$productId = (int) ($data['productId'] ?? 0);
$lotNumber = trim((string) ($data['lotNumber'] ?? ''));
$lotId = (int) ($data['lotId'] ?? 0);
if ($lotNumber === '' && $lotId <= 0) {
throw new InvalidArgumentException('Naurua-Chargennummer fehlt');
}
if ($lotId > 0) {
$lotStmt = $pdo->prepare(
"SELECT id
FROM stock_lot
WHERE id = :lot_id
AND product_id = :product_id
AND status = 'open'"
);
$lotStmt->execute([':lot_id' => $lotId, ':product_id' => $productId]);
if ($lotStmt->fetchColumn() === false) {
throw new InvalidArgumentException('Offene Charge nicht gefunden');
}
} else {
$lotId = get_or_create_open_lot($pdo, $productId);
}
$manufacturerPartyId = $data['manufacturerPartyId'] ?? null;
$manufacturerPartyId = $manufacturerPartyId === null || $manufacturerPartyId === '' ? null : (int) $manufacturerPartyId;
$receiptQty = isset($data['receiptQty']) && $data['receiptQty'] !== '' ? (float) $data['receiptQty'] : 0.0;
$targetQty = isset($data['targetQty']) && $data['targetQty'] !== '' ? (float) $data['targetQty'] : null;
$preparedLot = update_stock_lot_traceability(
$pdo,
$lotId,
$manufacturerPartyId,
isset($data['manufacturerLotNumber']) ? (string) $data['manufacturerLotNumber'] : null,
isset($data['mfgDate']) ? (string) $data['mfgDate'] : null,
isset($data['expiryDate']) ? (string) $data['expiryDate'] : null,
isset($data['purchaseDate']) ? (string) $data['purchaseDate'] : null,
(string) ($currentUser['username'] ?? $currentUser['email'] ?? 'unknown'),
$receiptQty,
0.0,
0.0,
$lotNumber,
$targetQty,
$lotId > 0 && $lotNumber === ''
);
refresh_lager_stock_lot_forecast_for_product($pdo, $productId);
$forecast = get_lager_product_forecast($pdo, $productId);
json_response(200, ['ok' => true, 'productId' => $productId, 'lot' => $preparedLot, 'balance' => get_stock_lot_balance($pdo, $lotId), 'availableQty' => get_lager_product_available_qty($pdo, $productId), 'forecast' => $forecast]);
}
json_response(422, ['ok' => false, 'error' => 'Unsupported action']);
} catch (JsonException) {
json_response(400, ['ok' => false, 'error' => 'Invalid JSON payload']);
} catch (InvalidArgumentException $error) {
json_response(422, ['ok' => false, 'error' => $error->getMessage()]);
} catch (Throwable $error) {
json_response(500, ['ok' => false, 'error' => 'Interner Serverfehler']);
}
+67 -11
View File
@@ -35,7 +35,12 @@ function lager_forecast_load_current_lots(PDO $pdo): array
{
$stmt = $pdo->query(
"SELECT p.id AS product_id, p.sku, sl.id AS lot_id,
COALESCE(v.qty_net, 0) AS physical_qty
COALESCE(v.qty_net, 0) + COALESCE((
SELECT SUM(GREATEST(COALESCE(open_v.qty_net, 0), 0))
FROM stock_lot open_lot
LEFT JOIN v_stock_lot_balance open_v ON open_v.stock_lot_id = open_lot.id
WHERE open_lot.product_id = p.id AND open_lot.status = 'open'
), 0) AS physical_qty
FROM product p
LEFT JOIN stock_lot sl ON sl.product_id = p.id AND sl.status = 'current'
LEFT JOIN v_stock_lot_balance v ON v.stock_lot_id = sl.id
@@ -45,11 +50,32 @@ function lager_forecast_load_current_lots(PDO $pdo): array
return $stmt->fetchAll();
}
function lager_forecast_load_daily_sales(PDO $pdo, DateTimeImmutable $asOfDate, int $lookbackDays): array
function lager_forecast_load_current_lot(PDO $pdo, int $productId): ?array
{
$stmt = $pdo->prepare(
"SELECT p.id AS product_id, p.sku, sl.id AS lot_id,
COALESCE(v.qty_net, 0) + COALESCE((
SELECT SUM(GREATEST(COALESCE(open_v.qty_net, 0), 0))
FROM stock_lot open_lot
LEFT JOIN v_stock_lot_balance open_v ON open_v.stock_lot_id = open_lot.id
WHERE open_lot.product_id = p.id AND open_lot.status = 'open'
), 0) AS physical_qty
FROM product p
LEFT JOIN stock_lot sl ON sl.product_id = p.id AND sl.status = 'current'
LEFT JOIN v_stock_lot_balance v ON v.stock_lot_id = sl.id
WHERE p.status = 'active' AND p.id = :product_id
LIMIT 1"
);
$stmt->execute([':product_id' => $productId]);
$row = $stmt->fetch();
return is_array($row) ? $row : null;
}
function lager_forecast_load_daily_sales(PDO $pdo, DateTimeImmutable $asOfDate, int $lookbackDays, ?int $productId = null): array
{
$startDate = $asOfDate->modify('-' . ($lookbackDays - 1) . ' days')->format('Y-m-d');
$endDate = $asOfDate->modify('+1 day')->format('Y-m-d');
$stmt = $pdo->prepare(
$sql =
"SELECT a.product_id, so.order_date::DATE AS sale_date,
SUM(a.qty * GREATEST(sol.qty - sol.qty_cancelled, 0) / NULLIF(sol.qty, 0)) AS units
FROM sales_order_line_lot_allocation a
@@ -61,11 +87,15 @@ function lager_forecast_load_daily_sales(PDO $pdo, DateTimeImmutable $asOfDate,
AND sol.line_status <> 'cancelled'
AND so.order_status <> 'cancelled'
AND so.order_date >= :start_date::DATE
AND so.order_date < :end_date::DATE
GROUP BY a.product_id, so.order_date::DATE
ORDER BY a.product_id, sale_date"
);
$stmt->execute([':start_date' => $startDate, ':end_date' => $endDate]);
AND so.order_date < :end_date::DATE";
$params = [':start_date' => $startDate, ':end_date' => $endDate];
if ($productId !== null) {
$sql .= ' AND a.product_id = :product_id';
$params[':product_id'] = $productId;
}
$sql .= ' GROUP BY a.product_id, so.order_date::DATE ORDER BY a.product_id, sale_date';
$stmt = $pdo->prepare($sql);
$stmt->execute($params);
$sales = [];
foreach ($stmt->fetchAll() as $row) {
@@ -136,7 +166,10 @@ function lager_forecast_calculate(DateTimeImmutable $asOfDate, float $physicalQt
function lager_forecast_persist(PDO $pdo, array $lot, array $forecast, DateTimeImmutable $asOfDate): void
{
$pdo->beginTransaction();
$startedTransaction = !$pdo->inTransaction();
if ($startedTransaction) {
$pdo->beginTransaction();
}
try {
$stmt = $pdo->prepare(
"INSERT INTO stock_lot_forecast_history (
@@ -172,15 +205,38 @@ function lager_forecast_persist(PDO $pdo, array $lot, array $forecast, DateTimeI
':confidence' => $forecast['confidence'], ':daily_demand' => $forecast['daily_demand'],
':lot_id' => (int) $lot['lot_id'],
]);
$pdo->commit();
if ($startedTransaction) {
$pdo->commit();
}
} catch (Throwable $error) {
if ($pdo->inTransaction()) {
if ($startedTransaction && $pdo->inTransaction()) {
$pdo->rollBack();
}
throw $error;
}
}
function refresh_lager_stock_lot_forecast_for_product(PDO $pdo, int $productId): void
{
if ($productId <= 0) {
throw new InvalidArgumentException('Produkt-ID fuer Forecast fehlt');
}
$lot = lager_forecast_load_current_lot($pdo, $productId);
if ($lot === null || $lot['lot_id'] === null) {
throw new RuntimeException("Keine aktuelle Charge fuer Produkt {$productId} vorhanden");
}
$asOfDate = new DateTimeImmutable('today', new DateTimeZone('Europe/Zurich'));
$dailySales = lager_forecast_load_daily_sales($pdo, $asOfDate, 365, $productId);
$forecast = lager_forecast_calculate(
$asOfDate,
(float) $lot['physical_qty'],
$dailySales[$productId] ?? []
);
lager_forecast_persist($pdo, $lot, $forecast, $asOfDate);
}
function run_lager_stock_lot_forecast(PDO $pdo): array
{
$runId = lager_forecast_start_run($pdo);
+696 -6
View File
@@ -1,6 +1,9 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/forecast.php';
require_once __DIR__ . '/../../system/audit.php';
function get_default_location_ids(PDO $pdo): array
{
$row = $pdo->query(
@@ -72,12 +75,33 @@ function get_lager_product_overview(PDO $pdo): array
p.name,
p.sku,
p.uom,
COALESCE(SUM(v.qty_net), 0) AS available_qty,
COALESCE(SUM(v.qty_net), 0) + COALESCE((
SELECT SUM(open_v.qty_net)
FROM stock_lot open_sl
INNER JOIN v_stock_lot_balance open_v ON open_v.stock_lot_id = open_sl.id
WHERE open_sl.product_id = p.id
AND open_sl.status = 'open'
), 0) AS available_qty,
COALESCE(v.qty_net, 0) AS current_qty,
COALESCE(v.qty_in, 0) AS qty_in,
sl.lot_number,
sl.supplier_lot_number,
sl.id AS lot_id,
sl.manufacturer_party_id,
sl.manufacturer_lot_number,
mp.name AS manufacturer_name,
sl.mfg_date,
sl.expiry_date,
sl.purchase_date,
sl.sellout_date,
sl.sellout_forecast_status,
COALESCE((
SELECT SUM(a.qty)
FROM sales_order_line_lot_allocation a
INNER JOIN stock_lot virtual_lot ON virtual_lot.id = a.lot_id
WHERE virtual_lot.product_id = p.id
AND virtual_lot.status = 'virtual_shortage'
AND a.allocation_status = 'reserved'
), 0) AS virtual_shortage_qty,
(
SELECT virtual_lot.lot_number
FROM stock_lot virtual_lot
@@ -90,30 +114,496 @@ function get_lager_product_overview(PDO $pdo): array
LEFT JOIN stock_lot sl
ON sl.product_id = p.id
AND sl.status = 'current'
LEFT JOIN party mp ON mp.id = sl.manufacturer_party_id
LEFT JOIN v_stock_lot_balance v
ON v.stock_lot_id = sl.id
WHERE p.status = 'active'
GROUP BY p.id, p.name, p.sku, p.uom, sl.lot_number, sl.supplier_lot_number,
sl.expiry_date, sl.sellout_date, sl.sellout_forecast_status
GROUP BY p.id, p.name, p.sku, p.uom, sl.id, sl.lot_number, sl.manufacturer_party_id,
sl.manufacturer_lot_number, mp.name, sl.mfg_date, sl.expiry_date, sl.purchase_date,
sl.sellout_date, sl.sellout_forecast_status, v.qty_net, v.qty_in
ORDER BY p.name ASC, p.id ASC"
);
return array_map(
$products = array_map(
static fn (array $row): array => [
'id' => (int) $row['id'],
'name' => (string) $row['name'],
'sku' => (string) $row['sku'],
'uom' => (string) $row['uom'],
'available_qty' => (float) $row['available_qty'],
'current_qty' => (float) $row['current_qty'],
'qty_in' => (float) $row['qty_in'],
'lot_id' => $row['lot_id'] === null ? null : (int) $row['lot_id'],
'lot_number' => $row['lot_number'],
'supplier_lot_number' => $row['supplier_lot_number'],
'manufacturer_party_id' => $row['manufacturer_party_id'] === null ? null : (int) $row['manufacturer_party_id'],
'manufacturer_lot_number' => $row['manufacturer_lot_number'],
'manufacturer_name' => $row['manufacturer_name'],
'mfg_date' => $row['mfg_date'],
'expiry_date' => $row['expiry_date'],
'purchase_date' => $row['purchase_date'],
'sellout_date' => $row['sellout_date'],
'sellout_forecast_status' => (string) ($row['sellout_forecast_status'] ?? 'no_demand'),
'virtual_shortage_qty' => (float) ($row['virtual_shortage_qty'] ?? 0),
'virtual_lot_number' => $row['virtual_lot_number'],
],
$stmt->fetchAll()
);
$historicalStmt = $pdo->query(
"SELECT
sl.product_id,
sl.id AS lot_id,
sl.lot_number,
sl.mfg_date,
sl.expiry_date,
sl.purchase_date,
sl.manufacturer_lot_number,
mp.name AS manufacturer_name,
COALESCE(v.qty_in, 0) AS qty_in,
COALESCE(v.qty_out, 0) AS qty_out,
COALESCE(v.qty_net, 0) AS qty_net,
MIN(sm.move_date) FILTER (WHERE sm.move_type = 'in') AS first_in_at,
MAX(sm.move_date) FILTER (WHERE sm.move_type = 'out') AS last_out_at
FROM stock_lot sl
LEFT JOIN party mp ON mp.id = sl.manufacturer_party_id
LEFT JOIN v_stock_lot_balance v ON v.stock_lot_id = sl.id
LEFT JOIN stock_move sm ON sm.lot_id = sl.id
WHERE sl.status = 'closed'
AND sl.lot_number NOT LIKE 'INIT-%'
GROUP BY sl.product_id, sl.id, sl.lot_number, sl.mfg_date, sl.expiry_date,
sl.purchase_date, sl.manufacturer_lot_number, mp.name,
v.qty_in, v.qty_out, v.qty_net
ORDER BY sl.product_id, sl.id DESC"
);
$historicalLotsByProduct = [];
foreach ($historicalStmt->fetchAll() as $row) {
$historicalLotsByProduct[(int) $row['product_id']][] = [
'lot_id' => (int) $row['lot_id'],
'lot_number' => $row['lot_number'],
'mfg_date' => $row['mfg_date'],
'expiry_date' => $row['expiry_date'],
'purchase_date' => $row['purchase_date'],
'manufacturer_lot_number' => $row['manufacturer_lot_number'],
'manufacturer_name' => $row['manufacturer_name'],
'qty_in' => (float) $row['qty_in'],
'qty_out' => (float) $row['qty_out'],
'qty_net' => (float) $row['qty_net'],
'first_in_at' => $row['first_in_at'],
'last_out_at' => $row['last_out_at'],
];
}
$openStmt = $pdo->query(
"SELECT
sl.product_id,
sl.id AS lot_id,
sl.lot_number,
sl.manufacturer_party_id,
sl.manufacturer_lot_number,
mp.name AS manufacturer_name,
sl.mfg_date,
sl.expiry_date,
sl.purchase_date,
COALESCE(v.qty_in, 0) AS qty_in,
COALESCE(v.qty_net, 0) AS qty_net
FROM stock_lot sl
LEFT JOIN party mp ON mp.id = sl.manufacturer_party_id
LEFT JOIN v_stock_lot_balance v ON v.stock_lot_id = sl.id
WHERE sl.status = 'open'
ORDER BY sl.product_id, sl.id"
);
$openLotsByProduct = [];
foreach ($openStmt->fetchAll() as $openLot) {
$openLotsByProduct[(int) $openLot['product_id']][] = [
'lot_id' => (int) $openLot['lot_id'],
'lot_number' => $openLot['lot_number'],
'manufacturer_party_id' => $openLot['manufacturer_party_id'] === null ? null : (int) $openLot['manufacturer_party_id'],
'manufacturer_lot_number' => $openLot['manufacturer_lot_number'],
'manufacturer_name' => $openLot['manufacturer_name'],
'mfg_date' => $openLot['mfg_date'],
'expiry_date' => $openLot['expiry_date'],
'purchase_date' => $openLot['purchase_date'],
'qty_in' => (float) $openLot['qty_net'],
'qty_net' => (float) $openLot['qty_net'],
];
}
foreach ($products as &$product) {
$product['historical_lots'] = $historicalLotsByProduct[$product['id']] ?? [];
$product['open_lots'] = $openLotsByProduct[$product['id']] ?? [];
}
unset($product);
$lotIds = [];
foreach ($products as $product) {
if (!empty($product['lot_id'])) {
$lotIds[] = (int) $product['lot_id'];
}
foreach ($product['historical_lots'] as $lot) {
$lotIds[] = (int) ($lot['lot_id'] ?? 0);
}
foreach ($product['open_lots'] as $lot) {
$lotIds[] = (int) ($lot['lot_id'] ?? 0);
}
}
$documentsByLot = get_stock_lot_documents_by_lot_ids($pdo, $lotIds);
foreach ($products as &$product) {
$product['documents'] = $documentsByLot[(int) ($product['lot_id'] ?? 0)] ?? [];
foreach ($product['historical_lots'] as &$lot) {
$lot['documents'] = $documentsByLot[(int) ($lot['lot_id'] ?? 0)] ?? [];
}
unset($lot);
foreach ($product['open_lots'] as &$lot) {
$lot['documents'] = $documentsByLot[(int) ($lot['lot_id'] ?? 0)] ?? [];
}
unset($lot);
}
unset($product);
return $products;
}
function get_lager_product_available_qty(PDO $pdo, int $productId): float
{
$stmt = $pdo->prepare(
"SELECT COALESCE(SUM(v.qty_net), 0)
FROM stock_lot sl
INNER JOIN v_stock_lot_balance v ON v.stock_lot_id = sl.id
WHERE sl.product_id = :product_id
AND sl.status IN ('current', 'open')"
);
$stmt->execute([':product_id' => $productId]);
return (float) $stmt->fetchColumn();
}
function get_stock_lot_documents_by_lot_ids(PDO $pdo, array $lotIds): array
{
$lotIds = array_values(array_unique(array_filter(array_map('intval', $lotIds), static fn (int $id): bool => $id > 0)));
if ($lotIds === []) {
return [];
}
$placeholders = implode(',', array_fill(0, count($lotIds), '?'));
$stmt = $pdo->prepare(
"SELECT id, stock_lot_id, original_filename, mime_type, size_bytes, uploaded_at
FROM stock_lot_document
WHERE stock_lot_id IN ({$placeholders})
ORDER BY stock_lot_id, id"
);
$stmt->execute($lotIds);
$documents = [];
foreach ($stmt->fetchAll() as $row) {
$documents[(int) $row['stock_lot_id']][] = [
'id' => (int) $row['id'],
'original_filename' => (string) $row['original_filename'],
'mime_type' => (string) $row['mime_type'],
'size_bytes' => (int) $row['size_bytes'],
'uploaded_at' => (string) $row['uploaded_at'],
];
}
return $documents;
}
function get_stock_lot_document(PDO $pdo, int $documentId): array
{
$stmt = $pdo->prepare(
'SELECT id, stock_lot_id, original_filename, stored_filename, mime_type, size_bytes
FROM stock_lot_document WHERE id = :id'
);
$stmt->execute([':id' => $documentId]);
$document = $stmt->fetch();
if (!is_array($document)) {
throw new RuntimeException('Dokument nicht gefunden');
}
return $document;
}
function delete_stock_lot_document(PDO $pdo, int $documentId): void
{
$document = get_stock_lot_document($pdo, $documentId);
$statusStmt = $pdo->prepare('SELECT status FROM stock_lot WHERE id = :id');
$statusStmt->execute([':id' => (int) $document['stock_lot_id']]);
$status = $statusStmt->fetchColumn();
if ($status !== 'current' && $status !== 'open') {
throw new InvalidArgumentException('Dokumente vergangener Chargen können nicht gelöscht werden');
}
$stmt = $pdo->prepare('DELETE FROM stock_lot_document WHERE id = :id');
$stmt->execute([':id' => $documentId]);
$path = dirname(__DIR__, 3) . '/runtime/uploads/lager/' . basename((string) $document['stored_filename']);
if (is_file($path) && !unlink($path)) {
throw new RuntimeException('Dokumentdatei konnte nicht gelöscht werden');
}
}
function upload_stock_lot_document(PDO $pdo, int $lotId, array $file, string $uploadedBy): array
{
if ($lotId <= 0 || !is_uploaded_file((string) ($file['tmp_name'] ?? ''))) {
throw new InvalidArgumentException('Ungültige Dokumentdatei');
}
$error = (int) ($file['error'] ?? UPLOAD_ERR_NO_FILE);
if ($error !== UPLOAD_ERR_OK) {
throw new InvalidArgumentException('Dokument konnte nicht hochgeladen werden');
}
$size = (int) ($file['size'] ?? 0);
if ($size <= 0 || $size > 52428800) {
throw new InvalidArgumentException('Dokument darf maximal 50 MB groß sein');
}
$originalFilename = basename((string) ($file['name'] ?? ''));
if ($originalFilename === '') {
throw new InvalidArgumentException('Dateiname fehlt');
}
$lotStmt = $pdo->prepare("SELECT id FROM stock_lot WHERE id = :id AND status IN ('current', 'open')");
$lotStmt->execute([':id' => $lotId]);
if ($lotStmt->fetchColumn() === false) {
throw new InvalidArgumentException('Charge nicht gefunden');
}
$directory = dirname(__DIR__, 3) . '/runtime/uploads/lager';
if (!is_dir($directory) && !mkdir($directory, 0770, true) && !is_dir($directory)) {
throw new RuntimeException('Dokumentenverzeichnis konnte nicht erstellt werden');
}
$storedFilename = bin2hex(random_bytes(16));
$targetPath = $directory . '/' . $storedFilename;
if (!move_uploaded_file((string) $file['tmp_name'], $targetPath)) {
throw new RuntimeException('Dokument konnte nicht gespeichert werden');
}
$mimeType = (new finfo(FILEINFO_MIME_TYPE))->file($targetPath) ?: 'application/octet-stream';
try {
$stmt = $pdo->prepare(
'INSERT INTO stock_lot_document
(stock_lot_id, original_filename, stored_filename, mime_type, size_bytes, uploaded_by)
VALUES (:lot_id, :original_filename, :stored_filename, :mime_type, :size_bytes, :uploaded_by)
RETURNING id'
);
$stmt->execute([
':lot_id' => $lotId,
':original_filename' => $originalFilename,
':stored_filename' => $storedFilename,
':mime_type' => $mimeType,
':size_bytes' => $size,
':uploaded_by' => $uploadedBy,
]);
return get_stock_lot_document($pdo, (int) $stmt->fetchColumn());
} catch (Throwable $error) {
unlink($targetPath);
throw $error;
}
}
function get_lager_product_forecast(PDO $pdo, int $productId): array
{
$stmt = $pdo->prepare(
"SELECT sellout_date, sellout_forecast_status, expiry_date
FROM stock_lot
WHERE product_id = :product_id
AND status = 'current'
LIMIT 1"
);
$stmt->execute([':product_id' => $productId]);
$forecast = $stmt->fetch();
if (!is_array($forecast)) {
throw new RuntimeException('Keine aktuelle Charge fuer Forecast vorhanden');
}
return [
'date' => $forecast['sellout_date'],
'status' => (string) $forecast['sellout_forecast_status'],
'expiryDate' => $forecast['expiry_date'],
];
}
function update_stock_lot_traceability(
PDO $pdo,
int $lotId,
?int $manufacturerPartyId,
?string $manufacturerLotNumber,
?string $mfgDate,
?string $expiryDate,
?string $purchaseDate,
string $changedBy,
float $receiptQty = 0.0,
float $adjustmentQty = 0.0,
float $outQty = 0.0,
?string $lotNumber = null,
?float $targetQty = null,
bool $clearLotNumber = false
): array {
if ($lotId <= 0) {
throw new InvalidArgumentException('Chargen-ID fehlt');
}
$manufacturerLotNumber = $manufacturerLotNumber !== null ? trim($manufacturerLotNumber) : null;
if ($manufacturerLotNumber === '') {
$manufacturerLotNumber = null;
}
$lotNumber = $lotNumber !== null ? trim($lotNumber) : null;
if ($lotNumber === '' && !$clearLotNumber) {
throw new InvalidArgumentException('Naurua-Chargennummer fehlt');
}
if ($lotNumber === '') {
$lotNumber = null;
}
if (!is_finite($receiptQty) || !is_finite($adjustmentQty) || !is_finite($outQty)) {
throw new InvalidArgumentException('Ungueltige Bewegungsmenge');
}
if ($targetQty !== null && (!is_finite($targetQty) || $targetQty < 0)) {
throw new InvalidArgumentException('Ungueltige Chargenmenge');
}
if ($receiptQty < 0 || $outQty < 0) {
throw new InvalidArgumentException('Zugang und Abgang duerfen nicht negativ sein');
}
if (fmod($adjustmentQty, 1.0) !== 0.0) {
throw new InvalidArgumentException('Bestandskorrekturen muessen ganzzahlig sein');
}
if ($adjustmentQty !== 0.0 && abs($adjustmentQty) < 0.0001) {
$adjustmentQty = 0.0;
}
$dates = [
'mfg_date' => $mfgDate,
'expiry_date' => $expiryDate,
'purchase_date' => $purchaseDate,
];
foreach ($dates as $field => $value) {
if ($value !== null && $value !== '') {
$parsed = DateTimeImmutable::createFromFormat('!Y-m-d', $value);
$errors = DateTimeImmutable::getLastErrors();
if ($parsed === false || (is_array($errors) && (($errors['warning_count'] ?? 0) > 0 || ($errors['error_count'] ?? 0) > 0))) {
throw new InvalidArgumentException("Ungueltiges Datum fuer {$field}");
}
}
$dates[$field] = $value !== null && $value !== '' ? $value : null;
}
$startedTransaction = !$pdo->inTransaction();
if ($startedTransaction) {
$pdo->beginTransaction();
}
try {
$lotStmt = $pdo->prepare(
"SELECT id, product_id, lot_number, manufacturer_party_id, manufacturer_lot_number,
mfg_date, expiry_date, purchase_date, status
FROM stock_lot
WHERE id = :id
FOR UPDATE"
);
$lotStmt->execute([':id' => $lotId]);
$before = $lotStmt->fetch();
if (!is_array($before)) {
throw new RuntimeException('Charge nicht gefunden');
}
if ((string) $before['status'] === 'virtual_shortage') {
throw new InvalidArgumentException('Virtuelle Fehlbestandschargen koennen nicht gepflegt werden');
}
if ($targetQty !== null && (string) $before['status'] !== 'open') {
throw new InvalidArgumentException('Nur offene Chargen koennen mit einer Zielmenge bearbeitet werden');
}
if ($lotNumber !== null && (string) $before['status'] !== 'open') {
throw new InvalidArgumentException('Nur offene Chargen koennen neu angelegt werden');
}
if ($manufacturerPartyId !== null) {
$manufacturerStmt = $pdo->prepare(
"SELECT p.id
FROM party p
INNER JOIN party_role pr ON pr.party_id = p.id AND pr.role_code = 'manufacturer'
WHERE p.id = :id AND p.status = 'active'
LIMIT 1"
);
$manufacturerStmt->execute([':id' => $manufacturerPartyId]);
if ($manufacturerStmt->fetchColumn() === false) {
throw new InvalidArgumentException('Hersteller ist nicht aktiv oder nicht als Hersteller gepflegt');
}
}
if ($manufacturerPartyId === null && $manufacturerLotNumber !== null) {
throw new InvalidArgumentException('Hersteller-Chargennummer benoetigt einen Hersteller');
}
if ($manufacturerPartyId !== null && $manufacturerLotNumber === null && (string) $before['status'] !== 'open') {
throw new InvalidArgumentException('Hersteller-Chargennummer fehlt');
}
if ($targetQty !== null) {
$balanceStmt = $pdo->prepare(
"SELECT COALESCE(SUM(CASE
WHEN move_type = 'in' THEN qty
WHEN move_type = 'out' THEN -qty
WHEN move_type = 'adjustment' AND to_location_id IS NOT NULL AND from_location_id IS NULL THEN qty
WHEN move_type = 'adjustment' AND from_location_id IS NOT NULL AND to_location_id IS NULL THEN -qty
ELSE 0
END), 0)
FROM stock_move
WHERE lot_id = :lot_id"
);
$balanceStmt->execute([':lot_id' => $lotId]);
$quantityDelta = $targetQty - (float) $balanceStmt->fetchColumn();
$receiptQty = $quantityDelta > 0 ? $quantityDelta : 0.0;
$outQty = 0.0;
$adjustmentQty = $quantityDelta < 0 ? $quantityDelta : 0.0;
}
if (($adjustmentQty !== 0.0 || $outQty > 0) && (string) $before['status'] !== 'current' && $targetQty === null) {
throw new InvalidArgumentException('Korrekturen und Abgaenge koennen nur auf der aktiven Charge gebucht werden');
}
$lotNumberAssignment = $clearLotNumber
? 'NULL'
: ($lotNumber === null ? 'lot_number' : 'COALESCE(:lot_number, lot_number)');
$updateStmt = $pdo->prepare(
"UPDATE stock_lot
SET lot_number = {$lotNumberAssignment},
manufacturer_party_id = :manufacturer_party_id,
manufacturer_lot_number = :manufacturer_lot_number,
mfg_date = :mfg_date,
expiry_date = :expiry_date,
purchase_date = :purchase_date,
updated_at = NOW()
WHERE id = :id"
);
$updateParams = [
':manufacturer_party_id' => $manufacturerPartyId,
':manufacturer_lot_number' => $manufacturerLotNumber,
':mfg_date' => $dates['mfg_date'],
':expiry_date' => $dates['expiry_date'],
':purchase_date' => $dates['purchase_date'],
':id' => $lotId,
];
if (!$clearLotNumber && $lotNumber !== null) {
$updateParams[':lot_number'] = $lotNumber;
}
$updateStmt->execute($updateParams);
$after = $before;
if ($clearLotNumber) {
$after['lot_number'] = null;
} elseif ($lotNumber !== null) {
$after['lot_number'] = $lotNumber;
}
$after['manufacturer_party_id'] = $manufacturerPartyId;
$after['manufacturer_lot_number'] = $manufacturerLotNumber;
$after['mfg_date'] = $dates['mfg_date'];
$after['expiry_date'] = $dates['expiry_date'];
$after['purchase_date'] = $dates['purchase_date'];
system_write_audit_log($pdo, 'stock_lot', (string) $lotId, 'traceability_updated', $changedBy, $before, $after, [
'module' => 'erp.lager',
]);
if ($receiptQty > 0 || $adjustmentQty !== 0.0 || $outQty > 0) {
book_stock_lot_movements($pdo, (int) $before['product_id'], $lotId, $receiptQty, $adjustmentQty, $outQty);
}
if ($startedTransaction) {
$pdo->commit();
}
return $after;
} catch (Throwable $error) {
if ($startedTransaction && $pdo->inTransaction()) {
$pdo->rollBack();
}
throw $error;
}
}
function get_current_lot_balance_for_update(PDO $pdo, int $productId): array
@@ -145,6 +635,30 @@ function get_current_lot_balance_for_update(PDO $pdo, int $productId): array
];
}
function get_stock_lot_balance(PDO $pdo, int $lotId): array
{
$stmt = $pdo->prepare(
"SELECT
COALESCE(v.qty_net, 0) AS qty_net,
COALESCE(v.qty_in, 0) AS qty_in,
COALESCE(v.qty_out, 0) AS qty_out
FROM stock_lot sl
LEFT JOIN v_stock_lot_balance v ON v.stock_lot_id = sl.id
WHERE sl.id = :lot_id"
);
$stmt->execute([':lot_id' => $lotId]);
$row = $stmt->fetch();
if (!is_array($row)) {
throw new RuntimeException('Charge nicht gefunden');
}
return [
'qty_net' => (float) $row['qty_net'],
'qty_in' => (float) $row['qty_in'],
'qty_out' => (float) $row['qty_out'],
];
}
function insert_stock_move_out(
PDO $pdo,
int $productId,
@@ -213,6 +727,91 @@ function insert_stock_move_in(
return (int) $id;
}
function insert_stock_move_adjustment(
PDO $pdo,
int $productId,
int $lotId,
float $qty,
?int $fromLocationId,
?int $toLocationId,
string $note
): int {
$stmt = $pdo->prepare(
"INSERT INTO stock_move (
product_id, lot_id, from_location_id, to_location_id, qty, move_type, move_date, note, created_at, updated_at
) VALUES (
:product_id, :lot_id, :from_location_id, :to_location_id, :qty, 'adjustment', NOW(), :note, NOW(), NOW()
)
RETURNING id"
);
$stmt->execute([
':product_id' => $productId,
':lot_id' => $lotId,
':from_location_id' => $fromLocationId,
':to_location_id' => $toLocationId,
':qty' => $qty,
':note' => $note,
]);
$id = $stmt->fetchColumn();
if ($id === false) {
throw new RuntimeException('Konnte Bestandskorrektur nicht schreiben');
}
return (int) $id;
}
function book_stock_lot_movements(
PDO $pdo,
int $productId,
int $lotId,
float $receiptQty,
float $adjustmentQty,
float $outQty
): void {
$locations = get_default_location_ids($pdo);
$balanceStmt = $pdo->prepare(
"SELECT COALESCE(
SUM(CASE
WHEN move_type = 'in' THEN qty
WHEN move_type = 'out' THEN -qty
WHEN move_type = 'adjustment' AND to_location_id IS NOT NULL AND from_location_id IS NULL THEN qty
WHEN move_type = 'adjustment' AND from_location_id IS NOT NULL AND to_location_id IS NULL THEN -qty
ELSE 0
END), 0
)
FROM stock_move
WHERE lot_id = :lot_id"
);
$balanceStmt->execute([':lot_id' => $lotId]);
$balance = (float) $balanceStmt->fetchColumn();
if ($receiptQty > 0) {
insert_stock_move_in($pdo, $productId, $lotId, $receiptQty, $locations['storage'], $locations['storage'], 'Manueller Lagerzugang via Chargenformular');
$balance += $receiptQty;
}
if ($adjustmentQty !== 0.0) {
if ($adjustmentQty > 0) {
insert_stock_move_adjustment($pdo, $productId, $lotId, $adjustmentQty, null, $locations['storage'], 'Manuelle Bestandskorrektur via Chargenformular');
} else {
$correctionQty = abs($adjustmentQty);
if ($correctionQty > $balance) {
throw new InvalidArgumentException('Bestandskorrektur wuerde den Chargensaldo negativ machen');
}
insert_stock_move_adjustment($pdo, $productId, $lotId, $correctionQty, $locations['storage'], null, 'Manuelle Bestandskorrektur via Chargenformular');
}
$balance += $adjustmentQty;
}
if ($outQty > $balance) {
throw new InvalidArgumentException('Lagerabgang ist groesser als der aktuelle Chargenbestand');
}
if ($outQty > 0) {
insert_stock_move_out($pdo, $productId, $lotId, $outQty, $locations['storage'], $locations['dispatch'], 'Manueller Lagerabgang via Chargenformular');
}
}
function switch_current_lot(PDO $pdo, int $productId, int $oldCurrentLotId, int $storageLocationId): int
{
$closeStmt = $pdo->prepare(
@@ -361,6 +960,91 @@ function get_or_create_virtual_shortage_lot(PDO $pdo, int $productId): int
return (int) $newLotId;
}
function get_or_create_open_lot(PDO $pdo, int $productId): int
{
$select = $pdo->prepare(
"SELECT id
FROM stock_lot
WHERE product_id = :product_id
AND status = 'open'
ORDER BY id
LIMIT 1
FOR UPDATE"
);
$select->execute([':product_id' => $productId]);
$lotId = $select->fetchColumn();
if ($lotId !== false) {
return (int) $lotId;
}
$insert = $pdo->prepare(
"INSERT INTO stock_lot (product_id, status, created_at, updated_at)
VALUES (:product_id, 'open', NOW(), NOW())
RETURNING id"
);
$insert->execute([':product_id' => $productId]);
$newLotId = $insert->fetchColumn();
if ($newLotId === false) {
throw new RuntimeException('Offene Charge konnte nicht angelegt werden');
}
return (int) $newLotId;
}
function delete_open_stock_lot(PDO $pdo, int $lotId, string $changedBy): int
{
if ($lotId <= 0 || trim($changedBy) === '') {
throw new InvalidArgumentException('Offene Charge und Bediener sind erforderlich');
}
$startedTransaction = !$pdo->inTransaction();
if ($startedTransaction) {
$pdo->beginTransaction();
}
try {
$lotStmt = $pdo->prepare(
"SELECT id, product_id, lot_number, manufacturer_party_id, manufacturer_lot_number,
mfg_date, expiry_date, purchase_date, status
FROM stock_lot
WHERE id = :id
FOR UPDATE"
);
$lotStmt->execute([':id' => $lotId]);
$lot = $lotStmt->fetch();
if (!is_array($lot)) {
throw new InvalidArgumentException('Charge nicht gefunden');
}
if ((string) $lot['status'] !== 'open') {
throw new InvalidArgumentException('Nur offene Chargen koennen geloescht werden');
}
system_write_audit_log($pdo, 'stock_lot', (string) $lotId, 'deleted', $changedBy, $lot, [], [
'module' => 'erp.lager',
]);
$moveStmt = $pdo->prepare('DELETE FROM stock_move WHERE lot_id = :lot_id');
$moveStmt->execute([':lot_id' => $lotId]);
$deleteStmt = $pdo->prepare('DELETE FROM stock_lot WHERE id = :id AND status = \'open\'');
$deleteStmt->execute([':id' => $lotId]);
if ($deleteStmt->rowCount() !== 1) {
throw new RuntimeException('Offene Charge konnte nicht geloescht werden');
}
$productId = (int) $lot['product_id'];
if ($startedTransaction) {
$pdo->commit();
}
return $productId;
} catch (Throwable $error) {
if ($startedTransaction && $pdo->inTransaction()) {
$pdo->rollBack();
}
throw $error;
}
}
function replace_virtual_lot_allocation(
PDO $pdo,
int $allocationId,
@@ -530,8 +1214,10 @@ function allocate_components_for_line(
$allocationCount = 0;
$virtualAllocationCount = 0;
$forecastProductIds = [];
foreach ($components as $component) {
$productId = (int) $component['product_id'];
$forecastProductIds[$productId] = true;
$remaining = $lineQty * (float) $component['qty_per_item'];
$guard = 0;
@@ -587,6 +1273,10 @@ function allocate_components_for_line(
}
}
foreach (array_keys($forecastProductIds) as $productId) {
refresh_lager_stock_lot_forecast_for_product($pdo, (int) $productId);
}
return [
'allocated' => true,
'allocationCount' => $allocationCount,
+416 -231
View File
@@ -171,22 +171,8 @@ function auth_render_bestellungen_large_table(array $bestellungenTable): string
$html[] = '<h2 class="sg-heading-h2 sg-card-list-page-drawer__title" data-order-drawer-title>Bestellung</h2>';
$html[] = '<button class="sg-interaction-element sg-hyperlink" type="button" data-order-drawer-close>schliessen</button>';
$html[] = '</div>';
$html[] = '<div class="sg-card-list-page-drawer__content">';
$html[] = '<article class="sg-card sg-vsf-drawer-card" data-component="card" data-pattern="card" aria-label="Bestell-Details">';
$html[] = '<header class="sg-card-segment sg-card-segment--header sg-card-segment--darkblue sg-vsf-drawer-card__header">';
$html[] = '<div class="sg-strong">Bestell-Details</div>';
$html[] = '</header>';
$html[] = '<div class="sg-card-segment sg-card-segment--body sg-vsf-drawer-card__content">';
$html[] = '<p class="sg-body"><strong>Bestellnummer:</strong> <span data-order-drawer-order-number></span></p>';
$html[] = '<p class="sg-body"><strong>Bestelldatum:</strong> <span data-order-drawer-order-date></span></p>';
$html[] = '<p class="sg-body"><strong>Lieferadresse:</strong></p>';
$html[] = '<p class="sg-body" data-order-drawer-address-line-1></p>';
$html[] = '<p class="sg-body" data-order-drawer-address-line-2></p>';
$html[] = '<p class="sg-body" data-order-drawer-address-line-3></p>';
$html[] = '<p class="sg-body"><strong>Gesamtsumme:</strong> <span data-order-drawer-total></span></p>';
$html[] = '<p class="sg-body">Detailinhalt wird noch definiert.</p>';
$html[] = '</div>';
$html[] = '</article>';
$html[] = '<div class="sg-card-list-page-drawer__content" data-order-drawer-cards>';
$html[] = '<p class="sg-body" data-order-drawer-loading>Bestelldetails werden geladen …</p>';
$html[] = '</div>';
$html[] = '</aside>';
@@ -254,12 +240,30 @@ function auth_render_lager_table(string $title, array $rows, array $columns, str
$detailData = '';
if ($tableKey === 'products') {
$detailData = ' data-lager-drawer-forecast="' . auth_escape_html($forecastText) . '"'
. ' data-lager-drawer-forecast-status="' . auth_escape_html($forecastStatus) . '"'
. ' data-lager-drawer-forecast-warning="' . ($forecastWarning ? 'true' : 'false') . '"'
. ' data-lager-drawer-forecast-mhd-warning="' . ($forecastMhdWarning ? 'true' : 'false') . '"'
. ' data-lager-drawer-current-qty="' . auth_escape_html(auth_format_lager_quantity((float) ($row['current_qty'] ?? 0))) . '"'
. ' data-lager-drawer-qty-in="' . auth_escape_html(auth_format_lager_quantity((float) ($row['qty_in'] ?? 0))) . '"'
. ' data-lager-drawer-virtual-shortage-qty="' . auth_escape_html(auth_format_lager_quantity((float) ($row['virtual_shortage_qty'] ?? 0))) . '"'
. ' data-lager-drawer-lot-id="' . auth_escape_html((string) ($row['lot_id'] ?? '')) . '"'
. ' data-lager-drawer-lot-number="' . auth_escape_html((string) ($row['lot_number'] ?? '')) . '"'
. ' data-lager-drawer-supplier-lot-number="' . auth_escape_html((string) ($row['supplier_lot_number'] ?? '')) . '"'
. ' data-lager-drawer-manufacturer-party-id="' . auth_escape_html((string) ($row['manufacturer_party_id'] ?? '')) . '"'
. ' data-lager-drawer-manufacturer-name="' . auth_escape_html((string) ($row['manufacturer_name'] ?? '')) . '"'
. ' data-lager-drawer-manufacturer-lot-number="' . auth_escape_html((string) ($row['manufacturer_lot_number'] ?? '')) . '"'
. ' data-lager-drawer-mfg-date="' . auth_escape_html((string) ($row['mfg_date'] ?? '')) . '"'
. ' data-lager-drawer-expiry-date="' . auth_escape_html((string) ($row['expiry_date'] ?? '')) . '"'
. ' data-lager-drawer-purchase-date="' . auth_escape_html((string) ($row['purchase_date'] ?? '')) . '"'
. ' data-lager-drawer-documents="' . auth_escape_html((string) json_encode($row['documents'] ?? [], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)) . '"'
. ' data-lager-drawer-virtual-lot-number="' . auth_escape_html((string) ($row['virtual_lot_number'] ?? '')) . '"';
$openLot = $row['open_lots'][0] ?? [];
$detailData .= ' data-lager-open-lot-id="' . auth_escape_html((string) ($openLot['lot_id'] ?? '')) . '"'
. ' data-lager-open-lot-number="' . auth_escape_html((string) ($openLot['lot_number'] ?? '')) . '"'
. ' data-lager-open-manufacturer-party-id="' . auth_escape_html((string) ($openLot['manufacturer_party_id'] ?? '')) . '"'
. ' data-lager-open-manufacturer-lot-number="' . auth_escape_html((string) ($openLot['manufacturer_lot_number'] ?? '')) . '"'
. ' data-lager-open-mfg-date="' . auth_escape_html((string) ($openLot['mfg_date'] ?? '')) . '"'
. ' data-lager-open-expiry-date="' . auth_escape_html((string) ($openLot['expiry_date'] ?? '')) . '"'
. ' data-lager-open-purchase-date="' . auth_escape_html((string) ($openLot['purchase_date'] ?? '')) . '"';
}
$html[] = '<div class="' . $rowClass . '" role="row" data-component-part="large-table-row" data-lager-row="true" data-lager-id="' . $id . '">';
@@ -278,29 +282,195 @@ function auth_render_lager_table(string $title, array $rows, array $columns, str
return implode('', $html);
}
function auth_render_lager_content(array $lagerProducts, array $lagerArtikel): string
function auth_format_lager_date(?string $value): string
{
return '<div data-lager-content="true">'
. '<section class="sg-card-list-page" aria-label="Lager">'
. auth_render_lager_table('Produkte', $lagerProducts, ['Produktname', 'Anzahl im Lager', 'Verbraucht bis'], 'products')
. auth_render_lager_table('Artikel', $lagerArtikel, ['Artikel-Name', 'Artikelnummer'], 'articles')
if ($value === null || $value === '') {
return 'Nicht hinterlegt';
}
return (new DateTimeImmutable($value))->format(str_contains($value, ':') ? 'd.m.Y H:i' : 'd.m.Y');
}
function auth_format_lager_quantity(float $value): string
{
return fmod($value, 1.0) === 0.0
? number_format($value, 0, ',', '.')
: number_format($value, 2, ',', '.');
}
function auth_render_lager_documents(array $documents, bool $editable, string $scope): string
{
$segmentClass = $editable ? ' sg-card-segment--gray' : '';
$segmentState = $editable ? ' hidden data-lager-edit-documents="' . auth_escape_html($scope) . '"' : '';
$html = '<div class="sg-card-segment sg-card-segment--body' . $segmentClass . '" data-lager-documents="' . auth_escape_html($scope) . '"' . $segmentState . '>';
if ($editable) {
$html .= '<div class="sg-form-sections-card-wrapper" data-pattern="form-sections" aria-label="Dokumente"><section class="sg-form-sections-card"><div class="sg-form-sections-card__body"><section class="sg-form-sections-card__chapter"><h2 class="sg-strong sg-form-sections-card__chapter-title">Dokumente</h2>';
} else {
$html .= '<section class="sg-form-sections-card__chapter"><h2 class="sg-strong sg-form-sections-card__chapter-title">Dokumente</h2>';
}
if (!$editable) {
$html .= '<div data-lager-documents-read="' . auth_escape_html($scope) . '">';
if ($documents === []) {
$html .= '<p class="sg-body">Keine Dokumente vorhanden.</p>';
} else {
foreach ($documents as $document) {
$documentId = (int) ($document['id'] ?? 0);
$filename = (string) ($document['original_filename'] ?? 'Dokument');
$html .= '<p class="sg-body" data-lager-document-row="' . $documentId . '"><a class="sg-hyperlink" href="/api/lager-document.php?id=' . $documentId . '" data-component="hyperlink">' . auth_escape_html($filename) . '</a></p>';
}
}
$html .= '</div>';
}
if ($editable) {
$html .= '<div data-lager-documents-edit="' . auth_escape_html($scope) . '">';
foreach ($documents as $document) {
$documentId = (int) ($document['id'] ?? 0);
$filename = (string) ($document['original_filename'] ?? 'Dokument');
$html .= '<div class="sg-form-sections-card__field-group" data-lager-document-edit-row="' . $documentId . '"><span class="sg-body">' . auth_escape_html($filename) . ' <span class="sg-tab-button-group" role="tablist" aria-label="Dokument entfernen" data-component="tab-navigation" data-component-size="compact"><button class="sg-interaction-element sg-button sg-tab-button sg-tab-button--compact" type="button" role="tab" aria-selected="false" data-component-part="tab-button" aria-label="Dokument entfernen: ' . auth_escape_html($filename) . '" data-lager-document-delete="' . $documentId . '">×</button></span></span></div>';
}
$html .= '<div class="sg-file-upload" data-pattern="file-upload" data-lager-document-upload-form="' . auth_escape_html($scope) . '"><div class="sg-file-input" data-component="file-input"><div class="sg-file-upload__dropzone sg-labeled-input-row"><label class="sg-label" for="lager-document-file-' . auth_escape_html($scope) . '">Dateien auswählen</label><input class="sg-file-input__control" id="lager-document-file-' . auth_escape_html($scope) . '" type="file" multiple aria-describedby="lager-document-file-status-' . auth_escape_html($scope) . '" data-lager-document-file><button class="sg-interaction-element sg-button sg-button--active sg-file-input__trigger" type="button" data-lager-document-file-trigger>Dateien auswählen</button></div></div><ul class="sg-file-upload__files" data-lager-document-selected-files hidden></ul><p class="sg-body sg-file-upload__status" id="lager-document-file-status-' . auth_escape_html($scope) . '" data-lager-document-status aria-live="polite">Noch keine Dateien ausgewählt.</p></div>';
$html .= '</div>';
}
return $html . ($editable ? '</section></div></section></div></div>' : '</section></div>');
}
function auth_render_lager_historical_lots(array $lagerProducts): string
{
$html = [];
foreach ($lagerProducts as $product) {
$productId = (int) ($product['id'] ?? 0);
$html[] = '<div data-lager-historical-product="' . $productId . '" hidden>';
foreach (($product['historical_lots'] ?? []) as $lot) {
$qtyNet = (float) ($lot['qty_net'] ?? 0);
$emptyDate = $qtyNet <= 0 ? auth_format_lager_date($lot['last_out_at'] ?? null) : 'Nicht eindeutig';
$html[] = '<article class="sg-card sg-card--content-card" data-component="content-card" aria-label="Vergangene Charge">'
. '<div class="sg-card-segment sg-card-segment--header sg-card-segment--darkgreen" data-component-part="card-header"><div class="sg-strong sg-text-on-dark">Vergangene Charge</div></div>'
. '<div class="sg-card-segment sg-card-segment--body" data-component-part="card-body">'
. '<p class="sg-body"><strong>Naurua-Chargennummer:</strong> ' . auth_escape_html((string) ($lot['lot_number'] ?? 'Nicht hinterlegt')) . '<br>'
. '<strong>Status:</strong> Abgeschlossen<br>'
. '<strong>Eingangsmenge:</strong> ' . auth_format_lager_quantity((float) ($lot['qty_in'] ?? 0)) . '<br>'
. '<strong>Verbrauchte Menge:</strong> ' . auth_format_lager_quantity((float) ($lot['qty_out'] ?? 0)) . '<br>'
. '<strong>Verbrauchszeitraum:</strong> ' . auth_format_lager_date($lot['first_in_at'] ?? null) . ' – ' . auth_format_lager_date($lot['last_out_at'] ?? null) . '<br>'
. '<strong>Aufgebraucht am:</strong> ' . $emptyDate . '<br>'
. '<strong>MHD:</strong> ' . auth_format_lager_date($lot['expiry_date'] ?? null) . '<br>'
. '<strong>Hersteller:</strong> ' . auth_escape_html((string) ($lot['manufacturer_name'] ?? 'Nicht hinterlegt')) . '<br>'
. '<strong>Hersteller-Chargennummer:</strong> ' . auth_escape_html((string) ($lot['manufacturer_lot_number'] ?? 'Nicht hinterlegt')) . '</p>'
. '</div>'
. auth_render_lager_documents($lot['documents'] ?? [], false, 'historical-' . (int) ($lot['lot_id'] ?? 0))
. '</article>';
}
$html[] = '</div>';
}
return implode('', $html);
}
function auth_render_lager_open_lots(array $lagerProducts, string $manufacturerOptions): string
{
$html = [];
foreach ($lagerProducts as $product) {
$productId = (int) ($product['id'] ?? 0);
$html[] = '<div data-lager-open-product="' . $productId . '" hidden>';
foreach (($product['open_lots'] ?? []) as $lot) {
$lotId = (int) ($lot['lot_id'] ?? 0);
$html[] = '<div class="sg-delete-confirmation-pattern__stage sg-delete-confirmation-pattern__host" data-pattern="overlay-card" data-dialog-open="false" data-lager-open-card-wrapper="' . $lotId . '"><article class="sg-card sg-card--content-card sg-delete-confirmation-pattern__target" data-component="content-card" data-pattern="overlay-card" aria-label="Offene Charge">'
. '<div class="sg-card-segment sg-card-segment--header sg-card-segment--darkgreen sg-object-card__header" data-component-part="card-header"><div class="sg-strong sg-text-on-dark">Offene Charge</div><div class="sg-component-row"><button class="sg-interaction-element sg-button sg-button--warning" type="button" data-component="button" data-component-variant="warning" data-lager-open-delete>Löschen</button><button class="sg-interaction-element sg-button sg-button--active" type="button" data-lager-open-edit-open>Bearbeiten</button></div></div>'
. '<div class="sg-card-segment sg-card-segment--body" data-component-part="card-body" data-lager-open-card="' . (int) ($lot['lot_id'] ?? 0) . '"><p class="sg-body"><strong>Naurua-Chargennummer:</strong> <span data-lager-open-card-lot-number>' . auth_escape_html((string) ($lot['lot_number'] ?? 'Nicht hinterlegt')) . '</span><br>'
. '<strong>Status:</strong> Vorbereitet<br>'
. '<strong>Bestand:</strong> <span data-lager-open-card-qty-net>' . auth_format_lager_quantity((float) ($lot['qty_net'] ?? 0)) . '</span><br>'
. '<strong>Eingangsmenge:</strong> <span data-lager-open-card-qty-in>' . auth_format_lager_quantity((float) ($lot['qty_in'] ?? 0)) . '</span><br>'
. '<strong>Hersteller:</strong> <span data-lager-open-card-manufacturer>' . auth_escape_html((string) ($lot['manufacturer_name'] ?? 'Nicht hinterlegt')) . '</span><br>'
. '<strong>Hersteller-Chargennummer:</strong> <span data-lager-open-card-manufacturer-lot>' . auth_escape_html((string) ($lot['manufacturer_lot_number'] ?? 'Nicht hinterlegt')) . '</span><br>'
. '<strong>Herstellungsdatum:</strong> <span data-lager-open-card-mfg-date>' . auth_format_lager_date($lot['mfg_date'] ?? null) . '</span><br>'
. '<strong>MHD:</strong> <span data-lager-open-card-expiry-date>' . auth_format_lager_date($lot['expiry_date'] ?? null) . '</span><br>'
. '<strong>Einkaufsdatum:</strong> <span data-lager-open-card-purchase-date>' . auth_format_lager_date($lot['purchase_date'] ?? null) . '</span></p></div>'
. auth_render_lager_documents($lot['documents'] ?? [], false, 'open-read-' . $lotId)
. '<div class="sg-card-segment sg-card-segment--body sg-card-segment--gray" data-lager-open-edit-form hidden><div class="sg-form-sections-card-wrapper" data-pattern="form-sections" aria-label="Offene Charge bearbeiten"><form class="sg-form-sections-card" id="lager-open-edit-form-' . $lotId . '" data-lager-open-edit-form-element><div class="sg-form-sections-card__body" data-pattern-part="form-body"><h2 class="sg-strong sg-form-sections-card__title">Offene Charge bearbeiten</h2>'
. '<section class="sg-form-sections-card__chapter" aria-labelledby="lager-open-charge-data-' . $lotId . '"><h2 id="lager-open-charge-data-' . $lotId . '" class="sg-strong sg-form-sections-card__chapter-title">Chargendaten</h2>'
. '<div class="sg-form-sections-card__field-group"><label class="sg-labeled-input-row"><span class="sg-label">Naurua-Chargennummer</span><input class="sg-interaction-element sg-input-single-line" type="text" data-lager-open-lot-number value="' . auth_escape_html((string) ($lot['lot_number'] ?? '')) . '"></label></div>'
. '<div class="sg-form-sections-card__field-group"><label class="sg-labeled-input-row"><span class="sg-label">Hersteller</span><select class="sg-interaction-element sg-input-single-line" data-lager-open-manufacturer>' . str_replace('value="' . (int) ($lot['manufacturer_party_id'] ?? 0) . '"', 'value="' . (int) ($lot['manufacturer_party_id'] ?? 0) . '" selected', $manufacturerOptions) . '</select></label></div>'
. '<div class="sg-form-sections-card__field-group"><label class="sg-labeled-input-row"><span class="sg-label">Hersteller-Chargennummer</span><input class="sg-interaction-element sg-input-single-line" type="text" data-lager-open-manufacturer-lot value="' . auth_escape_html((string) ($lot['manufacturer_lot_number'] ?? '')) . '"></label></div>'
. '<div class="sg-form-sections-card__field-group"><label class="sg-labeled-input-row"><span class="sg-label">Herstellungsdatum</span><input class="sg-interaction-element sg-input-single-line" type="date" data-lager-open-mfg-date value="' . auth_escape_html((string) ($lot['mfg_date'] ?? '')) . '"></label></div>'
. '<div class="sg-form-sections-card__field-group"><label class="sg-labeled-input-row"><span class="sg-label">MHD</span><input class="sg-interaction-element sg-input-single-line" type="date" data-lager-open-expiry-date value="' . auth_escape_html((string) ($lot['expiry_date'] ?? '')) . '"></label></div>'
. '<div class="sg-form-sections-card__field-group"><label class="sg-labeled-input-row"><span class="sg-label">Einkaufsdatum</span><input class="sg-interaction-element sg-input-single-line" type="date" data-lager-open-purchase-date value="' . auth_escape_html((string) ($lot['purchase_date'] ?? '')) . '"></label></div>'
. '<div class="sg-form-sections-card__field-group"><label class="sg-labeled-input-row"><span class="sg-label">Menge</span><input class="sg-interaction-element sg-input-single-line" type="number" min="0" step="1" inputmode="numeric" data-lager-open-target-qty value="' . auth_escape_html((string) ($lot['qty_net'] ?? 0)) . '"></label></div>'
. '</section></div></form></div></div>'
. auth_render_lager_documents($lot['documents'] ?? [], true, 'open-edit-' . $lotId)
. '<footer class="sg-card-segment sg-card-segment--body sg-card-segment--gray" data-lager-open-edit-actions hidden><div class="sg-form-sections-card-wrapper" data-pattern="form-sections"><div class="sg-form-sections-card"><div class="sg-form-sections-card__actions" data-pattern-part="form-actions"><button class="sg-interaction-element sg-button sg-button--active sg-form-sections-card__action" type="button" data-lager-open-edit-close>Abbrechen</button><button class="sg-interaction-element sg-button sg-button--process sg-form-sections-card__action" type="submit" form="lager-open-edit-form-' . $lotId . '">Speichern</button></div><p class="sg-body" data-lager-open-edit-status aria-live="polite"></p></div></div></footer>'
. '</article>'
. '<article class="sg-card sg-card--overlay-host sg-delete-confirmation-pattern__floating-card" aria-label="Leere Felder bestätigen" role="dialog" aria-modal="true" aria-labelledby="lager-open-confirmation-title-' . $lotId . '" data-lager-open-confirmation-card hidden><div class="sg-card-segment sg-card-segment--body sg-delete-confirmation-pattern__body"><p class="sg-body sg-delete-confirmation-pattern__text" id="lager-open-confirmation-title-' . $lotId . '"><strong>Leere Felder speichern?</strong></p><p class="sg-body sg-delete-confirmation-pattern__text">Die folgenden Felder werden leer gespeichert:</p><div class="sg-delete-confirmation-pattern__list" data-lager-open-confirmation-empty-fields aria-label="Leer zu speichernde Felder"></div><div class="sg-delete-confirmation-pattern__actions"><button class="sg-interaction-element sg-button sg-button--active" type="button" data-lager-open-confirmation-cancel>Abbrechen</button><button class="sg-interaction-element sg-button sg-button--process" type="button" data-lager-open-confirmation-submit>Speichern</button></div></div></article>'
. '<article class="sg-card sg-card--overlay-host sg-delete-confirmation-pattern__floating-card" aria-label="Löschbestätigung" role="dialog" aria-modal="true" aria-labelledby="lager-open-delete-title-' . $lotId . '" data-overlay-dialog="delete" data-lager-open-delete-confirmation hidden><div class="sg-card-segment sg-card-segment--body sg-delete-confirmation-pattern__body"><p class="sg-body sg-delete-confirmation-pattern__text" id="lager-open-delete-title-' . $lotId . '"><strong>Möchtest du diese offene Charge wirklich löschen?</strong></p><p class="sg-body sg-delete-confirmation-pattern__text">Du kannst das nicht rückgängig machen. Bestätige durch Eingabe von <span class="sg-delete-confirmation-pattern__code">DELETE</span>.</p><label class="sg-labeled-input-row sg-delete-confirmation-pattern__input-row"><span class="sg-label">Bestätigung</span><input class="sg-interaction-element sg-input-single-line sg-input-single-line--inactive-selectable sg-form-inactive-selectable" type="text" placeholder="DELETE" aria-label="Löschbestätigung durch DELETE" data-lager-open-delete-confirmation-input></label><p class="sg-body" data-lager-open-delete-status aria-live="polite"></p><div class="sg-delete-confirmation-pattern__actions"><button class="sg-interaction-element sg-button sg-button--active" type="button" data-lager-open-delete-confirmation-cancel>Abbrechen</button><button class="sg-interaction-element sg-button sg-button--process sg-button--process-inactive" type="button" disabled aria-disabled="true" data-lager-open-delete-confirmation-submit>Löschen</button></div></div></article></div>';
}
$html[] = '</div>';
}
return implode('', $html);
}
function auth_render_lager_content(array $lagerProducts, array $lagerArtikel, array $lagerManufacturers, string $csrfToken, string $sectionTitle = 'Lager'): string
{
$manufacturerOptions = '<option value="">Hersteller auswählen</option>';
foreach ($lagerManufacturers as $manufacturer) {
$manufacturerOptions .= '<option value="' . (int) ($manufacturer['id'] ?? 0) . '">' . auth_escape_html((string) ($manufacturer['name'] ?? '')) . '</option>';
}
$tables = $sectionTitle === 'Lager'
? auth_render_lager_table('Produkte', $lagerProducts, ['Produktname', 'Anzahl im Lager', 'Verbraucht bis'], 'products')
: auth_render_lager_table('Artikel', $lagerArtikel, ['Artikel-Name', 'Artikelnummer'], 'articles');
return '<div data-lager-content="true" data-lager-csrf-token="' . auth_escape_html($csrfToken) . '">'
. '<section class="sg-card-list-page" aria-label="' . auth_escape_html($sectionTitle) . '">'
. $tables
. '</section>'
. '<div class="sg-card-list-page-drawer-backdrop" aria-hidden="true" data-lager-drawer-backdrop="true" data-open="false"></div>'
. '<aside class="sg-card-list-page-drawer" aria-label="Lager-Details" aria-hidden="true" data-lager-drawer="true" data-open="false">'
. '<div class="sg-card-list-page-drawer__header"><h2 class="sg-heading-h2 sg-card-list-page-drawer__title" data-lager-drawer-title>Lager-Details</h2><button class="sg-interaction-element sg-hyperlink" type="button" data-lager-drawer-close>schliessen</button></div>'
. '<div class="sg-card-list-page-drawer__header"><h2 class="sg-heading-h2 sg-card-list-page-drawer__title" data-lager-drawer-title>Lager-Details</h2></div>'
. '<div class="sg-card-list-page-drawer__content">'
. '<article class="sg-card sg-vsf-drawer-card" data-component="card" data-pattern="card" aria-label="Lager-Details">'
. '<header class="sg-card-segment sg-card-segment--header sg-card-segment--darkblue sg-vsf-drawer-card__header"><div class="sg-strong">Lager-Details</div></header>'
. '<div class="sg-card-segment sg-card-segment--body sg-vsf-drawer-card__content"><p class="sg-body"><strong>Name:</strong> <span data-lager-drawer-name-value></span></p><p class="sg-body" data-lager-drawer-value-row><strong data-lager-drawer-value-label>Wert:</strong> <span data-lager-drawer-value-value></span></p></div>'
. '<div class="sg-card-segment sg-card-segment--body sg-vsf-drawer-card__content"><p class="sg-body"><strong>Name:</strong> <span data-lager-drawer-name-value></span></p><p class="sg-body" data-lager-drawer-value-row><strong data-lager-drawer-value-label>Wert:</strong> <span data-lager-drawer-value-value></span><br><strong>Voraussichtlich leer:</strong> <span data-lager-drawer-forecast-value>Keine Prognose</span></p></div>'
. '</article>'
. '<article class="sg-card sg-card--content-card" data-component="content-card" data-lager-drawer-topic="warning" aria-label="Warnungen">'
. '<div class="sg-card-segment sg-card-segment--header sg-card-segment--darkgreen" data-component-part="card-header"><div class="sg-strong sg-text-on-dark">Warnungen</div></div>'
. '<div class="sg-card-segment sg-card-segment--header sg-card-segment--darkgreen" data-component-part="card-header" data-lager-drawer-warning-header><div class="sg-strong sg-text-on-dark" data-lager-drawer-warning-title>Keine Warnung</div></div>'
. '<div class="sg-card-segment sg-card-segment--body" data-component-part="card-body"><p class="sg-body" data-lager-drawer-warning-value></p></div>'
. '</article>'
. '<article class="sg-card sg-card--content-card" data-component="content-card" data-lager-drawer-topic="lot" aria-label="Lagercharge">'
. '<div class="sg-card-segment sg-card-segment--header sg-card-segment--darkgreen" data-component-part="card-header"><div class="sg-strong sg-text-on-dark">Lagercharge</div></div>'
. '<div class="sg-card-segment sg-card-segment--body" data-component-part="card-body"><p class="sg-body"><strong>Naurua-Chargennummer:</strong> <span data-lager-drawer-lot-number></span><br><strong>Hersteller-Chargennummer:</strong> <span data-lager-drawer-supplier-lot-number></span><br><strong>MHD:</strong> <span data-lager-drawer-expiry-date></span></p></div>'
. '<div class="sg-inline-controls-row" data-lager-drawer-topic="new-lot-action"><button class="sg-interaction-element sg-button sg-button--active" type="button" data-lager-new-lot-open>Neue Charge anlegen</button></div>'
. '<article class="sg-card sg-card--content-card" data-lager-drawer-topic="new-lot-form" aria-label="Neue Charge anlegen" hidden>'
. '<div class="sg-card-segment sg-card-segment--body sg-card-segment--gray"><div class="sg-form-sections-card-wrapper" data-pattern="form-sections" aria-label="Neue Charge anlegen"><form class="sg-form-sections-card" data-lager-new-lot-form-element><div class="sg-form-sections-card__body" data-pattern-part="form-body"><h2 class="sg-strong sg-form-sections-card__title">Neue Charge anlegen</h2>'
. '<div class="sg-notifications-pattern" data-lager-new-lot-notification-wrapper hidden><article class="sg-card sg-notifications-pattern__card" data-pattern="notification-small" data-component="notification-card"><div class="sg-card-segment sg-card-segment--header sg-card-segment--signal-green sg-notifications-pattern__text-segment sg-notifications-pattern__text-segment--small" data-component-part="card-header"><p class="sg-body" data-lager-new-lot-notification></p></div></article></div>'
. '<section class="sg-form-sections-card__chapter" aria-labelledby="lager-new-charge-data-title"><h2 id="lager-new-charge-data-title" class="sg-strong sg-form-sections-card__chapter-title">Chargendaten</h2>'
. '<div class="sg-form-sections-card__field-group"><label class="sg-labeled-input-row"><span class="sg-label">Naurua-Chargennummer</span><input class="sg-interaction-element sg-input-single-line" type="text" data-lager-new-lot-number required></label></div>'
. '<div class="sg-form-sections-card__field-group"><label class="sg-labeled-input-row"><span class="sg-label">Hersteller</span><select class="sg-interaction-element sg-input-single-line" data-lager-new-manufacturer>' . $manufacturerOptions . '</select></label></div>'
. '<div class="sg-form-sections-card__field-group"><label class="sg-labeled-input-row"><span class="sg-label">Hersteller-Chargennummer</span><input class="sg-interaction-element sg-input-single-line" type="text" data-lager-new-manufacturer-lot></label></div>'
. '<div class="sg-form-sections-card__field-group"><label class="sg-labeled-input-row"><span class="sg-label">Herstellungsdatum</span><input class="sg-interaction-element sg-input-single-line" type="date" data-lager-new-mfg-date></label></div>'
. '<div class="sg-form-sections-card__field-group"><label class="sg-labeled-input-row"><span class="sg-label">MHD</span><input class="sg-interaction-element sg-input-single-line" type="date" data-lager-new-expiry-date></label></div>'
. '<div class="sg-form-sections-card__field-group"><label class="sg-labeled-input-row"><span class="sg-label">Einkaufsdatum</span><input class="sg-interaction-element sg-input-single-line" type="date" data-lager-new-purchase-date></label></div>'
. '<div class="sg-form-sections-card__field-group"><label class="sg-labeled-input-row"><span class="sg-label">Eingangsmenge</span><input class="sg-interaction-element sg-input-single-line" type="number" min="0" step="1" inputmode="numeric" data-lager-new-receipt-qty placeholder="Menge"></label></div>'
. '</section><footer class="sg-form-sections-card__actions-segment" data-pattern-part="form-actions-segment"><div class="sg-form-sections-card__actions" data-pattern-part="form-actions"><button class="sg-interaction-element sg-button sg-button--active sg-form-sections-card__action" type="button" data-lager-new-lot-close>Abbrechen</button><button class="sg-interaction-element sg-button sg-button--process sg-form-sections-card__action" type="submit">Speichern</button></div></footer><p class="sg-body" data-lager-new-lot-status aria-live="polite"></p></div></form></div></div>'
. '</article>'
. '<div data-lager-drawer-topic="open-lots" aria-label="Offene Chargen">' . auth_render_lager_open_lots($lagerProducts, $manufacturerOptions) . '</div>'
. '<article class="sg-card sg-card--content-card" data-component="content-card" data-lager-drawer-topic="lot" aria-label="Aktive Charge">'
. '<div class="sg-card-segment sg-card-segment--header sg-card-segment--darkgreen sg-object-card__header" data-component-part="card-header"><div class="sg-strong sg-text-on-dark">Aktive Charge</div><button class="sg-interaction-element sg-button sg-button--active" type="button" data-lager-edit-open>Bearbeiten</button></div>'
. '<div class="sg-card-segment sg-card-segment--body" data-component-part="card-body" data-lager-read-view><p class="sg-body"><strong>Naurua-Chargennummer:</strong> <span data-lager-drawer-lot-number></span><br><strong>Aktueller Bestand:</strong> <span data-lager-drawer-current-qty></span><br><strong>Eingangsmenge:</strong> <span data-lager-drawer-qty-in></span><br><strong>Hersteller:</strong> <span data-lager-drawer-manufacturer-name></span><br><strong>Hersteller-Chargennummer:</strong> <span data-lager-drawer-manufacturer-lot-number></span><br><strong>Herstellungsdatum:</strong> <span data-lager-drawer-mfg-date></span><br><strong>MHD:</strong> <span data-lager-drawer-expiry-date></span><br><strong>Einkaufsdatum:</strong> <span data-lager-drawer-purchase-date></span></p></div>' . auth_render_lager_documents([], false, 'current-read')
. '<div class="sg-card-segment sg-card-segment--body sg-card-segment--gray" data-lager-edit-form hidden><div class="sg-form-sections-card-wrapper" data-pattern="form-sections" aria-label="Aktive Charge bearbeiten">'
. '<form class="sg-form-sections-card" id="lager-edit-form" data-lager-edit-form-element><div class="sg-form-sections-card__body" data-pattern-part="form-body"><h2 class="sg-strong sg-form-sections-card__title">Aktive Charge bearbeiten</h2>'
. '<section class="sg-form-sections-card__chapter" aria-labelledby="lager-charge-traceability-title"><h2 id="lager-charge-traceability-title" class="sg-strong sg-form-sections-card__chapter-title">Rückverfolgbarkeit</h2>'
. '<div class="sg-form-sections-card__field-group"><label class="sg-labeled-input-row"><span class="sg-label">Naurua-Chargennummer</span><input class="sg-interaction-element sg-input-single-line sg-form-inactive-selectable" type="text" data-lager-lot-number readonly></label></div>'
. '<div class="sg-form-sections-card__field-group"><label class="sg-labeled-input-row"><span class="sg-label">Hersteller</span><select class="sg-interaction-element sg-input-single-line" id="lager-manufacturer" data-lager-manufacturer>' . $manufacturerOptions . '</select></label></div>'
. '<div class="sg-form-sections-card__field-group"><label class="sg-labeled-input-row"><span class="sg-label">Hersteller-Chargennummer</span><input class="sg-interaction-element sg-input-single-line" type="text" id="lager-manufacturer-lot" data-lager-manufacturer-lot></label></div>'
. '<div class="sg-form-sections-card__field-group"><label class="sg-labeled-input-row"><span class="sg-label">Herstellungsdatum</span><input class="sg-interaction-element sg-input-single-line" type="date" id="lager-mfg-date" data-lager-mfg-date></label></div>'
. '<div class="sg-form-sections-card__field-group"><label class="sg-labeled-input-row"><span class="sg-label">MHD</span><input class="sg-interaction-element sg-input-single-line" type="date" id="lager-expiry-date" data-lager-expiry-date></label></div>'
. '<div class="sg-form-sections-card__field-group"><label class="sg-labeled-input-row"><span class="sg-label">Einkaufsdatum</span><input class="sg-interaction-element sg-input-single-line" type="date" id="lager-purchase-date" data-lager-purchase-date></label></div>'
. '</section><section class="sg-form-sections-card__chapter" aria-labelledby="lager-charge-movements-title"><h2 id="lager-charge-movements-title" class="sg-strong sg-form-sections-card__chapter-title">Lagerbewegungen</h2>'
. '<p class="sg-form-sections-card__sentence">Buchungen werden zusätzlich zur Rückverfolgbarkeit als Lagerbewegung gespeichert. Positive Werte erhöhen den Bestand, negative Werte reduzieren ihn.</p>'
. '<div class="sg-form-sections-card__field-group"><label class="sg-labeled-input-row"><span class="sg-label">Zugang buchen</span><input class="sg-interaction-element sg-input-single-line" type="number" min="0" step="0.01" inputmode="decimal" data-lager-receipt-qty placeholder="Menge"></label></div>'
. '<div class="sg-form-sections-card__field-group"><label class="sg-labeled-input-row"><span class="sg-label">Bestandskorrektur (+ / −)</span><input class="sg-interaction-element sg-input-single-line" type="number" step="1" inputmode="numeric" data-lager-adjustment-qty placeholder="+ / − Menge"></label></div>'
. '<div class="sg-form-sections-card__field-group"><label class="sg-labeled-input-row"><span class="sg-label">Abgang buchen</span><input class="sg-interaction-element sg-input-single-line" type="number" min="0" step="0.01" inputmode="decimal" data-lager-out-qty placeholder="Menge"></label></div>'
. '</section></div></form></div></div>'
. auth_render_lager_documents([], true, 'current-edit')
. '<footer class="sg-card-segment sg-card-segment--body sg-card-segment--gray" data-lager-edit-actions hidden><div class="sg-form-sections-card-wrapper" data-pattern="form-sections"><div class="sg-form-sections-card"><div class="sg-form-sections-card__actions" data-pattern-part="form-actions"><button class="sg-interaction-element sg-button sg-button--active sg-form-sections-card__action" type="button" data-lager-edit-close>Abbrechen</button><button class="sg-interaction-element sg-button sg-button--process sg-form-sections-card__action" type="submit" form="lager-edit-form">Speichern</button></div><p class="sg-body" data-lager-edit-status aria-live="polite"></p></div></div></footer>'
. '</article>'
. '<div data-lager-drawer-topic="historical-lots" aria-label="Vergangene Chargen">' . auth_render_lager_historical_lots($lagerProducts) . '</div>'
. '<article class="sg-card sg-card--content-card" data-component="content-card" data-lager-drawer-topic="virtual-lot" aria-label="Virtuelle Charge">'
. '<div class="sg-card-segment sg-card-segment--header sg-card-segment--darkgreen" data-component-part="card-header"><div class="sg-strong sg-text-on-dark">Virtuelle Charge</div></div>'
. '<div class="sg-card-segment sg-card-segment--body" data-component-part="card-body"><p class="sg-body" data-lager-drawer-virtual-lot-value></p></div>'
@@ -310,7 +480,7 @@ function auth_render_lager_content(array $lagerProducts, array $lagerArtikel): s
. '</div>';
}
function render_auth_home_page(array $user, array $otcProducts = [], array $bestellungenTable = [], array $lagerProducts = [], array $lagerArtikel = []): void
function render_auth_home_page(array $user, array $otcProducts = [], array $bestellungenTable = [], array $lagerProducts = [], array $lagerArtikel = [], array $lagerManufacturers = []): void
{
$otcProductRows = '';
foreach ($otcProducts as $product) {
@@ -324,9 +494,9 @@ function render_auth_home_page(array $user, array $otcProducts = [], array $best
$disabledAttribute = $hasSellableMapping ? '' : ' disabled aria-disabled="true"';
$otcProductRows .= '<div class="sg-form-sections-card__field-group">';
$otcProductRows .= '<label class="sg-label" for="' . $inputId . '">' . $productName . ($productSku !== '' ? ' (' . $productSku . ')' : '') . $mappingNote . '</label>';
$otcProductRows .= '<input class="sg-interaction-element sg-input-single-line" type="number" id="' . $inputId . '" min="0" step="1" data-otc-order-product data-product-id="' . $productId . '" data-title="' . $productName . '" data-available-qty="' . $availableQty . '"' . $disabledAttribute . '>';
$otcProductRows .= '<p class="sg-body sg-form-sections-card__sentence">Physischer Bestand: ' . $availableQty . '</p>';
$otcProductRows .= '<label class="sg-labeled-input-row sg-otc-order-product-row"><span class="sg-label">' . $productName . ($productSku !== '' ? ' (' . $productSku . ')' : '') . $mappingNote . '</span>';
$otcProductRows .= '<input class="sg-interaction-element sg-input-single-line sg-input-single-line--inactive-selectable sg-form-inactive-selectable" type="number" id="' . $inputId . '" min="0" step="1" data-otc-order-product data-product-id="' . $productId . '" data-title="' . $productName . '" data-available-qty="' . $availableQty . '"' . $disabledAttribute . '>';
$otcProductRows .= '</label>';
$otcProductRows .= '</div>';
}
@@ -344,11 +514,12 @@ function render_auth_home_page(array $user, array $otcProducts = [], array $best
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES
);
$bestellungenContent = auth_render_bestellungen_content($bestellungenTable);
$lagerContent = auth_render_lager_content($lagerProducts, $lagerArtikel);
$lagerContent = auth_render_lager_content($lagerProducts, $lagerArtikel, $lagerManufacturers, auth_csrf_token());
$moduleContentCards = json_encode(
[
'Bestellungen' => $bestellungenContent,
'Lager' => $lagerContent,
'Artikel' => auth_render_lager_content([], $lagerArtikel, $lagerManufacturers, auth_csrf_token(), 'Artikel'),
],
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES
);
@@ -429,44 +600,40 @@ function render_auth_home_page(array $user, array $otcProducts = [], array $best
echo '</section>';
echo '</section>';
echo '<section class="sg-otc-order-overlay" data-otc-order-overlay data-open="false" aria-hidden="true">';
echo '<article class="sg-card sg-object-card sg-object-card--variable-height sg-otc-order-overlay__panel" data-pattern="object-card" aria-labelledby="otc-order-modal-title" role="dialog" aria-modal="true">';
echo '<header class="sg-card-segment sg-card-segment--header sg-card-segment--darkblue sg-object-card__header">';
echo '<div class="sg-delete-confirmation-pattern__stage sg-delete-confirmation-pattern__host sg-object-card" data-pattern="overlay-card" data-dialog-open="false" data-otc-confirmation-stage>';
echo '<article class="sg-card sg-scrollable-card sg-delete-confirmation-pattern__target sg-otc-order-overlay__panel" data-pattern="scrollable-card" aria-labelledby="otc-order-modal-title" role="dialog" aria-modal="true">';
echo '<header class="sg-card-segment sg-card-segment--header sg-card-segment--darkblue sg-scrollable-card__header sg-object-card__header" data-pattern-part="scrollable-card-header">';
echo '<h2 id="otc-order-modal-title" class="sg-strong sg-otc-order-overlay__title">Neuen OTC-Verkauf erfassen</h2>';
echo '<button class="sg-interaction-element sg-button sg-button--active sg-otc-order-overlay__close" type="button" data-otc-order-close>Schliessen</button>';
echo '</header>';
echo '<div class="sg-card-segment sg-card-segment--gray sg-object-card__content">';
echo '<div class="sg-card-segment sg-card-segment--gray sg-scrollable-card__content sg-object-card__content" data-pattern-part="scrollable-card-content" tabindex="0" aria-label="Scrollbarer Formularinhalt">';
echo '<div class="sg-form-sections-card-wrapper" data-pattern="form-sections" aria-label="Formular mit Abschnitten">';
echo '<form class="sg-form-sections-card" action="#" method="post" data-otc-order-form aria-label="Neuen OTC-Verkauf erfassen">';
echo '<form id="otc-order-form" class="sg-form-sections-card" action="#" method="post" data-otc-order-form aria-label="Neuen OTC-Verkauf erfassen">';
echo '<div class="sg-form-sections-card__body" data-pattern-part="form-body">';
echo '<section class="sg-form-sections-card__chapter" aria-labelledby="otc-products-title">';
echo '<h2 id="otc-products-title" class="sg-strong sg-form-sections-card__chapter-title">PRODUKTE</h2>';
echo $otcProductRows;
echo '</section>';
echo '<section class="sg-form-sections-card__chapter" aria-labelledby="otc-total-price-title">';
echo '<h2 id="otc-total-price-title" class="sg-strong sg-form-sections-card__chapter-title">Preis</h2>';
echo '<div class="sg-form-sections-card__field-group">';
echo '<label class="sg-label" for="otc-total-price">Preis alle Flaschen brutto (CHF)</label>';
echo '<input class="sg-interaction-element sg-input-single-line" type="number" id="otc-total-price" min="0" step="0.01" data-otc-order-total-price>';
echo '<p class="sg-body sg-form-sections-card__sentence">Der Preis wird durch die Anzahl aller Flaschen geteilt und das Ergebnis ist der Preis jeder einzelnen Flasche.</p>';
echo '</div>';
echo '</section>';
echo '<section class="sg-form-sections-card__chapter" aria-labelledby="otc-order-date-title">';
echo '<h2 id="otc-order-date-title" class="sg-strong sg-form-sections-card__chapter-title">Datum</h2>';
echo '<div class="sg-form-sections-card__field-group">';
echo '<label class="sg-label" for="otc-order-date">Bestelldatum</label>';
echo '<input class="sg-interaction-element sg-input-single-line" type="date" id="otc-order-date" value="' . htmlspecialchars(date('Y-m-d'), ENT_QUOTES, 'UTF-8') . '" data-otc-order-date>';
echo '<label class="sg-labeled-input-row"><span class="sg-label">Bestelldatum</span>';
echo '<input class="sg-interaction-element sg-input-single-line sg-input-single-line--inactive-selectable sg-form-inactive-selectable" type="date" id="otc-order-date" value="' . htmlspecialchars(date('Y-m-d'), ENT_QUOTES, 'UTF-8') . '" data-otc-order-date>';
echo '</label>';
echo '</div>';
echo '</section>';
echo '<section class="sg-form-sections-card__chapter" aria-labelledby="otc-payment-title">';
echo '<h2 id="otc-payment-title" class="sg-strong sg-form-sections-card__chapter-title">Bezahlung</h2>';
echo '<div data-otc-payment-rows>';
echo '<div class="sg-form-sections-card__field-group" data-otc-payment-row>';
echo '<label class="sg-label" for="otc-payment-method-1">Bezahlart</label>';
echo '<select class="sg-interaction-element sg-input-single-line" id="otc-payment-method-1" data-otc-payment-method>';
echo '<label class="sg-labeled-input-row"><span class="sg-label">Bezahlart</span>';
echo '<select class="sg-interaction-element sg-input-single-line sg-input-single-line--inactive-selectable sg-form-inactive-selectable sg-pulldown" id="otc-payment-method-1" data-otc-payment-method>';
echo '<option value="twint">Twint</option><option value="card">Kreditkarte</option><option value="cash">Barzahlung</option><option value="paypal">PayPal</option><option value="bank_transfer">Überweisung</option>';
echo '</select>';
echo '<label class="sg-label" for="otc-payment-amount-1">Betrag (CHF)</label>';
echo '<input class="sg-interaction-element sg-input-single-line" type="number" id="otc-payment-amount-1" min="0.01" step="0.01" value="0.00" data-otc-payment-amount>';
echo '</label>';
echo '<label class="sg-labeled-input-row"><span class="sg-label">Betrag (CHF)</span>';
echo '<input class="sg-interaction-element sg-input-single-line sg-input-single-line--inactive-selectable sg-form-inactive-selectable" type="number" id="otc-payment-amount-1" min="0.01" step="0.01" value="0.00" data-otc-payment-amount>';
echo '</label>';
echo '</div>';
echo '</div>';
echo '<div class="sg-form-sections-card__actions-segment">';
@@ -476,44 +643,63 @@ function render_auth_home_page(array $user, array $otcProducts = [], array $best
echo '<section class="sg-form-sections-card__chapter" aria-labelledby="otc-billing-title">';
echo '<h2 id="otc-billing-title" class="sg-strong sg-form-sections-card__chapter-title">Rechnungsadresse</h2>';
echo '<div class="sg-form-sections-card__field-group">';
echo '<label class="sg-label" for="otc-first-name">Vorname</label>';
echo '<input class="sg-interaction-element sg-input-single-line" type="text" id="otc-first-name" value="Fabienne" data-otc-order-first-name>';
echo '<label class="sg-labeled-input-row"><span class="sg-label">Vorname</span>';
echo '<input class="sg-interaction-element sg-input-single-line sg-input-single-line--inactive-selectable sg-form-inactive-selectable" type="text" id="otc-first-name" value="Fabienne" data-otc-order-first-name>';
echo '</label>';
echo '</div>';
echo '<div class="sg-form-sections-card__field-group">';
echo '<label class="sg-label" for="otc-last-name">Nachname</label>';
echo '<input class="sg-interaction-element sg-input-single-line" type="text" id="otc-last-name" value="Föhn" data-otc-order-last-name>';
echo '<label class="sg-labeled-input-row"><span class="sg-label">Nachname</span>';
echo '<input class="sg-interaction-element sg-input-single-line sg-input-single-line--inactive-selectable sg-form-inactive-selectable" type="text" id="otc-last-name" value="Föhn" data-otc-order-last-name>';
echo '</label>';
echo '</div>';
echo '<div class="sg-form-sections-card__field-group">';
echo '<label class="sg-label" for="otc-street">Strasse</label>';
echo '<input class="sg-interaction-element sg-input-single-line" type="text" id="otc-street" value="Im Hochrain" data-otc-order-street>';
echo '<label class="sg-labeled-input-row"><span class="sg-label">Strasse</span>';
echo '<input class="sg-interaction-element sg-input-single-line sg-input-single-line--inactive-selectable sg-form-inactive-selectable" type="text" id="otc-street" value="Im Hochrain" data-otc-order-street>';
echo '</label>';
echo '</div>';
echo '<div class="sg-form-sections-card__field-group">';
echo '<label class="sg-label" for="otc-house-number">Hausnummer</label>';
echo '<input class="sg-interaction-element sg-input-single-line" type="text" id="otc-house-number" value="2" data-otc-order-house-number>';
echo '<label class="sg-labeled-input-row"><span class="sg-label">Hausnummer</span>';
echo '<input class="sg-interaction-element sg-input-single-line sg-input-single-line--inactive-selectable sg-form-inactive-selectable" type="text" id="otc-house-number" value="2" data-otc-order-house-number>';
echo '</label>';
echo '</div>';
echo '<div class="sg-form-sections-card__field-group">';
echo '<label class="sg-label" for="otc-zip">PLZ</label>';
echo '<input class="sg-interaction-element sg-input-single-line" type="text" id="otc-zip" value="8102" data-otc-order-zip>';
echo '<label class="sg-labeled-input-row"><span class="sg-label">PLZ</span>';
echo '<input class="sg-interaction-element sg-input-single-line sg-input-single-line--inactive-selectable sg-form-inactive-selectable" type="text" id="otc-zip" value="8102" data-otc-order-zip>';
echo '</label>';
echo '</div>';
echo '<div class="sg-form-sections-card__field-group">';
echo '<label class="sg-label" for="otc-city">Ort</label>';
echo '<input class="sg-interaction-element sg-input-single-line" type="text" id="otc-city" value="Oberengstringen" data-otc-order-city>';
echo '<label class="sg-labeled-input-row"><span class="sg-label">Ort</span>';
echo '<input class="sg-interaction-element sg-input-single-line sg-input-single-line--inactive-selectable sg-form-inactive-selectable" type="text" id="otc-city" value="Oberengstringen" data-otc-order-city>';
echo '</label>';
echo '</div>';
echo '<div class="sg-form-sections-card__field-group">';
echo '<div class="sg-body sg-otc-order-form__status hidden" data-otc-order-success>Bestellung erfolgreich erfasst! Die Bestellnummer wird automatisch generiert.</div>';
echo '<div class="sg-body sg-otc-order-form__status sg-otc-order-form__status--error hidden" data-otc-order-error></div>';
echo '</div>';
echo '</div>';
echo '<footer class="sg-form-sections-card__actions-segment" data-pattern-part="form-actions-segment">';
echo '<div class="sg-form-sections-card__actions" data-pattern-part="form-actions">';
echo '<button class="sg-interaction-element sg-button sg-button--active sg-form-sections-card__action" type="button" data-otc-order-close>Abbrechen</button>';
echo '<button class="sg-interaction-element sg-button sg-button--process sg-button--process-inactive sg-form-sections-card__action" type="submit" disabled aria-disabled="true" data-otc-order-submit>Verkaufen</button>';
echo '</div>';
echo '</footer>';
echo '</form>';
echo '</div>';
echo '</div>';
echo '<footer class="sg-card-segment sg-card-segment--body sg-scrollable-card__actions-segment" data-pattern-part="scrollable-card-actions">';
echo '<div class="sg-scrollable-card__actions">';
echo '<button class="sg-interaction-element sg-button sg-button--active" type="button" form="otc-order-form" data-otc-order-close>Abbrechen</button>';
echo '<button class="sg-interaction-element sg-button sg-button--process sg-button--process-inactive" type="submit" form="otc-order-form" disabled aria-disabled="true" data-otc-order-submit>Weiter</button>';
echo '</div>';
echo '</footer>';
echo '</article>';
echo '<article class="sg-card sg-card--overlay-host sg-delete-confirmation-pattern__floating-card" aria-label="Verkaufsbestätigung" role="dialog" aria-modal="true" aria-labelledby="otc-confirmation-title" data-otc-confirmation-card hidden>';
echo '<div class="sg-card-segment sg-card-segment--body sg-delete-confirmation-pattern__body">';
echo '<p class="sg-body sg-delete-confirmation-pattern__text" id="otc-confirmation-title"><strong>Verkauf bestätigen</strong></p>';
echo '<div class="sg-delete-confirmation-pattern__list" data-otc-confirmation-products aria-label="Produkte"></div>';
echo '<p class="sg-body sg-delete-confirmation-pattern__text"><strong>Gesamtzahl Flaschen: <span data-otc-confirmation-quantity>0</span></strong></p>';
echo '<p class="sg-body sg-delete-confirmation-pattern__text"><strong>Gesamtbetrag: CHF <span data-otc-confirmation-total>0.00</span></strong></p>';
echo '<div class="sg-delete-confirmation-pattern__actions">';
echo '<button class="sg-interaction-element sg-button sg-button--active" type="button" data-otc-confirmation-cancel>Abbrechen</button>';
echo '<button class="sg-interaction-element sg-button sg-button--process" type="button" data-otc-confirmation-submit>Verkaufen</button>';
echo '</div>';
echo '</div>';
echo '</article>';
echo '</div>';
echo '</section>';
echo '<script src="/scripts/help-icon-overlays.js"></script>';
echo '<script>';
@@ -536,7 +722,7 @@ function render_auth_home_page(array $user, array $otcProducts = [], array $best
echo " if (contentTitle) { contentTitle.textContent = activeEntry; }";
echo " if (contentBody) { contentBody.innerHTML = portalModuleContentCards[activeEntry] || ''; }";
echo " if (activeEntry === 'Bestellungen') { initBestellungenBindings(); }";
echo " if (activeEntry === 'Lager') { initLagerBindings(); }";
echo " if (activeEntry === 'Lager' || activeEntry === 'Artikel') { initLagerBindings(); }";
echo " menu.innerHTML = '';";
echo " entries.forEach((entry, index) => {";
echo " const button = document.createElement('button');";
@@ -557,7 +743,7 @@ function render_auth_home_page(array $user, array $otcProducts = [], array $best
echo " if (contentTitle) { contentTitle.textContent = entryName; }";
echo " if (contentBody) { contentBody.innerHTML = portalModuleContentCards[entryName] || ''; }";
echo " if (entryName === 'Bestellungen') { initBestellungenBindings(); }";
echo " if (entryName === 'Lager') { initLagerBindings(); }";
echo " if (entryName === 'Lager' || entryName === 'Artikel') { initLagerBindings(); }";
echo " });";
echo " });";
echo "};";
@@ -636,26 +822,85 @@ function render_auth_home_page(array $user, array $otcProducts = [], array $best
echo " mediaQuery.addEventListener('change', syncMode);";
echo "})();";
echo "let bestellungenTableHandlersInstalled = false;";
echo "let bestellungenRealtimeSource = null;";
echo "let lagerBindingsInstalled = false;";
echo "function initLagerBindings() {";
echo " if (lagerBindingsInstalled) { return; }";
echo " const contentRoot = document.querySelector('[data-left-navigation-content-body]');";
echo " if (!contentRoot || !contentRoot.querySelector('[data-lager-content]')) { return; }";
echo " lagerBindingsInstalled = true;";
echo " const getDrawer = () => contentRoot.querySelector('[data-lager-drawer]');";
echo " let lagerMobileDetailHost = null;";
echo " let lagerMobileDetailOpen = false;";
echo " const renderLagerMobileDetail = (sourceDrawer, trigger) => { const type = trigger.dataset.lagerDrawerType || 'Lager'; const name = trigger.dataset.lagerDrawerName || ''; const value = trigger.dataset.lagerDrawerValue || ''; sourceDrawer.querySelector('[data-lager-drawer-title]').textContent = type + ': ' + name; sourceDrawer.querySelector('[data-lager-drawer-name-value]').textContent = name; sourceDrawer.querySelector('[data-lager-drawer-value-label]').textContent = type === 'Produkt' ? 'Anzahl im Lager:' : 'Artikelnummer:'; sourceDrawer.querySelector('[data-lager-drawer-value-value]').textContent = value; sourceDrawer.querySelectorAll('[data-lager-drawer-topic]').forEach((card) => { card.hidden = type !== 'Produkt'; }); const newLotForm = sourceDrawer.querySelector('[data-lager-drawer-topic=\"new-lot-form\"]'); if (newLotForm) { newLotForm.hidden = true; } const newLotAction = sourceDrawer.querySelector('[data-lager-drawer-topic=\"new-lot-action\"]'); if (newLotAction) { newLotAction.hidden = false; } if (type === 'Produkt') { const warning = trigger.dataset.lagerDrawerForecastWarning === 'true' || trigger.dataset.lagerDrawerForecastMhdWarning === 'true'; const warningValue = sourceDrawer.querySelector('[data-lager-drawer-warning-value]'); if (warningValue) { warningValue.textContent = warning ? (trigger.dataset.lagerDrawerForecast || 'Keine Prognose') : 'Keine akute Warnung.'; } const warningTitle = sourceDrawer.querySelector('[data-lager-drawer-warning-title]'); if (warningTitle) { warningTitle.textContent = getLagerWarningTitle(trigger); } sourceDrawer.querySelector('[data-lager-drawer-lot-number]').textContent = trigger.dataset.lagerDrawerLotNumber || 'Nicht hinterlegt'; sourceDrawer.querySelector('[data-lager-drawer-current-qty]').textContent = trigger.dataset.lagerDrawerCurrentQty || '0'; sourceDrawer.querySelector('[data-lager-drawer-qty-in]').textContent = trigger.dataset.lagerDrawerQtyIn || '0'; sourceDrawer.querySelector('[data-lager-drawer-manufacturer-name]').textContent = trigger.dataset.lagerDrawerManufacturerName || 'Nicht hinterlegt'; sourceDrawer.querySelector('[data-lager-drawer-manufacturer-lot-number]').textContent = trigger.dataset.lagerDrawerManufacturerLotNumber || 'Nicht hinterlegt'; sourceDrawer.querySelector('[data-lager-drawer-mfg-date]').textContent = trigger.dataset.lagerDrawerMfgDate || 'Nicht hinterlegt'; sourceDrawer.querySelector('[data-lager-drawer-expiry-date]').textContent = trigger.dataset.lagerDrawerExpiryDate || 'Nicht hinterlegt'; sourceDrawer.querySelector('[data-lager-drawer-purchase-date]').textContent = trigger.dataset.lagerDrawerPurchaseDate || 'Nicht hinterlegt'; const productId = trigger.closest('[data-lager-row]')?.dataset.lagerId || ''; sourceDrawer.querySelectorAll('[data-lager-historical-product]').forEach((product) => { product.hidden = product.dataset.lagerHistoricalProduct !== productId; }); } const host = document.createElement('div'); host.className = 'sg-lager-mobile-detail-host'; host.dataset.lagerMobileDetailHost = 'true'; host.innerHTML = '<div class=\"sg-navigation-card-layout sg-mobile-detail-back-card\"><div class=\"sg-navigation-card-block\"><article class=\"sg-card\" data-component=\"card\" data-pattern=\"navigation-card\" aria-label=\"Navigation Detailansicht zurück oben\"><div class=\"sg-card-segment sg-card-segment--body\" data-component-part=\"card-body\" data-pattern-part=\"navigation-card-segment\"><div class=\"sg-navigation-card-center\"><button class=\"sg-interaction-element sg-hyperlink\" type=\"button\" data-lager-detail-back>zurück</button></div></div></article></div></div>'; const mobileDrawer = sourceDrawer; mobileDrawer.dataset.lagerMobileDrawer = 'true'; mobileDrawer.dataset.open = 'true'; mobileDrawer.setAttribute('aria-hidden', 'false'); host.appendChild(sourceDrawer); return host; };";
echo " const restoreLagerMobileDetail = () => {";
echo " if (lagerMobileDetailHost && lagerMobileDetailHost.parentNode) { const mobileDrawer = lagerMobileDetailHost.querySelector('[data-lager-mobile-drawer]'); if (mobileDrawer) { contentRoot.appendChild(mobileDrawer); mobileDrawer.removeAttribute('data-lager-mobile-drawer'); mobileDrawer.dataset.open = 'false'; mobileDrawer.setAttribute('aria-hidden', 'true'); } lagerMobileDetailHost.parentNode.removeChild(lagerMobileDetailHost); }";
echo " lagerMobileDetailHost = null; lagerMobileDetailOpen = false; document.body.classList.remove('sg-lager-mobile-detail-open');";
echo " };";
echo " const openLagerMobileDetail = (trigger) => {";
echo " restoreLagerMobileDetail(); const sourceDrawer = contentRoot.querySelector('[data-lager-drawer]'); lagerMobileDetailHost = sourceDrawer ? renderLagerMobileDetail(sourceDrawer, trigger) : null; const mobileDrawer = lagerMobileDetailHost?.querySelector('[data-lager-mobile-drawer]'); if (mobileDrawer) { const virtualLot = trigger.dataset.lagerDrawerVirtualLotNumber || ''; const virtualLotValue = mobileDrawer.querySelector('[data-lager-drawer-virtual-lot-value]'); if (virtualLotValue) { virtualLotValue.textContent = virtualLot ? 'Virtuelle Charge: ' + virtualLot : 'Keine virtuelle Fehlbestandscharge vorhanden.'; } }";
echo " if (lagerMobileDetailHost) { document.body.appendChild(lagerMobileDetailHost); document.body.classList.add('sg-lager-mobile-detail-open'); }";
echo " lagerMobileDetailOpen = true; window.history.pushState({ ...(window.history.state || {}), lagerView: 'detail' }, '', window.location.href);";
echo " };";
echo " const closeLagerMobileDetail = () => {";
echo " if (!lagerMobileDetailOpen) { return; }";
echo " if (window.history.state && window.history.state.lagerView === 'detail') { window.history.back(); return; }";
echo " restoreLagerMobileDetail();";
echo " };";
echo " const getDrawer = () => document.querySelector('[data-lager-mobile-drawer]') || contentRoot.querySelector('[data-lager-drawer]');";
echo " const setDrawerState = (open) => {";
echo " const drawer = getDrawer(); const backdrop = contentRoot.querySelector('[data-lager-drawer-backdrop]');";
echo " if (!drawer || !backdrop) { return; }";
echo " drawer.dataset.open = String(open); drawer.setAttribute('aria-hidden', String(!open)); backdrop.dataset.open = String(open); backdrop.setAttribute('aria-hidden', String(!open)); document.body.classList.toggle('sg-card-list-page-drawer-open', open);";
echo " };";
echo " const getLagerWarningTitle = (trigger) => trigger.dataset.lagerDrawerForecastStatus === 'depleted' ? 'Warnung: Charge ist aufgebraucht' : (trigger.dataset.lagerDrawerForecastMhdWarning === 'true' ? 'Warnung: MHD wird vor Chargenende erreicht' : (trigger.dataset.lagerDrawerForecastWarning === 'true' ? 'Warnung: Charge wird innerhalb von drei Monaten aufgebraucht sein' : 'Keine Warnung'));";
echo " const escapeLagerHtml = (value) => String(value ?? '').replace(/[&<>\"']/g, (character) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '\"': '&quot;', \"'\": '&#39;' }[character] || character));";
echo " const renderLagerDocumentRows = (documents, editable) => { if (!documents.length) { return '<p class=\"sg-body\">Keine Dokumente vorhanden.</p>'; } return documents.map((document) => { const id = Number(document.id || 0); const filename = escapeLagerHtml(document.original_filename || 'Dokument'); if (!editable) { return '<p class=\"sg-body\" data-lager-document-row=\"' + id + '\"><a class=\"sg-hyperlink\" href=\"/api/lager-document.php?id=' + id + '\" data-component=\"hyperlink\">' + filename + '</a></p>'; } return '<div class=\"sg-form-sections-card__field-group\" data-lager-document-edit-row=\"' + id + '\"><span class=\"sg-body\">' + filename + ' <span class=\"sg-tab-button-group\" role=\"tablist\" aria-label=\"Dokument entfernen\" data-component=\"tab-navigation\" data-component-size=\"compact\"><button class=\"sg-interaction-element sg-button sg-tab-button sg-tab-button--compact\" type=\"button\" role=\"tab\" aria-selected=\"false\" data-component-part=\"tab-button\" aria-label=\"Dokument entfernen: ' + filename + '\" data-lager-document-delete=\"' + id + '\">×</button></span></span></div>'; }).join(''); };";
echo " const syncLagerDocumentViews = (root, documents) => { const read = root?.querySelector('[data-lager-documents-read]'); const edit = root?.querySelector('[data-lager-documents-edit]'); const upload = root?.querySelector('[data-lager-document-upload-form]'); if (read) { read.innerHTML = renderLagerDocumentRows(documents, false); } if (edit) { edit.innerHTML = renderLagerDocumentRows(documents, true); if (upload) { edit.appendChild(upload); } } };";
echo " const syncLagerCurrentDocuments = (trigger) => { const drawer = getDrawer(); if (!drawer) { return; } let documents = []; try { documents = JSON.parse(trigger.dataset.lagerDrawerDocuments || '[]'); } catch (error) { documents = []; } syncLagerDocumentViews(drawer.querySelector('[data-lager-documents=\"current-read\"]'), documents); syncLagerDocumentViews(drawer.querySelector('[data-lager-documents=\"current-edit\"]'), documents); };";
echo " const renderLagerSelectedFiles = (scope) => { const input = scope?.querySelector('[data-lager-document-file]'); const list = scope?.querySelector('[data-lager-document-selected-files]'); const status = scope?.querySelector('[data-lager-document-status]'); if (!input || !list) { return; } const files = Array.from(input.files || []); list.replaceChildren(...files.map((file, index) => { const item = document.createElement('li'); item.className = 'sg-file-upload__file'; const name = document.createElement('span'); name.className = 'sg-file-upload__file-name sg-body'; name.append(document.createTextNode(file.name + ' ')); const navigation = document.createElement('span'); navigation.className = 'sg-tab-button-group'; navigation.setAttribute('role', 'tablist'); navigation.setAttribute('aria-label', 'Datei entfernen'); navigation.dataset.component = 'tab-navigation'; navigation.dataset.componentSize = 'compact'; const remove = document.createElement('button'); remove.className = 'sg-interaction-element sg-button sg-tab-button sg-tab-button--compact sg-file-upload__remove'; remove.type = 'button'; remove.setAttribute('role', 'tab'); remove.setAttribute('aria-selected', 'false'); remove.setAttribute('data-component-part', 'tab-button'); remove.setAttribute('aria-label', 'Datei entfernen: ' + file.name); remove.dataset.lagerDocumentRemoveIndex = String(index); remove.textContent = '×'; navigation.append(remove); name.append(navigation); item.append(name); return item; })); list.hidden = files.length === 0; if (status && !status.textContent.includes('Upload') && !status.textContent.includes('hochgeladen')) { status.textContent = files.length ? files.length + ' Datei(en) ausgewählt.' : 'Noch keine Dateien ausgewählt.'; } };";
echo " const closeDrawer = () => { setDrawerState(false); contentRoot.querySelectorAll('[data-lager-drawer-open]').forEach((trigger) => trigger.setAttribute('aria-expanded', 'false')); };";
echo " contentRoot.addEventListener('click', (event) => {";
echo " document.addEventListener('click', (event) => { const trigger = event.target.closest('[data-lager-drawer-open]'); if (!trigger || trigger.dataset.lagerDrawerType !== 'Produkt') { return; } const drawer = getDrawer(); const forecast = drawer?.querySelector('[data-lager-drawer-forecast-value]'); if (forecast) { forecast.textContent = trigger.dataset.lagerDrawerForecast || 'Keine Prognose'; } syncLagerCurrentDocuments(trigger); });";
echo " const getLagerForecastWarningText = (forecast, trigger, text) => { if (forecast?.status === 'depleted' || trigger?.dataset.lagerDrawerForecastStatus === 'depleted') { return 'Die Charge ist aufgebraucht. Voraussichtlich leer: ' + text + '.'; } if (trigger?.dataset.lagerDrawerForecastMhdWarning === 'true') { const expiry = trigger.dataset.lagerDrawerExpiryDate ? new Date(trigger.dataset.lagerDrawerExpiryDate + 'T00:00:00').toLocaleDateString('de-CH') : 'dem MHD'; return 'Das MHD wird am ' + expiry + ' erreicht, bevor das Lager leer ist. Voraussichtlich leer: ' + text + '.'; } if (trigger?.dataset.lagerDrawerForecastWarning === 'true') { return 'Voraussichtlich leer: ' + text + '.'; } return 'Keine akute Warnung.'; };";
echo " document.addEventListener('click', (event) => { const trigger = event.target.closest('[data-lager-drawer-open]'); if (!trigger || trigger.dataset.lagerDrawerType !== 'Produkt') { return; } const drawer = getDrawer(); const warningValue = drawer?.querySelector('[data-lager-drawer-warning-value]'); if (warningValue) { warningValue.textContent = getLagerForecastWarningText(null, trigger, trigger.dataset.lagerDrawerForecast || 'Keine Prognose'); } });";
echo " const syncLagerForecast = (forecast, productId) => { const drawer = getDrawer(); const trigger = contentRoot.querySelector('[data-lager-drawer-open][aria-expanded=\"true\"]') || contentRoot.querySelector('[data-lager-row][data-lager-id=\"' + (productId || '') + '\"] [data-lager-drawer-open]'); if (!forecast || !drawer) { return; } const forecastDate = forecast.date ? new Date(forecast.date + 'T00:00:00') : null; const expiryDate = forecast.expiryDate ? new Date(forecast.expiryDate + 'T00:00:00') : null; const mhdWarning = Boolean(forecastDate && expiryDate && forecastDate > expiryDate); const nearExpiryWarning = Boolean(forecastDate && expiryDate && forecastDate >= new Date(new Date(expiryDate).setMonth(expiryDate.getMonth() - 3)) && forecastDate <= expiryDate); const depleted = forecast.status === 'depleted'; const forecastWarning = depleted || nearExpiryWarning; const date = forecastDate ? forecastDate.toLocaleDateString('de-CH') : 'Keine Prognose'; const text = depleted ? date + ' (CHARGE ENDE)' : (nearExpiryWarning || mhdWarning ? date + ' (MHD: ' + expiryDate.toLocaleDateString('de-CH') + ')' : date); const value = drawer.querySelector('[data-lager-drawer-forecast-value]'); if (value) { value.textContent = text; } const header = drawer.querySelector('[data-lager-drawer-warning-header]'); const title = drawer.querySelector('[data-lager-drawer-warning-title]'); const warningValue = drawer.querySelector('[data-lager-drawer-warning-value]'); if (trigger) { trigger.dataset.lagerDrawerForecast = text; trigger.dataset.lagerDrawerForecastStatus = forecast.status || 'no_demand'; trigger.dataset.lagerDrawerForecastWarning = forecastWarning ? 'true' : 'false'; trigger.dataset.lagerDrawerForecastMhdWarning = mhdWarning ? 'true' : 'false'; if (header) { header.classList.toggle('sg-card-segment--darkgreen', !forecastWarning && !mhdWarning); header.classList.toggle('sg-card-segment--signal-red', forecastWarning); header.classList.toggle('sg-card-segment--signal-yellow', mhdWarning); } if (title) { title.textContent = getLagerWarningTitle(trigger); } if (warningValue) { warningValue.textContent = forecastWarning || mhdWarning ? text : 'Keine akute Warnung.'; warningValue.classList.toggle('sg-strong', forecastWarning || mhdWarning); warningValue.classList.toggle('lager-forecast-warning', forecastWarning); warningValue.classList.toggle('lager-forecast-mhd-warning', mhdWarning); } const row = trigger.closest('[data-lager-row]'); const cells = row?.querySelectorAll('[role=\"cell\"]'); const forecastCell = cells?.[cells.length - 1]; if (forecastCell && cells.length === 3) { forecastCell.textContent = text; } } };";
echo " document.addEventListener('lager-save-complete', (event) => { const forecast = event.detail?.forecast; const productId = event.detail?.productId || ''; const trigger = contentRoot.querySelector('[data-lager-drawer-open][aria-expanded=\"true\"]') || contentRoot.querySelector('[data-lager-row][data-lager-id=\"' + productId + '\"] [data-lager-drawer-open]'); const drawer = getDrawer(); if (!forecast || !drawer) { return; } const date = forecast.date ? new Date(forecast.date + 'T00:00:00').toLocaleDateString('de-CH') : 'Keine Prognose'; const text = forecast.status === 'depleted' ? date + ' (CHARGE ENDE)' : date; const value = drawer.querySelector('[data-lager-drawer-forecast-value]'); if (value) { value.textContent = text; } if (trigger) { trigger.dataset.lagerDrawerForecast = text; const row = trigger.closest('[data-lager-row]'); const cells = row?.querySelectorAll('[role=\"cell\"]'); const forecastCell = cells?.[cells.length - 1]; if (forecastCell && cells.length === 3) { forecastCell.textContent = text; } } });";
echo " document.addEventListener('click', (event) => {";
echo " const trigger = event.target.closest('[data-lager-drawer-open]');";
echo " if (trigger) { event.preventDefault(); const drawer = getDrawer(); if (!drawer) { return; } const type = trigger.dataset.lagerDrawerType || 'Lager'; const name = trigger.dataset.lagerDrawerName || ''; const value = trigger.dataset.lagerDrawerValue || ''; drawer.querySelector('[data-lager-drawer-title]').textContent = type + ': ' + name; drawer.querySelector('[data-lager-drawer-name-value]').textContent = name; const valueRow = drawer.querySelector('[data-lager-drawer-value-row]'); if (valueRow) { valueRow.hidden = false; } drawer.querySelector('[data-lager-drawer-value-label]').textContent = type === 'Produkt' ? 'Anzahl im Lager:' : 'Artikelnummer:'; drawer.querySelector('[data-lager-drawer-value-value]').textContent = value; drawer.querySelectorAll('[data-lager-drawer-topic]').forEach((card) => { card.hidden = type !== 'Produkt'; }); if (type === 'Produkt') { const forecast = trigger.dataset.lagerDrawerForecast || 'Keine Prognose'; const warning = trigger.dataset.lagerDrawerForecastWarning === 'true' || trigger.dataset.lagerDrawerForecastMhdWarning === 'true'; const warningValue = drawer.querySelector('[data-lager-drawer-warning-value]'); if (warningValue) { warningValue.textContent = warning ? forecast : 'Keine akute Warnung.'; warningValue.classList.toggle('sg-strong', warning); warningValue.classList.toggle('lager-forecast-warning', trigger.dataset.lagerDrawerForecastWarning === 'true'); warningValue.classList.toggle('lager-forecast-mhd-warning', trigger.dataset.lagerDrawerForecastMhdWarning === 'true'); } drawer.querySelector('[data-lager-drawer-lot-number]').textContent = trigger.dataset.lagerDrawerLotNumber || 'Nicht hinterlegt'; drawer.querySelector('[data-lager-drawer-supplier-lot-number]').textContent = trigger.dataset.lagerDrawerSupplierLotNumber || 'Nicht hinterlegt'; drawer.querySelector('[data-lager-drawer-expiry-date]').textContent = trigger.dataset.lagerDrawerExpiryDate || 'Nicht hinterlegt'; const virtualLot = trigger.dataset.lagerDrawerVirtualLotNumber || ''; drawer.querySelector('[data-lager-drawer-virtual-lot-value]').textContent = virtualLot ? 'Virtuelle Charge: ' + virtualLot : 'Keine virtuelle Fehlbestandscharge vorhanden.'; } setDrawerState(true); contentRoot.querySelectorAll('[data-lager-drawer-open]').forEach((button) => button.setAttribute('aria-expanded', String(button === trigger))); return; }";
echo " if (trigger) { event.preventDefault(); if (window.matchMedia('(max-width: 767px)').matches) { openLagerMobileDetail(trigger); return; } const drawer = getDrawer(); if (!drawer) { return; } const type = trigger.dataset.lagerDrawerType || 'Lager'; const name = trigger.dataset.lagerDrawerName || ''; const value = trigger.dataset.lagerDrawerValue || ''; drawer.querySelector('[data-lager-drawer-title]').textContent = type + ': ' + name; drawer.querySelector('[data-lager-drawer-name-value]').textContent = name; const valueRow = drawer.querySelector('[data-lager-drawer-value-row]'); if (valueRow) { valueRow.hidden = false; } drawer.querySelector('[data-lager-drawer-value-label]').textContent = type === 'Produkt' ? 'Anzahl im Lager:' : 'Artikelnummer:'; drawer.querySelector('[data-lager-drawer-value-value]').textContent = value; drawer.querySelectorAll('[data-lager-drawer-topic]').forEach((card) => { card.hidden = type !== 'Produkt'; }); if (type === 'Produkt') { const forecast = trigger.dataset.lagerDrawerForecast || 'Keine Prognose'; const warning = trigger.dataset.lagerDrawerForecastWarning === 'true' || trigger.dataset.lagerDrawerForecastMhdWarning === 'true'; const warningValue = drawer.querySelector('[data-lager-drawer-warning-value]'); if (warningValue) { warningValue.textContent = warning ? forecast : 'Keine akute Warnung.'; warningValue.classList.toggle('sg-strong', warning); warningValue.classList.toggle('lager-forecast-warning', trigger.dataset.lagerDrawerForecastWarning === 'true'); warningValue.classList.toggle('lager-forecast-mhd-warning', trigger.dataset.lagerDrawerForecastMhdWarning === 'true'); } drawer.querySelector('[data-lager-drawer-lot-number]').textContent = trigger.dataset.lagerDrawerLotNumber || 'Nicht hinterlegt'; drawer.querySelector('[data-lager-drawer-manufacturer-name]').textContent = trigger.dataset.lagerDrawerManufacturerName || 'Nicht hinterlegt'; drawer.querySelector('[data-lager-drawer-manufacturer-lot-number]').textContent = trigger.dataset.lagerDrawerManufacturerLotNumber || 'Nicht hinterlegt'; drawer.querySelector('[data-lager-drawer-mfg-date]').textContent = trigger.dataset.lagerDrawerMfgDate || 'Nicht hinterlegt'; drawer.querySelector('[data-lager-drawer-expiry-date]').textContent = trigger.dataset.lagerDrawerExpiryDate || 'Nicht hinterlegt'; drawer.querySelector('[data-lager-drawer-purchase-date]').textContent = trigger.dataset.lagerDrawerPurchaseDate || 'Nicht hinterlegt'; const virtualLot = trigger.dataset.lagerDrawerVirtualLotNumber || ''; drawer.querySelector('[data-lager-drawer-virtual-lot-value]').textContent = virtualLot ? 'Virtuelle Charge: ' + virtualLot : 'Keine virtuelle Fehlbestandscharge vorhanden.'; const manufacturer = drawer.querySelector('[data-lager-manufacturer]'); if (manufacturer) { manufacturer.value = trigger.dataset.lagerDrawerManufacturerPartyId || ''; } const manufacturerLot = drawer.querySelector('[data-lager-manufacturer-lot]'); if (manufacturerLot) { manufacturerLot.value = trigger.dataset.lagerDrawerManufacturerLotNumber || ''; } const mfgDate = drawer.querySelector('[data-lager-mfg-date]'); if (mfgDate) { mfgDate.value = trigger.dataset.lagerDrawerMfgDate || ''; } const expiryDate = drawer.querySelector('[data-lager-expiry-date]'); if (expiryDate) { expiryDate.value = trigger.dataset.lagerDrawerExpiryDate || ''; } const purchaseDate = drawer.querySelector('[data-lager-purchase-date]'); if (purchaseDate) { purchaseDate.value = trigger.dataset.lagerDrawerPurchaseDate || ''; } const editForm = drawer.querySelector('[data-lager-edit-form]'); if (editForm) { editForm.hidden = true; } drawer.dataset.lagerActiveLotId = trigger.dataset.lagerDrawerLotId || ''; } setDrawerState(true); contentRoot.querySelectorAll('[data-lager-drawer-open]').forEach((button) => button.setAttribute('aria-expanded', String(button === trigger))); return; }";
echo " document.addEventListener('click', (event) => { const trigger = event.target.closest('[data-lager-drawer-open]'); if (trigger?.dataset.lagerDrawerType === 'Produkt') { syncLagerCurrentDocuments(trigger); } });";
echo " if (event.target.closest('[data-lager-drawer-close]') || event.target.closest('[data-lager-drawer-backdrop]')) { closeDrawer(); }";
echo " });";
echo " document.addEventListener('keydown', (event) => { if (event.key === 'Escape') { closeDrawer(); } });";
echo " document.addEventListener('click', (event) => { const trigger = event.target.closest('[data-lager-drawer-open]'); if (!trigger || trigger.dataset.lagerDrawerType !== 'Produkt' || window.matchMedia('(max-width: 767px)').matches) { return; } const header = contentRoot.querySelector('[data-lager-drawer-warning-header]'); const title = contentRoot.querySelector('[data-lager-drawer-warning-title]'); if (!header || !title) { return; } const forecastWarning = trigger.dataset.lagerDrawerForecastWarning === 'true'; const mhdWarning = trigger.dataset.lagerDrawerForecastMhdWarning === 'true'; title.textContent = getLagerWarningTitle(trigger); header.classList.toggle('sg-card-segment--darkgreen', !forecastWarning && !mhdWarning); header.classList.toggle('sg-card-segment--signal-red', forecastWarning); header.classList.toggle('sg-card-segment--signal-yellow', mhdWarning); });";
echo " document.addEventListener('click', (event) => { const trigger = event.target.closest('[data-lager-drawer-open]'); if (!trigger || trigger.dataset.lagerDrawerType !== 'Produkt' || window.matchMedia('(max-width: 767px)').matches) { return; } setLagerEditMode(false); const drawer = getDrawer(); const currentQty = drawer?.querySelector('[data-lager-drawer-current-qty]'); const qtyIn = drawer?.querySelector('[data-lager-drawer-qty-in]'); const lotNumber = drawer?.querySelector('[data-lager-lot-number]'); if (currentQty) { currentQty.textContent = trigger.dataset.lagerDrawerCurrentQty || '0'; } if (qtyIn) { qtyIn.textContent = trigger.dataset.lagerDrawerQtyIn || '0'; } if (lotNumber) { lotNumber.value = trigger.dataset.lagerDrawerLotNumber || ''; } const productId = trigger.closest('[data-lager-row]')?.dataset.lagerId || ''; contentRoot.querySelectorAll('[data-lager-historical-product]').forEach((product) => { product.hidden = product.dataset.lagerHistoricalProduct !== productId; }); });";
echo " const postLagerChange = async (payload) => { const response = await fetch('/api/lager-charge.php', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ...payload, csrfToken: contentRoot.querySelector('[data-lager-csrf-token]')?.dataset.lagerCsrfToken || '' }) }); const result = await response.json(); if (!response.ok || result.ok !== true) { throw new Error(result.error || 'Speichern fehlgeschlagen'); } syncLagerForecast(result.forecast, result.productId); const trigger = contentRoot.querySelector('[data-lager-drawer-open][aria-expanded=\"true\"]') || contentRoot.querySelector('[data-lager-row][data-lager-id=\"' + (result.productId || '') + '\"] [data-lager-drawer-open]'); const drawer = getDrawer(); const warningValue = drawer?.querySelector('[data-lager-drawer-warning-value]'); if (warningValue) { warningValue.textContent = getLagerForecastWarningText(result.forecast, trigger, trigger?.dataset.lagerDrawerForecast || 'Keine Prognose'); } return result; };";
echo " document.addEventListener('click', (event) => { const remove = event.target.closest('[data-lager-document-remove-index]'); if (remove) { const scope = remove.closest('[data-lager-document-upload-form]'); const input = scope?.querySelector('[data-lager-document-file]'); const index = Number(remove.dataset.lagerDocumentRemoveIndex); if (input && Number.isInteger(index)) { const data = new DataTransfer(); Array.from(input.files || []).forEach((file, fileIndex) => { if (fileIndex !== index) { data.items.add(file); } }); input.files = data.files; input.dispatchEvent(new Event('change', { bubbles: true })); } return; } const trigger = event.target.closest('[data-lager-document-file-trigger]'); const input = trigger?.closest('[data-lager-document-upload-form]')?.querySelector('[data-lager-document-file]'); if (!input) { return; } event.preventDefault(); input.click(); });";
echo " document.addEventListener('dragover', (event) => { const dropzone = event.target.closest('[data-lager-document-upload-form] .sg-file-upload__dropzone'); if (!dropzone) { return; } event.preventDefault(); dropzone.dataset.dragOver = 'true'; });";
echo " document.addEventListener('dragleave', (event) => { const dropzone = event.target.closest('[data-lager-document-upload-form] .sg-file-upload__dropzone'); if (dropzone && !dropzone.contains(event.relatedTarget)) { delete dropzone.dataset.dragOver; } });";
echo " document.addEventListener('drop', (event) => { const dropzone = event.target.closest('[data-lager-document-upload-form] .sg-file-upload__dropzone'); if (!dropzone) { return; } event.preventDefault(); delete dropzone.dataset.dragOver; const input = dropzone.closest('[data-lager-document-upload-form]')?.querySelector('[data-lager-document-file]'); if (input && event.dataTransfer?.files) { input.files = event.dataTransfer.files; input.dispatchEvent(new Event('change', { bubbles: true })); } });";
echo " document.addEventListener('change', (event) => { const input = event.target.closest('[data-lager-document-file]'); if (!input) { return; } const form = input.closest('[data-lager-document-upload-form]'); renderLagerSelectedFiles(form); });";
echo " const uploadLagerDocuments = async (scope, lotId) => { const files = Array.from(scope?.querySelectorAll('[data-lager-document-file]') || []).flatMap((input) => Array.from(input.files || [])); if (!files.length) { return []; } const status = scope.querySelector('[data-lager-document-status]'); const uploaded = []; const errorMessage = (error) => error instanceof Error ? error.message : 'Upload fehlgeschlagen'; if (files.some((file) => file.size > 52428800)) { const error = new Error('Dokument darf maximal 50 MB groß sein.'); if (status) { status.textContent = error.message; } throw error; } if (status) { status.textContent = 'Upload läuft …'; } try { for (const file of files) { const data = new FormData(); data.append('action', 'upload_document'); data.append('csrfToken', contentRoot.querySelector('[data-lager-csrf-token]')?.dataset.lagerCsrfToken || ''); data.append('lotId', lotId || ''); data.append('document', file); const response = await fetch('/api/lager-charge.php', { method: 'POST', body: data }); const result = await response.json(); if (!response.ok || result.ok !== true) { throw new Error(result.error || 'Upload fehlgeschlagen'); } uploaded.push(result.document); } } catch (error) { if (status) { status.textContent = errorMessage(error); } throw error; } if (status) { status.textContent = files.length + ' Datei(en) hochgeladen.'; } return uploaded; };";
echo " document.addEventListener('click', async (event) => { const remove = event.target.closest('[data-lager-document-delete]'); if (!remove) { return; } remove.disabled = true; try { await postLagerChange({ action: 'delete_document', documentId: remove.dataset.lagerDocumentDelete }); window.location.reload(); } catch (error) { remove.disabled = false; remove.setCustomValidity(error instanceof Error ? error.message : 'Dokument konnte nicht entfernt werden'); remove.reportValidity(); } });";
echo " document.addEventListener('click', (event) => { const editButton = event.target.closest('[data-lager-edit-open]'); if (editButton) { const drawer = getDrawer(); const editForm = drawer?.querySelector('[data-lager-edit-form]'); if (editForm) { editForm.hidden = false; } return; } const closeButton = event.target.closest('[data-lager-edit-close]'); if (closeButton) { const drawer = getDrawer(); const editForm = drawer?.querySelector('[data-lager-edit-form]'); if (editForm) { editForm.hidden = true; } } });";
echo " const setLagerEditMode = (editing) => { const drawer = getDrawer(); const readView = drawer?.querySelector('[data-lager-read-view]'); const readDocuments = drawer?.querySelector('[data-lager-documents=\"current-read\"]'); const editForm = drawer?.querySelector('[data-lager-edit-form]'); const documents = drawer?.querySelector('[data-lager-edit-documents=\"current-edit\"]'); const actions = drawer?.querySelector('[data-lager-edit-actions]'); if (readView) { readView.hidden = editing; } if (readDocuments) { readDocuments.hidden = editing; } if (editForm) { editForm.hidden = !editing; } if (documents) { documents.hidden = !editing; } if (actions) { actions.hidden = !editing; } };";
echo " document.addEventListener('click', (event) => { if (event.target.closest('[data-lager-edit-open]')) { setLagerEditMode(true); } if (event.target.closest('[data-lager-edit-close]')) { clearLagerMovementInputs(); setLagerEditMode(false); } });";
echo " const syncLagerReadView = () => { const drawer = getDrawer(); if (!drawer) { return; } const manufacturer = drawer.querySelector('[data-lager-manufacturer]'); const values = { '[data-lager-drawer-manufacturer-name]': manufacturer?.selectedOptions?.[0]?.textContent || 'Nicht hinterlegt', '[data-lager-drawer-manufacturer-lot-number]': drawer.querySelector('[data-lager-manufacturer-lot]')?.value || 'Nicht hinterlegt', '[data-lager-drawer-mfg-date]': drawer.querySelector('[data-lager-mfg-date]')?.value || 'Nicht hinterlegt', '[data-lager-drawer-expiry-date]': drawer.querySelector('[data-lager-expiry-date]')?.value || 'Nicht hinterlegt', '[data-lager-drawer-purchase-date]': drawer.querySelector('[data-lager-purchase-date]')?.value || 'Nicht hinterlegt' }; Object.entries(values).forEach(([selector, value]) => { const target = drawer.querySelector(selector); if (target) { target.textContent = value; } }); const trigger = contentRoot.querySelector('[data-lager-drawer-open][aria-expanded=\"true\"]'); if (trigger) { trigger.dataset.lagerDrawerManufacturerName = values['[data-lager-drawer-manufacturer-name]']; trigger.dataset.lagerDrawerManufacturerLotNumber = values['[data-lager-drawer-manufacturer-lot-number]']; trigger.dataset.lagerDrawerMfgDate = values['[data-lager-drawer-mfg-date]']; trigger.dataset.lagerDrawerExpiryDate = values['[data-lager-drawer-expiry-date]']; trigger.dataset.lagerDrawerPurchaseDate = values['[data-lager-drawer-purchase-date]']; } };";
echo " const syncLagerBalanceView = (balance) => { const drawer = getDrawer(); if (!drawer || !balance) { return; } const currentQty = drawer.querySelector('[data-lager-drawer-current-qty]'); const qtyIn = drawer.querySelector('[data-lager-drawer-qty-in]'); if (currentQty) { currentQty.textContent = String(balance.qty_net ?? 0); } if (qtyIn) { qtyIn.textContent = String(balance.qty_in ?? 0); } const trigger = contentRoot.querySelector('[data-lager-drawer-open][aria-expanded=\"true\"]'); if (trigger) { trigger.dataset.lagerDrawerCurrentQty = String(balance.qty_net ?? 0); trigger.dataset.lagerDrawerQtyIn = String(balance.qty_in ?? 0); } };";
echo " const clearLagerMovementInputs = () => { const drawer = getDrawer(); if (!drawer) { return; } drawer.querySelectorAll('[data-lager-receipt-qty], [data-lager-adjustment-qty], [data-lager-out-qty]').forEach((input) => { input.value = ''; }); };";
echo " const syncLagerOpenLotCard = (lot, balance) => { const drawer = getDrawer(); const card = drawer?.querySelector('[data-lager-open-card=\"' + (lot?.id || '') + '\"]'); if (!card) { return; } const values = { '[data-lager-open-card-lot-number]': lot?.lot_number || 'Nicht hinterlegt', '[data-lager-open-card-qty-net]': String(balance?.qty_net ?? 0), '[data-lager-open-card-qty-in]': String(balance?.qty_net ?? 0), '[data-lager-open-card-manufacturer]': drawer.querySelector('[data-lager-new-manufacturer]')?.selectedOptions?.[0]?.textContent || 'Nicht hinterlegt', '[data-lager-open-card-manufacturer-lot]': lot?.manufacturer_lot_number || 'Nicht hinterlegt', '[data-lager-open-card-mfg-date]': lot?.mfg_date || 'Nicht hinterlegt', '[data-lager-open-card-expiry-date]': lot?.expiry_date || 'Nicht hinterlegt', '[data-lager-open-card-purchase-date]': lot?.purchase_date || 'Nicht hinterlegt' }; Object.entries(values).forEach(([selector, value]) => { const target = card.querySelector(selector); if (target) { target.textContent = value; } }); const wrapper = card.closest('[data-lager-open-card-wrapper]'); wrapper?.querySelector('[data-lager-edit-documents]')?.setAttribute('hidden', ''); wrapper?.querySelector('[data-lager-open-edit-actions]')?.setAttribute('hidden', ''); wrapper?.querySelector('[data-lager-documents^=\"open-read-\"]')?.removeAttribute('hidden'); };";
echo " document.addEventListener('click', (event) => { const trigger = event.target.closest('[data-lager-drawer-open]'); if (!trigger || trigger.dataset.lagerDrawerType !== 'Produkt' || window.matchMedia('(max-width: 767px)').matches) { return; } const drawer = getDrawer(); const productId = trigger.closest('[data-lager-row]')?.dataset.lagerId || ''; const openProduct = drawer?.querySelector('[data-lager-open-product=\"' + productId + '\"]'); drawer?.querySelectorAll('[data-lager-open-product]').forEach((product) => { product.hidden = product !== openProduct; product.querySelectorAll('[data-lager-open-edit-form]').forEach((form) => { form.hidden = true; }); product.querySelectorAll('[data-lager-open-edit-actions]').forEach((actions) => { actions.hidden = true; }); product.querySelectorAll('[data-lager-open-card]').forEach((readView) => { readView.hidden = false; }); product.querySelectorAll('[data-lager-documents^=\"open-read-\"]').forEach((readDocuments) => { readDocuments.hidden = false; }); product.querySelectorAll('[data-lager-edit-documents]').forEach((documents) => { documents.hidden = true; }); }); const newForm = drawer?.querySelector('[data-lager-drawer-topic=\"new-lot-form\"]'); const newAction = drawer?.querySelector('[data-lager-drawer-topic=\"new-lot-action\"]'); if (newForm) { newForm.hidden = true; } if (newAction) { newAction.hidden = false; } const notificationWrapper = drawer?.querySelector('[data-lager-new-lot-notification-wrapper]'); const notification = drawer?.querySelector('[data-lager-new-lot-notification]'); const shortageQty = trigger.dataset.lagerDrawerVirtualShortageQty || '0'; const hasShortage = shortageQty !== '0' && shortageQty !== '0,00'; if (notificationWrapper) { notificationWrapper.hidden = !hasShortage; } if (notification && hasShortage) { notification.textContent = 'Für dieses Produkt bestehen virtuelle Fehlbestände in Höhe von ' + shortageQty + '. Die Eingangsmenge wird nach Aktivierung der Charge zur Nachallokation verwendet.'; } const setValue = (selector, value) => { const field = drawer?.querySelector(selector); if (field) { field.value = value || ''; } }; setValue('[data-lager-new-lot-number]', trigger.dataset.lagerOpenLotNumber); setValue('[data-lager-new-manufacturer]', trigger.dataset.lagerOpenManufacturerPartyId); setValue('[data-lager-new-manufacturer-lot]', trigger.dataset.lagerOpenManufacturerLotNumber); setValue('[data-lager-new-mfg-date]', trigger.dataset.lagerOpenMfgDate); setValue('[data-lager-new-expiry-date]', trigger.dataset.lagerOpenExpiryDate); setValue('[data-lager-new-purchase-date]', trigger.dataset.lagerOpenPurchaseDate); if (drawer) { drawer.dataset.lagerOpenLotId = trigger.dataset.lagerOpenLotId || ''; drawer.dataset.lagerProductId = productId; } });";
echo " document.addEventListener('click', (event) => { const openButton = event.target.closest('[data-lager-new-lot-open]'); if (openButton) { const drawer = getDrawer(); const form = drawer?.querySelector('[data-lager-drawer-topic=\"new-lot-form\"]'); const action = drawer?.querySelector('[data-lager-drawer-topic=\"new-lot-action\"]'); if (form) { form.hidden = false; } if (action) { action.hidden = true; } return; } if (event.target.closest('[data-lager-new-lot-close]')) { const drawer = getDrawer(); const form = drawer?.querySelector('[data-lager-drawer-topic=\"new-lot-form\"]'); const action = drawer?.querySelector('[data-lager-drawer-topic=\"new-lot-action\"]'); if (form) { form.hidden = true; } if (action) { action.hidden = false; } } });";
echo " document.addEventListener('click', (event) => { const editButton = event.target.closest('[data-lager-open-edit-open]'); if (editButton) { const card = editButton.closest('[data-lager-open-card-wrapper]'); const form = card?.querySelector('[data-lager-open-edit-form]'); const readView = card?.querySelector('[data-lager-open-card]'); const readDocuments = card?.querySelector('[data-lager-documents^=\"open-read-\"]'); const documents = card?.querySelector('[data-lager-edit-documents]'); const actions = card?.querySelector('[data-lager-open-edit-actions]'); if (form) { form.hidden = false; } if (readView) { readView.hidden = true; } if (readDocuments) { readDocuments.hidden = true; } if (documents) { documents.hidden = false; } if (actions) { actions.hidden = false; } return; } const closeButton = event.target.closest('[data-lager-open-edit-close]'); if (closeButton) { const card = closeButton.closest('[data-lager-open-card-wrapper]'); const form = card?.querySelector('[data-lager-open-edit-form]'); const readView = card?.querySelector('[data-lager-open-card]'); const readDocuments = card?.querySelector('[data-lager-documents^=\"open-read-\"]'); const documents = card?.querySelector('[data-lager-edit-documents]'); const actions = card?.querySelector('[data-lager-open-edit-actions]'); if (form) { form.hidden = true; } if (readView) { readView.hidden = false; } if (readDocuments) { readDocuments.hidden = false; } if (documents) { documents.hidden = true; } if (actions) { actions.hidden = true; } } });";
echo " document.addEventListener('input', (event) => { const input = event.target.closest('[data-lager-open-delete-confirmation-input]'); if (!input) { return; } const card = input.closest('[data-lager-open-card-wrapper]'); const submit = card?.querySelector('[data-lager-open-delete-confirmation-submit]'); const valid = input.value === 'DELETE'; if (submit) { submit.disabled = !valid; submit.setAttribute('aria-disabled', String(!valid)); submit.classList.toggle('sg-button--process-inactive', !valid); } });";
echo " document.addEventListener('click', async (event) => { const deleteButton = event.target.closest('[data-lager-open-delete]'); if (deleteButton) { const card = deleteButton.closest('[data-lager-open-card-wrapper]'); const confirmation = card?.querySelector('[data-lager-open-delete-confirmation]'); const input = card?.querySelector('[data-lager-open-delete-confirmation-input]'); const submit = card?.querySelector('[data-lager-open-delete-confirmation-submit]'); if (input) { input.value = ''; } if (submit) { submit.disabled = true; submit.setAttribute('aria-disabled', 'true'); submit.classList.add('sg-button--process-inactive'); } if (confirmation) { confirmation.hidden = false; } if (card) { card.dataset.dialogOpen = 'true'; } return; } const cancel = event.target.closest('[data-lager-open-delete-confirmation-cancel]'); if (cancel) { const card = cancel.closest('[data-lager-open-card-wrapper]'); const confirmation = card?.querySelector('[data-lager-open-delete-confirmation]'); if (confirmation) { confirmation.hidden = true; } if (card) { card.dataset.dialogOpen = 'false'; } return; } const submit = event.target.closest('[data-lager-open-delete-confirmation-submit]'); if (!submit || submit.disabled) { return; } const card = submit.closest('[data-lager-open-card-wrapper]'); const confirmation = card?.querySelector('[data-lager-open-delete-confirmation]'); const status = card?.querySelector('[data-lager-open-delete-status]'); if (status) { status.textContent = 'Löschen läuft …'; } submit.disabled = true; try { await postLagerChange({ action: 'delete_open_lot', lotId: card?.dataset.lagerOpenCardWrapper || '' }); card?.remove(); } catch (error) { if (status) { status.textContent = error instanceof Error ? error.message : 'Löschen fehlgeschlagen'; } submit.disabled = false; } });";
echo " document.addEventListener('click', (event) => { const cancel = event.target.closest('[data-lager-open-confirmation-cancel]'); if (cancel) { const card = cancel.closest('[data-lager-open-card-wrapper]'); const confirmation = card?.querySelector('[data-lager-open-confirmation-card]'); if (confirmation) { confirmation.hidden = true; } if (card) { card.dataset.dialogOpen = 'false'; } const form = card?.querySelector('[data-lager-open-edit-form-element]'); if (form) { form.dataset.confirmationAccepted = 'false'; } return; } const confirm = event.target.closest('[data-lager-open-confirmation-submit]'); if (confirm) { const card = confirm.closest('[data-lager-open-card-wrapper]'); const confirmation = card?.querySelector('[data-lager-open-confirmation-card]'); const form = card?.querySelector('[data-lager-open-edit-form-element]'); if (confirmation) { confirmation.hidden = true; } if (card) { card.dataset.dialogOpen = 'false'; } if (form) { form.dataset.confirmationAccepted = 'true'; form.requestSubmit(); } } });";
echo " document.addEventListener('submit', async (event) => { const openLotForm = event.target.closest('[data-lager-open-edit-form-element]'); if (!openLotForm) { return; } event.preventDefault(); const drawer = getDrawer(); const card = openLotForm.closest('[data-lager-open-card-wrapper]'); const editSection = card?.querySelector('[data-lager-open-edit-form]'); const fields = [{ label: 'Naurua-Chargennummer', input: openLotForm.querySelector('[data-lager-open-lot-number]') }, { label: 'Hersteller', input: openLotForm.querySelector('[data-lager-open-manufacturer]') }, { label: 'Hersteller-Chargennummer', input: openLotForm.querySelector('[data-lager-open-manufacturer-lot]') }, { label: 'Herstellungsdatum', input: openLotForm.querySelector('[data-lager-open-mfg-date]') }, { label: 'MHD', input: openLotForm.querySelector('[data-lager-open-expiry-date]') }, { label: 'Einkaufsdatum', input: openLotForm.querySelector('[data-lager-open-purchase-date]') }, { label: 'Menge', input: openLotForm.querySelector('[data-lager-open-target-qty]') }]; const emptyFields = fields.filter((field) => (field.input?.value || '').trim() === ''); if (emptyFields.length > 0 && openLotForm.dataset.confirmationAccepted !== 'true') { const confirmation = card?.querySelector('[data-lager-open-confirmation-card]'); const emptyFieldsList = card?.querySelector('[data-lager-open-confirmation-empty-fields]'); if (emptyFieldsList) { emptyFieldsList.replaceChildren(...emptyFields.map((field) => { const item = document.createElement('div'); item.className = 'sg-body'; item.textContent = field.label; return item; })); } if (confirmation) { confirmation.hidden = false; } if (card) { card.dataset.dialogOpen = 'true'; } return; } openLotForm.dataset.confirmationAccepted = 'false'; const status = openLotForm.closest('[data-lager-open-edit-form]')?.querySelector('[data-lager-open-edit-status]'); if (status) { status.textContent = 'Speichern läuft …'; } try { const lotId = card?.dataset.lagerOpenCardWrapper || ''; const uploadedDocuments = await uploadLagerDocuments(card?.querySelector('[data-lager-documents=open-edit-' + lotId + ']'), lotId); const existingDocuments = Array.from(card?.querySelectorAll('[data-lager-documents^=\"open-read-\"] [data-lager-document-row]') || []).map((row) => ({ id: Number(row.dataset.lagerDocumentRow || 0), original_filename: row.querySelector('a')?.textContent || 'Dokument' })); const targetQtyInput = openLotForm.querySelector('[data-lager-open-target-qty]'); const result = await postLagerChange({ action: 'prepare_lot', productId: drawer?.dataset.lagerProductId || '', lotId, lotNumber: openLotForm.querySelector('[data-lager-open-lot-number]')?.value || '', manufacturerPartyId: openLotForm.querySelector('[data-lager-open-manufacturer]')?.value || null, manufacturerLotNumber: openLotForm.querySelector('[data-lager-open-manufacturer-lot]')?.value || '', mfgDate: openLotForm.querySelector('[data-lager-open-mfg-date]')?.value || '', expiryDate: openLotForm.querySelector('[data-lager-open-expiry-date]')?.value || '', purchaseDate: openLotForm.querySelector('[data-lager-open-purchase-date]')?.value || '', targetQty: targetQtyInput?.value.trim() === '' ? '0' : targetQtyInput?.value || '' }); syncLagerDocumentViews(card, [...existingDocuments, ...uploadedDocuments]); syncLagerOpenLotCard(result.lot, result.balance); if (editSection) { editSection.hidden = true; } card?.querySelector('[data-lager-open-card]')?.removeAttribute('hidden'); if (status) { status.textContent = ''; } } catch (error) { if (status) { status.textContent = error instanceof Error ? error.message : 'Speichern fehlgeschlagen'; } } });";
echo " document.addEventListener('submit', async (event) => { const newLotForm = event.target.closest('[data-lager-new-lot-form-element]'); if (!newLotForm) { return; } event.preventDefault(); const drawer = getDrawer(); const status = drawer?.querySelector('[data-lager-new-lot-status]'); if (status) { status.textContent = 'Speichern läuft …'; } try { const result = await postLagerChange({ action: 'prepare_lot', productId: drawer?.dataset.lagerProductId || '', lotId: drawer?.dataset.lagerOpenLotId || '', lotNumber: drawer?.querySelector('[data-lager-new-lot-number]')?.value || '', manufacturerPartyId: drawer?.querySelector('[data-lager-new-manufacturer]')?.value || null, manufacturerLotNumber: drawer?.querySelector('[data-lager-new-manufacturer-lot]')?.value || '', mfgDate: drawer?.querySelector('[data-lager-new-mfg-date]')?.value || '', expiryDate: drawer?.querySelector('[data-lager-new-expiry-date]')?.value || '', purchaseDate: drawer?.querySelector('[data-lager-new-purchase-date]')?.value || '', receiptQty: drawer?.querySelector('[data-lager-new-receipt-qty]')?.value || '' }); syncLagerOpenLotCard(result.lot, result.balance); const form = drawer?.querySelector('[data-lager-drawer-topic=\"new-lot-form\"]'); const action = drawer?.querySelector('[data-lager-drawer-topic=\"new-lot-action\"]'); if (form) { form.hidden = true; } if (action) { action.hidden = false; } if (status) { status.textContent = ''; } } catch (error) { if (status) { status.textContent = error instanceof Error ? error.message : 'Speichern fehlgeschlagen'; } } });";
echo " document.addEventListener('submit', async (event) => { const editForm = event.target.closest('[data-lager-edit-form-element]'); if (!editForm) { return; } event.preventDefault(); const drawer = getDrawer(); const status = drawer?.querySelector('[data-lager-edit-status]'); if (status) { status.textContent = 'Speichern läuft …'; } try { const lotId = drawer?.dataset.lagerActiveLotId || ''; const uploadedDocuments = await uploadLagerDocuments(drawer?.querySelector('[data-lager-documents=\"current-edit\"]'), lotId); const result = await postLagerChange({ action: 'update_lot', lotId, manufacturerPartyId: drawer?.querySelector('[data-lager-manufacturer]')?.value || null, manufacturerLotNumber: drawer?.querySelector('[data-lager-manufacturer-lot]')?.value || '', mfgDate: drawer?.querySelector('[data-lager-mfg-date]')?.value || '', expiryDate: drawer?.querySelector('[data-lager-expiry-date]')?.value || '', purchaseDate: drawer?.querySelector('[data-lager-purchase-date]')?.value || '', receiptQty: drawer?.querySelector('[data-lager-receipt-qty]')?.value || '', adjustmentQty: drawer?.querySelector('[data-lager-adjustment-qty]')?.value || '', outQty: drawer?.querySelector('[data-lager-out-qty]')?.value || '' }); const trigger = contentRoot.querySelector('[data-lager-drawer-open][aria-expanded=\"true\"]'); if (trigger && uploadedDocuments.length) { let existingDocuments = []; try { existingDocuments = JSON.parse(trigger.dataset.lagerDrawerDocuments || '[]'); } catch (error) { existingDocuments = []; } trigger.dataset.lagerDrawerDocuments = JSON.stringify([...existingDocuments, ...uploadedDocuments]); syncLagerCurrentDocuments(trigger); } syncLagerReadView(); syncLagerBalanceView(result.balance); clearLagerMovementInputs(); setLagerEditMode(false); if (status) { status.textContent = ''; } } catch (error) { if (status) { status.textContent = error instanceof Error ? error.message : 'Speichern fehlgeschlagen'; } } });";
echo " document.addEventListener('click', (event) => { if (event.target.closest('[data-lager-detail-back]')) { event.preventDefault(); closeLagerMobileDetail(); } });";
echo " document.addEventListener('keydown', (event) => { if (event.key === 'Escape') { closeDrawer(); closeLagerMobileDetail(); } });";
echo " window.addEventListener('popstate', (event) => { if (lagerMobileDetailOpen && (!event.state || event.state.lagerView !== 'detail')) { restoreLagerMobileDetail(); } });";
echo " const syncLagerDisplayedTotal = () => { const drawer = getDrawer(); const trigger = contentRoot.querySelector('[data-lager-drawer-open][aria-expanded=\"true\"]'); if (!drawer || !trigger || trigger.dataset.lagerDrawerType !== 'Produkt') { return; } const active = Number((drawer.querySelector('[data-lager-drawer-current-qty]')?.textContent || '0').replace(',', '.')) || 0; const productId = trigger.closest('[data-lager-row]')?.dataset.lagerId || ''; const openProduct = drawer.querySelector('[data-lager-open-product=\"' + productId + '\"]'); const open = Array.from(openProduct?.querySelectorAll('[data-lager-open-card-qty-net]') || []).reduce((sum, node) => sum + (Number((node.textContent || '0').replace(',', '.')) || 0), 0); const total = active + open; const formatted = Number.isInteger(total) ? String(total) : total.toFixed(2).replace(/\.?0+$/, ''); const value = drawer.querySelector('[data-lager-drawer-value-value]'); if (value && value.textContent !== formatted) { value.textContent = formatted; } trigger.dataset.lagerDrawerValue = formatted; };";
echo " const lagerTotalObserver = new MutationObserver(syncLagerDisplayedTotal);";
echo " lagerTotalObserver.observe(contentRoot, { childList: true, subtree: true, characterData: true });";
echo "}";
echo "function initBestellungenBindings() {";
echo " const contentRoot = document.querySelector('[data-left-navigation-content-body]');";
@@ -695,7 +940,7 @@ function render_auth_home_page(array $user, array $otcProducts = [], array $best
echo " const isMobileDetailMode = () => window.matchMedia('(max-width: 767px)').matches;";
echo " const escapeHtml = (value) => String(value ?? '').replace(/[&<>\"']/g, (character) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '\"': '&quot;', \"'\": '&#39;' }[character] || character));";
echo " let bestellungenMobileDetailHost = null;";
echo " let bestellungenDetailOpen = false;";
echo " let bestellungenDetailOpen = false; let bestellungenOpeningMobileDetail = false;";
echo " const syncSearchState = () => {";
echo " const input = getSearchInput();";
echo " const wrap = getSearchWrap();";
@@ -704,46 +949,10 @@ function render_auth_home_page(array $user, array $otcProducts = [], array $best
echo " wrap.setAttribute('data-has-value', String(hasValue));";
echo " wrap.dataset.componentState = hasValue ? 'active' : 'inactive-selectable';";
echo " };";
echo " const renderMobileDetail = (trigger) => {";
echo " const orderNumber = trigger.dataset.orderNumber || '';";
echo " const orderDate = trigger.dataset.orderDate || '';";
echo " const total = trigger.dataset.orderTotal || '';";
echo " const street = trigger.dataset.orderStreet || '';";
echo " const houseNumber = trigger.dataset.orderHouseNumber || '';";
echo " const zip = trigger.dataset.orderZip || '';";
echo " const city = trigger.dataset.orderCity || '';";
echo " const country = trigger.dataset.orderCountry || '';";
echo " const streetLine = [street, houseNumber].filter((part) => part && part.length > 0).join(' ').trim();";
echo " const cityLine = [zip, city].filter((part) => part && part.length > 0).join(' ').trim();";
echo " return [";
echo " '<div class=\"sg-bestellungen-mobile-detail-host\" data-bestellungen-mobile-detail-host=\"true\">',";
echo " '<div class=\"sg-navigation-card-layout\">',";
echo " '<div class=\"sg-navigation-card-block\">',";
echo " '<article class=\"sg-card\" data-component=\"card\" data-pattern=\"navigation-card\" aria-label=\"Navigation Detailansicht zurück oben\">',";
echo " '<div class=\"sg-card-segment sg-card-segment--body\" data-component-part=\"card-body\" data-pattern-part=\"navigation-card-segment\">',";
echo " '<div class=\"sg-navigation-card-center\">',";
echo " '<button class=\"sg-interaction-element sg-hyperlink\" type=\"button\" data-bestellungen-detail-back>zurück</button>',";
echo " '</div>',";
echo " '</div>',";
echo " '</article>',";
echo " '</div>',";
echo " '</div>',";
echo " '<article class=\"sg-card sg-vsf-drawer-card\" data-component=\"card\" data-pattern=\"card\" aria-label=\"Bestell-Details\">',";
echo " '<div class=\"sg-card-segment sg-card-segment--body sg-vsf-drawer-card__content\">',";
echo " '<p class=\"sg-body\"><strong>Bestellnummer:</strong> <span>' + escapeHtml(orderNumber) + '</span></p>',";
echo " '<p class=\"sg-body\"><strong>Bestelldatum:</strong> <span>' + escapeHtml(orderDate) + '</span></p>',";
echo " '<p class=\"sg-body\"><strong>Lieferadresse:</strong></p>',";
echo " '<p class=\"sg-body\">' + escapeHtml(streetLine) + '</p>',";
echo " '<p class=\"sg-body\">' + escapeHtml(cityLine) + '</p>',";
echo " '<p class=\"sg-body\">' + escapeHtml(country) + '</p>',";
echo " '<p class=\"sg-body\"><strong>Gesamtsumme:</strong> <span>' + escapeHtml(total) + '</span></p>',";
echo " '<p class=\"sg-body\">Detailinhalt wird noch definiert.</p>',";
echo " '</div>',";
echo " '</article>',";
echo " '</div>'";
echo " ].join('');";
echo " };";
echo " const getDrawer = () => contentRoot.querySelector('[data-bestellungen-drawer]');";
echo " const renderMobileDetail = () => {";
echo " return '<div class=\"sg-bestellungen-mobile-detail-host\" data-bestellungen-mobile-detail-host=\"true\"><div class=\"sg-navigation-card-layout sg-mobile-detail-back-card\"><div class=\"sg-navigation-card-block\"><article class=\"sg-card\" data-component=\"card\" data-pattern=\"navigation-card\" aria-label=\"Navigation Detailansicht zurück oben\"><div class=\"sg-card-segment sg-card-segment--body\" data-component-part=\"card-body\" data-pattern-part=\"navigation-card-segment\"><div class=\"sg-navigation-card-center\"><button class=\"sg-interaction-element sg-hyperlink\" type=\"button\" data-bestellungen-detail-back>zurück</button></div></div></article></div></div></div>';";
echo " };";
echo " const getDrawer = () => document.querySelector('[data-bestellungen-mobile-drawer]') || contentRoot.querySelector('[data-bestellungen-drawer]');";
echo " const getDrawerBackdrop = () => contentRoot.querySelector('[data-bestellungen-drawer-backdrop]');";
echo " const setDrawerState = (open) => {";
echo " const drawer = getDrawer();";
@@ -764,41 +973,32 @@ function render_auth_home_page(array $user, array $otcProducts = [], array $best
echo " const openDrawer = (trigger) => {";
echo " const drawer = getDrawer();";
echo " if (!drawer) { return; }";
echo " if (isMobileDetailMode()) {";
echo " if (isMobileDetailMode() && !bestellungenOpeningMobileDetail) {";
echo " openMobileDetail(trigger);";
echo " return;";
echo " }";
echo " const drawerTitle = drawer.querySelector('[data-order-drawer-title]');";
echo " const drawerOrderNumber = drawer.querySelector('[data-order-drawer-order-number]');";
echo " const drawerOrderDate = drawer.querySelector('[data-order-drawer-order-date]');";
echo " const drawerAddressLine1 = drawer.querySelector('[data-order-drawer-address-line-1]');";
echo " const drawerAddressLine2 = drawer.querySelector('[data-order-drawer-address-line-2]');";
echo " const drawerAddressLine3 = drawer.querySelector('[data-order-drawer-address-line-3]');";
echo " const drawerTotal = drawer.querySelector('[data-order-drawer-total]');";
echo " const orderNumber = trigger.dataset.orderNumber || '';";
echo " const orderDate = trigger.dataset.orderDate || '';";
echo " const total = trigger.dataset.orderTotal || '';";
echo " const street = trigger.dataset.orderStreet || '';";
echo " const houseNumber = trigger.dataset.orderHouseNumber || '';";
echo " const zip = trigger.dataset.orderZip || '';";
echo " const city = trigger.dataset.orderCity || '';";
echo " const country = trigger.dataset.orderCountry || '';";
echo " const streetLine = [street, houseNumber].filter((part) => part && part.length > 0).join(' ').trim();";
echo " const cityLine = [zip, city].filter((part) => part && part.length > 0).join(' ').trim();";
echo " const cards = drawer.querySelector('[data-order-drawer-cards]');";
echo " const orderId = trigger.dataset.orderId || '';";
echo " const formatAmount = (value, currency) => value === null || value === undefined ? 'Nicht vorhanden' : currency + ' ' + Number(value).toFixed(2);";
echo " const formatDate = (value) => value ? new Date(value).toLocaleString('de-CH', { dateStyle: 'short', timeStyle: 'short' }) : 'Nicht vorhanden';";
echo " const value = (label, content) => '<p class=\"sg-body\"><strong>' + escapeHtml(label) + ':</strong> ' + escapeHtml(content || 'Nicht vorhanden') + '</p>';";
echo " const renderCards = (order) => {";
echo " const c = order.customer || {}; const a = order.amounts || {}; const address = [[c.street, c.house_number].filter(Boolean).join(' '), [c.zip, c.city].filter(Boolean).join(' '), c.country_name].filter(Boolean).join('<br>');";
echo " const lines = (order.lines || []).map((line) => '<div class=\"sg-card-segment sg-card-segment--body\"><p class=\"sg-body\"><strong>' + escapeHtml(line.item_name) + '</strong> · ' + escapeHtml(line.item_code) + ' · Menge: ' + escapeHtml(String(line.qty)) + '</p>' + value('Positionsstatus', line.line_status) + value('Einzelpreis', formatAmount(line.unit_price, a.currency || 'CHF')) + value('Positionssumme', formatAmount(line.line_total, a.currency || 'CHF')) + '</div>').join('');";
echo " const allocations = (order.lines || []).flatMap((line) => (line.product_allocations || []).map((item) => '<div class=\"sg-body\"><strong>' + escapeHtml(item.product_name) + ':</strong> ' + escapeHtml(item.lot_number || 'Nicht zugeordnet') + ' · ' + escapeHtml(item.allocated_qty === null ? 'Nicht zugeordnet' : String(item.allocated_qty)) + ' · ' + escapeHtml(item.allocation_status || 'Nicht vorhanden') + (item.lot_status === 'virtual_shortage' ? ' · Virtuelle Fehlbestandscharge' : '') + '</div>')).join('');";
echo " return '<article class=\"sg-card sg-card--content-card\" data-component=\"content-card\" aria-label=\"Bestellübersicht\"><header class=\"sg-card-segment sg-card-segment--header sg-card-segment--darkgreen\" data-component-part=\"card-header\"><div class=\"sg-strong sg-text-on-dark\">Bestellübersicht</div></header><div class=\"sg-card-segment sg-card-segment--body\" data-component-part=\"card-body\">' + value('Bestellnummer', order.external_ref) + value('Bestelldatum', formatDate(order.order_date)) + value('Verkaufskanal', order.order_source) + value('Bestellstatus', order.order_status) + value('Zahlungsstatus', order.payment_status) + value('Zahlungsart', order.payment_method) + value('Versandart', order.shipping_method) + '<p class=\"sg-body\"><strong>Kunde:</strong> ' + escapeHtml([c.first_name, c.last_name].filter(Boolean).join(' ') || 'Nicht vorhanden') + '</p><p class=\"sg-body\"><strong>Lieferadresse:</strong><br>' + address + '</p>' + value('Netto', formatAmount(a.net, a.currency || 'CHF')) + value('Versand', formatAmount(a.shipping, a.currency || 'CHF')) + value('Rabatt', formatAmount(a.discount, a.currency || 'CHF')) + value('Steuer', formatAmount(a.tax, a.currency || 'CHF')) + value('Gesamtsumme', formatAmount(a.total, a.currency || 'CHF')) + '</div></article><article class=\"sg-card sg-card--content-card\" data-component=\"content-card\" aria-label=\"Bestellte Artikel\"><header class=\"sg-card-segment sg-card-segment--header sg-card-segment--darkgreen\" data-component-part=\"card-header\"><div class=\"sg-strong sg-text-on-dark\">Bestellte Artikel</div></header>' + (lines || '<div class=\"sg-card-segment sg-card-segment--body\" data-component-part=\"card-body\"><p class=\"sg-body\">Keine Artikel vorhanden.</p></div>') + '</article><article class=\"sg-card sg-card--content-card\" data-component=\"content-card\" aria-label=\"Chargenzuordnung\"><header class=\"sg-card-segment sg-card-segment--header sg-card-segment--darkgreen\" data-component-part=\"card-header\"><div class=\"sg-strong sg-text-on-dark\">Chargenzuordnung</div></header><div class=\"sg-card-segment sg-card-segment--body\" data-component-part=\"card-body\">' + (allocations || '<p class=\"sg-body\">Keine Chargenzuordnung vorhanden.</p>') + '</div></article>';";
echo " };";
echo " setDrawerState(true);";
echo " if (drawerTitle) { drawerTitle.textContent = orderNumber !== '' ? 'Bestellung ' + orderNumber : 'Bestellung'; }";
echo " if (drawerOrderNumber) { drawerOrderNumber.textContent = orderNumber; }";
echo " if (drawerOrderDate) { drawerOrderDate.textContent = orderDate; }";
echo " if (drawerAddressLine1) { drawerAddressLine1.textContent = streetLine; }";
echo " if (drawerAddressLine2) { drawerAddressLine2.textContent = cityLine; }";
echo " if (drawerAddressLine3) { drawerAddressLine3.textContent = country; }";
echo " if (drawerTotal) { drawerTotal.textContent = total; }";
echo " if (drawerTitle) { drawerTitle.textContent = trigger.dataset.orderNumber ? 'Bestellung ' + trigger.dataset.orderNumber : 'Bestellung'; }";
echo " if (cards) { cards.innerHTML = '<p class=\"sg-body\">Bestelldetails werden geladen …</p>'; }";
echo " fetch('/api/bestellungen/detail.php?id=' + encodeURIComponent(orderId), { credentials: 'same-origin' }).then((response) => response.ok ? response.json() : Promise.reject(new Error('Bestelldetails konnten nicht geladen werden.'))).then((payload) => { if (!payload.ok) { throw new Error('Bestelldetails konnten nicht geladen werden.'); } if (cards) { cards.innerHTML = renderCards(payload.order); } }).catch((error) => { if (cards) { cards.innerHTML = '<p class=\"sg-body\">' + escapeHtml(error.message) + '</p>'; } });";
echo " contentRoot.querySelectorAll('[data-order-drawer-open]').forEach((button) => {";
echo " button.setAttribute('aria-expanded', String(button === trigger));";
echo " });";
echo " };";
echo " const restoreBestellungenList = () => {";
echo " if (bestellungenMobileDetailHost && bestellungenMobileDetailHost.parentNode) {";
echo " if (bestellungenMobileDetailHost && bestellungenMobileDetailHost.parentNode) { const mobileDrawer = bestellungenMobileDetailHost.querySelector('[data-bestellungen-mobile-drawer]'); if (mobileDrawer) { contentRoot.appendChild(mobileDrawer); mobileDrawer.removeAttribute('data-bestellungen-mobile-drawer'); mobileDrawer.dataset.open = 'false'; mobileDrawer.setAttribute('aria-hidden', 'true'); } document.body.classList.remove('sg-card-list-page-drawer-open');";
echo " bestellungenMobileDetailHost.parentNode.removeChild(bestellungenMobileDetailHost);";
echo " }";
echo " bestellungenMobileDetailHost = null;";
@@ -808,13 +1008,13 @@ function render_auth_home_page(array $user, array $otcProducts = [], array $best
echo " };";
echo " const openMobileDetail = (trigger) => {";
echo " restoreBestellungenList();";
echo " const sourceDrawer = contentRoot.querySelector('[data-bestellungen-drawer]');";
echo " if (!sourceDrawer) { return; }";
echo " const host = document.createElement('div');";
echo " host.innerHTML = renderMobileDetail(trigger);";
echo " host.innerHTML = renderMobileDetail();";
echo " bestellungenMobileDetailHost = host.firstElementChild;";
echo " if (bestellungenMobileDetailHost) {";
echo " document.body.appendChild(bestellungenMobileDetailHost);";
echo " document.body.classList.add('sg-bestellungen-mobile-detail-open');";
echo " }";
echo " if (bestellungenMobileDetailHost) { const mobileDrawer = sourceDrawer; mobileDrawer.dataset.bestellungenMobileDrawer = 'true'; mobileDrawer.dataset.open = 'true'; mobileDrawer.setAttribute('aria-hidden', 'false'); bestellungenMobileDetailHost.appendChild(mobileDrawer); document.body.appendChild(bestellungenMobileDetailHost); document.body.classList.add('sg-bestellungen-mobile-detail-open'); }";
echo " bestellungenOpeningMobileDetail = true; openDrawer(trigger); bestellungenOpeningMobileDetail = false;";
echo " bestellungenDetailOpen = true;";
echo " window.history.pushState({ bestellungenView: 'detail', orderId: trigger.dataset.orderId || '' }, '', window.location.href);";
echo " };";
@@ -959,35 +1159,6 @@ function render_auth_home_page(array $user, array $otcProducts = [], array $best
echo " }";
echo " }";
echo " };";
echo " const connectBestellungenRealtime = () => {";
echo " if (!window.EventSource) { return null; }";
echo " if (bestellungenRealtimeSource) {";
echo " bestellungenRealtimeSource.close();";
echo " }";
echo " const source = new EventSource('/api/realtime/bestellungen.php', { withCredentials: true });";
echo " source.addEventListener('bestellungen.changed', (event) => {";
echo " const payload = parseJsonRealtimeEvent(event.data);";
echo " if (!payload) { return; }";
echo " const kind = String(payload.kind || '');";
echo " const orderId = payload.orderId !== undefined && payload.orderId !== null ? String(payload.orderId) : '';";
echo " if (kind === 'updated' && orderId !== '') {";
echo " void reloadBestellungenRow(orderId);";
echo " return;";
echo " }";
echo " void reloadBestellungenSection();";
echo " });";
echo " bestellungenRealtimeSource = source;";
echo " return source;";
echo " };";
echo " const parseJsonRealtimeEvent = (raw) => {";
echo " if (typeof raw !== 'string' || raw.trim() === '') { return null; }";
echo " try {";
echo " const parsed = JSON.parse(raw);";
echo " return parsed && typeof parsed === 'object' ? parsed : null;";
echo " } catch (error) {";
echo " return null;";
echo " }";
echo " };";
echo " if (!bestellungenTableHandlersInstalled) {";
echo " bestellungenTableHandlersInstalled = true;";
echo " document.addEventListener('click', (event) => {";
@@ -997,7 +1168,7 @@ function render_auth_home_page(array $user, array $otcProducts = [], array $best
echo " closeMobileDetail();";
echo " }";
echo " });";
echo " contentRoot.addEventListener('click', (event) => {";
echo " document.addEventListener('click', (event) => {";
echo " const openTrigger = event.target.closest('[data-order-drawer-open]');";
echo " if (openTrigger) {";
echo " event.preventDefault();";
@@ -1053,14 +1224,12 @@ function render_auth_home_page(array $user, array $otcProducts = [], array $best
echo " }";
echo " }";
echo " bindTable();";
echo " connectBestellungenRealtime();";
echo "}";
echo "(() => {";
echo " const overlay = document.querySelector('[data-otc-order-overlay]');";
echo " if (!overlay) { return; }";
echo " const form = overlay.querySelector('[data-otc-order-form]');";
echo " const productInputs = Array.from(form.querySelectorAll('[data-otc-order-product]'));";
echo " const totalPriceInput = form.querySelector('[data-otc-order-total-price]');";
echo " const orderDateInput = form.querySelector('[data-otc-order-date]');";
echo " const orderDateDefaultValue = orderDateInput ? orderDateInput.value : '';";
echo " const paymentRows = form.querySelector('[data-otc-payment-rows]');";
@@ -1073,10 +1242,17 @@ function render_auth_home_page(array $user, array $otcProducts = [], array $best
echo " const houseNumberInput = form.querySelector('[data-otc-order-house-number]');";
echo " const zipInput = form.querySelector('[data-otc-order-zip]');";
echo " const cityInput = form.querySelector('[data-otc-order-city]');";
echo " const submitBtn = form.querySelector('[data-otc-order-submit]');";
echo " const submitBtn = overlay.querySelector('[data-otc-order-submit]');";
echo " const errorEl = form.querySelector('[data-otc-order-error]');";
echo " const successEl = form.querySelector('[data-otc-order-success]');";
echo " const successDefaultText = successEl ? successEl.textContent : '';";
echo " const confirmationStage = overlay.querySelector('[data-otc-confirmation-stage]');";
echo " const confirmationCard = overlay.querySelector('[data-otc-confirmation-card]');";
echo " const confirmationProducts = overlay.querySelector('[data-otc-confirmation-products]');";
echo " const confirmationQuantity = overlay.querySelector('[data-otc-confirmation-quantity]');";
echo " const confirmationTotal = overlay.querySelector('[data-otc-confirmation-total]');";
echo " const confirmationSubmit = overlay.querySelector('[data-otc-confirmation-submit]');";
echo " let pendingOrderData = null;";
echo " const openTriggers = () => document.querySelectorAll('[data-otc-order-open]');";
echo " const setTriggerState = (expanded) => {";
echo " openTriggers().forEach((trigger) => { trigger.setAttribute('aria-expanded', String(expanded)); });";
@@ -1087,7 +1263,6 @@ function render_auth_home_page(array $user, array $otcProducts = [], array $best
echo " const clearSuccess = () => {";
echo " if (successEl) { successEl.textContent = successDefaultText; successEl.classList.add('hidden'); }";
echo " };";
echo " let backdropPointerDown = false;";
echo " const getTodayValue = () => {";
echo " const now = new Date();";
echo " const year = now.getFullYear();";
@@ -1111,7 +1286,7 @@ function render_auth_home_page(array $user, array $otcProducts = [], array $best
echo " clearSuccess();";
echo " ensureCurrentOrderDate();";
echo " requestAnimationFrame(() => {";
echo " (productInputs[0] || totalPriceInput || submitBtn)?.focus();";
echo " (productInputs[0] || submitBtn)?.focus();";
echo " });";
echo " return;";
echo " }";
@@ -1123,9 +1298,7 @@ function render_auth_home_page(array $user, array $otcProducts = [], array $best
echo " const updatePaymentAddButton = () => { addPaymentButton.disabled = paymentRows.querySelectorAll('[data-otc-payment-row]').length >= paymentOptionValues.length; };";
echo " const updateFormState = () => {";
echo " const totalQty = productInputs.reduce((sum, input) => sum + getInputValue(input), 0);";
echo " const totalPrice = parseFloat(totalPriceInput.value || '0') || 0;";
echo " const isValid = totalQty > 0";
echo " && totalPrice > 0";
echo " && !!getFieldValue(orderDateInput)";
echo " && paymentRows.querySelectorAll('[data-otc-payment-row]').length > 0";
echo " && !!getFieldValue(firstNameInput)";
@@ -1137,12 +1310,8 @@ function render_auth_home_page(array $user, array $otcProducts = [], array $best
echo " let errorMsg = '';";
echo " if (totalQty === 0) {";
echo " errorMsg = 'Mindestens ein Produkt mit Menge > 0 erforderlich.';";
echo " } else if (totalPrice <= 0) {";
echo " errorMsg = 'Preis muss größer als 0 sein.';";
echo " } else if (!getFieldValue(orderDateInput)) {";
echo " errorMsg = 'Bestelldatum auswählen.';";
echo " } else if (getPaymentTotal() !== totalPrice) {";
echo " errorMsg = 'Die Zahlungsbeträge müssen dem Gesamtbetrag entsprechen.';";
echo " } else if (!getFieldValue(firstNameInput) || !getFieldValue(lastNameInput) || !getFieldValue(streetInput) || !getFieldValue(houseNumberInput) || !getFieldValue(zipInput) || !getFieldValue(cityInput)) {";
echo " errorMsg = 'Alle Rechnungsadress-Felder ausfüllen.';";
echo " }";
@@ -1163,9 +1332,27 @@ function render_auth_home_page(array $user, array $otcProducts = [], array $best
echo " if (!preserveSuccess) { clearSuccess(); }";
echo " if (!preserveSuccess) { updateFormState(); }";
echo " };";
echo " overlay.addEventListener('pointerdown', (event) => {";
echo " backdropPointerDown = event.target === overlay;";
echo " });";
echo " const closeConfirmation = () => {";
echo " pendingOrderData = null;";
echo " confirmationStage.dataset.dialogOpen = 'false';";
echo " confirmationCard.hidden = true;";
echo " };";
echo " const openConfirmation = (orderData) => {";
echo " pendingOrderData = orderData;";
echo " confirmationProducts.replaceChildren();";
echo " orderData.products.forEach((product) => {";
echo " const input = productInputs.find((candidate) => parseInt(candidate.dataset.productId || '0', 10) === product.productId);";
echo " const item = document.createElement('div');";
echo " item.className = 'sg-body';";
echo " item.textContent = (input?.dataset.title || 'Produkt') + ': ' + product.qty;";
echo " confirmationProducts.appendChild(item);";
echo " });";
echo " confirmationQuantity.textContent = String(orderData.products.reduce((sum, product) => sum + product.qty, 0));";
echo " confirmationTotal.textContent = orderData.payments.reduce((sum, payment) => sum + payment.amount, 0).toFixed(2);";
echo " confirmationStage.dataset.dialogOpen = 'true';";
echo " confirmationCard.hidden = false;";
echo " requestAnimationFrame(() => confirmationSubmit.focus());";
echo " };";
echo " document.addEventListener('click', (event) => {";
echo " const openTrigger = event.target.closest('[data-otc-order-open]');";
echo " if (openTrigger) {";
@@ -1179,18 +1366,7 @@ function render_auth_home_page(array $user, array $otcProducts = [], array $best
echo " toggleOverlay(false);";
echo " }";
echo " });";
echo " overlay.addEventListener('click', (event) => {";
echo " if (event.target === overlay && backdropPointerDown) {";
echo " toggleOverlay(false);";
echo " }";
echo " backdropPointerDown = false;";
echo " });";
echo " document.addEventListener('keydown', (event) => {";
echo " if (event.key === 'Escape' && overlay.dataset.open === 'true') {";
echo " toggleOverlay(false);";
echo " }";
echo " });";
echo " [totalPriceInput, orderDateInput, firstNameInput, lastNameInput, streetInput, houseNumberInput, zipInput, cityInput].forEach((input) => {";
echo " [orderDateInput, firstNameInput, lastNameInput, streetInput, houseNumberInput, zipInput, cityInput].forEach((input) => {";
echo " if (!input) { return; }";
echo " input.addEventListener('input', updateFormState);";
echo " input.addEventListener('change', updateFormState);";
@@ -1199,7 +1375,6 @@ function render_auth_home_page(array $user, array $otcProducts = [], array $best
echo " input.addEventListener('input', updateFormState);";
echo " input.addEventListener('change', updateFormState);";
echo " });";
echo " const getPaymentTotal = () => Math.round(Array.from(paymentRows.querySelectorAll('[data-otc-payment-amount]')).reduce((sum, input) => sum + (parseFloat(input.value || '0') || 0), 0) * 100) / 100;";
echo " const bindPaymentRow = (row) => {";
echo " row.querySelectorAll('select, input').forEach((input) => { input.addEventListener('input', updateFormState); input.addEventListener('change', updateFormState); });";
echo " };";
@@ -1211,7 +1386,7 @@ function render_auth_home_page(array $user, array $otcProducts = [], array $best
echo " const row = document.createElement('div');";
echo " row.className = 'sg-form-sections-card__field-group';";
echo " row.dataset.otcPaymentRow = '';";
echo " row.innerHTML = '<label class=\"sg-label\" for=\"otc-payment-method-' + number + '\">Bezahlart</label><select class=\"sg-interaction-element sg-input-single-line\" id=\"otc-payment-method-' + number + '\" data-otc-payment-method>' + paymentOptions + '</select><label class=\"sg-label\" for=\"otc-payment-amount-' + number + '\">Betrag (CHF)</label><input class=\"sg-interaction-element sg-input-single-line\" type=\"number\" id=\"otc-payment-amount-' + number + '\" min=\"0.01\" step=\"0.01\" value=\"0.00\" data-otc-payment-amount>';";
echo " row.innerHTML = '<label class=\"sg-labeled-input-row\"><span class=\"sg-label\">Bezahlart</span><select class=\"sg-interaction-element sg-input-single-line sg-input-single-line--inactive-selectable sg-form-inactive-selectable sg-pulldown\" id=\"otc-payment-method-' + number + '\" data-otc-payment-method>' + paymentOptions + '</select></label><label class=\"sg-labeled-input-row\"><span class=\"sg-label\">Betrag (CHF)</span><input class=\"sg-interaction-element sg-input-single-line sg-input-single-line--inactive-selectable sg-form-inactive-selectable\" type=\"number\" id=\"otc-payment-amount-' + number + '\" min=\"0.01\" step=\"0.01\" value=\"0.00\" data-otc-payment-amount></label>' ;";
echo " row.querySelector('[data-otc-payment-method]').value = nextPaymentMethod;";
echo " paymentRows.appendChild(row);";
echo " bindPaymentRow(row);";
@@ -1221,31 +1396,11 @@ function render_auth_home_page(array $user, array $otcProducts = [], array $best
echo " addPaymentButton.addEventListener('click', addPaymentRow);";
echo " bindPaymentRow(paymentRows.querySelector('[data-otc-payment-row]'));";
echo " updatePaymentAddButton();";
echo " form.addEventListener('submit', async (event) => {";
echo " event.preventDefault();";
echo " const submitOrder = async (orderData) => {";
echo " let submissionSucceeded = false;";
echo " if (!updateFormState()) { return; }";
echo " const products = productInputs";
echo " .filter((input) => getInputValue(input) > 0)";
echo " .map((input) => ({ productId: parseInt(input.dataset.productId || '0', 10), qty: getInputValue(input) }));";
echo " const orderData = {";
echo " products: products,";
echo " totalPrice: parseFloat(totalPriceInput.value || '0') || 0,";
echo " orderDate: orderDateInput ? orderDateInput.value : '',";
echo " payments: Array.from(paymentRows.querySelectorAll('[data-otc-payment-row]')).map((row) => ({ paymentMethod: row.querySelector('[data-otc-payment-method]').value, amount: parseFloat(row.querySelector('[data-otc-payment-amount]').value || '0') || 0 })),";
echo " billing: {";
echo " firstName: getFieldValue(firstNameInput),";
echo " lastName: getFieldValue(lastNameInput),";
echo " street: getFieldValue(streetInput),";
echo " houseNumber: getFieldValue(houseNumberInput),";
echo " zip: getFieldValue(zipInput),";
echo " city: getFieldValue(cityInput)";
echo " }";
echo " };";
echo " const originalBtnText = submitBtn.innerHTML;";
echo " submitBtn.innerHTML = '<span class=\"loading\"></span> Wird verarbeitet...';";
echo " submitBtn.disabled = true;";
echo " submitBtn.classList.add('sg-button--process-inactive');";
echo " const originalBtnText = confirmationSubmit.innerHTML;";
echo " confirmationSubmit.innerHTML = '<span class=\"loading\"></span> Wird verarbeitet...';";
echo " confirmationSubmit.disabled = true;";
echo " clearError();";
echo " try {";
echo " const response = await fetch('/api/otc-order.php', {";
@@ -1260,20 +1415,50 @@ function render_auth_home_page(array $user, array $otcProducts = [], array $best
echo " successEl.textContent = result.externalRef ? 'Bestellung erfolgreich erfasst! Bestellnummer: ' + result.externalRef : successDefaultText;";
echo " successEl.classList.remove('hidden');";
echo " }";
echo " resetForm(true);";
echo " closeConfirmation();";
echo " resetForm();";
echo " toggleOverlay(false);";
echo " } else if (errorEl) {";
echo " errorEl.textContent = result.error || 'Unbekannter Fehler';";
echo " errorEl.classList.remove('hidden');";
echo " closeConfirmation();";
echo " }";
echo " } catch (error) {";
echo " if (errorEl) {";
echo " errorEl.textContent = 'Netzwerkfehler: ' + error.message;";
echo " errorEl.classList.remove('hidden');";
echo " }";
echo " closeConfirmation();";
echo " } finally {";
echo " submitBtn.innerHTML = originalBtnText;";
echo " confirmationSubmit.innerHTML = originalBtnText;";
echo " confirmationSubmit.disabled = false;";
echo " if (!submissionSucceeded) { updateFormState(); }";
echo " }";
echo " };";
echo " form.addEventListener('submit', (event) => {";
echo " event.preventDefault();";
echo " if (!updateFormState()) { return; }";
echo " const products = productInputs";
echo " .filter((input) => getInputValue(input) > 0)";
echo " .map((input) => ({ productId: parseInt(input.dataset.productId || '0', 10), qty: getInputValue(input) }));";
echo " const orderData = {";
echo " products: products,";
echo " orderDate: orderDateInput ? orderDateInput.value : '',";
echo " payments: Array.from(paymentRows.querySelectorAll('[data-otc-payment-row]')).map((row) => ({ paymentMethod: row.querySelector('[data-otc-payment-method]').value, amount: parseFloat(row.querySelector('[data-otc-payment-amount]').value || '0') || 0 })),";
echo " billing: {";
echo " firstName: getFieldValue(firstNameInput),";
echo " lastName: getFieldValue(lastNameInput),";
echo " street: getFieldValue(streetInput),";
echo " houseNumber: getFieldValue(houseNumberInput),";
echo " zip: getFieldValue(zipInput),";
echo " city: getFieldValue(cityInput)";
echo " }";
echo " };";
echo " openConfirmation(orderData);";
echo " });";
echo " confirmationStage.addEventListener('click', (event) => {";
echo " if (event.target.closest('[data-otc-confirmation-cancel]')) { closeConfirmation(); return; }";
echo " if (event.target.closest('[data-otc-confirmation-submit]') && pendingOrderData) { submitOrder(pendingOrderData); }";
echo " });";
echo " updateFormState();";
echo "})();";
+30
View File
@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
function system_write_audit_log(
PDO $pdo,
string $entityName,
string $entityId,
string $action,
?string $changedBy,
array $beforeData,
array $afterData,
array $context = []
): void {
$stmt = $pdo->prepare(
"INSERT INTO audit_log (
entity_name, entity_id, action, changed_by, before_data, after_data, context
) VALUES (
:entity_name, :entity_id, :action, :changed_by, :before_data::jsonb, :after_data::jsonb, :context::jsonb
)"
);
$stmt->execute([
':entity_name' => $entityName,
':entity_id' => $entityId,
':action' => $action,
':changed_by' => $changedBy,
':before_data' => json_encode($beforeData, JSON_THROW_ON_ERROR),
':after_data' => json_encode($afterData, JSON_THROW_ON_ERROR),
':context' => json_encode($context, JSON_THROW_ON_ERROR),
]);
}
+21
View File
@@ -3,6 +3,8 @@ declare(strict_types=1);
require_once __DIR__ . '/../shared/db.php';
require_once __DIR__ . '/../erp/lager/forecast.php';
require_once __DIR__ . '/../erp/import-integration/shopify-webhook-worker.php';
require_once __DIR__ . '/../erp/import-integration/shopify-delta-run.php';
if (PHP_SAPI !== 'cli') {
http_response_code(404);
@@ -19,7 +21,26 @@ $env = expand_env_values(parse_env_file(dirname(__DIR__, 2) . '/.env'));
$pdo = connect_database($env);
try {
$reconciliationStartedAt = new DateTimeImmutable('now', new DateTimeZone('Europe/Zurich'));
$jobs = [
'erp.import-integration.shopify_webhook_worker' => static fn (): array => shopify_webhook_worker_run($pdo, $env, 100),
'erp.import-integration.shopify_delta_order_import' => static function () use ($pdo, $env, $reconciliationStartedAt): array {
$cutoff = shopify_delta_reconciliation_cutoff($pdo);
$recoveryOrderGids = shopify_delta_load_pending_order_gids($pdo);
$runId = shopify_delta_start_run($pdo, $cutoff, false);
try {
$result = run_shopify_delta($env, $pdo, $cutoff, false, $recoveryOrderGids);
$result['technical_run_id'] = $runId;
if (($result['status'] ?? '') === 'done') {
shopify_delta_store_watermark($pdo, $reconciliationStartedAt->format(DATE_ATOM));
}
shopify_delta_finish_run($pdo, $runId, (string) $result['status'], $result);
return $result;
} catch (Throwable $error) {
shopify_delta_finish_run($pdo, $runId, 'failed', ['status' => 'failed', 'technical_run_id' => $runId], $error);
throw $error;
}
},
'erp.lager.stock_lot_forecast_refresh' => static fn (): array => run_lager_stock_lot_forecast($pdo),
];
$results = [];
+34
View File
@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../../../modules/shared/db.php';
require_once __DIR__ . '/../../../modules/shared/auth/service.php';
require_once __DIR__ . '/../../../modules/erp/bestellungen/service.php';
header('Content-Type: application/json; charset=utf-8');
$env = expand_env_values(parse_env_file(__DIR__ . '/../../../.env'));
$pdo = connect_database($env);
auth_bootstrap_session();
if (auth_current_user($pdo) === null) {
http_response_code(401);
echo json_encode(['ok' => false, 'error' => 'Unauthorized']);
exit;
}
$orderId = filter_input(INPUT_GET, 'id', FILTER_VALIDATE_INT);
if (!is_int($orderId) || $orderId <= 0) {
http_response_code(400);
echo json_encode(['ok' => false, 'error' => 'Invalid order id']);
exit;
}
$detail = get_sales_order_detail($pdo, $orderId);
if ($detail === null) {
http_response_code(404);
echo json_encode(['ok' => false, 'error' => 'Order not found']);
exit;
}
echo json_encode(['ok' => true, 'order' => $detail], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
+4
View File
@@ -0,0 +1,4 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../../modules/erp/lager/api/lot.php';
+30
View File
@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../../modules/shared/db.php';
require_once __DIR__ . '/../../modules/shared/auth/service.php';
require_once __DIR__ . '/../../modules/erp/lager/service.php';
$env = expand_env_values(parse_env_file(__DIR__ . '/../../.env'));
try {
$pdo = connect_database($env);
auth_bootstrap_session();
if (auth_current_user($pdo) === null) {
http_response_code(401);
exit('Unauthorized');
}
$document = get_stock_lot_document($pdo, (int) ($_GET['id'] ?? 0));
$path = dirname(__DIR__, 2) . '/runtime/uploads/lager/' . basename((string) $document['stored_filename']);
if (!is_file($path)) {
http_response_code(404);
exit('Not Found');
}
header('Content-Type: application/octet-stream');
header('Content-Length: ' . (string) filesize($path));
header("Content-Disposition: attachment; filename*=UTF-8''" . rawurlencode((string) $document['original_filename']));
readfile($path);
} catch (Throwable) {
http_response_code(404);
exit('Not Found');
}
+5
View File
@@ -1,6 +1,11 @@
<?php
declare(strict_types=1);
http_response_code(410);
header('Content-Type: text/plain; charset=utf-8');
echo 'Bestellungen-Realtime ist deaktiviert.';
exit;
require_once __DIR__ . '/../../../modules/shared/db.php';
require_once __DIR__ . '/../../../modules/shared/auth/service.php';
require_once __DIR__ . '/../../../modules/erp/bestellungen/service.php';
+187
View File
@@ -175,6 +175,8 @@
--layout-object-card-mobile-width: var(--dimension-object-card-mobile-width);
--layout-object-card-mobile-height: var(--dimension-object-card-mobile-height);
--layout-object-card-desktop-breakpoint: var(--dimension-object-card-desktop-breakpoint);
--layout-scrollable-card-width: var(--dimension-scrollable-card-width);
--layout-scrollable-card-height: var(--dimension-scrollable-card-height);
--layout-content-card-margin-top-desktop: var(--dimension-content-card-margin-top-desktop);
--layout-content-card-margin-top-mobile: var(--dimension-content-card-margin-top-mobile);
--layout-notification-card-width: var(--dimension-notification-card-width);
@@ -287,6 +289,8 @@
--dimension-object-card-mobile-width: 100%;
--dimension-object-card-mobile-height: auto;
--dimension-object-card-desktop-breakpoint: 768px;
--dimension-scrollable-card-width: 600px;
--dimension-scrollable-card-height: 600px;
--dimension-content-card-margin-top-desktop: 100px;
--dimension-content-card-margin-top-mobile: 1rem;
--dimension-object-group-card-min-width: 450px;
@@ -656,6 +660,13 @@ section + section {
color: var(--text-control-default);
}
.sg-button--warning {
--surface-button-warning: var(--color-signal-red);
--text-button-warning: var(--color-font-light);
background: var(--surface-button-warning);
color: var(--text-button-warning);
}
.sg-button--process {
background: var(--surface-button-process);
color: var(--text-button-process);
@@ -2096,6 +2107,147 @@ section + section {
text-decoration: none;
}
/* Component: File Input */
.sg-file-input {
width: 100%;
}
.sg-file-input__row {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-large);
width: 100%;
}
.sg-file-input__control {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.sg-file-input:focus-within .sg-file-input__trigger {
outline: 2px solid var(--text-hyperlink);
outline-offset: 2px;
}
.sg-file-input__trigger {
align-self: flex-start;
}
@media (max-width: 48rem) {
.sg-file-input__row {
align-items: flex-start;
flex-direction: column;
}
}
.sg-file-input--disabled {
opacity: var(--disabled-opacity);
}
.sg-file-input--disabled .sg-file-input__trigger {
cursor: not-allowed;
}
/* ========================================================= */
/* Patterns: Datei-Upload */
/* ========================================================= */
.sg-file-upload {
--surface-file-upload-dropzone: var(--surface-form-preview);
--surface-file-upload-file-item: var(--surface-control-default);
--text-file-upload-meta: var(--text-control-disabled);
--text-file-upload-error: var(--color-signal-red);
display: flex;
flex-direction: column;
gap: var(--spacing-large);
width: 100%;
max-width: var(--layout-input-field-max-width);
}
.sg-file-upload__status,
.sg-file-upload__file-meta {
margin: var(--spacing-small) 0 0;
}
.sg-file-upload__dropzone {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-large);
width: 100%;
padding: var(--spacing-large);
background: var(--surface-file-upload-dropzone);
border: 1px dashed var(--surface-button-active);
border-radius: var(--radius-interaction);
}
.sg-file-upload__dropzone[data-drag-over="true"] {
outline: 2px solid var(--text-hyperlink);
outline-offset: 2px;
}
.sg-file-upload__files {
display: flex;
flex-direction: column;
gap: var(--spacing-small);
margin: 0;
padding: 0;
list-style: none;
}
.sg-file-upload__file {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-large);
padding: var(--spacing-small) var(--spacing-large);
color: var(--text-control-default);
background: var(--surface-file-upload-file-item);
border-radius: var(--radius-interaction);
}
.sg-file-upload__file-name {
min-width: 0;
overflow-wrap: anywhere;
}
.sg-file-upload__file-meta {
color: var(--text-file-upload-meta);
}
.sg-file-upload__remove {
flex: 0 0 auto;
}
.sg-file-upload__submit {
align-self: flex-end;
}
.sg-file-upload__error {
color: var(--text-file-upload-error);
}
@media (max-width: 48rem) {
.sg-file-upload__dropzone {
align-items: flex-start;
flex-direction: column;
}
.sg-file-upload__file {
align-items: flex-start;
flex-direction: column;
gap: var(--spacing-small);
}
}
/* ========================================================= */
/* Patterns: Portal Header */
/* ========================================================= */
@@ -5031,3 +5183,38 @@ main {
}
}
/* ========================================================= */
/* Patterns: Scrollable Card */
/* ========================================================= */
.sg-scrollable-card {
display: flex;
flex-direction: column;
width: var(--layout-scrollable-card-width);
max-width: 100%;
height: var(--layout-scrollable-card-height);
box-sizing: border-box;
}
.sg-scrollable-card__header,
.sg-scrollable-card__actions-segment {
flex: 0 0 auto;
}
.sg-scrollable-card__content {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
}
.sg-scrollable-card__actions {
display: flex;
gap: var(--spacing-small);
width: 100%;
}
.sg-scrollable-card__actions > .sg-button {
flex: 1 1 0;
min-width: 0;
}
+2 -2
View File
@@ -1,7 +1,7 @@
{
"styleguideVersion": "2026.05.18.1",
"styleguideCommit": "3f4ec38",
"syncedAtUtc": "2026-09-03T15:57:49Z",
"styleguideCommit": "71734d6",
"syncedAtUtc": "2026-09-08T09:49:27Z",
"sourceRepo": "/Users/mathias/Documents/Dokumente Chouchou/Codebases/Styleguide",
"mirroredDocsPath": "docs/styleguide"
}
+187
View File
@@ -175,6 +175,8 @@
--layout-object-card-mobile-width: var(--dimension-object-card-mobile-width);
--layout-object-card-mobile-height: var(--dimension-object-card-mobile-height);
--layout-object-card-desktop-breakpoint: var(--dimension-object-card-desktop-breakpoint);
--layout-scrollable-card-width: var(--dimension-scrollable-card-width);
--layout-scrollable-card-height: var(--dimension-scrollable-card-height);
--layout-content-card-margin-top-desktop: var(--dimension-content-card-margin-top-desktop);
--layout-content-card-margin-top-mobile: var(--dimension-content-card-margin-top-mobile);
--layout-notification-card-width: var(--dimension-notification-card-width);
@@ -287,6 +289,8 @@
--dimension-object-card-mobile-width: 100%;
--dimension-object-card-mobile-height: auto;
--dimension-object-card-desktop-breakpoint: 768px;
--dimension-scrollable-card-width: 600px;
--dimension-scrollable-card-height: 600px;
--dimension-content-card-margin-top-desktop: 100px;
--dimension-content-card-margin-top-mobile: 1rem;
--dimension-object-group-card-min-width: 450px;
@@ -656,6 +660,13 @@ section + section {
color: var(--text-control-default);
}
.sg-button--warning {
--surface-button-warning: var(--color-signal-red);
--text-button-warning: var(--color-font-light);
background: var(--surface-button-warning);
color: var(--text-button-warning);
}
.sg-button--process {
background: var(--surface-button-process);
color: var(--text-button-process);
@@ -2096,6 +2107,147 @@ section + section {
text-decoration: none;
}
/* Component: File Input */
.sg-file-input {
width: 100%;
}
.sg-file-input__row {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-large);
width: 100%;
}
.sg-file-input__control {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.sg-file-input:focus-within .sg-file-input__trigger {
outline: 2px solid var(--text-hyperlink);
outline-offset: 2px;
}
.sg-file-input__trigger {
align-self: flex-start;
}
@media (max-width: 48rem) {
.sg-file-input__row {
align-items: flex-start;
flex-direction: column;
}
}
.sg-file-input--disabled {
opacity: var(--disabled-opacity);
}
.sg-file-input--disabled .sg-file-input__trigger {
cursor: not-allowed;
}
/* ========================================================= */
/* Patterns: Datei-Upload */
/* ========================================================= */
.sg-file-upload {
--surface-file-upload-dropzone: var(--surface-form-preview);
--surface-file-upload-file-item: var(--surface-control-default);
--text-file-upload-meta: var(--text-control-disabled);
--text-file-upload-error: var(--color-signal-red);
display: flex;
flex-direction: column;
gap: var(--spacing-large);
width: 100%;
max-width: var(--layout-input-field-max-width);
}
.sg-file-upload__status,
.sg-file-upload__file-meta {
margin: var(--spacing-small) 0 0;
}
.sg-file-upload__dropzone {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-large);
width: 100%;
padding: var(--spacing-large);
background: var(--surface-file-upload-dropzone);
border: 1px dashed var(--surface-button-active);
border-radius: var(--radius-interaction);
}
.sg-file-upload__dropzone[data-drag-over="true"] {
outline: 2px solid var(--text-hyperlink);
outline-offset: 2px;
}
.sg-file-upload__files {
display: flex;
flex-direction: column;
gap: var(--spacing-small);
margin: 0;
padding: 0;
list-style: none;
}
.sg-file-upload__file {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-large);
padding: var(--spacing-small) var(--spacing-large);
color: var(--text-control-default);
background: var(--surface-file-upload-file-item);
border-radius: var(--radius-interaction);
}
.sg-file-upload__file-name {
min-width: 0;
overflow-wrap: anywhere;
}
.sg-file-upload__file-meta {
color: var(--text-file-upload-meta);
}
.sg-file-upload__remove {
flex: 0 0 auto;
}
.sg-file-upload__submit {
align-self: flex-end;
}
.sg-file-upload__error {
color: var(--text-file-upload-error);
}
@media (max-width: 48rem) {
.sg-file-upload__dropzone {
align-items: flex-start;
flex-direction: column;
}
.sg-file-upload__file {
align-items: flex-start;
flex-direction: column;
gap: var(--spacing-small);
}
}
/* ========================================================= */
/* Patterns: Portal Header */
/* ========================================================= */
@@ -5031,3 +5183,38 @@ main {
}
}
/* ========================================================= */
/* Patterns: Scrollable Card */
/* ========================================================= */
.sg-scrollable-card {
display: flex;
flex-direction: column;
width: var(--layout-scrollable-card-width);
max-width: 100%;
height: var(--layout-scrollable-card-height);
box-sizing: border-box;
}
.sg-scrollable-card__header,
.sg-scrollable-card__actions-segment {
flex: 0 0 auto;
}
.sg-scrollable-card__content {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
}
.sg-scrollable-card__actions {
display: flex;
gap: var(--spacing-small);
width: 100%;
}
.sg-scrollable-card__actions > .sg-button {
flex: 1 1 0;
min-width: 0;
}
+96 -3
View File
@@ -58,6 +58,10 @@ body.sg-bestellungen-mobile-detail-open > :not([data-bestellungen-mobile-detail-
display: none !important;
}
body.sg-lager-mobile-detail-open > :not([data-lager-mobile-detail-host]) {
display: none !important;
}
.sg-bestellungen-mobile-detail-host {
position: fixed;
inset: 0;
@@ -75,6 +79,48 @@ body.sg-bestellungen-mobile-detail-open > :not([data-bestellungen-mobile-detail-
width: 100%;
}
.sg-lager-mobile-detail-host {
position: fixed;
inset: 0;
z-index: 1500;
display: flex;
flex-direction: column;
gap: var(--spacing-large);
padding: var(--spacing-small);
box-sizing: border-box;
overflow: auto;
background: var(--color-background-purple);
}
.sg-lager-mobile-detail-host .sg-vsf-drawer-card {
width: 100%;
}
.sg-lager-mobile-detail-host .sg-card-list-page-drawer {
position: static;
display: flex;
width: 100%;
max-width: none;
transform: none;
box-shadow: none;
overflow: visible;
}
.sg-bestellungen-mobile-detail-host .sg-card-list-page-drawer {
position: static;
display: flex;
width: 100%;
max-width: none;
transform: none;
box-shadow: none;
overflow: visible;
}
.sg-mobile-detail-back-card,
.sg-mobile-detail-back-card * {
cursor: pointer;
}
.sg-otc-order-overlay {
position: fixed;
inset: 0;
@@ -91,9 +137,8 @@ body.sg-bestellungen-mobile-detail-open > :not([data-bestellungen-mobile-detail-
}
.sg-otc-order-overlay__panel {
width: min(960px, 100%);
max-height: calc(100vh - (2 * var(--spacing-large)));
overflow: auto;
--layout-scrollable-card-width: min(960px, 100%);
--layout-scrollable-card-height: min(600px, calc(100vh - (2 * var(--spacing-large))));
}
.sg-otc-order-overlay__title {
@@ -112,6 +157,19 @@ body.sg-bestellungen-mobile-detail-open > :not([data-bestellungen-mobile-detail-
color: var(--color-signal-red);
}
.sg-otc-order-overlay [data-otc-order-product] {
flex: 0 0 6rem;
width: 6rem;
max-width: 6rem;
margin-left: auto;
text-align: right;
}
.sg-otc-order-overlay .sg-otc-order-product-row .sg-label {
min-width: 0;
flex: 1 1 auto;
}
.sg-otc-order-overlay .sg-form-sections-card__field-group {
gap: var(--spacing-small);
}
@@ -128,6 +186,14 @@ body.sg-bestellungen-mobile-detail-open > :not([data-bestellungen-mobile-detail-
}
@media (max-width: 767px) {
.sg-otc-order-overlay [data-otc-order-product] {
flex: 1 1 auto;
width: 100%;
max-width: 100%;
margin-left: 0;
text-align: left;
}
.sg-large-table--bestellungen .sg-search-field-row {
width: min(100%, calc(var(--layout-search-field-width) * 0.75));
}
@@ -204,6 +270,33 @@ body.sg-bestellungen-mobile-detail-open > :not([data-bestellungen-mobile-detail-
color: var(--color-signal-yellow);
}
.sg-card-list-page-drawer [data-lager-edit-form][hidden],
.sg-card-list-page-drawer [data-lager-read-view][hidden],
.sg-card-list-page-drawer [data-lager-drawer-topic="new-lot-form"][hidden],
.sg-card-list-page-drawer [data-lager-new-lot-notification-wrapper][hidden],
.sg-card-list-page-drawer [data-lager-open-edit-form][hidden],
.sg-card-list-page-drawer [data-lager-open-edit-actions][hidden],
.sg-card-list-page-drawer [data-lager-open-card][hidden],
.sg-card-list-page-drawer [data-lager-edit-documents][hidden],
.sg-card-list-page-drawer [data-lager-edit-actions][hidden] {
display: none;
}
.sg-card-list-page-drawer .sg-file-upload {
max-width: none;
}
.sg-card-list-page-drawer .sg-file-upload__dropzone {
box-sizing: border-box;
}
.sg-card-list-page-drawer [data-lager-drawer-topic="new-lot-action"] {
display: flex;
align-items: center;
justify-content: flex-end;
gap: var(--spacing-small);
}
.sg-card-list-page-drawer__title {
color: var(--color-white);
}
+3 -1
View File
@@ -6,6 +6,7 @@ require_once __DIR__ . '/../modules/shared/auth/ui/login.php';
require_once __DIR__ . '/../modules/shared/auth/ui/home.php';
require_once __DIR__ . '/../modules/erp/bestellungen/service.php';
require_once __DIR__ . '/../modules/erp/lager/service.php';
require_once __DIR__ . '/../modules/erp/kontakte/service.php';
require_once __DIR__ . '/../modules/erp/artikel-mapping/service.php';
$env = expand_env_values(parse_env_file(__DIR__ . '/../.env'));
@@ -48,6 +49,7 @@ $currentUser = auth_current_user($pdo);
if ($currentUser !== null) {
$otcProducts = get_otc_order_form_products($pdo);
$lagerProducts = get_lager_product_overview($pdo);
$lagerManufacturers = get_manufacturer_options($pdo);
$lagerArtikel = get_lager_artikel_overview($pdo);
$bestellungenTable = get_sales_order_overview($pdo, [
'search' => (string) ($_GET['bestellungen_search'] ?? ''),
@@ -60,7 +62,7 @@ if ($currentUser !== null) {
echo auth_render_bestellungen_content($bestellungenTable);
exit;
}
render_auth_home_page($currentUser, $otcProducts, $bestellungenTable, $lagerProducts, $lagerArtikel);
render_auth_home_page($currentUser, $otcProducts, $bestellungenTable, $lagerProducts, $lagerArtikel, $lagerManufacturers);
exit;
}