All checks were successful
Deploy baustelle-pwa / deploy (push) Successful in 1s
<img src> kann keine Authorization-Header schicken. Wir holen die Bilder jetzt via fetch() mit Bearer-Token und setzen Blob-URLs in die Thumbnails ein (mit Cache für wiederholte Abrufe). Vorher zeigte die PWA leere Bild-Placeholder weil document.php eine Session verlangt. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> [deploy]
285 lines
11 KiB
JavaScript
285 lines
11 KiB
JavaScript
/* 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" data-relpath="${escapeHtml(p.relpath)}"><div class="thumb-placeholder">⏳</div></div>`).join('')}
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
loadThumbs();
|
|
|
|
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" data-relpath="${escapeHtml(p.relpath)}"><div class="thumb-placeholder">⏳</div></div>`).join('');
|
|
loadThumbs();
|
|
} 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;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Lädt alle sichtbaren Thumbnails im aktuellen Photo-Grid.
|
|
* Nutzt /api/photo.php mit JWT (Header kann <img src> nicht schicken,
|
|
* deshalb Blob-URLs).
|
|
*/
|
|
async function loadThumbs() {
|
|
const thumbs = document.querySelectorAll('.photo-grid .thumb[data-relpath]');
|
|
for (const t of thumbs) {
|
|
const rel = t.dataset.relpath;
|
|
try {
|
|
// Erst Thumbnail versuchen (_small), bei Misserfolg das Original
|
|
let url = await api.getPhotoBlobUrl(rel, 'small');
|
|
if (!url) url = await api.getPhotoBlobUrl(rel);
|
|
if (url) {
|
|
t.innerHTML = '<img loading="lazy" src="' + url + '">';
|
|
} else {
|
|
t.innerHTML = '<div class="thumb-placeholder">❌</div>';
|
|
}
|
|
} catch (e) {
|
|
t.innerHTML = '<div class="thumb-placeholder">❌</div>';
|
|
}
|
|
}
|
|
}
|
|
|
|
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]));
|
|
}
|