Auftragsbeschreibung in der App bearbeiten (Merkzettel zum Auftrag)
Beschreibung am Auftrag mit Knopf Bearbeiten, mehrzeilig angezeigt, Cursor am Ende. Hat das Büro inzwischen geändert: Büro-Stand + eigene Ergänzung, neuer Stand oder eigener Text. Hinweis auf den Stundenzettel. Knöpfe im Abschnittskopf nur so breit wie ihr Inhalt. Braucht Bericht 1.9.0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
47cd91193e
commit
ee28b6938f
5 changed files with 134 additions and 7 deletions
|
|
@ -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_<betreff>_<datum>.txt` im Auftragsordner — damit auch im Dolibarr-Auftrag und in der Anhänge-Spalte des Bericht-Editors sichtbar
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
9
app.css
9
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;
|
||||
|
|
|
|||
106
app.js
106
app.js
|
|
@ -842,11 +842,15 @@ router.on('/orders/:id', async (args) => {
|
|||
${data.customer.phone ? `<p><a class="tel-link" href="tel:${escapeHtml(data.customer.phone)}">${icon('telefon', 18)}${escapeHtml(data.customer.phone)}</a></p>` : ''}
|
||||
</div>
|
||||
|
||||
${data.order.auftragsbeschreibung ? `
|
||||
<div class="detail-section">
|
||||
<h3>Beschreibung</h3>
|
||||
<p>${escapeHtml(data.order.auftragsbeschreibung)}</p>
|
||||
</div>` : ''}
|
||||
<div class="photo-section-head">
|
||||
<h3 style="margin:0;">Beschreibung</h3>
|
||||
<button class="btn btn-small" id="btn-edit-desc">${icon('zeichnen', 18)}Bearbeiten</button>
|
||||
</div>
|
||||
${data.order.auftragsbeschreibung
|
||||
? `<p class="auftrag-beschreibung">${escapeHtml(data.order.auftragsbeschreibung)}</p>`
|
||||
: '<p class="auftrag-beschreibung-leer">Noch nichts festgehalten — mit „Bearbeiten" notieren, was du dir für den Auftrag merken willst.</p>'}
|
||||
</div>
|
||||
|
||||
<button class="btn btn-large" id="btn-take-photo">${icon('kamera', 20)}Foto aufnehmen</button>
|
||||
<input type="file" id="camera-input" class="hidden-input" accept="image/*" capture="environment" multiple>
|
||||
|
|
@ -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 = `
|
||||
<div class="fs-header">
|
||||
<button class="icon-btn" id="ds-close">✕</button>
|
||||
<div class="fs-title">Auftragsbeschreibung</div>
|
||||
<button class="icon-btn" id="ds-save" title="Speichern">✓</button>
|
||||
</div>
|
||||
<div class="note-edit-body">
|
||||
<div class="beschreibung-konflikt" id="ds-konflikt" hidden></div>
|
||||
<textarea id="ds-text" placeholder="Was du dir für den Auftrag merken willst …">${escapeHtml(vorher)}</textarea>
|
||||
<div class="note-edit-meta">Steht auch auf dem Stundenzettel für den Kunden.</div>
|
||||
</div>
|
||||
`;
|
||||
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 = `
|
||||
<strong>Inzwischen im Büro geändert:</strong>
|
||||
<div class="beschreibung-konflikt-text">${escapeHtml(neu) || '<em>(leer)</em>'}</div>
|
||||
<div class="beschreibung-konflikt-knoepfe">
|
||||
<button class="btn btn-small" id="ds-neu-laden">${ergaenzung.trim() ? 'Büro-Stand + meine Ergänzung' : 'Neuen Stand übernehmen'}</button>
|
||||
<button class="btn btn-small" id="ds-ueberschreiben">Meinen Text speichern</button>
|
||||
</div>`;
|
||||
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;
|
||||
|
|
|
|||
12
lib/api.js
12
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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue