602 lines
26 KiB
Python
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()">■ Dashboard</div>';
|
|
h += '<div class="special" onclick="showPendenzen()">⚠ 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">⚠ '+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?' ⚠':'')+'</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()
|