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-Viewer mit Zoom + Swipe
|
||||||
- ✅ Foto-Skizze: Annotationen mit Pfeilen, Kreisen, Rechtecken, Text
|
- ✅ 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**
|
**Notizen**
|
||||||
- ✅ Getippte Notiz zum Auftrag: Betreff + Text, Liste mit Vorschau und Zeitstempel, jederzeit wieder les- und änderbar
|
- ✅ 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
|
- ✅ 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
|
# 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
|
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
|
API-Änderung brauchen, sind mit **[API]** markiert und stehen zusätzlich in der ROADMAP des
|
||||||
|
|
@ -10,6 +10,9 @@ Bericht-Moduls.
|
||||||
|
|
||||||
## Offen
|
## 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
|
- [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
|
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.
|
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) */
|
color: #7aa2f7; /* fett in normaler Schreibweise — keine Versalien (Eddy) */
|
||||||
}
|
}
|
||||||
.detail-section p { margin: 4px 0; font-size: 14px; }
|
.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; }
|
.detail-section .label { opacity: 0.6; font-size: 12px; }
|
||||||
|
|
||||||
.photo-grid, .video-grid {
|
.photo-grid, .video-grid {
|
||||||
|
|
@ -442,6 +448,9 @@ body {
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
margin: 0 0 6px;
|
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 {
|
.btn-small {
|
||||||
padding: 6px 10px;
|
padding: 6px 10px;
|
||||||
font-size: 13px;
|
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>` : ''}
|
${data.customer.phone ? `<p><a class="tel-link" href="tel:${escapeHtml(data.customer.phone)}">${icon('telefon', 18)}${escapeHtml(data.customer.phone)}</a></p>` : ''}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
${data.order.auftragsbeschreibung ? `
|
|
||||||
<div class="detail-section">
|
<div class="detail-section">
|
||||||
<h3>Beschreibung</h3>
|
<div class="photo-section-head">
|
||||||
<p>${escapeHtml(data.order.auftragsbeschreibung)}</p>
|
<h3 style="margin:0;">Beschreibung</h3>
|
||||||
</div>` : ''}
|
<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>
|
<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>
|
<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
|
// Sprachnotiz
|
||||||
document.getElementById('btn-voice').onclick = () => openVoiceModal(args.id);
|
document.getElementById('btn-voice').onclick = () => openVoiceModal(args.id);
|
||||||
document.getElementById('btn-notes').onclick = () => openNotesModal(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
|
// Neuen Bericht anlegen
|
||||||
document.getElementById('btn-new-report').onclick = () => openNewReportModal(args.id);
|
document.getElementById('btn-new-report').onclick = () => openNewReportModal(args.id);
|
||||||
|
|
@ -4972,6 +4977,99 @@ async function openNotesModal(orderId) {
|
||||||
refresh();
|
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. */
|
/* Eine Notiz schreiben oder ändern. file = null → neue Notiz. */
|
||||||
async function openNoteEditor(orderId, file, onSaved) {
|
async function openNoteEditor(orderId, file, onSaved) {
|
||||||
let existing = null;
|
let existing = null;
|
||||||
|
|
|
||||||
12
lib/api.js
12
lib/api.js
|
|
@ -86,7 +86,8 @@
|
||||||
e.status = r.status; e.nonJson = true;
|
e.status = r.status; e.nonJson = true;
|
||||||
throw e;
|
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;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -268,6 +269,13 @@
|
||||||
return request(qs, { method: 'POST', body: JSON.stringify(data), timeoutMs: 45000 });
|
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) {
|
async function deleteNote(orderId, file) {
|
||||||
return request('/note.php?order_id=' + orderId + '&file=' + encodeURIComponent(file) + '&delete=1',
|
return request('/note.php?order_id=' + orderId + '&file=' + encodeURIComponent(file) + '&delete=1',
|
||||||
{ method: 'POST' });
|
{ method: 'POST' });
|
||||||
|
|
@ -425,7 +433,7 @@
|
||||||
listCustomers, getCustomer,
|
listCustomers, getCustomer,
|
||||||
getReport, listReports, createReport, listTemplates, listOdtTemplates, finalizeReport, deleteReport,
|
getReport, listReports, createReport, listTemplates, listOdtTemplates, finalizeReport, deleteReport,
|
||||||
deletePhoto, uploadVoiceNote, transcribeAudio, uploadAnnotatedPhoto,
|
deletePhoto, uploadVoiceNote, transcribeAudio, uploadAnnotatedPhoto,
|
||||||
listNotes, getNote, saveNote, deleteNote,
|
listNotes, getNote, saveNote, deleteNote, setOrderDescription,
|
||||||
listMaterials, addMaterial, deleteMaterial,
|
listMaterials, addMaterial, deleteMaterial,
|
||||||
getPhotoBlobUrl, clearPhotoCache, getFileBlobUrl, photoThumbUrl, photoUrl,
|
getPhotoBlobUrl, clearPhotoCache, getFileBlobUrl, photoThumbUrl, photoUrl,
|
||||||
deletePage, updatePageNote, uploadSignature, getPdfBlobUrl, reorderPages,
|
deletePage, updatePageNote, uploadSignature, getPdfBlobUrl, reorderPages,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue