Compare commits

..

No commits in common. "eaad796ab655d52c248c380167e46b9872f6ad48" and "707638808c436ef760f4bc2ee39fed52093b1451" have entirely different histories.

3 changed files with 27 additions and 99 deletions

View file

@ -13,8 +13,6 @@ Mobile Progressive Web App für die Baustellen-Doku — Foto-Upload, Sprach- und
**Aufträge** **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 - ✅ 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) - ✅ Auftragsliste mit Suche, gefiltert auf eigene Aufträge (Multi-User)
- ✅ Toggle "Auch abgeschlossene" (Filter in localStorage persistiert) - ✅ Toggle "Auch abgeschlossene" (Filter in localStorage persistiert)
- ✅ Auftragsdetail mit Kunde, Adresse, Telefon (Click-to-Call) - ✅ Auftragsdetail mit Kunde, Adresse, Telefon (Click-to-Call)
@ -71,20 +69,18 @@ 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: - ✅ **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 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 ü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 - ✅ 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 - ✅ 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 - ✅ 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 - ✅ „Nochmal drücken zum Beenden"-Toast auf Top-Level-Routen
- ✅ PIN-Schutz (optional) - ✅ 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 - ✅ **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 - ✅ **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 ## 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. 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.
@ -118,15 +114,12 @@ Die App spricht die Bericht-API unter `/custom/bericht/api/`:
**Auth** **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) - `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** **Aufträge**
- `GET /orders.php` — Aufträge des Users (Filter: `?open=1`, `?q=suche`) - `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` — Auftrag-Detail inkl. verknüpfte Berichte
- `GET /orders.php?id=X&action=photos` — Anhang-Liste (je Datei `taken_at` = EXIF-Aufnahmezeit, danach sortiert) - `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?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** **Kunden**
- `GET /customers.php` — Kundenliste (Filter: `?q=suche`) - `GET /customers.php` — Kundenliste (Filter: `?q=suche`)
@ -175,9 +168,6 @@ Die App spricht die Bericht-API unter `/custom/bericht/api/`:
- `GET /templates.php` — Bericht-Vorlagen - `GET /templates.php` — Bericht-Vorlagen
- `GET /odt_templates.php` — ODT-Deckblatt-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 ## Lizenz
GPL v3+ GPL v3+

View file

