feat: PWA Schnell-Bericht + Template-Dropdown + Whisper-Transkribieren
All checks were successful
Deploy baustelle-pwa / deploy (push) Successful in 1s

Schnell-Bericht-Modal (Phase 4.a + 4.i):
- '📑 Neuen Bericht anlegen…' Button im Auftrags-Detail
- openNewReportModal: Titel, Format/Orientation, Vorlage-Dropdown,
  ODT-Deckblatt-Dropdown
- Auto-Select der Default-ODT-Vorlage aus Admin-Config
- api.createReport() als Wrapper für /reports.php?action=create
- api.listTemplates() + api.listOdtTemplates()

Whisper-Transkription (Phase 5.7):
- Neuer 📝-Button je Audio-Item
- Tap → api.transcribeAudio(relpath) → Server ruft Whisper-Endpoint
- Transkribierter Text erscheint direkt unter dem Audio-Player
- Text ist wählbar und kann kopiert werden

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
[deploy]
This commit is contained in:
Eduard Wisch 2026-04-09 08:27:46 +02:00
parent 0e8ebed717
commit 31a690454c
3 changed files with 170 additions and 3 deletions

23
app.css
View file

@ -436,6 +436,29 @@ body {
flex-shrink: 0;
}
.audio-item audio { width: 100%; flex-basis: 100%; }
.audio-item .audio-transcribe {
background: #2a2a30;
color: #7aa2f7;
border: 1px solid #444;
border-radius: 50%;
width: 36px;
height: 36px;
font-size: 14px;
cursor: pointer;
flex-shrink: 0;
}
.audio-transcript {
flex-basis: 100%;
background: #1a1a1f;
padding: 10px 12px;
margin-top: 6px;
border-radius: 4px;
border-left: 3px solid #7aa2f7;
font-size: 13px;
line-height: 1.5;
color: #e0e0e0;
white-space: pre-wrap;
}
.btn[disabled] { opacity: 0.5; cursor: not-allowed; }

124
app.js
View file

