Karten-Akkordeon fuer rtable: nur eine Karte offen, Rest gestapelt

Auf Tablet/Handy sollen die Tabellen-zu-Karten-Ansichten weniger
Scrollen erfordern: jetzt ist immer nur eine Karte aufgeklappt, alle
anderen sind auf eine kompakte Kopfzeile reduziert und stapeln sich
eng darunter.

- Neue rtInit()-Helper-Funktion: erste Zeile startet offen, Klick auf
  eine Zeile oeffnet sie und schliesst alle anderen (Klicks auf
  verschachtelte Buttons/Links/Inputs/.tl loesen die Akkordeon-Logik
  nicht aus, damit Aktionen weiter unabhaengig funktionieren)
- Erste data-label-Zelle pro Tabelle als .rt-head markiert (mit
  Chevron-Icon), bleibt immer sichtbar; restliche data-label-Zellen
  nur bei .rt-open sichtbar. Zellen ohne data-label (Aktions-Buttons)
  bleiben immer erreichbar, auch bei zugeklappter Karte.
- rtInit() nach jedem rtable-Render aufgerufen: pgDash, renderPT
  (inkl. Filter-Refresh), pgAufgaben, renderAdminDash, loadPat
- Bugfix unterwegs: Chevron-Icon leckte in die normale Desktop/Tablet-
  Tabelle (display:none als Basis ergaenzt, nur im 700px-Breakpoint
  sichtbar) - vor dem Fix bei 820px Tablet-Breite als stoerendes Icon
  unter jedem Namen sichtbar

Verifiziert per Playwright auf 390px (Handy) und 820px (Tablet):
Standardzustand (erste Karte offen), Wechsel beim Klick auf andere
Karte, saubere Tabelle ohne Akkordeon-Artefakte oberhalb 700px.
This commit is contained in:
admin committed 2026-07-18 12:49:08 +02:00
1 parent 9ebb8f008a
commit 8dd141ea35
2 files changed
+42 -7

No files matched your search