@ -150,9 +150,6 @@ Vorher endete jeder Teilen-Vorgang in einer Sackgasse:
`api/note.php`. Schlägt die Notiz fehl (kein Netz), bleibt der Text gesichert und der `api/note.php`. Schlägt die Notiz fehl (kein Netz), bleibt der Text gesichert und der
Vorgang ist wiederholbar Vorgang ist wiederholbar
- [x] Banner in der App, wenn noch etwas Geteiltes unabgelegt bereitliegt - [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 ## Erledigt

View file

@ -54,11 +54,6 @@
* anmelden"), sondern über das awl_sso-Cookie, geprüft an api/verify.php. * anmelden"), sondern über das awl_sso-Cookie, geprüft an api/verify.php.
*/ */
const SHARE_KEY = 'shared_payload'; 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'); const main = () => document.getElementById('main');
window.showToast = function (msg, kind) { window.showToast = function (msg, kind) {
@ -79,8 +74,6 @@ document.getElementById('back-btn').addEventListener('click', () => { location.h
let payload = null; // { title, text, url, files:[{name,type,blob}] } let payload = null; // { title, text, url, files:[{name,type,blob}] }
let orders = []; let orders = [];
let selectedId = null; let selectedId = null;
let selectedRef = '';
let recent = []; // zuletzt benutzte Auftraege, neuester zuerst
let searchTimer = null; let searchTimer = null;
function toBlob(f) { function toBlob(f) {
@ -155,7 +148,7 @@ function renderLogin() {
try { try {
await api.login(fd.get('login'), fd.get('password')); await api.login(fd.get('login'), fd.get('password'));
showToast('Angemeldet'); showToast('Angemeldet');
await renderPicker(); renderPicker();
} catch (err) { } catch (err) {
showToast(err.message || 'Anmeldung fehlgeschlagen', 'error'); showToast(err.message || 'Anmeldung fehlgeschlagen', 'error');
} }
@ -170,7 +163,7 @@ function renderLogin() {
const res = await awlauthWebAuthn.login(); const res = await awlauthWebAuthn.login();
if (res && res.user) await idb.set('user', res.user); if (res && res.user) await idb.set('user', res.user);
showToast('Angemeldet'); showToast('Angemeldet');
await renderPicker(); renderPicker();
} catch (err) { } catch (err) {
pk.disabled = false; pk.disabled = false;
showToast((err && err.message) || 'Anmeldung fehlgeschlagen', 'error'); showToast((err && err.message) || 'Anmeldung fehlgeschlagen', 'error');
@ -179,60 +172,6 @@ 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 ----- */ /* ----- Auswahl: Text prüfen, Auftrag wählen ----- */
function defaultSubject() { function defaultSubject() {
// Der Betreff steht spaeter im Dateinamen — deshalb kurz und an der Wortgrenze // Der Betreff steht spaeter im Dateinamen — deshalb kurz und an der Wortgrenze
@ -251,11 +190,8 @@ function defaultText() {
return parts.join('\n\n'); return parts.join('\n\n');
} }
async function renderPicker() { function renderPicker() {
const n = payload.files.length; 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 = ` main().innerHTML = `
${n ? ` ${n ? `
<div class="share-section"> <div class="share-section">
@ -269,15 +205,8 @@ async function renderPicker() {
<textarea id="note-text" placeholder="Was ist zu tun / was hat der Kunde geschrieben?">${esc(defaultText())}</textarea> <textarea id="note-text" placeholder="Was ist zu tun / was hat der Kunde geschrieben?">${esc(defaultText())}</textarea>
</div> </div>
${recent.length ? `
<div class="share-section"> <div class="share-section">
<h3>Zuletzt benutzt</h3> <h3>Auftrag wählen</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"> <div class="search-bar">
<input type="search" id="order-search" placeholder="Auftrag, Kunde oder Ihr Zeichen…" autocomplete="off"> <input type="search" id="order-search" placeholder="Auftrag, Kunde oder Ihr Zeichen…" autocomplete="off">
</div> </div>
@ -310,8 +239,6 @@ async function renderPicker() {
}); });
document.getElementById('btn-store').addEventListener('click', store); document.getElementById('btn-store').addEventListener('click', store);
bindeKarten(main()); // Schnellzugriff klickbar machen
if (vorwahl) waehleAuftrag(vorwahl.id, vorwahl.ref, vorwahl.name, vorwahl.ort);
loadOrders(''); loadOrders('');
} }
@ -327,8 +254,24 @@ async function loadOrders(q) {
box.innerHTML = '<div class="empty-state"><div class="icon">🔍</div>Kein Auftrag gefunden</div>'; box.innerHTML = '<div class="empty-state"><div class="icon">🔍</div>Kein Auftrag gefunden</div>';
return; return;
} }
box.innerHTML = orders.map(o => karteHtml(o)).join(''); box.innerHTML = orders.map(o => `
bindeKarten(box); <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';
});
});
} catch (e) { } catch (e) {
box.innerHTML = '<div class="empty-state"><div class="icon"></div>' + esc(e.message || 'Fehler') + '</div>'; box.innerHTML = '<div class="empty-state"><div class="icon"></div>' + esc(e.message || 'Fehler') + '</div>';
} }
@ -376,8 +319,6 @@ async function store() {
} }
await clearPayload(); 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(() => {}); await offline.syncQueue().catch(() => {});
showToast('Abgelegt'); showToast('Abgelegt');
location.replace('./#/orders/' + orderId); location.replace('./#/orders/' + orderId);
@ -393,7 +334,7 @@ async function store() {
return; return;
} }
try { try {
if (await isLoggedIn()) await renderPicker(); if (await isLoggedIn()) renderPicker();
else renderLogin(); else renderLogin();
} catch (e) { } catch (e) {
main().innerHTML = `<div class="empty-state"><div class="icon">📴</div> main().innerHTML = `<div class="empty-state"><div class="icon">📴</div>