feat: Phase 4 — Bericht-Liste, Detail, Foto-Vollbild, Voice, Sketch
All checks were successful
Deploy baustelle-pwa / deploy (push) Successful in 1s
All checks were successful
Deploy baustelle-pwa / deploy (push) Successful in 1s
Der Reports-Tab listet alle Berichte des Users mit Status-Badge, Quell-Referenz und Seitenzahl. Klick → Bericht-Detail mit: - Meta (Titel, Format, Status, Seiten) - Seiten-Thumbnails - 'Bericht finalisieren' Button (triggert PDF-Generierung via API) - 'Im Desktop-Editor öffnen' Link Auftrags-Detail erweitert: - Tap auf Foto-Thumb → Vollbild-Modal mit Löschen-Button und 'Zeichnen'-Button (öffnet Skizzen-Editor) - '🎙 Sprachnotiz aufnehmen' Button → Voice-Modal mit MediaRecorder, Live-Timer, Preview, Upload Skizzen-Editor (Touch-fähig): - Bild wird in canvas geladen, max 1600px - Werkzeuge: Pen, Pfeil mit Spitze, Rechteck, Ellipse - Farbe + Linienstärke - Undo + Clear - Speichern → rendert als JPEG und lädt als neue Bericht-Seite hoch Service Worker Cache-Version auf v2 gebumpt. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> [deploy]
This commit is contained in:
parent
883ea17267
commit
a1589a7ae2
4 changed files with 602 additions and 3 deletions
152
app.css
152
app.css
|
|
@ -253,3 +253,155 @@ body {
|
|||
padding: 40px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.status-draft {
|
||||
background: #6c757d;
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 8px;
|
||||
margin-left: 6px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.status-final {
|
||||
background: #5cb85c;
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 8px;
|
||||
margin-left: 6px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* Fullscreen Modals (Foto-Vollbild, Voice, Sketch)
|
||||
* ============================================================ */
|
||||
.fullscreen-modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: #000;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-top: env(safe-area-inset-top);
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
.fs-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
background: rgba(0,0,0,0.7);
|
||||
color: #fff;
|
||||
border-bottom: 1px solid #222;
|
||||
}
|
||||
.fs-header .fs-title {
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.fs-header .icon-btn { color: #fff; }
|
||||
.fs-body {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 12px;
|
||||
}
|
||||
.fs-body img {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
/* Voice Modal */
|
||||
.voice-modal .voice-body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
gap: 16px;
|
||||
color: #fff;
|
||||
}
|
||||
.voice-indicator {
|
||||
font-size: 80px;
|
||||
color: #444;
|
||||
transition: color 0.3s;
|
||||
}
|
||||
.voice-indicator.recording {
|
||||
color: #d9534f;
|
||||
animation: pulse 1s infinite;
|
||||
}
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
.voice-time {
|
||||
font-size: 32px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: #7aa2f7;
|
||||
}
|
||||
|
||||
/* Sketch Modal */
|
||||
.sketch-modal .sketch-body {
|
||||
flex: 1;
|
||||
background: #222;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
.sketch-modal .sketch-body canvas {
|
||||
background: #fff;
|
||||
box-shadow: 0 0 20px rgba(0,0,0,0.5);
|
||||
touch-action: none;
|
||||
}
|
||||
.sketch-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 8px 12px;
|
||||
background: #1a1a1f;
|
||||
border-bottom: 1px solid #333;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
.sketch-toolbar button {
|
||||
background: #2a2a30;
|
||||
color: #fff;
|
||||
border: 1px solid #444;
|
||||
border-radius: 6px;
|
||||
padding: 8px 12px;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.sketch-toolbar button.active {
|
||||
background: #337ab7;
|
||||
border-color: #2868a0;
|
||||
}
|
||||
.sketch-toolbar .sep {
|
||||
width: 1px;
|
||||
background: #444;
|
||||
height: 24px;
|
||||
margin: 0 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.sketch-toolbar input[type="color"] {
|
||||
width: 40px;
|
||||
height: 36px;
|
||||
border: 1px solid #444;
|
||||
background: #2a2a30;
|
||||
border-radius: 6px;
|
||||
padding: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.sketch-toolbar input[type="range"] {
|
||||
width: 100px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
|
|
|||
421
app.js
421
app.js
|
|
@ -147,6 +147,7 @@ router.on('/orders/:id', async (args) => {
|
|||
<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>
|
||||
<button class="btn btn-secondary" id="btn-voice">🎙 Sprachnotiz aufnehmen</button>
|
||||
|
||||
<div class="detail-section" style="margin-top:16px;">
|
||||
<h3>Hochgeladene Fotos (${imagePhotos.length})</h3>
|
||||
|
|
@ -161,6 +162,14 @@ router.on('/orders/:id', async (args) => {
|
|||
</div>` : ''}
|
||||
`;
|
||||
|
||||
// Foto-Thumbnails: Tap = Vollbild-Modal
|
||||
document.querySelectorAll('#photo-grid .thumb').forEach(t => {
|
||||
t.addEventListener('click', () => openPhotoModal(args.id, t.dataset.relpath));
|
||||
});
|
||||
|
||||
// Sprachnotiz
|
||||
document.getElementById('btn-voice').onclick = () => openVoiceModal(args.id);
|
||||
|
||||
loadThumbs();
|
||||
|
||||
const camInput = document.getElementById('camera-input');
|
||||
|
|
@ -253,7 +262,87 @@ router.on('/reports', async () => {
|
|||
title('Berichte');
|
||||
setNav(true, 'reports');
|
||||
setBack(false);
|
||||
main().innerHTML = '<div class="empty-state"><div class="icon">📑</div>Berichte-Liste folgt</div>';
|
||||
showLoader('Lade Berichte…');
|
||||
try {
|
||||
const data = await api.listReports();
|
||||
if (!data.reports.length) {
|
||||
main().innerHTML = '<div class="empty-state"><div class="icon">📑</div>Noch keine Berichte</div>';
|
||||
return;
|
||||
}
|
||||
main().innerHTML = data.reports.map(r => {
|
||||
const statusLabel = r.status === 1 ? 'Final' : 'Entwurf';
|
||||
const statusClass = r.status === 1 ? 'status-final' : 'status-draft';
|
||||
const sourceIcon = r.element_type === 'order' ? '🛒' : (r.element_type === 'invoice' ? '📄' : '📋');
|
||||
return `
|
||||
<div class="order-card" data-id="${r.id}">
|
||||
<div class="ref">${escapeHtml(r.ref)} <span class="${statusClass}">${statusLabel}</span></div>
|
||||
<div class="name">${escapeHtml(r.titel || '')}</div>
|
||||
<div class="meta">
|
||||
<span>${sourceIcon} ${escapeHtml(r.parent_ref || '')}</span>
|
||||
<span class="badge">${r.page_count} Seite${r.page_count === 1 ? '' : 'n'}</span>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
document.querySelectorAll('.order-card').forEach(c => {
|
||||
c.addEventListener('click', () => router.go('#/reports/' + c.dataset.id));
|
||||
});
|
||||
} catch (e) {
|
||||
main().innerHTML = '<div class="empty-state"><div class="icon">⚠️</div>' + e.message + '</div>';
|
||||
}
|
||||
});
|
||||
|
||||
router.on('/reports/:id', async (args) => {
|
||||
if (!(await ensureAuth())) return;
|
||||
setNav(true, 'reports');
|
||||
setBack(true, '#/reports');
|
||||
showLoader('Lade Bericht…');
|
||||
|
||||
try {
|
||||
const data = await api.getReport(args.id);
|
||||
title(data.report.ref);
|
||||
|
||||
const statusLabel = data.report.status === 1 ? 'Final' : 'Entwurf';
|
||||
const canFinalize = data.report.status !== 1 && data.pages.length > 0;
|
||||
|
||||
main().innerHTML = `
|
||||
<div class="detail-section">
|
||||
<h3>Bericht</h3>
|
||||
<p><strong>${escapeHtml(data.report.titel || data.report.ref)}</strong></p>
|
||||
<p class="label">Auftrag: ${escapeHtml(data.report.auftragsnummer || '—')}</p>
|
||||
<p class="label">Format: ${escapeHtml(data.report.page_format || 'A4')} ${data.report.page_orientation === 'L' ? 'Quer' : 'Hoch'}</p>
|
||||
<p class="label">Seiten: ${data.pages.length}</p>
|
||||
<p class="label">Status: <strong>${statusLabel}</strong></p>
|
||||
</div>
|
||||
|
||||
<div class="photo-grid" id="report-pages">
|
||||
${data.pages.map(p => `<div class="thumb" data-relpath="${escapeHtml(p.source_path)}" data-page-id="${p.id}"><div class="thumb-placeholder">⏳</div></div>`).join('')}
|
||||
</div>
|
||||
|
||||
${canFinalize ? `<button class="btn btn-large" id="btn-finalize">📑 Bericht finalisieren (PDF)</button>` : ''}
|
||||
<button class="btn btn-secondary" id="btn-open-editor">✏️ Im Desktop-Editor öffnen</button>
|
||||
`;
|
||||
|
||||
loadThumbs();
|
||||
|
||||
if (canFinalize) {
|
||||
document.getElementById('btn-finalize').onclick = async () => {
|
||||
if (!confirm('Bericht jetzt finalisieren und PDF erzeugen?')) return;
|
||||
showToast('PDF wird erzeugt…');
|
||||
try {
|
||||
const r = await api.finalizeReport(args.id);
|
||||
showToast('✓ PDF erstellt: ' + r.filename);
|
||||
setTimeout(() => router.go('#/reports/' + args.id), 800);
|
||||
} catch (e) {
|
||||
showToast('Fehler: ' + e.message, 'error');
|
||||
}
|
||||
};
|
||||
}
|
||||
document.getElementById('btn-open-editor').onclick = () => {
|
||||
window.open(window.location.origin + '/custom/bericht/bericht_card.php?berichtid=' + args.id, '_blank');
|
||||
};
|
||||
} catch (e) {
|
||||
main().innerHTML = '<div class="empty-state"><div class="icon">⚠️</div>' + e.message + '</div>';
|
||||
}
|
||||
});
|
||||
|
||||
router.on('/settings', async () => {
|
||||
|
|
@ -293,3 +382,333 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||
function escapeHtml(s) {
|
||||
return String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* PHOTO VOLLBILD MODAL + SKIZZEN-EDITOR
|
||||
* ============================================================ */
|
||||
async function openPhotoModal(orderId, relpath) {
|
||||
const url = await api.getPhotoBlobUrl(relpath);
|
||||
if (!url) { showToast('Foto konnte nicht geladen werden', 'error'); return; }
|
||||
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'fullscreen-modal';
|
||||
modal.innerHTML = `
|
||||
<div class="fs-header">
|
||||
<button class="icon-btn" id="fs-close">✕</button>
|
||||
<div class="fs-title">${escapeHtml(relpath.split('/').pop())}</div>
|
||||
<button class="icon-btn" id="fs-sketch" title="Zeichnen">✏️</button>
|
||||
<button class="icon-btn" id="fs-delete" title="Löschen">🗑️</button>
|
||||
</div>
|
||||
<div class="fs-body"><img src="${url}" alt=""></div>
|
||||
`;
|
||||
document.body.appendChild(modal);
|
||||
|
||||
const close = () => modal.remove();
|
||||
modal.querySelector('#fs-close').onclick = close;
|
||||
modal.querySelector('#fs-delete').onclick = async () => {
|
||||
if (!confirm('Foto wirklich löschen?')) return;
|
||||
try {
|
||||
await api.deletePhoto(relpath);
|
||||
showToast('✓ Gelöscht');
|
||||
close();
|
||||
// Parent view neu laden
|
||||
router.navigate();
|
||||
} catch (e) { showToast('Löschen fehlgeschlagen: ' + e.message, 'error'); }
|
||||
};
|
||||
modal.querySelector('#fs-sketch').onclick = () => {
|
||||
close();
|
||||
openSketchEditor(orderId, url, relpath);
|
||||
};
|
||||
}
|
||||
|
||||
async function openVoiceModal(orderId) {
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'fullscreen-modal voice-modal';
|
||||
modal.innerHTML = `
|
||||
<div class="fs-header">
|
||||
<button class="icon-btn" id="v-close">✕</button>
|
||||
<div class="fs-title">🎙 Sprachnotiz</div>
|
||||
<span></span>
|
||||
</div>
|
||||
<div class="voice-body">
|
||||
<div class="voice-indicator" id="v-indicator">●</div>
|
||||
<div class="voice-time" id="v-time">00:00</div>
|
||||
<button class="btn btn-large" id="v-start">Aufnahme starten</button>
|
||||
<button class="btn btn-secondary" id="v-stop" style="display:none">Stopp</button>
|
||||
<button class="btn" id="v-send" style="display:none">Senden</button>
|
||||
<audio id="v-preview" controls style="display:none;width:100%;margin-top:12px;"></audio>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(modal);
|
||||
|
||||
let mediaRecorder = null;
|
||||
let chunks = [];
|
||||
let timer = null;
|
||||
let startTime = 0;
|
||||
let audioBlob = null;
|
||||
|
||||
const startBtn = modal.querySelector('#v-start');
|
||||
const stopBtn = modal.querySelector('#v-stop');
|
||||
const sendBtn = modal.querySelector('#v-send');
|
||||
const indicator = modal.querySelector('#v-indicator');
|
||||
const timeEl = modal.querySelector('#v-time');
|
||||
const preview = modal.querySelector('#v-preview');
|
||||
|
||||
modal.querySelector('#v-close').onclick = () => {
|
||||
if (mediaRecorder && mediaRecorder.state === 'recording') mediaRecorder.stop();
|
||||
if (timer) clearInterval(timer);
|
||||
modal.remove();
|
||||
};
|
||||
|
||||
startBtn.onclick = async () => {
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
chunks = [];
|
||||
mediaRecorder = new MediaRecorder(stream);
|
||||
mediaRecorder.ondataavailable = e => chunks.push(e.data);
|
||||
mediaRecorder.onstop = () => {
|
||||
stream.getTracks().forEach(t => t.stop());
|
||||
audioBlob = new Blob(chunks, { type: mediaRecorder.mimeType || 'audio/webm' });
|
||||
preview.src = URL.createObjectURL(audioBlob);
|
||||
preview.style.display = '';
|
||||
sendBtn.style.display = '';
|
||||
indicator.classList.remove('recording');
|
||||
};
|
||||
mediaRecorder.start();
|
||||
startTime = Date.now();
|
||||
indicator.classList.add('recording');
|
||||
startBtn.style.display = 'none';
|
||||
stopBtn.style.display = '';
|
||||
timer = setInterval(() => {
|
||||
const s = Math.floor((Date.now() - startTime) / 1000);
|
||||
timeEl.textContent = String(Math.floor(s / 60)).padStart(2, '0') + ':' + String(s % 60).padStart(2, '0');
|
||||
}, 500);
|
||||
} catch (e) {
|
||||
showToast('Mikrofon-Zugriff verweigert', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
stopBtn.onclick = () => {
|
||||
if (mediaRecorder && mediaRecorder.state === 'recording') mediaRecorder.stop();
|
||||
if (timer) clearInterval(timer);
|
||||
stopBtn.style.display = 'none';
|
||||
};
|
||||
|
||||
sendBtn.onclick = async () => {
|
||||
if (!audioBlob) return;
|
||||
sendBtn.disabled = true;
|
||||
try {
|
||||
await api.uploadVoiceNote(orderId, audioBlob, 'voice_' + Date.now() + '.webm');
|
||||
showToast('✓ Sprachnotiz hochgeladen');
|
||||
modal.remove();
|
||||
} catch (e) {
|
||||
showToast('Upload fehlgeschlagen: ' + e.message, 'error');
|
||||
sendBtn.disabled = false;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* SKIZZEN-EDITOR (Touch-fähig, einfache Vektor-Zeichnung)
|
||||
* ============================================================ */
|
||||
async function openSketchEditor(orderId, imageUrl, sourceRelpath) {
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'fullscreen-modal sketch-modal';
|
||||
modal.innerHTML = `
|
||||
<div class="fs-header">
|
||||
<button class="icon-btn" id="sk-close">✕</button>
|
||||
<div class="fs-title">✏️ Skizze</div>
|
||||
<button class="icon-btn" id="sk-save" title="Speichern">✓</button>
|
||||
</div>
|
||||
<div class="sketch-toolbar">
|
||||
<button class="sk-tool active" data-tool="pen">✏️</button>
|
||||
<button class="sk-tool" data-tool="arrow">↗</button>
|
||||
<button class="sk-tool" data-tool="rect">▭</button>
|
||||
<button class="sk-tool" data-tool="circle">○</button>
|
||||
<span class="sep"></span>
|
||||
<input type="color" id="sk-color" value="#ff0000">
|
||||
<input type="range" id="sk-width" min="2" max="20" value="5">
|
||||
<span class="sep"></span>
|
||||
<button id="sk-undo">↶</button>
|
||||
<button id="sk-clear">🗑</button>
|
||||
</div>
|
||||
<div class="sketch-body">
|
||||
<canvas id="sk-canvas"></canvas>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(modal);
|
||||
|
||||
const canvas = modal.querySelector('#sk-canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
// Bild laden
|
||||
const img = new Image();
|
||||
img.crossOrigin = 'anonymous';
|
||||
img.src = imageUrl;
|
||||
await new Promise(res => { img.onload = res; });
|
||||
|
||||
// Canvas auf Bildgröße (max 1600px)
|
||||
const maxSide = 1600;
|
||||
const scale = Math.min(1, maxSide / Math.max(img.width, img.height));
|
||||
canvas.width = img.width * scale;
|
||||
canvas.height = img.height * scale;
|
||||
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
|
||||
|
||||
// Canvas-Display: in den Container einpassen
|
||||
function fitCanvasToScreen() {
|
||||
const body = modal.querySelector('.sketch-body');
|
||||
const rect = body.getBoundingClientRect();
|
||||
const s = Math.min(rect.width / canvas.width, rect.height / canvas.height);
|
||||
canvas.style.width = (canvas.width * s) + 'px';
|
||||
canvas.style.height = (canvas.height * s) + 'px';
|
||||
}
|
||||
fitCanvasToScreen();
|
||||
window.addEventListener('resize', fitCanvasToScreen);
|
||||
|
||||
// State
|
||||
let tool = 'pen';
|
||||
let color = '#ff0000';
|
||||
let lineWidth = 5;
|
||||
let drawing = false;
|
||||
let startX = 0, startY = 0;
|
||||
const history = [canvas.toDataURL()];
|
||||
|
||||
function pushHistory() {
|
||||
history.push(canvas.toDataURL());
|
||||
if (history.length > 20) history.shift();
|
||||
}
|
||||
async function restoreSnapshot(dataUrl) {
|
||||
return new Promise(res => {
|
||||
const i = new Image();
|
||||
i.onload = () => { ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.drawImage(i, 0, 0); res(); };
|
||||
i.src = dataUrl;
|
||||
});
|
||||
}
|
||||
|
||||
modal.querySelectorAll('.sk-tool').forEach(b => {
|
||||
b.addEventListener('click', () => {
|
||||
modal.querySelectorAll('.sk-tool').forEach(x => x.classList.remove('active'));
|
||||
b.classList.add('active');
|
||||
tool = b.dataset.tool;
|
||||
});
|
||||
});
|
||||
modal.querySelector('#sk-color').oninput = e => { color = e.target.value; };
|
||||
modal.querySelector('#sk-width').oninput = e => { lineWidth = parseInt(e.target.value, 10); };
|
||||
|
||||
modal.querySelector('#sk-undo').onclick = async () => {
|
||||
if (history.length > 1) {
|
||||
history.pop();
|
||||
await restoreSnapshot(history[history.length - 1]);
|
||||
}
|
||||
};
|
||||
modal.querySelector('#sk-clear').onclick = async () => {
|
||||
await restoreSnapshot(history[0]);
|
||||
history.length = 1;
|
||||
};
|
||||
|
||||
function getPos(e) {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const scaleX = canvas.width / rect.width;
|
||||
const scaleY = canvas.height / rect.height;
|
||||
const t = e.touches ? e.touches[0] : e;
|
||||
return { x: (t.clientX - rect.left) * scaleX, y: (t.clientY - rect.top) * scaleY };
|
||||
}
|
||||
|
||||
let snapshotBeforeShape = null;
|
||||
|
||||
function startDraw(e) {
|
||||
e.preventDefault();
|
||||
drawing = true;
|
||||
const p = getPos(e);
|
||||
startX = p.x; startY = p.y;
|
||||
ctx.strokeStyle = color;
|
||||
ctx.fillStyle = color;
|
||||
ctx.lineWidth = lineWidth;
|
||||
ctx.lineCap = 'round';
|
||||
ctx.lineJoin = 'round';
|
||||
if (tool === 'pen') {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(startX, startY);
|
||||
} else {
|
||||
snapshotBeforeShape = canvas.toDataURL();
|
||||
}
|
||||
}
|
||||
|
||||
function moveDraw(e) {
|
||||
if (!drawing) return;
|
||||
e.preventDefault();
|
||||
const p = getPos(e);
|
||||
if (tool === 'pen') {
|
||||
ctx.lineTo(p.x, p.y);
|
||||
ctx.stroke();
|
||||
} else if (snapshotBeforeShape) {
|
||||
// Shape-Tools: vor jedem Draw den Snapshot wiederherstellen und neu zeichnen
|
||||
restoreSnapshot(snapshotBeforeShape).then(() => {
|
||||
ctx.strokeStyle = color;
|
||||
ctx.fillStyle = color;
|
||||
ctx.lineWidth = lineWidth;
|
||||
ctx.lineCap = 'round';
|
||||
ctx.lineJoin = 'round';
|
||||
if (tool === 'rect') {
|
||||
ctx.strokeRect(startX, startY, p.x - startX, p.y - startY);
|
||||
} else if (tool === 'circle') {
|
||||
const rx = Math.abs(p.x - startX) / 2;
|
||||
const ry = Math.abs(p.y - startY) / 2;
|
||||
const cx = (startX + p.x) / 2;
|
||||
const cy = (startY + p.y) / 2;
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(cx, cy, rx, ry, 0, 0, 2 * Math.PI);
|
||||
ctx.stroke();
|
||||
} else if (tool === 'arrow') {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(startX, startY);
|
||||
ctx.lineTo(p.x, p.y);
|
||||
ctx.stroke();
|
||||
// Pfeilspitze
|
||||
const angle = Math.atan2(p.y - startY, p.x - startX);
|
||||
const head = Math.max(12, lineWidth * 3);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(p.x, p.y);
|
||||
ctx.lineTo(p.x - head * Math.cos(angle - Math.PI / 6), p.y - head * Math.sin(angle - Math.PI / 6));
|
||||
ctx.lineTo(p.x - head * Math.cos(angle + Math.PI / 6), p.y - head * Math.sin(angle + Math.PI / 6));
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function endDraw() {
|
||||
if (!drawing) return;
|
||||
drawing = false;
|
||||
snapshotBeforeShape = null;
|
||||
pushHistory();
|
||||
}
|
||||
|
||||
canvas.addEventListener('mousedown', startDraw);
|
||||
canvas.addEventListener('mousemove', moveDraw);
|
||||
canvas.addEventListener('mouseup', endDraw);
|
||||
canvas.addEventListener('mouseleave', endDraw);
|
||||
canvas.addEventListener('touchstart', startDraw, { passive: false });
|
||||
canvas.addEventListener('touchmove', moveDraw, { passive: false });
|
||||
canvas.addEventListener('touchend', endDraw);
|
||||
|
||||
modal.querySelector('#sk-close').onclick = () => {
|
||||
window.removeEventListener('resize', fitCanvasToScreen);
|
||||
modal.remove();
|
||||
};
|
||||
|
||||
modal.querySelector('#sk-save').onclick = async () => {
|
||||
showToast('Speichere Skizze…');
|
||||
canvas.toBlob(async (blob) => {
|
||||
try {
|
||||
await api.uploadAnnotatedPhoto(orderId, blob, 'sketch_' + Date.now() + '.jpg');
|
||||
showToast('✓ Skizze gespeichert');
|
||||
modal.remove();
|
||||
router.navigate();
|
||||
} catch (e) {
|
||||
showToast('Upload fehlgeschlagen: ' + e.message, 'error');
|
||||
}
|
||||
}, 'image/jpeg', 0.9);
|
||||
};
|
||||
}
|
||||
|
|
|
|||
30
lib/api.js
30
lib/api.js
|
|
@ -84,6 +84,32 @@
|
|||
return request('/reports.php?id=' + id);
|
||||
}
|
||||
|
||||
async function listReports() {
|
||||
return request('/reports.php');
|
||||
}
|
||||
|
||||
async function finalizeReport(id) {
|
||||
return request('/reports.php?id=' + id + '&action=finalize', { method: 'POST' });
|
||||
}
|
||||
|
||||
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 uploadAnnotatedPhoto(orderId, fileBlob, filename) {
|
||||
// Wie uploadOrderPhoto — Skizze ist schon ins Bild eingebrannt
|
||||
return uploadOrderPhoto(orderId, fileBlob, filename);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lädt eine Bild-Datei von der API als Blob-URL (inkl. JWT).
|
||||
* Wird benötigt weil <img src> keine Authorization-Header mitschickt.
|
||||
|
|
@ -125,7 +151,9 @@
|
|||
window.api = {
|
||||
getToken, setToken, clearToken,
|
||||
login, logout,
|
||||
listOrders, getOrder, listOrderPhotos, uploadOrderPhoto, getReport,
|
||||
listOrders, getOrder, listOrderPhotos, uploadOrderPhoto,
|
||||
getReport, listReports, finalizeReport,
|
||||
deletePhoto, uploadVoiceNote, uploadAnnotatedPhoto,
|
||||
getPhotoBlobUrl, clearPhotoCache,
|
||||
};
|
||||
})();
|
||||
|
|
|
|||
2
sw.js
2
sw.js
|
|
@ -4,7 +4,7 @@
|
|||
* - API-Calls: network-first, kein offline-cache (da auth-pflichtig)
|
||||
*/
|
||||
|
||||
const CACHE = 'baustelle-v1';
|
||||
const CACHE = 'baustelle-v2';
|
||||
const SHELL = [
|
||||
'./',
|
||||
'./index.html',
|
||||
|
|
|
|||
Loading…
Reference in a new issue