From 4102ebfe5bd494b706cd03988d3fe92be0efbb15 Mon Sep 17 00:00:00 2001 From: Eduard Wisch Date: Sat, 22 Aug 2026 00:33:20 +0200 Subject: [PATCH] Editor-Ueberarbeitung 1.5.0: Anhaenge-Kacheln, echte Seitengeometrie, Arbeiten ohne Reload [deploy] Anhaenge-Spalte: Kachelraster mit Server-Thumbnails statt Dateinamen-Liste (Bilder per GD, PDFs per PDF.js), Grossansicht per Klick, Auswahlreihenfolge sichtbar, Sortierung/Filter (Aufnahmezeit per eigenem EXIF-Parser, da im Prod-Container ohne exif-Modul), ziehbare Spaltenbreite, Kachelgroesse, Drag & Drop auf Seitenliste/Arbeitsflaeche/Raster-Plaetze. Arbeitstisch zeigt jetzt Kopf-, Inhalts- und Fussbereich passend zur echten Seitengeometrie (bericht_page_geometry()) - Editor und PDF-Erzeugung rechnen mit denselben Werten, das PDF setzt die Arbeitsflaeche 1:1 statt sie ein zweites Mal einzupassen. Neu: Bild einer Seite austauschen (auch einzelne Raster-Plaetze), Seiten mehrfach markieren und sammeln loeschen/duplizieren, Autosave mit Verlassen-Warnung, Tastenkuerzel, kein location.reload() mehr nach Hinzufuegen/Loeschen/Hochladen (ajax/fragments.php). Nebenbei behoben: dol_dir_list() ohne mode=1 (Dateigroesse leer), PDF.js-Worker-Pfad ohne /custom-Praefix, PDFs liefen als Download statt inline, Groessen-Select blieb leer, Mehrfach-Upload nahm nur die erste Datei, Race-Condition bei schnellem Seitenwechsel. Co-Authored-By: Claude Opus 5 (1M context) --- ChangeLog.md | 118 +++ ROADMAP.md | 151 +++ ajax/attachment_thumb.php | 55 + ajax/fragments.php | 51 + ajax/page_bulk.php | 105 ++ ajax/page_image.php | 52 +- ajax/replace_page_source.php | 66 ++ bericht_card.php | 234 +++-- class/bericht.class.php | 45 + core/modules/modBericht.class.php | 2 +- css/bericht.css | 691 ++++++++++++- css/imageviewer.css | 33 +- js/editor.js | 1593 +++++++++++++++++++++++++++-- js/imageviewer.js | 34 +- lib/bericht.lib.php | 710 ++++++++++++- 15 files changed, 3640 insertions(+), 300 deletions(-) create mode 100644 ROADMAP.md create mode 100644 ajax/attachment_thumb.php create mode 100644 ajax/fragments.php create mode 100644 ajax/page_bulk.php create mode 100644 ajax/replace_page_source.php 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 ''; - print ''; - print ''; - print ''; - print ''; - print ''; - print ''; - // Format und Orientation - print ''; - print ''; - print '
'.$langs->trans("Ref").''.dol_escape_htmltag($bericht->ref).' v'.((int) $bericht->version).''.$version_links.''.$langs->trans("BerichtAuftragsnummer").''.dol_escape_htmltag($bericht->auftragsnummer).'
'.$langs->trans("BerichtTitle").''.$langs->trans("BerichtTemplate").''; + print ''; + + print '
'; + print ''; + print '
Format'; + print ''; + print ''; + + print '
'; + print ''; + print '
'; print ''; - print '   '; print ''; - print '
Wird beim Finalisieren angewendet
'; + print ''; + print 'Wird beim Finalisieren angewendet'; + print ''; + + print ''; // .bericht-meta-fields print ''; print ''; // Hauptlayout: links Anhänge, Mitte Editor, rechts Seiten print '
'; - // LINKS: Anhänge-Browser + // LINKS: Anhänge-Browser (Kachelraster mit Miniaturen) + $used_paths = bericht_used_source_paths($db, $bericht->id); + print ''; + // Ziehbare Trennlinie: Breite der Anhänge-Spalte anpassen + print '
'; + // MITTE: PDF.js + Fabric.js Editor print '
'; print '
'; - print ''; - print ''; - print ''; - print ''; - print ''; - print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; print ''; print ''; print ''; print ''; - print ''; - print ''; - print ''; + print ''; + print ''; + print ''; // ====== Zweite Zeile ====== print '
'; @@ -547,11 +602,21 @@ document.addEventListener("click", function(e) { .'' .'' .''; + print ''; print '
'; print '
'; + // Das Blatt: Kopfzone (Logo/Titel), Inhaltsbereich (= Arbeitsfläche), Fusszone. + // Die Arbeitsfläche entspricht 1:1 dem Bereich, der später gedruckt wird. + print '
'; + print '
Kopfbereich — Logo, Titel, Firmenname
'; + print '
'; print ''; print ''; print '
'; + print ''; + print ''; + print '
'; + print '
'; print '
'; print ''; // DolEditor (CKEditor) — wird im PDF mit writeHTMLCell gerendert @@ -567,31 +632,15 @@ document.addEventListener("click", function(e) { print '

'.$langs->trans("BerichtPages").' ('.count($pages).')

'; print ''; print '
'; - print '
'; - foreach ($pages as $idx => $p) { - // Ist das eine gehärtete Unterschrift? (hat .meta.json neben der source_path) - $is_signature = false; - if ($p->source_path && strpos($p->note ?? '', 'Unterschrift') === 0) { - $full_check = DOL_DATA_ROOT.'/'.$p->source_path; - if (file_exists($full_check.'.meta.json')) { - $is_signature = true; - } - } - print '
'; - print '
'; - print ''; - print '
'; - print '
'.($idx + 1).'' - .($is_signature ? ' 🔒' : '') - .'
'; - print '
'; - if ($is_signature) { - print ''; - } - print ''; - print '
'; - print '
'; - } + + // Sammelaktionen — erscheinen, sobald Seiten markiert sind (Klick auf die Nummer) + print ''; + bericht_print_page_list($pages); print '
'; print ''; @@ -599,7 +648,8 @@ document.addEventListener("click", function(e) { // Footer-Aktionen print '
'; - print ''; + print ''; + print ''; print ''; print ''; print ''; // .bericht-editor + // Statusleiste für den Bild-Austausch — wird sichtbar, sobald auf einer + // Seite "🔄" geklickt wurde. Fest am unteren Rand, damit sie beim Scrollen + // durch die Fotos nicht verschwindet. + print ''; + // PDF-Vorschau-Modal print '