Add appointments, therapists and calendar subscriptions

- Appointments per patient with therapist, duration, place and a note
  for the patient; series (e.g. 6x weekly); cancel keeps them visible.
- Practice overview of the next two weeks, filterable by therapist.
- Calendar feed per patient (iCalendar, RFC 5545) behind a secret
  link, with webcal link and QR code; contains no diagnoses and no
  patient name; the link can be renewed.
- Printable appointment list with QR code.
- Session summaries name the next appointment.

Assisted-by: Claude (Anthropic) via the pi coding agent
AuthorAdmin <admin@gpl-nas.de>
Date
Commit4ad9e56bc2ca3a8a9bdcc5cd5c454a4a8a1bb4ad
Parent1c8b018
20 files changed, 1266 insertions(+), 6 deletions(-)
▾Mgo.mod
@@ -6,6 +6,7 @@ require (
github.com/yuin/goldmark v1.8.7
golang.org/x/crypto v0.58.0
modernc.org/sqlite v1.62.0
rsc.io/qr v0.2.0
)
require (
▾Mgo.sum
@@ -52,3 +52,5 @@ modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY=
rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs=
▾Minternal/config/config.go
@@ -19,6 +19,8 @@ type Config struct {
DocsDir string
BackupDir string
SMTP SMTP
PublicURL string // von außen erreichbare Adresse für Kalender-Links (z. B. https://physio.example.com)
Address string // Praxisadresse, Standard-Ort für Termine
BackupKeep int
Username string
PasswordHash string
@@ -57,6 +59,8 @@ func Load() (*Config, error) {
PracticeName: get("PRACTICE_NAME", "PhysioBase"),
BaseURL: strings.TrimRight(get("BASE_URL", ""), "/"),
}
c.PublicURL = strings.TrimRight(get("PUBLIC_URL", c.BaseURL), "/")
c.Address = get("PRACTICE_ADDRESS", "")
c.DBPath = get("DB_PATH", filepath.Join(c.DataDir, "physiobase.db"))
c.MediaDir = get("MEDIA_DIR", filepath.Join(c.DataDir, "medien"))
c.DocsDir = get("DOCS_DIR", filepath.Join(c.DataDir, "patienten"))
▾Minternal/database/database.go
@@ -232,6 +232,38 @@ CREATE TABLE mail_log (
sent_at INTEGER NOT NULL DEFAULT (unixepoch())
);
CREATE INDEX idx_mail_log_summary ON mail_log(summary_id);
`},
{6, `
CREATE TABLE therapists (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
active INTEGER NOT NULL DEFAULT 1,
created_at INTEGER NOT NULL DEFAULT (unixepoch())
);
-- Termine. uid + sequence für den Kalender-Feed (Änderungen werden von Kalender-Apps erkannt).
-- note ist ein Hinweis FÜR den Patienten und erscheint im Feed und auf dem Ausdruck.
CREATE TABLE appointments (
id INTEGER PRIMARY KEY,
uid TEXT NOT NULL UNIQUE,
patient_id INTEGER NOT NULL REFERENCES patients(id) ON DELETE CASCADE,
therapist_id INTEGER REFERENCES therapists(id) ON DELETE SET NULL,
starts_at INTEGER NOT NULL,
duration_min INTEGER NOT NULL DEFAULT 30,
title TEXT NOT NULL DEFAULT 'Physiotherapie',
location TEXT NOT NULL DEFAULT '',
note TEXT NOT NULL DEFAULT '',
cancelled INTEGER NOT NULL DEFAULT 0,
sequence INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
updated_at INTEGER NOT NULL DEFAULT (unixepoch())
);
CREATE INDEX idx_appointments_patient ON appointments(patient_id, starts_at);
CREATE INDEX idx_appointments_start ON appointments(starts_at);
-- Geheimer Link für das Kalender-Abo des Patienten (NULL = noch keiner)
ALTER TABLE patients ADD COLUMN cal_token TEXT;
CREATE UNIQUE INDEX idx_patients_cal_token ON patients(cal_token) WHERE cal_token IS NOT NULL;
`},
}
▾Ainternal/ical/ical.go
@@ -0,0 +1,108 @@
// Package ical erzeugt iCalendar-Feeds (RFC 5545) zum Abonnieren in Kalender-Apps.
package ical
import (
"bytes"
"strings"
"time"
"unicode/utf8"
)
type Event struct {
UID string
Start, End, Stamp time.Time
Summary, Description, Location string
Sequence int
Cancelled bool
}
type Calendar struct {
Name, ProdID string
Events []Event
}
// Escape für TEXT-Werte (RFC 5545 3.3.11).
func Escape(s string) string {
r := strings.NewReplacer(`\`, `\\`, `;`, `\;`, `,`, `\,`, "\r\n", `\n`, "\n", `\n`, "\r", `\n`)
return r.Replace(s)
}
// fold bricht Zeilen nach höchstens 75 Bytes um, ohne UTF-8-Zeichen zu zerteilen.
func fold(line string) string {
if len(line) <= 75 {
return line + "\r\n"
}
var b strings.Builder
limit := 75
for len(line) > limit {
cut := limit
for cut > 0 && !utf8.RuneStart(line[cut]) {
cut--
}
b.WriteString(line[:cut])
b.WriteString("\r\n ")
line = line[cut:]
limit = 74 // Folgezeilen beginnen mit einem Leerzeichen
}
b.WriteString(line)
b.WriteString("\r\n")
return b.String()
}
func utc(t time.Time) string { return t.UTC().Format("20060102T150405Z") }
func (c Calendar) Bytes() []byte {
var b bytes.Buffer
w := func(s string) { b.WriteString(fold(s)) }
w("BEGIN:VCALENDAR")
w("VERSION:2.0")
w("PRODID:" + c.ProdID)
w("CALSCALE:GREGORIAN")
w("METHOD:PUBLISH")
w("X-WR-CALNAME:" + Escape(c.Name))
w("REFRESH-INTERVAL;VALUE=DURATION:PT1H")
w("X-PUBLISHED-TTL:PT1H")
for _, e := range c.Events {
w("BEGIN:VEVENT")
w("UID:" + e.UID)
w("DTSTAMP:" + utc(e.Stamp))
w("DTSTART:" + utc(e.Start))
w("DTEND:" + utc(e.End))
w("SEQUENCE:" + itoa(e.Sequence))
w("SUMMARY:" + Escape(e.Summary))
if e.Location != "" {
w("LOCATION:" + Escape(e.Location))
}
if e.Description != "" {
w("DESCRIPTION:" + Escape(e.Description))
}
if e.Cancelled {
w("STATUS:CANCELLED")
} else {
w("STATUS:CONFIRMED")
}
w("TRANSP:OPAQUE")
w("END:VEVENT")
}
w("END:VCALENDAR")
return b.Bytes()
}
func itoa(n int) string {
if n == 0 {
return "0"
}
var d []byte
neg := n < 0
if neg {
n = -n
}
for n > 0 {
d = append([]byte{byte('0' + n%10)}, d...)
n /= 10
}
if neg {
d = append([]byte{'-'}, d...)
}
return string(d)
}
▾Ainternal/ical/ical_test.go
@@ -0,0 +1,51 @@
package ical
import (
"strings"
"testing"
"time"
)
func TestEscape(t *testing.T) {
if got := Escape("a,b;c\\d\ne"); got != `a\,b\;c\\d\ne` {
t.Errorf("Escape: %q", got)
}
}
func TestFoldKeepsUTF8AndLength(t *testing.T) {
line := "DESCRIPTION:" + strings.Repeat("Übungsprogramm für die Kniestreckung – ", 6)
out := fold(line)
for _, l := range strings.Split(strings.TrimSuffix(out, "\r\n"), "\r\n") {
if len(l) > 75 {
t.Errorf("Zeile länger als 75 Bytes: %d", len(l))
}
if !strings.HasPrefix(l, "DESCRIPTION") && !strings.HasPrefix(l, " ") {
t.Errorf("Folgezeile ohne Leerzeichen: %q", l)
}
}
// Entfalten ergibt wieder das Original
if unfolded := strings.ReplaceAll(strings.TrimSuffix(out, "\r\n"), "\r\n ", ""); unfolded != line {
t.Error("Faltung nicht verlustfrei")
}
}
func TestCalendar(t *testing.T) {
berlin, _ := time.LoadLocation("Europe/Berlin")
start := time.Date(2026, 11, 12, 9, 30, 0, 0, berlin)
c := Calendar{Name: "Physiotherapie, Praxis", ProdID: "-//PhysioBase//DE", Events: []Event{
{UID: "abc@x", Start: start, End: start.Add(30 * time.Minute), Stamp: start, Summary: "Physiotherapie", Location: "Musterstr. 1, Köln", Sequence: 2},
{UID: "def@x", Start: start, End: start, Stamp: start, Summary: "Abgesagt", Cancelled: true},
}}
s := string(c.Bytes())
for _, want := range []string{
"BEGIN:VCALENDAR\r\n", "X-WR-CALNAME:Physiotherapie\\, Praxis\r\n", "DTSTART:20261112T083000Z\r\n",
"DTEND:20261112T090000Z\r\n", "SEQUENCE:2\r\n", "LOCATION:Musterstr. 1\\, Köln\r\n", "STATUS:CANCELLED\r\n", "END:VCALENDAR\r\n",
} {
if !strings.Contains(s, want) {
t.Errorf("fehlt: %q", want)
}
}
if strings.Contains(strings.ReplaceAll(s, "\r\n", ""), "\n") {
t.Error("nackte LF-Zeilenenden")
}
}
▾Ainternal/store/appointments.go
@@ -0,0 +1,211 @@
package store
import (
"context"
"crypto/rand"
"database/sql"
"encoding/hex"
"errors"
"time"
)
type Therapist struct {
ID int64
Name string
Active bool
}
type Appointment struct {
ID, PatientID, TherapistID int64
UID string
Start time.Time
Duration int // Minuten
Title, Location, Note string
Cancelled bool
Sequence int
Updated time.Time
TherapistName, PatientName string
}
func (a Appointment) End() time.Time { return a.Start.Add(time.Duration(a.Duration) * time.Minute) }
func randomToken(n int) string {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
panic(err) // crypto/rand schlägt praktisch nie fehl
}
return hex.EncodeToString(b)
}
// ---------- Behandler ----------
func (s *Store) Therapists(ctx context.Context, activeOnly bool) ([]Therapist, error) {
rows, err := s.db.QueryContext(ctx, `SELECT id, name, active FROM therapists WHERE (? = 0 OR active = 1)
ORDER BY active DESC, name COLLATE NOCASE`, activeOnly)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Therapist
for rows.Next() {
var t Therapist
if err := rows.Scan(&t.ID, &t.Name, &t.Active); err != nil {
return nil, err
}
out = append(out, t)
}
return out, rows.Err()
}
func (s *Store) CreateTherapist(ctx context.Context, name string) error {
_, err := s.db.ExecContext(ctx, `INSERT INTO therapists (name) VALUES (?)`, name)
return err
}
func (s *Store) UpdateTherapist(ctx context.Context, t Therapist) error {
_, err := s.db.ExecContext(ctx, `UPDATE therapists SET name = ?, active = ? WHERE id = ?`, t.Name, t.Active, t.ID)
return err
}
// ---------- Termine ----------
const apptSelect = `SELECT a.id, a.uid, a.patient_id, COALESCE(a.therapist_id, 0), a.starts_at, a.duration_min, a.title,
a.location, a.note, a.cancelled, a.sequence, a.updated_at, COALESCE(t.name, ''), p.name
FROM appointments a JOIN patients p ON p.id = a.patient_id LEFT JOIN therapists t ON t.id = a.therapist_id`
func scanAppt(sc scanner) (Appointment, error) {
var a Appointment
var start, upd int64
err := sc.Scan(&a.ID, &a.UID, &a.PatientID, &a.TherapistID, &start, &a.Duration, &a.Title, &a.Location, &a.Note,
&a.Cancelled, &a.Sequence, &upd, &a.TherapistName, &a.PatientName)
a.Start, a.Updated = time.Unix(start, 0), time.Unix(upd, 0)
return a, err
}
func (s *Store) appts(ctx context.Context, where string, args ...any) ([]Appointment, error) {
rows, err := s.db.QueryContext(ctx, apptSelect+" "+where, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Appointment
for rows.Next() {
a, err := scanAppt(rows)
if err != nil {
return nil, err
}
out = append(out, a)
}
return out, rows.Err()
}
// PatientAppointments ab einem Zeitpunkt (Null-Zeit = alle), aufsteigend.
func (s *Store) PatientAppointments(ctx context.Context, pid int64, from time.Time) ([]Appointment, error) {
var since int64
if !from.IsZero() {
since = from.Unix()
}
return s.appts(ctx, `WHERE a.patient_id = ? AND a.starts_at >= ? ORDER BY a.starts_at`, pid, since)
}
// AppointmentsBetween für die Praxisübersicht, optional nach Behandler gefiltert.
func (s *Store) AppointmentsBetween(ctx context.Context, from, to time.Time, therapistID int64) ([]Appointment, error) {
return s.appts(ctx, `WHERE a.starts_at >= ? AND a.starts_at < ? AND (? = 0 OR a.therapist_id = ?) ORDER BY a.starts_at`,
from.Unix(), to.Unix(), therapistID, therapistID)
}
// NextAppointment nach einem Zeitpunkt (nicht abgesagt).
func (s *Store) NextAppointment(ctx context.Context, pid int64, after time.Time) (Appointment, bool, error) {
as, err := s.appts(ctx, `WHERE a.patient_id = ? AND a.starts_at > ? AND a.cancelled = 0 ORDER BY a.starts_at LIMIT 1`, pid, after.Unix())
if err != nil || len(as) == 0 {
return Appointment{}, false, err
}
return as[0], true, nil
}
func (s *Store) Appointment(ctx context.Context, id int64) (Appointment, error) {
as, err := s.appts(ctx, `WHERE a.id = ?`, id)
if err != nil {
return Appointment{}, err
}
if len(as) == 0 {
return Appointment{}, ErrNotFound
}
return as[0], nil
}
func nullID(id int64) any {
if id > 0 {
return id
}
return nil
}
func (s *Store) CreateAppointment(ctx context.Context, a Appointment) (int64, error) {
res, err := s.db.ExecContext(ctx, `INSERT INTO appointments (uid, patient_id, therapist_id, starts_at, duration_min, title, location, note)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, randomToken(16), a.PatientID, nullID(a.TherapistID), a.Start.Unix(), a.Duration, a.Title, a.Location, a.Note)
if err != nil {
return 0, err
}
return res.LastInsertId()
}
// UpdateAppointment erhöht sequence, damit Kalender-Apps die Änderung übernehmen.
func (s *Store) UpdateAppointment(ctx context.Context, a Appointment) error {
res, err := s.db.ExecContext(ctx, `UPDATE appointments SET therapist_id=?, starts_at=?, duration_min=?, title=?, location=?, note=?,
cancelled=?, sequence=sequence+1, updated_at=unixepoch() WHERE id=?`,
nullID(a.TherapistID), a.Start.Unix(), a.Duration, a.Title, a.Location, a.Note, a.Cancelled, a.ID)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
func (s *Store) DeleteAppointment(ctx context.Context, id int64) (int64, error) {
var pid int64
err := s.db.QueryRowContext(ctx, `DELETE FROM appointments WHERE id = ? RETURNING patient_id`, id).Scan(&pid)
return pid, notFound(err)
}
// ---------- Kalender-Link ----------
// CalToken liefert den Abo-Token des Patienten und legt ihn bei Bedarf an.
func (s *Store) CalToken(ctx context.Context, pid int64) (string, error) {
var tok sql.NullString
if err := s.db.QueryRowContext(ctx, `SELECT cal_token FROM patients WHERE id = ?`, pid).Scan(&tok); err != nil {
return "", notFound(err)
}
if tok.Valid && tok.String != "" {
return tok.String, nil
}
return s.RotateCalToken(ctx, pid)
}
// RotateCalToken erzeugt einen neuen Link; der alte funktioniert danach nicht mehr.
func (s *Store) RotateCalToken(ctx context.Context, pid int64) (string, error) {
tok := randomToken(24)
res, err := s.db.ExecContext(ctx, `UPDATE patients SET cal_token = ? WHERE id = ?`, tok, pid)
if err != nil {
return "", err
}
if n, _ := res.RowsAffected(); n == 0 {
return "", ErrNotFound
}
return tok, nil
}
// PatientByCalToken für den öffentlichen Feed.
func (s *Store) PatientByCalToken(ctx context.Context, token string) (int64, error) {
if len(token) < 32 {
return 0, ErrNotFound
}
var pid int64
err := s.db.QueryRowContext(ctx, `SELECT id FROM patients WHERE cal_token = ?`, token).Scan(&pid)
if errors.Is(err, sql.ErrNoRows) {
return 0, ErrNotFound
}
return pid, err
}
▾Ainternal/web/appointments.go
@@ -0,0 +1,460 @@
package web
import (
"bytes"
"errors"
"fmt"
"html/template"
"net/http"
"strconv"
"strings"
"time"
"rsc.io/qr"
"forge.gpl-nas.de/physiobase/internal/ical"
"forge.gpl-nas.de/physiobase/internal/store"
)
var weekdays = [...]string{"So", "Mo", "Di", "Mi", "Do", "Fr", "Sa"}
func dayLabel(t time.Time) string {
return weekdays[t.Weekday()] + ", " + t.Format("02.01.2006")
}
func startOfDay(t time.Time) time.Time {
y, m, d := t.Date()
return time.Date(y, m, d, 0, 0, 0, 0, time.Local)
}
// parseAppt liest die gemeinsamen Terminfelder aus dem Formular.
func parseAppt(r *http.Request) (store.Appointment, error) {
start, err := time.ParseInLocation("2006-01-02 15:04", formText(r, "date")+" "+formText(r, "time"), time.Local)
if err != nil {
return store.Appointment{}, errors.New("Datum oder Uhrzeit fehlt bzw. ist ungültig") //nolint:staticcheck // ST1005: Meldung wird direkt angezeigt
}
dur, err := strconv.Atoi(formText(r, "duration"))
if err != nil || dur < 5 || dur > 480 {
dur = 30
}
a := store.Appointment{
Start: start, Duration: dur, Title: formText(r, "title"),
Location: formText(r, "location"), Note: formText(r, "note"),
}
if a.Title == "" {
a.Title = "Physiotherapie"
}
a.TherapistID, _ = formID(r, "therapist")
return a, nil
}
// ---------- Praxisübersicht ----------
type apptDay struct {
Label string
Today bool
Appts []store.Appointment
}
func (s *Server) listAppointments(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
from := startOfDay(time.Now())
if v := r.URL.Query().Get("von"); v != "" {
if t, err := time.ParseInLocation("2006-01-02", v, time.Local); err == nil {
from = t
}
}
days := 14
therapist, _ := strconv.ParseInt(r.URL.Query().Get("behandler"), 10, 64)
as, err := s.st.AppointmentsBetween(ctx, from, from.AddDate(0, 0, days), therapist)
if err != nil {
s.fail(w, r, err)
return
}
var grouped []apptDay
for _, a := range as {
label := dayLabel(a.Start)
if len(grouped) == 0 || grouped[len(grouped)-1].Label != label {
grouped = append(grouped, apptDay{Label: label, Today: startOfDay(a.Start).Equal(startOfDay(time.Now()))})
}
grouped[len(grouped)-1].Appts = append(grouped[len(grouped)-1].Appts, a)
}
therapists, err := s.st.Therapists(ctx, false)
if err != nil {
s.fail(w, r, err)
return
}
patients, err := s.st.Patients(ctx, "", false)
if err != nil {
s.fail(w, r, err)
return
}
s.render(w, r, "appointments", http.StatusOK, map[string]any{
"Days": grouped, "From": from.Format("2006-01-02"), "To": from.AddDate(0, 0, days-1),
"Prev": from.AddDate(0, 0, -days).Format("2006-01-02"), "Next": from.AddDate(0, 0, days).Format("2006-01-02"),
"Therapists": therapists, "Therapist": therapist, "Patients": patients, "Count": len(as),
"Today": time.Now().Format("2006-01-02"), "Address": s.cfg.Address, "Error": r.URL.Query().Get("fehler"),
})
}
// createAppointment legt einen Termin oder eine Serie an (z. B. 6× wöchentlich für eine Verordnung).
func (s *Server) createAppointment(w http.ResponseWriter, r *http.Request) {
if !limitForm(w, r) {
return
}
ctx := r.Context()
pid, ok := formID(r, "patient")
back := func(msg string) {
if r.FormValue("back") == "patient" && ok {
redirect(w, r, "/patient/%d?fehler=%s#termine", pid, urlPathEscape(msg))
return
}
redirect(w, r, "/termine?fehler=%s", urlPathEscape(msg))
}
if !ok {
back("Bitte einen Patienten auswählen.")
return
}
if _, err := s.st.Patient(ctx, pid); err != nil {
s.fail(w, r, err)
return
}
a, err := parseAppt(r)
if err != nil {
back(err.Error())
return
}
a.PatientID = pid
count, _ := strconv.Atoi(r.FormValue("repeat"))
count = max(1, min(count, 40))
every, _ := strconv.Atoi(r.FormValue("every"))
every = max(1, min(every, 60))
for i := range count {
x := a
x.Start = a.Start.AddDate(0, 0, i*every) // AddDate hält die Uhrzeit auch über die Zeitumstellung
if _, err := s.st.CreateAppointment(ctx, x); err != nil {
s.fail(w, r, err)
return
}
}
if r.FormValue("back") == "patient" {
redirect(w, r, "/patient/%d#termine", pid)
return
}
redirect(w, r, "/termine?von=%s", startOfDay(a.Start).Format("2006-01-02"))
}
func (s *Server) showAppointment(w http.ResponseWriter, r *http.Request) {
id, ok := pathID(r, "id")
if !ok {
http.NotFound(w, r)
return
}
a, err := s.st.Appointment(r.Context(), id)
if err != nil {
s.fail(w, r, err)
return
}
therapists, err := s.st.Therapists(r.Context(), false)
if err != nil {
s.fail(w, r, err)
return
}
s.render(w, r, "appointment", http.StatusOK, map[string]any{"A": a, "Therapists": therapists, "Error": r.URL.Query().Get("fehler")})
}
func (s *Server) saveAppointment(w http.ResponseWriter, r *http.Request) {
if !limitForm(w, r) {
return
}
id, ok := pathID(r, "id")
if !ok {
http.NotFound(w, r)
return
}
old, err := s.st.Appointment(r.Context(), id)
if err != nil {
s.fail(w, r, err)
return
}
a, err := parseAppt(r)
if err != nil {
redirect(w, r, "/termin/%d?fehler=%s", id, urlPathEscape(err.Error()))
return
}
a.ID, a.PatientID, a.Cancelled = id, old.PatientID, r.FormValue("cancelled") == "1"
if err := s.st.UpdateAppointment(r.Context(), a); err != nil {
s.fail(w, r, err)
return
}
redirect(w, r, "/patient/%d#termine", a.PatientID)
}
// toggleCancel sagt einen Termin ab bzw. wieder zu; im Kalender erscheint er als abgesagt.
func (s *Server) toggleCancel(w http.ResponseWriter, r *http.Request) {
id, ok := pathID(r, "id")
if !ok {
http.NotFound(w, r)
return
}
a, err := s.st.Appointment(r.Context(), id)
if err != nil {
s.fail(w, r, err)
return
}
a.Cancelled = !a.Cancelled
if err := s.st.UpdateAppointment(r.Context(), a); err != nil {
s.fail(w, r, err)
return
}
if to := r.FormValue("back"); to == "termine" {
redirect(w, r, "/termine?von=%s", startOfDay(a.Start).Format("2006-01-02"))
return
}
redirect(w, r, "/patient/%d#termine", a.PatientID)
}
func (s *Server) deleteAppointment(w http.ResponseWriter, r *http.Request) {
id, ok := pathID(r, "id")
if !ok {
http.NotFound(w, r)
return
}
pid, err := s.st.DeleteAppointment(r.Context(), id)
if err != nil {
s.fail(w, r, err)
return
}
redirect(w, r, "/patient/%d#termine", pid)
}
// ---------- Behandler ----------
func (s *Server) createTherapist(w http.ResponseWriter, r *http.Request) {
if !limitForm(w, r) {
return
}
if name := formText(r, "name"); name != "" {
if err := s.st.CreateTherapist(r.Context(), name); err != nil {
s.fail(w, r, err)
return
}
}
redirect(w, r, "/termine#behandler")
}
func (s *Server) updateTherapist(w http.ResponseWriter, r *http.Request) {
if !limitForm(w, r) {
return
}
id, ok := pathID(r, "id")
name := formText(r, "name")
if !ok || name == "" {
http.Error(w, "Name fehlt", http.StatusBadRequest)
return
}
if err := s.st.UpdateTherapist(r.Context(), store.Therapist{ID: id, Name: name, Active: r.FormValue("active") == "1"}); err != nil {
s.fail(w, r, err)
return
}
redirect(w, r, "/termine#behandler")
}
// ---------- Kalender-Abo ----------
// calURL: Abo-Adresse. Ohne PHYSIOBASE_PUBLIC_URL nur im lokalen Netz erreichbar.
func (s *Server) calURL(r *http.Request, token string) (string, bool) {
base, public := s.cfg.PublicURL, true
if base == "" {
scheme := "http"
if r.TLS != nil {
scheme = "https"
}
base, public = scheme+"://"+r.Host, false
}
return base + "/kalender/" + token + ".ics", public
}
func webcal(u string) string {
if i := strings.Index(u, "://"); i >= 0 {
return "webcal" + u[i:]
}
return u
}
// calendarFeed ist öffentlich (Kalender-Apps können sich nicht anmelden) und nur über den
// geheimen Token erreichbar. Er enthält bewusst keine Diagnosen und keinen Patientennamen.
func (s *Server) calendarFeed(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
token := strings.TrimSuffix(r.PathValue("file"), ".ics")
pid, err := s.st.PatientByCalToken(ctx, token)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
time.Sleep(500 * time.Millisecond) // Raten unattraktiv machen
http.NotFound(w, r)
return
}
s.fail(w, r, err)
return
}
as, err := s.st.PatientAppointments(ctx, pid, time.Now().AddDate(0, 0, -90))
if err != nil {
s.fail(w, r, err)
return
}
host := "physiobase"
if i := strings.LastIndex(s.cfg.SMTP.From, "@"); i >= 0 {
host = s.cfg.SMTP.From[i+1:]
}
cal := ical.Calendar{Name: s.cfg.PracticeName + " – Termine", ProdID: "-//PhysioBase//Termine//DE"}
for _, a := range as {
var desc []string
if a.TherapistName != "" {
desc = append(desc, "Behandlung bei: "+a.TherapistName)
}
if a.Note != "" {
desc = append(desc, "Hinweis: "+a.Note)
}
if a.Cancelled {
desc = append(desc, "Dieser Termin wurde abgesagt.")
}
summary := a.Title + " – " + s.cfg.PracticeName
if a.Cancelled {
summary = "Abgesagt: " + summary
}
loc := a.Location
if loc == "" {
loc = s.cfg.Address
}
cal.Events = append(cal.Events, ical.Event{
UID: a.UID + "@" + host, Start: a.Start, End: a.End(), Stamp: a.Updated,
Summary: summary, Description: strings.Join(desc, "\n"), Location: loc,
Sequence: a.Sequence, Cancelled: a.Cancelled,
})
}
w.Header().Set("Content-Type", "text/calendar; charset=utf-8")
w.Header().Set("Content-Disposition", `inline; filename="termine.ics"`)
w.Header().Set("Cache-Control", "private, max-age=900")
w.Header().Set("X-Robots-Tag", "noindex")
_, _ = w.Write(cal.Bytes())
}
// qrSVG zeichnet einen QR-Code als SVG (scharf in jeder Druckgröße).
func qrSVG(text string) ([]byte, error) {
code, err := qr.Encode(text, qr.M)
if err != nil {
return nil, err
}
const quiet = 4
n := code.Size + 2*quiet
var b bytes.Buffer
fmt.Fprintf(&b, `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 %d %d" shape-rendering="crispEdges">`, n, n)
fmt.Fprintf(&b, `<rect width="%d" height="%d" fill="#fff"/><path fill="#000" d="`, n, n)
for y := range code.Size {
for x := range code.Size {
if code.Black(x, y) {
fmt.Fprintf(&b, "M%d %dh1v1h-1z", x+quiet, y+quiet)
}
}
}
b.WriteString(`"/></svg>`)
return b.Bytes(), nil
}
func (s *Server) calendarQR(w http.ResponseWriter, r *http.Request) {
pid, ok := pathID(r, "id")
if !ok {
http.NotFound(w, r)
return
}
tok, err := s.st.CalToken(r.Context(), pid)
if err != nil {
s.fail(w, r, err)
return
}
u, _ := s.calURL(r, tok)
svg, err := qrSVG(webcal(u))
if err != nil {
s.fail(w, r, err)
return
}
w.Header().Set("Content-Type", "image/svg+xml")
w.Header().Set("Cache-Control", "private, no-store")
_, _ = w.Write(svg) //nolint:gosec // G705: SVG wird hier aus dem QR-Raster erzeugt, kein Benutzertext
}
func (s *Server) rotateCalendar(w http.ResponseWriter, r *http.Request) {
pid, ok := pathID(r, "id")
if !ok {
http.NotFound(w, r)
return
}
if _, err := s.st.RotateCalToken(r.Context(), pid); err != nil {
s.fail(w, r, err)
return
}
redirect(w, r, "/patient/%d#termine", pid)
}
func (s *Server) printAppointments(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
pid, ok := pathID(r, "id")
if !ok {
http.NotFound(w, r)
return
}
p, err := s.st.Patient(ctx, pid)
if err != nil {
s.fail(w, r, err)
return
}
as, err := s.st.PatientAppointments(ctx, pid, startOfDay(time.Now()))
if err != nil {
s.fail(w, r, err)
return
}
tok, err := s.st.CalToken(ctx, pid)
if err != nil {
s.fail(w, r, err)
return
}
u, public := s.calURL(r, tok)
s.render(w, r, "appointments_print", http.StatusOK, map[string]any{
"P": p, "Appts": as, "CalURL": u, "Public": public, "Address": s.cfg.Address, "Today": time.Now(),
})
}
// patientAppointmentData für die Patientenseite.
func (s *Server) patientAppointmentData(r *http.Request, pid int64) (map[string]any, error) {
ctx := r.Context()
all, err := s.st.PatientAppointments(ctx, pid, time.Time{})
if err != nil {
return nil, err
}
now := time.Now()
var upcoming, past []store.Appointment
for _, a := range all {
if a.End().After(now) {
upcoming = append(upcoming, a)
} else {
past = append(past, a)
}
}
for i, j := 0, len(past)-1; i < j; i, j = i+1, j-1 { // vergangene: neueste zuerst
past[i], past[j] = past[j], past[i]
}
therapists, err := s.st.Therapists(ctx, true)
if err != nil {
return nil, err
}
tok, err := s.st.CalToken(ctx, pid)
if err != nil {
return nil, err
}
u, public := s.calURL(r, tok)
return map[string]any{
"Upcoming": upcoming, "Past": past, "Therapists": therapists,
// webcal:// kennt html/template nicht als sicheres Schema; die Adresse bauen wir selbst.
"CalURL": u, "Webcal": template.URL(webcal(u)), "CalPublic": public, "Address": s.cfg.Address, //nolint:gosec // G203
}, nil
}
▾Ainternal/web/appointments_test.go
@@ -0,0 +1,102 @@
package web
import (
"io"
"net/http"
"net/url"
"regexp"
"strings"
"testing"
"time"
"forge.gpl-nas.de/physiobase/internal/config"
)
func TestAppointmentsAndCalendar(t *testing.T) {
ts, c := newServerCfg(t, func(cfg *config.Config) {
cfg.PublicURL = "https://physio.example.org"
cfg.Address = "Musterstraße 1, 50667 Köln"
})
login(t, c, ts.URL)
post(c, ts.URL+"/patienten", url.Values{"name": {"Kai Kalender"}})
post(c, ts.URL+"/behandler", url.Values{"name": {"Anna Schmidt"}})
// Serie: 3× wöchentlich, ab morgen
day := time.Now().AddDate(0, 0, 1).Format("2006-01-02")
r, _ := post(c, ts.URL+"/termine", url.Values{
"patient": {"1"}, "date": {day}, "time": {"09:30"}, "duration": {"25"},
"therapist": {"1"}, "title": {"Krankengymnastik"}, "note": {"Handtuch, mitbringen; bitte"}, "repeat": {"3"}, "every": {"7"}, "back": {"patient"},
})
if r.Header.Get("Location") != "/patient/1#termine" {
t.Fatalf("Termin anlegen: %d %s", r.StatusCode, r.Header.Get("Location"))
}
_, html := body(t, c, ts.URL+"/patient/1")
if strings.Count(html, "Krankengymnastik · Anna Schmidt") != 3 {
t.Fatalf("Serie nicht vollständig auf der Patientenseite")
}
m := regexp.MustCompile(`https://physio\.example\.org/kalender/([0-9a-f]{48})\.ics`).FindStringSubmatch(html)
if m == nil || !strings.Contains(html, "webcal://physio.example.org/kalender/") {
t.Fatal("Kalender-Link fehlt")
}
feed := "/kalender/" + m[1] + ".ics"
// Feed ohne Login abrufbar, korrektes Format, keine Gesundheitsdaten/Namen
anon := &http.Client{}
resp, err := anon.Get(ts.URL + feed)
if err != nil {
t.Fatal(err)
}
b, _ := io.ReadAll(resp.Body)
resp.Body.Close()
ics := string(b)
if resp.StatusCode != http.StatusOK || !strings.HasPrefix(resp.Header.Get("Content-Type"), "text/calendar") {
t.Fatalf("Feed: %d %s", resp.StatusCode, resp.Header.Get("Content-Type"))
}
for _, want := range []string{
"BEGIN:VCALENDAR", "SUMMARY:Krankengymnastik – Test", "Behandlung bei: Anna Schmidt",
`Hinweis: Handtuch\, mitbringen\; bitte`, `LOCATION:Musterstraße 1\, 50667 Köln`, "STATUS:CONFIRMED",
} {
if !strings.Contains(strings.ReplaceAll(ics, "\r\n ", ""), want) {
t.Errorf("Feed ohne %q", want)
}
}
if strings.Count(ics, "BEGIN:VEVENT") != 3 || strings.Contains(ics, "Kai Kalender") {
t.Error("Feed: falsche Anzahl Termine oder Patientenname enthalten")
}
// Absagen → STATUS:CANCELLED, SEQUENCE steigt
post(c, ts.URL+"/termin/1/absagen", nil)
resp, _ = anon.Get(ts.URL + feed)
b, _ = io.ReadAll(resp.Body)
resp.Body.Close()
if !strings.Contains(string(b), "STATUS:CANCELLED") || !strings.Contains(string(b), "SEQUENCE:1") {
t.Error("Absage nicht im Feed")
}
// Falscher Token → 404; Link erneuern macht alten Link ungültig
if resp, _ := anon.Get(ts.URL + "/kalender/" + strings.Repeat("0", 48) + ".ics"); resp.StatusCode != http.StatusNotFound {
t.Errorf("falscher Token: %d", resp.StatusCode)
}
post(c, ts.URL+"/patient/1/kalender-link", nil)
if resp, _ := anon.Get(ts.URL + feed); resp.StatusCode != http.StatusNotFound {
t.Errorf("alter Link nach Erneuern noch gültig: %d", resp.StatusCode)
}
// QR-Code, Druckliste, Übersicht
if code, svg := body(t, c, ts.URL+"/patient/1/kalender-qr.svg"); code != http.StatusOK || !strings.HasPrefix(svg, "<svg") {
t.Errorf("QR: %d", code)
}
if _, pr := body(t, c, ts.URL+"/patient/1/termine/druck"); !strings.Contains(pr, "Ihre Termine") || strings.Count(pr, "Anna Schmidt") < 3 {
t.Error("Druckliste unvollständig")
}
if _, ov := body(t, c, ts.URL+"/termine"); !strings.Contains(ov, "Kai Kalender") || !strings.Contains(ov, "abgesagt") {
t.Error("Übersicht unvollständig")
}
// Behandlungszusammenfassung nennt den nächsten Termin
post(c, ts.URL+"/patient/1/verlauf", url.Values{"date": {time.Now().Format("2006-01-02")}, "goal": {"Mobilisation"}})
r, _ = post(c, ts.URL+"/verlauf/1/zusammenfassung", nil)
if _, sum := body(t, c, ts.URL+r.Header.Get("Location")); !strings.Contains(sum, "um 09:30 Uhr bei Anna Schmidt") {
t.Error("nächster Termin fehlt in der Zusammenfassung")
}
}
▾Minternal/web/auth.go
@@ -99,7 +99,8 @@ func (s *Server) hasSession(r *http.Request) bool {
}
func publicPath(p string) bool {
return p == "/login" || p == "/healthz" || strings.HasPrefix(p, "/static/")
// /kalender/ ist der Abo-Feed für Kalender-Apps: geschützt durch den geheimen Token im Pfad.
return p == "/login" || p == "/healthz" || strings.HasPrefix(p, "/static/") || strings.HasPrefix(p, "/kalender/")
}
func (s *Server) requireAuth(next http.Handler) http.Handler {
▾Minternal/web/patients.go
@@ -111,6 +111,11 @@ func (s *Server) showPatient(w http.ResponseWriter, r *http.Request) {
s.fail(w, r, err)
return
}
appt, err := s.patientAppointmentData(r, id)
if err != nil {
s.fail(w, r, err)
return
}
// Verlauf der Kernwerte über alle Befunde (älteste zuerst)
type trendRow struct {
Date, Title string
@@ -129,7 +134,8 @@ func (s *Server) showPatient(w http.ResponseWriter, r *http.Request) {
s.render(w, r, "patient", http.StatusOK, map[string]any{
"P": p, "Befunde": bs, "Sessions": sessions, "Linked": linked, "Linkable": linkable,
"Handouts": handouts, "Trend": trend, "Today": time.Now().Format("2006-01-02"),
"Docs": docs, "DocCategories": store.DocCategories, "Summaries": summaries,
"Docs": docs, "DocCategories": store.DocCategories, "Summaries": summaries, "Appt": appt,
"Error": r.URL.Query().Get("fehler"),
})
}
▾Minternal/web/server.go
@@ -41,14 +41,14 @@ func New(ctx context.Context, cfg *config.Config, st *store.Store, log *slog.Log
if err := s.loadSessionSecret(ctx); err != nil {
return nil, err
}
for _, p := range []string{"index", "entry", "edit", "handouts", "handout", "login", "patients", "patient", "befund", "compare", "summary"} {
for _, p := range []string{"index", "entry", "edit", "handouts", "handout", "login", "patients", "patient", "befund", "compare", "summary", "appointments", "appointment"} {
t, err := template.New("").Funcs(funcs).ParseFS(physiobase.TemplatesFS, "web/templates/layout.html", "web/templates/"+p+".html")
if err != nil {
return nil, fmt.Errorf("template %s: %w", p, err)
}
s.pages[p] = t
}
for _, p := range []string{"print", "befund_print", "summary_print"} {
for _, p := range []string{"print", "befund_print", "summary_print", "appointments_print"} {
t, err := template.New("").Funcs(funcs).ParseFS(physiobase.TemplatesFS, "web/templates/"+p+".html")
if err != nil {
return nil, fmt.Errorf("template %s: %w", p, err)
@@ -120,6 +120,19 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("POST /zusammenfassung/{id}/loeschen", s.deleteSummary)
mux.HandleFunc("GET /zusammenfassung/{id}/druck", s.printSummary)
mux.HandleFunc("POST /zusammenfassung/{id}/senden", s.sendSummary)
mux.HandleFunc("GET /termine", s.listAppointments)
mux.HandleFunc("POST /termine", s.createAppointment)
mux.HandleFunc("GET /termin/{id}", s.showAppointment)
mux.HandleFunc("POST /termin/{id}", s.saveAppointment)
mux.HandleFunc("POST /termin/{id}/absagen", s.toggleCancel)
mux.HandleFunc("POST /termin/{id}/loeschen", s.deleteAppointment)
mux.HandleFunc("POST /behandler", s.createTherapist)
mux.HandleFunc("POST /behandler/{id}", s.updateTherapist)
mux.HandleFunc("GET /patient/{id}/kalender-qr.svg", s.calendarQR)
mux.HandleFunc("POST /patient/{id}/kalender-link", s.rotateCalendar)
mux.HandleFunc("GET /patient/{id}/termine/druck", s.printAppointments)
mux.HandleFunc("GET /kalender/{file}", s.calendarFeed)
mux.HandleFunc("GET /befund/{id}", s.showBefund)
mux.HandleFunc("POST /befund/{id}", s.saveBefund)
mux.HandleFunc("POST /befund/{id}/loeschen", s.deleteBefund)
@@ -196,6 +209,9 @@ var funcs = template.FuncMap{
},
"date": func(t time.Time) string { return t.Local().Format("02.01.2006") },
"add1": func(i int) int { return i + 1 },
"day": dayLabel,
"hm": func(t time.Time) string { return t.Local().Format("15:04") },
"ymd": func(t time.Time) string { return t.Local().Format("2006-01-02") },
// Diagnose eines Befunds für Listen
"diag": func(raw string) string { d, _ := befund.Parse(raw); return d.Get("diag") },
"cell": func(t, c string, i int) string { return fmt.Sprintf("t.%s.%d.%s", t, i, c) },
@@ -250,7 +266,7 @@ func (s *Server) render(w http.ResponseWriter, r *http.Request, page string, sta
data["Kinds"] = store.Kinds
data["Path"] = r.URL.Path
name := "layout"
if page == "print" || page == "befund_print" || page == "summary_print" {
if strings.HasSuffix(page, "print") {
name = page
}
var buf bytes.Buffer
▾Minternal/web/summaries.go
@@ -196,7 +196,21 @@ func (s *Server) sessionDraft(ctx context.Context, x store.Session) (string, err
d.line("- ")
d.note("z. B. Belastung dosieren, Schmerz bei Übungen bis 3–4 von 10 ist in Ordnung, am Folgetag nicht schlechter.")
d.section("Nächster Termin")
d.line("- ")
day, _ := time.ParseInLocation("2006-01-02", x.Date, time.Local)
next, found, err := s.st.NextAppointment(ctx, x.PatientID, day.Add(24*time.Hour-time.Second))
if err != nil {
return "", err
}
if found {
with := ""
if next.TherapistName != "" {
with = " bei " + next.TherapistName
}
d.line("- %s um %s Uhr%s", dayLabel(next.Start), next.Start.Format("15:04"), with)
} else {
d.line("- ")
d.note("Noch kein Folgetermin eingetragen.")
}
return d.String(), nil
}
▾Mweb/static/print.css
@@ -30,3 +30,16 @@ footer { margin-top: 1rem; font-size: 9.5pt; color: #555; border-top: 1px solid
.summary-body ul { margin: .2rem 0 .5rem; padding-left: 1.3rem; }
.summary-body li { margin: .15rem 0; }
.summary-body p { margin: .3rem 0; }
/* Terminliste */
.appt-print { width: 100%; border-collapse: collapse; font-size: 11.5pt; margin: .5rem 0 1rem; }
.appt-print th, .appt-print td { border: 1px solid #bbb; padding: .3rem .45rem; text-align: left; vertical-align: top; }
.appt-print th { background: #f2f7f6; }
.appt-print tr { break-inside: avoid; }
.appt-print tr.cancelled td { color: #888; text-decoration: line-through; }
.appt-print tr.cancelled td strong { text-decoration: none; color: #c0392b; }
.cal-box { display: flex; gap: 6mm; align-items: center; border: 1px solid #cfe5df; border-radius: 8px; padding: 4mm; break-inside: avoid; }
.cal-box .qr { width: 38mm; height: 38mm; flex: 0 0 auto; }
.cal-box h2 { font-size: 13pt; margin: 0 0 .3rem; color: #16a085; }
.cal-box p { margin: .2rem 0; }
.cal-box .url { font-family: ui-monospace, monospace; font-size: 8.5pt; color: #555; word-break: break-all; }
▾Mweb/static/style.css
@@ -224,3 +224,23 @@ label.consent { display: flex; gap: .5rem; align-items: flex-start; font-size: .
label.consent input { flex: 0 0 auto; margin-top: .2rem; }
.maillog { font-size: .88em; padding-left: 0; margin-top: .8rem; }
.maillog li { list-style: none; }
/* ---------- Termine ---------- */
.appt-filter > input, .appt-filter > select { flex: 1 1 10rem; }
.appt-nav { display: flex; justify-content: space-between; align-items: baseline; gap: .5rem; }
.appt-day h4 { margin-bottom: .5rem; }
.appt-day.today { border-left: 4px solid var(--pico-primary); }
.appt { display: flex; flex-wrap: wrap; gap: .3rem .8rem; align-items: baseline; padding: .4rem 0; border-bottom: 1px solid var(--pico-muted-border-color); }
.appt:last-child { border-bottom: 0; }
.appt-time { font-variant-numeric: tabular-nums; font-weight: 600; min-width: 6.5rem; }
.appt.cancelled { opacity: .6; }
.appt.cancelled .appt-time { text-decoration: line-through; }
.appt-actions { margin-left: auto; display: flex; gap: .6rem; align-items: baseline; }
.appt-fields { display: grid; grid-template-columns: repeat(auto-fill, minmax(11rem, 1fr)); gap: 0 .8rem; margin-top: .6rem; }
.appt-fields .wide { grid-column: 1 / -1; }
.appt-fields label { font-size: .88em; }
.past-appts { font-size: .88em; }
.cal-share { display: flex; gap: 1.2rem; align-items: flex-start; margin-top: 1rem; padding-top: 1rem; border-top: 1px solid var(--pico-muted-border-color); }
.cal-share .qr { width: 9rem; height: 9rem; flex: 0 0 auto; background: #fff; border-radius: .3rem; }
.cal-share input { font-size: .8em; }
@media (max-width: 576px) { .cal-share { flex-direction: column; align-items: center; } .appt-time { min-width: 0; } .appt-actions { margin-left: 0; } }
▾Aweb/templates/appointment.html
@@ -0,0 +1,34 @@
{{define "title"}}Termin bearbeiten{{end}}
{{define "content"}}
{{$a := .A}}
<nav aria-label="breadcrumb"><ul><li><a href="/termine">Termine</a></li><li><a href="/patient/{{$a.PatientID}}#termine">{{$a.PatientName}}</a></li><li>{{day $a.Start}}</li></ul></nav>
{{with .Error}}<p class="error" role="alert">{{.}}</p>{{end}}
<article>
<h2>Termin bearbeiten</h2>
<form method="post" action="/termin/{{$a.ID}}">
<div class="appt-fields">
<label>Datum <input type="date" name="date" value="{{ymd $a.Start}}" required></label>
<label>Uhrzeit <input type="time" name="time" value="{{hm $a.Start}}" step="300" required></label>
<label>Dauer (Min.) <input type="number" name="duration" value="{{$a.Duration}}" min="5" max="480" step="5"></label>
<label>Behandler
<select name="therapist"><option value="0">—</option>
{{range .Therapists}}{{if or .Active (eq .ID $a.TherapistID)}}<option value="{{.ID}}"{{if eq .ID $a.TherapistID}} selected{{end}}>{{.Name}}</option>{{end}}{{end}}
</select>
</label>
<label>Art <input name="title" value="{{$a.Title}}"></label>
<label>Ort <input name="location" value="{{$a.Location}}"></label>
<label class="wide">Hinweis für den Patienten <input name="note" value="{{$a.Note}}"></label>
</div>
<label class="consent"><input type="checkbox" name="cancelled" value="1"{{if $a.Cancelled}} checked{{end}}> Abgesagt</label>
<div class="actions"><button type="submit">Speichern</button><a href="/patient/{{$a.PatientID}}#termine">Abbrechen</a></div>
<small class="muted">Änderungen erscheinen im Kalender des Patienten beim nächsten Abgleich (meist innerhalb einer Stunde).</small>
</form>
</article>
<details class="danger">
<summary>Termin löschen …</summary>
<form method="post" action="/termin/{{$a.ID}}/loeschen">
<p>Besser „Abgesagt“ anhaken – dann sieht der Patient die Absage im Kalender. Gelöschte Termine verschwinden einfach.</p>
<button type="submit" class="danger">Endgültig löschen</button>
</form>
</details>
{{end}}
▾Aweb/templates/appointments.html
@@ -0,0 +1,84 @@
{{define "title"}}Termine{{end}}
{{define "content"}}
<h2>Termine</h2>
{{with .Error}}<p class="error" role="alert">{{.}}</p>{{end}}
<form method="get" action="/termine" class="row appt-filter">
<input type="date" name="von" value="{{.From}}" aria-label="Ab Datum">
<select name="behandler" aria-label="Behandler">
<option value="0">Alle Behandler</option>
{{range .Therapists}}<option value="{{.ID}}"{{if eq .ID $.Therapist}} selected{{end}}>{{.Name}}{{if not .Active}} (inaktiv){{end}}</option>{{end}}
</select>
<button type="submit" class="secondary">Anzeigen</button>
</form>
<p class="appt-nav">
<a href="/termine?von={{.Prev}}&amp;behandler={{.Therapist}}">← früher</a>
<span class="muted">{{.Count}} Termin(e) bis {{day .To}}</span>
<a href="/termine?von={{.Next}}&amp;behandler={{.Therapist}}">später →</a>
</p>
{{range .Days}}
<article class="appt-day{{if .Today}} today{{end}}">
<h4>{{.Label}}{{if .Today}} · heute{{end}}</h4>
{{range .Appts}}
<div class="appt{{if .Cancelled}} cancelled{{end}}">
<span class="appt-time">{{hm .Start}}–{{hm .End}}</span>
<a href="/patient/{{.PatientID}}#termine"><strong>{{.PatientName}}</strong></a>
<span class="muted">{{.Title}}{{with .TherapistName}} · {{.}}{{end}}{{if .Cancelled}} · abgesagt{{end}}</span>
<span class="appt-actions">
<a href="/termin/{{.ID}}" class="small-link">Bearbeiten</a>
<form method="post" action="/termin/{{.ID}}/absagen" class="inline"><input type="hidden" name="back" value="termine">
<button type="submit" class="link small-link">{{if .Cancelled}}Wieder zusagen{{else}}Absagen{{end}}</button></form>
</span>
</div>
{{end}}
</article>
{{else}}<p class="muted">Keine Termine in diesem Zeitraum.</p>{{end}}
<article>
<h3>Neuer Termin</h3>
{{if .Patients}}
<form method="post" action="/termine">
<select name="patient" required aria-label="Patient"><option value="">Patient wählen …</option>
{{range .Patients}}<option value="{{.ID}}">{{.Name}}{{with .Dob}} ({{.}}){{end}}</option>{{end}}
</select>
{{template "apptfields" .}}
<button type="submit">Termin anlegen</button>
</form>
{{else}}<p class="muted">Zuerst einen <a href="/patienten">Patienten anlegen</a>.</p>{{end}}
</article>
<article id="behandler">
<h3>Behandler</h3>
{{range .Therapists}}
<form method="post" action="/behandler/{{.ID}}" class="row">
<input name="name" value="{{.Name}}" required aria-label="Name">
<label class="consent"><input type="checkbox" name="active" value="1"{{if .Active}} checked{{end}}> aktiv</label>
<button type="submit" class="secondary outline">Speichern</button>
</form>
{{else}}<p class="muted">Noch keine Behandler eingetragen.</p>{{end}}
<form method="post" action="/behandler" class="row">
<input name="name" placeholder="Name, z. B. Anna Schmidt" required aria-label="Neuer Behandler">
<button type="submit">+ Behandler</button>
</form>
<small class="muted">Inaktive Behandler bleiben an alten Terminen erhalten, stehen aber nicht mehr zur Auswahl.</small>
</article>
{{end}}
{{define "apptfields"}}
<div class="appt-fields">
<label>Datum <input type="date" name="date" value="{{.Today}}" required></label>
<label>Uhrzeit <input type="time" name="time" value="08:00" step="300" required></label>
<label>Dauer (Min.) <input type="number" name="duration" value="30" min="5" max="480" step="5"></label>
<label>Behandler
<select name="therapist"><option value="0">—</option>
{{range .Therapists}}{{if .Active}}<option value="{{.ID}}">{{.Name}}</option>{{end}}{{end}}
</select>
</label>
<label>Art <input name="title" value="Physiotherapie" list="dl-appttypes"></label>
<label>Ort <input name="location" placeholder="{{if .Address}}{{.Address}}{{else}}Praxis / Hausbesuch{{end}}"></label>
<label class="wide">Hinweis für den Patienten <input name="note" placeholder="z. B. Handtuch und bequeme Kleidung mitbringen"></label>
<label>Wiederholen <input type="number" name="repeat" value="1" min="1" max="40"></label>
<label>alle … Tage <input type="number" name="every" value="7" min="1" max="60"></label>
</div>
<datalist id="dl-appttypes"><option value="Physiotherapie"><option value="Krankengymnastik"><option value="Manuelle Therapie"><option value="Klassische Massage"><option value="Lymphdrainage"><option value="Befundaufnahme"><option value="Wiederbefund"><option value="Hausbesuch"></datalist>
{{end}}
▾Aweb/templates/appointments_print.html
@@ -0,0 +1,41 @@
{{define "appointments_print"}}<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Termine – {{.P.Name}}</title>
<link rel="stylesheet" href="/static/print.css">
</head>
<body class="summary">
<p class="hint">Drucken oder als PDF speichern: <kbd>Strg</kbd>+<kbd>P</kbd>. · <a href="/patient/{{.P.ID}}#termine">zurück</a>
{{if not .Public}}<br><strong>Achtung:</strong> PHYSIOBASE_PUBLIC_URL ist nicht gesetzt – der Kalender-Link funktioniert nur im Praxisnetz.{{end}}</p>
<header>
<div>
<h1>Ihre Termine</h1>
<p class="for">für {{.P.Name}}</p>
</div>
<div class="meta"><img src="/static/logo.webp" alt="" class="logo"><br>{{.Practice}}{{with .Address}}<br>{{.}}{{end}}<br>Stand {{date .Today}}</div>
</header>
{{if .Appts}}
<table class="appt-print">
<thead><tr><th>Tag</th><th>Uhrzeit</th><th>Behandlung</th><th>bei</th><th>Hinweis</th></tr></thead>
<tbody>
{{range .Appts}}
<tr{{if .Cancelled}} class="cancelled"{{end}}><td>{{day .Start}}</td><td>{{hm .Start}}–{{hm .End}} Uhr</td>
<td>{{.Title}}{{if .Cancelled}} – <strong>abgesagt</strong>{{end}}{{with .Location}}<br><small>{{.}}</small>{{end}}</td>
<td>{{.TherapistName}}</td><td>{{.Note}}</td></tr>
{{end}}
</tbody>
</table>
{{else}}<p>Zurzeit sind keine Termine eingetragen.</p>{{end}}
<section class="cal-box">
<img src="/patient/{{.P.ID}}/kalender-qr.svg" alt="QR-Code Kalender-Abo" class="qr">
<div>
<h2>Termine im Handy-Kalender</h2>
<p>QR-Code mit der Kamera scannen und den Kalender abonnieren. Neue Termine, Änderungen und Absagen erscheinen dann automatisch.</p>
<p class="url">{{.CalURL}}</p>
</div>
</section>
<footer>Bitte sagen Sie Termine, die Sie nicht wahrnehmen können, mindestens 24 Stunden vorher ab.</footer>
</body>
</html>{{end}}
▾Mweb/templates/layout.html
@@ -17,6 +17,7 @@
<ul>
<li><a href="/"{{if eq .Path "/"}} aria-current="page"{{end}}>Wissen</a></li>
<li><a href="/patienten"{{if eq .Path "/patienten"}} aria-current="page"{{end}}>Patienten</a></li>
<li><a href="/termine"{{if eq .Path "/termine"}} aria-current="page"{{end}}>Termine</a></li>
<li><a href="/handouts"{{if eq .Path "/handouts"}} aria-current="page"{{end}}>Handouts</a></li>
<li>
<details class="dropdown">
▾Mweb/templates/patient.html
@@ -47,6 +47,65 @@
</article>
{{end}}
{{with .Error}}<p class="error" role="alert">{{.}}</p>{{end}}
<article id="termine">
<h3>Termine</h3>
{{with .Appt.Upcoming}}
<div class="appts">
{{range .}}
<div class="appt{{if .Cancelled}} cancelled{{end}}">
<span class="appt-time">{{day .Start}} · {{hm .Start}}–{{hm .End}}</span>
<span>{{.Title}}{{with .TherapistName}} · {{.}}{{end}}{{if .Cancelled}} · <strong>abgesagt</strong>{{end}}</span>
<span class="appt-actions">
<a href="/termin/{{.ID}}" class="small-link">Bearbeiten</a>
<form method="post" action="/termin/{{.ID}}/absagen" class="inline"><button type="submit" class="link small-link">{{if .Cancelled}}Wieder zusagen{{else}}Absagen{{end}}</button></form>
</span>
</div>
{{end}}
</div>
{{else}}<p class="muted">Keine anstehenden Termine.</p>{{end}}
{{with .Appt.Past}}<details><summary>Vergangene Termine ({{len .}})</summary>
<ul class="past-appts">{{range .}}<li>{{day .Start}} {{hm .Start}} · {{.Title}}{{with .TherapistName}} · {{.}}{{end}}{{if .Cancelled}} · abgesagt{{end}}</li>{{end}}</ul>
</details>{{end}}
<details>
<summary>+ Termin / Terminserie anlegen</summary>
<form method="post" action="/termine">
<input type="hidden" name="patient" value="{{$p.ID}}"><input type="hidden" name="back" value="patient">
<div class="appt-fields">
<label>Datum <input type="date" name="date" value="{{.Today}}" required></label>
<label>Uhrzeit <input type="time" name="time" value="08:00" step="300" required></label>
<label>Dauer (Min.) <input type="number" name="duration" value="30" min="5" max="480" step="5"></label>
<label>Behandler
<select name="therapist"><option value="0">—</option>{{range .Appt.Therapists}}<option value="{{.ID}}">{{.Name}}</option>{{end}}</select>
</label>
<label>Art <input name="title" value="Physiotherapie"></label>
<label>Ort <input name="location" placeholder="{{if .Appt.Address}}{{.Appt.Address}}{{else}}Praxis / Hausbesuch{{end}}"></label>
<label class="wide">Hinweis für den Patienten <input name="note" placeholder="z. B. Handtuch mitbringen"></label>
<label>Wiederholen <input type="number" name="repeat" value="1" min="1" max="40"></label>
<label>alle … Tage <input type="number" name="every" value="7" min="1" max="60"></label>
</div>
<button type="submit">Anlegen</button>
{{if not .Appt.Therapists}}<small class="muted">Behandler werden unter <a href="/termine#behandler">Termine</a> eingetragen.</small>{{end}}
</form>
</details>
<div class="cal-share">
<img src="/patient/{{$p.ID}}/kalender-qr.svg" alt="QR-Code für das Kalender-Abo" class="qr">
<div>
<strong>Termine im Kalender des Patienten</strong>
<p class="muted">QR-Code scannen lassen oder den Link senden. Neue Termine, Änderungen und Absagen kommen automatisch an. Der Kalender enthält nur Zeiten, Behandler, Ort und Hinweis – keine Diagnosen.</p>
{{if not .Appt.CalPublic}}<p class="error">PHYSIOBASE_PUBLIC_URL ist nicht gesetzt: Der Link funktioniert nur im Praxisnetz.</p>{{end}}
<p><a href="{{.Appt.Webcal}}">Kalender abonnieren (webcal)</a> · <a href="{{.Appt.CalURL}}">.ics-Datei</a></p>
<input readonly value="{{.Appt.CalURL}}" aria-label="Kalender-Link" class="mono">
<div class="actions">
<a role="button" class="secondary" href="/patient/{{$p.ID}}/termine/druck" target="_blank">Terminliste drucken</a>
<form method="post" action="/patient/{{$p.ID}}/kalender-link" class="inline"><button type="submit" class="outline secondary" title="Der alte Link funktioniert danach nicht mehr">Link erneuern</button></form>
</div>
</div>
</div>
</article>
<article id="verlauf">
<h3>Behandlungsverlauf</h3>
<form method="post" action="/patient/{{$p.ID}}/verlauf" class="session-form">