feat: PIN-Schutz + Seiten-Reorder + Stamps
All checks were successful
Deploy baustelle-pwa / deploy (push) Successful in 1s

4.c PIN-Schutz (optional):
- Settings → Sicherheit → Toggle aktiviert PIN
- 4-stelliger Keypad-Dialog (promptPin/promptNewPin)
- SHA256 mit random Salt, beides in IndexedDB
- appBoot() Lockscreen vor Router-Start
- PIN ändern, deaktivieren jederzeit möglich
- Logout löscht PIN-Daten

4.f Seiten umsortieren:
- Long-Press (500ms) auf Seiten-Thumb → Drag-Modus
- Touch-basierter Reorder mit elementFromPoint
- Vibrationshinweis beim Drag-Start
- Beim Release: api.reorderPages + Nummern-Badges updaten

5.1 Stamps im Sketch-Editor:
- Vier vordefinierte Stempel in der Toolbar:
  ⚠ Achtung, ✓ OK, ✗ Mangel, 🔧 Reparatur
- Ein-Klick-Platzierung, Farbe aus Color-Picker,
  weißer Outline damit sie auf jedem Bild sichtbar sind
- 96px Bold-Font

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
[deploy]
This commit is contained in:
Eduard Wisch 2026-04-09 08:18:53 +02:00
parent 503d36bd09
commit 0e8ebed717
4 changed files with 334 additions and 14 deletions

64
app.css
View file