+1
View File
@@ -69,6 +69,7 @@ The frontend was originally desktop-only (zero `@media` queries, fixed 228px sid
- **Glass design system**: `:root` defines a dedicated token set — `--glass`/`--glass-strong` (translucent white fills), `--glass-border`, `--glass-blur`, `--glass-shadow`/`--glass-shadow-lg`, plus four `--bento-*` gradient tokens (mint/lavender/peach/rose) for tile accents. `body` has a fixed multi-blob `radial-gradient` background (`background-attachment:fixed`) that the translucent, `backdrop-filter:blur(...)` cards/sidebar/topbar/modal/login-box float over. Regular form inputs stay solid (`var(--surface)`, opaque white) — only container-level chrome (`.card`, `.modal`, `.sb`, `.topbar`, `.lo-box`) is glassy, intentionally, for text legibility. Always pair `backdrop-filter` with `-webkit-backdrop-filter` (Safari).
- **Dashboard is a real Bento grid**, not just a relabeled stat row: `.bento` is a 4-column CSS grid with **explicit** `grid-column`/`grid-row` line placement per tile class (`.bento-stat`, `.bento-hero`, `.bento-aufg`, `.bento-quick`) — deliberately not relying on grid auto-placement, which does not reliably fill remaining same-row cells the way you'd expect when items of different spans are mixed. If you add/resize tiles, keep using explicit line numbers (or verify auto-placement with the debug technique below) rather than assuming `span N` will pack tiles the way you expect.
- **Responsive tables → cards**: any live-UI (non-PDF-export) table that needs to stay usable at phone width gets `class="tw rtable"` on its wrapper and `data-label="…"` on every `<td>`. Below 700px, `.rtable` CSS turns each `<tr>` into its own glass card (label stacked above value per cell, via `td[data-label]::before{content:attr(data-label)}`) instead of squeezing table columns. Applied to: Dashboard's recent-patients table, `renderPT` (Patientenliste), `pgAufgaben`, `renderAdminDash`'s Konten table, and the Patientenakte Anträge table. Follow this pattern for any new data table — don't ship a raw `<table>` without it.
- **Accordion on the card view**: within `.rtable`, exactly one row is expanded at a time — the rest collapse to a single header line (`td.rt-head`, marked explicitly per table since it's usually the first `data-label` cell) and stack tightly, so scanning many rows needs far less scrolling. `rtInit()` (shared helper, defined near `escH`) wires this up: on load the first row gets `.rt-open`; clicking any row's header toggles it open and closes the others (clicks on nested `button,a,select,input,textarea,.tl` are ignored so action buttons/links inside a row still work independently of the accordion). **Every function that renders an `.rtable` must call `rtInit()` after the DOM is in place** (see `pgDash`, `renderPT`, `pgAufgaben`, `renderAdminDash`, `loadPat`) — a new table using this pattern that forgets the call will render but never expand/collapse. Cells without `data-label` (action-button columns) are never hidden by the accordion, so those buttons stay reachable even on a collapsed row. The `.rt-chev` chevron icon is `display:none` by default and only shown inside the `max-width:700px` breakpoint — without that base rule it leaks into the plain desktop/tablet `<table>` as a stray icon.
- **Mobile/tablet nav**: below 900px the `.sb` sidebar becomes a fixed off-canvas panel (`transform:translateX(-100%)`, slides in via `.sb.open`), toggled by a hamburger button (`#sb-toggle`, only visible in that breakpoint) and a `#sb-backdrop` overlay that closes it on tap. `toggleSidebar()`/`closeSidebar()` manage this; every nav-item click auto-closes it. Form grids (`.fg.c2/.c3`), stat grids (`.g4`), and the bento grid collapse to fewer columns at 900px/700px/560px. Modals go full-screen below 560px. Form inputs are forced to `font-size:16px` on phones to stop iOS Safari's auto-zoom-on-focus.
- **Verification gotcha #1 — internal scroll**: `.content` scrolls internally (`overflow-y:auto` inside a `height:100vh` shell), so Playwright's `page.screenshot({fullPage:true})` does **not** capture content below the fold — only the viewport. Scroll `.content` itself (`document.querySelector('.content').scrollTop = ...`) before screenshotting to see the rest.
- **Verification gotcha #2 — stale template cache**: `start.py` runs with `debug=False`, so Flask's `TEMPLATES_AUTO_RELOAD` is off and Jinja caches the compiled `index.html` **in memory for the life of the process**, from the first request after startup. Editing the template while a `python start.py` process is still running from before your edit is invisible to the browser no matter how many times you reload — you'll see stale HTML/JS and it's easy to misdiagnose as a CSS/JS bug. **Always restart the server after editing `templates/index.html`** before taking verification screenshots or you'll debug a ghost.
@@ -154,15 +154,26 @@ body{
.b-out{background:transparent;border:1px solid var(--border2);color:var(--muted)}
.tw{overflow-x:auto}
/* ── Responsive Tabellen: werden unter 700px zu Karten (rtable) ── */
.rt-chev{display:none}
@media (max-width:700px){
.rtable table,.rtable thead,.rtable tbody,.rtable tr,.rtable td{display:block;width:100%}
.rtable thead{display:none}
.rtable tr{background:var(--glass);-webkit-backdrop-filter:blur(var(--glass-blur));backdrop-filter:blur(var(--glass-blur));border:1px solid var(--glass-border);border-radius:var(--r);margin-bottom:10px;padding:4px 12px;box-shadow:var(--glass-shadow)}
.rtable tr{background:var(--glass);-webkit-backdrop-filter:blur(var(--glass-blur));backdrop-filter:blur(var(--glass-blur));border:1px solid var(--glass-border);border-radius:var(--r);margin-bottom:6px;padding:10px 12px;box-shadow:var(--glass-shadow);cursor:pointer}
.rtable tr:last-child{margin-bottom:0}
.rtable td{border:none;border-bottom:1px solid var(--border);padding:8px 0}
.rtable td:last-child{border-bottom:none}
.rtable td:empty{display:none}
.rtable td[data-label]::before{content:attr(data-label);display:block;font-size:10px;font-weight:600;color:var(--hint);text-transform:uppercase;letter-spacing:.04em;margin-bottom:3px}
/* Akkordeon: nur die offene Karte zeigt alle Felder, der Rest ist auf
die Kopfzeile zusammengeklappt und stapelt sich eng - weniger Scrollen. */
.rtable tr td[data-label]{display:none}
.rtable tr td.rt-head{display:flex!important;align-items:center;justify-content:space-between;gap:8px;border-bottom:none;padding:0}
.rtable tr td.rt-head::before{display:none}
.rtable tr .rt-chev{display:inline-block;font-size:16px;color:var(--hint);transition:transform .18s;flex-shrink:0}
.rtable tr.rt-open{padding-bottom:2px;box-shadow:var(--glass-shadow-lg);border-color:var(--green)}
.rtable tr.rt-open td[data-label]{display:block}
.rtable tr.rt-open td.rt-head{border-bottom:1px solid var(--border);padding:0 0 8px;margin-bottom:6px}
.rtable tr.rt-open .rt-chev{transform:rotate(180deg);color:var(--green)}
}
table{width:100%;border-collapse:collapse}
th{text-align:left;padding:8px 11px;font-size:11.5px;font-weight:500;color:var(--muted);border-bottom:1px solid var(--border)}
@@ -452,6 +463,24 @@ function toFilename(s){return(s||'').replace(/ä/g,'ae').replace(/ö/g,'oe').rep
function isoDate(){return new Date().toISOString().slice(0,10);}
function toast(m,ok=true){const t=document.createElement('div');t.className='toast';t.innerHTML='<i class="ti ti-'+(ok?'check':'alert-triangle')+'"></i>'+m;document.body.appendChild(t);setTimeout(()=>t.remove(),2800);}
function escH(s){return(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
// Responsive Tabellen-Akkordeon (Mobile/Tablet): erste Karte offen, Rest
// zugeklappt/gestapelt; Klick auf eine Karte öffnet sie und schliesst die
// anderen. Nach jedem rtable-Render aufrufen (Filter/Refresh bauen neue
// <tr>-Knoten, daher jedes Mal neu initialisieren statt einmalig).
function rtInit(){
document.querySelectorAll('.rtable tbody').forEach(tbody=>{
const rows=tbody.querySelectorAll('tr');
rows.forEach((tr,i)=>{
if(i===0)tr.classList.add('rt-open');
tr.onclick=e=>{
if(e.target.closest('button,a,select,input,textarea,.tl'))return;
const wasOpen=tr.classList.contains('rt-open');
rows.forEach(r=>r.classList.remove('rt-open'));
if(!wasOpen)tr.classList.add('rt-open');
};
});
});
}
function editNotizen(pid){
const el=document.getElementById('notiz-display-'+pid);
const current=el?el.dataset.notiz:'';
@@ -1255,7 +1284,7 @@ function renderAdminDash(nutzer,log,patienten){
:'<span class="badge b-gr">kein Zugang</span>');
return '<tr>'+
'<td>'+av(n.name||n.benutzername,n.id)+'</td>'+
'<td data-label="Name"><div class="w5">'+escH(n.name||n.benutzername)+'</div><div class="sm muted">@'+escH(n.benutzername)+'</div></td>'+
'<td data-label="Name" class="rt-head"><div><div class="w5">'+escH(n.name||n.benutzername)+'</div><div class="sm muted">@'+escH(n.benutzername)+'</div></div><i class="ti ti-chevron-down rt-chev"></i></td>'+
'<td data-label="Rolle">'+(n.rolle==='admin'?'<span class="badge b-b">Admin</span>':'<span class="badge b-gr">Nutzer</span>')+'</td>'+
'<td data-label="Status">'+(gesperrt?'<span class="badge b-r">Gesperrt</span>':'<span class="badge b-g">Aktiv</span>')+'</td>'+
'<td data-label="Patienten">'+n.patienten_anzahl+'</td>'+
@@ -1300,6 +1329,7 @@ function renderAdminDash(nutzer,log,patienten){
logRows+
'</div>',
'Admin-Dashboard');
rtInit();
}
function adminNutzerModal(uid,name,aktion){
const titel={sperren:'Konto sperren',entsperren:'Konto entsperren',loeschen:'Konto löschen'}[aktion];
@@ -1382,7 +1412,7 @@ async function pgDash(){
'<div class="card bento-hero"><div class="ch"><span class="ct">Aktuelle Patienten</span><button class="btn btn-sm" onclick="go(\'patienten\')">Alle</button></div>'+
'<div class="tw rtable"><table><thead><tr><th>Name</th><th>Grad</th><th>Zielgruppe</th><th>Status</th></tr></thead><tbody>'+
d.recent.map((p,i)=>'<tr><td data-label="Name"><div class="fl ic g8">'+av(p.vorname+' '+p.nachname,i)+'<span class="tl" onclick="loadPat('+p.id+')">'+p.vorname+' '+p.nachname+'</span></div></td><td data-label="Grad">'+
d.recent.map((p,i)=>'<tr><td data-label="Name" class="rt-head"><div class="fl ic g8">'+av(p.vorname+' '+p.nachname,i)+'<span class="tl" onclick="loadPat('+p.id+')">'+p.vorname+' '+p.nachname+'</span></div><i class="ti ti-chevron-down rt-chev"></i></td><td data-label="Grad">'+
(p.pflegegrad?'<span class="badge b-out">Grad '+p.pflegegrad+'</span>':'<span class="muted sm">–</span>')+'</td><td data-label="Zielgruppe">'+zg_badge(p.zielgruppe||'alterspflege')+'</td><td data-label="Status">'+bdg(p.status||'offen')+'</td></tr>').join('')+
'</tbody></table></div></div>'+
@@ -1403,6 +1433,7 @@ async function pgDash(){
'Dashboard',
'<button class="btn btn-p" onclick="go(\'neu\')"><i class="ti ti-user-plus"></i>Neuer Patient</button>');
checkTagebuchReminder();
rtInit();
}
// ── Patienten ─────────────────────────────────────────────
@@ -1425,10 +1456,10 @@ function renderPT(data){
const archiv=p.patient_status&&p.patient_status!=='aktiv';
const stLabel={verstorben:'Verstorben',verzogen:'Verzogen',archiviert:'Archiviert'};
return '<tr'+(archiv?' style="opacity:.72"':'')+'>'+
'<td data-label="Name"><div class="fl ic g8">'+av(p.vorname+' '+p.nachname,i)+
'<td data-label="Name" class="rt-head"><div class="fl ic g8">'+av(p.vorname+' '+p.nachname,i)+
'<div><span class="tl" onclick="loadPat('+p.id+')">'+p.vorname+' '+p.nachname+'</span>'+
(archiv?'<span class="badge b-gr" style="margin-left:6px;font-size:10px">'+(stLabel[p.patient_status]||p.patient_status)+'</span>':'')+
'</div></div></td>'+
'</div></div><i class="ti ti-chevron-down rt-chev"></i></td>'+
'<td data-label="Geburtsdatum">'+fdate(p.geburtsdatum)+'</td>'+
'<td data-label="Krankenkasse">'+(p.krankenkasse||'–')+'</td>'+
'<td data-label="Grad">'+(p.pflegegrad?'<span class="badge b-out">Grad '+p.pflegegrad+'</span>':'<span class="muted">–</span>')+'</td>'+
@@ -1439,6 +1470,7 @@ function renderPT(data){
'</div></td></tr>';
}).join('')+
'</tbody></table></div>');
rtInit();
}
async function filterPat(){
let url='/api/patienten?';
@@ -1502,7 +1534,7 @@ async function loadPat(id){
'<div class="card"><div class="ch"><span class="ct">Anträge</span><button class="btn btn-sm" onclick="go(\'antrag\','+id+')"><i class="ti ti-plus"></i>Neu</button></div>'+
(d.antraege.length===0?'<p class="muted sm">Noch kein Antrag.</p>':
'<div class="tw rtable"><table><thead><tr><th>Typ</th><th>Datum</th><th>Grad</th><th>Status</th><th>Dokument</th><th></th></tr></thead><tbody>'+
d.antraege.map(a=>'<tr><td data-label="Typ"><span class="tag">'+a.typ+'</span></td><td data-label="Datum">'+fdate(a.antragsdatum)+'</td><td data-label="Grad">'+(a.beantragter_pflegegrad?'Grad '+a.beantragter_pflegegrad:'–')+'</td><td data-label="Status">'+bdg(a.status)+'</td><td data-label="Dokument">'+(a.pdf_pfad?'<a href="/static/'+a.pdf_pfad+'" target="_blank" class="tl" style="white-space:nowrap"><i class="ti ti-file-type-pdf"></i> PDF öffnen</a>':'–')+'</td>'+
d.antraege.map(a=>'<tr><td data-label="Typ" class="rt-head"><span class="tag">'+a.typ+'</span><i class="ti ti-chevron-down rt-chev"></i></td><td data-label="Datum">'+fdate(a.antragsdatum)+'</td><td data-label="Grad">'+(a.beantragter_pflegegrad?'Grad '+a.beantragter_pflegegrad:'–')+'</td><td data-label="Status">'+bdg(a.status)+'</td><td data-label="Dokument">'+(a.pdf_pfad?'<a href="/static/'+a.pdf_pfad+'" target="_blank" class="tl" style="white-space:nowrap"><i class="ti ti-file-type-pdf"></i> PDF öffnen</a>':'–')+'</td>'+
'<td><button class="btn btn-sm" onclick="bescheidModal('+a.id+','+id+','+(a.beantragter_pflegegrad||1)+',\''+a.typ+'\')"><i class="ti ti-edit"></i>Bescheid</button></td></tr>').join('')+'</tbody></table></div>')+
'</div>'+
'<div class="card"><div class="ch"><span class="ct">Aufgaben</span></div>'+
@@ -1538,6 +1570,7 @@ async function loadPat(id){
'<button class="btn" style="margin-left:auto;color:var(--amtx);border-color:var(--amtx)" onclick="archivierenModal('+id+',\''+p.vorname+' '+p.nachname+'\')"><i class="ti ti-archive"></i>Archivieren</button>'+
'</div>',
p.vorname+' '+p.nachname);
rtInit();
// Tagebuch-Vorschau laden
api('/api/patienten/'+id+'/tagebuch').then(entries=>{
const el=document.getElementById('tbsummary');if(!el)return;
@@ -2553,12 +2586,13 @@ async function pgAufgaben(){
'<button class="btn btn-sm" onclick="newAM()"><i class="ti ti-plus"></i>Neue Aufgabe</button></div>'+
(data.length===0?'<p class="muted sm">Keine offenen Aufgaben – alles erledigt!</p>':
'<div class="tw rtable"><table><thead><tr><th>Aufgabe</th><th>Patient</th><th>Fällig</th><th>Priorität</th><th></th></tr></thead><tbody>'+
data.map(a=>'<tr><td data-label="Aufgabe"><div class="w5">'+a.titel+'</div>'+(a.beschreibung?'<div class="sm muted">'+a.beschreibung+'</div>':'')+
data.map(a=>'<tr><td data-label="Aufgabe" class="rt-head"><div><div class="w5">'+a.titel+'</div>'+(a.beschreibung?'<div class="sm muted">'+a.beschreibung+'</div>':'')+'</div><i class="ti ti-chevron-down rt-chev"></i>'+
'</td><td data-label="Patient">'+(a.patient_name?'<span class="tl" onclick="loadPat('+a.patient_id+')">'+a.patient_name+'</span>':'<span class="muted">–</span>')+
'</td><td data-label="Fällig">'+fdate(a.faellig_am)+'</td><td data-label="Priorität">'+prio(a.prioritaet)+
'</td><td><button class="btn btn-sm btn-p" onclick="erlA('+a.id+')"><i class="ti ti-check"></i>Erledigt</button></td></tr>').join('')+
'</tbody></table></div>')+
'</div>','Offene Aufgaben');
rtInit();
}
async function erlA(id){
await api('/api/aufgaben/'+id+'/erledigt',{method:'POST'});