feat: Initiales Release Baustelle PWA v1.0.0 [deploy]
All checks were successful
Deploy baustelle-pwa / deploy (push) Successful in 1s

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) <noreply@anthropic.com>
This commit is contained in:
Eduard Wisch 2026-04-08 22:50:01 +02:00
commit 7c0aefa793
15 changed files with 1065 additions and 0 deletions

View file

@ -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}"

6
.gitignore vendored Normal file
View file

@ -0,0 +1,6 @@
.DS_Store
*.swp
*.bak
.vscode/
.idea/
node_modules/

69
README.md Normal file
View file

@ -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+

243
app.css Normal file
View file

@ -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;
}

265
app.js Normal file
View file

@ -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 = '<div class="loader">' + (text || 'Lade…') + '</div>';
}
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 = `
<form class="login-form" id="login-form">
<h2>🔧 Baustelle</h2>
<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>
</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('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 = '<div class="empty-state"><div class="icon">📭</div>Keine offenen Aufträge</div>';
return;
}
const html = `
<div class="search-bar"><input type="search" id="order-search" placeholder="🔍 Suchen…"></div>
<div id="order-list">${renderOrderList(data.orders)}</div>
`;
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 = '<div class="empty-state"><div class="icon">⚠️</div>' + e.message + '</div>';
}
});
function renderOrderList(orders) {
return orders.map(o => `
<div class="order-card" data-id="${o.id}">
<div class="ref">${escapeHtml(o.ref)}</div>
<div class="name">${escapeHtml(o.customer.name || '')}</div>
<div class="meta">
<span>${escapeHtml((o.customer.zip || '') + ' ' + (o.customer.town || ''))}</span>
${o.bericht_count > 0 ? `<span class="badge">📑 ${o.bericht_count}</span>` : ''}
</div>
</div>
`).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 = `
<div class="detail-section">
<h3>Kunde</h3>
<p><strong>${escapeHtml(data.customer.name)}</strong></p>
<p>${escapeHtml(data.customer.address || '')}</p>
<p>${escapeHtml((data.customer.zip || '') + ' ' + (data.customer.town || ''))}</p>
${data.customer.phone ? `<p><a href="tel:${escapeHtml(data.customer.phone)}">📞 ${escapeHtml(data.customer.phone)}</a></p>` : ''}
</div>
${data.order.auftragsbeschreibung ? `
<div class="detail-section">
<h3>Beschreibung</h3>
<p>${escapeHtml(data.order.auftragsbeschreibung)}</p>
</div>` : ''}
<button class="btn btn-large" id="btn-take-photo">📷 Foto aufnehmen</button>
<input type="file" id="camera-input" class="hidden-input" accept="image/*" capture="environment" multiple>
<button class="btn btn-secondary" id="btn-pick-photo">📂 Aus Galerie wählen</button>
<input type="file" id="gallery-input" class="hidden-input" accept="image/*" multiple>
<div class="detail-section" style="margin-top:16px;">
<h3>Hochgeladene Fotos (${photos.photos.length})</h3>
<div class="photo-grid" id="photo-grid">
${photos.photos.map(p => `<div class="thumb"><img loading="lazy" src="${getPhotoUrl(p.relpath)}"></div>`).join('')}
</div>
</div>
`;
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 => `<div class="thumb"><img loading="lazy" src="${getPhotoUrl(p.relpath)}"></div>`).join('');
} catch (e) {}
}
camInput.addEventListener('change', () => handleFiles(camInput.files));
galInput.addEventListener('change', () => handleFiles(galInput.files));
} catch (e) {
main().innerHTML = '<div class="empty-state"><div class="icon">⚠️</div>' + e.message + '</div>';
}
});
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 = '<div class="empty-state"><div class="icon">📑</div>Berichte-Liste folgt</div>';
});
router.on('/settings', async () => {
if (!(await ensureAuth())) return;
title('Einstellungen');
setNav(true, 'settings');
setBack(false);
const user = await idb.get('user') || {};
main().innerHTML = `
<div class="detail-section">
<h3>Konto</h3>
<p><strong>${escapeHtml(user.name || user.login || 'Unbekannt')}</strong></p>
<p class="label">${escapeHtml(user.login || '')}</p>
</div>
<button class="btn btn-secondary" id="btn-sync">🔄 Offline-Queue synchronisieren</button>
<button class="btn btn-secondary" id="btn-logout">🚪 Abmelden</button>
<p class="label" style="text-align:center;margin-top:24px;">Baustelle PWA v1.0</p>
`;
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 => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}

BIN
icons/icon-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

BIN
icons/icon-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

4
icons/icon.svg Normal file
View file

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<rect width="512" height="512" fill="#1a1a1f"/>
<text x="256" y="320" font-size="280" text-anchor="middle" font-family="-apple-system,sans-serif">🔧</text>
</svg>

After

Width:  |  Height:  |  Size: 228 B

47
index.html Normal file
View file

@ -0,0 +1,47 @@
<!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">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<title>Baustelle</title>
<link rel="manifest" href="manifest.webmanifest">
<link rel="apple-touch-icon" href="icons/icon-192.png">
<link rel="stylesheet" href="app.css">
</head>
<body>
<div id="app">
<header id="topbar">
<button id="back-btn" class="icon-btn" style="display:none"></button>
<h1 id="page-title">Baustelle</h1>
<span id="status-badge">🟢</span>
</header>
<main id="main"></main>
<nav id="bottom-nav" style="display:none">
<button data-route="orders" class="active">📋 Aufträge</button>
<button data-route="reports">📑 Berichte</button>
<button data-route="settings">⚙️</button>
</nav>
</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 src="lib/router.js"></script>
<script src="app.js"></script>
<script>
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('sw.js').catch(e => console.warn('SW reg failed', e));
});
}
</script>
</body>
</html>

92
lib/api.js Normal file
View file

@ -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,
};
})();

89
lib/idb.js Normal file
View file

@ -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 };
})();

74
lib/offline.js Normal file
View file

@ -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 };
})();

55
lib/router.js Normal file
View file

@ -0,0 +1,55 @@
/* Mini Hash-Router für die PWA.
* Routen:
* #/login
* #/orders
* #/orders/<id>
* #/orders/<id>/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 };
})();

16
manifest.webmanifest Normal file
View file

@ -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" }
]
}

65
sw.js Normal file
View file

@ -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;
});
})
);
}
});