diff --git a/ChangeLog.md b/ChangeLog.md
index 61e4efb..5b5162c 100644
--- a/ChangeLog.md
+++ b/ChangeLog.md
@@ -1,5 +1,123 @@
# Changelog
+## 1.5.0 — 2026-08-21
+
+### Anhänge-Spalte: Miniaturen statt Dateinamen
+
+- Die linke Spalte im Editor ist jetzt ein **Kachelraster mit Vorschaubildern**. Bei Fotos mit
+ Namen wie `IMG_20260812_141233_HDR.jpg` war in der 260 px schmalen Spalte vorher nicht zu
+ erkennen, welches Bild gemeint ist.
+- Neuer Endpoint **`ajax/attachment_thumb.php`**: erzeugt Miniaturen serverseitig mit GD,
+ legt sie unter `bericht/thumbs/` ab und liefert sie mit ETag aus. Die Originale (Handyfotos
+ mit mehreren MB) werden für die Spalte nicht mehr geladen.
+ EXIF-Ausrichtung wird berücksichtigt — mit eigenem Mini-Parser, weil das `exif`-Modul im
+ Prod-Container nicht geladen ist.
+- **PDF-Anhänge** bekommen Seite 1 als Miniatur (PDF.js, erst beim Scrollen gerendert).
+- **Klick auf eine Kachel öffnet die Großansicht** (`✕`/Esc schließt): Bilder mit Zoom, Pan und
+ Wischen, PDFs im PDF-Viewer des Browsers.
+- Auswahl per Kachel-Checkbox, die **Reihenfolge ist sichtbar (① ② ③)** und bestimmt jetzt auch
+ die Platzverteilung im Raster-Layout (vorher die Reihenfolge im Dokument).
+- Bereits verwendete Anhänge sind mit ✓ markiert, „alle"-Umschalter je Herkunft, Auswahlzähler,
+ Umschalter zwischen Kachel- und Listenansicht (wird gemerkt).
+
+### Arbeitstisch zeigt die echte Seite
+
+- Der Editor stellt jetzt **Blatt mit Kopfbereich, Seitenrändern und Fußbereich** dar; die
+ Arbeitsfläche ist exakt der Bereich, der gedruckt wird.
+- Neue gemeinsame Geometrie-Quelle `bericht_page_geometry()` — Editor und PDF-Erzeugung
+ rechnen mit denselben Werten (Rand 10 mm, Kopf 30 mm, Fuß 16 mm, Notizzone 25 %).
+- **Das PDF setzt die Arbeitsfläche 1:1 ein.** Vorher wurde das komplette Blatt noch einmal in
+ den Satzspiegel eingepasst, der Inhalt also ein zweites Mal verkleinert — daher der breite
+ Rand und der Unterschied zur Vorschau.
+- Alle Layout-Zweige (Einzelbild, Raster, Vorher/Nachher, Notiz) nutzen dieselbe Geometrie.
+ Seiten ohne gespeichertes Composite liefen vorher über den Kopfbereich mit Logo und Titel.
+- Umschalter „📐 Rand" blendet die Zonen aus.
+- Ältere Composites (Blatt-Proportion) werden erkannt und proportional eingepasst statt verzerrt.
+
+### Bild einer Seite austauschen
+
+- Neu: **`ajax/replace_page_source.php`**. Über „🔄" auf der Seitenminiatur lässt sich das Bild
+ einer bestehenden Seite ersetzen — Reihenfolge, Notiz und Anmerkungen bleiben erhalten.
+ Bisher musste man die Seite löschen und neu anhängen, wodurch sie ans Ende rutschte.
+- Bei Raster-Seiten ist der einzelne Platz austauschbar. Das dafür nötige `set_slot_image.php`
+ existierte bereits, hatte aber keine Bedienung.
+- Finalisierte Berichte werden serverseitig abgelehnt.
+
+### Arbeiten ohne ständiges Neuladen
+
+- Hinzufügen, Löschen, Hochladen, Bildtausch und der QR-Upload luden bisher die **ganze Seite neu**.
+ Der Editor sprang dabei auf Seite 1 zurück und der Zoom war weg — bei 20 Fotos hintereinander
+ entsprechend zäh. Jetzt werden nur noch die betroffenen Listen aktualisiert.
+- Neuer Endpoint **`ajax/fragments.php`** liefert Seiten- und Anhängeliste als HTML. Gerendert wird
+ über `bericht_print_page_list()` / `bericht_print_attachment_list()` — dieselben Funktionen, die
+ auch die Karte aufbauen, damit beide Darstellungen nicht auseinanderlaufen.
+- Nach dem Finalisieren wird weiterhin neu geladen: der Bericht wechselt den Status.
+
+### Automatisches Speichern
+
+- Änderungen werden **4 Sekunden nach der letzten Aktion automatisch gespeichert**, außerdem beim
+ Seitenwechsel und beim Wegschalten des Browser-Tabs. Bisher gab es nur „💾 Entwurf speichern".
+- In der Fußleiste steht der Stand („● nicht gespeichert" / „✓ gespeichert"), beim Verlassen der
+ Seite mit offenen Änderungen warnt der Browser.
+
+### Sortieren und Filtern der Anhänge
+
+- Sortierung nach **Aufnahmezeit** (auf/ab), Name oder Größe — bei Baustellenfotos ist die zeitliche
+ Reihenfolge meist genau die richtige. Filter: alle · nur Bilder · nur PDFs · noch nicht verwendet.
+- Die Aufnahmezeit steht unter der Kachel. Ausgelesen wird sie mit einem **eigenen EXIF-Parser**,
+ weil das `exif`-Modul im Prod-Container nicht geladen ist; gegen das PHP-Modul geprüft, identische
+ Werte. Ohne EXIF gilt das Dateidatum.
+
+### Bedienung: Tastenkürzel, Spaltenbreite, Ziehen und Ablegen
+
+- **Tastenkürzel**: `V` Auswahl, `P` Zeichnen, `R` Rechteck, `K` Kreis, `A` Pfeil, `T` Text,
+ `Entf` löscht die Auswahl, `Strg+Z`/`Strg+Y` Rückgängig/Wiederholen, `Strg+S` speichern,
+ `↑`/`↓` blättert durch die Seiten. In Eingabefeldern und bei offenem Dialog greifen sie nicht;
+ das Hintergrundbild lässt sich mit `Entf` nicht versehentlich entfernen.
+- **Anhänge-Spalte in der Breite ziehbar** (150–620 px, Doppelklick auf die Trennlinie setzt zurück)
+ und über „«" einklappbar — die Arbeitsfläche wird dabei neu gezeichnet.
+- **Kachelgröße** in drei Stufen über „⤢".
+- **Ziehen und Ablegen**: Kachel auf die Seitenliste legt neue Seiten an, Kachel auf die
+ Arbeitsfläche ersetzt das Bild der offenen Seite, Kachel auf einen Raster-Platz setzt genau
+ diesen. Dateien vom Rechner auf die Anhänge-Spalte werden hochgeladen.
+
+### Seiten: mehrere auf einmal, verdoppeln
+
+- Klick auf die **Seitennummer** markiert eine Seite, Umschalt erweitert bis dorthin. Über die
+ Sammelleiste lassen sich markierte Seiten **verdoppeln** oder **löschen**.
+- Neuer Endpoint **`ajax/page_bulk.php`**. Kopien werden direkt hinter ihrem Original einsortiert,
+ nach dem Löschen wird die Reihenfolge lückenlos neu nummeriert. Anmerkungen, Notiz und — bei
+ Rastern — die Bilder in den Plätzen werden mitkopiert; das gespeicherte Seitenbild bewusst nicht,
+ sonst zeigten Original und Kopie auf dieselbe Datei.
+
+### Raster-Plätze direkt auf der Seite
+
+- Bei Raster-Layouts liegt über der Arbeitsfläche ein **Gitter mit einer Schaltfläche je Platz**
+ (bei Vorher/Nachher entsprechend beschriftet). Ein Klick setzt das links ausgewählte Bild, ein
+ abgelegtes Bild setzt genau diesen Platz. Bisher ließen sich Raster nur beim Anlegen befüllen —
+ der Endpoint dafür existierte, hatte aber keine Bedienung.
+- Die Plätze reagieren nur im Auswahlmodus; sobald ein Zeichenwerkzeug aktiv ist, haben die
+ Werkzeuge Vorrang.
+
+### Behobene Fehler
+
+- `dol_dir_list()` wurde ohne `$mode = 1` aufgerufen — `size` und `date` blieben leer, in der
+ Anhängeliste stand „ Bytes" ohne Zahl.
+- Der PDF.js-Worker-Pfad war ohne `/custom`-Präfix hartkodiert. PDF.js fiel auf den
+ „fake worker" zurück und rendert seitdem wieder im Worker statt auf dem Hauptthread.
+- PDF-Anhänge wurden über `document.php` **ohne** `attachment=0` verlinkt und dadurch
+ heruntergeladen statt angezeigt.
+- Die Größen-Auswahl einer Seite blieb leer: der DB-Wert `1` traf die Option `1.0` nicht.
+- Der Datei-Upload verarbeitete nur die erste Datei einer Mehrfachauswahl.
+- Eingabefelder im Editor nutzen jetzt `--inputbackgroundcolor`/`--inputbordercolor`. Die
+ Modul-Doku hielt die Variablen für im AwlDark nicht vorhanden — sie sind gesetzt (KB #512).
+- **Schneller Seitenwechsel konnte Inhalte vertauschen**: zwei Durchläufe von `loadPage()` liefen
+ ineinander — der zweite speicherte die noch nicht umgeschaltete Seite ein weiteres Mal, und beide
+ zeichneten in denselben Canvas. Dabei konnte die Notiz der einen Seite bei der anderen landen.
+ Seitenwechsel laufen jetzt nacheinander; ein überholter Durchlauf bricht ab.
+- Der Dialog beim Löschen eines Anhangs sagt jetzt, dass die Datei **endgültig aus den Dokumenten
+ des Belegs** verschwindet, und warnt, wenn sie im Bericht in Gebrauch ist.
+
## 1.4.0 — 2026-08-15
### Kein Stunden-Dummy mehr beim Direkt-Freigeben
diff --git a/ROADMAP.md b/ROADMAP.md
new file mode 100644
index 0000000..d8c98b3
--- /dev/null
+++ b/ROADMAP.md
@@ -0,0 +1,151 @@
+# ROADMAP — Modul Bericht
+
+Stand: 2026-08-21 · Version im Repo: 1.5.0
+
+---
+
+## 1. Anhänge-Spalte (links im Editor) — Hauptbaustelle
+
+Ausgangslage (Eddy, 21.08.2026): *„die Dateinamen können in dem schmalen Fenster nicht ganz
+angezeigt werden, ich habe Schwierigkeiten die richtigen Bilder auszusuchen."*
+Die Spalte ist 260 px breit und zeigt pro Datei nur eine Zeile `🖼 langer_dateiname… 2,3 MB 🗑️`.
+Bei 20–40 Baustellenfotos mit Namen wie `IMG_20260812_141233_HDR.jpg` ist das unbrauchbar.
+
+### 1.1 Miniatur-Vorschau statt Dateiname ✅ 1.5.0
+- [x] Kachel-Raster (2 Spalten) statt Liste
+- [x] Server-Thumbnails für Bilder (GD, gecacht) — Original-Handyfotos werden **nicht** mehr voll geladen
+- [x] PDF-Anhänge: Seite 1 als Miniatur (PDF.js, clientseitig, lazy)
+- [x] Klick auf Miniatur → Lightbox (Zoom/Pan/Swipe), `✕` bzw. Esc schließt — PDFs im Vollbild-Viewer
+- [x] Dateiname zweizeilig unter der Kachel + voller Name als Tooltip
+
+### 1.2 Auswahl bedienbar machen ✅ 1.5.0
+- [x] Checkbox als Overlay auf der Kachel (großes Klickziel), Klick auf die Namenszeile wählt ebenfalls aus
+- [x] Auswahl-Reihenfolge sichtbar (① ② ③) — bestimmt die Slot-Reihenfolge im Grid-Layout
+ (vorher: DOM-Reihenfolge, also nicht steuerbar)
+- [x] „Alle / Keine" pro Herkunfts-Gruppe + Zähler „3 ausgewählt"
+- [x] Bereits im Bericht verwendete Anhänge werden markiert (✓-Badge, ausgegraut)
+
+### 1.3 Sortierung und Filter ✅ 1.5.0
+- [x] Sortierung nach **Aufnahmezeit** (auf- und absteigend), Name, Größe — jeweils innerhalb der
+ Herkunfts-Gruppe, damit die Zuordnung zum Beleg erhalten bleibt
+- [x] Filter: alle · nur Bilder · nur PDFs · noch nicht verwendet
+- [x] Aufnahmezeit steht unter der Kachel (`12.08. 11:48 · 56 Kb`)
+- [x] EXIF-Parser in PHP (`bericht_jpeg_taken_at`), weil das `exif`-Modul im Container
+ `90-Dolibarr-Prod-Custom` **nicht geladen** ist. Gegen das PHP-Modul geprüft: identische Werte.
+ Fallback auf das Dateidatum, wenn kein EXIF vorhanden ist.
+- [x] Auswahl von Sortierung/Filter/Ansicht wird gemerkt (localStorage)
+
+### 1.4 Spaltenbreite, Kachelgröße, Ziehen und Ablegen ✅ 1.5.0
+- [x] **Spaltenbreite ziehbar** — Trennlinie zwischen Anhängen und Arbeitsfläche (150–620 px),
+ Doppelklick setzt auf 260 px zurück; Breite wird gemerkt
+- [x] **Ein-/ausklappbar** über „«" in der Kopfzeile — eingeklappt bleiben 26 px stehen
+- [x] **Kachelgröße** klein / mittel / groß über „⤢" (in der kleinsten Stufe entfällt die Größenangabe)
+- [x] **Kachel auf die Seitenliste ziehen** → neue Seite(n). Sind mehrere Kacheln angekreuzt und man
+ zieht eine davon, kommen alle mit.
+- [x] **Kachel auf die Arbeitsfläche ziehen** → ersetzt das Bild der offenen Seite
+- [x] **Kachel auf einen Raster-Platz ziehen** → setzt genau diesen Platz
+- [x] **Dateien vom Rechner auf die Spalte ziehen** → Upload (nicht unterstützte Typen werden
+ benannt und übersprungen)
+- [x] SortableJS bekam `group: {put: false}` — sonst streiten sich Sortierung und Ablegen um die
+ gleichen Ereignisse
+
+---
+
+## 2. Seitenansicht ("Arbeitstisch") ✅ 1.5.0
+
+Vorher zeigte der Editor ein A4-Blatt, auf dem das Foto formatfüllend lag. Beim PDF wurde
+dieses ganze Blatt **nochmal** in den Satzspiegel eingepasst — der Inhalt schrumpfte also ein
+zweites Mal, und die Vorschau entsprach nicht dem Ergebnis. Seiten, die nie im Editor
+gespeichert wurden, liefen zudem über den Kopfbereich (Logo/Titel) hinweg.
+
+- [x] Eine gemeinsame Geometrie-Quelle: `bericht_page_geometry($format, $orientation, $has_note)`
+ in `lib/bericht.lib.php` — liefert Seitenmaß, Ränder (10 mm), Kopfbereich (30 mm),
+ Fußbereich (16 mm), Notizzone und den Inhaltsbereich. Editor **und** PDF rechnen damit.
+- [x] Arbeitsfläche = Inhaltsbereich. Drumherum das Blatt mit schraffierter Kopf-/Fußzone
+ (das, was TCPDF selbst druckt) und einer Notizzone, sobald die Seite eine Notiz hat.
+- [x] PDF setzt das Composite 1:1 in den Inhaltsbereich statt es erneut einzupassen
+- [x] Alle Layout-Zweige der PDF-Erzeugung (Einzelbild, Raster, Vorher/Nachher, Notiz)
+ auf dieselbe Geometrie umgestellt — vorher rechneten sie mit `margin = 10` ab
+ Blattkante und schoben Bilder unter den Kopfbereich
+- [x] Raster-Vorschau (`ajax/page_image.php`) rendert im Format des Inhaltsbereichs
+- [x] Umschalter „📐 Rand" in der Werkzeugleiste (Zustand wird gemerkt)
+- [x] Rückwärtskompatibel: Composites aus der Zeit davor haben Blatt-Proportion und werden
+ an der Proportion erkannt und proportional eingepasst statt verzerrt
+
+---
+
+## 3. Bild einer Seite austauschen ✅ 1.5.0
+
+- [x] `ajax/replace_page_source.php` — tauscht die Quelle einer Seite; Reihenfolge, Notiz und
+ Anmerkungen bleiben erhalten (vorher: Seite löschen und hinten neu anhängen)
+- [x] Raster-Seiten: einzelner Platz austauschbar (nutzt das vorhandene `set_slot_image.php`,
+ für das es bis dahin keine Bedienung gab)
+- [x] Bedienung: „🔄" auf der Seitenminiatur → Statusleiste am unteren Rand → links auf das
+ Ersatzbild klicken. Esc bricht ab. Bei Rastern vorher den Platz (1…n) wählen.
+- [x] Finalisierte Berichte werden serverseitig abgewiesen
+
+---
+
+## 4. Editor / Arbeitsablauf
+
+- [x] **Kein `location.reload()` mehr** ✅ 1.5.0 — Hinzufügen, Löschen, Hochladen, Bildtausch und
+ der QR-Upload aktualisieren nur noch die betroffenen Listen. Neuer Endpoint
+ `ajax/fragments.php` liefert Seiten- und Anhängeliste als HTML; gerendert wird über
+ `bericht_print_page_list()` / `bericht_print_attachment_list()`, dieselben Funktionen wie beim
+ Seitenaufbau. Der Editor bleibt auf der bearbeiteten Seite, Zoom und Ansicht bleiben erhalten.
+ Einzige Ausnahme: nach dem Finalisieren wird bewusst neu geladen, weil der Bericht den Status
+ wechselt und die ganze Karte anders aussieht.
+- [x] **Autosave** ✅ 1.5.0 — 4 Sekunden nach der letzten Änderung wird gespeichert, ebenso beim
+ Seitenwechsel und beim Wegschalten des Tabs. Zustand steht in der Fußleiste
+ („● nicht gespeichert" / „✓ gespeichert"), beim Verlassen der Seite warnt der Browser.
+ Wichtig: das Aufbauen einer Seite (und Zoomen) löst Fabric-Events aus, zählt aber nicht als
+ Änderung — sonst stünde direkt nach dem Öffnen „nicht gespeichert" da.
+- [x] **Tastenkürzel** ✅ 1.5.0 — V/P/R/K/A/T für die Werkzeuge, Entf löscht die Auswahl (das
+ Hintergrundbild ist ausgenommen), Strg+Z/Y, Strg+S, ↑/↓ blättert durch die Seiten.
+ In Eingabefeldern und bei offenem Dialog oder offener Großansicht greifen sie nicht.
+ Die Kürzel stehen in den Tooltips der Schaltflächen.
+- [x] **Seitenliste: Mehrfachauswahl** ✅ 1.5.0 — Klick auf die Seitennummer markiert, Umschalt
+ erweitert bis dorthin. Sammelleiste mit Verdoppeln (⧉), Löschen (🗑️) und Aufheben (Esc).
+ Neuer Endpoint `ajax/page_bulk.php`; Kopien landen direkt hinter ihrem Original
+ (`bericht_reorder_with_copies()`), nach dem Löschen wird lückenlos neu nummeriert.
+ `composite_path` wird bewusst **nicht** mitkopiert — sonst zeigten beide Seiten auf dieselbe
+ Datei und das Bearbeiten der einen überschriebe die andere.
+- [x] **Raster-Plätze direkt anklicken** ✅ 1.5.0 — über der Arbeitsfläche liegt ein Gitter mit
+ einer Schaltfläche je Platz (bei Vorher/Nachher entsprechend beschriftet). Klick setzt das
+ links ausgewählte Bild, Ablegen per Drag setzt das gezogene. Die Aufteilung entspricht
+ `BerichtPage::slotRectsInBox()`. Die Plätze reagieren nur im Auswahlmodus — beim Zeichnen
+ haben die Werkzeuge Vorrang, sonst käme man auf Raster-Seiten nicht mehr zum Zeichnen.
+- [x] **Seitenwechsel serialisiert** ✅ 1.5.0 — beim schnellen Durchklicken überlappten sich zwei
+ Durchläufe von `loadPage()`: der zweite speicherte die noch nicht umgeschaltete Seite ein
+ zweites Mal, und beide rendern in denselben Canvas — so konnte die Notiz der einen Seite bei
+ der anderen landen. Jeder Aufruf bekommt jetzt eine Nummer und bricht ab, sobald ein neuerer
+ gestartet ist.
+
+## 5. Kleinkram / Fixes
+
+- [x] **Mehrfach-Upload**: `bindExtraUpload` verarbeitete nur `inp.files[0]` — Mehrfachauswahl im
+ Dateidialog war möglich, es kam aber nur eine Datei an ✅ 1.5.0
+- [x] Upload-Fortschritt ✅ 1.5.0 — der Knopf zeigt beim Mehrfach-Upload „⏳ 3 / 8"
+- [x] `att-delete` löscht die Datei wirklich aus den Dokumenten des Belegs ✅ 1.5.0 — der Dialog
+ sagt das jetzt deutlich und warnt zusätzlich, wenn die Datei im Bericht in Gebrauch ist.
+
+---
+
+## Erledigt
+
+### 1.5.0 — 2026-08-21
+Vollständig abgearbeitet. Anhänge-Kachelraster mit Miniaturen (1.1–1.4), Server-Thumbnail-Endpoint,
+PDF-Lightbox, Arbeitstisch mit echter Seitengeometrie (2), Bildaustausch auf bestehenden Seiten (3),
+Arbeiten ohne Neuladen, Autosave, Tastenkürzel, Sammelaktionen auf Seiten und Raster-Plätze (4).
+
+Nebenbei gefundene und behobene Fehler:
+- `dol_dir_list()` wurde ohne `$mode = 1` aufgerufen — dadurch waren `size` und `date` leer
+ und in der Anhängeliste stand „ Bytes" ohne Zahl
+- PDF.js-Worker-Pfad war hartkodiert ohne `/custom`-Präfix → PDF.js lief im „fake worker"-Modus
+ auf dem Hauptthread (spürbar langsamer beim Rendern)
+- `document.php` wurde ohne `attachment=0` verlinkt → PDFs wurden heruntergeladen statt angezeigt
+- Größen-Auswahl der Seite blieb leer, weil der DB-Wert `1` die Option `1.0` nicht traf
+- Mehrfach-Upload nahm nur die erste Datei
+- `--inputbackgroundcolor` wurde im Modul als „im AwlDark nicht vorhanden" behandelt; die
+ Variable existiert (KB #512, per DevTools bestätigt) — Editor-Eingabefelder nutzen jetzt
+ dieselben Farben wie Dolibarr selbst
diff --git a/ajax/attachment_thumb.php b/ajax/attachment_thumb.php
new file mode 100644
index 0000000..dc411a6
--- /dev/null
+++ b/ajax/attachment_thumb.php
@@ -0,0 +1,55 @@
+ 0 && $j > 0 && isset($tmp[$i]) && isset($tmp2[$j]) && $tmp[$i] == $tmp2[$j]) { $i--; $j--; }
+if (!$res && $i > 0 && file_exists(substr($tmp, 0, ($i + 1))."/main.inc.php")) $res = @include substr($tmp, 0, ($i + 1))."/main.inc.php";
+if (!$res && $i > 0 && file_exists(dirname(substr($tmp, 0, ($i + 1)))."/main.inc.php")) $res = @include dirname(substr($tmp, 0, ($i + 1)))."/main.inc.php";
+if (!$res && file_exists("../../main.inc.php")) $res = @include "../../main.inc.php";
+if (!$res && file_exists("../../../main.inc.php")) $res = @include "../../../main.inc.php";
+if (!$res) die("Include of main fails");
+
+require_once __DIR__.'/../lib/bericht.lib.php';
+
+if (!$user->hasRight('bericht', 'read')) accessforbidden();
+
+$relpath = GETPOST('relpath', 'alpha');
+// GETPOST 'alpha' wuerde Slashes behalten, aber Umlaute/Leerzeichen in Dateinamen
+// killen - deshalb den Rohwert nehmen und selbst absichern.
+if (isset($_GET['relpath'])) $relpath = $_GET['relpath'];
+$maxside = GETPOSTINT('w');
+if ($maxside <= 0) $maxside = 260;
+if ($maxside > 800) $maxside = 800;
+
+if ($relpath === '' || strpos($relpath, "\0") !== false) { http_response_code(400); exit; }
+
+$full = bericht_resolve_data_path($relpath);
+if (!$full || !is_file($full)) { http_response_code(404); exit; }
+
+$thumb = bericht_attachment_thumb($full, $maxside);
+if ($thumb === null) { http_response_code(415); exit; }
+
+// Bedingtes GET: spart bei 40 Kacheln jede Menge Traffic
+$etag = '"'.md5($full.'|'.filemtime($full).'|'.$maxside).'"';
+header('ETag: '.$etag);
+header('Cache-Control: private, max-age=86400');
+if (isset($_SERVER['HTTP_IF_NONE_MATCH']) && trim($_SERVER['HTTP_IF_NONE_MATCH']) === $etag) {
+ http_response_code(304);
+ exit;
+}
+
+header('Content-Type: image/jpeg');
+header('Content-Length: '.strlen($thumb));
+echo $thumb;
diff --git a/ajax/fragments.php b/ajax/fragments.php
new file mode 100644
index 0000000..23a770f
--- /dev/null
+++ b/ajax/fragments.php
@@ -0,0 +1,51 @@
+fetch($berichtid) <= 0) bericht_ajax_fail('Bericht nicht gefunden', 404);
+
+$langs->loadLangs(array('bericht@bericht', 'other'));
+
+$out = array();
+$pages = BerichtPage::fetchAllForBericht($db, $bericht->id);
+$out['page_count'] = count($pages);
+
+if ($what === 'pages' || $what === 'both') {
+ ob_start();
+ bericht_print_page_list($pages);
+ $out['pages_html'] = ob_get_clean();
+}
+
+if ($what === 'attachments' || $what === 'both') {
+ $parent = bericht_fetch_parent($db, $bericht->element_type, $bericht->fk_element);
+ if (!$parent) bericht_ajax_fail('Verknüpftes Objekt nicht gefunden', 404);
+
+ $attachments = bericht_collect_attachments($db, $parent, $bericht->element_type);
+ $used_paths = bericht_used_source_paths($db, $bericht->id);
+
+ $by_source = array();
+ foreach ($attachments as $a) {
+ $by_source[$a['source'].':'.$a['source_ref']][] = $a;
+ }
+
+ ob_start();
+ bericht_print_attachment_list($by_source, $used_paths);
+ $out['attachments_html'] = ob_get_clean();
+ $out['attachment_count'] = count($attachments);
+}
+
+bericht_ajax_ok($out);
diff --git a/ajax/page_bulk.php b/ajax/page_bulk.php
new file mode 100644
index 0000000..1bca4d2
--- /dev/null
+++ b/ajax/page_bulk.php
@@ -0,0 +1,105 @@
+hasRight('bericht', 'write')) bericht_ajax_fail('Permission denied', 403);
+
+$berichtid = (int) ($_POST['berichtid'] ?? 0);
+$action = (string) ($_POST['action'] ?? '');
+$ids_raw = (string) ($_POST['pageids'] ?? '[]');
+
+if (!$berichtid) bericht_ajax_fail('berichtid fehlt');
+if (!in_array($action, array('delete', 'duplicate'), true)) bericht_ajax_fail('Unbekannte Aktion');
+
+$ids = json_decode($ids_raw, true);
+if (!is_array($ids) || empty($ids)) bericht_ajax_fail('Keine Seiten ausgewählt');
+$ids = array_values(array_unique(array_filter(array_map('intval', $ids))));
+if (empty($ids)) bericht_ajax_fail('Keine gültigen Seiten');
+
+$b = new Bericht($db);
+if ($b->fetch($berichtid) <= 0) bericht_ajax_fail('Bericht nicht gefunden', 404);
+if ((int) $b->status === Bericht::STATUS_FINAL) {
+ bericht_ajax_fail('Der Bericht ist finalisiert und kann nicht mehr geändert werden', 403);
+}
+
+// Nur Seiten dieses Berichts anfassen
+$in = implode(',', $ids);
+$check = $db->query("SELECT rowid FROM ".$db->prefix()."bericht_page"
+ ." WHERE rowid IN (".$in.") AND fk_bericht = ".((int) $berichtid));
+if (!$check) bericht_ajax_fail($db->lasterror());
+$valid = array();
+while ($r = $db->fetch_object($check)) $valid[] = (int) $r->rowid;
+if (count($valid) !== count($ids)) bericht_ajax_fail('Seiten gehören nicht zu diesem Bericht', 403);
+
+$db->begin();
+
+if ($action === 'delete') {
+ $sql = "DELETE FROM ".$db->prefix()."bericht_page WHERE rowid IN (".implode(',', $valid).")";
+ if (!$db->query($sql)) {
+ $db->rollback();
+ bericht_ajax_fail($db->lasterror());
+ }
+ bericht_renumber_pages($db, $berichtid);
+ $db->commit();
+ bericht_ajax_ok(array('deleted' => count($valid)));
+}
+
+/* --- Duplizieren --- */
+$pages = BerichtPage::fetchAllForBericht($db, $berichtid);
+$by_id = array();
+foreach ($pages as $p) $by_id[(int) $p->id] = $p;
+
+$created = 0;
+foreach ($valid as $pid) {
+ if (!isset($by_id[$pid])) continue;
+ $src = $by_id[$pid];
+
+ $copy = new BerichtPage($db);
+ $copy->fk_bericht = $berichtid;
+ // Vorlaeufig ans Ende; die endgueltige Reihenfolge macht die Neunummerierung
+ $copy->page_order = 9000 + (int) $src->page_order;
+ $copy->source_type = $src->source_type;
+ $copy->source_path = $src->source_path;
+ $copy->source_page = $src->source_page;
+ $copy->rotation = $src->rotation;
+ $copy->fabric_json = $src->fabric_json;
+ $copy->note = $src->note;
+ $copy->title = $src->title;
+ $copy->layout = $src->layout;
+ $copy->image_scale = $src->image_scale;
+ $copy->image_align = $src->image_align;
+
+ $newid = $copy->create();
+ if ($newid <= 0) {
+ $db->rollback();
+ bericht_ajax_fail('Kopie konnte nicht angelegt werden');
+ }
+
+ // Bilder der Plaetze mitkopieren (Raster-Layouts)
+ if ($src->layout !== 'single') {
+ foreach ($src->getImages() as $img) {
+ if ($copy->setSlotImage($img['slot'], $img['source_path'], $img['rotation']) <= 0) {
+ $db->rollback();
+ bericht_ajax_fail('Bilder der Kopie konnten nicht angelegt werden');
+ }
+ }
+ }
+ // composite_path bewusst NICHT kopieren: die Kopie erzeugt beim ersten
+ // Speichern ihr eigenes, sonst zeigen beide Seiten auf dieselbe Datei
+ // und das Bearbeiten der einen ueberschreibt die andere.
+
+ $created++;
+}
+
+// Kopien direkt hinter ihr Original einsortieren
+bericht_reorder_with_copies($db, $berichtid, $valid);
+$db->commit();
+bericht_ajax_ok(array('created' => $created));
diff --git a/ajax/page_image.php b/ajax/page_image.php
index 7532619..c95e059 100644
--- a/ajax/page_image.php
+++ b/ajax/page_image.php
@@ -30,6 +30,16 @@ if (!$row) { http_response_code(404); exit; }
// Bei Multi-Image-Seiten: serverseitig ein Composite-Bild rendern (PNG)
if ($row->layout !== 'single') {
+ // Format/Orientierung des Berichts holen, damit die Vorschau dieselbe
+ // Proportion hat wie der Inhaltsbereich im Editor und im PDF
+ $bfmt = 'A4'; $bori = 'P';
+ $bq = $db->query("SELECT b.page_format, b.page_orientation FROM ".$db->prefix()."bericht b"
+ ." INNER JOIN ".$db->prefix()."bericht_page p ON p.fk_bericht = b.rowid"
+ ." WHERE p.rowid = ".((int) $pageid));
+ if ($bq && ($brow = $db->fetch_object($bq))) {
+ if (in_array($brow->page_format, array('A4','A3','A5','Letter'), true)) $bfmt = $brow->page_format;
+ if (in_array($brow->page_orientation, array('P','L'), true)) $bori = $brow->page_orientation;
+ }
$imgs_q = $db->query("SELECT slot, source_path FROM ".$db->prefix()."bericht_page_image WHERE fk_page = ".((int) $pageid)." ORDER BY slot");
if (!$imgs_q) { http_response_code(500); exit; }
$imgs = array();
@@ -38,7 +48,7 @@ if ($row->layout !== 'single') {
}
if (empty($imgs)) { http_response_code(404); exit; }
- $composite = bericht_render_grid_png($row->layout, $imgs);
+ $composite = bericht_render_grid_png($row->layout, $imgs, bericht_page_geometry($bfmt, $bori, false));
if (!$composite) { http_response_code(500); exit; }
header('Content-Type: image/png');
header('Cache-Control: no-store');
@@ -58,39 +68,37 @@ readfile($full);
/**
* Erstellt ein Composite-PNG für eine Multi-Image-Seite (Editor-Vorschau).
*/
-function bericht_render_grid_png($layout, $imgs)
+function bericht_render_grid_png($layout, $imgs, array $g)
{
- // A4 Hochformat als Grundfläche, 150 DPI
- $W = 1240; // ~A4 @ 150dpi
- $H = 1754;
- $margin = 40;
- $gap = 16;
+ // Grundfläche = Inhaltsbereich der Seite bei ~150 DPI, damit die Vorschau
+ // exakt der Fläche entspricht, die das PDF später bedruckt.
+ $px_per_mm = 150 / 25.4;
+ $W = (int) round($g['contentW'] * $px_per_mm);
+ $H = (int) round($g['contentH'] * $px_per_mm);
+ $gap = (int) round(4 * $px_per_mm);
$canvas = imagecreatetruecolor($W, $H);
imagefill($canvas, 0, 0, imagecolorallocate($canvas, 255, 255, 255));
- // Slot-Berechnung (gleiche Logik wie BerichtPage::slotRects, aber in Pixel)
- $iw = $W - 2 * $margin;
- $ih = $H - 2 * $margin - 60; // 60px für Notiz-Bereich
-
+ // Slot-Aufteilung wie BerichtPage::slotRectsInBox, hier in Pixel
$rects = array();
if ($layout === 'grid_2') {
- $h = ($ih - $gap) / 2;
- for ($i = 0; $i < 2; $i++) $rects[] = array($margin, $margin + $i * ($h + $gap), $iw, $h);
- } elseif ($layout === 'grid_2v') {
- $w = ($iw - $gap) / 2;
- for ($i = 0; $i < 2; $i++) $rects[] = array($margin + $i * ($w + $gap), $margin, $w, $ih);
+ $h = ($H - $gap) / 2;
+ for ($i = 0; $i < 2; $i++) $rects[] = array(0, $i * ($h + $gap), $W, $h);
+ } elseif ($layout === 'grid_2v' || $layout === 'before_after') {
+ $w = ($W - $gap) / 2;
+ for ($i = 0; $i < 2; $i++) $rects[] = array($i * ($w + $gap), 0, $w, $H);
} elseif ($layout === 'grid_4') {
- $w = ($iw - $gap) / 2;
- $h = ($ih - $gap) / 2;
+ $w = ($W - $gap) / 2;
+ $h = ($H - $gap) / 2;
for ($r = 0; $r < 2; $r++) for ($c = 0; $c < 2; $c++) {
- $rects[] = array($margin + $c * ($w + $gap), $margin + $r * ($h + $gap), $w, $h);
+ $rects[] = array($c * ($w + $gap), $r * ($h + $gap), $w, $h);
}
} elseif ($layout === 'grid_6') {
- $w = ($iw - 2 * $gap) / 3;
- $h = ($ih - $gap) / 2;
+ $w = ($W - 2 * $gap) / 3;
+ $h = ($H - $gap) / 2;
for ($r = 0; $r < 2; $r++) for ($c = 0; $c < 3; $c++) {
- $rects[] = array($margin + $c * ($w + $gap), $margin + $r * ($h + $gap), $w, $h);
+ $rects[] = array($c * ($w + $gap), $r * ($h + $gap), $w, $h);
}
}
diff --git a/ajax/replace_page_source.php b/ajax/replace_page_source.php
new file mode 100644
index 0000000..47d67f5
--- /dev/null
+++ b/ajax/replace_page_source.php
@@ -0,0 +1,66 @@
+hasRight('bericht', 'write')) bericht_ajax_fail('Permission denied', 403);
+
+$pageid = (int) ($_POST['pageid'] ?? 0);
+$relpath = (string) ($_POST['relpath'] ?? '');
+$mime = (string) ($_POST['mime'] ?? '');
+$slot = isset($_POST['slot']) && $_POST['slot'] !== '' ? (int) $_POST['slot'] : null;
+
+if (!$pageid || $relpath === '') bericht_ajax_fail('Parameter fehlen');
+
+$full = bericht_resolve_data_path($relpath);
+if (!$full || !file_exists($full)) bericht_ajax_fail('Datei nicht gefunden', 404);
+
+$res = $db->query("SELECT rowid, fk_bericht, COALESCE(layout,'single') AS layout"
+ ." FROM ".$db->prefix()."bericht_page WHERE rowid = ".((int) $pageid));
+if (!$res || !($row = $db->fetch_object($res))) bericht_ajax_fail('Seite nicht gefunden', 404);
+
+// Bericht darf nicht finalisiert sein
+$b = new Bericht($db);
+if ($b->fetch((int) $row->fk_bericht) <= 0) bericht_ajax_fail('Bericht nicht gefunden', 404);
+if ((int) $b->status === Bericht::STATUS_FINAL) {
+ bericht_ajax_fail('Der Bericht ist finalisiert und kann nicht mehr geändert werden', 403);
+}
+
+if ($row->layout !== 'single' && $slot !== null) {
+ // Grid: nur den einen Slot ersetzen
+ if (strpos($mime, 'image') !== 0 && $mime !== '') {
+ bericht_ajax_fail('In Raster-Layouts sind nur Bilder möglich');
+ }
+ $slots = BerichtPage::slotCountForLayout($row->layout);
+ if ($slot < 0 || $slot >= $slots) bericht_ajax_fail('Ungültiger Slot');
+
+ $p = new BerichtPage($db);
+ $p->id = (int) $row->rowid;
+ $p->fk_bericht = (int) $row->fk_bericht;
+ $p->layout = $row->layout;
+ if ($p->setSlotImage($slot, $relpath, 0) > 0) {
+ bericht_ajax_ok(array('slot' => $slot));
+ }
+ bericht_ajax_fail('DB-Fehler beim Ersetzen des Slots');
+}
+
+// Single: source_path/source_type der Seite ersetzen
+$source_type = (strpos($mime, 'pdf') !== false || preg_match('/\.pdf$/i', $relpath)) ? 'pdf' : 'image';
+$sql = "UPDATE ".$db->prefix()."bericht_page SET"
+ ." source_path = '".$db->escape($relpath)."',"
+ ." source_type = '".$db->escape($source_type)."',"
+ ." source_page = 1,"
+ ." rotation = 0"
+ ." WHERE rowid = ".((int) $pageid);
+if ($db->query($sql)) {
+ bericht_ajax_ok(array('source_type' => $source_type));
+}
+bericht_ajax_fail('DB-Fehler beim Ersetzen der Seite');
diff --git a/bericht_card.php b/bericht_card.php
index 1840baa..d994a2c 100644
--- a/bericht_card.php
+++ b/bericht_card.php
@@ -308,6 +308,8 @@ document.addEventListener("click", function(e) {
/*
* MODUS B: Editor — bestehender Bericht wird bearbeitet
*/
+ require_once DOL_DOCUMENT_ROOT.'/societe/class/societe.class.php';
+
$pages = BerichtPage::fetchAllForBericht($db, $bericht->id);
$attachments = bericht_collect_attachments($db, $parent, $element);
$templates = bericht_list_templates();
@@ -336,6 +338,14 @@ document.addEventListener("click", function(e) {
'list_pages' => dol_buildpath('/bericht/ajax/list_pages.php', 1),
'verify_signature' => dol_buildpath('/bericht/ajax/verify_signature.php', 1),
'save_as_template' => dol_buildpath('/bericht/ajax/save_as_template.php', 1),
+ 'replace_page_source' => dol_buildpath('/bericht/ajax/replace_page_source.php', 1),
+ 'fragments' => dol_buildpath('/bericht/ajax/fragments.php', 1),
+ 'page_bulk' => dol_buildpath('/bericht/ajax/page_bulk.php', 1),
+ 'pdf_worker' => dol_buildpath('/bericht/js/lib/pdf.worker.min.js', 1),
+ ),
+ 'geometry' => array(
+ 'plain' => bericht_page_geometry($bericht->page_format ?: 'A4', $bericht->page_orientation ?: 'P', false),
+ 'note' => bericht_page_geometry($bericht->page_format ?: 'A4', $bericht->page_orientation ?: 'P', true),
),
'lang' => array(
'undo' => $langs->trans("BerichtUndo"),
@@ -375,46 +385,118 @@ document.addEventListener("click", function(e) {
if (count($vs) > 1) $version_links = ' Versionen: '.implode(' · ', $vs).'';
}
- print '