baustelle-pwa/share.html
Eduard Wisch c7cd2fa461
All checks were successful
Deploy baustelle-pwa / deploy (push) Successful in 13s
Knopf „Geteiltes ablegen" blieb dauerhaft stehen: leere Pakete wegräumen, Verwerfen [deploy]
Ein leeres Teilen-Paket in IndexedDB hielt den Knopf für immer stehen
(share.html meldete „Es kam nichts an" und ließ den Rest liegen). Die
App räumt leere Pakete jetzt selbst weg, share.html ebenso; der Knopf
nennt „(Notiz)" bei reinem Text; share.html bietet „Verwerfen" mit
Rückfrage in der Seite.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 21:49:01 +02:00

422 lines
18 KiB
HTML

<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no">
<meta name="theme-color" content="#1a1a1f">
<title>Geteiltes ablegen — Baustelle</title>
<link rel="stylesheet" href="app.css">
<style>
/* Nur für diese Seite — der Rest kommt aus app.css */
.share-section { padding: 12px 16px; border-bottom: 1px solid #2a2a30; }
.share-section h3 { margin: 0 0 8px; font-size: 14px; font-weight: 600; opacity: 0.8; }
.share-note input, .share-note textarea {
width: 100%; background: #25252b; color: #f0f0f0; border: 1px solid #3a3a42;
border-radius: 8px; padding: 12px; font-size: 16px; font-family: inherit;
}
.share-note textarea { min-height: 96px; line-height: 1.5; resize: vertical; margin-top: 8px; }
.order-card.selected { outline: 2px solid #7aa2f7; background: #2f2f35; }
.share-actions {
position: sticky; bottom: 0; background: #1a1a1f; padding: 12px 16px;
padding-bottom: calc(12px + env(safe-area-inset-bottom));
border-top: 1px solid #2a2a30;
}
#share-orders { padding: 0 16px 12px; }
</style>
</head>
<body>
<div id="app">
<header id="topbar">
<button id="back-btn" class="icon-btn" title="Zur App">←</button>
<h1 id="page-title">Geteiltes ablegen</h1>
<span id="status-badge">🟢</span>
</header>
<main id="main"><div class="loader">Geteilte Inhalte werden gelesen…</div></main>
</div>
<div id="toast-container"></div>
<script src="lib/idb.js"></script>
<script src="lib/api.js"></script>
<script src="lib/offline.js"></script>
<script>window.AWLAUTH_BASE = '/custom/awlauth';</script>
<script src="/custom/awlauth/js/awlauth-webauthn.js"></script>
<script>
/* Empfänger des Web Share Target.
*
* Der Service Worker hat den POST aus WhatsApp & Co. schon abgefangen und alles —
* Bilder UND Text — unter 'shared_payload' in IndexedDB gelegt. Hier wird daraus:
* anmelden (falls nötig), Auftrag wählen, ablegen.
*
* Die Anmeldung läuft NICHT über ein Token in IndexedDB (das gibt es seit der
* SSO-Umstellung nicht mehr — genau deshalb kam hier früher immer "Bitte zuerst
* anmelden"), sondern über das awl_sso-Cookie, geprüft an api/verify.php.
*/
const SHARE_KEY = 'shared_payload';
const RECENT_KEY = 'share_recent_orders';
/* Wer mehrere Sachen hintereinander teilt, will nicht jedes Mal suchen. Innerhalb dieser
* Spanne ist der zuletzt benutzte Auftrag vorausgewaehlt — danach steht er nur noch oben
* als Schnellzugriff, damit nichts versehentlich im Auftrag von vorgestern landet. */
const VORWAHL_MS = 30 * 60 * 1000;
const main = () => document.getElementById('main');
window.showToast = function (msg, kind) {
const t = document.createElement('div');
t.className = 'toast' + (kind ? ' ' + kind : '');
t.textContent = msg;
document.getElementById('toast-container').appendChild(t);
setTimeout(() => t.remove(), 3200);
};
function esc(s) {
return String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
document.getElementById('back-btn').addEventListener('click', () => { location.href = './'; });
/* ----- Das geteilte Paket ----- */
let payload = null; // { title, text, url, files:[{name,type,blob}] }
let orders = [];
let selectedId = null;
let selectedRef = '';
let recent = []; // zuletzt benutzte Auftraege, neuester zuerst
let searchTimer = null;
function toBlob(f) {
const d = f && f.data;
if (!d) return null;
if (d instanceof Blob) return d; // File ist ein Blob
if (d instanceof ArrayBuffer) return new Blob([d], { type: f.type || 'image/jpeg' });
return null;
}
async function readPayload() {
let p = await idb.get(SHARE_KEY).catch(() => null);
if (!p) {
// Format vor dem 30.08.2026: nur ein Array von Dateien, kein Text
const alt = await idb.get('shared_files').catch(() => null);
if (alt && alt.length) p = { title: '', text: '', url: '', files: alt };
}
if (!p) return null;
p.files = (p.files || [])
.map(f => ({ name: f.name || 'foto.jpg', type: f.type || 'image/jpeg', blob: toBlob(f) }))
.filter(f => f.blob);
p.title = p.title || '';
p.text = p.text || '';
p.url = p.url || '';
return p;
}
/** Was der Nutzer gerade in den Feldern stehen hat — überlebt einen Fehlversuch. */
async function persistPayload() {
const files = payload.files.map(f => ({ name: f.name, type: f.type, data: f.blob }));
await idb.set(SHARE_KEY, {
title: (document.getElementById('note-subject') || {}).value || payload.title,
text: (document.getElementById('note-text') || {}).value || payload.text,
url: '', files, ts: Date.now(),
}).catch(() => {});
}
async function clearPayload() {
await idb.del(SHARE_KEY).catch(() => {});
await idb.del('shared_files').catch(() => {});
}
/* ----- Anmeldung ----- */
async function isLoggedIn() {
try {
await api.request('/verify.php');
return true;
} catch (e) {
if (e.status === 401) return false;
throw e; // kein Netz — der Aufrufer zeigt die Offline-Ansicht
}
}
function renderLogin() {
main().innerHTML = `
<form class="login-form" id="login-form">
<h2>🔧 Anmelden</h2>
<p class="label" style="margin:-12px 0 16px;opacity:.7;text-align:center;">
Die geteilten Inhalte sind gesichert und werden gleich abgelegt.
</p>
<input type="text" name="login" placeholder="Benutzername" autocomplete="username" required>
<input type="password" name="password" placeholder="Passwort" autocomplete="current-password" required>
<button type="submit" class="btn btn-large">Anmelden</button>
<button type="button" id="bl-passkey" class="btn btn-secondary" style="display:none;margin-top:10px;">
🔑 Mit Fingerabdruck anmelden
</button>
</form>
`;
document.getElementById('login-form').addEventListener('submit', async (e) => {
e.preventDefault();
const fd = new FormData(e.target);
try {
await api.login(fd.get('login'), fd.get('password'));
showToast('Angemeldet');
await renderPicker();
} catch (err) {
showToast(err.message || 'Anmeldung fehlgeschlagen', 'error');
}
});
const pk = document.getElementById('bl-passkey');
if (pk && window.awlauthWebAuthn && awlauthWebAuthn.isSupported()) {
pk.style.display = '';
pk.addEventListener('click', async () => {
pk.disabled = true;
try {
const res = await awlauthWebAuthn.login();
if (res && res.user) await idb.set('user', res.user);
showToast('Angemeldet');
await renderPicker();
} catch (err) {
pk.disabled = false;
showToast((err && err.message) || 'Anmeldung fehlgeschlagen', 'error');
}
});
}
}
/* ----- Zuletzt benutzte Auftraege ----- */
async function ladeRecent() {
const liste = await idb.get(RECENT_KEY).catch(() => null);
return Array.isArray(liste) ? liste : [];
}
async function merkeAuftrag(o) {
const eintrag = { id: String(o.id), ref: o.ref || '', name: o.name || '', ort: o.ort || '', ts: Date.now() };
const rest = recent.filter(x => String(x.id) !== eintrag.id);
await idb.set(RECENT_KEY, [eintrag].concat(rest).slice(0, 3)).catch(() => {});
}
function seitdem(ts) {
const min = Math.round((Date.now() - ts) / 60000);
if (min < 1) return 'gerade eben';
if (min < 60) return 'vor ' + min + ' Min.';
const std = Math.round(min / 60);
if (std < 24) return 'vor ' + std + ' Std.';
const tage = Math.round(std / 24);
return tage === 1 ? 'gestern' : 'vor ' + tage + ' Tagen';
}
/** Auswahl setzen — gilt ueber beide Listen hinweg (Schnellzugriff und Suchtreffer). */
function waehleAuftrag(id, ref, name, ort) {
selectedId = String(id);
selectedRef = ref || '';
document.querySelectorAll('.order-card').forEach(c => c.classList.toggle('selected', c.dataset.id === selectedId));
const btn = document.getElementById('btn-store');
if (!btn) return;
btn.disabled = false;
btn.textContent = 'In ' + (selectedRef || 'Auftrag') + ' ablegen';
btn.dataset.name = name || '';
btn.dataset.ort = ort || '';
}
function karteHtml(o) {
const kunde = o.name !== undefined ? o.name : ((o.customer && o.customer.name) || '');
const ort = o.ort !== undefined ? o.ort : (((o.customer && o.customer.zip) || '') + ' ' + ((o.customer && o.customer.town) || ''));
return `
<div class="order-card${String(o.id) === String(selectedId) ? ' selected' : ''}"
data-id="${o.id}" data-ref="${esc(o.ref)}" data-name="${esc(kunde)}" data-ort="${esc(ort.trim())}">
<div class="ref">${esc(o.ref)}</div>
${o.ref_client ? `<div class="ref-client">${esc(o.ref_client)}</div>` : ''}
<div class="name">${esc(kunde)}</div>
<div class="meta"><span>${esc(ort.trim())}</span></div>
</div>`;
}
function bindeKarten(wurzel) {
wurzel.querySelectorAll('.order-card').forEach(c => {
c.addEventListener('click', () => waehleAuftrag(c.dataset.id, c.dataset.ref, c.dataset.name, c.dataset.ort));
});
}
/* ----- Auswahl: Text prüfen, Auftrag wählen ----- */
function defaultSubject() {
// Der Betreff steht spaeter im Dateinamen — deshalb kurz und an der Wortgrenze
const roh = payload.title || (payload.text || '').split('\n').map(s => s.trim()).find(s => s) || '';
if (!roh) return payload.files.length ? 'Vom Kunden geschickt' : '';
if (roh.length <= 45) return roh;
const kurz = roh.slice(0, 45);
const luecke = kurz.lastIndexOf(' ');
return (luecke > 20 ? kurz.slice(0, luecke) : kurz).trim();
}
function defaultText() {
const parts = [];
if (payload.text) parts.push(payload.text);
if (payload.url && payload.text.indexOf(payload.url) === -1) parts.push(payload.url);
return parts.join('\n\n');
}
async function renderPicker() {
const n = payload.files.length;
recent = await ladeRecent();
// Frisch benutzt heisst: der Nutzer teilt gerade eine Serie -> vorauswaehlen
const vorwahl = recent[0] && (Date.now() - recent[0].ts) < VORWAHL_MS ? recent[0] : null;
main().innerHTML = `
${n ? `
<div class="share-section">
<h3>${n} Bild${n > 1 ? 'er' : ''}</h3>
<div class="photo-grid" id="share-thumbs"></div>
</div>` : ''}
<div class="share-section share-note">
<h3>Notiz zum Auftrag${n ? ' (optional)' : ''}</h3>
<input type="text" id="note-subject" placeholder="Betreff" value="${esc(defaultSubject())}">
<textarea id="note-text" placeholder="Was ist zu tun / was hat der Kunde geschrieben?">${esc(defaultText())}</textarea>
</div>
${recent.length ? `
<div class="share-section">
<h3>Zuletzt benutzt</h3>
${recent.map(o => karteHtml(o)).join('')}
${vorwahl ? `<p class="label" style="margin-top:8px;">Vorausgewählt — zuletzt ${esc(seitdem(vorwahl.ts))} benutzt</p>` : ''}
</div>` : ''}
<div class="share-section">
<h3>${recent.length ? 'Anderer Auftrag' : 'Auftrag wählen'}</h3>
<div class="search-bar">
<input type="search" id="order-search" placeholder="Auftrag, Kunde oder Ihr Zeichen…" autocomplete="off">
</div>
</div>
<div id="share-orders"><div class="loader">Lade Aufträge…</div></div>
<div class="share-actions" id="share-actions">
<button class="btn btn-large" id="btn-store" disabled>Ablegen</button>
<button class="btn btn-secondary" id="btn-discard" style="margin-top:10px;width:100%;">Verwerfen</button>
</div>
`;
// Vorschau der geteilten Bilder
const grid = document.getElementById('share-thumbs');
if (grid) {
payload.files.forEach(f => {
const d = document.createElement('div');
d.className = 'thumb';
const img = document.createElement('img');
img.src = URL.createObjectURL(f.blob);
img.alt = f.name;
d.appendChild(img);
grid.appendChild(d);
});
}
const search = document.getElementById('order-search');
search.addEventListener('input', () => {
clearTimeout(searchTimer);
searchTimer = setTimeout(() => loadOrders(search.value.trim()), 300);
});
document.getElementById('btn-store').addEventListener('click', store);
// Verwerfen (Eddy 19.10.2026): wer etwas Geteiltes nicht ablegen will, kam bisher nicht mehr
// davon los — der Knopf in der App blieb stehen. Rückfrage in der Seite, kein Browser-Dialog.
document.getElementById('btn-discard').addEventListener('click', () => {
const box = document.getElementById('share-actions');
box.innerHTML = `<p class="label" style="margin-bottom:10px;">Geteilte Inhalte wirklich verwerfen? Sie werden nicht abgelegt.</p>
<button class="btn btn-large" id="btn-discard-yes">Ja, verwerfen</button>
<button class="btn btn-secondary" id="btn-discard-no" style="margin-top:10px;width:100%;">Zurück</button>`;
document.getElementById('btn-discard-yes').addEventListener('click', async () => {
await clearPayload();
location.href = './';
});
document.getElementById('btn-discard-no').addEventListener('click', () => renderPicker());
});
bindeKarten(main()); // Schnellzugriff klickbar machen
if (vorwahl) waehleAuftrag(vorwahl.id, vorwahl.ref, vorwahl.name, vorwahl.ort);
loadOrders('');
}
async function loadOrders(q) {
const box = document.getElementById('share-orders');
box.innerHTML = '<div class="loader">Lade Aufträge…</div>';
try {
// Ohne Suchbegriff die offenen Aufträge, mit Suchbegriff alle — ein älterer
// Auftrag lässt sich sonst nicht mehr treffen.
const data = await api.listOrders(q ? { q } : { open: 1 });
orders = data.orders || [];
if (!orders.length) {
box.innerHTML = '<div class="empty-state"><div class="icon">🔍</div>Kein Auftrag gefunden</div>';
return;
}
box.innerHTML = orders.map(o => karteHtml(o)).join('');
bindeKarten(box);
} catch (e) {
box.innerHTML = '<div class="empty-state"><div class="icon">⚠</div>' + esc(e.message || 'Fehler') + '</div>';
}
}
/* ----- Ablegen ----- */
async function store() {
const btn = document.getElementById('btn-store');
const orderId = selectedId;
const subject = document.getElementById('note-subject').value.trim();
const text = document.getElementById('note-text').value.trim();
btn.disabled = true;
btn.textContent = 'Wird abgelegt…';
// Fotos zuerst in die Upload-Queue — ab da können sie nicht mehr verlorengehen,
// auch nicht bei totem Netz oder wenn das Display ausgeht (Persist-First).
try {
for (let i = 0; i < payload.files.length; i++) {
const f = payload.files[i];
const name = f.name || ('geteilt_' + Date.now() + '_' + i + '.jpg');
await offline.enqueuePhoto(orderId, f.blob, name);
}
} catch (e) {
showToast('Fotos konnten nicht gesichert werden: ' + (e.message || e), 'error');
btn.disabled = false;
btn.textContent = 'Ablegen';
return;
}
payload.files = []; // gesichert — nicht noch einmal einreihen
await persistPayload();
// Notiz braucht das Netz. Klappt sie nicht, bleibt der Text gesichert und der
// Vorgang lässt sich wiederholen — der Text ist dann nicht weg.
if (subject || text) {
try {
await api.saveNote(orderId, { subject: subject || 'Vom Kunden geschickt', text });
} catch (e) {
showToast('Notiz nicht gespeichert (' + (e.message || 'Fehler') + ') — Fotos sind gesichert', 'error');
btn.disabled = false;
btn.textContent = 'Notiz erneut speichern';
const grid = document.getElementById('share-thumbs');
if (grid) grid.closest('.share-section').remove();
return;
}
}
await clearPayload();
// Fuer die naechste Ladung merken — dann steht der Auftrag oben und ist vorausgewaehlt
await merkeAuftrag({ id: orderId, ref: selectedRef, name: btn.dataset.name, ort: btn.dataset.ort });
await offline.syncQueue().catch(() => {});
showToast('Abgelegt');
location.replace('./#/orders/' + orderId);
}
/* ----- Start ----- */
(async function boot() {
payload = await readPayload();
if (!payload || (!payload.files.length && !payload.text && !payload.title && !payload.url)) {
// Leeren Rest wegräumen — sonst bleibt der Knopf „Geteiltes ablegen" in der App für immer stehen
await clearPayload();
main().innerHTML = `<div class="empty-state"><div class="icon">📭</div>
Es kam nichts an.<br>Teile Bilder oder Text aus WhatsApp erneut mit „Baustelle".
<br><button class="btn" style="margin-top:16px;" onclick="location.href='./'">Zur App</button></div>`;
return;
}
try {
if (await isLoggedIn()) await renderPicker();
else renderLogin();
} catch (e) {
main().innerHTML = `<div class="empty-state"><div class="icon">📴</div>
Kein Netz — die geteilten Inhalte sind gesichert und lassen sich später ablegen.
<br><button class="btn" style="margin-top:16px;" onclick="location.reload()">Nochmal versuchen</button></div>`;
}
})();
</script>
</body>
</html>