1437 lines
64 KiB
HTML
1437 lines
64 KiB
HTML
<!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">💾 .db herunterladen</a>
|
||
<a href="/api/export/json" class="link-btn">📄 JSON</a>
|
||
<label class="link-btn" style="cursor:pointer">
|
||
📂 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')">⚙ Einstellungen & 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">▼</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">▼</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">▼</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">▼</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">▼</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">▼</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">▼</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 & 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,'&').replace(/"/g,'"').replace(/</g,'<').replace(/>/g,'>');
|
||
}
|
||
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">Ψ 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)}">Ψ AMDP</button>`;
|
||
} else if (st && !st.ready) {
|
||
main = `<button class="amdp-btn" disabled>Ψ AMDP</button>`;
|
||
} else {
|
||
main = `<button class="amdp-btn" data-app="${AMDP_APP}"
|
||
onclick="handleShinyClick(this)" title="AMDP starten">Ψ 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})">↩</button>
|
||
<button class="btn-x" title="Endgültig löschen" onclick="hardDeleteRow(${row.id},'${esc(row.chiffre || '')}')">×</button>
|
||
`;
|
||
}
|
||
return `<button class="btn-icon" title="Archivieren" onclick="archiveRow(${row.id})">🗃</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">📋</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">📋</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>
|