diff --git a/README.md b/README.md index d7e6139..e39757c 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,15 @@ Mobile Progressive Web App für die Baustellen-Doku — Foto-Upload, Sprach- und - ✅ Foto-Viewer mit Zoom + Swipe - ✅ Foto-Skizze: Annotationen mit Pfeilen, Kreisen, Rechtecken, Text +**Auftragsbeschreibung als Merkzettel** (21.10.2026, Server: Bericht ≥ 1.9.0) +- ✅ Am Auftrag „Beschreibung" mit Knopf **Bearbeiten**: Vollbild-Textfeld, Cursor steht am Ende (meist wird etwas + dazugeschrieben), mehrzeilig angezeigt. Gespeichert im Zusatzfeld `auftragsbeschreibung` — dasselbe wie im Büro an der + Auftragskarte. Eddy: „Sachen, die ich mir merken will, lieber in der Auftragsbeschreibung festhalten." +- ✅ Hinweis im Dialog: **steht auch auf dem Stundenzettel für den Kunden** +- ✅ Kein stilles Überschreiben: hat das Büro inzwischen geändert, zeigt der Dialog den neuen Stand — + „Büro-Stand + meine Ergänzung" (wenn nur angehängt wurde), „Neuen Stand übernehmen" oder „Meinen Text speichern" +- ✅ Nur mit Netz wie die Notizen: ohne Netz bleibt der Dialog offen, nichts geht verloren + **Notizen** - ✅ Getippte Notiz zum Auftrag: Betreff + Text, Liste mit Vorschau und Zeitstempel, jederzeit wieder les- und änderbar - ✅ Ablage als `notiz__.txt` im Auftragsordner — damit auch im Dolibarr-Auftrag und in der Anhänge-Spalte des Bericht-Editors sichtbar diff --git a/ROADMAP.md b/ROADMAP.md index 38d6ad2..379247c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,6 +1,6 @@ # ROADMAP — Baustelle PWA -Stand: 2026-10-19 · Gegenstück: `data/bericht` (Modul-Version 1.8.0) +Stand: 2026-10-21 · Gegenstück: `data/bericht` (Modul-Version 1.9.0) Die PWA hat kein eigenes Backend — alles läuft über `custom/bericht/api/`. Punkte, die eine API-Änderung brauchen, sind mit **[API]** markiert und stehen zusätzlich in der ROADMAP des @@ -10,6 +10,9 @@ Bericht-Moduls. ## Offen +- [x] ✅ 21.10.2026 **[API]** Auftragsbeschreibung in der App bearbeiten (Eddy: Merkzettel lieber in der Beschreibung als + als extra Notiz-Datei). Bericht 1.9.0 `set_beschreibung` mit Konfliktschutz (409). Steht auch auf dem + Stundenzettel-PDF — Eddy hat das so gewählt. Erst Bericht ausliefern, dann die App. - [x] ✅ 19.10.2026 (ohne Schema-Änderung): `idb.queueAll()` lud **alle** Foto-Daten in den Speicher, weil sie als ArrayBuffer gesichert waren. Neue Einträge tragen den Blob selbst — Chrome legt ihn als Datei ab und liest ihn erst beim Hochladen; `getAll()` liefert nur den Verweis. Alte ArrayBuffer-Einträge laufen wie bisher ab. diff --git a/app.css b/app.css index 38f3470..7712e5a 100644 --- a/app.css +++ b/app.css @@ -354,6 +354,12 @@ body { color: #7aa2f7; /* fett in normaler Schreibweise — keine Versalien (Eddy) */ } .detail-section p { margin: 4px 0; font-size: 14px; } +/* Auftragsbeschreibung: Zeilenumbrüche behalten (mehrzeilig gemerkt) */ +.auftrag-beschreibung { white-space: pre-wrap; word-break: break-word; } +.auftrag-beschreibung-leer { opacity: 0.6; font-size: 13px; } +.beschreibung-konflikt { background: #3a2a10; border: 1px solid #ca8a04; border-radius: 8px; padding: 10px; margin-bottom: 10px; font-size: 14px; } +.beschreibung-konflikt-text { white-space: pre-wrap; word-break: break-word; margin: 6px 0; opacity: 0.9; } +.beschreibung-konflikt-knoepfe { display: flex; gap: 8px; flex-wrap: wrap; } .detail-section .label { opacity: 0.6; font-size: 12px; } .photo-grid, .video-grid { @@ -442,6 +448,9 @@ body { gap: 8px; margin: 0 0 6px; } +/* Knopf im Abschnittskopf (Bearbeiten, Auswählen) so breit wie sein Inhalt — .btn hat sonst width:100% + und zog sich über die ganze Zeile */ +.photo-section-head > .btn { width: auto; flex: 0 0 auto; } .btn-small { padding: 6px 10px; font-size: 13px; diff --git a/app.js b/app.js index 970eb4f..4e5443e 100644 --- a/app.js +++ b/app.js @@ -842,11 +842,15 @@ router.on('/orders/:id', async (args) => { ${data.customer.phone ? `

