[deploy] Schnell-Auftrag-Erfassen via FAB + Login-Persistenz-Fix
All checks were successful
Deploy baustelle-pwa / deploy (push) Successful in 5s
All checks were successful
Deploy baustelle-pwa / deploy (push) Successful in 5s
- FAB unten rechts (halbtransparent) öffnet Fullscreen-Modal zur Auftrags-Anlage: Kundensuche mit Debounce und "Zuletzt verwendet"- Quick-Pick aus IndexedDB → Anzeige der übernommenen Defaults → Titel + optional Kunden-Ref → direkter Sprung auf #/orders/<id> wo die Fotos aufgenommen werden können. - appBoot() preloadet das JWT aktiv aus IndexedDB bevor der Router läuft — fixt "nach App-Neustart immer wieder einloggen müssen". - setNav() steuert zusätzlich die FAB-Sichtbarkeit mit. - api.createOrder() als neuer Wrapper für POST /orders.php?action=create. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
cb8dc396b3
commit
d4e4d09366
4 changed files with 272 additions and 1 deletions
65
app.css
65
app.css
|
|
@ -72,6 +72,71 @@ body {
|
|||
}
|
||||
#bottom-nav button.active { color: #7aa2f7; }
|
||||
|
||||
/* ----- FAB (Neuer Auftrag) ----- */
|
||||
.fab {
|
||||
position: fixed;
|
||||
right: 18px;
|
||||
bottom: calc(68px + env(safe-area-inset-bottom, 0px));
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
font-size: 30px;
|
||||
line-height: 1;
|
||||
font-weight: 300;
|
||||
background: rgba(122, 162, 247, 0.85);
|
||||
color: #fff;
|
||||
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.45);
|
||||
backdrop-filter: blur(6px);
|
||||
-webkit-backdrop-filter: blur(6px);
|
||||
z-index: 50;
|
||||
cursor: pointer;
|
||||
transition: transform 0.1s ease, background 0.15s ease;
|
||||
}
|
||||
.fab:active { transform: scale(0.92); background: rgba(122, 162, 247, 1); }
|
||||
.fab[hidden] { display: none; }
|
||||
|
||||
/* ----- Neuer Auftrag Modal (nutzt .fullscreen-modal) ----- */
|
||||
.new-order-customer-row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
background: #2a2a30;
|
||||
border: 1px solid #3a3a42;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
.new-order-customer-row .nc-name { font-weight: 600; flex: 1; }
|
||||
.new-order-customer-row .nc-meta { color: #888; font-size: 12px; }
|
||||
.new-order-customer-row .nc-change {
|
||||
background: transparent; border: 1px solid #555;
|
||||
color: #7aa2f7; border-radius: 6px; padding: 4px 10px; cursor: pointer;
|
||||
}
|
||||
.new-order-defaults {
|
||||
font-size: 12px; color: #aaa;
|
||||
padding: 6px 12px; margin-bottom: 10px;
|
||||
border-left: 2px solid #7aa2f7;
|
||||
}
|
||||
.new-order-customer-search {
|
||||
display: block; width: 100%;
|
||||
padding: 12px; border-radius: 8px;
|
||||
border: 1px solid #444; background: #2a2a30; color: #eee;
|
||||
font-size: 15px; margin-bottom: 8px;
|
||||
}
|
||||
.new-order-customer-list {
|
||||
max-height: 45vh; overflow-y: auto;
|
||||
border: 1px solid #333; border-radius: 8px; background: #222228;
|
||||
}
|
||||
.new-order-customer-list .nc-item {
|
||||
padding: 12px; border-bottom: 1px solid #2a2a30; cursor: pointer;
|
||||
}
|
||||
.new-order-customer-list .nc-item:last-child { border-bottom: none; }
|
||||
.new-order-customer-list .nc-item:active { background: #2e2e35; }
|
||||
.new-order-customer-list .nc-item .nc-sub { color: #888; font-size: 12px; }
|
||||
.new-order-customer-list .nc-item.recent { background: rgba(122,162,247,0.06); }
|
||||
.new-order-empty { padding: 16px; color: #888; text-align: center; }
|
||||
|
||||
/* ----- Login ----- */
|
||||
.login-form {
|
||||
max-width: 360px;
|
||||
|
|
|
|||
197
app.js
197
app.js
|
|
@ -21,6 +21,9 @@ function setNav(visible, active) {
|
|||
nav.querySelectorAll('button').forEach(b => {
|
||||
b.classList.toggle('active', b.dataset.route === active);
|
||||
});
|
||||
// FAB folgt der Nav-Sichtbarkeit (auf Login verstecken, sonst überall erreichbar)
|
||||
const fab = document.getElementById('fab-new-order');
|
||||
if (fab) fab.hidden = !visible;
|
||||
}
|
||||
|
||||
function setBack(visible, hash) {
|
||||
|
|
@ -173,6 +176,23 @@ async function promptNewPin() {
|
|||
* Startet die App — fragt ggf. PIN ab bevor router läuft.
|
||||
*/
|
||||
window.appBoot = async function appBoot() {
|
||||
// JWT aktiv aus IndexedDB preloaden, bevor eine Route rennt.
|
||||
// Verhindert Race-Conditions, in denen ensureAuth() zu früh ein `null` bekommt
|
||||
// und nach Login-Screen redirectet, obwohl ein gültiges Token in IDB liegt.
|
||||
try {
|
||||
const t = await api.getToken();
|
||||
console.log('[boot] jwt vorhanden:', !!t);
|
||||
} catch (e) {
|
||||
console.warn('[boot] Token-Preload fehlgeschlagen', e);
|
||||
}
|
||||
|
||||
// FAB: Click öffnet das Schnell-Auftrag-Modal
|
||||
const fab = document.getElementById('fab-new-order');
|
||||
if (fab && !fab.dataset.bound) {
|
||||
fab.dataset.bound = '1';
|
||||
fab.addEventListener('click', () => { openNewOrderModal(); });
|
||||
}
|
||||
|
||||
const pinSet = await idb.get('pin_hash');
|
||||
if (!pinSet) return; // kein PIN-Schutz aktiv
|
||||
// Lockscreen: vollständiger Overlay bis richtige PIN
|
||||
|
|
@ -1241,6 +1261,183 @@ async function openNewReportModal(orderId) {
|
|||
};
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* NEUER AUFTRAG MODAL (Schnell-Erfassung vom FAB)
|
||||
* ============================================================ */
|
||||
async function openNewOrderModal() {
|
||||
if (!(await ensureAuth())) return;
|
||||
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'fullscreen-modal';
|
||||
modal.innerHTML = `
|
||||
<div class="fs-header">
|
||||
<button class="icon-btn" id="no-close" aria-label="Schliessen">✕</button>
|
||||
<div class="fs-title">➕ Neuer Auftrag</div>
|
||||
<button class="icon-btn" id="no-save" title="Anlegen & Fotos machen" disabled>✓</button>
|
||||
</div>
|
||||
<div class="fs-body" style="flex-direction:column;gap:10px;padding:16px;align-items:stretch;">
|
||||
<div id="no-step-customer">
|
||||
<label class="label">Kunde wählen</label>
|
||||
<input type="search" id="no-customer-search" class="new-order-customer-search" placeholder="🔍 Name, Ort…" autocomplete="off">
|
||||
<div id="no-customer-list" class="new-order-customer-list"></div>
|
||||
</div>
|
||||
|
||||
<div id="no-step-details" hidden>
|
||||
<div class="new-order-customer-row">
|
||||
<div style="flex:1">
|
||||
<div class="nc-name" id="no-soc-name"></div>
|
||||
<div class="nc-meta" id="no-soc-meta"></div>
|
||||
</div>
|
||||
<button class="nc-change" id="no-change-customer">Ändern</button>
|
||||
</div>
|
||||
<div class="new-order-defaults" id="no-defaults"></div>
|
||||
|
||||
<label class="label">Titel / Kurzbeschreibung</label>
|
||||
<input type="text" id="no-title" placeholder="z. B. Wallbox-Installation" style="padding:12px;background:#2a2a30;color:#fff;border:1px solid #444;border-radius:6px;">
|
||||
|
||||
<label class="label">Kunden-Referenz (optional)</label>
|
||||
<input type="text" id="no-refclient" placeholder="z. B. Angebot Nr. 123" style="padding:12px;background:#2a2a30;color:#fff;border:1px solid #444;border-radius:6px;">
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(modal);
|
||||
|
||||
const closeBtn = modal.querySelector('#no-close');
|
||||
const saveBtn = modal.querySelector('#no-save');
|
||||
const searchIn = modal.querySelector('#no-customer-search');
|
||||
const listEl = modal.querySelector('#no-customer-list');
|
||||
const stepCust = modal.querySelector('#no-step-customer');
|
||||
const stepDet = modal.querySelector('#no-step-details');
|
||||
const socName = modal.querySelector('#no-soc-name');
|
||||
const socMeta = modal.querySelector('#no-soc-meta');
|
||||
const defaultsEl = modal.querySelector('#no-defaults');
|
||||
const titleIn = modal.querySelector('#no-title');
|
||||
const refIn = modal.querySelector('#no-refclient');
|
||||
const changeBtn = modal.querySelector('#no-change-customer');
|
||||
|
||||
let selectedCustomer = null;
|
||||
|
||||
closeBtn.onclick = () => modal.remove();
|
||||
|
||||
/* ---- Letzte Kunden als Quick-Pick ---- */
|
||||
async function renderRecent() {
|
||||
const recent = (await idb.get('recent_customers')) || [];
|
||||
if (!recent.length) {
|
||||
listEl.innerHTML = '<div class="new-order-empty">Tippe zum Suchen…</div>';
|
||||
return;
|
||||
}
|
||||
listEl.innerHTML = '<div style="padding:8px 12px;color:#888;font-size:12px;">Zuletzt verwendet</div>' +
|
||||
recent.map(c => `
|
||||
<div class="nc-item recent" data-id="${c.id}">
|
||||
<div>${escapeHtml(c.name)}</div>
|
||||
<div class="nc-sub">${escapeHtml((c.zip || '') + ' ' + (c.town || ''))}</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
await renderRecent();
|
||||
|
||||
/* ---- Suche mit Debounce ---- */
|
||||
let searchTimer = null;
|
||||
searchIn.addEventListener('input', () => {
|
||||
clearTimeout(searchTimer);
|
||||
const q = searchIn.value.trim();
|
||||
if (!q) { renderRecent(); return; }
|
||||
searchTimer = setTimeout(async () => {
|
||||
try {
|
||||
const data = await api.listCustomers({ q });
|
||||
const items = (data.customers || []).slice(0, 30);
|
||||
if (!items.length) {
|
||||
listEl.innerHTML = '<div class="new-order-empty">Keine Treffer</div>';
|
||||
return;
|
||||
}
|
||||
listEl.innerHTML = items.map(c => `
|
||||
<div class="nc-item" data-id="${c.id}">
|
||||
<div>${escapeHtml(c.name)}</div>
|
||||
<div class="nc-sub">${escapeHtml((c.zip || '') + ' ' + (c.town || ''))}</div>
|
||||
</div>
|
||||
`).join('');
|
||||
} catch (e) {
|
||||
listEl.innerHTML = '<div class="new-order-empty">Fehler: ' + escapeHtml(e.message) + '</div>';
|
||||
}
|
||||
}, 300);
|
||||
});
|
||||
|
||||
/* ---- Kunden-Klick: Details laden, Wechsel zum zweiten Schritt ---- */
|
||||
listEl.addEventListener('click', async (e) => {
|
||||
const item = e.target.closest('.nc-item');
|
||||
if (!item) return;
|
||||
const id = parseInt(item.dataset.id, 10);
|
||||
if (!id) return;
|
||||
|
||||
showToast('Lade Kundendaten…');
|
||||
try {
|
||||
const res = await api.getCustomer(id);
|
||||
const c = res.customer || res;
|
||||
selectedCustomer = c;
|
||||
|
||||
socName.textContent = c.name || '';
|
||||
socMeta.textContent = [c.address, ((c.zip || '') + ' ' + (c.town || '')).trim()].filter(Boolean).join(' · ');
|
||||
|
||||
const defs = [];
|
||||
if (c.cond_reglement_label) defs.push('💳 ' + c.cond_reglement_label);
|
||||
if (c.mode_reglement_label) defs.push('🏦 ' + c.mode_reglement_label);
|
||||
if (!defs.length) defs.push('Keine speziellen Defaults hinterlegt');
|
||||
defaultsEl.innerHTML = 'Übernommen: ' + escapeHtml(defs.join(' · '));
|
||||
|
||||
stepCust.hidden = true;
|
||||
stepDet.hidden = false;
|
||||
saveBtn.disabled = false;
|
||||
titleIn.focus();
|
||||
} catch (err) {
|
||||
showToast('Fehler: ' + err.message, 'error');
|
||||
}
|
||||
});
|
||||
|
||||
changeBtn.onclick = () => {
|
||||
selectedCustomer = null;
|
||||
stepDet.hidden = true;
|
||||
stepCust.hidden = false;
|
||||
saveBtn.disabled = true;
|
||||
searchIn.focus();
|
||||
};
|
||||
|
||||
/* ---- Anlegen ---- */
|
||||
saveBtn.onclick = async () => {
|
||||
if (!selectedCustomer) { showToast('Bitte zuerst Kunde wählen', 'error'); return; }
|
||||
const title = titleIn.value.trim();
|
||||
if (!title) { showToast('Titel ist Pflicht', 'error'); titleIn.focus(); return; }
|
||||
|
||||
saveBtn.disabled = true;
|
||||
showToast('Lege Auftrag an…');
|
||||
try {
|
||||
const res = await api.createOrder({
|
||||
socid: selectedCustomer.id,
|
||||
title: title,
|
||||
ref_client: refIn.value.trim(),
|
||||
});
|
||||
// Recent-Liste in IDB aktualisieren (letzte 5)
|
||||
try {
|
||||
const recent = (await idb.get('recent_customers')) || [];
|
||||
const filtered = recent.filter(c => c.id !== selectedCustomer.id);
|
||||
filtered.unshift({
|
||||
id: selectedCustomer.id,
|
||||
name: selectedCustomer.name,
|
||||
zip: selectedCustomer.zip,
|
||||
town: selectedCustomer.town,
|
||||
});
|
||||
await idb.set('recent_customers', filtered.slice(0, 5));
|
||||
} catch (e) {}
|
||||
|
||||
showToast('✓ Auftrag ' + (res.order.ref || '') + ' angelegt');
|
||||
modal.remove();
|
||||
router.go('#/orders/' + res.order.id);
|
||||
} catch (e) {
|
||||
showToast('Fehler: ' + e.message, 'error');
|
||||
saveBtn.disabled = false;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* SEITEN-AKTIONEN MODAL (Notiz, Löschen, Vollbild)
|
||||
* ============================================================ */
|
||||
|
|
|
|||
|
|
@ -49,6 +49,8 @@ header('Expires: 0');
|
|||
|
||||
<main id="main"></main>
|
||||
|
||||
<button id="fab-new-order" class="fab" title="Neuer Auftrag" aria-label="Neuer Auftrag" hidden>+</button>
|
||||
|
||||
<nav id="bottom-nav" style="display:none">
|
||||
<button data-route="today">☀️ Heute</button>
|
||||
<button data-route="orders" class="active">📋 Aufträge</button>
|
||||
|
|
|
|||
|
|
@ -91,6 +91,13 @@
|
|||
});
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
|
@ -245,7 +252,7 @@
|
|||
request,
|
||||
getToken, setToken, clearToken,
|
||||
login, logout,
|
||||
listOrders, getOrder, listOrderPhotos, uploadOrderPhoto,
|
||||
listOrders, getOrder, listOrderPhotos, uploadOrderPhoto, createOrder,
|
||||
listCustomers, getCustomer,
|
||||
getReport, listReports, createReport, listTemplates, listOdtTemplates, finalizeReport, deleteReport,
|
||||
deletePhoto, uploadVoiceNote, transcribeAudio, uploadAnnotatedPhoto,
|
||||
|
|
|
|||
Loading…
Reference in a new issue