Upload via GUI (10 Dateien)
This commit is contained in:
@@ -0,0 +1,435 @@
|
||||
// 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
|
||||
})();
|
||||
})();
|
||||
Reference in New Issue
Block a user