Upload via GUI (10 Dateien)

This commit is contained in:
Git Manager GUI
2026-09-14 20:56:41 +02:00
commit 6733901d99
10 changed files with 8679 additions and 0 deletions
+435
View File
@@ -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
})();
})();