From 7c0aefa7934cdab14a6548f0f3b3e38583af8748 Mon Sep 17 00:00:00 2001 From: Eduard Wisch Date: Wed, 8 Apr 2026 22:50:01 +0200 Subject: [PATCH] feat: Initiales Release Baustelle PWA v1.0.0 [deploy] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mobile Progressive Web App für Baustellen-Doku, spricht die REST-API des Dolibarr-Bericht-Moduls. MVP-Features: - Vanilla JavaScript, kein Build-Step nötig - Login mit Dolibarr-Credentials → JWT (7 Tage) - Auftragsliste mit Suche und Multi-User-Filter - Auftragsdetail mit Kunde, Adresse, Click-to-Call - Foto-Aufnahme via Kamera oder Galerie (multiple) - Clientseitige Bildverkleinerung (max 2000px, JPEG q=0.85) - Offline-Queue in IndexedDB für Uploads ohne Netz - Auto-Sync bei Online-Event mit Status-Badge - Service Worker für App-Shell-Cache - PWA-installierbar (Manifest, Icons, Theme-Color) Hosting: awl.data-it-solution.de/baustelle/ via Apache-Alias Co-Authored-By: Claude Opus 4.6 (1M context) --- .forgejo/workflows/deploy.yml | 40 +++++ .gitignore | 6 + README.md | 69 +++++++++ app.css | 243 +++++++++++++++++++++++++++++++ app.js | 265 ++++++++++++++++++++++++++++++++++ icons/icon-192.png | Bin 0 -> 1174 bytes icons/icon-512.png | Bin 0 -> 3161 bytes icons/icon.svg | 4 + index.html | 47 ++++++ lib/api.js | 92 ++++++++++++ lib/idb.js | 89 ++++++++++++ lib/offline.js | 74 ++++++++++ lib/router.js | 55 +++++++ manifest.webmanifest | 16 ++ sw.js | 65 +++++++++ 15 files changed, 1065 insertions(+) create mode 100644 .forgejo/workflows/deploy.yml create mode 100644 .gitignore create mode 100644 README.md create mode 100644 app.css create mode 100644 app.js create mode 100644 icons/icon-192.png create mode 100644 icons/icon-512.png create mode 100644 icons/icon.svg create mode 100644 index.html create mode 100644 lib/api.js create mode 100644 lib/idb.js create mode 100644 lib/offline.js create mode 100644 lib/router.js create mode 100644 manifest.webmanifest create mode 100644 sw.js diff --git a/.forgejo/workflows/deploy.yml b/.forgejo/workflows/deploy.yml new file mode 100644 index 0000000..2f11cf6 --- /dev/null +++ b/.forgejo/workflows/deploy.yml @@ -0,0 +1,40 @@ +name: Deploy baustelle-pwa + +on: + push: + tags: + - 'v*' + branches: + - main + +jobs: + deploy: + runs-on: docker + if: startsWith(github.ref, 'refs/tags/v') || contains(github.event.head_commit.message, '[deploy]') + steps: + - name: Checkout + run: | + git clone --depth 1 --branch "${GITHUB_REF_NAME}" \ + "https://token:${{ secrets.GIT_TOKEN }}@git.data-it-solution.de/${GITHUB_REPOSITORY}.git" . + + - name: Deploy nach Apache + run: | + DEPLOY_PATH="/mnt/appdata/firma/dolibarr-202509/baustelle" + REF="${GITHUB_REF#refs/*/}" + + echo "Deploye ${REF} nach ${DEPLOY_PATH} ..." + + if [ -d "$DEPLOY_PATH" ]; then + find "$DEPLOY_PATH" -mindepth 1 -delete 2>/dev/null || true + else + mkdir -p "$DEPLOY_PATH" + fi + + rsync -a \ + --exclude='.git' \ + --exclude='.forgejo' \ + --exclude='.gitignore' \ + --exclude='CLAUDE.md' \ + ./ "$DEPLOY_PATH/" + + echo "Deployment erfolgreich: ${REF} -> ${DEPLOY_PATH}" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f6bb0af --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.DS_Store +*.swp +*.bak +.vscode/ +.idea/ +node_modules/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..9b55a84 --- /dev/null +++ b/README.md @@ -0,0 +1,69 @@ +# Baustelle PWA + +Mobile Progressive Web App für die Baustellen-Doku — Foto-Upload, Sprachnotizen, Skizzen, offline-fähig. Spricht die REST-API des Dolibarr-Bericht-Moduls. + +## Stack + +- **Vanilla JavaScript** — kein Build, keine Framework-Abhängigkeit +- **Service Worker** für App-Shell-Cache (Workbox-frei, eigene Implementierung) +- **IndexedDB** für JWT-Storage und Offline-Upload-Queue +- **REST API** des `bericht`-Dolibarr-Moduls unter `/custom/bericht/api/` + +## Features (MVP) + +- ✅ Login mit Dolibarr-Credentials → JWT (7 Tage gültig) +- ✅ Auftragsliste mit Suche, gefiltert auf eigene Aufträge (Multi-User) +- ✅ Auftragsdetail mit Kunde, Adresse, Telefon (Click-to-Call) +- ✅ Foto-Aufnahme direkt aus der Kamera oder aus Galerie +- ✅ Clientseitige Bild-Verkleinerung auf 2000px (JPEG q=0.85) +- ✅ Offline-Queue: bei fehlgeschlagenem Upload landet das Foto in IndexedDB +- ✅ Auto-Sync wenn wieder online (mit Status-Badge) +- ✅ Service Worker für Offline-Start +- ✅ Installierbar als PWA (Home-Screen-Icon) + +## Geplant (Phase 4) + +- Sprachnotizen via MediaRecorder +- Touch-Skizzen-Editor für Bilder +- Schnell-Bericht direkt in der PWA erstellen +- Touch-Unterschrift abnehmen +- PIN-Schutz / WebAuthn +- Push-Notifications für neue Aufträge +- Web Share Target API + +## Hosting + +Wird bei jedem Push nach `/srv/http/baustelle/` (lokal) bzw. `/mnt/appdata/firma/dolibarr-202509/baustelle/` (prod) deployt. Apache-Alias unter `https://awl.data-it-solution.de/baustelle/`. + +## Verzeichnis-Struktur + +``` +baustelle-pwa/ +├── index.html App-Shell +├── manifest.webmanifest PWA Manifest +├── sw.js Service Worker +├── app.js Hauptlogik (Routes, Views) +├── app.css Mobile-first Styling +├── lib/ +│ ├── idb.js IndexedDB-Wrapper +│ ├── api.js REST-API-Client (JWT) +│ ├── offline.js Offline-Queue + Sync +│ └── router.js Hash-Router +└── icons/ + ├── icon-192.png + └── icon-512.png +``` + +## API + +Die App spricht ausschließlich die Bericht-API: +- `POST /api/auth.php` — Login mit Dolibarr-Credentials +- `GET /api/orders.php` — Aufträge des Users +- `GET /api/orders.php?id=X` — Auftrag-Detail +- `GET /api/orders.php?id=X&action=photos` — Anhang-Liste +- `POST /api/orders.php?id=X&action=upload_photo` — Foto-Upload +- `GET /api/reports.php?id=X` — Bericht-Detail + +## Lizenz + +GPL v3+ diff --git a/app.css b/app.css new file mode 100644 index 0000000..4dd944a --- /dev/null +++ b/app.css @@ -0,0 +1,243 @@ +/* Baustelle PWA — Mobile-first CSS */ + +* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; } +html, body { margin: 0; padding: 0; height: 100%; } +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + background: #1a1a1f; + color: #f0f0f0; + -webkit-font-smoothing: antialiased; + overscroll-behavior-y: contain; +} + +#app { + display: flex; + flex-direction: column; + min-height: 100vh; + min-height: 100dvh; +} + +#topbar { + display: flex; + align-items: center; + gap: 12px; + padding: 14px 16px; + background: #25252b; + border-bottom: 1px solid #333; + position: sticky; + top: 0; + z-index: 10; +} +#topbar h1 { + flex: 1; + margin: 0; + font-size: 17px; + font-weight: 600; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.icon-btn { + background: transparent; + border: none; + color: #f0f0f0; + font-size: 22px; + padding: 4px 8px; + cursor: pointer; + -webkit-appearance: none; +} +#status-badge { font-size: 14px; } + +#main { + flex: 1; + padding: 16px; + overflow-y: auto; + -webkit-overflow-scrolling: touch; +} + +#bottom-nav { + display: flex; + background: #25252b; + border-top: 1px solid #333; + padding-bottom: env(safe-area-inset-bottom); +} +#bottom-nav button { + flex: 1; + background: transparent; + border: none; + color: #888; + padding: 14px 8px; + font-size: 12px; + cursor: pointer; +} +#bottom-nav button.active { color: #7aa2f7; } + +/* ----- Login ----- */ +.login-form { + max-width: 360px; + margin: 60px auto 0; + text-align: center; +} +.login-form h2 { margin: 0 0 24px; font-weight: 600; } +.login-form input { + display: block; + width: 100%; + padding: 16px; + margin-bottom: 12px; + border-radius: 8px; + border: 1px solid #444; + background: #2a2a30; + color: #f0f0f0; + font-size: 16px; + -webkit-appearance: none; +} +.login-form input:focus { outline: 2px solid #337ab7; } + +/* ----- Buttons ----- */ +.btn { + display: block; + width: 100%; + padding: 16px 20px; + font-size: 16px; + font-weight: 600; + border-radius: 10px; + border: none; + cursor: pointer; + background: #337ab7; + color: #fff; + margin: 8px 0; + -webkit-appearance: none; + transition: transform 0.1s; +} +.btn:active { transform: scale(0.97); } +.btn-secondary { + background: #2a2a30; + border: 1px solid #444; + color: #f0f0f0; +} +.btn-large { padding: 22px; font-size: 18px; } + +.hidden-input { display: none; } + +/* ----- Order List ----- */ +.search-bar { + margin-bottom: 12px; +} +.search-bar input { + width: 100%; + padding: 12px 16px; + border-radius: 24px; + border: 1px solid #444; + background: #2a2a30; + color: #f0f0f0; + font-size: 15px; + -webkit-appearance: none; +} + +.order-card { + background: #25252b; + border-radius: 10px; + padding: 14px 16px; + margin-bottom: 10px; + cursor: pointer; + transition: background 0.15s; +} +.order-card:active { background: #2f2f35; } +.order-card .ref { + font-weight: 600; + color: #7aa2f7; + font-size: 14px; +} +.order-card .name { + font-size: 15px; + margin-top: 4px; +} +.order-card .meta { + display: flex; + justify-content: space-between; + margin-top: 6px; + font-size: 12px; + opacity: 0.7; +} +.order-card .badge { + background: #337ab7; + color: #fff; + padding: 2px 8px; + border-radius: 10px; + font-size: 11px; + font-weight: 600; +} + +/* ----- Order Detail ----- */ +.detail-section { + background: #25252b; + border-radius: 10px; + padding: 14px 16px; + margin-bottom: 12px; +} +.detail-section h3 { + margin: 0 0 8px; + font-size: 13px; + text-transform: uppercase; + color: #7aa2f7; + letter-spacing: 0.5px; +} +.detail-section p { margin: 4px 0; font-size: 14px; } +.detail-section .label { opacity: 0.6; font-size: 12px; } + +.photo-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 6px; + margin-top: 10px; +} +.photo-grid .thumb { + aspect-ratio: 1; + background: #2a2a30; + border-radius: 6px; + overflow: hidden; + position: relative; +} +.photo-grid .thumb img { + width: 100%; + height: 100%; + object-fit: cover; +} + +/* ----- Toast ----- */ +#toast-container { + position: fixed; + top: 16px; + left: 16px; + right: 16px; + z-index: 100; + pointer-events: none; +} +.toast { + background: #5cb85c; + color: #fff; + padding: 14px 16px; + border-radius: 8px; + margin-bottom: 8px; + text-align: center; + box-shadow: 0 4px 16px rgba(0,0,0,0.4); + animation: slide-down 0.2s; + pointer-events: auto; +} +.toast.error { background: #d9534f; } +.toast.warn { background: #f0ad4e; } +@keyframes slide-down { from { opacity: 0; transform: translateY(-20px); } to { opacity: 1; } } + +.empty-state { + text-align: center; + padding: 40px 20px; + opacity: 0.6; +} +.empty-state .icon { font-size: 48px; margin-bottom: 12px; } + +/* Loader */ +.loader { + text-align: center; + padding: 40px; + opacity: 0.6; +} diff --git a/app.js b/app.js new file mode 100644 index 0000000..2a4d0a7 --- /dev/null +++ b/app.js @@ -0,0 +1,265 @@ +/* Baustelle PWA Hauptlogik. Alle Routen + Views in einer Datei. */ + +const main = () => document.getElementById('main'); +const title = (s) => document.getElementById('page-title').textContent = s; + +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(), 2800); +}; + +function showLoader(text) { + main().innerHTML = '
' + (text || 'Lade…') + '
'; +} + +function setNav(visible, active) { + const nav = document.getElementById('bottom-nav'); + nav.style.display = visible ? '' : 'none'; + nav.querySelectorAll('button').forEach(b => { + b.classList.toggle('active', b.dataset.route === active); + }); +} + +function setBack(visible, hash) { + const btn = document.getElementById('back-btn'); + btn.style.display = visible ? '' : 'none'; + btn.onclick = () => { if (hash) router.go(hash); else history.back(); }; +} + +/* ----- Auth-Check ----- */ +async function ensureAuth() { + const t = await api.getToken(); + if (!t) { + router.go('#/login'); + return false; + } + return true; +} + +/* ====== ROUTES ====== */ + +router.on('/login', async () => { + title('Anmelden'); + setNav(false); + setBack(false); + main().innerHTML = ` + + `; + 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('Erfolgreich angemeldet'); + router.go('#/orders'); + } catch (err) { + showToast(err.message, 'error'); + } + }); +}); + +router.on('/orders', async () => { + if (!(await ensureAuth())) return; + title('Aufträge'); + setNav(true, 'orders'); + setBack(false); + showLoader('Lade Aufträge…'); + + try { + const data = await api.listOrders({ open: 1 }); + if (!data.orders.length) { + main().innerHTML = '
📭
Keine offenen Aufträge
'; + return; + } + const html = ` + +
${renderOrderList(data.orders)}
+ `; + main().innerHTML = html; + document.querySelectorAll('.order-card').forEach(c => { + c.addEventListener('click', () => router.go('#/orders/' + c.dataset.id)); + }); + document.getElementById('order-search').addEventListener('input', async (e) => { + const q = e.target.value; + const d = await api.listOrders({ q, open: 1 }); + document.getElementById('order-list').innerHTML = renderOrderList(d.orders); + document.querySelectorAll('.order-card').forEach(c => { + c.addEventListener('click', () => router.go('#/orders/' + c.dataset.id)); + }); + }); + } catch (e) { + main().innerHTML = '
⚠️
' + e.message + '
'; + } +}); + +function renderOrderList(orders) { + return orders.map(o => ` +
+
${escapeHtml(o.ref)}
+
${escapeHtml(o.customer.name || '')}
+
+ ${escapeHtml((o.customer.zip || '') + ' ' + (o.customer.town || ''))} + ${o.bericht_count > 0 ? `📑 ${o.bericht_count}` : ''} +
+
+ `).join(''); +} + +router.on('/orders/:id', async (args) => { + if (!(await ensureAuth())) return; + setNav(true, 'orders'); + setBack(true, '#/orders'); + showLoader('Lade Auftrag…'); + + try { + const data = await api.getOrder(args.id); + const photos = await api.listOrderPhotos(args.id).catch(() => ({ photos: [] })); + title(data.order.ref); + + main().innerHTML = ` +
+

