Compare commits

..

2 commits

Author SHA1 Message Date
Eddy
eaad796ab6 Teilen-Seite merkt sich die letzten Aufträge — Serie kostet nur noch einen Tipp [deploy]
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>
2026-08-30 20:19:37 +02:00
Eddy
b7e80fac8c Doku-Drift beseitigt: Push-Notifications, Heute-Route, drei API-Endpunkte fehlten
Beim Doku-Abgleich (/docs) gegen den Code aufgefallen:
- ntfy-Push-Notifications sind laengst fertig (app.js Einstellungen-Route),
  README fuehrte sie noch unter "Geplant" als offen
- "Heute"-Ansicht (Route /today, Google-Maps-Tagesroute) fehlte komplett
- API-Abschnitt: verify.php, logout.php, orders.php?action=create und
  config.php wurden von lib/api.js schon lange genutzt, standen aber nicht
  in der Endpunkt-Liste

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 20:12:31 +02:00
3 changed files with 99 additions and 27 deletions

View file

@ -13,6 +13,8 @@ Mobile Progressive Web App für die Baustellen-Doku — Foto-Upload, Sprach- und
**Aufträge**
- ✅ Login mit Dolibarr-Credentials → zentrales SSO (HttpOnly-Cookie `awl_sso`, same-origin; Single-Sign-On/-Logout über alle AWL-Apps). WebAuthn/Passkey-Login möglich
- ✅ **„Heute"-Ansicht**: offene Aufträge des Tages, gefiltert aus der normalen Auftragsliste, mit Route
für alle Termine auf einen Blick (öffnet Google Maps mit allen Adressen als Wegpunkten)
- ✅ Auftragsliste mit Suche, gefiltert auf eigene Aufträge (Multi-User)
- ✅ Toggle "Auch abgeschlossene" (Filter in localStorage persistiert)
- ✅ Auftragsdetail mit Kunde, Adresse, Telefon (Click-to-Call)
@ -69,18 +71,20 @@ Mobile Progressive Web App für die Baustellen-Doku — Foto-Upload, Sprach- und
- ✅ **Web Share Target** — aus WhatsApp & Co. Bilder **und** Beschreibungstext an „Baustelle" teilen:
anmelden (falls nötig, direkt in der Teilen-Seite), Auftrag suchen und wählen, ablegen. Bilder gehen
über die normale Upload-Queue, der Text wird als Notiz (`notiz_*.txt`) im Auftrag gespeichert
- ✅ **Serie ohne Sucherei**: die Teilen-Seite merkt sich die letzten drei benutzten Aufträge und zeigt
sie unter „Zuletzt benutzt" ganz oben. Wurde einer davon in den letzten 30 Minuten benutzt, ist er
**vorausgewählt** — die zweite und dritte Nachricht der Kundin kosten dann nur noch einen Tipp.
Danach steht er nur noch als Schnellzugriff da, damit nichts versehentlich im Auftrag von vorgestern landet
- ✅ Nativer Android-Share-Sheet für Fotos, PDFs und Dokumente (`navigator.share`) — direkt an WhatsApp / Signal / Mail
- ✅ Mehrfachauswahl in Foto- und Dokument-Liste mit Batch-Teilen
- ✅ Android-Hardware-Back-Button schließt Modale/Ansichten sauber (History-Modal-Stack), bricht Mehrfachauswahl ab statt App zu verlassen
- ✅ „Nochmal drücken zum Beenden"-Toast auf Top-Level-Routen
- ✅ PIN-Schutz (optional)
- ✅ **Push-Benachrichtigungen via ntfy** (statt klassischem Web-Push): eigener Server/Topic in den
Einstellungen, Browser-Notification-Permission, Reconnect bei Verbindungsabbruch
- ✅ **Bild-Cache** `baustelle-media` (cache-first, LRU 300): schon angesehene Fotos sind auch ohne Netz da; überlebt Deploys, wird beim Abmelden geleert
- ✅ **Keine Browser-Dialoge**`confirm`/`alert`/`prompt` sind durch eigene Modale ersetzt (`confirmDialog`, `alertDialog`, `inputDialog`); der Hardware-Zurück-Button schließt sie als Abbruch
## Geplant
- Push-Notifications für neue Aufträge
## Hosting
Deploy per Forgejo-Pipeline (Commit-Tag `[deploy]`) nach `/mnt/appdata/firma/dolibarr-202509/modules/baustelle/`. Erreichbar unter `https://awl.data-it-solution.de/custom/baustelle/`. Cache-Busting automatisch via `filemtime()` in `index.php` (`?v=<mtime>` an JS/CSS) — kein manuelles Versions-Hochzählen nötig, die PWA aktualisiert sich beim nächsten Laden.
@ -114,12 +118,15 @@ Die App spricht die Bericht-API unter `/custom/bericht/api/`:
**Auth**
- `POST /auth.php` — Login mit Dolibarr-Credentials → setzt HttpOnly-Cookie `awl_sso` (zentrales SSO via awlauth). Folge-Requests authentisieren same-origin über das Cookie (kein Bearer-Token)
- `GET /verify.php` — prüft das Cookie und verlängert die Session (sliding renewal); läuft bei jedem Routenwechsel (`ensureAuth()`) und ist der Weg, wie `share.html` prüft, ob eine Anmeldung nötig ist
- `POST /logout.php` — löscht das `awl_sso`-Cookie (Single-Logout über alle AWL-Apps)
**Aufträge**
- `GET /orders.php` — Aufträge des Users (Filter: `?open=1`, `?q=suche`)
- `GET /orders.php?id=X` — Auftrag-Detail inkl. verknüpfte Berichte
- `GET /orders.php?id=X&action=photos` — Anhang-Liste (je Datei `taken_at` = EXIF-Aufnahmezeit, danach sortiert)
- `POST /orders.php?id=X&action=upload_photo` — Foto-Upload (multipart)
- `POST /orders.php?action=create` — Neuen Auftrag anlegen (Kunde, Ihr Zeichen, Beschreibung, Liefertermin, optional direkt freigeben)
**Kunden**
- `GET /customers.php` — Kundenliste (Filter: `?q=suche`)
@ -168,6 +175,9 @@ Die App spricht die Bericht-API unter `/custom/bericht/api/`:
- `GET /templates.php` — Bericht-Vorlagen
- `GET /odt_templates.php` — ODT-Deckblatt-Vorlagen
**Konfiguration**
- `GET /config.php` — Admin-Vorgaben zur Fotoqualität (`photo_maxside`, `photo_quality`); wird beim Start und nach jedem Login neu geladen, zwischengespeichert in `localStorage` für den Offline-Fall
## Lizenz
GPL v3+

