From 16de72c0173f8c9bee420a78187c30eab170577f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20F=C3=B6rtsch?= Date: Wed, 11 Mar 2026 13:08:48 +0100 Subject: [PATCH] add settings page, contact editing, process flow, PWA updates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - settings tab with theme picker, data management, developer mode (mock data), PWA version/update - contact detail page with inline editing of therapist info and contact attempts - quick-action buttons on contact cards (update latest kontakt instead of creating duplicates) - process stepper with inline step actions, Erstgespräch form embedded in step 1 - sticky bottom tab bar, Switch component, .htaccess in build output - pre-warm PGlite on onboarding mount, spinner feedback on submit - move DB init out of beforeLoad for faster initial page render - bump to 2026.03.11.1 Co-Authored-By: Claude Opus 4.6 --- package.json | 2 +- public/.htaccess | 6 + .../components/settings-page.tsx | 197 +++++++ .../kontakte/components/contact-card.tsx | 86 +++- .../kontakte/components/contact-detail.tsx | 482 ++++++++++++++++++ .../kontakte/components/contact-list.tsx | 11 +- src/features/kontakte/hooks.ts | 196 ++++++- src/features/kontakte/index.ts | 12 + .../onboarding/components/onboarding-form.tsx | 57 ++- .../prozess/components/phase-card.tsx | 47 +- .../prozess/components/process-stepper.tsx | 267 +++++++++- src/routeTree.gen.ts | 42 ++ src/routes/__root.tsx | 91 ++-- src/routes/einstellungen/index.tsx | 6 + src/routes/index.tsx | 44 +- src/routes/kontakte/$id.tsx | 9 + src/routes/prozess/index.tsx | 16 +- src/shared/components/ui/switch.tsx | 27 + src/shared/lib/constants.ts | 5 +- src/vite-env.d.ts | 4 + vite.config.ts | 12 +- 21 files changed, 1491 insertions(+), 128 deletions(-) create mode 100644 public/.htaccess create mode 100644 src/features/einstellungen/components/settings-page.tsx create mode 100644 src/features/kontakte/components/contact-detail.tsx create mode 100644 src/routes/einstellungen/index.tsx create mode 100644 src/routes/kontakte/$id.tsx create mode 100644 src/shared/components/ui/switch.tsx create mode 100644 src/vite-env.d.ts diff --git a/package.json b/package.json index 16b922e..2eb72ad 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "therapyfinder", "private": true, - "version": "2026.03.11", + "version": "2026.03.11.1", "type": "module", "scripts": { "dev": "vite", diff --git a/public/.htaccess b/public/.htaccess new file mode 100644 index 0000000..af0d8eb --- /dev/null +++ b/public/.htaccess @@ -0,0 +1,6 @@ +RewriteEngine On +RewriteBase /tpf/ +RewriteRule ^index\.html$ - [L] +RewriteCond %{REQUEST_FILENAME} !-f +RewriteCond %{REQUEST_FILENAME} !-d +RewriteRule . /tpf/index.html [L] diff --git a/src/features/einstellungen/components/settings-page.tsx b/src/features/einstellungen/components/settings-page.tsx new file mode 100644 index 0000000..829f0b1 --- /dev/null +++ b/src/features/einstellungen/components/settings-page.tsx @@ -0,0 +1,197 @@ +import { useRegisterSW } from "virtual:pwa-register/react"; +import { useNavigate } from "@tanstack/react-router"; +import { + Database, + Download, + Monitor, + Moon, + RefreshCw, + Sun, + Trash2, +} from "lucide-react"; +import { useState } from "react"; +import { deleteAllData, seedMockData } from "@/features/kontakte/hooks"; +import { Button } from "@/shared/components/ui/button"; +import { Card, CardContent } from "@/shared/components/ui/card"; +import { Label } from "@/shared/components/ui/label"; +import { Separator } from "@/shared/components/ui/separator"; +import { Switch } from "@/shared/components/ui/switch"; +import { useThemeStore } from "@/shared/hooks/use-theme"; + +type Theme = "light" | "dark" | "system"; + +const themeOptions: { value: Theme; label: string; icon: typeof Sun }[] = [ + { value: "light", label: "Hell", icon: Sun }, + { value: "dark", label: "Dunkel", icon: Moon }, + { value: "system", label: "System", icon: Monitor }, +]; + +export function SettingsPage() { + const { theme, setTheme } = useThemeStore(); + const navigate = useNavigate(); + const [confirmDelete, setConfirmDelete] = useState(false); + const [devMode, setDevMode] = useState( + () => localStorage.getItem("tpf-dev-mode") === "true", + ); + const [mockStatus, setMockStatus] = useState<"idle" | "done">("idle"); + + const { + needRefresh: [needRefresh], + updateServiceWorker, + } = useRegisterSW(); + + const toggleDevMode = (checked: boolean) => { + setDevMode(checked); + localStorage.setItem("tpf-dev-mode", String(checked)); + }; + + return ( +
+

Einstellungen

+ + {/* PWA Update */} +
+

App

+ + +
+

+ {needRefresh ? "Update verfügbar" : "App ist aktuell"} +

+

+ v{__APP_VERSION__} +

+
+ {needRefresh ? ( + + ) : ( + + )} +
+
+
+ + + + {/* Theme */} +
+

Erscheinungsbild

+
+ {themeOptions.map(({ value, label, icon: Icon }) => ( + + ))} +
+
+ + + + {/* Data */} +
+

Daten

+ + +

+ Alle Daten werden lokal in deinem Browser gespeichert. Beim + Löschen gehen alle Einträge unwiderruflich verloren. +

+ {!confirmDelete ? ( + + ) : ( +
+

+ Wirklich alle Daten löschen? +

+ + +
+ )} +
+
+
+ + + + {/* Developer */} +
+
+ + +
+ + {devMode && ( + + +
+
+

Testdaten

+

+ 20 Therapeut:innen mit Kontaktversuchen einfügen +

+
+
+ {mockStatus === "done" && ( + Fertig + )} + +
+
+
+
+ )} +
+
+ ); +} diff --git a/src/features/kontakte/components/contact-card.tsx b/src/features/kontakte/components/contact-card.tsx index 9861f9a..2a5dbfa 100644 --- a/src/features/kontakte/components/contact-card.tsx +++ b/src/features/kontakte/components/contact-card.tsx @@ -1,6 +1,9 @@ +import { useNavigate } from "@tanstack/react-router"; +import { Check, Clock, X } from "lucide-react"; import { Badge } from "@/shared/components/ui/badge"; import { Card, CardContent } from "@/shared/components/ui/card"; import type { KontaktErgebnis } from "@/shared/db/schema"; +import { dbExec } from "@/shared/hooks/use-db"; import { ERGEBNIS_LABELS } from "@/shared/lib/constants"; interface ContactCardProps { @@ -10,6 +13,7 @@ interface ContactCardProps { letzterKontakt: string | null; letztesErgebnis: string | null; kontakteGesamt: number; + onUpdate: () => void; } const ergebnisVariant: Record< @@ -22,24 +26,100 @@ const ergebnisVariant: Record< keine_antwort: "outline", }; +async function quickUpdate( + therapeutId: number, + ergebnis: KontaktErgebnis, + onUpdate: () => void, +) { + // Update the most recent kontakt for this therapist, or create one if none exists + const result = await dbExec( + "SELECT id FROM kontakt WHERE therapeut_id = $1 ORDER BY datum DESC, id DESC LIMIT 1", + [therapeutId], + ); + if (result.rows.length > 0) { + const kontaktId = (result.rows[0] as { id: number }).id; + await dbExec("UPDATE kontakt SET ergebnis = $1 WHERE id = $2", [ + ergebnis, + kontaktId, + ]); + } else { + const d = new Date(); + const datum = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; + await dbExec( + "INSERT INTO kontakt (therapeut_id, datum, kanal, ergebnis) VALUES ($1, $2, 'telefon', $3)", + [therapeutId, datum, ergebnis], + ); + } + onUpdate(); +} + export function ContactCard({ + id, name, stadt, letzterKontakt, letztesErgebnis, kontakteGesamt, + onUpdate, }: ContactCardProps) { + const navigate = useNavigate(); + return ( - - + + navigate({ to: "/kontakte/$id", params: { id: String(id) } }) + } + > +

{name}

{stadt &&

{stadt}

}

{kontakteGesamt} Kontakt{kontakteGesamt !== 1 ? "e" : ""} - {letzterKontakt && <> · Letzter: {letzterKontakt}} + {letzterKontakt && <> · {letzterKontakt}}

+ +
+ + + +
+ {letztesErgebnis && ( = { + zusage: "default", + warteliste: "secondary", + absage: "destructive", + keine_antwort: "outline", +}; + +export function ContactDetail({ therapeutId }: { therapeutId: number }) { + const navigate = useNavigate(); + const { + data: therapeuten, + loading: tLoading, + refetch: refetchT, + } = useTherapeut(therapeutId); + const { + data: kontakte, + loading: kLoading, + refetch: refetchK, + } = useKontakteForTherapeut(therapeutId); + + const therapeut = therapeuten[0]; + + if (tLoading || kLoading) return

Laden…

; + if (!therapeut) return

Therapeut:in nicht gefunden.

; + + return ( +
+
+ + + +

{therapeut.name}

+
+ + + + + +
+

Kontaktversuche

+
+ + { + refetchK(); + refetchT(); + }} + /> + + {kontakte.length === 0 ? ( +

+ Noch keine Kontaktversuche. +

+ ) : ( +
+ {kontakte.map((k) => ( + { + refetchK(); + refetchT(); + }} + /> + ))} +
+ )} + + + + +
+ ); +} + +function TherapeutEditForm({ + therapeut, + onSaved, +}: { + therapeut: { + id: number; + name: string; + adresse: string | null; + plz: string | null; + stadt: string | null; + telefon: string | null; + email: string | null; + website: string | null; + therapieform: string | null; + }; + onSaved: () => void; +}) { + const form = useForm({ + defaultValues: { + name: therapeut.name, + adresse: therapeut.adresse ?? "", + plz: therapeut.plz ?? "", + stadt: therapeut.stadt ?? "", + telefon: therapeut.telefon ?? "", + email: therapeut.email ?? "", + website: therapeut.website ?? "", + therapieform: therapeut.therapieform ?? "", + }, + onSubmit: async ({ value }) => { + await updateTherapeut(therapeut.id, value); + onSaved(); + }, + }); + + return ( +
{ + e.preventDefault(); + e.stopPropagation(); + form.handleSubmit(); + }} + className="space-y-3" + > +
+ + {(field) => ( +
+ + field.handleChange(e.target.value)} + onBlur={field.handleBlur} + /> +
+ )} +
+ + {(field) => ( +
+ + field.handleChange(e.target.value)} + onBlur={field.handleBlur} + /> +
+ )} +
+
+
+ + {(field) => ( +
+ + field.handleChange(e.target.value)} + onBlur={field.handleBlur} + /> +
+ )} +
+ + {(field) => ( +
+ + field.handleChange(e.target.value)} + onBlur={field.handleBlur} + /> +
+ )} +
+
+ +
+ ); +} + +function NewKontaktForm({ + therapeutId, + onSaved, +}: { + therapeutId: number; + onSaved: () => void; +}) { + const form = useForm({ + defaultValues: { + datum: todayISO(), + kanal: "telefon" as KontaktKanal, + ergebnis: "keine_antwort" as KontaktErgebnis, + notiz: "", + }, + onSubmit: async ({ value }) => { + await createKontakt({ + therapeut_id: therapeutId, + datum: value.datum, + kanal: value.kanal, + ergebnis: value.ergebnis, + notiz: value.notiz, + }); + form.reset(); + onSaved(); + }, + }); + + return ( + + +
{ + e.preventDefault(); + e.stopPropagation(); + form.handleSubmit(); + }} + className="space-y-3" + > +

Neuer Kontaktversuch

+
+ + {(field) => ( +
+ + field.handleChange(e.target.value)} + /> +
+ )} +
+ + {(field) => ( +
+ + +
+ )} +
+ + {(field) => ( +
+ + +
+ )} +
+
+ + {(field) => ( +
+ +