wiki/e-compliance/ims/cockpit/build_cockpit.py

602 lines
26 KiB
Python

#!/usr/bin/env python3
"""IMS Management Cockpit Generator.
Liest die IMS-Markdown-Dokumente (Ordner ../), parst die Dokumentenlenkungs-Header,
konvertiert Markdown -> HTML und erzeugt EINE in sich geschlossene Datei
``IMS-Cockpit.html`` (Mermaid inline), die per Doppelklick (file://) offline nutzbar ist.
Aufruf: python build_cockpit.py
Ergebnis: IMS-Cockpit.html im selben Ordner.
Keine externen Python-Pakete nötig. Mermaid wird einmalig von einem CDN geladen und
in ../cockpit/_vendor/ zwischengespeichert, danach in die HTML eingebettet (offline).
"""
import os
import re
import json
import html
import datetime
import urllib.request
HERE = os.path.dirname(os.path.abspath(__file__))
IMS_DIR = os.path.dirname(HERE) # .../ims
OUT_FILE = os.path.join(HERE, "IMS-Cockpit.html")
VENDOR_DIR = os.path.join(HERE, "_vendor")
MERMAID_URL = "https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js"
CHAPTER_LABELS = {
"00": "Handbuch",
"01_kontext": "4 - Kontext",
"02_fuehrung": "5 - Führung",
"03_planung": "6 - Planung",
"04_unterstuetzung": "7 - Unterstützung",
"05_betrieb": "8 - Betrieb / Annex A",
"06_bewertung": "9 - Bewertung",
"07_verbesserung": "10 - Verbesserung",
"mappings": "Mappings",
}
CHAPTER_ORDER = list(CHAPTER_LABELS.keys())
TODAY = datetime.date.today()
# --------------------------------------------------------------------------- #
# Markdown -> HTML (dependency-free) #
# --------------------------------------------------------------------------- #
def _inline(text):
codes = []
def _grab(m):
codes.append(m.group(1))
return "\x00C%d\x00" % (len(codes) - 1)
text = re.sub(r"`([^`]+)`", _grab, text)
text = html.escape(text, quote=False)
text = re.sub(
r"\[([^\]]+)\]\(([^)]+)\)",
lambda m: '<a href="%s">%s</a>' % (html.escape(m.group(2), quote=True), m.group(1)),
text,
)
text = re.sub(r"\*\*([^*]+)\*\*", r"<strong>\1</strong>", text)
text = re.sub(r"(?<!\*)\*(?!\*)([^*]+?)\*(?!\*)", r"<em>\1</em>", text)
def _restore(m):
return "<code>" + html.escape(codes[int(m.group(1))], quote=False) + "</code>"
return re.sub(r"\x00C(\d+)\x00", _restore, text)
def _cells(row):
row = row.strip()
if row.startswith("|"):
row = row[1:]
if row.endswith("|"):
row = row[:-1]
return [c.strip() for c in row.split("|")]
def _table(header, rows):
head = "".join("<th>%s</th>" % _inline(c) for c in _cells(header))
body = ""
for r in rows:
body += "<tr>" + "".join("<td>%s</td>" % _inline(c) for c in _cells(r)) + "</tr>"
return "<table><thead><tr>%s</tr></thead><tbody>%s</tbody></table>" % (head, body)
def md_to_html(md):
lines = md.split("\n")
out = []
i, n = 0, len(lines)
while i < n:
line = lines[i]
if re.match(r"^\s*<!--.*-->\s*$", line):
i += 1
continue
m = re.match(r"^```(\w*)\s*$", line.strip())
if m:
lang = m.group(1)
i += 1
buf = []
while i < n and not lines[i].strip().startswith("```"):
buf.append(lines[i])
i += 1
i += 1
code = "\n".join(buf)
if lang == "mermaid":
out.append('<pre class="mermaid">%s</pre>' % html.escape(code, quote=False))
else:
out.append('<pre class="code"><code>%s</code></pre>' % html.escape(code, quote=False))
continue
if line.strip() == "":
i += 1
continue
if re.match(r"^\s*(---|\*\*\*|___)\s*$", line):
out.append("<hr>")
i += 1
continue
hm = re.match(r"^(#{1,6})\s+(.*)$", line)
if hm:
lvl = len(hm.group(1))
out.append("<h%d>%s</h%d>" % (lvl, _inline(hm.group(2).strip()), lvl))
i += 1
continue
if "|" in line and i + 1 < n and "-" in lines[i + 1] and re.match(r"^\s*\|?[\s:|-]+\|?\s*$", lines[i + 1]):
header = line
i += 2
rows = []
while i < n and "|" in lines[i] and lines[i].strip() != "":
rows.append(lines[i])
i += 1
out.append(_table(header, rows))
continue
if re.match(r"^\s*>\s?", line):
buf = []
while i < n and re.match(r"^\s*>\s?", lines[i]):
buf.append(re.sub(r"^\s*>\s?", "", lines[i]))
i += 1
out.append("<blockquote>%s</blockquote>" % _inline(" ".join(buf)))
continue
if re.match(r"^\s*([-*+])\s+", line) or re.match(r"^\s*\d+\.\s+", line):
ordered = bool(re.match(r"^\s*\d+\.\s+", line))
tag = "ol" if ordered else "ul"
items = []
while i < n and (re.match(r"^\s*([-*+])\s+", lines[i]) or re.match(r"^\s*\d+\.\s+", lines[i])):
item = re.sub(r"^\s*([-*+]|\d+\.)\s+", "", lines[i])
items.append("<li>%s</li>" % _inline(item))
i += 1
out.append("<%s>%s</%s>" % (tag, "".join(items), tag))
continue
buf = [line]
i += 1
while i < n and lines[i].strip() != "" and not re.match(
r"^(#{1,6})\s|^\s*```|^\s*([-*+])\s+|^\s*\d+\.\s+|^\s*>\s?", lines[i]
) and "|" not in lines[i]:
buf.append(lines[i])
i += 1
out.append("<p>%s</p>" % _inline(" ".join(b.strip() for b in buf)))
return "\n".join(out)
# --------------------------------------------------------------------------- #
# Parsing helpers #
# --------------------------------------------------------------------------- #
def parse_header(text):
"""Parse only the contiguous comment block at the very top of the file."""
meta = {}
for line in text.split("\n"):
s = line.strip()
if s == "":
continue
m = re.match(r"^<!--\s*([\w]+)\s*:\s*(.*?)\s*-->$", s)
if m:
meta[m.group(1)] = m.group(2).strip()
continue
break # first non-comment, non-blank line ends the header block
return meta
def doc_title(text, fallback):
m = re.search(r"^#\s+(.*)$", text, re.M)
return m.group(1).strip() if m else fallback
def parse_date(value):
if not value:
return None
try:
return datetime.datetime.strptime(value.strip(), "%Y-%m-%d").date()
except ValueError:
return None
def find_zu_pruefen(text):
hits = []
current = ""
for line in text.split("\n"):
hm = re.match(r"^#{1,6}\s+(.*)$", line)
if hm:
current = hm.group(1).strip()
scan = re.sub(r"`[^`]*`", "", line) # Inline-Code (Marker-Erklaerungen) ignorieren
if re.search(r"ZU\s*PR(?:UE|\u00dc)FEN", scan, re.I):
hits.append(current or "(Dokumentanfang)")
return hits
def parse_massnahmen(text):
"""Parse the markdown action register table -> list of dict."""
rows = []
lines = text.split("\n")
header_idx = None
for idx, line in enumerate(lines):
if "|" in line and re.search(r"\bID\b", line) and re.search(r"Titel", line, re.I):
nxt = lines[idx + 1] if idx + 1 < len(lines) else ""
if "-" in nxt and re.match(r"^\s*\|?[\s:|-]+\|?\s*$", nxt):
header_idx = idx
break
if header_idx is None:
return rows
cols = [c.strip().lower() for c in _cells(lines[header_idx])]
for line in lines[header_idx + 2:]:
if "|" not in line or line.strip() == "":
break
vals = _cells(line)
if len(vals) < len(cols):
continue
row = dict(zip(cols, vals))
rows.append(
{
"id": row.get("id", ""),
"titel": row.get("titel", ""),
"owner": row.get("owner", ""),
"quelle": row.get("quelle", ""),
"faellig": row.get("faellig", "") or row.get("f\u00e4llig", ""),
"status": row.get("status", ""),
}
)
return rows
# --------------------------------------------------------------------------- #
# Collect documents #
# --------------------------------------------------------------------------- #
def collect_docs():
docs = []
for root, dirs, files in os.walk(IMS_DIR):
dirs[:] = [d for d in dirs if d not in ("cockpit", "_vendor")]
for fn in files:
if not fn.endswith(".md"):
continue
full = os.path.join(root, fn)
rel = os.path.relpath(full, IMS_DIR).replace("\\", "/")
with open(full, "r", encoding="utf-8") as fh:
text = fh.read()
parts = rel.split("/")
chapter = "00" if len(parts) == 1 else parts[0]
meta = parse_header(text)
next_review = parse_date(meta.get("nextReview"))
overdue = bool(next_review and next_review < TODAY and meta.get("status") not in ("abgeloest", "abgelöst"))
docs.append(
{
"id": rel,
"path": rel,
"chapter": chapter,
"title": doc_title(text, fn),
"filename": fn,
"meta": meta,
"html": md_to_html(text),
"text": re.sub(r"\s+", " ", text).lower(),
"nextReview": meta.get("nextReview", ""),
"overdueReview": overdue,
"zuPruefen": find_zu_pruefen(text),
}
)
docs.sort(key=lambda d: (CHAPTER_ORDER.index(d["chapter"]) if d["chapter"] in CHAPTER_ORDER else 99, d["filename"]))
return docs
def get_mermaid_js():
os.makedirs(VENDOR_DIR, exist_ok=True)
cache = os.path.join(VENDOR_DIR, "mermaid.min.js")
if os.path.exists(cache):
with open(cache, "r", encoding="utf-8") as fh:
return fh.read()
try:
print("Lade mermaid.min.js vom CDN ...")
req = urllib.request.Request(MERMAID_URL, headers={"User-Agent": "Mozilla/5.0"})
data = urllib.request.urlopen(req, timeout=30).read().decode("utf-8")
with open(cache, "w", encoding="utf-8") as fh:
fh.write(data)
return data
except Exception as exc: # noqa: BLE001
print("WARNUNG: mermaid.min.js konnte nicht geladen werden (%s). Diagramme werden als Code angezeigt." % exc)
return ""
# --------------------------------------------------------------------------- #
# HTML assembly #
# --------------------------------------------------------------------------- #
def build():
docs = collect_docs()
pendenzen_reg = []
for d in docs:
if d["filename"] == "massnahmen-register.md":
with open(os.path.join(IMS_DIR, d["path"]), "r", encoding="utf-8") as fh:
pendenzen_reg = parse_massnahmen(fh.read())
overdue_reviews = [
{"id": d["id"], "title": d["title"], "owner": d["meta"].get("owner", ""), "nextReview": d["nextReview"]}
for d in docs
if d["overdueReview"]
]
zu_pruefen = [
{"id": d["id"], "title": d["title"], "section": s}
for d in docs
for s in d["zuPruefen"]
]
status_counts = {}
for d in docs:
st = d["meta"].get("status", "unbekannt")
status_counts[st] = status_counts.get(st, 0) + 1
open_reg = [p for p in pendenzen_reg if p["status"].lower() != "erledigt"]
overdue_reg = [p for p in open_reg if (parse_date(p["faellig"]) and parse_date(p["faellig"]) < TODAY)]
stats = {
"docs": len(docs),
"statusCounts": status_counts,
"overdueReviews": len(overdue_reviews),
"openPendenzen": len(open_reg),
"overduePendenzen": len(overdue_reg),
"generated": TODAY.isoformat(),
}
chapters = [{"key": k, "label": CHAPTER_LABELS[k]} for k in CHAPTER_ORDER]
payload = {
"docs": docs,
"chapters": chapters,
"pendenzenReg": pendenzen_reg,
"overdueReviews": overdue_reviews,
"zuPruefen": zu_pruefen,
"stats": stats,
"today": TODAY.isoformat(),
}
data_json = json.dumps(payload, ensure_ascii=False).replace("</", "<\\/")
mermaid_js = get_mermaid_js()
html_out = HTML_TEMPLATE.replace("/*__DATA__*/", "window.IMS_DATA = " + data_json + ";")
html_out = html_out.replace("/*__MERMAID__*/", mermaid_js)
with open(OUT_FILE, "w", encoding="utf-8") as fh:
fh.write(html_out)
print("OK: %s (%d Dokumente, %d offene Pendenzen, %d überfällige Reviews)" % (
OUT_FILE, stats["docs"], stats["openPendenzen"], stats["overdueReviews"]))
HTML_TEMPLATE = r"""<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>PowerOn IMS - Management Cockpit</title>
<style>
:root{
--bg:#0f1729; --panel:#16213a; --panel2:#1d2a48; --line:#2a3a5e;
--text:#e7edf7; --muted:#93a3c0; --accent:#4f8cff; --accent2:#6ee7b7;
--green:#34d399; --amber:#fbbf24; --red:#f87171; --grey:#8aa0c0;
}
*{box-sizing:border-box}
body{margin:0;font-family:'Segoe UI',system-ui,-apple-system,sans-serif;background:var(--bg);color:var(--text);font-size:15px;line-height:1.55}
a{color:var(--accent);text-decoration:none}
a:hover{text-decoration:underline}
.app{display:flex;min-height:100vh}
/* sidebar */
.sidebar{width:320px;flex:0 0 320px;background:var(--panel);border-right:1px solid var(--line);height:100vh;position:sticky;top:0;overflow-y:auto;padding:0}
.brand{padding:18px 20px;border-bottom:1px solid var(--line)}
.brand h1{font-size:16px;margin:0 0 2px}
.brand .sub{color:var(--muted);font-size:12px}
.search{padding:12px 16px;border-bottom:1px solid var(--line)}
.search input{width:100%;padding:9px 12px;border-radius:8px;border:1px solid var(--line);background:var(--bg);color:var(--text);font-size:13px}
.nav{padding:8px 0 40px}
.nav .special{padding:9px 20px;cursor:pointer;display:flex;align-items:center;gap:8px;font-weight:600}
.nav .special:hover{background:var(--panel2)}
.chapter{margin-top:6px}
.chapter > .ctitle{padding:8px 20px 4px;color:var(--muted);font-size:11px;text-transform:uppercase;letter-spacing:.06em}
.nav .item{padding:6px 20px 6px 26px;cursor:pointer;font-size:13.5px;display:flex;gap:8px;align-items:center}
.nav .item:hover{background:var(--panel2)}
.nav .item.active{background:var(--accent);color:#fff}
.dot{width:8px;height:8px;border-radius:50%;flex:0 0 8px}
.dot.freigegeben{background:var(--green)} .dot.entwurf{background:var(--amber)}
.dot.abgeloest{background:var(--grey)} .dot.unbekannt{background:var(--line)}
.badge-od{margin-left:auto;background:var(--red);color:#fff;border-radius:10px;padding:0 7px;font-size:10px}
/* main */
.main{flex:1;min-width:0;display:flex;flex-direction:column}
.topbar{padding:14px 28px;border-bottom:1px solid var(--line);display:flex;align-items:center;gap:16px;background:var(--panel)}
.topbar .crumbs{color:var(--muted);font-size:13px}
.topbar .gen{margin-left:auto;color:var(--muted);font-size:12px}
.content{padding:30px 38px;max-width:1000px}
.metabar{display:flex;flex-wrap:wrap;gap:8px;margin:0 0 22px;padding:12px 16px;background:var(--panel);border:1px solid var(--line);border-radius:10px}
.chip{font-size:12px;padding:3px 10px;border-radius:20px;background:var(--panel2);color:var(--muted);border:1px solid var(--line)}
.chip b{color:var(--text);font-weight:600}
.chip.s-freigegeben{border-color:var(--green);color:var(--green)}
.chip.s-entwurf{border-color:var(--amber);color:var(--amber)}
.chip.s-abgeloest{border-color:var(--grey);color:var(--grey)}
.chip.od{border-color:var(--red);color:var(--red)}
/* doc typography */
.doc h1{font-size:26px;margin:.2em 0 .6em;border-bottom:1px solid var(--line);padding-bottom:.3em}
.doc h2{font-size:20px;margin:1.3em 0 .5em}
.doc h3{font-size:16px;margin:1.1em 0 .4em}
.doc p{margin:.6em 0}
.doc table{border-collapse:collapse;width:100%;margin:1em 0;font-size:13.5px}
.doc th,.doc td{border:1px solid var(--line);padding:7px 10px;text-align:left;vertical-align:top}
.doc th{background:var(--panel2)}
.doc tr:nth-child(even) td{background:rgba(255,255,255,.02)}
.doc code{background:var(--panel2);padding:1px 6px;border-radius:5px;font-size:.9em;font-family:'Cascadia Code',Consolas,monospace}
.doc pre.code{background:#0b1220;border:1px solid var(--line);border-radius:8px;padding:14px;overflow:auto}
.doc pre.code code{background:none;padding:0}
.doc blockquote{margin:1em 0;padding:.4em 1em;border-left:3px solid var(--accent);background:var(--panel);color:var(--muted)}
.doc hr{border:none;border-top:1px solid var(--line);margin:1.5em 0}
.doc .mermaid{background:#0b1220;border:1px solid var(--line);border-radius:8px;padding:16px;margin:1em 0;text-align:center}
/* dashboard */
.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:16px;margin-bottom:26px}
.card{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:18px 20px}
.card .num{font-size:30px;font-weight:700}
.card .lbl{color:var(--muted);font-size:13px;margin-top:4px}
.card.alert .num{color:var(--red)}
.card.ok .num{color:var(--green)}
.section-h{font-size:17px;margin:26px 0 10px;font-weight:600}
.filters{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:12px}
.filters select,.filters button{background:var(--panel2);color:var(--text);border:1px solid var(--line);border-radius:7px;padding:6px 10px;font-size:13px;cursor:pointer}
table.grid{border-collapse:collapse;width:100%;font-size:13.5px}
table.grid th,table.grid td{border:1px solid var(--line);padding:7px 10px;text-align:left}
table.grid th{background:var(--panel2);position:sticky;top:0}
tr.od td{background:rgba(248,113,113,.12)}
.tag{font-size:11px;padding:1px 8px;border-radius:10px;border:1px solid var(--line)}
.tag.q-Audit{color:#c4b5fd;border-color:#c4b5fd}
.tag.q-Risiko{color:var(--amber);border-color:var(--amber)}
.tag.q-Review{color:var(--accent2);border-color:var(--accent2)}
.tag.q-Incident{color:var(--red);border-color:var(--red)}
.tag.q-Doku{color:var(--accent);border-color:var(--accent)}
.pill{font-size:11px;padding:1px 8px;border-radius:10px}
.pill.offen{background:rgba(251,191,36,.18);color:var(--amber)}
.pill.erledigt{background:rgba(52,211,153,.18);color:var(--green)}
.pill.inArbeit{background:rgba(79,140,255,.18);color:var(--accent)}
.hidden{display:none}
@media(max-width:820px){.sidebar{display:none}.content{padding:20px}}
</style>
</head>
<body>
<div class="app">
<aside class="sidebar">
<div class="brand"><h1>PowerOn IMS</h1><div class="sub">Integriertes Managementsystem - ISO 9001 + 27001</div></div>
<div class="search"><input id="q" placeholder="Dokumente durchsuchen ..."></div>
<nav class="nav" id="nav"></nav>
</aside>
<main class="main">
<div class="topbar"><div class="crumbs" id="crumbs">Dashboard</div><div class="gen" id="gen"></div></div>
<div class="content" id="content"></div>
</main>
</div>
<script>/*__MERMAID__*/</script>
<script>/*__DATA__*/</script>
<script>
const D = window.IMS_DATA;
const byId = {}; D.docs.forEach(d => byId[d.id] = d);
const hasMermaid = (typeof window.mermaid !== 'undefined');
if (hasMermaid) { try { mermaid.initialize({startOnLoad:false, theme:'dark'}); } catch(e){} }
document.getElementById('gen').textContent = 'Generiert: ' + D.stats.generated;
function statusClass(s){ return ({'freigegeben':'freigegeben','entwurf':'entwurf','abgeloest':'abgeloest','abgelöst':'abgeloest'})[s] || 'unbekannt'; }
function buildNav(filter){
filter = (filter||'').toLowerCase();
const nav = document.getElementById('nav');
let h = '';
h += '<div class="special" onclick="showDashboard()">&#9632; Dashboard</div>';
h += '<div class="special" onclick="showPendenzen()">&#9888; Pendenzen';
if (D.stats.openPendenzen) h += ' <span class="badge-od">'+D.stats.openPendenzen+'</span>';
h += '</div>';
D.chapters.forEach(ch => {
const items = D.docs.filter(d => d.chapter === ch.key && (!filter || (d.title.toLowerCase().includes(filter) || d.text.includes(filter))));
if (!items.length) return;
h += '<div class="chapter"><div class="ctitle">'+ch.label+'</div>';
items.forEach(d => {
h += '<div class="item" data-id="'+encodeURIComponent(d.id)+'" onclick="showDoc(\''+encodeURIComponent(d.id)+'\')">'
+ '<span class="dot '+statusClass(d.meta.status)+'"></span>'
+ '<span>'+d.title+'</span>'
+ (d.overdueReview ? '<span class="badge-od">Review</span>' : '')
+ '</div>';
});
h += '</div>';
});
nav.innerHTML = h;
}
function setActive(id){
document.querySelectorAll('.nav .item').forEach(el=>el.classList.toggle('active', el.dataset.id===encodeURIComponent(id)));
}
function runMermaid(scope){
if(!hasMermaid) return;
const nodes = scope.querySelectorAll('.mermaid');
if(nodes.length){ try{ mermaid.run({nodes}); }catch(e){ console.warn(e); } }
}
function showDoc(encId){
const id = decodeURIComponent(encId);
const d = byId[id]; if(!d) return;
const m = d.meta || {};
const od = d.overdueReview;
let chips = '<span class="chip s-'+statusClass(m.status)+'">'+(m.status||'unbekannt')+'</span>';
if(m.version) chips += '<span class="chip">v<b>'+m.version+'</b></span>';
if(m.owner) chips += '<span class="chip">Owner: <b>'+m.owner+'</b></span>';
if(m.classification) chips += '<span class="chip">'+m.classification+'</span>';
if(d.nextReview) chips += '<span class="chip '+(od?'od':'')+'">Review: <b>'+d.nextReview+'</b>'+(od?' (überfällig)':'')+'</span>';
if(m.isoRefs) chips += '<span class="chip">ISO: <b>'+m.isoRefs+'</b></span>';
let warn = '';
if(d.zuPruefen && d.zuPruefen.length){
warn = '<div class="metabar" style="border-color:var(--amber)"><span class="chip od">&#9888; '+d.zuPruefen.length+' offene Punkte ([ZU PRÜFEN]) in diesem Dokument</span></div>';
}
document.getElementById('crumbs').textContent = d.path;
const c = document.getElementById('content');
c.innerHTML = '<div class="metabar">'+chips+'</div>'+warn+'<div class="doc">'+d.html+'</div>';
setActive(id); window.scrollTo(0,0); runMermaid(c);
}
function showDashboard(){
document.getElementById('crumbs').textContent = 'Dashboard';
setActive('__none__');
const s = D.stats;
let sc = '';
Object.keys(s.statusCounts).forEach(k=>{ sc += '<span class="chip s-'+statusClass(k)+'">'+k+': <b>'+s.statusCounts[k]+'</b></span> '; });
let h = '<div class="doc"><h1>IMS Management Cockpit</h1></div>';
h += '<div class="cards">';
h += '<div class="card"><div class="num">'+s.docs+'</div><div class="lbl">Dokumente</div></div>';
h += '<div class="card '+(s.openPendenzen?'':'ok')+'"><div class="num">'+s.openPendenzen+'</div><div class="lbl">Offene Pendenzen</div></div>';
h += '<div class="card '+(s.overduePendenzen?'alert':'ok')+'"><div class="num">'+s.overduePendenzen+'</div><div class="lbl">Überfällige Pendenzen</div></div>';
h += '<div class="card '+(s.overdueReviews?'alert':'ok')+'"><div class="num">'+s.overdueReviews+'</div><div class="lbl">Überfällige Reviews</div></div>';
h += '</div>';
h += '<div class="metabar">'+sc+'</div>';
h += '<div class="section-h">Nächste Schritte</div>';
h += '<p style="color:var(--muted)">Öffne die Seite <a href="#" onclick="showPendenzen();return false;">Pendenzen</a> für die zusammengeführte Aufgabenliste, oder wähle links ein Dokument.</p>';
document.getElementById('content').innerHTML = h;
window.scrollTo(0,0);
}
let pFilter = {quelle:'', overdue:false};
function showPendenzen(){
document.getElementById('crumbs').textContent = 'Pendenzen';
setActive('__none__');
const today = D.today;
// register
const reg = D.pendenzenReg.filter(p=>p.status.toLowerCase()!=='erledigt');
const quellen = Array.from(new Set(D.pendenzenReg.map(p=>p.quelle).filter(Boolean)));
let h = '<div class="doc"><h1>Pendenzen</h1><p>Zusammengeführt aus Massnahmenregister, überfälligen Dokumenten-Reviews und [ZU PRÜFEN]-Markern.</p></div>';
// filters
h += '<div class="filters"><select id="fq" onchange="applyP()"><option value="">Alle Quellen</option>';
quellen.forEach(q=>{ h += '<option value="'+q+'"'+(pFilter.quelle===q?' selected':'')+'>'+q+'</option>'; });
h += '</select><button onclick="toggleOd()">'+(pFilter.overdue?'Nur überfällige: AN':'Nur überfällige: AUS')+'</button></div>';
// register table
h += '<div class="section-h">Massnahmenregister ('+reg.length+' offen)</div>';
h += '<table class="grid"><thead><tr><th>ID</th><th>Titel</th><th>Owner</th><th>Quelle</th><th>Fällig</th><th>Status</th></tr></thead><tbody>';
reg.forEach(p=>{
const od = p.faellig && p.faellig < today;
if(pFilter.quelle && p.quelle!==pFilter.quelle) return;
if(pFilter.overdue && !od) return;
const stcl = p.status.toLowerCase()==='in arbeit'?'inArbeit':p.status.toLowerCase();
h += '<tr class="'+(od?'od':'')+'"><td>'+p.id+'</td><td>'+p.titel+'</td><td>'+p.owner+'</td>'
+ '<td><span class="tag q-'+p.quelle+'">'+p.quelle+'</span></td>'
+ '<td>'+p.faellig+(od?' &#9888;':'')+'</td><td><span class="pill '+stcl+'">'+p.status+'</span></td></tr>';
});
h += '</tbody></table>';
// overdue reviews
h += '<div class="section-h">Überfällige Dokumenten-Reviews ('+D.overdueReviews.length+')</div>';
if(D.overdueReviews.length){
h += '<table class="grid"><thead><tr><th>Dokument</th><th>Owner</th><th>Review fällig</th></tr></thead><tbody>';
D.overdueReviews.forEach(r=>{ h += '<tr class="od"><td><a href="#" onclick="showDoc(\''+encodeURIComponent(r.id)+'\');return false;">'+r.title+'</a></td><td>'+r.owner+'</td><td>'+r.nextReview+'</td></tr>'; });
h += '</tbody></table>';
} else { h += '<p style="color:var(--muted)">Keine überfälligen Reviews.</p>'; }
// zu pruefen
h += '<div class="section-h">[ZU PRÜFEN]-Marker ('+D.zuPruefen.length+')</div>';
if(D.zuPruefen.length){
h += '<table class="grid"><thead><tr><th>Dokument</th><th>Abschnitt</th></tr></thead><tbody>';
D.zuPruefen.forEach(z=>{ h += '<tr><td><a href="#" onclick="showDoc(\''+encodeURIComponent(z.id)+'\');return false;">'+z.title+'</a></td><td>'+z.section+'</td></tr>'; });
h += '</tbody></table>';
} else { h += '<p style="color:var(--muted)">Keine offenen Marker.</p>'; }
document.getElementById('content').innerHTML = h;
window.scrollTo(0,0);
}
function applyP(){ pFilter.quelle = document.getElementById('fq').value; showPendenzen(); }
function toggleOd(){ pFilter.overdue = !pFilter.overdue; showPendenzen(); }
document.getElementById('q').addEventListener('input', e=>buildNav(e.target.value));
buildNav('');
showDashboard();
</script>
</body>
</html>
"""
if __name__ == "__main__":
build()