Kunde

+

${escapeHtml(data.customer.name)}

+

${escapeHtml(data.customer.address || '')}

+

${escapeHtml((data.customer.zip || '') + ' ' + (data.customer.town || ''))}

+ ${data.customer.phone ? `

📞 ${escapeHtml(data.customer.phone)}

` : ''} +
+ + ${data.order.auftragsbeschreibung ? ` +
+

Beschreibung

+

${escapeHtml(data.order.auftragsbeschreibung)}

+
` : ''} + + + + + + +
+

Hochgeladene Fotos (${photos.photos.length})

+
+ ${photos.photos.map(p => `
`).join('')} +
+
+ `; + + const camInput = document.getElementById('camera-input'); + const galInput = document.getElementById('gallery-input'); + document.getElementById('btn-take-photo').onclick = () => camInput.click(); + document.getElementById('btn-pick-photo').onclick = () => galInput.click(); + + async function handleFiles(files) { + for (const f of files) { + await uploadPhoto(args.id, f); + } + // Reload Photo-Liste + try { + const np = await api.listOrderPhotos(args.id); + document.getElementById('photo-grid').innerHTML = + np.photos.map(p => `
`).join(''); + } catch (e) {} + } + camInput.addEventListener('change', () => handleFiles(camInput.files)); + galInput.addEventListener('change', () => handleFiles(galInput.files)); + } catch (e) { + main().innerHTML = '
⚠️
' + e.message + '
'; + } +}); + +async function uploadPhoto(orderId, file) { + showToast('Optimiere & sende ' + file.name); + const blob = await resizeImage(file, 2000); + if (!navigator.onLine) { + await offline.enqueuePhoto(orderId, blob, file.name); + showToast('Offline — Foto in Queue', 'warn'); + return; + } + try { + await api.uploadOrderPhoto(orderId, blob, file.name); + showToast('✓ ' + file.name + ' hochgeladen'); + } catch (e) { + await offline.enqueuePhoto(orderId, blob, file.name); + showToast('Upload fehlgeschlagen — in Queue', 'error'); + } +} + +async function resizeImage(file, maxSide) { + return new Promise((resolve) => { + const img = new Image(); + const url = URL.createObjectURL(file); + img.onload = () => { + URL.revokeObjectURL(url); + const scale = Math.min(1, maxSide / Math.max(img.width, img.height)); + if (scale === 1) { resolve(file); return; } + const c = document.createElement('canvas'); + c.width = Math.round(img.width * scale); + c.height = Math.round(img.height * scale); + c.getContext('2d').drawImage(img, 0, 0, c.width, c.height); + c.toBlob(b => resolve(b || file), 'image/jpeg', 0.85); + }; + img.onerror = () => { URL.revokeObjectURL(url); resolve(file); }; + img.src = url; + }); +} + +function getPhotoUrl(relpath) { + // Wir haben keinen direkten "Read-Photo"-Endpoint im Bericht-Modul, + // nutzen stattdessen page_image.php der Anhänge — funktioniert nur via document.php + // Vereinfacht: Dolibarr's document.php mit modulepart=ecm + return window.location.origin + '/document.php?modulepart=commande&file=' + encodeURIComponent(relpath.replace(/^commande\//, '')); +} + +router.on('/reports', async () => { + if (!(await ensureAuth())) return; + title('Berichte'); + setNav(true, 'reports'); + setBack(false); + main().innerHTML = '
📑
Berichte-Liste folgt
'; +}); + +router.on('/settings', async () => { + if (!(await ensureAuth())) return; + title('Einstellungen'); + setNav(true, 'settings'); + setBack(false); + + const user = await idb.get('user') || {}; + main().innerHTML = ` +
+

