feat: PWA Schnell-Bericht + Template-Dropdown + Whisper-Transkribieren
All checks were successful
Deploy baustelle-pwa / deploy (push) Successful in 1s
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:
parent
0e8ebed717
commit
31a690454c
3 changed files with 170 additions and 3 deletions
23
app.css
23
app.css
|
|
@ -436,6 +436,29 @@ body {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.audio-item audio { width: 100%; flex-basis: 100%; }
|
.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; }
|
.btn[disabled] { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
|
||||||
|
|
|
||||||
124
app.js
124
app.js
|
|
@ -253,6 +253,7 @@ router.on('/orders/:id', async (args) => {
|
||||||
<button class="btn btn-secondary" id="btn-pick-photo">📂 Aus Galerie wählen</button>
|
<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>
|
<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-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;">
|
<div class="detail-section" style="margin-top:16px;">
|
||||||
<h3>Hochgeladene Fotos (${imagePhotos.length})</h3>
|
<h3>Hochgeladene Fotos (${imagePhotos.length})</h3>
|
||||||
|
|
@ -264,7 +265,11 @@ router.on('/orders/:id', async (args) => {
|
||||||
<div class="detail-section">
|
<div class="detail-section">
|
||||||
<h3>🎙 Sprachnotizen (${audioFiles.length})</h3>
|
<h3>🎙 Sprachnotizen (${audioFiles.length})</h3>
|
||||||
<div class="audio-list">
|
<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>
|
||||||
</div>` : ''}
|
</div>` : ''}
|
||||||
|
|
||||||
|
|
@ -283,6 +288,33 @@ router.on('/orders/:id', async (args) => {
|
||||||
// Sprachnotiz
|
// Sprachnotiz
|
||||||
document.getElementById('btn-voice').onclick = () => openVoiceModal(args.id);
|
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
|
// Audio-Files abspielen
|
||||||
document.querySelectorAll('.audio-item .audio-play').forEach(btn => {
|
document.querySelectorAll('.audio-item .audio-play').forEach(btn => {
|
||||||
btn.addEventListener('click', async (e) => {
|
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)
|
* SEITEN-AKTIONEN MODAL (Notiz, Löschen, Vollbild)
|
||||||
* ============================================================ */
|
* ============================================================ */
|
||||||
|
|
|
||||||
26
lib/api.js
26
lib/api.js
|
|
@ -99,6 +99,21 @@
|
||||||
return request('/reports.php');
|
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) {
|
async function finalizeReport(id) {
|
||||||
return request('/reports.php?id=' + id + '&action=finalize', { method: 'POST' });
|
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 });
|
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) {
|
async function uploadAnnotatedPhoto(orderId, fileBlob, filename) {
|
||||||
// Wie uploadOrderPhoto — Skizze ist schon ins Bild eingebrannt
|
// Wie uploadOrderPhoto — Skizze ist schon ins Bild eingebrannt
|
||||||
return uploadOrderPhoto(orderId, fileBlob, filename);
|
return uploadOrderPhoto(orderId, fileBlob, filename);
|
||||||
|
|
@ -204,8 +226,8 @@
|
||||||
login, logout,
|
login, logout,
|
||||||
listOrders, getOrder, listOrderPhotos, uploadOrderPhoto,
|
listOrders, getOrder, listOrderPhotos, uploadOrderPhoto,
|
||||||
listCustomers, getCustomer,
|
listCustomers, getCustomer,
|
||||||
getReport, listReports, finalizeReport,
|
getReport, listReports, createReport, listTemplates, listOdtTemplates, finalizeReport,
|
||||||
deletePhoto, uploadVoiceNote, uploadAnnotatedPhoto,
|
deletePhoto, uploadVoiceNote, transcribeAudio, uploadAnnotatedPhoto,
|
||||||
getPhotoBlobUrl, clearPhotoCache,
|
getPhotoBlobUrl, clearPhotoCache,
|
||||||
deletePage, updatePageNote, uploadSignature, getPdfBlobUrl, reorderPages,
|
deletePage, updatePageNote, uploadSignature, getPdfBlobUrl, reorderPages,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue