feat: Initiales Release Baustelle PWA v1.0.0 [deploy]
All checks were successful
Deploy baustelle-pwa / deploy (push) Successful in 1s
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:
commit
7c0aefa793
15 changed files with 1065 additions and 0 deletions
40
.forgejo/workflows/deploy.yml
Normal file
40
.forgejo/workflows/deploy.yml
Normal 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
6
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
.DS_Store
|
||||
*.swp
|
||||
*.bak
|
||||
.vscode/
|
||||
.idea/
|
||||
node_modules/
|
||||
69
README.md
Normal file
69
README.md
Normal 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
243
app.css
Normal 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
265
app.js
Normal 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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
}
|
||||
BIN
icons/icon-192.png
Normal file
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
BIN
icons/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.1 KiB |
4
icons/icon.svg
Normal file
4
icons/icon.svg
Normal 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
47
index.html
Normal 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
92
lib/api.js
Normal 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
89
lib/idb.js
Normal 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
74
lib/offline.js
Normal 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
55
lib/router.js
Normal 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
16
manifest.webmanifest
Normal 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
65
sw.js
Normal 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;
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
Loading…
Reference in a new issue