@ -253,6 +253,7 @@ router.on('/orders/:id', async (args) => {
<button class="btn btn-secondary" id="btn-pick-photo">📂 Aus Galerie wählen</button>
<input type="file" id="gallery-input" class="hidden-input" accept="image/*" multiple>
<button class="btn btn-secondary" id="btn-voice">🎙 Sprachnotiz aufnehmen</button>
<button class="btn btn-secondary" id="btn-new-report">📑 Neuen Bericht anlegen</button>
<div class="detail-section" style="margin-top:16px;">
<h3>Hochgeladene Fotos (${imagePhotos.length})</h3>
@ -264,7 +265,11 @@ router.on('/orders/:id', async (args) => {
<div class="detail-section">
<h3>🎙 Sprachnotizen (${audioFiles.length})</h3>
<div class="audio-list">
${audioFiles.map(a => `<div class="audio-item" data-relpath="${escapeHtml(a.relpath)}" data-mime="${escapeHtml(a.mime || 'audio/webm')}"><span class="audio-name">${escapeHtml(a.filename)}</span><button class="audio-play" title="Abspielen">▶</button></div>`).join('')}
${audioFiles.map(a => `<div class="audio-item" data-relpath="${escapeHtml(a.relpath)}" data-mime="${escapeHtml(a.mime || 'audio/webm')}">
<span class="audio-name">${escapeHtml(a.filename)}</span>
<button class="audio-play" title="Abspielen"></button>
<button class="audio-transcribe" title="Transkribieren (Whisper)">📝</button>
</div>`).join('')}
</div>
</div>` : ''}
@ -283,6 +288,33 @@ router.on('/orders/:id', async (args) => {
// Sprachnotiz
document.getElementById('btn-voice').onclick = () => openVoiceModal(args.id);
// Neuen Bericht anlegen
document.getElementById('btn-new-report').onclick = () => openNewReportModal(args.id);
// Transkribieren
document.querySelectorAll('.audio-item .audio-transcribe').forEach(btn => {
btn.addEventListener('click', async (e) => {
const item = e.target.closest('.audio-item');
const rel = item.dataset.relpath;
btn.textContent = '⏳';
try {
const r = await api.transcribeAudio(rel);
const text = r.text || '(leer)';
const existing = item.querySelector('.audio-transcript');
if (existing) existing.remove();
const box = document.createElement('div');
box.className = 'audio-transcript';
box.textContent = text;
item.appendChild(box);
btn.textContent = '📝';
showToast('✓ Transkribiert');
} catch (err) {
btn.textContent = '📝';
showToast('Whisper-Fehler: ' + err.message, 'error');
}
});
});
// Audio-Files abspielen
document.querySelectorAll('.audio-item .audio-play').forEach(btn => {
btn.addEventListener('click', async (e) => {
@ -814,6 +846,96 @@ function bindReportPageInteractions(reportId) {
});
}
/* ============================================================
* NEW REPORT MODAL (Schnell-Bericht mit Meta + Vorlage + ODT)
* ============================================================ */
async function openNewReportModal(orderId) {
// Templates parallel laden
let tpls = [], odts = [];
try { tpls = (await api.listTemplates()).templates || []; } catch (e) {}
try { odts = (await api.listOdtTemplates()).templates || []; } catch (e) {}
const modal = document.createElement('div');
modal.className = 'fullscreen-modal';
modal.innerHTML = `
<div class="fs-header">
<button class="icon-btn" id="nr-close"></button>
<div class="fs-title">📑 Neuer Bericht</div>
<button class="icon-btn" id="nr-save" title="Anlegen"></button>
</div>
<div class="fs-body" style="flex-direction:column;gap:12px;padding:16px;align-items:stretch;">
<label class="label">Titel</label>
<input type="text" id="nr-titel" placeholder="z. B. Wallbox-Installation" style="padding:12px;background:#2a2a30;color:#fff;border:1px solid #444;border-radius:6px;">
${tpls.length ? `
<label class="label">Aus Vorlage erstellen (optional)</label>
<select id="nr-template" style="padding:12px;background:#2a2a30;color:#fff;border:1px solid #444;border-radius:6px;">
<option value="0"> Leerer Bericht </option>
${tpls.map(t => `<option value="${t.id}">📋 ${escapeHtml(t.label)} (${t.page_count} Seiten)</option>`).join('')}
</select>` : ''}
<label class="label">Format</label>
<div style="display:flex;gap:8px;">
<select id="nr-format" style="flex:1;padding:12px;background:#2a2a30;color:#fff;border:1px solid #444;border-radius:6px;">
<option value="A4">A4</option>
<option value="A3">A3</option>
<option value="A5">A5</option>
<option value="Letter">Letter</option>
</select>
<select id="nr-orient" style="flex:1;padding:12px;background:#2a2a30;color:#fff;border:1px solid #444;border-radius:6px;">
<option value="P">Hochformat</option>
<option value="L">Querformat</option>
</select>
</div>
${odts.length ? `
<label class="label">Deckblatt-Vorlage (ODT)</label>
<select id="nr-odt" style="padding:12px;background:#2a2a30;color:#fff;border:1px solid #444;border-radius:6px;">
<option value=""> Kein Deckblatt </option>
${odts.map(o => `<option value="${escapeHtml(o.filename)}">${escapeHtml(o.label)}</option>`).join('')}
</select>` : ''}
</div>
`;
document.body.appendChild(modal);
// ODT-Default vorauswählen
try {
const odtData = await api.listOdtTemplates();
if (odtData.default) {
const sel = modal.querySelector('#nr-odt');
if (sel) sel.value = odtData.default;
}
} catch (e) {}
modal.querySelector('#nr-close').onclick = () => modal.remove();
modal.querySelector('#nr-save').onclick = async () => {
const titel = modal.querySelector('#nr-titel').value.trim();
const tplSel = modal.querySelector('#nr-template');
const tpl = tplSel ? parseInt(tplSel.value, 10) : 0;
const format = modal.querySelector('#nr-format').value;
const orient = modal.querySelector('#nr-orient').value;
const odtSel = modal.querySelector('#nr-odt');
const odt = odtSel ? odtSel.value : '';
showToast('Lege Bericht an…');
try {
const res = await api.createReport({
element_type: 'order',
element_id: parseInt(orderId, 10),
titel: titel,
template_id: tpl,
page_format: format,
page_orientation: orient,
template_odt: odt,
});
showToast('✓ Bericht angelegt');
modal.remove();
router.go('#/reports/' + res.bericht_id);
} catch (e) {
showToast('Fehler: ' + e.message, 'error');
}
};
}
/* ============================================================
* SEITEN-AKTIONEN MODAL (Notiz, Löschen, Vollbild)
* ============================================================ */

View file

@ -99,6 +99,21 @@
return request('/reports.php');
}
async function createReport(opts) {
return request('/reports.php?action=create', {
method: 'POST',
body: JSON.stringify(opts),
});
}
async function listTemplates() {
return request('/templates.php');
}
async function listOdtTemplates() {
return request('/odt_templates.php');
}
async function finalizeReport(id) {
return request('/reports.php?id=' + id + '&action=finalize', { method: 'POST' });
}
@ -116,6 +131,13 @@
return request('/voice.php?order_id=' + orderId, { method: 'POST', body: fd });
}
async function transcribeAudio(relpath) {
return request('/transcribe.php', {
method: 'POST',
body: JSON.stringify({ relpath }),
});
}
async function uploadAnnotatedPhoto(orderId, fileBlob, filename) {
// Wie uploadOrderPhoto — Skizze ist schon ins Bild eingebrannt
return uploadOrderPhoto(orderId, fileBlob, filename);
@ -204,8 +226,8 @@
login, logout,
listOrders, getOrder, listOrderPhotos, uploadOrderPhoto,
listCustomers, getCustomer,
getReport, listReports, finalizeReport,
deletePhoto, uploadVoiceNote, uploadAnnotatedPhoto,
getReport, listReports, createReport, listTemplates, listOdtTemplates, finalizeReport,
deletePhoto, uploadVoiceNote, transcribeAudio, uploadAnnotatedPhoto,
getPhotoBlobUrl, clearPhotoCache,
deletePage, updatePageNote, uploadSignature, getPdfBlobUrl, reorderPages,
};