View file

@ -150,6 +150,9 @@ Vorher endete jeder Teilen-Vorgang in einer Sackgasse:
`api/note.php`. Schlägt die Notiz fehl (kein Netz), bleibt der Text gesichert und der
Vorgang ist wiederholbar
- [x] Banner in der App, wenn noch etwas Geteiltes unabgelegt bereitliegt
- [x] **Mehrere Nachrichten hintereinander**: die letzten drei Aufträge stehen unter „Zuletzt benutzt"
oben; innerhalb von 30 Minuten ist der letzte vorausgewählt (danach nur noch Schnellzugriff —
eine Dauer-Vorauswahl würde irgendwann im falschen Auftrag landen)
## Erledigt

View file

@ -54,6 +54,11 @@
* 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) {
@ -74,6 +79,8 @@ document.getElementById('back-btn').addEventListener('click', () => { location.h
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) {
@ -148,7 +155,7 @@ function renderLogin() {
try {
await api.login(fd.get('login'), fd.get('password'));
showToast('Angemeldet');
renderPicker();
await renderPicker();
} catch (err) {
showToast(err.message || 'Anmeldung fehlgeschlagen', 'error');
}
@ -163,7 +170,7 @@ function renderLogin() {
const res = await awlauthWebAuthn.login();
if (res && res.user) await idb.set('user', res.user);
showToast('Angemeldet');
renderPicker();
await renderPicker();
} catch (err) {
pk.disabled = false;
showToast((err && err.message) || 'Anmeldung fehlgeschlagen', 'error');
@ -172,6 +179,60 @@ function renderLogin() {
}
}
/* ----- 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
@ -190,8 +251,11 @@ function defaultText() {
return parts.join('\n\n');
}
function renderPicker() {
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">
@ -205,8 +269,15 @@ function renderPicker() {
<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>Auftrag wählen</h3>
<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>
@ -239,6 +310,8 @@ function renderPicker() {
});
document.getElementById('btn-store').addEventListener('click', store);
bindeKarten(main()); // Schnellzugriff klickbar machen
if (vorwahl) waehleAuftrag(vorwahl.id, vorwahl.ref, vorwahl.name, vorwahl.ort);
loadOrders('');
}
@ -254,24 +327,8 @@ async function loadOrders(q) {
box.innerHTML = '<div class="empty-state"><div class="icon">🔍</div>Kein Auftrag gefunden</div>';
return;
}
box.innerHTML = orders.map(o => `
<div class="order-card${String(o.id) === String(selectedId) ? ' selected' : ''}" data-id="${o.id}">
<div class="ref">${esc(o.ref)}</div>
${o.ref_client ? `<div class="ref-client">${esc(o.ref_client)}</div>` : ''}
<div class="name">${esc((o.customer && o.customer.name) || '')}</div>
<div class="meta"><span>${esc(((o.customer && o.customer.zip) || '') + ' ' + ((o.customer && o.customer.town) || ''))}</span></div>
</div>
`).join('');
box.querySelectorAll('.order-card').forEach(c => {
c.addEventListener('click', () => {
selectedId = c.dataset.id;
box.querySelectorAll('.order-card').forEach(x => x.classList.toggle('selected', x === c));
const btn = document.getElementById('btn-store');
btn.disabled = false;
const o = orders.find(x => String(x.id) === String(selectedId));
btn.textContent = 'In ' + ((o && o.ref) || 'Auftrag') + ' ablegen';
});
});
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>';
}
@ -319,6 +376,8 @@ async function store() {
}
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);
@ -334,7 +393,7 @@ async function store() {
return;
}
try {
if (await isLoggedIn()) renderPicker();
if (await isLoggedIn()) await renderPicker();
else renderLogin();
} catch (e) {
main().innerHTML = `<div class="empty-state"><div class="icon">📴</div>