All checks were successful
Deploy baustelle-pwa / deploy (push) Successful in 13s
Wenn die Kundin drei Nachrichten hintereinander schickt, musste man den Auftrag
bisher jedes Mal neu heraussuchen. Jetzt stehen die letzten drei benutzten
Aufträge unter "Zuletzt benutzt" ganz oben.
Wurde einer davon in den letzten 30 Minuten benutzt, ist er vorausgewählt und
der Knopf ist sofort scharf ("In SO2608-0044 ablegen") — die zweite und dritte
Ladung kosten damit einen einzigen Tipp. Nach Ablauf der 30 Minuten steht der
Auftrag nur noch als Schnellzugriff da, ohne Vorauswahl: eine dauerhafte
Vorauswahl würde irgendwann still im Auftrag von vorgestern landen, und die
Auftragsnummer steht deshalb auch immer im Knopftext.
Die Merkliste (IndexedDB 'share_recent_orders') wird beim erfolgreichen Ablegen
fortgeschrieben, neuester zuerst, ohne Duplikate, maximal drei Einträge.
Kartenaufbau und Auswahl-Logik liegen jetzt in gemeinsamen Helfern
(karteHtml/bindeKarten/waehleAuftrag), damit Schnellzugriff und Suchtreffer
denselben Auftrag synchron markieren.
Lokal durchgespielt: erste Ladung ohne Merkliste (keine Vorauswahl), zweite
direkt danach (vorausgewählt, ein Tipp, landet im selben Auftrag), und ein
drei Stunden alter Eintrag (Schnellzugriff sichtbar, aber Knopf grau).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
406 lines
16 KiB
HTML
406 lines
16 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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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">
|
|
<button class="btn btn-large" id="btn-store" disabled>Ablegen</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);
|
|
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)) {
|
|
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>
|