Konto

+

${escapeHtml(user.name || user.login || 'Unbekannt')}

+

${escapeHtml(user.login || '')}

+
+ + +

Baustelle PWA v1.0

+ `; + document.getElementById('btn-sync').onclick = async () => { + await offline.syncQueue(); + showToast('Sync ausgelöst'); + }; + document.getElementById('btn-logout').onclick = async () => { + await api.logout(); + router.go('#/login'); + }; +}); + +/* Bottom nav */ +document.addEventListener('DOMContentLoaded', () => { + document.querySelectorAll('#bottom-nav button').forEach(b => { + b.addEventListener('click', () => router.go('#/' + b.dataset.route)); + }); +}); + +function escapeHtml(s) { + return String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); +} diff --git a/icons/icon-192.png b/icons/icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..a9c0798103558f07314fda6eda09ec460bc6d9be GIT binary patch literal 1174 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 literal 0 HcmV?d00001 diff --git a/icons/icon.svg b/icons/icon.svg new file mode 100644 index 0000000..a928ba2 --- /dev/null +++ b/icons/icon.svg @@ -0,0 +1,4 @@ + + +🔧 + diff --git a/index.html b/index.html new file mode 100644 index 0000000..5a9cd0e --- /dev/null +++ b/index.html @@ -0,0 +1,47 @@ + + + + + + + + +Baustelle + + + + + + +
+
+ +

Baustelle

+ 🟢 +
+ +
+ + +
+ +
+ + + + + + + + + diff --git a/lib/api.js b/lib/api.js new file mode 100644 index 0000000..880901f --- /dev/null +++ b/lib/api.js @@ -0,0 +1,92 @@ +/* API-Client für die Bericht-REST-API */ +(function () { + // API-Base wird aus der aktuellen Origin gebaut. + // PWA läuft unter /baustelle/, API unter /custom/bericht/api/ + const API_BASE = window.location.origin + '/custom/bericht/api'; + + let cachedToken = null; + + async function getToken() { + if (cachedToken) return cachedToken; + cachedToken = await idb.get('jwt'); + return cachedToken; + } + + async function setToken(t) { + cachedToken = t; + await idb.set('jwt', t); + } + + async function clearToken() { + cachedToken = null; + await idb.del('jwt'); + await idb.del('user'); + } + + async function request(path, opts = {}) { + const t = await getToken(); + const headers = opts.headers || {}; + if (t && !headers.Authorization) headers.Authorization = 'Bearer ' + t; + if (!headers['Content-Type'] && !(opts.body instanceof FormData)) { + headers['Content-Type'] = 'application/json'; + } + const r = await fetch(API_BASE + path, { ...opts, headers }); + if (r.status === 401) { + await clearToken(); + window.location.hash = '#/login'; + throw new Error('Nicht authentifiziert'); + } + const data = await r.json().catch(() => ({})); + if (!r.ok) throw new Error(data.error || 'API-Fehler'); + return data; + } + + async function login(loginName, password) { + const r = await request('/auth.php', { + method: 'POST', + body: JSON.stringify({ login: loginName, password }), + }); + await setToken(r.token); + await idb.set('user', r.user); + return r; + } + + async function logout() { + await clearToken(); + } + + async function listOrders(opts = {}) { + const params = new URLSearchParams(); + if (opts.q) params.set('q', opts.q); + if (opts.open) params.set('open', '1'); + const qs = params.toString(); + return request('/orders.php' + (qs ? '?' + qs : '')); + } + + async function getOrder(id) { + return request('/orders.php?id=' + id); + } + + async function listOrderPhotos(id) { + return request('/orders.php?id=' + id + '&action=photos'); + } + + async function uploadOrderPhoto(orderId, fileBlob, filename) { + const fd = new FormData(); + fd.append('file', fileBlob, filename || 'photo.jpg'); + return request('/orders.php?id=' + orderId + '&action=upload_photo', { + method: 'POST', + body: fd, + }); + } + + async function getReport(id) { + return request('/reports.php?id=' + id); + } + + window.api = { + getToken, setToken, clearToken, + login, logout, + listOrders, getOrder, listOrderPhotos, uploadOrderPhoto, getReport, + }; +})(); diff --git a/lib/idb.js b/lib/idb.js new file mode 100644 index 0000000..247d5a3 --- /dev/null +++ b/lib/idb.js @@ -0,0 +1,89 @@ +/* Mini IndexedDB Wrapper für die PWA. + * Speichert: Auth-Token, Offline-Queue für Uploads. + */ +(function () { + const DB_NAME = 'baustelle-pwa-v1'; + const DB_VERSION = 1; + let dbPromise = null; + + function open() { + if (dbPromise) return dbPromise; + dbPromise = new Promise((resolve, reject) => { + const req = indexedDB.open(DB_NAME, DB_VERSION); + req.onupgradeneeded = () => { + const db = req.result; + if (!db.objectStoreNames.contains('kv')) { + db.createObjectStore('kv'); + } + if (!db.objectStoreNames.contains('queue')) { + db.createObjectStore('queue', { keyPath: 'id', autoIncrement: true }); + } + }; + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); + return dbPromise; + } + + async function get(key) { + const db = await open(); + return new Promise((res, rej) => { + const tx = db.transaction('kv', 'readonly'); + const r = tx.objectStore('kv').get(key); + r.onsuccess = () => res(r.result); + r.onerror = () => rej(r.error); + }); + } + + async function set(key, val) { + const db = await open(); + return new Promise((res, rej) => { + const tx = db.transaction('kv', 'readwrite'); + tx.objectStore('kv').put(val, key); + tx.oncomplete = () => res(); + tx.onerror = () => rej(tx.error); + }); + } + + async function del(key) { + const db = await open(); + return new Promise((res, rej) => { + const tx = db.transaction('kv', 'readwrite'); + tx.objectStore('kv').delete(key); + tx.oncomplete = () => res(); + tx.onerror = () => rej(tx.error); + }); + } + + async function queuePush(item) { + const db = await open(); + return new Promise((res, rej) => { + const tx = db.transaction('queue', 'readwrite'); + const r = tx.objectStore('queue').add(item); + r.onsuccess = () => res(r.result); + r.onerror = () => rej(r.error); + }); + } + + async function queueAll() { + const db = await open(); + return new Promise((res, rej) => { + const tx = db.transaction('queue', 'readonly'); + const r = tx.objectStore('queue').getAll(); + r.onsuccess = () => res(r.result || []); + r.onerror = () => rej(r.error); + }); + } + + async function queueDelete(id) { + const db = await open(); + return new Promise((res, rej) => { + const tx = db.transaction('queue', 'readwrite'); + tx.objectStore('queue').delete(id); + tx.oncomplete = () => res(); + tx.onerror = () => rej(tx.error); + }); + } + + window.idb = { get, set, del, queuePush, queueAll, queueDelete }; +})(); diff --git a/lib/offline.js b/lib/offline.js new file mode 100644 index 0000000..737c02a --- /dev/null +++ b/lib/offline.js @@ -0,0 +1,74 @@ +/* Offline-Queue für Foto-Uploads. + * Wenn der Upload fehlschlägt (offline), wird er in IndexedDB abgelegt + * und beim nächsten Online-Event automatisch nachgesendet. + */ +(function () { + let syncing = false; + + async function enqueuePhoto(orderId, fileBlob, filename) { + // Blob → ArrayBuffer für IndexedDB-Speicherung + const buf = await fileBlob.arrayBuffer(); + const id = await idb.queuePush({ + type: 'photo', + order_id: orderId, + filename, + mime: fileBlob.type || 'image/jpeg', + data: buf, + created: Date.now(), + }); + updateBadge(); + return id; + } + + async function syncQueue() { + if (syncing) return; + if (!navigator.onLine) return; + syncing = true; + try { + const items = await idb.queueAll(); + for (const it of items) { + try { + if (it.type === 'photo') { + const blob = new Blob([it.data], { type: it.mime }); + await api.uploadOrderPhoto(it.order_id, blob, it.filename); + } + await idb.queueDelete(it.id); + } catch (e) { + console.warn('Sync failed for item', it.id, e); + // Nicht weiter versuchen wenn ein Item failed + break; + } + } + } finally { + syncing = false; + updateBadge(); + } + } + + async function updateBadge() { + const items = await idb.queueAll(); + const badge = document.getElementById('status-badge'); + if (!badge) return; + if (!navigator.onLine) { + badge.textContent = items.length ? '🔴 ' + items.length : '🔴'; + badge.title = items.length + ' Uploads warten'; + } else if (items.length) { + badge.textContent = '🟡 ' + items.length; + badge.title = items.length + ' werden synchronisiert'; + } else { + badge.textContent = '🟢'; + badge.title = 'Online'; + } + } + + window.addEventListener('online', () => { updateBadge(); syncQueue(); }); + window.addEventListener('offline', updateBadge); + document.addEventListener('DOMContentLoaded', () => { + updateBadge(); + if (navigator.onLine) syncQueue(); + // Periodischer Sync alle 30s + setInterval(() => { if (navigator.onLine) syncQueue(); }, 30000); + }); + + window.offline = { enqueuePhoto, syncQueue, updateBadge }; +})(); diff --git a/lib/router.js b/lib/router.js new file mode 100644 index 0000000..64e16c2 --- /dev/null +++ b/lib/router.js @@ -0,0 +1,55 @@ +/* Mini Hash-Router für die PWA. + * Routen: + * #/login + * #/orders + * #/orders/ + * #/orders//upload + * #/settings + */ +(function () { + const routes = {}; + let currentRoute = null; + + function on(pattern, handler) { + // Pattern: '/orders/:id' + const re = new RegExp('^' + pattern.replace(/:[a-z]+/gi, '([^/]+)') + '$'); + const params = (pattern.match(/:([a-z]+)/gi) || []).map(s => s.slice(1)); + routes[pattern] = { re, params, handler }; + } + + async function navigate(hash) { + if (!hash) hash = window.location.hash || '#/orders'; + const path = hash.replace(/^#/, '') || '/orders'; + for (const key of Object.keys(routes)) { + const r = routes[key]; + const m = path.match(r.re); + if (m) { + const args = {}; + r.params.forEach((p, i) => { args[p] = decodeURIComponent(m[i + 1]); }); + currentRoute = { pattern: key, args }; + try { + await r.handler(args); + } catch (e) { + console.error(e); + showToast('Fehler: ' + e.message, 'error'); + } + return; + } + } + // Default + navigate('#/orders'); + } + + function go(hash) { + if (hash !== window.location.hash) { + window.location.hash = hash; + } else { + navigate(hash); + } + } + + window.addEventListener('hashchange', () => navigate()); + document.addEventListener('DOMContentLoaded', () => navigate()); + + window.router = { on, navigate, go }; +})(); diff --git a/manifest.webmanifest b/manifest.webmanifest new file mode 100644 index 0000000..529d06e --- /dev/null +++ b/manifest.webmanifest @@ -0,0 +1,16 @@ +{ + "name": "Baustelle — Bericht-Doku", + "short_name": "Baustelle", + "description": "Mobile Doku für Baustellen — Fotos, Sprachnotizen, Skizzen", + "start_url": "./", + "scope": "./", + "display": "standalone", + "orientation": "portrait", + "background_color": "#1a1a1f", + "theme_color": "#1a1a1f", + "lang": "de", + "icons": [ + { "src": "icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any maskable" }, + { "src": "icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" } + ] +} diff --git a/sw.js b/sw.js new file mode 100644 index 0000000..bc10dfc --- /dev/null +++ b/sw.js @@ -0,0 +1,65 @@ +/* Baustelle PWA Service Worker. + * Cache-Strategie: + * - App-Shell (HTML/CSS/JS): cache-first, network update + * - API-Calls: network-first, kein offline-cache (da auth-pflichtig) + */ + +const CACHE = 'baustelle-v1'; +const SHELL = [ + './', + './index.html', + './app.css', + './app.js', + './manifest.webmanifest', + './lib/idb.js', + './lib/api.js', + './lib/offline.js', + './lib/router.js', + './icons/icon-192.png', + './icons/icon-512.png', +]; + +self.addEventListener('install', (e) => { + e.waitUntil(caches.open(CACHE).then(c => c.addAll(SHELL).catch(() => null))); + self.skipWaiting(); +}); + +self.addEventListener('activate', (e) => { + e.waitUntil( + caches.keys().then(keys => Promise.all( + keys.filter(k => k !== CACHE).map(k => caches.delete(k)) + )) + ); + self.clients.claim(); +}); + +self.addEventListener('fetch', (e) => { + const url = new URL(e.request.url); + + // API-Requests: nicht cachen, durchreichen + if (url.pathname.includes('/custom/bericht/api/')) { + return; // default network + } + + // App-Shell: cache-first + if (e.request.method === 'GET' && url.origin === location.origin) { + e.respondWith( + caches.match(e.request).then(hit => { + if (hit) { + // Background-Update + fetch(e.request).then(r => { + if (r.ok) caches.open(CACHE).then(c => c.put(e.request, r.clone())); + }).catch(() => null); + return hit; + } + return fetch(e.request).then(r => { + if (r.ok) { + const clone = r.clone(); + caches.open(CACHE).then(c => c.put(e.request, clone)); + } + return r; + }); + }) + ); + } +});