DiagnostikApps/pseudonym-generator.html
2026-09-22 18:35:43 +02:00

1437 lines
64 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Diagnostik</title>
<link rel="icon" type="image/x-icon" href="faveicon.ico">
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, sans-serif; font-size: 14px; background: #f5f5f5; color: #222; padding: 20px 24px; }
h1 { font-size: 18px; font-weight: 600; color: #8b2635; margin-bottom: 14px; }
/* ---- Buttons ---- */
button { padding: 7px 14px; border: none; border-radius: 4px; cursor: pointer; font-size: 13px; font-weight: 500; }
.btn-p { background: #8b2635; color: #fff; }
.btn-p:hover { background: #7b2030; }
.btn-p:disabled { background: #c9a0a5; cursor: default; }
.amdp-btn { display: inline-block; padding: 7px 14px; border: none; border-radius: 4px; font-size: 13px; font-weight: 500; line-height: 1.4; cursor: pointer; text-decoration: none; background: #8b2635; color: #fff; }
.amdp-btn:hover { background: #7b2030; }
.amdp-btn:disabled { background: #c9a0a5; cursor: default; }
.btn-s { background: #e0e0e0; color: #333; }
.btn-s:hover { background: #d0d0d0; }
.btn-x { background: transparent; color: #bbb; font-size: 16px; padding: 2px 6px; line-height: 1; border: none; }
.btn-x:hover { color: #8b2635; }
.btn-copy { background: transparent; border: 1px solid #ccc; color: #666; padding: 2px 7px; font-size: 12px; border-radius: 3px; cursor: pointer; }
.btn-copy:hover { border-color: #8b2635; color: #8b2635; }
.btn-copy.done { border-color: #4a4; color: #4a4; }
/* Icon-Buttons (Kopieren) - feste, quadratische Breite, damit der Wechsel Icon <-> Haekchen
beim Kopieren keinen Layout-Sprung verursacht (Emoji und ✓ sind sonst unterschiedlich breit). */
.btn-copy-icon { width: 20px; padding: 2px 0; font-size: 11px; text-align: center; }
.btn-icon { background: transparent; border: 1px solid #ddd; color: #777; padding: 3px 7px; font-size: 12px; border-radius: 4px; cursor: pointer; line-height: 1.4; }
.btn-icon:hover { border-color: #8b2635; color: #8b2635; }
.status-ok { font-size: 12px; color: #4a4; }
.status-err { font-size: 12px; color: #c0392b; }
/* ---- Toolbar ---- */
.toolbar { display: flex; gap: 8px; margin-bottom: 14px; flex-wrap: wrap; align-items: center; }
.search { padding: 6px 10px; border: 1px solid #ccc; border-radius: 4px; font-size: 13px; width: 180px; }
.search:focus { outline: none; border-color: #8b2635; }
.count { font-size: 12px; color: #999; }
.link-btn { text-decoration: none; padding: 7px 14px; border-radius: 4px; font-size: 13px; font-weight: 500; background: #e0e0e0; color: #333; }
.link-btn:hover { background: #d0d0d0; }
.settings-btn { background: transparent; border: 1px solid #ddd; padding: 6px 12px; font-size: 13px; border-radius: 4px; cursor: pointer; color: #777; margin-left: auto; }
.settings-btn:hover { border-color: #888; color: #333; }
/* ---- Tabs ---- */
.tabs { display: flex; gap: 4px; margin-bottom: 10px; }
.tab-btn { background: transparent; border: 1px solid #ddd; color: #777; padding: 6px 14px; font-size: 13px; border-radius: 4px; cursor: pointer; }
.tab-btn:hover { color: #8b2635; }
.tab-btn.active { background: #fff; border-color: #8b2635; color: #8b2635; font-weight: 600; }
.tab-btn .tab-count { font-weight: 400; color: #999; margin-left: 3px; }
/* ---- Modal overlay ---- */
.modal-overlay {
position: fixed; inset: 0; z-index: 500;
background: rgba(0,0,0,.45);
display: none; align-items: flex-start; justify-content: center;
padding-top: 60px;
}
.modal-overlay.open { display: flex; }
.modal {
background: #fff; border-radius: 8px;
box-shadow: 0 4px 32px rgba(0,0,0,.28);
width: min(760px, 96vw); max-height: calc(100vh - 100px);
overflow-y: auto; display: flex; flex-direction: column;
}
.modal-header {
display: flex; justify-content: space-between; align-items: center;
padding: 14px 20px 12px; border-bottom: 1px solid #eee;
position: sticky; top: 0; background: #fff; z-index: 1; flex-shrink: 0;
}
.modal-header h2 { font-size: 15px; font-weight: 700; color: #8b2635; }
.modal-close { background: transparent; border: none; font-size: 22px; color: #bbb; cursor: pointer; padding: 0 2px; line-height: 1; }
.modal-close:hover { color: #8b2635; }
.modal-body { padding: 16px 20px 20px; }
/* ---- Settings sections ---- */
.panel-section { margin-bottom: 16px; }
.panel-section + .panel-section { border-top: 1px solid #eee; padding-top: 16px; }
.panel-section h3 { font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: .06em; color: #8b2635; margin-bottom: 12px; }
.panel-section summary { font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: .06em; color: #8b2635; cursor: pointer; user-select: none; }
.panel-section[open] summary { margin-bottom: 12px; }
.panel-section summary::-webkit-details-marker { color: #8b2635; }
.fg-row { display: flex; gap: 10px; flex-wrap: wrap; align-items: flex-end; margin-bottom: 10px; }
.fg { display: flex; flex-direction: column; gap: 3px; }
.fg label { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: .04em; color: #777; }
.fg input, .fg select { padding: 5px 9px; border: 1px solid #ccc; border-radius: 4px; font-size: 13px; color: #222; }
.fg input:focus { outline: none; border-color: #8b2635; }
.secret-row { display: flex; gap: 6px; align-items: center; }
.secret-hint { font-size: 12px; color: #999; }
/* ---- Runs list ---- */
.runs-list { margin-top: 10px; display: flex; flex-direction: column; gap: 6px; }
.run-item { display: flex; align-items: center; justify-content: space-between; gap: 8px; background: #f8f8f8; border: 1px solid #eee; border-radius: 4px; padding: 6px 10px; }
.run-item-info { display: flex; flex-direction: column; gap: 1px; flex: 1; min-width: 0; }
.run-item-name { font-size: 12px; font-weight: 600; color: #8b2635; font-family: monospace; }
.run-item-label { font-size: 12px; color: #666; }
.run-item-kats { display: flex; flex-wrap: wrap; gap: 3px; margin-top: 3px; }
.run-item-kat { display: inline-block; font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: .04em; color: #7b5800; background: #fff8e1; border: 1px solid #ffe082; border-radius: 3px; padding: 1px 5px; }
.run-item-shiny { display: inline-block; font-size: 10px; font-weight: 600; color: #2e6b8b; background: #e8f2f9; border: 1px solid #a9d3e8; border-radius: 3px; padding: 1px 5px; margin-top: 3px; align-self: flex-start; }
.run-item-actions { display: flex; gap: 4px; align-items: center; flex-shrink: 0; }
.run-item-edit { display: flex; align-items: center; gap: 6px; flex: 1; }
.run-item-edit input { padding: 4px 7px; border: 1px solid #ccc; border-radius: 4px; font-size: 12px; color: #222; }
.run-item-edit input:focus { outline: none; border-color: #8b2635; }
.runs-empty { font-size: 12px; color: #bbb; }
/* ---- New-entry form ---- */
.form-row { display: flex; gap: 10px; flex-wrap: wrap; align-items: flex-end; margin-bottom: 10px; }
.fg input.err { border-color: #c0392b; background: #fff8f8; }
.fg input.ok { border-color: #4a4; }
.form-actions { display: flex; gap: 8px; }
.err-msg { color: #c0392b; font-size: 12px; margin-top: 6px; }
/* ---- Run checkboxes ---- */
.run-checks { display: flex; flex-direction: column; gap: 4px; margin-top: 4px; }
.run-checks-group { display: flex; flex-direction: column; gap: 3px; }
.run-checks-group-header { font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: .06em; color: #7b5800; margin-top: 4px; margin-bottom: 1px; }
.run-checks-group:first-child .run-checks-group-header { margin-top: 0; }
.run-check-label { display: flex; align-items: center; gap: 5px; cursor: pointer; font-size: 13px; padding-left: 2px; }
.run-check-label input { cursor: pointer; }
/* ---- Table ---- */
table { width: 100%; border-collapse: collapse; background: #fff; border-radius: 6px; overflow: hidden; box-shadow: 0 1px 4px rgba(0,0,0,.1); }
th { background: #8b2635; color: #fff; text-align: left; padding: 8px 10px; font-weight: 500; font-size: 12px; text-transform: uppercase; letter-spacing: .04em; white-space: nowrap; }
.th-label { cursor: pointer; user-select: none; }
.th-label:hover { text-decoration: underline; text-underline-offset: 2px; }
.sort-icon { font-size: 10px; opacity: .8; margin-left: 3px; }
td { padding: 5px 8px; border-bottom: 1px solid #eee; vertical-align: middle; }
/* ---- Spaltenfilter (Excel-artig) ---- */
.th-inner { display: flex; align-items: center; justify-content: space-between; gap: 6px; }
.filter-btn { background: transparent; border: none; color: rgba(255,255,255,.7); cursor: pointer; font-size: 12px; padding: 2px 4px; border-radius: 3px; line-height: 1; flex: none; }
.filter-btn:hover { color: #fff; background: rgba(255,255,255,.15); }
.filter-btn.active { color: #ffe082; }
.col-filter-panel { position: fixed; z-index: 60; background: #fff; border: 1px solid #ccc; border-radius: 6px; box-shadow: 0 4px 16px rgba(0,0,0,.18); width: 220px; max-height: 320px; display: flex; flex-direction: column; font-weight: 400; text-transform: none; letter-spacing: normal; color: #222; }
.col-filter-panel[hidden] { display: none; }
.col-filter-panel [hidden] { display: none !important; }
.col-filter-search { margin: 8px 8px 4px; padding: 5px 8px; border: 1px solid #ccc; border-radius: 4px; font-size: 12px; }
.col-filter-search:focus { outline: none; border-color: #8b2635; }
.col-filter-all { display: flex; align-items: center; gap: 6px; padding: 4px 10px; font-size: 12px; font-weight: 600; border-bottom: 1px solid #eee; cursor: pointer; }
.col-filter-all input { accent-color: #8b2635; cursor: pointer; }
.col-filter-list { overflow-y: auto; padding: 4px 8px; flex: 1; }
.col-filter-item { display: flex; align-items: center; gap: 6px; padding: 3px 2px; font-size: 12px; cursor: pointer; }
.col-filter-item input { margin: 0; cursor: pointer; accent-color: #8b2635; }
.col-filter-empty { padding: 8px 10px; font-size: 12px; color: #999; }
.col-filter-daterange { display: flex; flex-direction: column; gap: 10px; padding: 10px 8px; }
.col-filter-daterange label { display: flex; flex-direction: column; gap: 4px; font-size: 12px; color: #555; }
.col-filter-daterange input[type="date"] { padding: 5px 6px; border: 1px solid #ccc; border-radius: 4px; font-size: 12px; font-family: inherit; accent-color: #8b2635; }
.col-filter-daterange input[type="date"]:focus { outline: none; border-color: #8b2635; }
.col-filter-daterange input[type="date"]:disabled { background: #f5f5f5; color: #aaa; }
.col-filter-openonly { display: flex; align-items: center; gap: 6px; font-size: 12px; color: #555; cursor: pointer; padding-bottom: 8px; border-bottom: 1px dashed #ddd; }
.col-filter-openonly input { accent-color: #8b2635; cursor: pointer; }
.col-filter-actions { display: flex; justify-content: flex-end; gap: 6px; padding: 7px 8px; border-top: 1px solid #eee; }
.col-filter-actions button { font-size: 12px; padding: 4px 10px; }
tr:last-child td { border-bottom: none; }
tr:hover td { background: #fafafa; }
tr.row-ended td { background: #f1f8e9; }
tr.row-ended:hover td { background: #e8f5e9; }
tr.row-t00 td { background: rgba(255, 213, 79, 0.18); }
tr.row-t00:hover td { background: rgba(255, 213, 79, 0.28); }
.empty { text-align: center; color: #bbb; padding: 36px; }
.code { font-family: monospace; font-size: 14px; font-weight: 600; color: #8b2635; letter-spacing: .05em; }
.code-cell { display: flex; align-items: center; gap: 6px; white-space: nowrap; }
.cell-input { border: 1px solid transparent; background: transparent; width: 100%; padding: 3px 5px; font-size: 13px; border-radius: 3px; color: #222; }
.cell-input:focus { border-color: #8b2635; outline: none; background: #fff; }
.cell-input.err { border-color: #c0392b; background: #fff8f8; }
/* ---- Run chips ---- */
.chips { display: flex; flex-wrap: wrap; gap: 4px; align-items: center; }
.chip { font-size: 11px; padding: 2px 7px; border-radius: 10px; font-weight: 600; white-space: nowrap; line-height: 1.6; }
.chip-ok { background: #e8f5e9; color: #2e7d32; border: 1px solid #a5d6a7; }
.chip-ended { background: #f9fbe7; color: #7cb342; border: 1px solid #dce775; }
/* ---- Register popover ---- */
.reg-wrap { position: relative; display: inline-block; }
.reg-popup {
position: fixed; z-index: 1000; background: #fff; border: 1px solid #ddd; border-radius: 6px;
box-shadow: 0 3px 12px rgba(0,0,0,.18); min-width: 220px; max-width: 320px;
max-height: 70vh; display: none; flex-direction: column;
}
.reg-popup.open { display: flex; }
.reg-popup .reg-title { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .04em; color: #777; padding: 10px 12px 6px; flex-shrink: 0; }
.reg-popup-list { overflow-y: auto; flex: 1; padding: 0 12px 4px; }
.reg-popup .reg-group-header { font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: .06em; color: #7b5800; margin-top: 6px; margin-bottom: 2px; }
.reg-popup .reg-group:first-of-type .reg-group-header { margin-top: 0; }
.reg-popup label { display: flex; align-items: center; gap: 6px; font-size: 13px; padding: 3px 0; cursor: pointer; }
.reg-popup .reg-footer { padding: 8px 12px 10px; border-top: 1px solid #eee; flex-shrink: 0; }
.reg-popup .reg-actions { display: flex; gap: 6px; }
.reg-popup .reg-actions button { padding: 4px 10px; font-size: 12px; }
.reg-result { font-size: 11px; margin-top: 6px; }
/* ---- Shiny start button ---- */
.shiny-ctrl { display: inline-flex; gap: 3px; align-items: center; }
.shiny-btn { font-size: 11px; padding: 2px 6px; text-decoration: none; }
.shiny-btn.shiny-ready { background: #e8f5e9; border-color: #a5d6a7; color: #2e7d32; }
.shiny-btn.shiny-exited { background: #fff3e0; border-color: #ffcc80; color: #e65100; }
.shiny-stop { font-size: 11px; padding: 2px 6px; color: #c0392b; }
.shiny-stop:hover { border-color: #c0392b; }
.shiny-btn:disabled, .shiny-stop:disabled { opacity: .6; cursor: default; }
</style>
</head>
<body>
<h1>Diagnostik</h1>
<!-- Toolbar -->
<div class="toolbar">
<button class="btn-p" id="newEntryBtn" onclick="openModal('newEntry')">+ Neuer Eintrag</button>
<a href="/api/export/db" class="link-btn">&#128190; .db herunterladen</a>
<a href="/api/export/json" class="link-btn">&#128196; JSON</a>
<label class="link-btn" style="cursor:pointer">
&#128194; JSON importieren
<input type="file" accept=".json" onchange="importJSON(this)" style="display:none">
</label>
<input class="search" id="search" type="text" placeholder="Chiffre oder Notiz suchen …" oninput="loadRows(false)">
<span class="count" id="count"></span>
<span class="shiny-ctrl" id="amdpCtrl"></span>
<button class="settings-btn" onclick="openModal('settings')">&#9881; Einstellungen &amp; Runs</button>
</div>
<!-- Tabs -->
<div class="tabs">
<button class="tab-btn active" id="tabActive" onclick="switchView(false)">Aktiv<span class="tab-count" id="cntActive"></span></button>
<button class="tab-btn" id="tabArchived" onclick="switchView(true)">Archiviert<span class="tab-count" id="cntArchived"></span></button>
</div>
<!-- Table -->
<table>
<thead>
<tr>
<th style="width:110px"><div class="th-inner">
<span class="th-label" onclick="setSortCol('chiffre')">Chiffre <span class="sort-icon" id="si-chiffre"></span></span>
<button class="filter-btn" data-col="chiffre" onclick="toggleColFilter(event,'chiffre')" title="Filtern">&#9660;</button>
</div></th>
<th style="width:130px"><div class="th-inner">
<span>Pseudonym</span>
<button class="filter-btn" data-col="pseudonym" onclick="toggleColFilter(event,'pseudonym')" title="Filtern">&#9660;</button>
</div></th>
<th style="width:110px"><div class="th-inner">
<span class="th-label" onclick="setSortCol('datum')">Angelegt <span class="sort-icon" id="si-datum"></span></span>
<button class="filter-btn" data-col="datum" onclick="toggleColFilter(event,'datum')" title="Filtern">&#9660;</button>
</div></th>
<th style="width:120px"><div class="th-inner">
<span class="th-label" onclick="setSortCol('ausgefuellt')">Ausgefüllt <span class="sort-icon" id="si-ausgefuellt"></span></span>
<button class="filter-btn" data-col="ausgefuellt" onclick="toggleColFilter(event,'ausgefuellt')" title="Filtern">&#9660;</button>
</div></th>
<th style="width:220px"><div class="th-inner">
<span class="th-label" onclick="setSortCol('notiz')">Notiz <span class="sort-icon" id="si-notiz"></span></span>
<button class="filter-btn" data-col="notiz" onclick="toggleColFilter(event,'notiz')" title="Filtern">&#9660;</button>
</div></th>
<th style="width:320px"><div class="th-inner">
<span>Instrument</span>
<button class="filter-btn" data-col="instrument" onclick="toggleColFilter(event,'instrument')" title="Filtern">&#9660;</button>
</div></th>
<th style="width:150px"><div class="th-inner">
<span>Auswertung</span>
<button class="filter-btn" data-col="auswertung" onclick="toggleColFilter(event,'auswertung')" title="Filtern">&#9660;</button>
</div></th>
<th style="width:70px"></th>
</tr>
</thead>
<tbody id="tbody"></tbody>
</table>
<!-- Spaltenfilter-Panel (ein gemeinsames, wird je Spalte neu befuellt/positioniert) -->
<div class="col-filter-panel" id="colFilterPanel" hidden onclick="event.stopPropagation()">
<input class="col-filter-search" id="colFilterSearch" type="text" placeholder="Suchen …" oninput="renderColFilterList()">
<label class="col-filter-all" id="colFilterAllRow">
<input type="checkbox" id="colFilterAllCb" onchange="toggleColFilterAll(this.checked)">
Alle auswählen
</label>
<div class="col-filter-list" id="colFilterList"></div>
<div class="col-filter-actions">
<button class="btn-s" onclick="clearColFilter()">Zurücksetzen</button>
</div>
</div>
<!-- ===== Settings modal ===== -->
<div class="modal-overlay" id="settingsOverlay" onclick="closeModal('settings')">
<div class="modal" onclick="event.stopPropagation()">
<div class="modal-header">
<h2>Einstellungen &amp; Runs</h2>
<button class="modal-close" onclick="closeModal('settings')">×</button>
</div>
<div class="modal-body">
<details class="panel-section">
<summary>formr API</summary>
<div class="fg-row">
<div class="fg">
<label>API-URL</label>
<input id="sApiUrl" type="url" placeholder="https://diagnostik.karneboge.de/api" style="width:300px">
</div>
<div class="fg">
<label>Client ID</label>
<input id="sClientId" type="text" placeholder="ID aus formr Admin" style="width:170px">
</div>
</div>
<div class="fg-row">
<div class="fg">
<label>Client Secret</label>
<div class="secret-row">
<input id="sClientSecret" type="password" placeholder="Neues Secret eingeben" style="width:230px">
<span class="secret-hint" id="secretHint"></span>
</div>
</div>
</div>
<div style="display:flex;gap:8px;align-items:center">
<button class="btn-p" onclick="saveConfig()">Speichern</button>
<span class="status-ok" id="configStatus" style="display:none">Gespeichert.</span>
</div>
</details>
<div class="panel-section">
<h3>Runs verwalten</h3>
<p style="font-size:12px;color:#888;margin-bottom:10px">
Der <em>Name</em> muss exakt dem formr-Run-Namen entsprechen (URL-Slug).
<em>Label</em> und <em>Kategorie</em> sind nur zur Anzeige.
</p>
<div class="fg-row">
<div class="fg">
<label>Name <span style="font-weight:400;color:#bbb">(formr)</span></label>
<input id="rName" type="text" placeholder="BDI2" style="width:130px" autocomplete="off">
</div>
<div class="fg">
<label>Label <span style="font-weight:400;color:#bbb">(Anzeige)</span></label>
<input id="rLabel" type="text" placeholder="BDI-II Verlaufsmessung" style="width:190px">
</div>
<div class="fg">
<label>Kategorie <span style="font-weight:400;color:#bbb">(mehrere: Komma)</span></label>
<input id="rKategorie" type="text" placeholder="z.B. Depression, PTBS" style="width:190px">
</div>
<div class="fg">
<label>Shiny-App <span style="font-weight:400;color:#bbb">(optional)</span></label>
<select id="rShinyApp" style="width:160px"></select>
</div>
<button class="btn-p" onclick="addRun()" style="margin-bottom:1px">Run hinzufügen</button>
</div>
<span class="status-err" id="runErr"></span>
<div class="runs-list" id="runsList"></div>
</div>
</div>
</div>
</div>
<!-- ===== New-entry modal (no backdrop close) ===== -->
<div class="modal-overlay" id="newEntryOverlay">
<div class="modal">
<div class="modal-header">
<h2>Neuer Eintrag</h2>
<button class="modal-close" onclick="closeModal('newEntry')">×</button>
</div>
<div class="modal-body">
<div class="form-row">
<div class="fg">
<label>Chiffre <span style="font-weight:400;color:#bbb">(Buchstabe + 6 Ziffern)</span></label>
<input id="fChiffre" type="text" maxlength="7" placeholder="A123456" autocomplete="off"
oninput="validateChiffre(this)" style="width:120px">
</div>
<div class="fg">
<label>Pseudonym <span style="font-weight:400;color:#bbb">(auto)</span></label>
<input id="fPseudonym" type="text" readonly style="width:160px;background:#f5f5f5;color:#8b2635;font-family:monospace;font-weight:600;text-overflow:ellipsis;overflow:hidden">
</div>
<div class="fg">
<label>Angelegt</label>
<input id="fDatum" type="date" style="width:140px">
</div>
<div class="fg" style="flex:1;min-width:140px">
<label>Notiz</label>
<input id="fNotiz" type="text">
</div>
</div>
<div class="fg" id="fRunsRow" style="display:none;margin-bottom:10px">
<label>In formr-Run registrieren</label>
<div class="run-checks" id="fRunChecks"></div>
</div>
<div class="form-actions">
<button class="btn-p" onclick="saveNew()">Speichern</button>
<button class="btn-s" onclick="newCode()" title="Neuen Zufallscode generieren (ohne Speichern)">↺ Code neu</button>
<button class="btn-s" onclick="closeModal('newEntry')">Abbrechen</button>
</div>
<div class="err-msg" id="formErr"></div>
</div>
</div>
</div>
<div class="modal-overlay" id="runSelectOverlay">
<div class="modal" style="width:min(480px,96vw)">
<div class="modal-header">
<h2>Run auswählen</h2>
<button class="modal-close" onclick="closeRunSelectModal()">×</button>
</div>
<div class="modal-body" style="padding-bottom:16px">
<div id="runSelectList" style="max-height:60vh;overflow-y:auto;margin-bottom:12px"></div>
<div style="display:flex;gap:8px;align-items:center">
<button class="btn-p" onclick="submitRunSelectModal()">Registrieren</button>
<button class="btn-s" onclick="closeRunSelectModal()">Abbrechen</button>
<span id="runSelectResult" style="font-size:12px;margin-left:4px"></span>
</div>
</div>
</div>
</div>
<script>
// ===== Constants =====
const ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
const CHIFFRE_RE = /^[A-Z][0-9]{6}$/;
let allRuns = [];
let apiUrl = '';
let shinyApps = [];
let shinyStatus = {};
// ===== Sort state =====
let sortCol = null;
let sortDir = 1; // 1 = asc, -1 = desc
// ===== View state =====
let viewArchived = false;
// ===== Utility =====
function generateCode() {
const b = new Uint8Array(64);
crypto.getRandomValues(b);
return Array.from(b, x => ALPHABET[x % 32]).join('');
}
function truncCode(s) { return s.length > 16 ? s.slice(0, 8) + '…' + s.slice(-4) : s; }
function today() { return new Date().toISOString().slice(0, 10); }
function esc(s) {
return String(s ?? '').replace(/&/g,'&amp;').replace(/"/g,'&quot;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
function sessionUrl(runName, pseudonym) {
if (!apiUrl || !runName || !pseudonym) return '';
const base = apiUrl.replace(/\/api\/?$/, '');
return `${base}/${encodeURIComponent(runName)}/?code=${encodeURIComponent(pseudonym)}`;
}
function rowEl(rowId) { return document.querySelector(`tr[data-id="${rowId}"]`); }
// Ein Eintrag kann UI-seitig nur in genau einem Run registriert werden (der "+ Run"-Button
// verschwindet nach der ersten Registrierung), daher ist "der erste Run" == "der Run".
function primaryRun(row) { return (row.runs || [])[0]; }
function endedBadgeHtml(endedAt) {
return `<span style="color:#7cb342;font-size:12px" title="Abgeschlossen ${esc(endedAt)}">✓ <b>${esc(fmtEndedDate(endedAt))}</b></span>`;
}
function copySessionUrl(btn, rowId, runName) {
const tr = rowEl(rowId);
const url = sessionUrl(runName, tr ? tr.dataset.pseudonym : '');
if (url) copyText(btn, url);
}
function copyText(btn, text) {
navigator.clipboard.writeText(text).then(() => {
// Icon-Buttons duerfen bei der Erfolgsmeldung nicht breiter werden - Haekchen statt Text.
const orig = btn.textContent;
btn.textContent = '✓'; btn.classList.add('done');
setTimeout(() => { btn.textContent = orig; btn.classList.remove('done'); }, 1500);
});
}
function copyInputValue(btn) {
const input = btn.parentElement.querySelector('input');
if (input && input.value) copyText(btn, input.value);
}
async function api(path, opts = {}) {
const r = await fetch(path, { headers: { 'Content-Type': 'application/json' }, ...opts });
if (!r.ok) { const t = await r.text(); throw new Error(t); }
return r.json();
}
// ===== Grouped runs helpers =====
function groupedRuns(runs) {
const map = {};
runs.forEach(r => {
const cats = r.kategorie ? r.kategorie.split(',').map(s => s.trim()).filter(Boolean) : [];
if (cats.length === 0) {
if (!map['']) map[''] = [];
map[''].push(r);
} else {
cats.forEach(cat => {
if (!map[cat]) map[cat] = [];
map[cat].push(r);
});
}
});
const named = Object.keys(map).filter(k => k).sort((a, b) => a.localeCompare(b, 'de'));
const order = map[''] ? [...named, ''] : named;
return order.map(k => ({ kategorie: k, runs: map[k] }));
}
function katBadges(kategorie) {
if (!kategorie) return '';
const cats = kategorie.split(',').map(s => s.trim()).filter(Boolean);
if (!cats.length) return '';
return `<div class="run-item-kats">${cats.map(c => `<span class="run-item-kat">${esc(c)}</span>`).join('')}</div>`;
}
// ===== Modals =====
function openModal(name) {
document.getElementById(name === 'settings' ? 'settingsOverlay' : 'newEntryOverlay')
.classList.add('open');
if (name === 'settings') {
loadConfig();
loadShinyApps();
renderRunsList();
} else {
newCode();
document.getElementById('fDatum').value = today();
document.getElementById('formErr').textContent = '';
renderFormRunChecks();
document.getElementById('newEntryBtn').disabled = true;
setTimeout(() => document.getElementById('fChiffre').focus(), 50);
}
}
function closeModal(name) {
document.getElementById(name === 'settings' ? 'settingsOverlay' : 'newEntryOverlay')
.classList.remove('open');
if (name === 'newEntry') {
document.getElementById('newEntryBtn').disabled = false;
}
}
// Esc closes settings only (new-entry requires explicit cancel)
document.addEventListener('keydown', e => {
if (e.key === 'Escape') closeModal('settings');
});
// ===== Config =====
async function loadConfig() {
try {
const c = await api('/api/config');
apiUrl = c.api_url || '';
document.getElementById('sApiUrl').value = apiUrl;
document.getElementById('sClientId').value = c.client_id || '';
document.getElementById('secretHint').textContent = c.has_secret ? '●●●●●● (gespeichert)' : '';
} catch (_) {}
}
async function saveConfig() {
const payload = {
api_url: document.getElementById('sApiUrl').value.trim(),
client_id: document.getElementById('sClientId').value.trim(),
};
const sec = document.getElementById('sClientSecret').value;
if (sec) payload.client_secret = sec;
try {
await api('/api/config', { method: 'POST', body: JSON.stringify(payload) });
document.getElementById('sClientSecret').value = '';
await loadConfig();
const st = document.getElementById('configStatus');
st.style.display = 'inline';
setTimeout(() => { st.style.display = 'none'; }, 2000);
} catch (e) { alert('Fehler: ' + e.message); }
}
// ===== Runs =====
async function loadRuns() {
allRuns = await api('/api/runs');
renderRunsList();
renderFormRunChecks();
}
function renderRunsList() {
const el = document.getElementById('runsList');
if (!allRuns.length) {
el.innerHTML = '<span class="runs-empty">Noch keine Runs hinterlegt.</span>';
return;
}
const sorted = [...allRuns].sort((a, b) =>
(a.label || a.name).localeCompare(b.label || b.name, 'de'));
el.innerHTML = sorted.map(r => `
<div class="run-item" id="ri-${r.id}">
<div class="run-item-info">
<span class="run-item-name">${esc(r.name)}</span>
<span class="run-item-label">${esc(r.label || '—')}</span>
${katBadges(r.kategorie)}
${r.shiny_app ? `<span class="run-item-shiny">▶ ${esc(r.shiny_app)}</span>` : ''}
</div>
<div class="run-item-actions">
<button class="btn-icon" style="font-size:12px;padding:2px 6px" title="Bearbeiten" onclick="editRun(${r.id})">✏</button>
<button class="btn-x" title="Entfernen" onclick="deleteRun(${r.id})">×</button>
</div>
</div>
`).join('');
}
function shinyAppOptionsHtml(selected) {
return `<option value="">— keine —</option>` + shinyApps.map(a =>
`<option value="${esc(a)}"${a === selected ? ' selected' : ''}>${esc(a)}</option>`
).join('');
}
function editRun(id) {
const run = allRuns.find(r => r.id === id);
const item = document.getElementById(`ri-${id}`);
item.innerHTML = `
<div class="run-item-info" style="flex-shrink:0">
<span class="run-item-name">${esc(run.name)}</span>
</div>
<div class="run-item-edit">
<input type="text" id="re-label-${id}" value="${esc(run.label)}" placeholder="Label" style="width:160px">
<input type="text" id="re-kat-${id}" value="${esc(run.kategorie || '')}" placeholder="Kategorie (mehrere: Komma)" style="width:200px">
<select id="re-shiny-${id}" style="width:150px">${shinyAppOptionsHtml(run.shiny_app || '')}</select>
<button class="btn-p" style="padding:4px 10px;font-size:12px" onclick="saveEditRun(${id})">Speichern</button>
<button class="btn-s" style="padding:4px 10px;font-size:12px" onclick="loadRuns()">Abbrechen</button>
</div>
`;
document.getElementById(`re-label-${id}`).focus();
}
async function saveEditRun(id) {
const label = document.getElementById(`re-label-${id}`).value.trim();
const kategorie = document.getElementById(`re-kat-${id}`).value.trim();
const shiny_app = document.getElementById(`re-shiny-${id}`).value;
try {
await api(`/api/runs/${id}`, { method: 'PUT', body: JSON.stringify({ label, kategorie, shiny_app }) });
await loadRuns();
await loadRows();
} catch (e) { alert('Fehler: ' + e.message); }
}
async function addRun() {
const name = document.getElementById('rName').value.trim();
const label = document.getElementById('rLabel').value.trim();
const kategorie = document.getElementById('rKategorie').value.trim();
const shiny_app = document.getElementById('rShinyApp').value;
const errEl = document.getElementById('runErr');
errEl.textContent = '';
if (!name) { errEl.textContent = 'Name ist Pflichtfeld.'; return; }
try {
await api('/api/runs', { method: 'POST', body: JSON.stringify({ name, label, kategorie, shiny_app }) });
document.getElementById('rName').value = '';
document.getElementById('rLabel').value = '';
document.getElementById('rKategorie').value = '';
document.getElementById('rShinyApp').value = '';
await loadRuns();
await loadRows();
} catch (e) { errEl.textContent = e.message; }
}
async function deleteRun(id) {
if (!confirm('Run entfernen? Registrierungen werden lokal gelöscht (formr-Daten bleiben unberührt).')) return;
try {
await api(`/api/runs/${id}`, { method: 'DELETE' });
await loadRuns();
await loadRows();
} catch (e) { alert('Fehler: ' + e.message); }
}
// ===== Shiny apps =====
async function loadShinyApps() {
try { shinyApps = await api('/api/shiny/apps'); } catch (_) { shinyApps = []; }
const sel = document.getElementById('rShinyApp');
if (sel) sel.innerHTML = shinyAppOptionsHtml('');
}
let lastShinyStatusJson = '';
async function pollShinyStatus() {
try {
const fresh = await api('/api/shiny/status');
const freshJson = JSON.stringify(fresh);
if (freshJson === lastShinyStatusJson) return; // unveraendert - DOM-Rebuild sparen
lastShinyStatusJson = freshJson;
shinyStatus = fresh;
refreshShinyButtons();
} catch (_) { /* Server evtl. kurz nicht erreichbar - beim nächsten Poll erneut versuchen */ }
}
setInterval(pollShinyStatus, 1000);
function shinyQueryString(chiffre, pseudonym) {
return `?chiffre=${encodeURIComponent(chiffre)}&pseudonym=${encodeURIComponent(pseudonym || '')}`;
}
function shinyButtonHtml(rowId, runId, appName, chiffre, pseudonym) {
const id = `shiny-${rowId}-${runId}`;
const st = shinyStatus[appName];
const qs = shinyQueryString(chiffre, pseudonym);
let main;
if (st && st.ready) {
// Shiny-Sessions sind pro Tab unabhaengig (eigene Reactive-Session je Verbindung) -
// ein neuer Tab mit dieser Zeile Chiffre/Pseudonym in der URL stoert kein anderes,
// bereits offenes Tab. Deshalb immer "öffnen", unabhaengig davon, welche Zeile den
// Prozess zuletzt gestartet/angefragt hat.
main = `<a class="btn-icon shiny-btn shiny-ready" href="http://localhost:${st.port}/${qs}" target="_blank" title="${esc(appName)} öffnen">→ öffnen</a>`;
} else if (st && st.exited) {
main = `<button class="btn-icon shiny-btn shiny-exited" data-app="${esc(appName)}"
onclick="handleShinyClick(this)" title="Abgestürzt (Exit-Code ${st.exit_code}) - Log: /api/shiny/log?app=${encodeURIComponent(appName)}">⚠ erneut starten</button>`;
} else if (st && !st.ready) {
main = `<button class="btn-icon shiny-btn" disabled>⏳ startet…</button>`;
} else {
main = `<button class="btn-icon shiny-btn" data-app="${esc(appName)}"
onclick="handleShinyClick(this)" title="${esc(appName)}">▶ App starten</button>`;
}
const stopBtn = (st && !st.exited)
? `<button class="btn-icon shiny-stop" data-app="${esc(appName)}" onclick="handleShinyStop(this)" title="${esc(appName)} stoppen">■</button>`
: '';
return `<span class="shiny-ctrl" id="${id}">${main}${stopBtn}</span>`;
}
function refreshShinyButtons() {
document.querySelectorAll('[id^="shiny-"]').forEach(el => {
const [, rowId, runId] = el.id.split('-');
const tr = rowEl(rowId);
if (!tr) return;
const run = allRuns.find(r => r.id === parseInt(runId));
const appName = run ? run.shiny_app : null;
if (!appName) return;
const html = shinyButtonHtml(rowId, runId, appName, tr.dataset.chiffre || '', tr.dataset.pseudonym || '');
const wrap = document.createElement('div');
wrap.innerHTML = html;
el.replaceWith(wrap.firstElementChild);
});
refreshAmdpButton();
}
// ===== AMDP-App (Kopfzeile) =====
// Eigenstaendige App im Ordner "AMDP" neben server.py (nur in Produktion vorhanden,
// nicht in dieser Entwicklungsumgebung) - unabhaengig von Chiffre/Pseudonym, daher
// ohne shinyQueryString und ohne Zeilenbezug.
const AMDP_APP = 'AMDP';
function amdpButtonHtml() {
const st = shinyStatus[AMDP_APP];
let main;
if (st && st.ready) {
main = `<a class="amdp-btn" href="http://localhost:${st.port}/" target="_blank" title="AMDP öffnen">&#936; AMDP</a>`;
} else if (st && st.exited) {
main = `<button class="amdp-btn" data-app="${AMDP_APP}"
onclick="handleShinyClick(this)" title="Abgestürzt (Exit-Code ${st.exit_code}) - Log: /api/shiny/log?app=${encodeURIComponent(AMDP_APP)}">&#936; AMDP</button>`;
} else if (st && !st.ready) {
main = `<button class="amdp-btn" disabled>&#936; AMDP</button>`;
} else {
main = `<button class="amdp-btn" data-app="${AMDP_APP}"
onclick="handleShinyClick(this)" title="AMDP starten">&#936; AMDP</button>`;
}
const stopBtn = (st && !st.exited)
? `<button class="btn-icon shiny-stop" data-app="${AMDP_APP}" onclick="handleShinyStop(this)" title="AMDP stoppen">■</button>`
: '';
return main + stopBtn;
}
function refreshAmdpButton() {
const el = document.getElementById('amdpCtrl');
if (el) el.innerHTML = amdpButtonHtml();
}
function handleShinyClick(btn) {
const app = btn.dataset.app;
btn.disabled = true;
api('/api/shiny/start', { method: 'POST', body: JSON.stringify({ app }) })
.then(() => pollShinyStatus())
.catch(e => alert('Fehler beim Start: ' + e.message))
.finally(() => { btn.disabled = false; });
}
function handleShinyStop(btn) {
const app = btn.dataset.app;
if (!confirm(`App "${app}" wirklich stoppen?`)) return;
btn.disabled = true;
api('/api/shiny/stop', { method: 'POST', body: JSON.stringify({ app }) })
.then(() => pollShinyStatus())
.catch(e => alert('Fehler beim Stoppen: ' + e.message))
.finally(() => { btn.disabled = false; });
}
// ===== Form run checkboxes =====
function syncRunSelect(el) {
const val = el.value;
document.querySelectorAll('#fRunChecks .run-select').forEach(cb => {
cb.checked = (cb.value === val);
});
}
function renderFormRunChecks() {
const row = document.getElementById('fRunsRow');
const wrap = document.getElementById('fRunChecks');
if (!allRuns.length) { row.style.display = 'none'; return; }
row.style.display = 'flex';
const groups = groupedRuns(allRuns);
wrap.innerHTML = groups.map(g => `
<div class="run-checks-group">
${g.kategorie ? `<div class="run-checks-group-header">${esc(g.kategorie)}</div>` : ''}
${g.runs.map(r => `
<label class="run-check-label">
<input type="checkbox" class="run-select" value="${r.id}" onchange="syncRunSelect(this)">
${esc(r.label || r.name)}
</label>
`).join('')}
</div>
`).join('');
// No default selection
}
function selectedRunIds() {
const cb = document.querySelector('#fRunChecks .run-select:checked');
return cb ? [parseInt(cb.value)] : [];
}
// ===== Sorting =====
function setSortCol(col) {
sortDir = (sortCol === col) ? -sortDir : 1;
sortCol = col;
updateSortIcons();
loadRows();
}
function updateSortIcons() {
['chiffre', 'datum', 'notiz', 'ausgefuellt'].forEach(col => {
const el = document.getElementById(`si-${col}`);
if (el) el.textContent = sortCol === col ? (sortDir === 1 ? ' ▲' : ' ▼') : ' ⇅';
});
}
function sortValue(row, col) {
if (col === 'ausgefuellt') return primaryRun(row)?.ended_at || '';
return row[col] || '';
}
// Ein einziger, priorisierter Klassenname statt zweier unabhaengiger Toggles - vermeidet
// dass zwei Hervorhebungen gleichzeitig aktiv sind und ihre Prioritaet nur zufaellig aus
// der CSS-Regel-Reihenfolge folgt. T00-Chiffren haben Vorrang vor "fertig" (gruen).
function rowHighlightClass(row) {
if ((row.chiffre || '').toUpperCase().startsWith('T00')) return 'row-t00';
if (primaryRun(row)?.ended_at) return 'row-ended';
return '';
}
function sortedRows(rows) {
if (!sortCol) return rows;
return [...rows].sort((a, b) => {
const av = String(sortValue(a, sortCol)).toLowerCase();
const bv = String(sortValue(b, sortCol)).toLowerCase();
return av < bv ? -sortDir : av > bv ? sortDir : 0;
});
}
// ===== View switching =====
function switchView(archived) {
if (viewArchived === archived) return;
viewArchived = archived;
document.getElementById('tabActive').classList.toggle('active', !archived);
document.getElementById('tabArchived').classList.toggle('active', archived);
loadRows();
}
async function loadCounts() {
try {
const c = await api('/api/eintraege/counts');
document.getElementById('cntActive').textContent = c.active ? ` (${c.active})` : '';
document.getElementById('cntArchived').textContent = c.archived ? ` (${c.archived})` : '';
} catch (_) {}
}
// ===== Spaltenfilter (Excel-artig) =====
// columnFilters[col] fehlt -> Spalte ungefiltert (alle Werte erlaubt)
// columnFilters[col] = Set(...) -> nur diese Werte sind erlaubt (auch ein leeres Set moeglich = alles ausgeblendet)
let columnFilters = {};
let lastLoadedRows = []; // letzter Server-Fetch (nur Tab+Suche angewandt), Basis fuer Werte-Listen + Spaltenfilter
let colFilterOpenCol = null; // Spalte, deren Filter-Panel gerade offen ist
// Werte, nach denen eine Zeile in dieser Spalte gruppiert/gefiltert wird. Mehrere Werte
// moeglich (z.B. mehrere Runs in "Instrument") - die Zeile matcht, wenn EINER davon erlaubt ist.
function filterValues(col, row) {
switch (col) {
case 'chiffre': return [row.chiffre || '(leer)'];
case 'pseudonym': return [row.pseudonym || '(leer)'];
case 'datum': return [fmtPlainDate(row.datum) || '(leer)'];
case 'notiz': return [row.notiz || '(leer)'];
case 'ausgefuellt': {
const r = primaryRun(row);
return [r ? (r.ended_at ? 'Abgeschlossen' : 'Offen') : '(kein Run)'];
}
case 'instrument': {
const labels = (row.runs || []).map(r => r.label || r.name);
return labels.length ? labels : ['(kein Run)'];
}
case 'auswertung': {
const apps = (row.runs || []).filter(r => r.shiny_app && r.ended_at).map(r => r.shiny_app);
return apps.length ? apps : ['(keine)'];
}
default: return [''];
}
}
function uniqueValuesForColumn(col) {
const set = new Set();
lastLoadedRows.forEach(row => filterValues(col, row).forEach(v => set.add(v)));
return [...set].sort((a, b) => a.localeCompare(b, 'de', { numeric: true }));
}
// Spalten mit Von-Bis-Zeitraum statt Werte-Checkliste: Angelegt-Datum bzw. Abschlussdatum.
const DATE_RANGE_COLS = ['datum', 'ausgefuellt'];
function dateRangeValue(col, row) {
if (col === 'datum') return row.datum || '';
if (col === 'ausgefuellt') return (primaryRun(row)?.ended_at || '').slice(0, 10);
return '';
}
// Chiffre ist praktisch pro Zeile eindeutig - eine Werte-Checkliste waere unbrauchbar lang,
// daher hier ein simpler "enthaelt"-Textfilter statt der Checkbox-Liste.
const TEXT_FILTER_COLS = ['chiffre'];
function rowMatchesFilters(row) {
return Object.keys(columnFilters).every(col => {
const f = columnFilters[col];
if (!f) return true;
if (DATE_RANGE_COLS.includes(col)) {
// "Noch offen" ist das Pendant zu Excels "(Leere)"-Filterwert: nur Zeilen ohne Datum.
if (f.openOnly) return !dateRangeValue(col, row);
const d = dateRangeValue(col, row);
if (!d) return false; // kein Datum vorhanden -> matcht nie einen aktiven Zeitraum-Filter
if (f.from && d < f.from) return false;
if (f.to && d > f.to) return false;
return true;
}
if (TEXT_FILTER_COLS.includes(col)) {
return (row[col] || '').toUpperCase().includes(f.contains.toUpperCase());
}
return filterValues(col, row).some(v => f.has(v));
});
}
function applyColumnFilters(rows) { return rows.filter(rowMatchesFilters); }
function refreshFilterIcons() {
document.querySelectorAll('.filter-btn').forEach(btn => {
btn.classList.toggle('active', !!columnFilters[btn.dataset.col]);
});
}
function toggleColFilter(ev, col) {
ev.stopPropagation();
const panel = document.getElementById('colFilterPanel');
if (colFilterOpenCol === col && !panel.hidden) { closeColFilter(); return; }
colFilterOpenCol = col;
renderColFilterPanel();
panel.hidden = false;
const r = ev.currentTarget.getBoundingClientRect();
const left = Math.max(4, Math.min(r.right - panel.offsetWidth, window.innerWidth - panel.offsetWidth - 4));
panel.style.left = left + 'px';
panel.style.top = (r.bottom + 4) + 'px';
const focusEl = DATE_RANGE_COLS.includes(col) ? document.getElementById('colFilterDateFrom') : document.getElementById('colFilterSearch');
if (focusEl) focusEl.focus();
}
function closeColFilter() {
document.getElementById('colFilterPanel').hidden = true;
colFilterOpenCol = null;
}
document.addEventListener('click', e => {
const panel = document.getElementById('colFilterPanel');
if (!panel.hidden && !panel.contains(e.target) && !e.target.closest('.filter-btn')) closeColFilter();
});
document.addEventListener('keydown', e => { if (e.key === 'Escape') closeColFilter(); });
// Datum und Ausgefuellt bekommen statt der Checkbox-Liste einen Von-Bis-Bereich (beide Enden
// optional); Chiffre bekommt statt der Checkbox-Liste ein simples "enthaelt"-Textfeld.
function renderColFilterPanel() {
const col = colFilterOpenCol;
const isDate = DATE_RANGE_COLS.includes(col);
const isText = TEXT_FILTER_COLS.includes(col);
const searchBox = document.getElementById('colFilterSearch');
document.getElementById('colFilterAllRow').hidden = isDate || isText;
searchBox.hidden = isDate;
if (isDate) {
renderDateRangeFilter();
} else if (isText) {
searchBox.placeholder = 'Enthält …';
searchBox.value = columnFilters[col]?.contains || '';
searchBox.oninput = () => setTextFilter(searchBox.value);
document.getElementById('colFilterList').innerHTML = '';
searchBox.focus();
} else {
searchBox.placeholder = 'Suchen …';
searchBox.value = '';
searchBox.oninput = renderColFilterList;
renderColFilterList();
}
}
function renderDateRangeFilter() {
const col = colFilterOpenCol;
const cur = columnFilters[col] || {};
const openOnly = !!cur.openOnly;
const openToggleHtml = col === 'ausgefuellt' ? `
<label class="col-filter-openonly">
<input type="checkbox" id="colFilterOpenOnly" ${openOnly ? 'checked' : ''} onchange="setOpenOnlyFilter(this.checked)">
Noch offen (ohne Abschlussdatum)
</label>` : '';
document.getElementById('colFilterList').innerHTML = `
<div class="col-filter-daterange">
${openToggleHtml}
<label>Von
<input type="date" id="colFilterDateFrom" value="${esc(openOnly ? '' : (cur.from || ''))}" ${openOnly ? 'disabled' : ''} onchange="setDateRangeFilter()">
</label>
<label>Bis
<input type="date" id="colFilterDateTo" value="${esc(openOnly ? '' : (cur.to || ''))}" ${openOnly ? 'disabled' : ''} onchange="setDateRangeFilter()">
</label>
</div>`;
}
function setDateRangeFilter() {
const col = colFilterOpenCol;
const from = document.getElementById('colFilterDateFrom').value || null;
const to = document.getElementById('colFilterDateTo').value || null;
if (!from && !to) delete columnFilters[col];
else columnFilters[col] = { from, to };
refreshFilterIcons();
applyFiltersAndRerender();
}
// "Noch offen" ist mit dem Von-Bis-Bereich exklusiv - beide gleichzeitig zu kombinieren
// waere entweder widerspruechlich (Zeile hat gar kein Datum, das in einen Bereich fallen
// koennte) oder, bei leerem Bereich, gleichbedeutend mit "kein Filter" (siehe rowMatchesFilters).
function setOpenOnlyFilter(checked) {
const col = colFilterOpenCol;
if (checked) columnFilters[col] = { openOnly: true };
else delete columnFilters[col];
refreshFilterIcons();
renderDateRangeFilter();
applyFiltersAndRerender();
}
function setTextFilter(text) {
const col = colFilterOpenCol;
const v = text.trim();
if (!v) delete columnFilters[col];
else columnFilters[col] = { contains: v };
refreshFilterIcons();
applyFiltersAndRerender();
}
function renderColFilterList() {
const col = colFilterOpenCol;
const allValues = uniqueValuesForColumn(col);
const q = document.getElementById('colFilterSearch').value.trim().toLowerCase();
const shown = q ? allValues.filter(v => v.toLowerCase().includes(q)) : allValues;
const allowed = columnFilters[col]; // Set oder undefined ("alle erlaubt")
const list = document.getElementById('colFilterList');
list.innerHTML = shown.length ? shown.map(v => `
<label class="col-filter-item">
<input type="checkbox" data-val="${esc(v)}" ${(!allowed || allowed.has(v)) ? 'checked' : ''}
onchange="setColFilterValue(this.dataset.val, this.checked)">
<span>${esc(v)}</span>
</label>`).join('') : '<div class="col-filter-empty">Keine Werte gefunden.</div>';
const allowedCount = allValues.filter(v => !allowed || allowed.has(v)).length;
const allCb = document.getElementById('colFilterAllCb');
allCb.checked = allowedCount === allValues.length;
allCb.indeterminate = allowedCount > 0 && allowedCount < allValues.length;
}
function setColFilterValue(val, checked) {
const col = colFilterOpenCol;
const allValues = uniqueValuesForColumn(col);
const allowed = columnFilters[col] ? new Set(columnFilters[col]) : new Set(allValues);
if (checked) allowed.add(val); else allowed.delete(val);
columnFilters[col] = (allowed.size === allValues.length) ? undefined : allowed;
if (columnFilters[col] === undefined) delete columnFilters[col];
refreshFilterIcons();
renderColFilterList();
applyFiltersAndRerender();
}
function toggleColFilterAll(checked) {
const col = colFilterOpenCol;
if (checked) delete columnFilters[col];
else columnFilters[col] = new Set();
refreshFilterIcons();
renderColFilterList();
applyFiltersAndRerender();
}
function clearColFilter() {
delete columnFilters[colFilterOpenCol];
refreshFilterIcons();
renderColFilterPanel();
applyFiltersAndRerender();
}
function applyFiltersAndRerender() {
const visible = applyColumnFilters(lastLoadedRows);
const n = visible.length;
document.getElementById('count').textContent = n
? n + ' Eintrag' + (n !== 1 ? 'e' : '') + (n !== lastLoadedRows.length ? ` (von ${lastLoadedRows.length})` : '')
: (lastLoadedRows.length ? 'Keine Treffer für die Filter' : '');
renderTable(visible);
}
// ===== Main table =====
async function loadRows(alsoVerify = true) {
const q = document.getElementById('search').value.trim();
const params = new URLSearchParams({ archived: viewArchived ? '1' : '0' });
if (q) params.set('q', q);
lastLoadedRows = await api('/api/eintraege?' + params.toString());
applyFiltersAndRerender();
loadCounts();
// Archivierte Eintraege muessen nicht mehr live gegen formr geprueft werden
if (alsoVerify && !viewArchived) verifyAll();
}
function rowActionsHtml(row) {
if (viewArchived) {
return `
<button class="btn-icon" title="Wiederherstellen" onclick="restoreRow(${row.id})">&#8617;</button>
<button class="btn-x" title="Endgültig löschen" onclick="hardDeleteRow(${row.id},'${esc(row.chiffre || '')}')">&times;</button>
`;
}
return `<button class="btn-icon" title="Archivieren" onclick="archiveRow(${row.id})">&#128451;</button>`;
}
function renderTable(rows) {
const tbody = document.getElementById('tbody');
const sorted = sortedRows(rows);
if (!sorted.length) {
let msg;
if (lastLoadedRows.length) msg = 'Keine Treffer für die aktuellen Filter.';
else msg = viewArchived ? 'Keine archivierten Einträge.' : 'Noch keine Einträge.';
tbody.innerHTML = `<tr><td colspan="8" class="empty">${msg}</td></tr>`;
return;
}
tbody.innerHTML = '';
sorted.forEach(row => {
const tr = document.createElement('tr');
const chiffreClass = (!row.chiffre || CHIFFRE_RE.test(row.chiffre)) ? '' : 'err';
tr.dataset.id = row.id;
tr.dataset.pseudonym = row.pseudonym;
tr.dataset.chiffre = row.chiffre || '';
const hl = rowHighlightClass(row);
if (hl) tr.classList.add(hl);
tr.innerHTML = `
<td><div class="code-cell">
<input class="cell-input ${chiffreClass}" type="text" value="${esc(row.chiffre)}"
maxlength="7" placeholder="A123456" oninput="validateChiffre(this)"
onblur="updateField(${row.id},'chiffre',this.value)" style="width:80px">
<button class="btn-copy btn-copy-icon" onclick="copyInputValue(this)" title="Kopieren">&#128203;</button>
</div></td>
<td><div class="code-cell">
<span class="code" title="${esc(row.pseudonym)}">${esc(truncCode(row.pseudonym))}</span>
<button class="btn-copy btn-copy-icon" onclick="copyText(this,'${esc(row.pseudonym)}')" title="Kopieren">&#128203;</button>
</div></td>
<td><span style="font-size:12px;color:#555">${esc(fmtPlainDate(row.datum))}</span></td>
<td>${renderAusgefuellt(row)}</td>
<td><input class="cell-input" type="text" value="${esc(row.notiz)}"
onblur="updateField(${row.id},'notiz',this.value)"></td>
<td>${renderRunCell(row)}</td>
<td>${renderAppCell(row)}</td>
<td>${rowActionsHtml(row)}</td>
`;
tbody.appendChild(tr);
});
}
function renderRunCell(row) {
const regIds = new Set((row.runs || []).map(r => r.id));
const chips = (row.runs || []).map(r => {
const url = sessionUrl(r.name, row.pseudonym);
const chipClass = r.ended_at ? 'chip chip-ended' : 'chip chip-ok';
return [
`<span class="${chipClass}" id="chip-${row.id}-${r.id}" title="Registriert ${esc(r.registered_at)}">${esc(r.label || r.name)}</span>`,
url ? `<button class="btn-copy" onclick="copySessionUrl(this,${row.id},'${encodeURIComponent(r.name)}')" title="${esc(url)}">Link</button>` : '',
].join('');
}).join('');
const unregistered = allRuns.filter(r => !regIds.has(r.id));
const addBtn = (row.runs || []).length === 0 && unregistered.length
? `<button class="btn-icon" onclick="openRunSelectModal(${row.id})" title="In Run registrieren">+ Run</button>`
: '';
return `<div class="chips">${chips}${addBtn}</div>`;
}
function renderAppCell(row) {
// Auswertung ergibt erst Sinn, wenn der Fragebogen ausgefuellt ist
const runsWithApp = (row.runs || []).filter(r => r.shiny_app && r.ended_at);
if (!runsWithApp.length) return '';
return `<div class="chips">${runsWithApp.map(r =>
shinyButtonHtml(row.id, r.id, r.shiny_app, row.chiffre, row.pseudonym)
).join('')}</div>`;
}
function renderAusgefuellt(row) {
const r = primaryRun(row);
if (!r) return '<span style="color:#ccc;font-size:12px">—</span>';
if (r.ended_at) {
// Abgeschlossen ist bei formr final - kein "vstat"-Id, kein Refresh-Button,
// wird von verifyAll() dadurch automatisch nicht mehr erneut abgefragt
return endedBadgeHtml(r.ended_at);
}
const refresh = `<button class="btn-icon" style="font-size:10px;padding:1px 4px;margin-left:4px" onclick="verifySession(${row.id},${r.id},true)" title="Jetzt live prüfen">↻</button>`;
return `<span class="vstat" id="vstat-${row.id}-${r.id}" style="color:#999;font-size:12px">…</span>${refresh}`;
}
let currentRegRowId = null;
function openRunSelectModal(rowId) {
currentRegRowId = rowId;
const unregistered = allRuns.filter(r => {
const tr = rowEl(rowId);
// re-derive registered run ids from DOM chips
const registered = new Set(
[...(tr ? tr.querySelectorAll('[id^="chip-"]') : [])].map(el => parseInt(el.id.split('-')[2]))
);
return !registered.has(r.id);
});
const list = document.getElementById('runSelectList');
list.innerHTML = groupedRuns(unregistered).map(g => `
<div class="reg-group">
${g.kategorie ? `<div class="reg-group-header" style="font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:#7b5800;margin-top:8px;margin-bottom:2px">${esc(g.kategorie)}</div>` : ''}
${g.runs.map(r =>
`<label style="display:flex;align-items:center;gap:7px;font-size:13px;padding:3px 0;cursor:pointer">
<input type="radio" name="run-select-modal" value="${r.id}"> ${esc(r.label || r.name)}
</label>`
).join('')}
</div>
`).join('');
document.getElementById('runSelectResult').textContent = '';
document.getElementById('runSelectOverlay').classList.add('open');
}
function closeRunSelectModal() {
document.getElementById('runSelectOverlay').classList.remove('open');
currentRegRowId = null;
}
async function submitRunSelectModal() {
if (!currentRegRowId) return;
const checked = document.querySelector('input[name="run-select-modal"]:checked');
const resEl = document.getElementById('runSelectResult');
if (!checked) { resEl.textContent = 'Bitte Run auswählen.'; return; }
const runIds = [parseInt(checked.value)];
resEl.textContent = '…';
try {
const res = await api(`/api/eintraege/${currentRegRowId}/register`, {
method: 'POST', body: JSON.stringify({ run_ids: runIds })
});
const results = res.reg_results || {};
const all_ok = Object.values(results).every(v => v.ok);
const errors = Object.entries(results).filter(([,v]) => !v.ok);
if (all_ok) {
const first = Object.values(results)[0] || {};
const existed = first.already_existed;
const verified = first.verified;
const label = existed ? '↩ Bereits vorhanden — gespeichert' : '✓ Registriert';
const vText = verified === true ? ' — Session bestätigt ✓'
: verified === false ? ' — Session nicht gefunden ⚠' : '';
resEl.innerHTML = `<span style="color:#2e7d32">${label}${vText}</span>`;
setTimeout(async () => { closeRunSelectModal(); await loadRows(); }, 900);
} else {
const errText = errors.map(([id, v]) => {
const run = allRuns.find(r => String(r.id) === id);
return `${run ? run.name : id}: ${v.error}`;
}).join('; ');
resEl.innerHTML = `<span style="color:#c0392b">Fehler: ${esc(errText)}</span>`;
loadRows();
}
} catch (e) { resEl.innerHTML = `<span style="color:#c0392b">${esc(e.message)}</span>`; }
}
async function verifyAll() {
if (!apiUrl) return;
const els = [...document.querySelectorAll('[id^="vstat-"]')];
await Promise.all(els.map(el => {
const parts = el.id.split('-');
if (parts.length !== 3) return;
return verifySession(parseInt(parts[1]), parseInt(parts[2]));
}));
}
function fmtPlainDate(s) {
const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(s || '');
return m ? `${m[3]}.${m[2]}.${m[1]}` : (s || '');
}
function fmtEndedDate(s) {
if (!s) return '';
const d = new Date(s.replace(' ', 'T'));
if (isNaN(d)) return s.slice(0, 10);
return d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' });
}
async function verifySession(rowId, runId, force) {
const el = document.getElementById(`vstat-${rowId}-${runId}`);
const chip = document.getElementById(`chip-${rowId}-${runId}`);
if (!el) return;
el.innerHTML = '<span style="color:#999;font-size:12px">…</span>';
try {
const res = await api(`/api/eintraege/${rowId}/verify?run_id=${runId}${force ? '&force=1' : ''}`);
if (res.found === true && res.ended) {
if (chip) chip.className = 'chip chip-ended';
const tr = rowEl(rowId);
// row-t00 hat Vorrang vor row-ended (siehe rowHighlightClass) - nicht ueberschreiben
if (tr && !tr.classList.contains('row-t00')) tr.classList.add('row-ended');
// Final - Zelle inkl. Refresh-Button komplett durch statische Ansicht ersetzen,
// damit kuenftige verifyAll()-Laeufe diese Zeile nicht mehr anfassen
const td = el.closest('td');
if (td) td.innerHTML = endedBadgeHtml(res.ended);
return;
}
if (res.found === true) {
el.innerHTML = '<span style="color:#2e7d32;font-size:12px" title="Session aktiv, noch nicht abgeschlossen">läuft</span>';
} else if (res.found === false) {
el.innerHTML = '<span style="color:#c0392b;font-size:12px" title="Session nicht in formr gefunden">nicht gefunden</span>';
} else {
el.innerHTML = `<span style="color:#e65100;font-size:12px" title="${esc(res.error || 'Prüfung nicht möglich')}">?</span>`;
}
} catch (_) { el.innerHTML = '<span style="color:#c0392b;font-size:12px">!</span>'; }
}
// ===== New entry =====
function newCode() { document.getElementById('fPseudonym').value = generateCode(); }
function validateChiffre(input) {
const v = input.value;
if (!v) { input.classList.remove('err', 'ok'); return true; }
const ok = CHIFFRE_RE.test(v);
input.classList.toggle('err', !ok);
input.classList.toggle('ok', ok);
return ok;
}
async function saveNew() {
const chiffre = document.getElementById('fChiffre').value.trim();
const pseudonym = document.getElementById('fPseudonym').value.trim();
const datum = document.getElementById('fDatum').value || today();
const notiz = document.getElementById('fNotiz').value.trim();
const errEl = document.getElementById('formErr');
const run_ids = selectedRunIds();
if (chiffre && !CHIFFRE_RE.test(chiffre)) {
errEl.textContent = 'Chiffre: 1 Großbuchstabe + genau 6 Ziffern (z.B. A123456)';
return;
}
if (!pseudonym) { errEl.textContent = 'Pseudonym fehlt.'; return; }
errEl.textContent = '';
try {
const res = await api('/api/eintraege', {
method: 'POST',
body: JSON.stringify({ chiffre, pseudonym, datum, notiz, run_ids })
});
if (run_ids.length) {
const errs = Object.entries(res.reg_results || {}).filter(([,v]) => !v.ok);
if (errs.length) {
const msg = errs.map(([id, v]) => {
const run = allRuns.find(r => String(r.id) === id);
return `${run ? run.name : id}: ${v.error}`;
}).join('\n');
errEl.textContent = 'Gespeichert, aber Registrierungsfehler:\n' + msg;
}
}
document.getElementById('fChiffre').value = '';
document.getElementById('fChiffre').classList.remove('ok', 'err');
document.getElementById('fNotiz').value = '';
closeModal('newEntry');
// Neue Eintraege sind nie archiviert - bei Bedarf zurueck zum Aktiv-Tab, sonst waere die
// gerade angelegte Zeile im Archiviert-Tab unsichtbar
if (viewArchived) switchView(false); else await loadRows();
} catch (e) { errEl.textContent = 'Fehler: ' + e.message; }
}
// ===== Row actions =====
async function updateField(id, field, value) {
try {
await api(`/api/eintraege/${id}`, { method: 'PUT', body: JSON.stringify({ [field]: value }) });
if (field === 'chiffre') {
const tr = rowEl(id);
if (tr) {
tr.dataset.chiffre = value;
tr.classList.toggle('row-t00', value.toUpperCase().startsWith('T00'));
refreshShinyButtons();
}
}
} catch (_) {}
}
async function archiveRow(id) {
// Archivieren ist jederzeit rückgängig zu machen (siehe restoreRow) - keine Rückfrage nötig
await api(`/api/eintraege/${id}`, { method: 'PUT', body: JSON.stringify({ archived: true }) });
loadRows();
}
async function restoreRow(id) {
await api(`/api/eintraege/${id}`, { method: 'PUT', body: JSON.stringify({ archived: false }) });
loadRows();
}
async function hardDeleteRow(id, chiffre) {
const msg = chiffre
? `Eintrag „${chiffre}" endgültig löschen? Das kann nicht rückgängig gemacht werden.`
: 'Eintrag endgültig löschen? Das kann nicht rückgängig gemacht werden.';
if (!confirm(msg)) return;
await api(`/api/eintraege/${id}`, { method: 'DELETE' });
loadRows();
}
// ===== Import =====
function importJSON(input) {
const file = input.files[0];
if (!file) return;
const replace = !confirm('Zusammenführen?\n\nOK = Zusammenführen (bestehende Daten bleiben)\nAbbrechen = Ersetzen');
const reader = new FileReader();
reader.onload = async e => {
try {
const entries = JSON.parse(e.target.result);
if (!Array.isArray(entries)) throw new Error('Kein Array');
const res = await api('/api/import/json', {
method: 'POST', body: JSON.stringify({ entries, replace })
});
alert('Import abgeschlossen. ' + res.count + ' Einträge gesamt.');
loadRows();
} catch (err) { alert('Fehler beim Import: ' + err.message); }
};
reader.readAsText(file);
input.value = '';
}
// ===== Init =====
(async () => {
updateSortIcons();
refreshAmdpButton();
// Vier unabhaengige Requests - keiner braucht das Ergebnis eines anderen, nur
// loadRows() danach braucht sie alle (apiUrl, shinyStatus, allRuns) fertig
await Promise.all([loadConfig(), loadShinyApps(), pollShinyStatus(), loadRuns(), loadCounts()]);
await loadRows();
})();
</script>
</body>
</html>