All checks were successful
Deploy baustelle-pwa / deploy (push) Successful in 14s
- Kamera bleibt nach jedem Schnappschuss offen (getUserMedia Live-Video statt native capture-Input, der nach jedem Foto schliesst) - Filmstreifen mit Status-Badges pro Foto (💾 gesichert → ⏳ lädt hoch → ✓ fertig) - Persist-First: Foto zuerst in IndexedDB schreiben, Upload erst danach - Foto verlässt Queue NUR nach HTTP-2xx mit gültigem relpath - 2xx-HTML (abgelaufene Session, Proxy-Loginseite) wird als Fehler erkannt - HTTP-Status auf Error-Objekt: 5xx/429/408 sind transient (nicht quarantänisiert) - 45-Sekunden-Upload-Timeout via AbortController - Quarantäne nach 6 Fehlversuchen, Recovery via Status-Badge-Tipp - Beforeunload-Warnung wenn Fotos noch nicht gesichert - idb.queueUpdate() für state-preserving ItemUpdates ohne Datenverlust Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
353 lines
13 KiB
JavaScript
353 lines
13 KiB
JavaScript
/* 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';
|
|
}
|
|
// Optionaler Timeout: bricht hängende Requests ab, statt ewig zu warten
|
|
// (schwaches/totes Netz auf der Baustelle → Foto landet zuverlässig in der Queue)
|
|
let signal = opts.signal;
|
|
let toHandle = null;
|
|
if (opts.timeoutMs && typeof AbortController !== 'undefined') {
|
|
const ctrl = new AbortController();
|
|
signal = ctrl.signal;
|
|
toHandle = setTimeout(() => { try { ctrl.abort(); } catch (_) {} }, opts.timeoutMs);
|
|
}
|
|
let r;
|
|
try {
|
|
r = await fetch(API_BASE + path, { ...opts, headers, signal });
|
|
} finally {
|
|
if (toHandle) clearTimeout(toHandle);
|
|
}
|
|
if (r.status === 401) {
|
|
await clearToken();
|
|
window.location.hash = '#/login';
|
|
const e = new Error('Nicht authentifiziert'); e.status = 401; throw e;
|
|
}
|
|
// Antwort MUSS JSON sein. Ein 2xx mit HTML (z.B. Apache/Proxy-Login- oder
|
|
// Wartungsseite bei abgelaufener Session, ErrorDocument 200) darf NIEMALS als
|
|
// Erfolg gelten — sonst würde ein Foto fälschlich als hochgeladen aus der Queue
|
|
// gelöscht und wäre verloren.
|
|
const ct = (r.headers.get('Content-Type') || '').toLowerCase();
|
|
let data = {};
|
|
if (ct.includes('application/json')) {
|
|
data = await r.json().catch(() => ({}));
|
|
} else if (r.ok) {
|
|
const e = new Error('Unerwartete Server-Antwort (kein JSON)');
|
|
e.status = r.status; e.nonJson = true;
|
|
throw e;
|
|
}
|
|
if (!r.ok) { const e = new Error(data.error || 'API-Fehler'); e.status = r.status; throw e; }
|
|
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 listCustomers(opts = {}) {
|
|
const params = new URLSearchParams();
|
|
if (opts.q) params.set('q', opts.q);
|
|
const qs = params.toString();
|
|
return request('/customers.php' + (qs ? '?' + qs : ''));
|
|
}
|
|
|
|
async function getCustomer(id) {
|
|
return request('/customers.php?id=' + id);
|
|
}
|
|
|
|
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');
|
|
}
|
|
|
|
// Semantik: at-least-once. Geht die Server-Antwort NACH dem Speichern verloren
|
|
// (z.B. Netz-Abbruch, Proxy-2xx-HTML), gilt der Upload als unbestätigt und wird
|
|
// wiederholt → das Foto kann dadurch doppelt auf dem Server landen (der Dateiname
|
|
// enthält uniqid(), es wird also nicht überschrieben). Bewusst gewählt: lieber ein
|
|
// löschbares Duplikat als ein verlorenes Foto.
|
|
async function uploadOrderPhoto(orderId, fileBlob, filename) {
|
|
const fd = new FormData();
|
|
fd.append('file', fileBlob, filename || 'photo.jpg');
|
|
const res = await request('/orders.php?id=' + orderId + '&action=upload_photo', {
|
|
method: 'POST',
|
|
body: fd,
|
|
timeoutMs: 45000,
|
|
});
|
|
// Der Server MUSS einen relpath liefern, wenn er die Datei wirklich gespeichert hat.
|
|
// Fehlt er, gilt der Upload als NICHT bestätigt → Foto bleibt in der Queue (Retry).
|
|
if (!res || typeof res.relpath !== 'string' || res.relpath === '') {
|
|
const e = new Error('Upload nicht bestätigt (kein relpath)');
|
|
e.uploadUnconfirmed = true;
|
|
throw e;
|
|
}
|
|
return res;
|
|
}
|
|
|
|
async function createOrder(payload) {
|
|
return request('/orders.php?action=create', {
|
|
method: 'POST',
|
|
body: JSON.stringify(payload),
|
|
});
|
|
}
|
|
|
|
async function getReport(id) {
|
|
return request('/reports.php?id=' + id);
|
|
}
|
|
|
|
async function listReports() {
|
|
return request('/reports.php');
|
|
}
|
|
|
|
async function createReport(opts) {
|
|
return request('/reports.php?action=create', {
|
|
method: 'POST',
|
|
body: JSON.stringify(opts),
|
|
});
|
|
}
|
|
|
|
async function listTemplates() {
|
|
return request('/templates.php');
|
|
}
|
|
|
|
async function listOdtTemplates() {
|
|
return request('/odt_templates.php');
|
|
}
|
|
|
|
async function finalizeReport(id) {
|
|
return request('/reports.php?id=' + id + '&action=finalize', { method: 'POST' });
|
|
}
|
|
|
|
async function deleteReport(id) {
|
|
return request('/reports.php?id=' + id, { method: 'DELETE' });
|
|
}
|
|
|
|
async function deletePhoto(relpath) {
|
|
return request('/delete_photo.php', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ relpath }),
|
|
});
|
|
}
|
|
|
|
async function uploadVoiceNote(orderId, audioBlob, filename) {
|
|
const fd = new FormData();
|
|
fd.append('file', audioBlob, filename || 'voice.webm');
|
|
return request('/voice.php?order_id=' + orderId, { method: 'POST', body: fd });
|
|
}
|
|
|
|
async function transcribeAudio(relpath) {
|
|
return request('/transcribe.php', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ relpath }),
|
|
});
|
|
}
|
|
|
|
async function listMaterials(elementType, elementId) {
|
|
return request('/materials.php?element_type=' + elementType + '&element_id=' + elementId);
|
|
}
|
|
|
|
async function addMaterial(elementType, elementId, data) {
|
|
return request('/materials.php?element_type=' + elementType + '&element_id=' + elementId, {
|
|
method: 'POST',
|
|
body: JSON.stringify(data),
|
|
});
|
|
}
|
|
|
|
async function deleteMaterial(id) {
|
|
return request('/materials.php?id=' + id + '&delete=1', { method: 'POST' });
|
|
}
|
|
|
|
async function uploadAnnotatedPhoto(orderId, fileBlob, filename) {
|
|
// Wie uploadOrderPhoto — Skizze ist schon ins Bild eingebrannt
|
|
return uploadOrderPhoto(orderId, fileBlob, filename);
|
|
}
|
|
|
|
async function deletePage(pageId) {
|
|
return request('/pages.php?id=' + pageId, { method: 'DELETE' });
|
|
}
|
|
|
|
async function updatePageNote(pageId, note) {
|
|
return request('/pages.php?id=' + pageId, {
|
|
method: 'POST',
|
|
body: JSON.stringify({ note }),
|
|
});
|
|
}
|
|
|
|
async function uploadSignature(berichtId, pngBlob, opts) {
|
|
const fd = new FormData();
|
|
fd.append('file', pngBlob, 'signature.png');
|
|
if (opts && opts.signer_name) fd.append('signer_name', opts.signer_name);
|
|
if (opts && opts.gps_lat != null) fd.append('gps_lat', opts.gps_lat);
|
|
if (opts && opts.gps_lon != null) fd.append('gps_lon', opts.gps_lon);
|
|
return request('/pages.php?action=signature&bericht_id=' + berichtId, {
|
|
method: 'POST',
|
|
body: fd,
|
|
});
|
|
}
|
|
|
|
/* ----- Lieferungen ----- */
|
|
async function listShipments(orderId) {
|
|
return request('/shipments.php?order_id=' + orderId);
|
|
}
|
|
|
|
async function getShipment(id) {
|
|
return request('/shipments.php?id=' + id);
|
|
}
|
|
|
|
async function getShipmentPdfBlobUrl(shipmentId) {
|
|
const t = await getToken();
|
|
if (!t) return null;
|
|
const params = new URLSearchParams({ id: shipmentId, action: 'pdf', jwt: t });
|
|
const r = await fetch(API_BASE + '/shipments.php?' + params.toString());
|
|
if (!r.ok) return null;
|
|
const blob = await r.blob();
|
|
return { url: URL.createObjectURL(blob), blob };
|
|
}
|
|
|
|
async function confirmShipment(shipmentId, pngBlob, opts) {
|
|
const fd = new FormData();
|
|
fd.append('file', pngBlob, 'signature.png');
|
|
fd.append('signer_name', (opts && opts.signer_name) || '');
|
|
if (opts && opts.gps_lat != null) fd.append('gps_lat', opts.gps_lat);
|
|
if (opts && opts.gps_lon != null) fd.append('gps_lon', opts.gps_lon);
|
|
return request('/shipments.php?id=' + shipmentId + '&action=confirm', {
|
|
method: 'POST',
|
|
body: fd,
|
|
});
|
|
}
|
|
|
|
async function reorderPages(pageIds) {
|
|
return request('/pages.php?action=reorder', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ order: pageIds }),
|
|
});
|
|
}
|
|
|
|
async function getPdfBlobUrl(berichtId) {
|
|
const t = await getToken();
|
|
if (!t) return null;
|
|
const params = new URLSearchParams({ id: berichtId, jwt: t });
|
|
const r = await fetch(API_BASE + '/pdf.php?' + params.toString());
|
|
if (!r.ok) return null;
|
|
const blob = await r.blob();
|
|
return URL.createObjectURL(blob);
|
|
}
|
|
|
|
/**
|
|
* Lädt eine Bild-Datei von der API als Blob-URL (inkl. JWT).
|
|
* Wird benötigt weil <img src> keine Authorization-Header mitschickt.
|
|
*/
|
|
const blobUrlCache = new Map();
|
|
async function getPhotoBlobUrl(relpath, size) {
|
|
const key = (size || 'full') + '|' + relpath;
|
|
if (blobUrlCache.has(key)) return blobUrlCache.get(key);
|
|
|
|
const t = await getToken();
|
|
if (!t) return null;
|
|
// JWT als Query-Param, weil Apache auf prod den Authorization-Header filtert
|
|
const params = new URLSearchParams({ relpath, jwt: t });
|
|
if (size) params.set('size', size);
|
|
|
|
const r = await fetch(API_BASE + '/photo.php?' + params.toString());
|
|
if (!r.ok) {
|
|
const body = await r.text().catch(() => '');
|
|
console.warn('photo.php failed', r.status, relpath, body);
|
|
return null;
|
|
}
|
|
const ct = r.headers.get('Content-Type') || '';
|
|
if (!ct.startsWith('image/')) {
|
|
const body = await r.text().catch(() => '');
|
|
console.warn('photo.php not an image', ct, body);
|
|
return null;
|
|
}
|
|
const blob = await r.blob();
|
|
const url = URL.createObjectURL(blob);
|
|
blobUrlCache.set(key, url);
|
|
return url;
|
|
}
|
|
|
|
function clearPhotoCache() {
|
|
for (const url of blobUrlCache.values()) URL.revokeObjectURL(url);
|
|
blobUrlCache.clear();
|
|
}
|
|
|
|
// Lädt eine beliebige Datei (PDF, DOCX, ...) als Blob-URL inkl. JWT.
|
|
// Ohne Mime-Filter — Aufrufer entscheidet selbst, was damit passiert.
|
|
async function getFileBlobUrl(relpath) {
|
|
const t = await getToken();
|
|
if (!t) return null;
|
|
const params = new URLSearchParams({ relpath, jwt: t });
|
|
const url = API_BASE + '/photo.php?' + params.toString();
|
|
console.log('[API] Fetching file:', relpath);
|
|
const r = await fetch(url);
|
|
if (!r.ok) {
|
|
console.warn('[API] File fetch failed', r.status, relpath);
|
|
return null;
|
|
}
|
|
const blob = await r.blob();
|
|
console.log('[API] Got blob:', blob.size, 'bytes, type:', blob.type);
|
|
return { url: URL.createObjectURL(blob), blob, mime: r.headers.get('Content-Type') || '' };
|
|
}
|
|
|
|
// Low-level request-Funktion auch exposen für Spezialfälle
|
|
window.api = {
|
|
request,
|
|
getToken, setToken, clearToken,
|
|
login, logout,
|
|
listOrders, getOrder, listOrderPhotos, uploadOrderPhoto, createOrder,
|
|
listCustomers, getCustomer,
|
|
getReport, listReports, createReport, listTemplates, listOdtTemplates, finalizeReport, deleteReport,
|
|
deletePhoto, uploadVoiceNote, transcribeAudio, uploadAnnotatedPhoto,
|
|
listMaterials, addMaterial, deleteMaterial,
|
|
getPhotoBlobUrl, clearPhotoCache, getFileBlobUrl,
|
|
deletePage, updatePageNote, uploadSignature, getPdfBlobUrl, reorderPages,
|
|
listShipments, getShipment, getShipmentPdfBlobUrl, confirmShipment,
|
|
};
|
|
})();
|