${icon('telefon', 18)}${escapeHtml(data.customer.phone)}

` : ''} - ${data.order.auftragsbeschreibung ? `
-

Beschreibung

-

${escapeHtml(data.order.auftragsbeschreibung)}

-
` : ''} +
+

Beschreibung

+ +
+ ${data.order.auftragsbeschreibung + ? `

${escapeHtml(data.order.auftragsbeschreibung)}

` + : '

Noch nichts festgehalten — mit „Bearbeiten" notieren, was du dir für den Auftrag merken willst.

'} + @@ -1040,6 +1044,7 @@ router.on('/orders/:id', async (args) => { // Sprachnotiz document.getElementById('btn-voice').onclick = () => openVoiceModal(args.id); document.getElementById('btn-notes').onclick = () => openNotesModal(args.id); + document.getElementById('btn-edit-desc').onclick = () => openDescriptionEditor(args.id, data.order.auftragsbeschreibung || ''); // Neuen Bericht anlegen document.getElementById('btn-new-report').onclick = () => openNewReportModal(args.id); @@ -4972,6 +4977,99 @@ async function openNotesModal(orderId) { refresh(); } +/* ============================================================ + * AUFTRAGSBESCHREIBUNG BEARBEITEN (Bericht ≥ 1.9.0) + * + * Eddy: „Notizen ist gut, aber als extra Datei brauche ich das selten — Sachen, die ich mir + * merken will, lieber in der Auftragsbeschreibung festhalten." Gespeichert wird im Zusatzfeld + * auftragsbeschreibung am Auftrag — dasselbe, das im Büro an der Auftragskarte steht und auf + * dem Stundenzettel-PDF für den Kunden gedruckt wird (Eddy wollte es so, Hinweis im Dialog). + * + * Nur mit Netz, wie die Notizen: ohne Netz bleibt der Dialog offen, nichts geht verloren. + * Der angezeigte Stand reist als `vorher` mit — hat das Büro inzwischen geändert, zeigt der + * Dialog den neuen Stand und fragt, statt ihn zu überschreiben. + * ============================================================ */ +async function openDescriptionEditor(orderId, aktuell) { + let vorher = aktuell || ''; + const modal = document.createElement('div'); + modal.className = 'fullscreen-modal note-modal'; + modal.innerHTML = ` +
+ +
Auftragsbeschreibung
+ +
+
+ + +
Steht auch auf dem Stundenzettel für den Kunden.
+
+ `; + document.body.appendChild(modal); + pushModal(modal); + + const textEl = modal.querySelector('#ds-text'); + const saveBtn = modal.querySelector('#ds-save'); + const konfliktEl = modal.querySelector('#ds-konflikt'); + + modal.querySelector('#ds-close').onclick = async () => { + if (textEl.value.replace(/\s+$/, '') !== vorher.replace(/\s+$/, '') && !await confirmDialog({ + title: 'Änderungen verwerfen', + text: 'Die Beschreibung wurde noch nicht gespeichert.', + confirmLabel: 'Verwerfen', + danger: true, + })) return; + closeModal(modal); + }; + + async function speichern() { + saveBtn.disabled = true; + try { + const r = await api.setOrderDescription(orderId, textEl.value, vorher); + showToast(r.unveraendert ? 'Keine Änderung' : '✓ Beschreibung gespeichert'); + closeModal(modal); + router.navigate(); + } catch (e) { + saveBtn.disabled = false; + if (e && e.status === 409 && e.data) { + // Im Büro geändert: neuen Stand zeigen, Eddy entscheidet + const neu = e.data.auftragsbeschreibung || ''; + // Meist wurde unten nur etwas angehängt — dann bleibt die eigene Ergänzung beim Übernehmen erhalten + const ergaenzung = textEl.value.startsWith(vorher) ? textEl.value.slice(vorher.length) : ''; + konfliktEl.hidden = false; + konfliktEl.innerHTML = ` + Inzwischen im Büro geändert: +
${escapeHtml(neu) || '(leer)'}
+
+ + +
`; + konfliktEl.querySelector('#ds-neu-laden').onclick = () => { + textEl.value = neu + (ergaenzung.trim() ? ergaenzung : ''); + vorher = neu; konfliktEl.hidden = true; + textEl.focus(); + const n = textEl.value.length; + try { textEl.setSelectionRange(n, n); } catch (_) {} + }; + konfliktEl.querySelector('#ds-ueberschreiben').onclick = () => { + vorher = neu; konfliktEl.hidden = true; speichern(); + }; + return; + } + showToast((e && e.offline) ? 'Kein Netz — noch nicht gespeichert. Später erneut ✓ tippen.' : ('Speichern fehlgeschlagen: ' + e.message), 'error'); + } + } + saveBtn.onclick = speichern; + + // Cursor ans Ende: meist wird etwas dazugeschrieben + setTimeout(() => { + textEl.focus(); + const n = textEl.value.length; + try { textEl.setSelectionRange(n, n); } catch (_) {} + textEl.scrollTop = textEl.scrollHeight; + }, 80); +} + /* Eine Notiz schreiben oder ändern. file = null → neue Notiz. */ async function openNoteEditor(orderId, file, onSaved) { let existing = null; diff --git a/lib/api.js b/lib/api.js index ce94a50..bd96cf8 100644 --- a/lib/api.js +++ b/lib/api.js @@ -86,7 +86,8 @@ e.status = r.status; e.nonJson = true; throw e; } - if (!r.ok) { const e = new Error(data.error || 'API-Fehler'); e.status = r.status; throw e; } + // e.data: die Antwort selbst — z. B. 409 bei der Auftragsbeschreibung trägt den aktuellen Stand + if (!r.ok) { const e = new Error(data.error || 'API-Fehler'); e.status = r.status; e.data = data; throw e; } return data; } @@ -268,6 +269,13 @@ return request(qs, { method: 'POST', body: JSON.stringify(data), timeoutMs: 45000 }); } + /* Auftragsbeschreibung ändern (Bericht ≥ 1.9.0). vorher = angezeigter Stand: hat das Büro + * inzwischen geändert, antwortet der Server mit 409 + aktuellem Text statt zu überschreiben. */ + async function setOrderDescription(orderId, text, vorher) { + return request('/orders.php?id=' + orderId + '&action=set_beschreibung', + { method: 'POST', body: JSON.stringify({ text, vorher }), timeoutMs: 30000 }); + } + async function deleteNote(orderId, file) { return request('/note.php?order_id=' + orderId + '&file=' + encodeURIComponent(file) + '&delete=1', { method: 'POST' }); @@ -425,7 +433,7 @@ listCustomers, getCustomer, getReport, listReports, createReport, listTemplates, listOdtTemplates, finalizeReport, deleteReport, deletePhoto, uploadVoiceNote, transcribeAudio, uploadAnnotatedPhoto, - listNotes, getNote, saveNote, deleteNote, + listNotes, getNote, saveNote, deleteNote, setOrderDescription, listMaterials, addMaterial, deleteMaterial, getPhotoBlobUrl, clearPhotoCache, getFileBlobUrl, photoThumbUrl, photoUrl, deletePage, updatePageNote, uploadSignature, getPdfBlobUrl, reorderPages,