@ -439,6 +439,63 @@ body {
.btn[disabled] { opacity: 0.5; cursor: not-allowed; } .btn[disabled] { opacity: 0.5; cursor: not-allowed; }
/* PIN-Modal */
.pin-modal .pin-body {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 24px;
background: #1a1a1f;
}
.pin-modal .pin-icon { font-size: 56px; margin-bottom: 12px; }
.pin-modal .pin-title { font-size: 18px; color: #e0e0e0; margin-bottom: 24px; }
.pin-modal .pin-display {
display: flex; gap: 16px; margin-bottom: 32px;
}
.pin-modal .pin-display span {
width: 18px; height: 18px; border-radius: 50%;
border: 2px solid #7aa2f7; background: transparent;
transition: background 0.1s;
}
.pin-modal .pin-display span.filled { background: #7aa2f7; }
.pin-modal .pin-keypad {
display: grid;
grid-template-columns: repeat(3, 72px);
gap: 12px;
}
.pin-modal .pin-key {
width: 72px; height: 72px;
border-radius: 50%;
background: #2a2a30;
color: #fff;
border: 1px solid #444;
font-size: 24px;
cursor: pointer;
-webkit-appearance: none;
}
.pin-modal .pin-key:active { background: #3a3a40; transform: scale(0.97); }
.pin-modal .pin-cancel {
margin-top: 32px;
background: transparent;
border: none;
color: #999;
cursor: pointer;
font-size: 14px;
}
/* Toggle-Row für Settings */
.toggle-row {
display: flex; align-items: center; gap: 10px;
padding: 10px 0;
cursor: pointer;
}
.toggle-row input[type="checkbox"] {
width: 18px; height: 18px;
accent-color: #337ab7;
}
/* Mini-Cards für Kundendetail (Aufträge/Rechnungen/Berichte) */ /* Mini-Cards für Kundendetail (Aufträge/Rechnungen/Berichte) */
.mini-card { .mini-card {
background: #2a2a30; background: #2a2a30;
@ -472,7 +529,12 @@ body {
} }
/* Report-Page-Thumb mit Nummer */ /* Report-Page-Thumb mit Nummer */
.report-page-thumb { position: relative; } .report-page-thumb { position: relative; transition: transform 0.1s, opacity 0.1s; }
.report-page-thumb.dragging {
opacity: 0.6;
transform: scale(1.05);
box-shadow: 0 4px 20px rgba(51, 122, 183, 0.5);
}
.report-page-thumb .page-num { .report-page-thumb .page-num {
position: absolute; position: absolute;
top: 4px; left: 4px; top: 4px; left: 4px;

268
app.js
View file

@ -39,6 +39,107 @@ async function ensureAuth() {
return true; return true;
} }
/* ============================================================
* PIN-Schutz (optional, Settings Sicherheit)
* ============================================================ */
async function hashPin(pin) {
const salt = crypto.getRandomValues(new Uint8Array(16));
const saltStr = Array.from(salt).map(b => b.toString(16).padStart(2, '0')).join('');
const enc = new TextEncoder();
const data = enc.encode(saltStr + ':' + pin);
const buf = await crypto.subtle.digest('SHA-256', data);
const hash = Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, '0')).join('');
return { hash, salt: saltStr };
}
async function verifyPin(pin) {
const salt = await idb.get('pin_salt');
const stored = await idb.get('pin_hash');
if (!salt || !stored) return false;
const enc = new TextEncoder();
const data = enc.encode(salt + ':' + pin);
const buf = await crypto.subtle.digest('SHA-256', data);
const hash = Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, '0')).join('');
return hash === stored;
}
function promptPin(title) {
return new Promise((resolve) => {
const modal = document.createElement('div');
modal.className = 'fullscreen-modal pin-modal';
modal.innerHTML = `
<div class="pin-body">
<div class="pin-icon">🔐</div>
<div class="pin-title">${escapeHtml(title || 'PIN eingeben')}</div>
<div class="pin-display"><span></span><span></span><span></span><span></span></div>
<div class="pin-keypad"></div>
<div class="pin-error" id="pin-error"></div>
</div>
`;
const pad = modal.querySelector('.pin-keypad');
const disp = modal.querySelectorAll('.pin-display span');
let current = '';
function render() {
disp.forEach((el, i) => { el.classList.toggle('filled', i < current.length); });
}
function add(d) {
if (current.length >= 4) return;
current += d;
render();
if (current.length === 4) {
setTimeout(() => { modal.remove(); resolve(current); }, 150);
}
}
function del() {
current = current.slice(0, -1);
render();
}
const keys = ['1','2','3','4','5','6','7','8','9','','0','←'];
keys.forEach(k => {
const b = document.createElement('button');
b.className = 'pin-key';
b.textContent = k;
if (!k) { b.style.visibility = 'hidden'; }
else if (k === '←') b.onclick = del;
else b.onclick = () => add(k);
pad.appendChild(b);
});
// Cancel-Button
const cancel = document.createElement('button');
cancel.className = 'pin-cancel';
cancel.textContent = 'Abbrechen';
cancel.onclick = () => { modal.remove(); resolve(null); };
modal.querySelector('.pin-body').appendChild(cancel);
document.body.appendChild(modal);
});
}
async function promptNewPin() {
const p1 = await promptPin('Neue 4-stellige PIN');
if (!p1 || p1.length !== 4) return null;
const p2 = await promptPin('PIN wiederholen');
if (p2 !== p1) {
alert('PINs stimmen nicht überein');
return null;
}
return p1;
}
/**
* Startet die App fragt ggf. PIN ab bevor router läuft.
*/
window.appBoot = async function appBoot() {
const pinSet = await idb.get('pin_hash');
if (!pinSet) return; // kein PIN-Schutz aktiv
// Lockscreen: vollständiger Overlay bis richtige PIN
document.getElementById('app').style.visibility = 'hidden';
while (true) {
const pin = await promptPin('PIN eingeben');
if (pin && (await verifyPin(pin))) break;
// Falsch → kurzes Feedback und nochmal
showToast('Falsche PIN', 'error');
}
document.getElementById('app').style.visibility = '';
}
/* ====== ROUTES ====== */ /* ====== ROUTES ====== */
router.on('/login', async () => { router.on('/login', async () => {
@ -512,10 +613,8 @@ router.on('/reports/:id', async (args) => {
loadThumbs(); loadThumbs();
// Tap auf Report-Page-Thumb → Seiten-Aktionen-Modal // Tap = Aktionen, Long-Press = Drag-Sort
document.querySelectorAll('.report-page-thumb').forEach(t => { bindReportPageInteractions(args.id);
t.addEventListener('click', () => openPageActionsModal(args.id, t.dataset.pageId, t.dataset.relpath, t.dataset.note || ''));
});
// PDF-Vorschau // PDF-Vorschau
const pdfBtn = document.getElementById('btn-view-pdf'); const pdfBtn = document.getElementById('btn-view-pdf');
@ -561,12 +660,25 @@ router.on('/settings', async () => {
setBack(false); setBack(false);
const user = await idb.get('user') || {}; const user = await idb.get('user') || {};
const pinEnabled = !!(await idb.get('pin_hash'));
main().innerHTML = ` main().innerHTML = `
<div class="detail-section"> <div class="detail-section">
<h3>Konto</h3> <h3>Konto</h3>
<p><strong>${escapeHtml(user.name || user.login || 'Unbekannt')}</strong></p> <p><strong>${escapeHtml(user.name || user.login || 'Unbekannt')}</strong></p>
<p class="label">${escapeHtml(user.login || '')}</p> <p class="label">${escapeHtml(user.login || '')}</p>
</div> </div>
<div class="detail-section">
<h3>Sicherheit</h3>
<p class="label">PIN-Schutz beim App-Start nützlich falls das Handy verloren geht.</p>
<label class="toggle-row">
<input type="checkbox" id="pin-toggle" ${pinEnabled ? 'checked' : ''}>
<span>PIN beim Öffnen abfragen</span>
</label>
${pinEnabled ? '<button class="btn btn-secondary" id="btn-pin-change">🔑 PIN ändern</button>' : ''}
</div>
<button class="btn btn-secondary" id="btn-sync">🔄 Offline-Queue synchronisieren</button> <button class="btn btn-secondary" id="btn-sync">🔄 Offline-Queue synchronisieren</button>
<button class="btn btn-secondary" id="btn-logout">🚪 Abmelden</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> <p class="label" style="text-align:center;margin-top:24px;">Baustelle PWA v1.0</p>
@ -577,8 +689,43 @@ router.on('/settings', async () => {
}; };
document.getElementById('btn-logout').onclick = async () => { document.getElementById('btn-logout').onclick = async () => {
await api.logout(); await api.logout();
await idb.del('pin_hash');
await idb.del('pin_salt');
router.go('#/login'); router.go('#/login');
}; };
document.getElementById('pin-toggle').addEventListener('change', async (e) => {
if (e.target.checked) {
// PIN setzen
const pin = await promptNewPin();
if (!pin) { e.target.checked = false; return; }
const { hash, salt } = await hashPin(pin);
await idb.set('pin_salt', salt);
await idb.set('pin_hash', hash);
showToast('✓ PIN gesetzt');
router.navigate();
} else {
if (!confirm('PIN-Schutz wirklich deaktivieren?')) {
e.target.checked = true;
return;
}
await idb.del('pin_hash');
await idb.del('pin_salt');
showToast('PIN-Schutz aus');
router.navigate();
}
});
const changeBtn = document.getElementById('btn-pin-change');
if (changeBtn) changeBtn.onclick = async () => {
const old = await promptPin('Aktuelle PIN eingeben');
if (!old) return;
if (!(await verifyPin(old))) { showToast('Falsche PIN', 'error'); return; }
const neu = await promptNewPin();
if (!neu) return;
const { hash, salt } = await hashPin(neu);
await idb.set('pin_salt', salt);
await idb.set('pin_hash', hash);
showToast('✓ PIN geändert');
};
}); });
/* Bottom nav + Hilfe-Button */ /* Bottom nav + Hilfe-Button */
@ -590,6 +737,83 @@ document.addEventListener('DOMContentLoaded', () => {
if (helpBtn) helpBtn.addEventListener('click', openHelpModal); if (helpBtn) helpBtn.addEventListener('click', openHelpModal);
}); });
/**
* Bindet Tap + Long-Press-Drag an alle .report-page-thumb des Bericht-Details.
*/
function bindReportPageInteractions(reportId) {
const grid = document.getElementById('report-pages');
if (!grid) return;
const thumbs = Array.from(grid.querySelectorAll('.report-page-thumb'));
let longPressTimer = null;
let dragging = null;
let touchStart = null;
thumbs.forEach(t => {
// Click/Tap → Aktion-Modal (nur wenn nicht gedraggt wurde)
t.addEventListener('click', (e) => {
if (dragging) { e.preventDefault(); return; }
openPageActionsModal(reportId, t.dataset.pageId, t.dataset.relpath, t.dataset.note || '');
});
// Long-Press → Drag-Modus
const startLongPress = (e) => {
touchStart = e.touches ? { x: e.touches[0].clientX, y: e.touches[0].clientY } : null;
longPressTimer = setTimeout(() => {
dragging = t;
t.classList.add('dragging');
showToast('Seite verschieben — ziehen und loslassen');
if (navigator.vibrate) navigator.vibrate(50);
}, 500);
};
const cancelLongPress = () => {
if (longPressTimer) { clearTimeout(longPressTimer); longPressTimer = null; }
};
t.addEventListener('touchstart', startLongPress, { passive: true });
t.addEventListener('touchmove', (e) => {
if (!dragging && touchStart) {
const dx = e.touches[0].clientX - touchStart.x;
const dy = e.touches[0].clientY - touchStart.y;
if (Math.abs(dx) > 8 || Math.abs(dy) > 8) cancelLongPress();
}
if (dragging) {
e.preventDefault();
// Welcher Thumb liegt unter dem Finger?
const tx = e.touches[0].clientX;
const ty = e.touches[0].clientY;
const below = document.elementFromPoint(tx, ty);
const target = below ? below.closest('.report-page-thumb') : null;
if (target && target !== dragging) {
const rect = target.getBoundingClientRect();
const middle = rect.left + rect.width / 2;
if (tx < middle) grid.insertBefore(dragging, target);
else grid.insertBefore(dragging, target.nextSibling);
}
}
}, { passive: false });
t.addEventListener('touchend', async () => {
cancelLongPress();
if (dragging) {
dragging.classList.remove('dragging');
const ids = Array.from(grid.querySelectorAll('.report-page-thumb')).map(x => x.dataset.pageId);
try {
await api.reorderPages(ids);
showToast('✓ Reihenfolge gespeichert');
// Page-Nummer-Badges neu setzen
Array.from(grid.querySelectorAll('.report-page-thumb')).forEach((el, i) => {
const num = el.querySelector('.page-num');
if (num) num.textContent = (i + 1);
});
} catch (e) { showToast('Fehler: ' + e.message, 'error'); }
dragging = null;
}
touchStart = null;
});
t.addEventListener('touchcancel', () => { cancelLongPress(); dragging = null; touchStart = null; });
});
}
/* ============================================================ /* ============================================================
* SEITEN-AKTIONEN MODAL (Notiz, Löschen, Vollbild) * SEITEN-AKTIONEN MODAL (Notiz, Löschen, Vollbild)
* ============================================================ */ * ============================================================ */
@ -1021,16 +1245,21 @@ async function openSketchEditor(orderId, imageUrl, sourceRelpath) {
<button class="icon-btn" id="sk-save" title="Speichern"></button> <button class="icon-btn" id="sk-save" title="Speichern"></button>
</div> </div>
<div class="sketch-toolbar"> <div class="sketch-toolbar">
<button class="sk-tool active" data-tool="pen"></button> <button class="sk-tool active" data-tool="pen" title="Stift"></button>
<button class="sk-tool" data-tool="arrow"></button> <button class="sk-tool" data-tool="arrow" title="Pfeil"></button>
<button class="sk-tool" data-tool="rect"></button> <button class="sk-tool" data-tool="rect" title="Rechteck"></button>
<button class="sk-tool" data-tool="circle"></button> <button class="sk-tool" data-tool="circle" title="Kreis"></button>
<span class="sep"></span>
<button class="sk-tool" data-tool="stamp" data-stamp="⚠" title="Achtung"></button>
<button class="sk-tool" data-tool="stamp" data-stamp="✓" title="OK"></button>
<button class="sk-tool" data-tool="stamp" data-stamp="✗" title="Mangel"></button>
<button class="sk-tool" data-tool="stamp" data-stamp="🔧" title="Reparatur">🔧</button>
<span class="sep"></span> <span class="sep"></span>
<input type="color" id="sk-color" value="#ff0000"> <input type="color" id="sk-color" value="#ff0000">
<input type="range" id="sk-width" min="2" max="20" value="5"> <input type="range" id="sk-width" min="2" max="20" value="5">
<span class="sep"></span> <span class="sep"></span>
<button id="sk-undo"></button> <button id="sk-undo" title="Rückgängig"></button>
<button id="sk-clear">🗑</button> <button id="sk-clear" title="Alles löschen">🗑</button>
</div> </div>
<div class="sketch-body"> <div class="sketch-body">
<canvas id="sk-canvas"></canvas> <canvas id="sk-canvas"></canvas>
@ -1067,6 +1296,7 @@ async function openSketchEditor(orderId, imageUrl, sourceRelpath) {
// State // State
let tool = 'pen'; let tool = 'pen';
let stampChar = null;
let color = '#ff0000'; let color = '#ff0000';
let lineWidth = 5; let lineWidth = 5;
let drawing = false; let drawing = false;
@ -1090,6 +1320,7 @@ async function openSketchEditor(orderId, imageUrl, sourceRelpath) {
modal.querySelectorAll('.sk-tool').forEach(x => x.classList.remove('active')); modal.querySelectorAll('.sk-tool').forEach(x => x.classList.remove('active'));
b.classList.add('active'); b.classList.add('active');
tool = b.dataset.tool; tool = b.dataset.tool;
stampChar = b.dataset.stamp || null;
}); });
}); });
modal.querySelector('#sk-color').oninput = e => { color = e.target.value; }; modal.querySelector('#sk-color').oninput = e => { color = e.target.value; };
@ -1118,9 +1349,24 @@ async function openSketchEditor(orderId, imageUrl, sourceRelpath) {
function startDraw(e) { function startDraw(e) {
e.preventDefault(); e.preventDefault();
drawing = true;
const p = getPos(e); const p = getPos(e);
startX = p.x; startY = p.y; startX = p.x; startY = p.y;
// Stamps sind ein Single-Click-Tool (kein Drag)
if (tool === 'stamp' && stampChar) {
ctx.fillStyle = color;
ctx.strokeStyle = '#fff';
ctx.lineWidth = 4;
ctx.font = 'bold 96px -apple-system, sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.strokeText(stampChar, p.x, p.y);
ctx.fillText(stampChar, p.x, p.y);
pushHistory();
return;
}
drawing = true;
ctx.strokeStyle = color; ctx.strokeStyle = color;
ctx.fillStyle = color; ctx.fillStyle = color;
ctx.lineWidth = lineWidth; ctx.lineWidth = lineWidth;

View file

@ -144,6 +144,13 @@
}); });
} }
async function reorderPages(pageIds) {
return request('/pages.php?action=reorder', {
method: 'POST',
body: JSON.stringify({ order: pageIds }),
});
}
async function getPdfBlobUrl(berichtId) { async function getPdfBlobUrl(berichtId) {
const t = await getToken(); const t = await getToken();
if (!t) return null; if (!t) return null;
@ -200,6 +207,6 @@
getReport, listReports, finalizeReport, getReport, listReports, finalizeReport,
deletePhoto, uploadVoiceNote, uploadAnnotatedPhoto, deletePhoto, uploadVoiceNote, uploadAnnotatedPhoto,
getPhotoBlobUrl, clearPhotoCache, getPhotoBlobUrl, clearPhotoCache,
deletePage, updatePageNote, uploadSignature, getPdfBlobUrl, deletePage, updatePageNote, uploadSignature, getPdfBlobUrl, reorderPages,
}; };
})(); })();

View file

@ -49,7 +49,12 @@
} }
window.addEventListener('hashchange', () => navigate()); window.addEventListener('hashchange', () => navigate());
document.addEventListener('DOMContentLoaded', () => navigate()); document.addEventListener('DOMContentLoaded', async () => {
if (typeof window.appBoot === 'function') {
try { await window.appBoot(); } catch (e) {}
}
navigate();
});
window.router = { on, navigate, go }; window.router = { on, navigate, go };
})(); })();