436 lines
16 KiB
JavaScript
436 lines
16 KiB
JavaScript
// pair-client.js — loaded before app.js. Defines window.api for the web client
|
|
// (talking to main/webserver.js over the local network instead of Electron IPC),
|
|
// handles the one-time pairing screen, and only injects app.js once a valid
|
|
// session is confirmed — app.js calls window.api immediately on load, so it must
|
|
// never run before window.api (and a working session) exist.
|
|
//
|
|
// Phase 3: offline. Reads fall back to the local cache (offline-store.js) when the
|
|
// practice PC isn't reachable; writes to Notizen/Befunde queue locally and apply
|
|
// optimistically to the cache, then replay automatically once reachable again. This
|
|
// only works for a tab that was already open (and the animal already viewed) while
|
|
// still on the practice WiFi — a from-scratch cold load with zero network needs a
|
|
// Service Worker, which needs HTTPS, which this plain-WLAN setup deliberately skips.
|
|
"use strict";
|
|
|
|
(function () {
|
|
const TOKEN_KEY = "fellakte_token";
|
|
let token = localStorage.getItem(TOKEN_KEY);
|
|
let isOnline = true;
|
|
let syncing = false;
|
|
|
|
const overlay = document.getElementById("pairing-overlay");
|
|
const form = document.getElementById("pair-form");
|
|
const codeInput = document.getElementById("pair-code-input");
|
|
const status = document.getElementById("pair-status");
|
|
const toast = document.getElementById("readonly-toast");
|
|
|
|
function showPairing(prefillCode) {
|
|
overlay.hidden = false;
|
|
if (prefillCode) codeInput.value = prefillCode;
|
|
codeInput.focus();
|
|
}
|
|
function hidePairing() {
|
|
overlay.hidden = true;
|
|
}
|
|
|
|
function authHeaders() {
|
|
return token ? { Authorization: "Bearer " + token } : {};
|
|
}
|
|
|
|
let toastTimer = null;
|
|
function showToast(text) {
|
|
if (!toast) return;
|
|
toast.textContent = text;
|
|
toast.hidden = false;
|
|
clearTimeout(toastTimer);
|
|
toastTimer = setTimeout(() => (toast.hidden = true), 3200);
|
|
}
|
|
function readOnlyNotice() {
|
|
showToast("Bearbeiten kommt in einer späteren Phase — aktuell nur zum Ansehen.");
|
|
}
|
|
function offlineUnavailableNotice() {
|
|
showToast("Ohne Verbindung aktuell nicht möglich.");
|
|
}
|
|
|
|
function showWarningBanner(info) {
|
|
const banner = document.getElementById("warning-banner");
|
|
const text = document.getElementById("warning-banner-text");
|
|
if (!banner || !text) return;
|
|
const when = new Date(info.updatedAt).toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" });
|
|
text.textContent = `Achtung: "${info.host}" wurde zuletzt um ${when} Uhr ebenfalls verwendet — kurz absprechen, bevor beide gleichzeitig speichern.`;
|
|
banner.hidden = false;
|
|
}
|
|
|
|
async function renderSyncStatus() {
|
|
const chip = document.getElementById("sync-status");
|
|
if (!chip) return;
|
|
const pending = await window.offlineStore.queueCount();
|
|
if (isOnline && pending === 0) {
|
|
chip.textContent = "Verbunden";
|
|
chip.style.background = "var(--ok-tint)";
|
|
chip.style.color = "var(--ok)";
|
|
} else if (isOnline && pending > 0) {
|
|
chip.textContent = `Synchronisiere … (${pending})`;
|
|
chip.style.background = "var(--warn-tint)";
|
|
chip.style.color = "var(--warn)";
|
|
} else if (pending > 0) {
|
|
chip.textContent = `Offline — ${pending} wartend`;
|
|
chip.style.background = "var(--warn-tint)";
|
|
chip.style.color = "var(--warn)";
|
|
} else {
|
|
chip.textContent = "Offline";
|
|
chip.style.background = "var(--surface-alt)";
|
|
chip.style.color = "var(--ink-faint)";
|
|
}
|
|
}
|
|
function setOnline(v) {
|
|
const changed = v !== isOnline;
|
|
isOnline = v;
|
|
renderSyncStatus();
|
|
if (changed && v) trySync();
|
|
}
|
|
|
|
// ---- reads: network first, cache fallback ----
|
|
async function apiGet(path, cache) {
|
|
try {
|
|
const res = await fetch(path, { headers: authHeaders() });
|
|
if (res.status === 401) {
|
|
token = null;
|
|
localStorage.removeItem(TOKEN_KEY);
|
|
showPairing();
|
|
throw new Error("unauthorized");
|
|
}
|
|
if (!res.ok) throw new Error("request-failed:" + res.status);
|
|
const data = await res.json();
|
|
setOnline(true);
|
|
if (cache && cache.kind === "directory") window.offlineStore.saveDirectory(data);
|
|
if (cache && cache.kind === "bundle") window.offlineStore.saveBundle(data);
|
|
return data;
|
|
} catch (err) {
|
|
if (err.message === "unauthorized") throw err;
|
|
setOnline(false);
|
|
if (cache && cache.kind === "directory") {
|
|
const cached = await window.offlineStore.getDirectory();
|
|
if (cached) return cached;
|
|
}
|
|
if (cache && cache.kind === "bundle") {
|
|
const cached = await window.offlineStore.getBundle(cache.key);
|
|
if (cached) return cached;
|
|
}
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
// ---- writes: network first; on failure, queue + apply optimistically to the
|
|
// cache when `offlineSpec` says this kind of write supports that (Notizen,
|
|
// Befunde). Surfaces the server's "someone else is using this data folder right
|
|
// now" advisory as a dismissible banner (same idea as the desktop app's startup
|
|
// dialog) — advisory only, matching how the two practice PCs already behave.
|
|
async function apiSend(method, path, body, offlineSpec) {
|
|
try {
|
|
const res = await fetch(path, {
|
|
method,
|
|
headers: { ...authHeaders(), "Content-Type": "application/json" },
|
|
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
});
|
|
if (res.status === 401) {
|
|
token = null;
|
|
localStorage.removeItem(TOKEN_KEY);
|
|
showPairing();
|
|
throw new Error("unauthorized");
|
|
}
|
|
if (!res.ok) throw new Error("request-failed:" + res.status);
|
|
const data = await res.json().catch(() => ({}));
|
|
setOnline(true);
|
|
if (data && data.warning) showWarningBanner(data.warning);
|
|
return data;
|
|
} catch (err) {
|
|
if (err.message === "unauthorized") throw err;
|
|
setOnline(false);
|
|
if (!offlineSpec) {
|
|
offlineUnavailableNotice();
|
|
throw err;
|
|
}
|
|
await window.offlineStore.enqueue({ method, path, body });
|
|
await applyOptimistic(offlineSpec, body);
|
|
renderSyncStatus();
|
|
return { ok: true, offline: true };
|
|
}
|
|
}
|
|
|
|
async function applyOptimistic(spec, body) {
|
|
const nowIso = new Date().toISOString();
|
|
const localId = () => "lokal-" + Date.now() + "-" + Math.random().toString(36).slice(2, 7);
|
|
|
|
if (spec.kind === "note") {
|
|
const bundle = await window.offlineStore.getBundle(body.animalId);
|
|
if (!bundle) return;
|
|
bundle.notes.unshift({
|
|
id: localId(),
|
|
animal_id: body.animalId,
|
|
date: new Date().toLocaleDateString("de-DE"),
|
|
text: body.text,
|
|
created_at: nowIso,
|
|
});
|
|
await window.offlineStore.saveBundle(bundle);
|
|
} else if (spec.kind === "finding-add") {
|
|
const bundle = await window.offlineStore.getBundle(body.animalId);
|
|
if (!bundle) return;
|
|
bundle.findings.push({
|
|
id: localId(),
|
|
animal_id: body.animalId,
|
|
x: body.x,
|
|
y: body.y,
|
|
severity: body.severity,
|
|
text: body.text,
|
|
created_at: nowIso,
|
|
});
|
|
await window.offlineStore.saveBundle(bundle);
|
|
} else if (spec.kind === "finding-update") {
|
|
const animalId = await window.offlineStore.findAnimalIdForFinding(spec.findingId);
|
|
if (!animalId) return;
|
|
const bundle = await window.offlineStore.getBundle(animalId);
|
|
const f = bundle.findings.find((x) => x.id === spec.findingId);
|
|
if (f) {
|
|
f.severity = body.severity;
|
|
f.text = body.text;
|
|
await window.offlineStore.saveBundle(bundle);
|
|
}
|
|
} else if (spec.kind === "finding-delete") {
|
|
const animalId = await window.offlineStore.findAnimalIdForFinding(spec.findingId);
|
|
if (!animalId) return;
|
|
const bundle = await window.offlineStore.getBundle(animalId);
|
|
bundle.findings = bundle.findings.filter((x) => x.id !== spec.findingId);
|
|
await window.offlineStore.saveBundle(bundle);
|
|
}
|
|
}
|
|
|
|
// Replays the queue in order against the real server. Stops (leaves the rest
|
|
// queued) at the first failure — still offline, or the practice PC went away
|
|
// mid-sync. On a full, non-empty drain, reloads so the whole page re-reads fresh,
|
|
// server-confirmed data instead of the optimistic local copies.
|
|
async function trySync() {
|
|
if (syncing) return;
|
|
syncing = true;
|
|
try {
|
|
const queue = await window.offlineStore.listQueue();
|
|
if (!queue.length) {
|
|
setOnline(await pingServer());
|
|
return;
|
|
}
|
|
let drained = 0;
|
|
for (const op of queue) {
|
|
try {
|
|
const res = await fetch(op.path, {
|
|
method: op.method,
|
|
headers: { ...authHeaders(), "Content-Type": "application/json" },
|
|
body: op.body !== undefined ? JSON.stringify(op.body) : undefined,
|
|
});
|
|
if (res.status === 401) {
|
|
token = null;
|
|
localStorage.removeItem(TOKEN_KEY);
|
|
showPairing();
|
|
return;
|
|
}
|
|
if (!res.ok) throw new Error("sync-failed:" + res.status);
|
|
await window.offlineStore.removeFromQueue(op.id);
|
|
drained++;
|
|
} catch {
|
|
setOnline(false);
|
|
return; // still offline (or PC unreachable) — rest stays queued for next try
|
|
}
|
|
}
|
|
setOnline(true);
|
|
if (drained > 0) location.reload();
|
|
} finally {
|
|
syncing = false;
|
|
}
|
|
}
|
|
|
|
async function pingServer() {
|
|
try {
|
|
await fetch("/api/directory", { headers: authHeaders() });
|
|
return true; // any HTTP response at all means the practice PC is reachable
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// Resolves with `fallback` after showing the read-only notice — safe for callers
|
|
// that just re-render afterward (await refreshBundle(); renderPanels();).
|
|
function stub(fallback) {
|
|
return async () => {
|
|
readOnlyNotice();
|
|
return fallback;
|
|
};
|
|
}
|
|
// Rejects instead — used only where the caller immediately depends on the result
|
|
// (e.g. `const id = await createAnimal(...); await selectAnimal(id)`), so a
|
|
// silently-wrong id can't cascade into a broken render.
|
|
function rejectingStub() {
|
|
return async () => {
|
|
readOnlyNotice();
|
|
throw new Error("read-only");
|
|
};
|
|
}
|
|
|
|
window.api = {
|
|
listDirectory: () => apiGet("/api/directory", { kind: "directory" }),
|
|
getAnimalBundle: (id) => apiGet("/api/animal/" + encodeURIComponent(id), { kind: "bundle", key: id }),
|
|
listAppointments: (fromDate, toDate) =>
|
|
apiGet(`/api/appointments?from=${encodeURIComponent(fromDate || "")}&to=${encodeURIComponent(toDate || "")}`),
|
|
listRemedies: (query) => apiGet("/api/remedies?q=" + encodeURIComponent(query || "")),
|
|
searchNotes: (query) => apiGet("/api/search-notes?q=" + encodeURIComponent(query || "")),
|
|
openDocument: async (documentId) => {
|
|
const { url } = await apiGet("/api/document-url/" + encodeURIComponent(documentId));
|
|
window.open(url, "_blank");
|
|
return true;
|
|
},
|
|
documentAbsPath: stub(null),
|
|
dataFolder: stub(null),
|
|
|
|
createOwner: rejectingStub(),
|
|
createAnimal: rejectingStub(),
|
|
updateAnimalField: stub(true),
|
|
updateOwnerField: stub(true),
|
|
deleteAnimal: stub(true),
|
|
deleteOwner: stub(true),
|
|
|
|
addNote: (animalId, text) => apiSend("POST", "/api/note", { animalId, text }, { kind: "note" }),
|
|
addHistoryEntry: stub(true),
|
|
|
|
addLabResult: stub(null),
|
|
addLabValue: stub(true),
|
|
deleteLabResult: stub(true),
|
|
|
|
addFinding: (animalId, finding) => apiSend("POST", "/api/finding", { animalId, ...finding }, { kind: "finding-add" }),
|
|
updateFinding: (id, patch) =>
|
|
apiSend("PATCH", "/api/finding/" + encodeURIComponent(id), patch, { kind: "finding-update", findingId: id }),
|
|
deleteFinding: (id) =>
|
|
apiSend("DELETE", "/api/finding/" + encodeURIComponent(id), undefined, { kind: "finding-delete", findingId: id }),
|
|
|
|
addExam: stub(null),
|
|
|
|
// Phase 4: needs a live connection to the practice PC (PDF/E-Mail are server-side
|
|
// operations — see the file header) — not queued offline like Notizen/Befunde.
|
|
setConsent: (animalId, patch) => apiSend("POST", "/api/consent", { animalId, ...patch }),
|
|
saveSignature: (animalId, dataUrl) => apiSend("POST", "/api/consent/signature", { animalId, dataUrl }),
|
|
setContract: (animalId, patch) => apiSend("POST", "/api/contract", { animalId, ...patch }),
|
|
saveContractSignature: (animalId, dataUrl) => apiSend("POST", "/api/contract/signature", { animalId, dataUrl }),
|
|
|
|
generatePdf: (animalId, kind, content) => apiSend("POST", "/api/document/pdf", { animalId, kind, ...content }),
|
|
generateTemplatePdf: (animalId, filenamePrefix, content) =>
|
|
apiSend("POST", "/api/document/template-pdf", { animalId, filenamePrefix, ...content }),
|
|
sendDocumentEmail: (animalId, kind, file) => apiSend("POST", "/api/document/email", { animalId, kind, ...file }),
|
|
|
|
addRemedy: stub(null),
|
|
updateRemedy: stub(true),
|
|
deleteRemedy: stub(true),
|
|
|
|
// Termine: not yet queued for offline (Phase 3 scope is Notizen/Befunde) — a
|
|
// clear "not available offline" notice instead of queueing silently.
|
|
addAppointment: (data) => apiSend("POST", "/api/appointment", data),
|
|
updateAppointment: (id, data) => apiSend("PATCH", "/api/appointment/" + encodeURIComponent(id), data),
|
|
deleteAppointment: (id) => apiSend("DELETE", "/api/appointment/" + encodeURIComponent(id)),
|
|
|
|
getLexwarePath: stub(null),
|
|
setLexwarePath: stub(true),
|
|
openLexware: stub({ ok: false, error: "not-available" }),
|
|
|
|
getSmtpSettings: stub(null),
|
|
setSmtpSettings: stub(true),
|
|
testSmtpSettings: stub({ ok: false, error: "not-available" }),
|
|
|
|
getReminderLead: stub(30),
|
|
setReminderLead: stub(true),
|
|
|
|
backupNow: stub({ ok: false, error: "not-available" }),
|
|
getBackupInfo: stub({ count: 0, last: null }),
|
|
listBackups: stub([]),
|
|
openBackupFolder: stub(true),
|
|
restoreBackup: stub({ ok: false, error: "not-available" }),
|
|
|
|
addDocument: stub([]),
|
|
};
|
|
|
|
const warningClose = document.getElementById("warning-banner-close");
|
|
if (warningClose) warningClose.addEventListener("click", () => (document.getElementById("warning-banner").hidden = true));
|
|
|
|
function loadApp() {
|
|
const s = document.createElement("script");
|
|
s.src = "app.js";
|
|
document.body.appendChild(s);
|
|
}
|
|
|
|
// Distinguishes "token is genuinely invalid" (401 → must re-pair) from "can't
|
|
// reach the practice PC right now" (network error → proceed in offline mode with
|
|
// whatever's cached, a valid token just can't be re-confirmed this moment).
|
|
async function checkSession() {
|
|
try {
|
|
const res = await fetch("/api/directory", { headers: authHeaders() });
|
|
if (res.status === 401) return "invalid";
|
|
return "online";
|
|
} catch {
|
|
return "offline";
|
|
}
|
|
}
|
|
|
|
form.addEventListener("submit", async (e) => {
|
|
e.preventDefault();
|
|
const code = codeInput.value.trim();
|
|
if (!code) return;
|
|
status.textContent = "Verbinde …";
|
|
try {
|
|
const res = await fetch("/api/pair", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ code, deviceName: guessDeviceName() }),
|
|
});
|
|
if (!res.ok) {
|
|
status.textContent = res.status === 410 || res.status === 429
|
|
? "Code abgelaufen. Am PC einen neuen Code erzeugen."
|
|
: "Code falsch — bitte nochmal prüfen.";
|
|
return;
|
|
}
|
|
const data = await res.json();
|
|
token = data.token;
|
|
localStorage.setItem(TOKEN_KEY, token);
|
|
location.reload();
|
|
} catch {
|
|
status.textContent = "Keine Verbindung zum Praxis-PC — gleiches WLAN?";
|
|
}
|
|
});
|
|
|
|
function guessDeviceName() {
|
|
const ua = navigator.userAgent || "";
|
|
if (/iPad/.test(ua)) return "iPad";
|
|
if (/iPhone/.test(ua)) return "iPhone";
|
|
if (/Android/.test(ua)) return "Android-Gerät";
|
|
if (/Macintosh/.test(ua)) return "Mac";
|
|
return "Gerät";
|
|
}
|
|
|
|
(async function init() {
|
|
const params = new URLSearchParams(location.search);
|
|
const prefill = params.get("pair");
|
|
|
|
if (!token) {
|
|
showPairing(prefill);
|
|
return;
|
|
}
|
|
const state = await checkSession();
|
|
if (state === "invalid") {
|
|
showPairing(prefill);
|
|
return;
|
|
}
|
|
hidePairing();
|
|
if (prefill) history.replaceState(null, "", location.pathname);
|
|
isOnline = state === "online";
|
|
loadApp();
|
|
renderSyncStatus();
|
|
setInterval(trySync, 15000);
|
|
window.addEventListener("online", trySync);
|
|
if (state === "online") trySync(); // drain any queue left over from last time
|
|
})();
|
|
})();
|