Compare commits
2 commits
707638808c
...
eaad796ab6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eaad796ab6 | ||
|
|
b7e80fac8c |
3 changed files with 99 additions and 27 deletions
18
README.md
18
README.md
|
|
@ -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+
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
105
share.html
105
share.html
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue