"""
SAP Claim Analyzer — Flask Monolith
Joins ZLSGS_Dataexport.csv with zlsgs_claimexport.csv to validate
milestone shift claims against aggregated delay totals.
Port: 5015
"""

import os
import re
import json
import csv
import datetime
import gzip
import hashlib
import pickle
import sqlite3
import statistics
import math
import threading
import time
from flask import Flask, jsonify, request, render_template_string, send_file, Response
from collections import defaultdict, Counter
from concurrent.futures import ThreadPoolExecutor, TimeoutError as FuturesTimeout
import io
import openpyxl
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
from openpyxl.utils import get_column_letter

STATIC_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'static')
app = Flask(__name__, static_folder=STATIC_DIR, static_url_path='/static')

BASE_DIR    = os.path.dirname(os.path.abspath(__file__))
DATA_DIR    = os.path.join(BASE_DIR, 'sap_data')
FAMILY_MAP_PATH = os.path.join(BASE_DIR, 'engine_families.json')
CONFIG_PATH     = os.path.join(BASE_DIR, 'sap_config.json')

# Pfad-Einstellungen (CSV-Quellen) und Datenbank-Einstellungen (Incidents).
# Incidents kommen seit 09/2026 ausschließlich aus der MTU-Hub-SQL — es gibt
# keine ServiceNow-CSV-Exporte mehr.
_CONFIG_PATH_KEYS = ('export_path', 'claim_path')

_CONFIG_DEFAULTS = {
    'export_path':       os.path.join(DATA_DIR, 'ZLSGS_Dataexport.csv'),
    'claim_path':        os.path.join(DATA_DIR, 'zlsgs_claimexport.csv'),
    'incident_database': '',                 # leer → aus sql_state.json
    'incident_table':    '',                 # leer → im Schema automatisch suchen
    'incident_cycle':    'Cycle 3',          # leer → alle Zyklen
    'mtuhub_demo':       False,              # True → Fake-Daten aus .demo/ statt MTU-Hub-SQL
    'module_visits':     'aus',              # Modul-Shop-Visits in Dashboards: aus | ein | nur
}

def load_config() -> dict:
    if os.path.exists(CONFIG_PATH):
        with open(CONFIG_PATH, encoding='utf-8') as f:
            content = f.read().strip()
            if content:
                stored = json.loads(content)
                # nur bekannte Schlüssel übernehmen (z. B. incident_path aus der CSV-Zeit)
                stored = {k: v for k, v in stored.items() if k in _CONFIG_DEFAULTS}
                return {**_CONFIG_DEFAULTS, **stored}
    return dict(_CONFIG_DEFAULTS)


# ── Demo-Modus ────────────────────────────────────────────────────────────────
# Ohne Firmennetz gibt es keine MTU-Hub-SQL. Mit MTUHUB_DEMO=1 (oder
# "mtuhub_demo": true in sap_config.json) liest der Analyzer stattdessen die
# Fake-Daten aus .demo/ (erzeugt von mtuhub_demo_data.py) — über denselben
# SQL-Code, nur die Verbindung ist eine SQLite-Datei mit T-SQL-Übersetzung.
# Bewusst KEIN automatischer Rückfall: Fake-Zahlen dürfen nie unbemerkt
# erscheinen. Deshalb auch ein Banner auf jeder Seite und ein eigener
# Cache-Ordner — sonst würde nach dem nächsten echten DB-Ausfall der Demo-Stand
# als „letzter guter Stand" gezeigt. Umschalten nur mit Neustart.
DEMO_DIR    = os.path.join(BASE_DIR, '.demo')
MTUHUB_DEMO = (os.environ.get('MTUHUB_DEMO', '').strip().lower() in ('1', 'true', 'ja', 'yes')
               or bool(load_config().get('mtuhub_demo')))
SQL_CACHE_DIR = os.path.join(BASE_DIR, '.parse_cache', 'demo') if MTUHUB_DEMO \
    else os.path.join(BASE_DIR, '.parse_cache')


def save_config(cfg: dict):
    with open(CONFIG_PATH, 'w', encoding='utf-8') as f:
        json.dump(cfg, f, indent=2, ensure_ascii=False)


_family_map_cache = None
_family_map_lock  = threading.Lock()


def _load_family_map() -> dict:
    """Prefix → family mapping, held in RAM (called once per engine row otherwise)."""
    global _family_map_cache
    if _family_map_cache is None:
        with _family_map_lock:
            if _family_map_cache is None:
                mapping = {}
                if os.path.exists(FAMILY_MAP_PATH):
                    with open(FAMILY_MAP_PATH, encoding='utf-8') as f:
                        content = f.read().strip()
                        if content:
                            mapping = json.loads(content)
                _family_map_cache = mapping
    return _family_map_cache


def _save_family_map(mapping: dict):
    global _family_map_cache
    with open(FAMILY_MAP_PATH, 'w', encoding='utf-8') as f:
        json.dump(dict(sorted(mapping.items())), f, indent=2, ensure_ascii=False)
    _family_map_cache = dict(mapping)

# Milestone columns present in ZLSGS_Dataexport, in process order
MILESTONE_COLS = [
    'TSC','TSP','A','CP','SRI','S0S','S0E','ITS','ITE','PC','RI','I',
    'DE','DM','CC','ED','S1','R1','SC','CS','ND','#','ON','CE','CR',
    'D1','D2','DC','R','M','B0',
    'B1R','B1','S2','R2','SU','B2R','B2','B3',
    'TS','TE','SA','S','SR','OI','P1','P2','P3','P4','TEC',
]

MILESTONE_LABELS = {
    'TSC': 'TAT Start Customer',   'TSP': 'TAT Start Planning',
    'A':   'Arrival',              'S0S': 'Shop Opening Start',
    'S0E': 'Shop Opening End',     'I':   'Induction',
    'DE':  'Disassembly End',      'DM':  'Disposition / Material',
    'CC':  'Critical Components',  '#':   'Parts Lock',
    'ON':  'On-Order Notice',      'D1':  'Delivery 1',
    'D2':  'Delivery 2',           'DC':  'Delivery Complete',
    'R':   'Reassembly Start',     'B0':  'Build 0',
    'B1':  'Build 1',              'B2':  'Build 2',
    'B3':  'Build 3',              'TS':  'Test Start',
    'TE':  'Test End',             'SA':  'Shipment Authorization',
    'S':   'Shipment',             'TEC': 'TAT End Customer',
    'B1R': 'Build 1 Ready',        'B2R': 'Build 2 Ready',
    'SU':  'Stand Up',             'SC':  'Scope Complete',
    'CS':  'Customer Signature',
}

NOTIF_TYPE_LABELS = {
    'P0': 'Customer Service',
    'P1': 'Milestone Confirmation',
    'P7': 'MRO Claim (Shift)',
    'P9': 'Quality',
    'P3': 'Internal',
    'P5': 'Supplier',
}

OTD_ORDER = {'Red': 0, 'Yellow': 1, 'Green': 2, '': 3}

# Endtermin für den Endverzug, in Rückfall-Reihenfolge (erster vorhandener gewinnt)
END_DELAY_MILESTONES = ('TEC', 'SA', 'S')
END_DELAY_MAX_ABS    = 730    # |Endverzug| darüber = Platzhalter-Datum, nicht auswerten

# Cycle definitions: each cycle has a start/end milestone as boundary
CYCLE_DEFS = [
    # Used for Soll/Ist duration calculation (backend).
    # Boundaries use R (Release) because it is reliably populated across all workscopes.
    # Visual grouping in the MS table uses a separate JS definition (CYCLE_DEFS_JS) with B0 as boundary.
    # queue_only=True: no Fischgräte template for this phase, only operational actual
    {'name': 'Cycle 0', 'label': 'Queue-Zeit (Wartezeit bis Induction)', 'start': 'TSP', 'end': 'I',   'queue_only': True},
    {'name': 'Cycle 1', 'label': 'Demontage & Inspektion',               'start': 'I',   'end': '#'},
    {'name': 'Cycle 2', 'label': 'Material & Dispo',                     'start': '#',   'end': 'R'},
    {'name': 'Cycle 3', 'label': 'Aufbau & Test',                        'start': 'R',   'end': 'SA'},
    {'name': 'Gesamt',  'label': 'Gesamt-TAT (I → SA)',                  'start': 'I',   'end': 'SA'},
]


def _engine_family_heuristic(engine_type: str) -> str:
    """Heuristic fallback: derive family from type string."""
    et = engine_type.strip()
    m = re.match(r'(CFM56-\d[A-Z]+)', et)
    if m:
        return m.group(1)
    if re.match(r'V25\d', et):
        return 'V2500'
    if re.match(r'GE90', et):
        return 'GE90'
    m = re.match(r'(CF6-\d+[A-Z]+)', et)
    if m:
        return m.group(1)
    if re.match(r'GP7\d', et):
        return 'GP7000'
    cleaned = re.sub(r'[\d/][A-Z0-9/\-]*$', '', et).rstrip('-/ ')
    return cleaned or et


def _engine_family(engine_type: str) -> str:
    """Return family — longest startswith-prefix in mapping wins, heuristic as fallback."""
    if not engine_type or engine_type == '–':
        return '–'
    et = engine_type.strip()
    mapping = _load_family_map()
    # Sort by prefix length descending so more specific prefixes win
    for prefix in sorted(mapping.keys(), key=len, reverse=True):
        if et.startswith(prefix):
            return mapping[prefix]
    return _engine_family_heuristic(et)


def _strip_quotes(val: str) -> str:
    return val.strip().strip('"')


def _parse_sap_float(val: str) -> float:
    """SAP uses comma as decimal and trailing '-' for negative."""
    val = _strip_quotes(val).replace(' ', '')
    if not val:
        return 0.0
    negative = val.endswith('-')
    val = val.replace('-', '').replace(',', '.')
    try:
        result = float(val)
        return -result if negative else result
    except ValueError:
        return 0.0


def _parse_sap_date(val: str) -> str:
    """Return ISO date string or '' from SAP date formats."""
    val = _strip_quotes(val).strip()
    if not val:
        return ''
    # Format: "28.04.2026   14:00:00"
    m = re.match(r'(\d{2})\.(\d{2})\.(\d{4})', val)
    if m:
        return f'{m.group(3)}-{m.group(2)}-{m.group(1)}'
    # Format: "28.04.2026"
    m2 = re.match(r'(\d{2})\.(\d{2})\.(\d{4})$', val)
    if m2:
        return f'{m2.group(3)}-{m2.group(2)}-{m2.group(1)}'
    return ''


def _wbs_base(wbs: str) -> str:
    """L.20320.01.00 → L.20320  (strip trailing .XX.XX)"""
    wbs = _strip_quotes(wbs)
    # Remove trailing .NN.NN only when it's the standard suffix
    m = re.match(r'^(.+?)\.\d{2}\.\d{2}$', wbs)
    return m.group(1) if m else wbs


def _milestone_short(code: str) -> str:
    """ZMRO001450_CC → CC"""
    code = _strip_quotes(code)
    m = re.search(r'_(.+)$', code)
    return m.group(1) if m else code


# ── Data loading ─────────────────────────────────────────────────────────────

def _date_delta_days(iso1: str, iso2: str):
    """iso2 - iso1 in calendar days. Returns None if either is empty."""
    if not iso1 or not iso2:
        return None
    try:
        from datetime import datetime as _dt
        return (_dt.strptime(iso2, '%Y-%m-%d') - _dt.strptime(iso1, '%Y-%m-%d')).days
    except Exception:
        return None


# ── Engine-Alter (TSN/CSN/TSLSV/CSLSV …) ───────────────────────────────────────
# Stand 26.09.2026: die lokale ZLSGS-Kopie hat KEINE Alters-Spalten; ob die Version
# auf dem Firmenrechner sie hat, ist offen. Deshalb tolerant: Spaltennamen werden
# normalisiert (klein, ohne Leer-/Sonderzeichen) und gegen Kandidaten geprüft,
# erlaubt sind Einheiten-/Zeitpunkt-Zusätze („TSN (h)", „CSN at Induction").
# Reihenfolge = Priorität: der erste gefundene Wert ist die Vorauswahl, die übrigen bleiben
# umschaltbar. Zyklen vor Stunden (Zyklen treiben LLP- und Heißteil-Verschleiß), „seit letztem
# Shop-Visit" vor „seit Neubau" (Zustand beim Eingang). Die Werte gelten ab Einlastung und
# bleiben im Shop stehen (die Engine fliegt nicht) — sie beschreiben also den Eingang.
ENGINE_AGE_METRICS = [
    {'key': 'cslsv', 'label': 'CSLSV', 'hint': 'Cycles Since Last Shop Visit — Zyklen seit dem letzten Shop-Visit, Stand Einlastung',
     'names': ['cslsv', 'cyclessincelastshopvisit', 'cyclesincelastshopvisit', 'cyclessincelastsv']},
    {'key': 'csn',   'label': 'CSN',   'hint': 'Cycles Since New — Zyklen seit Neubau, Stand Einlastung (treibt LLP-Verbrauch)',
     'names': ['csn', 'cyclessincenew', 'cyclesincenew']},
    {'key': 'tslsv', 'label': 'TSLSV', 'hint': 'Time Since Last Shop Visit — Stunden seit dem letzten Shop-Visit, Stand Einlastung',
     'names': ['tslsv', 'timesincelastshopvisit', 'timesincelastsv']},
    {'key': 'tsn',   'label': 'TSN',   'hint': 'Time Since New — Betriebsstunden seit Neubau, Stand Einlastung',
     'names': ['tsn', 'timesincenew']},
    {'key': 'cso',   'label': 'CSO',   'hint': 'Cycles Since Overhaul', 'names': ['cso', 'cyclessinceoverhaul']},
    {'key': 'tso',   'label': 'TSO',   'hint': 'Time Since Overhaul', 'names': ['tso', 'timesinceoverhaul']},
]
_AGE_SUFFIXES = ('', 'h', 'hrs', 'hours', 'std', 'stunden', 'fh', 'efh', 'c', 'cyc', 'cycles', 'zyklen',
                 'atinduction', 'induction', 'ind', 'beieingang', 'eingang', 'atarrival', 'arrival')


def _norm_hdr(h) -> str:
    return re.sub(r'[^a-z0-9]', '', str(h or '').lower())


def _age_columns(headers) -> dict:
    """Kennzahl → Original-Spaltenname. Längere Kandidaten zuerst, jede Spalte nur einmal."""
    found, used = {}, set()
    cands = sorted(((n, m['key']) for m in ENGINE_AGE_METRICS for n in m['names']), key=lambda t: -len(t[0]))
    for name, key in cands:
        if key in found:
            continue
        for h in headers or []:
            n = _norm_hdr(h)
            if h not in used and n.startswith(name) and n[len(name):] in _AGE_SUFFIXES:
                found[key] = h
                used.add(h)
                break
    return found


def _parse_age(v):
    """'12.345,6' / '12345' / '1234:30' (hh:mm) → float; leer oder ≤ 0 → None."""
    v = _strip_quotes(str(v or '')).replace(' ', '')
    if not v:
        return None
    m = re.fullmatch(r'(\d+):(\d{1,2})', v)
    x = int(m.group(1)) + int(m.group(2)) / 60 if m else None
    if x is None:
        if re.fullmatch(r'\d{1,3}(\.\d{3})+(,\d+)?', v):    # 12.345,6 → Tausenderpunkte
            v = v.replace('.', '')
        x = _parse_sap_float(v)
    return x if x and x > 0 else None


def export_age_columns() -> dict:
    """Welche Alters-Spalten hat der aktuelle Export? (nur die Kopfzeile lesen)"""
    path = load_config().get('export_path', '')
    try:
        with open(path, encoding='latin1', newline='') as f:
            head = next(csv.reader(f, delimiter=';'))
        return _age_columns([_strip_quotes(h) for h in head])
    except Exception:
        return {}


def load_dataexport():
    """Returns dict project → engine dict with ZMMT + ZMMP milestones."""
    zmmt_rows = {}   # project → row
    zmmp_rows = {}   # project → row

    export_path = load_config()['export_path']
    if not os.path.exists(export_path):
        return {}

    actual_zmmp_rows = {}  # project → Actual (ZMMP) row

    with open(export_path, encoding='latin1', newline='') as f:
        reader = csv.DictReader(f, delimiter=';')
        age_cols = _age_columns(reader.fieldnames)
        for row in reader:
            mtype   = _strip_quotes(row.get('Milestone type', ''))
            project = _strip_quotes(row.get('Project', ''))
            if not project:
                continue
            if 'ZMMT' in mtype and 'Cust. Committed' in mtype:
                zmmt_rows[project] = row
            elif 'ZMMP' in mtype and 'Planung' in mtype:
                zmmp_rows[project] = row
            elif 'Actual' in mtype and 'ZMMP' in mtype:
                actual_zmmp_rows[project] = row

    engines = {}
    for project, row in zmmt_rows.items():
        milestones_zmmt = {}
        for col in MILESTONE_COLS:
            if col in row:
                milestones_zmmt[col] = _parse_sap_date(row[col])

        milestones_zmmp = {}
        zmmp_row = zmmp_rows.get(project, {})
        for col in MILESTONE_COLS:
            if col in zmmp_row:
                milestones_zmmp[col] = _parse_sap_date(zmmp_row[col])

        milestones_actual = {}
        actual_row = actual_zmmp_rows.get(project, {})
        for col in MILESTONE_COLS:
            if col in actual_row:
                milestones_actual[col] = _parse_sap_date(actual_row[col])

        # Anchor dates
        tsp_zmmt = milestones_zmmt.get('TSP', '')
        tsp_zmmp = milestones_zmmp.get('TSP', '')
        i_zmmt   = milestones_zmmt.get('I', '')   # Induction = process anchor
        i_zmmp   = milestones_zmmp.get('I', '')

        # I-anchored process offsets (days from Induction)
        # Pre-induction milestones get negative offsets; post-induction get positive
        milestones_zmmt_offset = {}
        for col in MILESTONE_COLS:
            d = _date_delta_days(i_zmmt, milestones_zmmt.get(col, ''))
            if d is not None:
                milestones_zmmt_offset[col] = d

        milestones_zmmp_offset = {}
        for col in MILESTONE_COLS:
            d = _date_delta_days(i_zmmp, milestones_zmmp.get(col, ''))
            if d is not None:
                milestones_zmmp_offset[col] = d

        # Δ = I-normalised: pure process delay, queue-time excluded
        # Only computed for milestones at or after I in the ZMMT template (offset >= 0)
        milestones_delta = {}
        for col in MILESTONE_COLS:
            d_zmmt = milestones_zmmt_offset.get(col)
            d_zmmp = milestones_zmmp_offset.get(col)
            if d_zmmt is not None and d_zmmp is not None and d_zmmt >= 0:
                milestones_delta[col] = d_zmmp - d_zmmt

        # Endverzug: um wie viele Kalendertage der Endtermin gegenüber der
        # Kundenzusage (ZMMT) wandert. Anders als MTU Delay CD — der die
        # Verschiebungen aller Meilensteine aufsummiert und kaskadierende
        # Verzüge dadurch mehrfach zählt — ist das der tatsächliche Terminverzug.
        # Ohne Kundenzusage (Budget TAT ≤ 0) steht in ZMMT ein Platzhalter-
        # Endtermin (TAT Start + ~750 Tage oder + ~20 Jahre) — dann kein Endverzug.
        # Auch ZMMP trägt vereinzelt Platzhalter (Plan-Ende vor TAT Start oder
        # Jahre daneben); solche Werte gelten als unplausibel und fallen raus.
        end_delay_ms = end_delay_cd = end_delay_actual = None
        has_commitment = _parse_sap_float(row.get('Budget TAT (SM)', '')) > 0
        tat_start_iso  = _parse_sap_date(row.get('TAT Start Date', ''))
        for col in (END_DELAY_MILESTONES if has_commitment else ()):
            d = _date_delta_days(milestones_zmmt.get(col, ''), milestones_zmmp.get(col, ''))
            plan_dur = _date_delta_days(tat_start_iso, milestones_zmmp.get(col, ''))
            if d is not None:
                if abs(d) > END_DELAY_MAX_ABS or (plan_dur is not None and plan_dur < 0):
                    break
                end_delay_ms, end_delay_cd = col, d
                end_delay_actual = _date_delta_days(milestones_zmmt.get(col, ''),
                                                    milestones_actual.get(col, ''))
                break

        # Cycle Soll/Ist durations
        cycles_summary = []
        for cyc in CYCLE_DEFS:
            s, e2     = cyc['start'], cyc['end']
            is_queue  = cyc.get('queue_only', False)
            if is_queue:
                # No Fischgräte template for queue phase — only show actual wait time
                soll  = None
                ist   = _date_delta_days(milestones_zmmp.get(s,''), milestones_zmmp.get(e2,''))
                delta = None
            else:
                soll  = _date_delta_days(milestones_zmmt.get(s,''), milestones_zmmt.get(e2,''))
                ist   = _date_delta_days(milestones_zmmp.get(s,''), milestones_zmmp.get(e2,''))
                delta = (ist - soll) if (soll is not None and ist is not None) else None
            cycles_summary.append({
                'name':       cyc['name'],
                'label':      cyc['label'],
                'start':      s,
                'end':        e2,
                'queue_only': is_queue,
                'soll_days':  soll,
                'ist_days':   ist,
                'delta_days': delta,
            })

        # Alter: Zeile der Zusage, sonst Ist-/Planzeile (welche Zeile es trägt, ist unbekannt)
        age = {}
        for k, col in age_cols.items():
            for r_ in (row, actual_row, zmmp_row):
                x = _parse_age(r_.get(col)) if r_ else None
                if x is not None:
                    age[k] = x
                    break
        engines[project] = {
            'project':             project,
            'age':                 age,
            'esn':                 _strip_quotes(row.get('Engine Serial', '')),
            'engine_type':         _strip_quotes(row.get('Engine Type', '')),
            'customer':            _strip_quotes(row.get('Customer', '')),
            'workscope':           _strip_quotes(row.get('Workscope', '')),
            'otd':                 _strip_quotes(row.get('OTD', '')),
            'status':              _strip_quotes(row.get('STTXT_INT', '')),
            'cpm':                 _strip_quotes(row.get('CPM', '')),
            'resp_ppe':            _strip_quotes(row.get('Resp. PPE', '')),
            'mtu_delay_cd':        _parse_sap_float(row.get('MTU Delay CD', '0')),
            'oem_delay_cd':        _parse_sap_float(row.get('OEM Delay CD', '0')),
            'cust_delay_cd':       _parse_sap_float(row.get('Customer/Excusable delay CD', '0')),
            'mtu_delay_wd':        _parse_sap_float(row.get('MTU Delay WD', '0')),
            'end_delay_ms':        end_delay_ms,
            'end_delay_cd':        end_delay_cd,
            'end_delay_actual_cd': end_delay_actual,
            'total_tat_cd':        _parse_sap_float(row.get('Total TAT CD', '0')),
            'slu':                 _parse_sap_float(row.get('SLU', '0')),
            'budget_tat':          _strip_quotes(row.get('Budget TAT (SM)', '')).strip(),
            'tat_start':           _parse_sap_date(row.get('TAT Start Date', '')),
            'tec_mlst':            _strip_quotes(row.get('TEC_MLST', '')),
            'tsc_mlst':            _strip_quotes(row.get('TSC_MLST', '')),
            'milestones':              milestones_zmmt,   # backward compat
            'milestones_zmmt':         milestones_zmmt,
            'milestones_zmmp':         milestones_zmmp,
            'milestones_actual':       milestones_actual,
            'milestones_delta':        milestones_delta,
            'tsp_zmmt':                tsp_zmmt,
            'tsp_zmmp':                tsp_zmmp,
            'i_zmmt':                  i_zmmt,
            'i_zmmp':                  i_zmmp,
            'milestones_zmmt_offset':  milestones_zmmt_offset,
            'milestones_zmmp_offset':  milestones_zmmp_offset,
            'cycles_summary':          cycles_summary,
        }
    return engines


def load_claims():
    """Returns dict project_base → list of claim dicts."""
    claims = defaultdict(list)
    claim_path = load_config()['claim_path']
    if not os.path.exists(claim_path):
        return claims
    with open(claim_path, encoding='latin1', newline='') as f:
        reader = csv.DictReader(f, delimiter=';')
        for row in reader:
            wbs_raw = row.get('WBS Element', '')
            project = _wbs_base(wbs_raw)
            if not project:
                continue
            notif_type = _strip_quotes(row.get('Notification Type', ''))
            ms_raw     = _strip_quotes(row.get('Milestone (short text)', ''))
            ms_short   = _milestone_short(ms_raw) if ms_raw else ''
            former     = _parse_sap_date(row.get('Former plan date', ''))
            new_date   = _parse_sap_date(row.get('New plan date', ''))
            delay_cd   = _parse_sap_float(row.get('Delay CD', '0'))
            delay_wd   = _parse_sap_float(row.get('Delay WD', '0'))
            claims[project].append({
                'notification':  _strip_quotes(row.get('Notification', '')),
                'cycle':         _strip_quotes(row.get('Cycle', '')),
                'notif_type':    notif_type,
                'notif_label':   NOTIF_TYPE_LABELS.get(notif_type, notif_type),
                'milestone_raw': ms_raw,
                'milestone':     ms_short,
                'created_on':    _parse_sap_date(row.get('Created on', '')),
                'created_by':    _strip_quotes(row.get('Created by', '')),
                'description':   _strip_quotes(row.get('Description', '')),
                'former_date':   former,
                'new_date':      new_date,
                'delay_cd':      delay_cd,
                'delay_wd':      delay_wd,
                'subject':       _strip_quotes(row.get('Subject', '')),
                'cause_text':    _strip_quotes(row.get('Cause Text', '')),
                'cause_code':    _strip_quotes(row.get('Cause code', '')),
                'breakdown':     _strip_quotes(row.get('Breakdown', '')),
                'deletion':      _strip_quotes(row.get('Deletion Indicator', '')),
            })
    return claims


# ── Incidents aus der MTU-Hub-SQL ────────────────────────────────────────────
# Die ServiceNow-CSV ist abgelöst. Der Loader ist schema-tolerant: welche
# Spalten es wirklich gibt, wird einmal aus INFORMATION_SCHEMA gelesen und je
# logischem Feld der erste vorhandene Kandidat genommen. Damit bricht nichts,
# wenn der Mirror z. B. 'stage' statt 'state' führt.

INCIDENT_SNAPSHOT = os.path.join(SQL_CACHE_DIR, 'incidents_sql.pkl')

# logisches Feld → Spaltenkandidaten in der Reihenfolge der Präferenz
_INC_FIELDS = {
    'number':       ['number'],
    'description':  ['short_description'],
    'long_text':    ['description'],
    'wbs':          ['wbs'],
    'criticality':  ['criticality'],
    'state':        ['state', 'stage'],
    'group':        ['requester_group'],
    'assign_group': ['assignment_group'],
    'created':      ['sys_created_on', 'opened_at'],
    'opened':       ['opened_at'],
    'resolved':     ['resolved_at'],
    'closed':       ['closed_at'],
    'cycle':        ['cycle_of_wbs', 'cycle'],
    'category':     ['category'],
    'reason':       ['reason'],           # Grund im Klartext („Fehlt", „Befundbeauftragung" …)
    'cause':        ['cause'],
    'priority':     ['priority'],
    'type':         ['type'],             # LUD-Core-Typ: „Störung" | „Anfrage"
    'reopened':     ['reopen_count'],
    'made_sla':     ['made_sla'],
    'updated':      ['sys_updated_on'],
    'imported':     ['import_at'],
}

_inc_schema_cache = {}       # (db, table) → {logisches Feld: Spaltenname}

# Nachbartabellen der Incidents im selben Schema (Schema-Snapshot 24.09.2026):
#  • task      — ServiceNow-Elternklasse, trägt den echten Status ('stage' ist im
#                Mirror leer: 100 von 100 Zeilen). Join über sys_id.
#  • project   — SAP-Projekt mit Engine-Typ, Kunde, ESN. Join über die WBS-Basis
#                (L.21396.01.00 → L.21396); vom Nutzer geprüft: 30 343 von 30 345.
#  • parts     — betroffene Teile (m2m), 1:n je Incident.
# Im Mirror ist kein einziger Schlüssel deklariert, deshalb wird jede Nachbar-
# tabelle vor dem Join auf eine Zeile je Schlüssel reduziert (ROW_NUMBER) —
# sonst würden Doppel-Importe Incidents vervielfachen.
INCIDENT_STATE_TABLE   = 'task'
INCIDENT_PROJECT_TABLE = 'x_mtuae_ins_project'
INCIDENT_PARTS_TABLE   = 'x_mtuae_ins_m2m_affected_part'
_PARTS_INC_KEY         = 'x_mtuae_ins_shop_incident_sys_id'
#  • wbs        — WBS-Element, 'name' = Modulgruppe (CK23: #2 BRG. GROUP); über wbs_sys_id
#  • cs_order   — SAP-Auftrag (CS01/CS07/CS99, Wartungsart); über cs_order_sys_id
#  • operation  — Arbeitsvorgang der CS-Order (17,6 Mio. Zeilen!); über
#                 cs_order_sys_id + opn (Vorgangsnummer, z. B. 0160 ↔ vornr)
INCIDENT_WBS_TABLE     = 'x_mtuae_ins_wbs'
INCIDENT_CSO_TABLE     = 'x_mtuae_ins_cs_order'
INCIDENT_OP_TABLE      = 'x_mtuae_ins_operation'
#  • part (ECMS) — serialisierte Einzelteile mit Einbau-WBS (9 Mio. Zeilen); die
#                  letzten zwei Stellen der WBS sind das CK-Modul. Nur für die
#                  Modulvorschlags-Matrix, nie im Baukasten.
INCIDENT_ECMS_TABLE    = 'x_mtuae_ins_part'
#  • part_models (MPL/IPC) — Katalogteile mit catalog_sequence_number „724100 02 060"
#                  = ATA 72-41-00; für Quelle 3 bei Teilen, die ECMS nicht kennt
INCIDENT_MPL_TABLE     = 'x_mtuae_ins_part_models'

# logisches Feld → Projektspalte; 'engine' ist Pflicht, der Rest optional
_PROJECT_COLS = {'engine': 'engine', 'customer': 'customer',
                 'esn': 'engine_serial_number', 'state': 'state', 'name': 'name'}
_PARTS_COLS   = ('part', 'part_description', 'quantity', 'serialnumber')

# WBS-Basis in T-SQL, identisch zur Prüfabfrage: bis vor den zweiten Punkt,
# ohne zweiten Punkt die ganze WBS.
_WBS_BASE_SQL = ("CASE WHEN CHARINDEX('.', {c}, CHARINDEX('.', {c}) + 1) > 0 "
                 "THEN LEFT({c}, CHARINDEX('.', {c}, CHARINDEX('.', {c}) + 1) - 1) "
                 "ELSE {c} END")

_inc_extras_cache = {}       # (db, table) → {'task', 'project', 'parts', 'wbs', …}


def _incident_extras(db: str, table: str) -> dict:
    """Welche Nachbartabellen es mit welchen Spalten gibt. Eine Metadaten-Abfrage
    für alle. Nicht lesbar → leere Auskunft (nicht gecacht); der Loader darf
    daran nie scheitern, er lädt dann eben ohne Anreicherung."""
    key = (db, table)
    if key in _inc_extras_cache:
        return _inc_extras_cache[key]
    schema, _, inc_name = table.rpartition('.')
    names = [INCIDENT_STATE_TABLE, INCIDENT_PROJECT_TABLE, INCIDENT_PARTS_TABLE,
             INCIDENT_WBS_TABLE, INCIDENT_CSO_TABLE, INCIDENT_OP_TABLE,
             INCIDENT_ECMS_TABLE, INCIDENT_MPL_TABLE, inc_name]
    try:
        _, rows, _ = _sql_query(
            "SELECT TABLE_NAME, COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS "
            "WHERE TABLE_SCHEMA = ? AND TABLE_NAME IN (" + ', '.join('?' * len(names)) + ")",
            [schema] + names, database=db, cap=0)
    except Exception as e:
        print(f'[incidents] Nachbartabellen nicht prüfbar: {e}')
        return {'task': None, 'project': None, 'parts': None, 'wbs': None,
                'wbs_elem': None, 'cso': None, 'op': None, 'inc': {}, 'ecms': None, 'mpl': None}
    cols = defaultdict(dict)                 # tabelle → {klein: Original}
    for t, c in rows:
        cols[t.lower()][c.lower()] = c
    inc  = cols.get(inc_name.lower(), {})
    task = cols.get(INCIDENT_STATE_TABLE.lower(), {})
    prj  = cols.get(INCIDENT_PROJECT_TABLE.lower(), {})
    prt  = cols.get(INCIDENT_PARTS_TABLE.lower(), {})
    wbt  = cols.get(INCIDENT_WBS_TABLE.lower(), {})
    cso  = cols.get(INCIDENT_CSO_TABLE.lower(), {})
    opt  = cols.get(INCIDENT_OP_TABLE.lower(), {})
    ecm  = cols.get(INCIDENT_ECMS_TABLE.lower(), {})
    mpl  = cols.get(INCIDENT_MPL_TABLE.lower(), {})

    def order(c):
        return '[sys_updated_on] DESC' if 'sys_updated_on' in c else '(SELECT NULL)'

    out = {'task': None, 'project': None, 'parts': None, 'wbs': inc.get('wbs'),
           'wbs_elem': None, 'cso': None, 'op': None, 'inc': inc, 'ecms': None, 'mpl': None}
    if {'part_number', 'catalog_sequence_number', 'engine_type'} <= set(mpl):
        out['mpl'] = {'table': f'{schema}.{INCIDENT_MPL_TABLE}',
                      'cols': {c: mpl[c] for c in ('part_number', 'catalog_sequence_number',
                                                   'engine_type', 'name_of_part') if c in mpl}}
    if {'sys_id', 'part_number', 'wbs'} <= set(ecm):
        out['ecms'] = {'table': f'{schema}.{INCIDENT_ECMS_TABLE}',
                       'cols': {c: ecm[c] for c in ('part_number', 'wbs', 'name', 'text_of_module_group',
                                                    'build_up_position', 'serial_number', 'module_group',
                                                    'module_group_serial_number', 'install_status') if c in ecm}}
    if 'sys_id' in inc and {'sys_id', 'state'} <= set(task):
        out['task'] = {'table': f'{schema}.{INCIDENT_STATE_TABLE}', 'order': order(task),
                       'value': task.get('state_value')}
    if inc.get('wbs_sys_id') and {'sys_id', 'name'} <= set(wbt):
        out['wbs_elem'] = {'table': f'{schema}.{INCIDENT_WBS_TABLE}', 'key': inc['wbs_sys_id'],
                           'wbs': wbt.get('wbs')}
    if inc.get('cs_order_sys_id') and {'sys_id', 'type'} <= set(cso):
        out['cso'] = {'table': f'{schema}.{INCIDENT_CSO_TABLE}', 'key': inc['cs_order_sys_id'],
                      'cols': {c: cso[c] for c in ('type', 'maintenance_type') if c in cso},
                      # für Quelle 3 (ATA aus dem Auftragstext) — nicht in den Baukasten-Join
                      'extra': {c: cso[c] for c in ('serviceproduct', 'wbs', 'wbs_sys_id') if c in cso}}
    if inc.get('cs_order_sys_id') and inc.get('opn') and {'cs_order_id', 'vornr', 'name'} <= set(opt):
        out['op'] = {'table': f'{schema}.{INCIDENT_OP_TABLE}', 'key': inc['cs_order_sys_id'],
                     'opn': inc['opn'], 'cols': {c: opt[c] for c in ('name', 'arbpl') if c in opt}}
    if inc.get('wbs') and {'number', 'engine'} <= set(prj):
        out['project'] = {'table': f'{schema}.{INCIDENT_PROJECT_TABLE}', 'order': order(prj),
                          'cols': {k: prj[c] for k, c in _PROJECT_COLS.items() if c in prj}}
    if 'sys_id' in inc and {'sys_id', _PARTS_INC_KEY.lower(), 'part'} <= set(prt):
        out['parts'] = {'table': f'{schema}.{INCIDENT_PARTS_TABLE}', 'order': order(prt),
                        'cols': {c: prt[c] for c in _PARTS_COLS if c in prt},
                        'key': prt[_PARTS_INC_KEY.lower()],
                        # Verweis auf die ECMS-Zeile (Einzelteil mit Einbau) — Weg zur Einheit
                        'ecms_key': prt.get('x_mtuae_ins_part_sys_id')}
    _inc_extras_cache[key] = out
    return out


def _incident_state_source(db: str, table: str):
    return _incident_extras(db, table)['task']


def _dedup(src: dict, key: str, select: str) -> str:
    """Nachbartabelle als Unterabfrage mit genau einer Zeile je Schlüssel."""
    return (f'(SELECT {select}, ROW_NUMBER() OVER (PARTITION BY [{key}] '
            f'ORDER BY {src["order"]}) AS rn FROM {src["table"]})')


def _incident_state_join(src: dict, alias: str = 't', inc: str = 'i') -> str:
    sel = '[sys_id], [state]' + (f', [{src["value"]}] AS [state_value]' if src.get('value') else '')
    return (f'LEFT JOIN {_dedup(src, "sys_id", sel)} {alias} '
            f'ON {alias}.[sys_id] = {inc}.[sys_id] AND {alias}.rn = 1')


def _prj_latest(src: dict) -> str:
    """Projekt → Engine-Typ, je Projektnummer nur der NEUESTE Datensatz. Nicht
    MAX(engine): bei einem Doppel-Import gewänne sonst der alphabetisch letzte
    Typ statt des aktuellen (im Demo-Datensatz aufgefallen: „VERALTET")."""
    ecol = src['cols']['engine']
    return (f"(SELECT d.[number], d.[{ecol}] AS [engine] FROM "
            f"{_dedup(src, 'number', f'[number], [{ecol}]')} d WHERE d.rn = 1)")


def _incident_project_join(src: dict, wbs_col: str, alias: str = 'p', inc: str = 'i') -> str:
    sel = ', '.join(['[number]'] + [f'[{c}]' for c in src['cols'].values()])
    base = _WBS_BASE_SQL.format(c=f'{inc}.[{wbs_col}]')
    return (f'LEFT JOIN {_dedup(src, "number", sel)} {alias} '
            f'ON {alias}.[number] = {base} AND {alias}.rn = 1')


def _incident_parts_sql(src: dict) -> str:
    """Teile je Incident mit sprechenden Spalten. 'MPL: 9346M79G05' trägt die
    Herkunft (MPL = Katalogteil, ECMS = Einzelteil mit Seriennummer) vor der
    Teilenummer — beides wird getrennt, sonst zählt jede Nummer doppelt."""
    c    = src['cols']
    part = f"[{c['part']}]"
    colon = f"CHARINDEX(':', {part})"
    sel = [f"[{src['key']}] AS [inc_sys_id]",
           f"CASE WHEN {colon} > 0 THEN LTRIM(SUBSTRING({part}, {colon} + 1, 255)) "
           f"ELSE {part} END AS [teil_nr]",
           f"CASE WHEN {colon} > 0 THEN LEFT({part}, {colon} - 1) END AS [teil_herkunft]"]
    if 'part_description' in c:
        sel.append(f"[{c['part_description']}] AS [teil_bez]")
    if 'quantity' in c:
        sel.append(f"[{c['quantity']}] AS [teil_menge]")
    if 'serialnumber' in c:
        sel.append(f"[{c['serialnumber']}] AS [teil_sn]")
    if src.get('ecms_key'):
        sel.append(f"[{src['ecms_key']}] AS [ecms_sys_id]")
    return (f"(SELECT * FROM {_dedup(src, 'sys_id', ', '.join(sel))} d WHERE d.rn = 1)")


# Arbeits-, Staging- und Sicherungskopien, die nie die Quelle sein sollen.
_TABLE_NOISE = re.compile(
    r'(_tmp|_temp|_bak|_backup|_old|_copy|_stg|_staging|_archiv\w*|_test|_delta|\d{6,8})$',
    re.I)


def _rank_tables(cands: list, base: str) -> list:
    """Tabellenkandidaten sortieren: echte Tabelle vor Kopie, exakt vor Präfix.

    Im MTU Hub liegen neben x_mtuae_ins_shop_incident auch
    x_mtuae_ins_shop_incident_tmp & Co. — die dürfen nie gewinnen.
    cands: [(schema, name, typ)] → sortierte Liste gleicher Form.
    """
    bl = base.lower()

    def score(c):
        schema, name, typ = c
        nl = name.lower()
        sc = 0
        if _TABLE_NOISE.search(nl):
            sc += 1000                      # Hilfstabelle: ganz nach hinten
        if nl == bl:
            sc -= 20                        # exakter Treffer
        elif nl.endswith(bl):
            sc -= 10                        # nur ein Präfix davor (x_…)
        if (typ or '').upper() == 'VIEW':
            sc += 3                         # Tabelle vor View
        sc += abs(len(nl) - len(bl))        # je weniger Beiwerk, desto besser
        return (sc, nl)

    return sorted(cands, key=score)


def _is_noise_table(name: str) -> bool:
    return bool(_TABLE_NOISE.search(name.rsplit('.', 1)[-1]))


def _incident_target() -> tuple:
    """(Datenbank, Tabellen-Hinweis) aus Konfiguration bzw. SQL-Explorer-Fund.
    Der Hinweis darf unvollständig sein (ohne Schema, ohne x_-Präfix) —
    _incident_resolve() macht daraus die echte Tabelle."""
    cfg   = load_config()
    db    = (cfg.get('incident_database') or '').strip()
    table = (cfg.get('incident_table') or '').strip()
    state = _sql_state()
    db    = db    or state.get('incident_database', '') or ('LUD_Core' if MTUHUB_DEMO else '')
    table = table or state.get('incident_table', '') or INCIDENT_TABLE
    return db, table


_inc_table_cache = {}        # (db, hinweis) → aufgelöster 'schema.name'


def _incident_resolve(db: str, hint: str) -> str:
    """Aus dem Hinweis die tatsächlich vorhandene Tabelle machen."""
    key = (db, hint)
    if key in _inc_table_cache:
        return _inc_table_cache[key]

    schema, _, name = hint.rpartition('.')
    # 1) Hinweis wörtlich vorhanden?
    _, exact, _ = _sql_query(
        "SELECT TABLE_SCHEMA, TABLE_NAME, TABLE_TYPE FROM INFORMATION_SCHEMA.TABLES "
        "WHERE TABLE_NAME = ? AND (? = '' OR TABLE_SCHEMA = ?)",
        [name, schema, schema], database=db, cap=0)
    if exact:
        found = f'{exact[0][0]}.{exact[0][1]}'
    else:
        # 2) sonst über den Namensteil suchen und die Kandidaten ordnen
        base = re.sub(r'^x_', '', name, flags=re.I)
        _, rows, _ = _sql_query(
            "SELECT TABLE_SCHEMA, TABLE_NAME, TABLE_TYPE FROM INFORMATION_SCHEMA.TABLES "
            "WHERE TABLE_NAME LIKE ?", ['%' + base + '%'], database=db, cap=0)
        if not rows:
            raise RuntimeError(
                f'Keine Tabelle mit „{base}" in Datenbank {db} gefunden — '
                f'Datenbank/Tabelle in den Einstellungen prüfen.')
        ranked = _rank_tables([tuple(r) for r in rows], base)
        best   = ranked[0]
        if _is_noise_table(best[1]) and len(ranked) > 1:
            best = next((c for c in ranked if not _is_noise_table(c[1])), best)
        found = f'{best[0]}.{best[1]}'
        skipped = [f'{r[0]}.{r[1]}' for r in ranked if f'{r[0]}.{r[1]}' != found]
        if skipped:
            print(f'[incidents] Tabelle {found} gewählt, übergangen: {", ".join(skipped[:4])}')

    _inc_table_cache[key] = found
    return found


def _incident_columns(db: str, table: str) -> dict:
    """Vorhandene Spalten ermitteln und den logischen Feldern zuordnen."""
    key = (db, table)
    if key in _inc_schema_cache:
        return _inc_schema_cache[key]
    schema, _, name = table.rpartition('.')
    _, rows, _ = _sql_query(
        "SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS "
        "WHERE TABLE_NAME = ? AND (? = '' OR TABLE_SCHEMA = ?)",
        [name, schema, schema], database=db, cap=0)
    present = {r[0].lower(): r[0] for r in rows}
    if not present:
        raise RuntimeError(f'Tabelle {table} in Datenbank {db} hat keine lesbaren Spalten.')
    mapping = {}
    for logical, cands in _INC_FIELDS.items():
        for c in cands:
            if c.lower() in present:
                mapping[logical] = present[c.lower()]
                break
    missing = [f for f in ('number', 'wbs', 'criticality', 'created') if f not in mapping]
    if missing:
        raise RuntimeError('Pflichtspalten fehlen in ' + table + ': ' + ', '.join(missing))
    _inc_schema_cache[key] = mapping
    return mapping


def _inc_date(v) -> str:
    """datetime2 → 'YYYY-MM-DD' (ISO, im Gegensatz zum alten CSV-Format)."""
    if isinstance(v, (datetime.datetime, datetime.date)):
        return v.strftime('%Y-%m-%d')
    if isinstance(v, str) and len(v) >= 10:
        return v[:10]
    return ''


def _inc_ts(v):
    """Zeitstempel aus der SQL. _sql_query() schickt jedes datetime durch
    _sql_jsonable() und macht daraus ISO-Text — hier wieder zurück, sonst
    bleibt jede Lösungszeit leer."""
    if isinstance(v, datetime.datetime):
        return v
    if isinstance(v, str) and len(v) >= 10:     # auch reines Datum zulassen
        try:
            return datetime.datetime.fromisoformat(v.strip())
        except ValueError:
            return None
    return None


def _inc_hours(a, b):
    """Stunden zwischen zwei Zeitstempeln, auf 0.1 gerundet."""
    a, b = _inc_ts(a), _inc_ts(b)
    if a is None or b is None:
        return None
    return round((b - a).total_seconds() / 3600, 1)


def load_incidents_sql() -> tuple:
    """Liest die Incidents aus der Hub-SQL. Liefert (dict project → [incident], meta)."""
    incidents = defaultdict(list)
    meta = {'source': 'sql', 'ok': False, 'rows': 0, 'joined': 0, 'unjoined': 0, 'with_project': 0,
            'database': '', 'table': '', 'cycle': '', 'ts': '', 'error': ''}

    db, hint = _incident_target()
    meta['database'], meta['table'] = db, hint
    if not db:
        meta['error'] = ('Keine Datenbank für die Incident-Tabelle hinterlegt — '
                         'in den Einstellungen setzen oder im SQL-Explorer suchen.')
        return incidents, meta

    table = _incident_resolve(db, hint)      # 'MTUhub.x_mtuae_ins_shop_incident'
    meta['table'] = table
    cycle = (load_config().get('incident_cycle') or '').strip()
    meta['cycle'] = cycle
    cols  = _incident_columns(db, table)

    extras    = _incident_extras(db, table)
    state_src = extras['task']
    prj_src   = extras['project']
    meta['state_source']   = state_src['table'] if state_src else table
    meta['project_source'] = prj_src['table'] if prj_src else ''
    parts = []
    for logical, c in cols.items():
        if logical == 'state' and state_src:
            continue
        parts.append(f'i.[{c}] AS [{logical}]')
    if state_src:
        # task-Status zuerst; die eigene Spalte nur, falls task nichts liefert
        own = f"NULLIF(i.[{cols['state']}], '')" if 'state' in cols else 'NULL'
        parts.append(f"COALESCE(NULLIF(t.[state], ''), {own}) AS [state]")
    if prj_src:
        for logical, c in prj_src['cols'].items():
            parts.append(f'p.[{c}] AS [prj_{logical}]')
    sql    = f'SELECT {", ".join(parts)} FROM {table} i'
    if state_src:
        sql += ' ' + _incident_state_join(state_src)
    if prj_src:
        sql += ' ' + _incident_project_join(prj_src, extras['wbs'])
    params = []
    if cycle and 'cycle' in cols:
        sql += f' WHERE i.[{cols["cycle"]}] = ?'
        params.append(cycle)

    names, rows, truncated = _sql_query(sql, params, database=db, timeout=180, cap=0)
    idx = {n: i for i, n in enumerate(names)}

    def val(row, logical):
        i = idx.get(logical)
        return row[i] if i is not None else None

    for row in rows:
        meta['rows'] += 1
        wbs = (val(row, 'wbs') or '')
        wbs = wbs.strip() if isinstance(wbs, str) else ''
        project = _wbs_base(wbs)          # gleiche Logik wie bei den Claims
        if not project:
            meta['unjoined'] += 1
            continue
        created  = val(row, 'created')
        resolved = val(row, 'resolved') or val(row, 'closed')
        engine_type = (val(row, 'prj_engine') or '').strip()
        incidents[project].append({
            'number':        (val(row, 'number') or '').strip(),
            'description':   (val(row, 'description') or '').strip(),
            'long_text':     (val(row, 'long_text') or '').strip(),
            'criticality':   (val(row, 'criticality') or '').strip(),
            'state':         (val(row, 'state') or '').strip(),
            'group':         (val(row, 'group') or '').strip(),
            'assign_group':  (val(row, 'assign_group') or '').strip(),
            'category':      (val(row, 'category') or '').strip(),
            'reason':        (val(row, 'reason') or '').strip(),
            'cause':         (val(row, 'cause') or '').strip(),
            'priority':      (val(row, 'priority') or '').strip(),
            'type':          (val(row, 'type') or '').strip(),
            'cycle':         (val(row, 'cycle') or '').strip(),
            'wbs':           wbs,
            'created_on':    _inc_date(created),
            'opened_at':     _inc_date(val(row, 'opened')),
            'resolved_at':   _inc_date(resolved),
            'closed_at':     _inc_date(val(row, 'closed')),
            'resolution_h':  _inc_hours(created, resolved),
            'reopen_count':  val(row, 'reopened') or 0,
            'made_sla':      (val(row, 'made_sla') or '').strip(),
            # aus dem SAP-Projekt (x_mtuae_ins_project) — unabhängig davon, ob
            # das Projekt im SAP-Dataexport steht
            'engine_type':   engine_type,
            'engine_family': _engine_family(engine_type) if engine_type else '',
            'customer':      (val(row, 'prj_customer') or '').strip(),
            'esn':           (val(row, 'prj_esn') or '').strip(),
            'project_state': (val(row, 'prj_state') or '').strip(),
            'project_name':  (val(row, 'prj_name') or '').strip(),
        })
        meta['joined'] += 1
        if engine_type:
            meta['with_project'] = meta.get('with_project', 0) + 1

    meta['ok'] = True
    meta['truncated'] = truncated
    meta['ts'] = datetime.datetime.now().isoformat(timespec='seconds')
    return incidents, meta


def load_incidents():
    """Incidents mit Snapshot-Rückfall: ist die DB nicht erreichbar, wird der
    letzte erfolgreiche Stand von der Platte genommen, damit der Analyzer auch
    außerhalb des Firmennetzes startet."""
    global _incident_meta
    try:
        incidents, meta = load_incidents_sql()
        # Snapshot nur bei echtem Inhalt — ein leeres Ergebnis (z. B. falscher
        # Zyklus-Filter) darf den letzten guten Stand nicht überschreiben.
        if meta['ok'] and meta['joined']:
            try:
                os.makedirs(SQL_CACHE_DIR, exist_ok=True)
                tmp = INCIDENT_SNAPSHOT + '.tmp'
                with open(tmp, 'wb') as f:
                    pickle.dump((dict(incidents), meta), f, pickle.HIGHEST_PROTOCOL)
                os.replace(tmp, INCIDENT_SNAPSHOT)
            except Exception:
                pass
        _incident_meta = meta
        return incidents
    except Exception as e:
        err = str(e)

    try:
        with open(INCIDENT_SNAPSHOT, 'rb') as f:
            stored, meta = pickle.load(f)
        meta = dict(meta)
        meta.update({'ok': False, 'stale': True, 'error': err})
        _incident_meta = meta
        print(f'[incidents] Quelle nicht lesbar ({err[:90]}) — Snapshot vom '
              f'{meta.get("ts", "?")} verwendet')
        out = defaultdict(list)
        out.update(stored)
        return out
    except Exception:
        _incident_meta = {'source': 'sql', 'ok': False, 'stale': False, 'rows': 0,
                          'joined': 0, 'unjoined': 0, 'error': err, 'ts': ''}
        print(f'[incidents] Quelle nicht lesbar und kein Snapshot: {err[:140]}')
        return defaultdict(list)


_incident_meta = {'source': 'sql', 'ok': False, 'pending': True,
                  'error': 'noch nicht geladen'}


def get_incident_meta() -> dict:
    return dict(_incident_meta)


# ── Parse cache (pickle sidecars, keyed by source mtime+size) ─────────────────
# The CSVs live on a network share; re-parsing them on every start costs ~10 s.
PARSE_CACHE_DIR  = os.path.join(BASE_DIR, '.parse_cache')
PARSE_CACHE_VER  = 5   # bump when a loader's output structure changes (5: Engine-Alter)


def _sidecar_path(tag: str, src_path: str) -> str:
    h = hashlib.md5(os.path.abspath(src_path).encode('utf-8')).hexdigest()[:12]
    return os.path.join(PARSE_CACHE_DIR, f'{tag}_{h}.pkl')


def _cached_parse(tag: str, src_path: str, loader):
    """Run loader(), memoised in a local pickle keyed by the source file's mtime+size."""
    try:
        st  = os.stat(src_path)
        key = (PARSE_CACHE_VER, int(st.st_mtime), st.st_size)
    except OSError:
        return loader()                      # missing source → loader returns its empty default

    sidecar = _sidecar_path(tag, src_path)
    try:
        with open(sidecar, 'rb') as f:
            stored_key, data = pickle.load(f)
        if stored_key == key:
            return data
    except Exception:
        pass                                 # no/stale/corrupt sidecar → parse again

    data = loader()
    try:
        os.makedirs(PARSE_CACHE_DIR, exist_ok=True)
        tmp = sidecar + '.tmp'
        with open(tmp, 'wb') as f:
            pickle.dump((key, data), f, pickle.HIGHEST_PROTOCOL)
        os.replace(tmp, sidecar)             # atomic swap
    except Exception:
        pass                                 # cache is optional, never fail the request
    return data


_cache      = {}
_cache_lock = threading.Lock()


def _get_cache():
    """Returns the fully built cache dict. Exactly one thread builds it (no stampede)."""
    global _cache
    snapshot = _cache
    if snapshot:
        return snapshot
    with _cache_lock:
        if not _cache:                       # double-checked: another thread may have built it
            _cache = _build_cache()          # rebind only when complete
        return _cache


DUMMY_WORKSCOPE_PREFIX = 'WS99'   # MIGRATION_DUMMY — carries no real shop work

# ── Shop-Visit-Dedup ──────────────────────────────────────────────────────────
# SAP führt für eine einzige Shop-Einlieferung teils mehrere Projektnummern:
# ein Hauptprojekt (L./K.) plus Nebenaufträge, meist V. und oft WS90 - INV.
# Beide tragen dieselbe Engine Serial und denselben TAT Start. Ungefiltert zählt
# das Dashboard sie als getrennte Engines und summiert ihre Delays doppelt —
# im Export betrifft das 200 Einlieferungen mit 248 überzähligen Projekten.
# Der Schlüssel eines Shop Visits ist deshalb (Engine Serial, TAT Start Date).
_PREFIX_RANK = {'L': 0, 'K': 1, 'M': 2, 'N': 3, 'W': 4, 'V': 5}
SIDE_ORDER_WORKSCOPE_PREFIX = 'WS90'   # INV — Begleitauftrag, nie der Hauptauftrag


def _shop_visit_rank(eng: dict) -> tuple:
    """Sortierschlüssel: das Hauptprojekt eines Shop Visits kommt zuerst."""
    proj   = eng.get('project', '')
    prefix = proj.split('.', 1)[0] if '.' in proj else ''
    ws     = eng.get('workscope') or ''
    return (
        _PREFIX_RANK.get(prefix, 9),
        1 if ws.startswith(SIDE_ORDER_WORKSCOPE_PREFIX) else 0,
        -(eng.get('total_tat_cd') or 0),
        -(eng.get('mtu_delay_cd') or 0),
        proj,
    )


def _merge_shop_visits(engines: dict):
    """
    Projekte mit gleicher (Engine Serial, TAT Start) zu einem Shop Visit bündeln.

    Liefert (engines, aliases, stats). Das Hauptprojekt behält seine eigenen
    Zahlen unverändert — es wird nichts addiert, denn der Delay eines
    Nebenauftrags steckt bereits im TAT des Hauptprojekts. Die Nebenaufträge
    bleiben als `sub_projects` am Hauptprojekt erhalten, damit die Detailansicht
    sie zeigen kann; `aliases` hält ihre Projektnummern auflösbar.
    """
    groups  = defaultdict(list)
    singles = {}
    for proj, e in engines.items():
        esn = (e.get('esn') or '').strip()
        tat = (e.get('tat_start') or '').strip()
        ws  = e.get('workscope') or ''
        # Ohne ESN oder TAT Start gibt es keinen verlässlichen Schlüssel; die
        # MIGRATION_DUMMYs teilen sich alle ein Fake-Datum und dürfen deshalb
        # nie zusammenfallen.
        if not esn or not tat or ws.startswith(DUMMY_WORKSCOPE_PREFIX):
            singles[proj] = e
        else:
            groups[(esn, tat)].append(e)

    merged   = dict(singles)
    aliases  = {}
    n_merged = 0
    for members in groups.values():
        if len(members) == 1:
            merged[members[0]['project']] = members[0]
            continue
        members       = sorted(members, key=_shop_visit_rank)
        primary, rest = members[0], members[1:]
        primary['sub_projects'] = [{
            'project':       s['project'],
            'workscope':     s['workscope'],
            'otd':           s['otd'],
            'status':        s['status'],
            'mtu_delay_cd':  s['mtu_delay_cd'],
            'oem_delay_cd':  s.get('oem_delay_cd', 0),
            'cust_delay_cd': s.get('cust_delay_cd', 0),
            'total_tat_cd':  s.get('total_tat_cd', 0),
            'slu':           s.get('slu', 0),
        } for s in rest]
        for s in rest:
            aliases[s['project']] = primary['project']
        n_merged += len(rest)
        merged[primary['project']] = primary

    return merged, aliases, {'merged_away': n_merged, 'shop_visits': len(merged)}


def _build_cache():
    cfg       = load_config()
    t0        = time.time()
    engines   = _cached_parse('dataexport', cfg['export_path'],   load_dataexport)
    claims    = _cached_parse('claims',     cfg['claim_path'],    load_claims)
    incidents = load_incidents()          # eigener Snapshot-Fallback, kein mtime-Sidecar
    engines, aliases, merge_stats = _merge_shop_visits(engines)
    for eng in engines.values():
        eng.update(visit_kind(eng.get('esn'), eng.get('workscope')))
        for sub in eng.get('sub_projects', []):
            sub.update(visit_kind(sub.get('esn', eng.get('esn')), sub.get('workscope')))
    # Enrich engines with claim summary
    for project, eng in engines.items():
        eng_claims = claims.get(project, [])
        p7 = [c for c in eng_claims if c['notif_type'] == 'P7' and c['deletion'] != 'X']
        p1 = [c for c in eng_claims if c['notif_type'] == 'P1']
        p0 = [c for c in eng_claims if c['notif_type'] == 'P0']
        # Net delay sum from P7 claims
        claim_delay_cd = round(sum(c['delay_cd'] for c in p7), 2)
        # Per-milestone breakdown
        ms_shifts = defaultdict(list)
        for c in p7:
            if c['milestone']:
                ms_shifts[c['milestone']].append(c)
        eng['claims_p7']       = sorted(p7, key=lambda x: x['created_on'])
        eng['claims_p1']       = sorted(p1, key=lambda x: x['created_on'])
        eng['claims_p0']       = sorted(p0, key=lambda x: x['created_on'])
        eng['claim_delay_cd']  = claim_delay_cd
        eng['claim_match']     = abs(claim_delay_cd - eng['mtu_delay_cd']) < 0.1
        eng['ms_shifts']       = {k: v for k, v in ms_shifts.items()}
        eng['total_claims']    = len(eng_claims)
        # P7 delay aggregated per cycle
        cycles_p7 = {}
        for cyc in CYCLE_DEFS:
            cyc_claims = [c for c in p7 if c['cycle'] == cyc['name']]
            cycles_p7[cyc['name']] = {
                'delay_cd': round(sum(c['delay_cd'] for c in cyc_claims), 2),
                'count':    len(cyc_claims),
            }
        eng['cycles_p7'] = cycles_p7
        # Delay decomposition: Queue (Cycle 0) vs. Process (Cycle 1–4)
        queue_p7   = [c for c in p7 if c['cycle'] == 'Cycle 0']
        process_p7 = [c for c in p7 if c['cycle'] != 'Cycle 0']
        eng['queue_delay_cd']       = round(sum(c['delay_cd'] for c in queue_p7),   2)
        eng['process_delay_cd']     = round(sum(c['delay_cd'] for c in process_p7), 2)
        eng['queue_delay_count']    = len(queue_p7)
        eng['process_delay_count']  = len(process_p7)
        # Claims der Nebenaufträge bleiben separat ausgewiesen — sie in den
        # Hauptauftrag zu summieren würde claim_match gegen dessen eigenen
        # mtu_delay_cd zerstören.
        for sub in eng.get('sub_projects', []):
            sub_claims = claims.get(sub['project'], [])
            sub_p7 = [c for c in sub_claims
                      if c['notif_type'] == 'P7' and c['deletion'] != 'X']
            sub['total_claims']   = len(sub_claims)
            sub['claim_delay_cd'] = round(sum(c['delay_cd'] for c in sub_p7), 2)
        # Cycle-3 incidents from ServiceNow — über alle Projektnummern des Shop
        # Visits, sonst gingen die Incidents der Nebenaufträge verloren.
        inc = list(incidents.get(project, []))
        for sub in eng.get('sub_projects', []):
            inc.extend(incidents.get(sub['project'], []))
        inc = sorted(inc, key=lambda x: x['created_on'])
        res_times = [i['resolution_h'] for i in inc if i['resolution_h'] is not None]
        eng['incidents_c3']          = inc
        eng['incident_count']        = len(inc)
        eng['baustopp_count']        = sum(1 for i in inc if i['criticality'] == 'Baustopp')
        eng['kritisch_count']        = sum(1 for i in inc if i['criticality'] == 'Kritisch')
        eng['incident_avg_res_h']    = round(sum(res_times) / len(res_times), 1) if res_times else None
        eng['incident_total_res_h']  = round(sum(res_times), 1) if res_times else 0.0
    print(f'[cache] {len(engines)} shop visits '
          f'(+{merge_stats["merged_away"]} Nebenaufträge gebündelt), '
          f'{len(claims)} claim groups in {time.time() - t0:.1f}s')
    _write_snapshot(engines)
    return {'engines': engines, 'claims': claims, 'incidents': incidents,
            'aliases': aliases, 'merge_stats': merge_stats}


def get_data():
    c = _get_cache()
    return c['engines'], c['claims']


def get_incidents():
    return _get_cache()['incidents']


def resolve_project(project: str) -> str:
    """Projektnummer eines Nebenauftrags auf das führende Shop-Visit-Projekt mappen."""
    return _get_cache()['aliases'].get(project, project)


# ── Modul-Shop-Visits ─────────────────────────────────────────────────────────
# Rund jeder fünfte „Shop Visit" ist gar keine Engine, sondern ein einzelnes Modul.
# Zwei unabhängige Spuren (Abfragen K/L, 28.09.2026):
#   • im ESN-Feld steht eine Modul-Seriennummer: zwei Ziffern + X/T + Kennung
#     („30X7S4248", „40X481788R", „11X74702MTU") — dieselbe Konvention wie
#     module_group_serial_number in ECMS; die zwei Ziffern sind der CK des Moduls
#   • der Workscope-Text trägt „- M -" („WS80 - M - HS", „WS82 - M - AGB")
# Beides zusammen trifft 890 Projekte, nur der Code 322 (v. a. PT6 „32X… / WS3 - REP"),
# nur der Workscope 83 (LM-Gasgeneratoren mit eigener SN wie „FCP5FR1M").
# Nicht verwechseln mit der Workscope-KLASSE „Modular" — die meint Modularbeit AN
# einer Engine (HSI, QT LPT); hier kommt nur das Modul ins Haus.
MODULE_SN_RX = re.compile(r'^\s*(\d{2})([XT])', re.I)
MODULE_WS_RX = re.compile(r'-\s*M\s*-')
VISIT_ENGINE, VISIT_MODULE = 'engine', 'modul'


def visit_kind(esn: str, workscope: str) -> dict:
    """{'visit_kind': engine|modul, 'unit_code': '40X'|'', 'unit_ck': 'CK40'|'', 'visit_source': …}"""
    m = MODULE_SN_RX.match(esn or '')
    ws = bool(MODULE_WS_RX.search(workscope or ''))
    if not m and not ws:
        return {'visit_kind': VISIT_ENGINE, 'unit_code': '', 'unit_ck': '', 'visit_source': ''}
    return {'visit_kind': VISIT_MODULE,
            'unit_code': (m.group(1) + m.group(2).upper()) if m else '',
            'unit_ck': f'CK{m.group(1)}' if m else '',
            'visit_source': ('ESN-Code + Workscope' if m and ws else 'ESN-Code' if m else 'Workscope „- M -"')}


MODULE_VISIT_MODES = {'aus': 'Modul-Visits ausgeblendet', 'ein': 'Engines + Modul-Visits',
                      'nur': 'nur Modul-Visits'}
_visit_view = {'base': None, 'views': {}}


def module_visit_mode() -> str:
    """Globaler Schalter (sap_config.json); ?mv= überstimmt ihn für einen Aufruf."""
    try:
        arg = (request.args.get('mv') or '').strip()
        if arg in MODULE_VISIT_MODES:
            return arg
    except RuntimeError:                    # außerhalb eines Requests
        pass
    m = load_config().get('module_visits') or 'aus'
    return m if m in MODULE_VISIT_MODES else 'aus'


def dash_data():
    """Wie get_data(), aber nach dem Modul-Visit-Schalter gefiltert — für alle
    Auswertungen über viele Shop Visits (Dashboards, Workscopes, Export). Je Modus
    immer DASSELBE Dict-Objekt, damit Caches, die auf id(engines) schlüsseln
    (shop_exposure), weiter treffen und je Modus getrennt bleiben."""
    engines, claims = get_data()
    mode = module_visit_mode()
    if mode == 'ein':
        return engines, claims
    if _visit_view['base'] is not engines:
        _visit_view.update(base=engines, views={})
    view = _visit_view['views'].get(mode)
    if view is None:
        want = VISIT_MODULE if mode == 'nur' else VISIT_ENGINE
        view = {p: e for p, e in engines.items() if e.get('visit_kind', VISIT_ENGINE) == want}
        _visit_view['views'][mode] = view
    return view, claims


def _engine_visits(engines: dict) -> dict:
    """Nur Engine-Shop-Visits — für alles, was Triebwerke zählt."""
    return {p: e for p, e in engines.items() if e.get('visit_kind', VISIT_ENGINE) == VISIT_ENGINE}


def _distinct_esn(engines) -> int:
    """Physische Triebwerke: eine ESN kann über die Jahre mehrfach im Shop sein.
    Modul-Shop-Visits zählen nicht — ihre „ESN" ist die Seriennummer eines Moduls."""
    return len({(e.get('esn') or '').strip() for e in _engine_visits(engines).values()
                if (e.get('esn') or '').strip()})


# ── API routes ────────────────────────────────────────────────────────────────

@app.route('/api/engines')
def api_engines():
    engines, _ = get_data()
    q       = request.args.get('q', '').lower()
    otd     = request.args.get('otd', '')
    min_del = float(request.args.get('min_delay', -9999))
    kind    = request.args.get('kind', '')          # '' | engine | modul

    result = []
    for proj, e in engines.items():
        if kind and e.get('visit_kind', VISIT_ENGINE) != kind:
            continue
        subs = e.get('sub_projects', [])
        if q and q not in proj.lower() and q not in e['esn'].lower() \
              and q not in e['engine_type'].lower() and q not in e['customer'].lower() \
              and not any(q in s['project'].lower() for s in subs):
            continue
        if otd and e['otd'] != otd:
            continue
        if e['mtu_delay_cd'] < min_del:
            continue
        result.append({
            'project':       proj,
            'esn':           e['esn'],
            'engine_type':   e['engine_type'],
            'customer':      e['customer'],
            'workscope':     e['workscope'],
            'otd':           e['otd'],
            'status':        e['status'],
            'mtu_delay_cd':  e['mtu_delay_cd'],
            'oem_delay_cd':  e['oem_delay_cd'],
            'cust_delay_cd': e['cust_delay_cd'],
            'claim_delay_cd':e['claim_delay_cd'],
            'claim_match':   e['claim_match'],
            'total_claims':  e['total_claims'],
            'tat_start':     e['tat_start'],
            'tec':           e['milestones'].get('TEC', ''),
            'cpm':           e['cpm'],
            'sub_count':     len(subs),
            'visit_kind':    e.get('visit_kind', VISIT_ENGINE),
            'unit_code':     e.get('unit_code', ''),
            'visit_source':  e.get('visit_source', ''),
        })
    result.sort(key=lambda x: (OTD_ORDER.get(x['otd'], 3), -x['mtu_delay_cd']))
    return jsonify(result)


@app.route('/api/engine/<path:project>')
def api_engine(project):
    engines, _ = get_data()
    e = engines.get(project) or engines.get(resolve_project(project))
    if not e:
        return jsonify({'error': 'not found'}), 404
    return jsonify(e)


@app.route('/api/reload', methods=['POST'])
def api_reload():
    global _cache
    with _cache_lock:
        _cache = _build_cache()
    engines, _ = get_data()
    return jsonify({'ok': True, 'engines': len(engines)})


def _settings_payload(cfg: dict) -> dict:
    """Pfad-Schlüssel mit Datei-Prüfung, übrige Schlüssel als reiner Wert."""
    out = {}
    for key, val in cfg.items():
        if key in _CONFIG_PATH_KEYS:
            out[key] = {'path': val, 'exists': os.path.isfile(val)}
        else:
            out[key] = {'value': val}
    db, hint = _incident_target()
    resolved = ''
    try:
        resolved = _incident_resolve(db, hint) if db else ''
    except Exception:
        pass
    out['_incidents'] = {'database': db, 'table': hint, 'resolved': resolved,
                         'meta': get_incident_meta()}
    return out


@app.route('/api/settings', methods=['GET'])
def api_settings_get():
    return jsonify(_settings_payload(load_config()))


@app.route('/api/settings', methods=['POST'])
def api_settings_post():
    data    = request.get_json(force=True)
    allowed = set(_CONFIG_PATH_KEYS) | {'incident_database', 'incident_table',
                                        'incident_cycle'}
    cfg = load_config()
    for key in allowed:
        if key in data:
            cfg[key] = str(data[key]).strip()
    save_config(cfg)
    _inc_schema_cache.clear()        # Tabelle kann sich geändert haben
    _inc_table_cache.clear()
    _inc_extras_cache.clear()
    global _cache
    with _cache_lock:
        _cache = {}                  # nächster get_data() baut neu auf
    return jsonify({'ok': True, 'settings': _settings_payload(cfg)})


@app.route('/api/settings/module-visits', methods=['GET', 'POST'])
def api_settings_module_visits():
    """Globaler Schalter: Modul-Shop-Visits in Auswertungen ausblenden / einbeziehen / nur sie."""
    if request.method == 'POST':
        mode = str((request.get_json(silent=True) or {}).get('mode') or '').strip()
        if mode not in MODULE_VISIT_MODES:
            return jsonify({'error': 'mode muss aus, ein oder nur sein'}), 400
        cfg = load_config()
        cfg['module_visits'] = mode
        save_config(cfg)
    engines, _ = get_data()
    real = _real_shop_visits(engines)
    return jsonify({'mode': module_visit_mode(), 'label': MODULE_VISIT_MODES[module_visit_mode()],
                    'shop_visits': len(real),
                    'modul_visits': sum(1 for e in real.values() if e.get('visit_kind') == VISIT_MODULE)})


@app.route('/api/incidents/diag')
def api_incidents_diag():
    return jsonify(incident_diag())


def incident_diag() -> dict:
    """Erste-Hilfe-Diagnose nach dem Umstieg auf die SQL-Quelle: Woher kommen
    die Daten, wie viele Zeilen, welche Ausprägungen haben die Textfelder,
    die der Code hart vergleicht ('Baustopp'/'Kritisch')?"""
    engines, _ = get_data()
    incidents  = get_incidents()
    meta       = get_incident_meta()

    crit, state, groups, cycles, years = (defaultdict(int) for _ in range(5))
    total = res_known = unmatched_proj = 0
    # Projekt-Anreicherung: wie viele Incidents tragen einen Engine-Typ aus
    # x_mtuae_ins_project, wie viele davon fehlen im SAP-Dataexport, und passt
    # die Familie dort, wo es beide Quellen gibt?
    prj = {'mit_engine_typ': 0, 'ohne_sap_export_aber_mit_projekt': 0,
           'familie_gleich': 0, 'familie_abweichend': 0}
    fam_diff = defaultdict(int)
    for project, lst in incidents.items():
        eng = engines.get(project)
        if project not in engines:
            unmatched_proj += 1
        for i in lst:
            total += 1
            if i.get('engine_type'):
                prj['mit_engine_typ'] += 1
                if not eng:
                    prj['ohne_sap_export_aber_mit_projekt'] += 1
                else:
                    a = _engine_family(eng.get('engine_type') or '')
                    b = i.get('engine_family') or ''
                    if a == b:
                        prj['familie_gleich'] += 1
                    else:
                        prj['familie_abweichend'] += 1
                        fam_diff[f'{a} ≠ {b}'] += 1
            crit[i.get('criticality') or '–']   += 1
            state[i.get('state') or '–']        += 1
            groups[i.get('group') or '–']       += 1
            cycles[i.get('cycle') or '–']       += 1
            y = (i.get('created_on') or '')[:4]
            if y.isdigit():
                years[y] += 1
            if i.get('resolution_h') is not None:
                res_known += 1

    known_crit = {'Baustopp', 'Kritisch', 'Nicht kritisch'}
    unknown    = sorted(k for k in crit if k not in known_crit and k != '–')
    try:
        db, hint = _incident_target()
        cols = _incident_columns(db, _incident_resolve(db, hint))
    except Exception as e:
        cols = {'_fehler': str(e)}

    return {
        'meta':            meta,
        'spalten_mapping': cols,
        'projekte':        len(incidents),
        'projekte_ohne_engine': unmatched_proj,
        'incidents':       total,
        'mit_loesungszeit': res_known,
        'kritikalitaet':   dict(sorted(crit.items(),   key=lambda x: -x[1])),
        'status':          dict(sorted(state.items(),  key=lambda x: -x[1])),
        'projekt':         {**prj, 'abweichungen_top10':
                            sorted(fam_diff.items(), key=lambda x: -x[1])[:10]},
        'zyklen':          dict(sorted(cycles.items(), key=lambda x: -x[1])),
        'jahre':           dict(sorted(years.items())),
        'gruppen_top10':   sorted(groups.items(), key=lambda x: -x[1])[:10],
        'warnung_kritikalitaet': unknown,
    }


def _year_matches(year, y4: str) -> bool:
    """Year-pill semantics applied to a bucket anchor year ('YYYY' string)."""
    if not year or year in ('all', 'running'):
        return True
    if year == 'older':
        return bool(y4) and y4 < '2020'
    return y4 == str(year)


def _filter_engines(engines, year):
    """Filter engine dict by TAT-Start year. Special: 'all' = no filter, 'running' = no actual SA,
    'older' = TAT start before 2020 (same semantics as _year_matches)."""
    if not year or year == 'all':
        return engines
    if year == 'running':
        return {p: e for p, e in engines.items()
                if not e.get('milestones_actual', {}).get('SA', '')}
    if year == 'older':
        return {p: e for p, e in engines.items()
                if _year_matches('older', (e.get('tat_start') or '')[:4])}
    return {p: e for p, e in engines.items()
            if (e.get('tat_start') or '')[:4] == str(year)}


@app.route('/api/dashboard/years')
def api_dashboard_years():
    """Available TAT-start years and running engine count for filter pills."""
    engines, _ = dash_data()
    years = sorted(
        {(e.get('tat_start') or '')[:4] for e in engines.values()
         if (e.get('tat_start') or '')[:4].isdigit()},
        reverse=True
    )
    running = sum(1 for e in engines.values()
                  if not e.get('milestones_actual', {}).get('SA', ''))
    return jsonify({'years': years, 'running_count': running,
                    'total': len(engines)})


def _real_shop_visits(engines: dict) -> dict:
    """Ohne MIGRATION_DUMMY (WS99) — diese Projekte tragen keine reale Shop-Arbeit."""
    return {p: e for p, e in engines.items()
            if not (e.get('workscope') or '').startswith(DUMMY_WORKSCOPE_PREFIX)}


def _end_delay_stats(vals) -> dict:
    """Ø und Median des Endverzugs (ZMMP − ZMMT am Endtermin) über Engines mit Endtermin."""
    ds = [e['end_delay_cd'] for e in vals if e.get('end_delay_cd') is not None]
    return {
        'avg_end_delay_cd':    round(sum(ds) / len(ds), 1) if ds else None,
        'median_end_delay_cd': round(statistics.median(ds), 1) if ds else None,
        'end_delay_n':         len(ds),
    }


def _avg1(xs):
    """Mittelwert auf eine Nachkommastelle, None bei leerer Liste."""
    return round(sum(xs) / len(xs), 1) if xs else None


@app.route('/api/stats')
def api_stats():
    engines, _ = dash_data()
    year = request.args.get('year', 'all')
    engines = _real_shop_visits(_filter_engines(engines, year))
    otd_counts = defaultdict(int)
    total_mtu = 0.0
    total_oem = 0.0
    total_cust = 0.0
    incomplete = 0
    for e in engines.values():
        otd_counts[e['otd']] += 1
        total_mtu  += e['mtu_delay_cd']
        total_oem  += e.get('oem_delay_cd', 0) or 0
        total_cust += e.get('cust_delay_cd', 0) or 0
        if not e.get('milestones_actual', {}).get('SA', ''):
            incomplete += 1
    n = len(engines)
    return jsonify({
        'total_engines':    n,           # = Shop Visits, nach Dedup und ohne WS99
        'distinct_esn':     _distinct_esn(engines),
        'module_visits':    sum(1 for e in engines.values() if e.get('visit_kind') == VISIT_MODULE),
        'module_visit_mode': module_visit_mode(),
        'module_visits_hidden': (sum(1 for e in _real_shop_visits(_filter_engines(get_data()[0], year)).values()
                                     if e.get('visit_kind') == VISIT_MODULE)
                                 if module_visit_mode() == 'aus' else 0),
        'sub_projects':     sum(len(e.get('sub_projects', [])) for e in engines.values()),
        'otd':              dict(otd_counts),
        'avg_mtu_delay_cd': round(total_mtu  / n, 1) if n else 0,
        'avg_oem_delay_cd': round(total_oem  / n, 1) if n else 0,
        'avg_cust_delay_cd':round(total_cust / n, 1) if n else 0,
        **_end_delay_stats(engines.values()),
        'claim_match_rate': round(sum(1 for e in engines.values() if e.get('claim_match'))
                                  / n * 100, 1) if n else 0,
        'incomplete_count': incomplete,
    })


COLLECTIVE_DATE_MIN_N  = 10       # identical planned induction on N+ engines = placeholder


def _data_through(engines) -> str:
    """Latest actual date in the export — everything after this is plan, not fact."""
    through = ''
    for e in engines.values():
        act = e.get('milestones_actual') or {}
        for col in ('I', 'SA'):
            if act.get(col, '') > through:
                through = act[col]
    return through


def _collective_induction_dates(engines) -> dict:
    """
    Planned induction dates shared by many engines that never actually arrived.
    These are 'date still open' placeholders (typically 01.12.), not real slots —
    they otherwise pile up as phantom peaks in the capacity chart.
    """
    counts = defaultdict(int)
    for e in engines.values():
        act = (e.get('milestones_actual') or {}).get('I', '')
        pln = (e.get('milestones_zmmp') or {}).get('I', '')
        if pln and not act:
            counts[pln] += 1
    return {d: n for d, n in counts.items() if n >= COLLECTIVE_DATE_MIN_N}


@app.route('/api/dashboard/slu-capacity')
def api_slu_capacity():
    """
    SLU-Kapazitätslast pro Monat oder Quartal, aufgeteilt nach Engine-Familie.
    Ankerdatum: Induction-Meilenstein (I) aus aktuellem Plan (ZMMP), Fallback ZMMT.
    Query-Params: year (default 'all'), gran ('month'|'quarter', default 'month')
    Returns: { periods, families, data, summary }
    """
    engines, _ = dash_data()
    year = request.args.get('year', 'all')
    gran = request.args.get('gran', 'month')
    # Year filter runs on the bucket anchor (Induction), NOT on TAT Start Date:
    # both exist and differ for ~10% of engines, which used to push bars into
    # neighbouring years and drop engines inducted in the selected year.
    if year == 'running':
        engines = _filter_engines(engines, year)

    through   = _data_through(engines)
    collective = _collective_induction_dates(engines)

    buckets = defaultdict(lambda: {'slu': 0.0, 'engines': 0, 'no_slu': 0,
                                   'slu_actual': 0.0, 'slu_plan': 0.0,
                                   'by_family': defaultdict(float)})
    total_slu = 0.0
    engines_with_slu = engines_total = 0
    skip_dummy = skip_open = 0
    skip_open_slu = 0.0

    for e in engines.values():
        slu = e.get('slu', 0.0) or 0.0
        if (e.get('workscope') or '').startswith(DUMMY_WORKSCOPE_PREFIX):
            skip_dummy += 1                     # migration record, no shop work
            continue
        act_i     = (e.get('milestones_actual') or {}).get('I', '')
        induction = (e.get('milestones_zmmp') or {}).get('I', '') or \
                    (e.get('milestones_zmmt') or {}).get('I', '')
        if not induction or len(induction) < 7:
            continue
        if not act_i and induction in collective:
            skip_open     += 1                  # collective placeholder date
            skip_open_slu += slu
            continue
        if not _year_matches(year, induction[:4]):
            continue
        if gran == 'quarter':
            month_num = int(induction[5:7])
            q = (month_num - 1) // 3 + 1
            key = f"{induction[:4]}-Q{q}"
        else:
            key = induction[:7]
        b = buckets[key]
        b['engines'] += 1
        engines_total += 1
        if slu <= 0.0:
            b['no_slu'] += 1                    # counted, but contributes no load
            continue
        family = _engine_family(e.get('engine_type', ''))
        b['slu'] += slu
        b['by_family'][family] += slu
        b['slu_actual' if act_i else 'slu_plan'] += slu
        total_slu += slu
        engines_with_slu += 1

    # Collect all families that appear, sorted by total SLU desc
    family_totals = defaultdict(float)
    for bd in buckets.values():
        for fam, v in bd['by_family'].items():
            family_totals[fam] += v
    families = sorted(family_totals, key=lambda f: -family_totals[f])

    periods = sorted(buckets.keys())
    data = {}
    for p in periods:
        bd = buckets[p]
        data[p] = {
            'total_slu':   round(bd['slu'], 3),
            'total_hours': round(bd['slu'] * 3000),
            'engines':     bd['engines'],
            'no_slu':      bd['no_slu'],
            'coverage':    round(100 * (bd['engines'] - bd['no_slu']) / bd['engines'], 1)
                           if bd['engines'] else 0.0,
            'slu_actual':  round(bd['slu_actual'], 3),
            'slu_plan':    round(bd['slu_plan'], 3),
            'is_plan':     p > (through[:7] if gran == 'month'
                                else f"{through[:4]}-Q{(int(through[5:7]) - 1)//3 + 1}"),
            'by_family':   {f: round(bd['by_family'].get(f, 0.0), 3) for f in families},
        }

    n = engines_with_slu
    return jsonify({
        'periods':  periods,
        'families': families,
        'data':     data,
        'data_through': through,
        'summary': {
            'total_slu':          round(total_slu, 2),
            'total_hours':        round(total_slu * 3000),
            'avg_slu_per_engine': round(total_slu / n, 3) if n else 0,
            'engines_with_slu':   n,
            'engines_total':      engines_total,
            'engines_no_slu':     engines_total - n,
            'coverage':           round(100 * n / engines_total, 1) if engines_total else 0.0,
            'excluded_dummy':     skip_dummy,
            'excluded_open_date': skip_open,
            'excluded_open_slu':  round(skip_open_slu, 2),
            'collective_dates':   len(collective),
        },
    })


@app.route('/api/dashboard/throughput-by-month')
def api_throughput_by_month():
    """
    Ist-Durchsatz: Eingang (Actual Induction) vs. Ausgang (Actual SA) je Periode,
    als Stückzahl UND als SLU. Bewusst nur Actual-Daten — keine Plan-Termine,
    daher auch keine Sammeltermin-Platzhalter (01.12.) und keine Migrations-
    Dummies (WS99 hat praktisch keine Actual-Daten).
    Query: year (default 'all'), gran ('month'|'quarter')
    """
    engines, _ = dash_data()
    year = request.args.get('year', 'all')
    gran = request.args.get('gran', 'month')

    def bucket(iso):
        if not iso or len(iso) < 7:
            return None
        if gran == 'quarter':
            return f"{iso[:4]}-Q{(int(iso[5:7]) - 1) // 3 + 1}"
        return iso[:7]

    def blank():
        return {'in_count': 0, 'in_slu': 0.0, 'in_no_slu': 0,
                'out_count': 0, 'out_slu': 0.0, 'out_no_slu': 0}

    buckets   = defaultdict(blank)
    data_through = _data_through(engines)      # export horizon

    for e in engines.values():
        act = e.get('milestones_actual') or {}
        slu = e.get('slu', 0.0) or 0.0
        for col, pre in (('I', 'in'), ('SA', 'out')):
            iso = act.get(col, '')
            if not iso:
                continue
            if not _year_matches(year, iso[:4]):
                continue
            b = bucket(iso)
            if not b:
                continue
            buckets[b][f'{pre}_count'] += 1
            if slu > 0:
                buckets[b][f'{pre}_slu'] += slu
            else:
                buckets[b][f'{pre}_no_slu'] += 1

    cut = bucket(data_through) or ''
    periods = sorted(p for p in buckets if not cut or p <= cut)

    data = {}
    for p in periods:
        b = buckets[p]
        data[p] = {
            'in_count':   b['in_count'],
            'in_slu':     round(b['in_slu'], 3),
            'in_hours':   round(b['in_slu'] * 3000),
            'in_no_slu':  b['in_no_slu'],
            'out_count':  b['out_count'],
            'out_slu':    round(b['out_slu'], 3),
            'out_hours':  round(b['out_slu'] * 3000),
            'out_no_slu': b['out_no_slu'],
            'delta':      b['in_count'] - b['out_count'],
            'partial':    p == cut,      # bucket is cut off by the export date
        }

    # Averages exclude the partial last bucket — it would drag the mean down.
    full = [p for p in periods if not data[p]['partial']]
    n    = len(full)
    tin  = sum(data[p]['in_count']  for p in periods)
    tout = sum(data[p]['out_count'] for p in periods)
    miss = sum(data[p]['in_no_slu'] for p in periods)

    return jsonify({
        'periods': periods,
        'data':    data,
        'gran':    gran,
        'data_through': data_through,
        'summary': {
            'total_in':      tin,
            'total_out':     tout,
            'total_in_slu':  round(sum(data[p]['in_slu']  for p in periods), 2),
            'total_out_slu': round(sum(data[p]['out_slu'] for p in periods), 2),
            'avg_in':        round(sum(data[p]['in_count']  for p in full) / n, 1) if n else 0,
            'avg_out':       round(sum(data[p]['out_count'] for p in full) / n, 1) if n else 0,
            'periods_full':  n,
            'slu_missing':   miss,
            'slu_coverage':  round(100 * (tin - miss) / tin, 1) if tin else 0.0,
        },
    })


# ── Snapshots ─────────────────────────────────────────────────────────────────
# One compact row per engine, written once per export date. Plan dates in SAP get
# overwritten by actuals, so a forecast can only ever be back-tested against
# archived snapshots — this is what builds that history.
SNAPSHOT_DIR = os.path.join(BASE_DIR, 'snapshots')

_SNAPSHOT_FIELDS = ('project', 'esn', 'engine_type', 'workscope', 'status',
                    'customer', 'otd', 'slu', 'tat_start', 'mtu_delay_cd')


def _write_snapshot(engines: dict) -> str:
    """Archive the current export state. No-op if this export date is already stored."""
    through = _data_through(engines)
    if not through:
        return ''
    path = os.path.join(SNAPSHOT_DIR, f'snapshot_{through}.json.gz')
    if os.path.exists(path):
        return path
    rows = []
    for e in engines.values():
        act, zp, zt = (e.get('milestones_actual') or {}), (e.get('milestones_zmmp') or {}), \
                      (e.get('milestones_zmmt') or {})
        row = {k: e.get(k) for k in _SNAPSHOT_FIELDS}
        row['family']     = _engine_family(e.get('engine_type', ''))
        row['act_i']      = act.get('I', '')
        row['act_sa']     = act.get('SA', '')
        row['act_last']   = _last_actual_milestone(act)
        row['plan_i']     = zp.get('I', '')
        row['plan_sa']    = zp.get('SA', '')
        row['commit_sa']  = zt.get('SA', '')
        rows.append(row)
    try:
        os.makedirs(SNAPSHOT_DIR, exist_ok=True)
        tmp = path + '.tmp'
        with gzip.open(tmp, 'wt', encoding='utf-8') as f:
            json.dump({'data_through': through, 'written_at': datetime.date.today().isoformat(),
                       'engines': rows}, f, ensure_ascii=False)
        os.replace(tmp, path)
        print(f'[snapshot] {len(rows)} engines -> {os.path.basename(path)}')
    except Exception as exc:
        print(f'[snapshot] skipped: {exc}')
    return path


@app.route('/api/snapshots')
def api_snapshots():
    """Archived export states, newest first — the raw material for a later back-test."""
    out = []
    if os.path.isdir(SNAPSHOT_DIR):
        for name in sorted(os.listdir(SNAPSHOT_DIR), reverse=True):
            if not name.endswith('.json.gz'):
                continue
            full = os.path.join(SNAPSHOT_DIR, name)
            out.append({'file': name,
                        'data_through': name[len('snapshot_'):-len('.json.gz')],
                        'size_kb': round(os.path.getsize(full) / 1024, 1)})
    return jsonify({'count': len(out), 'dir': SNAPSHOT_DIR, 'snapshots': out})


# ── Forecast ──────────────────────────────────────────────────────────────────
FORECAST_STALE_FACTOR = 2.0   # last actual older than factor × median TAT → record is dead
# Measured bias, forecast/actual, from a truncated-history back-test over 8 cut-off
# dates (2024-06 … 2026-02). Reported in the UI, never silently applied.
FORECAST_BIAS = {
    1: {'slu': 1.46, 'count': 1.08, 'band_hits': '4/8'},
    2: {'slu': 0.98, 'count': 0.82, 'band_hits': '7/8'},
    3: {'slu': 1.06, 'count': 0.78, 'band_hits': '6/8'},
}
_COHORT_MIN_N         = 20    # below this, fall back to a coarser cohort


def _last_actual_milestone(actual: dict):
    """Furthest milestone in process order that carries an actual date → (code, date)."""
    found = None
    for col in MILESTONE_COLS:
        if actual.get(col):
            found = (col, actual[col])
    return found


def _quartiles(vals):
    v = sorted(vals)
    if len(v) < 4:
        med = statistics.median(v) if v else 0
        return [med, med, med]
    return statistics.quantiles(v, n=4)


def _remaining_time_cohorts(engines, since='2024-01'):
    """Empirical days-to-SA per last-reached milestone, from engines delivered since `since`."""
    samples = defaultdict(list)
    for e in engines.values():
        act = e.get('milestones_actual') or {}
        sa  = act.get('SA', '')
        if not sa or sa < since:
            continue
        for col in MILESTONE_COLS:
            d = act.get(col)
            if not d or d > sa:
                continue
            days = _date_delta_days(d, sa)
            if days is not None and days >= 0:
                samples[col].append(days)
    return {k: _quartiles(v) for k, v in samples.items() if len(v) >= _COHORT_MIN_N}, samples


@app.route('/api/dashboard/forecast')
def api_forecast():
    """
    Ausgangs-Prognose aus dem laufenden Bestand: jede Engine wird über den zuletzt
    ERREICHTEN Ist-Meilenstein eingeordnet und mit der empirischen Restlaufzeit
    dieser Kohorte (Q25/Median/Q75 aus abgeschlossenen Engines) fortgeschrieben.
    Drei Szenarien (schnell/mittel/langsam), keine Konfidenzintervalle.
    Eingang: Quartile der letzten 12 Ist-Monate (das Auftragsbuch reicht nicht weit genug).
    Query: gran ('month'|'quarter'), horizon (1..6 Perioden, default 3)
    """
    engines, _ = dash_data()
    gran    = request.args.get('gran', 'month')
    horizon = max(1, min(6, int(request.args.get('horizon', 3))))

    ref = _data_through(engines)
    if not ref:
        return jsonify({'error': 'no actual data'}), 400
    ref_date = datetime.date.fromisoformat(ref)

    def bucket(d: datetime.date):
        return f"{d.year}-Q{(d.month - 1)//3 + 1}" if gran == 'quarter' else d.strftime('%Y-%m')

    # Forecast starts at the next FULL period: the export cuts the current one, and
    # everything already overdue would otherwise pile into that first bar.
    partial_bucket = bucket(ref_date)
    periods = []
    cur = ref_date.replace(day=1)
    while len(periods) < horizon:
        cur = (cur.replace(day=28) + datetime.timedelta(days=8)).replace(day=1)
        b = bucket(cur)
        if b != partial_bucket and b not in periods:
            periods.append(b)

    # Historical TAT (Induction → SA) as the fallback cohort and the staleness yardstick
    tats = [_date_delta_days((e.get('milestones_actual') or {}).get('I', ''),
                             (e.get('milestones_actual') or {}).get('SA', ''))
            for e in engines.values()
            if (e.get('milestones_actual') or {}).get('SA', '') >= '2025-01'
            and (e.get('milestones_actual') or {}).get('I', '')]
    tats = [t for t in tats if t is not None and t > 0]
    tat_q = _quartiles(tats)
    stale_days = FORECAST_STALE_FACTOR * tat_q[1]

    cohorts, raw = _remaining_time_cohorts(engines)

    # SLU imputation per family — 10% of engines carry no SLU value
    fam_slu = defaultdict(list)
    for e in engines.values():
        if e.get('slu', 0) > 0:
            fam_slu[_engine_family(e.get('engine_type', ''))].append(e['slu'])
    fam_med = {f: statistics.median(v) for f, v in fam_slu.items()}
    all_med = statistics.median([s for v in fam_slu.values() for s in v]) if fam_slu else 0.0

    scen  = {q: defaultdict(lambda: [0, 0.0]) for q in (0, 1, 2)}   # q-index → period → [count, slu]
    rest  = {q: [0, 0.0] for q in (0, 1, 2)}   # still to come inside the running period
    due_now = [0, 0.0]      # median scenario: already overdue when the export was taken
    wip = active = imputed = stale = closed_no_sa = beyond = 0

    for e in engines.values():
        act = e.get('milestones_actual') or {}
        if not act.get('I') or act.get('SA'):
            continue
        wip += 1
        if e.get('status') == 'CLSD':
            closed_no_sa += 1
            continue
        last = _last_actual_milestone(act)
        if not last:
            continue
        ms, ms_date = last
        age = _date_delta_days(ms_date, ref)
        if age is None or age > stale_days:
            stale += 1                      # no activity for 2× median TAT → not a live engine
            continue
        active += 1

        slu = e.get('slu', 0.0) or 0.0
        if slu <= 0:
            slu = fam_med.get(_engine_family(e.get('engine_type', '')), all_med)
            imputed += 1

        q = cohorts.get(ms) or tat_q        # fallback: overall Induction→SA distribution
        base = datetime.date.fromisoformat(ms_date)
        for qi in (0, 1, 2):
            eta = base + datetime.timedelta(days=float(q[qi]))
            if eta < ref_date:              # overdue at the time of the export
                if qi == 1:
                    due_now[0] += 1         # backlog, deliberately kept out of every bar
                    due_now[1] += slu
                continue
            b = bucket(eta)
            if b == partial_bucket:         # still to come in the running period
                rest[qi][0] += 1
                rest[qi][1] += slu
            elif b in periods:
                scen[qi][b][0] += 1
                scen[qi][b][1] += slu
            elif qi == 1:
                beyond += 1

    # Eingang: quartiles of the last 12 actual months (order book horizon is too short)
    ins = defaultdict(lambda: [0, 0.0])
    for e in engines.values():
        i = (e.get('milestones_actual') or {}).get('I', '')
        if i:
            ins[i[:7]][0] += 1
            ins[i[:7]][1] += (e.get('slu', 0.0) or 0.0)
    hist_months = sorted(m for m in ins if m < ref[:7])[-12:]
    per_period  = 3 if gran == 'quarter' else 1
    in_cnt = _quartiles([ins[m][0] * per_period for m in hist_months])
    in_slu = _quartiles([ins[m][1] * per_period for m in hist_months])

    # Order book (planned inductions) for context only — dummy collective dates excluded
    order_book = defaultdict(int)
    for e in engines.values():
        pi = (e.get('milestones_zmmp') or {}).get('I', '')
        if pi and pi >= ref and not (pi[5:7] == '12' and pi[8:10] in ('01', '29')):
            d = datetime.date.fromisoformat(pi)
            order_book[bucket(d)] += 1

    # Actuals booked so far inside the running period
    cur_in = [0, 0.0]
    cur_out = [0, 0.0]
    for e in engines.values():
        act = e.get('milestones_actual') or {}
        for col, acc in (('I', cur_in), ('SA', cur_out)):
            iso = act.get(col, '')
            if iso and bucket(datetime.date.fromisoformat(iso)) == partial_bucket:
                acc[0] += 1
                acc[1] += (e.get('slu', 0.0) or 0.0)

    p_first, p_next = _period_bounds(ref_date, gran)
    days_total = (p_next - p_first).days
    days_left  = max(0, (p_next - ref_date).days - 1)
    left_share = days_left / days_total if days_total else 0.0

    def pack(cnt3, slu3):
        lo_c, hi_c = min(cnt3), max(cnt3)
        lo_s, hi_s = min(slu3), max(slu3)
        return {
            'count': {'lo': lo_c, 'mid': cnt3[1], 'hi': hi_c},
            'slu':   {'lo': round(lo_s, 2), 'mid': round(slu3[1], 2), 'hi': round(hi_s, 2)},
            'hours': {'lo': round(lo_s * 3000), 'mid': round(slu3[1] * 3000), 'hi': round(hi_s * 3000)},
        }

    out_data, in_data = {}, {}
    for p in periods:
        out_data[p] = pack([scen[q][p][0] for q in (0, 1, 2)],
                           [scen[q][p][1] for q in (0, 1, 2)])
        in_data[p] = pack([in_cnt[0], in_cnt[1], in_cnt[2]], [in_slu[0], in_slu[1], in_slu[2]])
        in_data[p]['order_book'] = order_book.get(p, 0)

    # Running period: booked so far + what the model still expects before it closes.
    # The incoming side simply pro-rates the historical median over the remaining days.
    current = {
        'period':      partial_bucket,
        'days_left':   days_left,
        'days_total':  days_total,
        'actual':      {'count': cur_out[0], 'slu': round(cur_out[1], 2),
                        'hours': round(cur_out[1] * 3000)},
        'actual_in':   {'count': cur_in[0], 'slu': round(cur_in[1], 2),
                        'hours': round(cur_in[1] * 3000)},
        'rest':        pack([rest[q][0] for q in (0, 1, 2)], [rest[q][1] for q in (0, 1, 2)]),
        'rest_in':     {'count': {'lo': round(in_cnt[0] * left_share), 'mid': round(in_cnt[1] * left_share),
                                  'hi': round(in_cnt[2] * left_share)},
                        'slu':   {'lo': round(in_slu[0] * left_share, 2), 'mid': round(in_slu[1] * left_share, 2),
                                  'hi': round(in_slu[2] * left_share, 2)},
                        'hours': {'lo': round(in_slu[0] * left_share * 3000), 'mid': round(in_slu[1] * left_share * 3000),
                                  'hi': round(in_slu[2] * left_share * 3000)}},
    }

    return jsonify({
        'periods':  periods,
        'gran':     gran,
        'ref_date': ref,
        'out':      out_data,
        'in':       in_data,
        'current':  current,
        'bias':     {str(k): v for k, v in FORECAST_BIAS.items()},
        'wip': {
            'total': wip, 'active': active, 'stale': stale, 'closed_no_sa': closed_no_sa,
            'slu_imputed': imputed, 'beyond_horizon': beyond,
            'due_now': due_now[0], 'due_now_slu': round(due_now[1], 2),
        },
        'method': {
            'tat_median_d':  round(tat_q[1]),
            'stale_days':    round(stale_days),
            'cohorts':       len(cohorts),
            'cohort_min_n':  _COHORT_MIN_N,
            'in_basis_months': len(hist_months),
            'valid_through': (ref_date + datetime.timedelta(days=float(tat_q[1]))).strftime('%Y-%m'),
            'note': ('Ausgang: laufender Bestand, je Engine ab zuletzt erreichtem Ist-Meilenstein '
                     'mit der empirischen Restlaufzeit (Q25/Median/Q75) dieser Kohorte. '
                     'Band = schnellstes bis langsamstes Szenario, kein Konfidenzintervall. '
                     'Eingang: Quartile der letzten Ist-Monate.'),
        },
    })


# ── Shop load ─────────────────────────────────────────────────────────────────
def _period_key(d: datetime.date, gran: str) -> str:
    return f"{d.year}-Q{(d.month - 1)//3 + 1}" if gran == 'quarter' else d.strftime('%Y-%m')


def _period_bounds(d: datetime.date, gran: str):
    """First day of d's period and first day of the next one."""
    if gran == 'quarter':
        first = datetime.date(d.year, 3 * ((d.month - 1) // 3) + 1, 1)
        nxt   = datetime.date(first.year + 1, 1, 1) if first.month == 10 \
                else datetime.date(first.year, first.month + 3, 1)
    else:
        first = d.replace(day=1)
        nxt   = datetime.date(first.year + 1, 1, 1) if first.month == 12 \
                else datetime.date(first.year, first.month + 1, 1)
    return first, nxt


def _spread(start: datetime.date, end: datetime.date, slu: float, gran: str, add):
    """Distribute one engine's SLU evenly over its residence time, period by period."""
    total = (end - start).days
    if total <= 0:
        total = 1
        end = start + datetime.timedelta(days=1)
    cur = start
    while cur < end:
        first, nxt = _period_bounds(cur, gran)
        seg = min(end, nxt) - cur
        days = seg.days
        if days > 0:
            add(_period_key(cur, gran), slu * days / total, days, (nxt - first).days)
        cur = nxt


@app.route('/api/dashboard/shop-load')
def api_shop_load():
    """
    Werkstattlast: SLU über die Verweildauer verteilt statt auf den Induction-Tag
    gebucht — die Arbeit fällt über die TAT an, nicht am Einlieferungstag.
    Drei Beiträge: abgeschlossene Engines (Ist), laufender Bestand bis zum
    erwarteten SA (Prognosemodell) und geplanter Zulauf ab Plan-Induction.
    Query: year (default 'all'), gran ('month'|'quarter')
    """
    engines, _ = dash_data()
    year = request.args.get('year', 'all')
    gran = request.args.get('gran', 'month')

    through    = _data_through(engines)
    ref_date   = datetime.date.fromisoformat(through) if through else datetime.date.today()
    collective = _collective_induction_dates(engines)

    # Reuse the forecast machinery for the engines that have not shipped yet
    cohorts, _sm = _remaining_time_cohorts(engines)
    tats = [_date_delta_days((e.get('milestones_actual') or {}).get('I', ''),
                             (e.get('milestones_actual') or {}).get('SA', ''))
            for e in engines.values()
            if (e.get('milestones_actual') or {}).get('SA', '') >= '2025-01'
            and (e.get('milestones_actual') or {}).get('I', '')]
    tats  = [t for t in tats if t is not None and t > 0]
    tat_q = _quartiles(tats)
    stale_days = FORECAST_STALE_FACTOR * tat_q[1]

    fam_slu = defaultdict(list)
    for e in engines.values():
        if e.get('slu', 0) > 0:
            fam_slu[_engine_family(e.get('engine_type', ''))].append(e['slu'])
    fam_med = {f: statistics.median(v) for f, v in fam_slu.items()}
    all_med = statistics.median([s for v in fam_slu.values() for s in v]) if fam_slu else 0.0

    buckets = defaultdict(lambda: {'actual': 0.0, 'wip': 0.0, 'plan': 0.0,
                                   'engine_days': 0.0, 'period_days': 0})
    counted = {'actual': 0, 'wip': 0, 'plan': 0}
    imputed = skipped_stale = skipped_dummy = 0

    for e in engines.values():
        if (e.get('workscope') or '').startswith(DUMMY_WORKSCOPE_PREFIX):
            skipped_dummy += 1
            continue
        act = e.get('milestones_actual') or {}
        act_i, act_sa = act.get('I', ''), act.get('SA', '')

        slu = e.get('slu', 0.0) or 0.0
        if slu <= 0:
            slu = fam_med.get(_engine_family(e.get('engine_type', '')), all_med)
            imputed += 1

        kind = start = end = None
        if act_i and act_sa:
            kind  = 'actual'
            start = datetime.date.fromisoformat(act_i)
            end   = datetime.date.fromisoformat(act_sa)
        elif act_i:
            if e.get('status') == 'CLSD':
                skipped_stale += 1
                continue
            last = _last_actual_milestone(act)
            if not last:
                continue
            ms, ms_date = last
            age = _date_delta_days(ms_date, through)
            if age is None or age > stale_days:
                skipped_stale += 1          # dead record, would inflate the load forever
                continue
            kind  = 'wip'
            start = datetime.date.fromisoformat(act_i)
            rem   = (cohorts.get(ms) or tat_q)[1]
            end   = max(datetime.date.fromisoformat(ms_date) + datetime.timedelta(days=float(rem)),
                        ref_date + datetime.timedelta(days=1))
        else:
            pi = (e.get('milestones_zmmp') or {}).get('I', '')
            if not pi or pi in collective or pi < through:
                continue                    # placeholder date or a plan already overtaken
            kind  = 'plan'
            start = datetime.date.fromisoformat(pi)
            end   = start + datetime.timedelta(days=float(tat_q[1]))

        counted[kind] += 1

        def add(key, val, days, pdays, _k=kind):
            if not _year_matches(year, key[:4]):
                return
            b = buckets[key]
            b[_k] += val
            b['engine_days'] += days
            b['period_days'] = pdays if gran == 'month' else max(b['period_days'], pdays)

        _spread(start, end, slu, gran, add)

    periods = sorted(buckets)
    data = {}
    for p in periods:
        b = buckets[p]
        total = b['actual'] + b['wip'] + b['plan']
        pd    = b['period_days'] or 30
        data[p] = {
            'actual':      round(b['actual'], 3),
            'wip':         round(b['wip'], 3),
            'plan':        round(b['plan'], 3),
            'total_slu':   round(total, 3),
            'total_hours': round(total * 3000),
            'avg_engines': round(b['engine_days'] / pd, 1),
            'is_forecast': (b['wip'] + b['plan']) > 0.6 * total if total else False,
        }

    return jsonify({
        'periods': periods,
        'data':    data,
        'data_through': through,
        'summary': {
            'engines_actual': counted['actual'],
            'engines_wip':    counted['wip'],
            'engines_plan':   counted['plan'],
            'slu_imputed':    imputed,
            'skipped_stale':  skipped_stale,
            'skipped_dummy':  skipped_dummy,
            'tat_median_d':   round(tat_q[1]),
            'valid_through':  _period_key(ref_date + datetime.timedelta(days=float(tat_q[1])), gran),
            'note': ('SLU gleichmäßig über die Verweildauer verteilt (Ist: Induction→SA, '
                     'laufender Bestand: Induction→erwartetes SA aus dem Kohortenmodell, '
                     'geplanter Zulauf: Plan-Induction + TAT-Median). Fehlende SLU-Werte '
                     'über den Familien-Median ergänzt, inaktive Datensätze aussortiert.'),
        },
    })


# ══════════════════════════════════════════════════════════════════════════════
# UI-Sprache — gemeinsamer Rahmen aller Seiten (nach RFX_Battlefield)
# Tokens, Leiste und Seitenkopf existieren genau einmal. Jedes Template trägt
# nur Platzhalter (<!--SAP_BASE--> <!--SAP_SHELL--> <!--SAP_RAIL--> <!--SAP_PH-->),
# sap_shell() setzt sie beim Laden des Moduls ein. Seiten-CSS nutzt nur Tokens.
# Styleguide: https://claude.ai/artifact/VS5CuafShbYehRjZhxxshA
# ══════════════════════════════════════════════════════════════════════════════

SAP_BASE_CSS = r"""
:root {
  /* Neutrale · warmes Papier */
  --paper:#f0ede6; --surface:#fbfaf6; --sunk:#e7e3da; --hover:#f5f2ec;
  --ink:#1b1b1b; --ink-2:#5f5a54; --ink-3:#8a847c;
  --line:rgba(27,27,27,.12); --line-2:rgba(27,27,27,.24);
  /* Status — immer mit Wort, nie nur Farbe */
  --ok:#2d7a2d;   --ok-bg:#e2eedd;
  --warn:#946300; --warn-bg:#f5ead0; --warn-line:#e3cf9c;
  --crit:#b8322a; --crit-bg:#f5e0db;
  --neutral-bg:#e6e2d9;
  /* Daten — nie Akzent */
  --info:#2f5f8f; --info-bg:#e3eaf2; --info-2:#9fb9d4;
  --violet:#5a45ad; --violet-bg:#ebe7f5;
  --ws-voll:#7a2e12; --ws-mod:#c8671c; --ws-bef:#3b6ea5; --ws-aus:#9e9e9e;
  /* Akzent: eine Tinte */
  --acc:var(--ink); --acc-soft:rgba(27,27,27,.06); --acc-line:rgba(27,27,27,.18); --on-acc:var(--surface);
  /* Schrift, Radien */
  --font:"Helvetica Neue",Helvetica,Arial,sans-serif;
  --mono:ui-monospace,"SF Mono",Menlo,Consolas,monospace;
  --fs-micro:10px; --fs-xs:11px; --fs-s:13px; --fs-m:15px; --fs-l:20px; --fs-xl:28px; --fs-hero:64px;
  --r-s:4px; --r-m:8px; --r-pill:999px;
  --ease:cubic-bezier(.4,0,.2,1);
  --rail-w:64px; --ph-h:0px;

  /* Alt-Namen der Seiten → neue Tokens */
  --bg:var(--paper); --border:var(--line); --text:var(--ink); --muted:var(--ink-2);
  --accent:var(--ink); --row-hover:var(--hover); --shadow:none;
  --green:var(--ok); --red:var(--crit); --yellow:var(--warn); --amber:var(--warn);
  --blue:var(--info); --purple:var(--violet);
  --tag-p7:var(--violet); --tag-p1:var(--info); --tag-p0:var(--ink-2);
  --voll:var(--ws-voll); --modular:var(--ws-mod); --befund:var(--ws-bef); --aus:var(--ws-aus);
}
"""

SAP_SHELL_CSS = r"""
html, body { background:var(--paper); }
body { margin:0; font-family:var(--font); color:var(--ink); padding-left:var(--rail-w);
       -webkit-font-smoothing:antialiased; }
button, input, select, textarea { font-family:inherit; }
:focus-visible { outline:2px solid var(--acc); outline-offset:2px; }
::-webkit-scrollbar { width:8px; height:8px; }
::-webkit-scrollbar-thumb { background:var(--line-2); border-radius:4px; }
::-webkit-scrollbar-track { background:transparent; }

/* ── Leiste ── */
#rail { position:fixed; left:0; top:0; bottom:0; width:var(--rail-w); z-index:900; overflow:hidden;
  display:flex; flex-direction:column; padding:24px 0 20px; box-sizing:border-box;
  background:var(--surface); border-right:1px solid var(--acc-line);
  transition:width .28s var(--ease), box-shadow .28s; }
#rail.expanded { width:200px; box-shadow:8px 0 32px rgba(27,27,27,.10); }
#rail .rail-top { display:flex; justify-content:center; padding-bottom:20px; }
#rail .rail-label { all:unset; cursor:pointer; font-size:9px; font-weight:700; letter-spacing:.18em;
  text-transform:uppercase; color:var(--ink-3); writing-mode:vertical-rl; transform:rotate(180deg);
  white-space:nowrap; transition:color .18s; }
#rail .rail-label:hover { color:var(--acc); }
#rail.expanded .rail-top { justify-content:flex-start; }
#rail.expanded .rail-label { writing-mode:horizontal-tb; transform:none; padding-left:18px; }
#rail .rail-nav { display:flex; flex-direction:column; gap:2px; margin:auto 0; overflow-y:auto; overflow-x:hidden; }
#rail .sb-sep { width:32px; height:1px; background:var(--line); margin:3px 16px; transition:width .28s var(--ease); }
#rail.expanded .sb-sep { width:168px; }
#rail .sb-item { display:flex; align-items:center; width:100%; padding:9px 0; box-sizing:border-box;
  text-decoration:none; border-left:2px solid transparent; transition:background .18s, border-color .18s; }
#rail .sb-item:hover { background:var(--hover); border-left-color:var(--acc-line); }
#rail .sb-item.active { background:var(--acc-soft); border-left-color:var(--acc); }
#rail .sb-ic { width:62px; flex-shrink:0; display:flex; flex-direction:column; align-items:center; gap:3px; }
#rail .sb-glyph { font-size:15px; line-height:1; color:var(--ink-3); }
#rail .sb-num { font-size:8px; letter-spacing:.12em; color:var(--ink-3); font-variant-numeric:tabular-nums; }
#rail .sb-text { font-size:var(--fs-xs); letter-spacing:.04em; color:var(--ink-2); white-space:nowrap;
  opacity:0; transform:translateX(-6px); transition:opacity .2s, transform .2s; }
#rail.expanded .sb-text { opacity:1; transform:none; }
#rail .sb-item.active .sb-glyph, #rail .sb-item.active .sb-num, #rail .sb-item.active .sb-text { color:var(--acc); }
#rail .sb-sub { display:none; flex-direction:column; padding:0 0 4px 62px; }
#rail.expanded .sb-sub { display:flex; }
#rail .sb-sub a { font-size:var(--fs-xs); color:var(--ink-3); padding:3px 0; white-space:nowrap; text-decoration:none; }
#rail .sb-sub a span { font-variant-numeric:tabular-nums; margin-right:6px; }
#rail .sb-sub a:hover, #rail .sb-sub a.on { color:var(--acc); }
#rail-tip { position:fixed; z-index:1000; left:calc(var(--rail-w) + 8px); pointer-events:none;
  display:flex; align-items:baseline; gap:8px; padding:6px 10px; border-radius:var(--r-s);
  background:var(--ink); color:var(--paper); font:500 var(--fs-xs)/1.2 var(--font); letter-spacing:.04em;
  white-space:nowrap; opacity:0; transform:translate(-4px,-50%); transition:opacity .12s, transform .12s; }
#rail-tip.on { opacity:1; transform:translate(0,-50%); }
#rail-tip::before { content:""; position:absolute; left:-4px; top:50%; margin-top:-4px;
  border:4px solid transparent; border-left:0; border-right-color:var(--ink); }
#rail-tip b { font-weight:400; font-size:9px; letter-spacing:.12em; opacity:.6; font-variant-numeric:tabular-nums; }
#rail .rail-foot { display:flex; justify-content:center; padding-top:16px; font-size:9px; letter-spacing:.14em; color:var(--ink-3); }

/* ── Seitenkopf: groß (Battlefield) ⇄ schmal ──
   Lese-Seiten: groß, schrumpft beim Scrollen (.collapsible → .slim); die gewonnene Höhe wird
   als margin-bottom zurückgegeben (.comp), damit der Inhalt nicht springt.
   Werkzeugseiten (Engine-Liste, SQL, Hierarchie): von Anfang an .slim. */
.sap-ph { display:flex; align-items:flex-start; flex-shrink:0; box-sizing:border-box; margin:0;
  padding:22px clamp(16px,3vw,36px) 18px; background:var(--paper); border-bottom:1px solid var(--line-2);
  font-family:var(--font); color:var(--ink); position:relative; z-index:100; }
.sap-ph.collapsible { position:sticky; top:0; height:var(--ph-big, auto); overflow:hidden; }
.sap-ph .ph-left { display:flex; flex-direction:column; gap:6px; min-width:200px; flex-shrink:0; }
.sap-ph .eyebrow { font-size:var(--fs-micro); letter-spacing:.14em; text-transform:uppercase; color:var(--ink-2);
  white-space:nowrap; max-height:16px; max-width:280px; overflow:hidden; }
.sap-ph .ph-num { font-size:var(--fs-hero); font-weight:700; line-height:.88; letter-spacing:-.03em; white-space:nowrap; }
.sap-ph .ph-num span { font-size:22px; font-weight:300; vertical-align:top; display:inline-block; padding-top:6px;
  letter-spacing:.01em; margin-left:4px; }
.sap-ph .ph-div { width:1px; align-self:stretch; background:var(--line-2); margin:0 32px; flex-shrink:0; }
.sap-ph .ph-center { flex:0 1 auto; display:flex; flex-direction:column; gap:10px; padding-top:2px; min-width:0; }
.sap-ph .ph-title { font-size:var(--fs-l); font-weight:400; line-height:1.25; letter-spacing:-.01em; margin:0; }
.sap-ph .ph-meta { color:var(--ink-2); font-size:var(--fs-xs); letter-spacing:.04em; white-space:nowrap;
  overflow:hidden; text-overflow:ellipsis; max-height:16px; }
.sap-ph .ph-right { display:flex; flex-wrap:nowrap; justify-content:flex-end; align-items:center; gap:8px;
  flex:1 1 0; min-width:120px; margin-left:16px; color:var(--ink-2); font-size:var(--fs-xs); white-space:nowrap; }
.sap-ph .ph-right > :not(.ph-mark):not(.btn):not(.mv-switch) { min-width:0; overflow:hidden; }
.sap-ph .mv-switch { display:flex; align-items:center; gap:6px; font-size:var(--fs-xs); color:var(--ink-2);
  white-space:nowrap; padding:3px 4px 3px 8px; border:1px solid var(--border); border-radius:var(--r-s); }
.sap-ph .mv-switch.mv-on { border-style:dashed; border-color:var(--ink-3); }
.sap-ph .mv-switch select { font:inherit; border:0; background:transparent; color:var(--ink-1, inherit); cursor:pointer; }
.sap-ph .ph-right #conntext { display:block; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.sap-ph .ph-mark { width:28px; height:28px; color:var(--ink); flex-shrink:0; margin-left:8px; }

/* Übergang: Zahl, Titel und Abstände schrumpfen sichtbar mit (320 ms) */
.sap-ph.collapsible, .sap-ph.collapsible .ph-left, .sap-ph.collapsible .eyebrow, .sap-ph.collapsible .ph-num,
.sap-ph.collapsible .ph-num span, .sap-ph.collapsible .ph-div, .sap-ph.collapsible .ph-center,
.sap-ph.collapsible .ph-title, .sap-ph.collapsible .ph-meta, .sap-ph.collapsible .ph-mark {
  transition:height .32s var(--ease), padding .32s var(--ease), margin .32s var(--ease), gap .32s var(--ease),
    min-width .32s var(--ease), font-size .32s var(--ease), line-height .32s var(--ease),
    max-height .32s var(--ease), max-width .32s var(--ease), opacity .22s var(--ease), width .32s var(--ease); }

.sap-ph.slim { align-items:center; padding-top:10px; padding-bottom:10px; min-height:52px; }
.sap-ph.collapsible.slim { height:52px; }
.sap-ph.slim.comp { margin-bottom:calc(var(--ph-big, 52px) - 52px); }
.sap-ph.slim .ph-left { flex-direction:row; align-items:baseline; gap:12px; min-width:0; }
.sap-ph.collapsible.slim .ph-left { flex-direction:column; gap:0; }
.sap-ph.collapsible.slim .eyebrow { max-height:0; max-width:0; opacity:0; }
.sap-ph.slim .ph-num { font-size:var(--fs-l); line-height:1; letter-spacing:-.02em; }
.sap-ph.slim .ph-num span { font-size:var(--fs-s); padding-top:0; vertical-align:baseline; margin-left:3px; }
.sap-ph.slim .ph-div { align-self:center; height:20px; margin:0 18px; }
.sap-ph.slim .ph-center { flex-direction:row; align-items:baseline; gap:14px; padding-top:0; }
.sap-ph.slim .ph-title { font-size:var(--fs-m); line-height:1.3; white-space:nowrap; flex-shrink:0; }
.sap-ph.slim .ph-title br { display:none; }
.sap-ph.slim .ph-meta { color:var(--ink-3); flex-shrink:2; }
.sap-ph.collapsible.slim .ph-meta { max-height:0; opacity:0; }
.sap-ph.slim .ph-mark { width:18px; height:18px; margin-left:6px; }
html.embed #rail, html.embed .sap-ph { display:none !important; }
html.embed body { padding-left:0; }

/* ── Abschnittsraster der Lese-Seiten (wie im Styleguide) ──
   links Kategorie, Überschrift, Frage und Schalter (klebt beim Scrollen), rechts der Inhalt */
.sap-page { max-width:1680px; margin:0 auto; padding:0 clamp(16px,3vw,36px) 72px; box-sizing:border-box; }
.sap-sec { display:grid; grid-template-columns:220px minmax(0,1fr); gap:20px 40px; padding-block:32px;
  border-top:1px solid var(--line-2); }
.sap-sec.first, .sap-page > .sap-sec:first-child { border-top:0; }
.sap-sec-head { position:sticky; top:calc(var(--ph-stick, 0px) + 20px); align-self:start;
  display:flex; flex-direction:column; gap:8px; min-width:0; }
.sap-sec-head > .eyebrow { font-size:var(--fs-micro); }
.sap-sec-head h2 { font-size:var(--fs-l); font-weight:500; letter-spacing:-.01em; line-height:1.25; margin:0;
  text-wrap:balance; }
.sap-sec-head p { color:var(--ink-2); font-size:var(--fs-s); line-height:1.5; margin:0; }
.sap-sec-ctl { display:flex; flex-wrap:wrap; gap:6px; align-items:center; margin-top:6px; }
.sap-sec-ctl:empty { display:none; }
.sap-sec-body { min-width:0; display:flex; flex-direction:column; gap:20px; }
@media (max-width:1280px) {
  .sap-sec { grid-template-columns:minmax(0,1fr); gap:14px; padding-block:24px; }
  .sap-sec-head { position:static; }
}

/* ── Bausteine ── */
.btn { display:inline-flex; align-items:center; justify-content:center; gap:7px; height:32px; padding:0 14px;
  border-radius:var(--r-s); border:1px solid var(--line-2); background:var(--surface); color:var(--ink);
  font:500 var(--fs-s)/1 var(--font); cursor:pointer; text-decoration:none; white-space:nowrap; box-sizing:border-box;
  transition:background .15s, border-color .15s, color .15s; }
.btn:hover { background:var(--hover); border-color:var(--ink-3); }
.btn.primary { background:var(--acc); border-color:var(--acc); color:var(--on-acc); }
.btn.primary:hover { background:#3a3835; border-color:#3a3835; }
.btn.ghost { background:transparent; border-color:transparent; color:var(--ink-2); }
.btn.ghost:hover { background:var(--hover); color:var(--ink); }
.btn.danger { color:var(--crit); border-color:rgba(184,50,42,.4); }
.btn.danger:hover { background:var(--crit-bg); }
.btn.sm { height:26px; padding:0 10px; font-size:var(--fs-xs); }
.btn.on:hover { background:#3a3835; border-color:#3a3835; color:var(--paper); }
.btn:disabled { opacity:.45; cursor:not-allowed; }
.chip { display:inline-flex; align-items:center; gap:6px; height:28px; padding:0 12px; border-radius:var(--r-pill);
  border:1px solid var(--line-2); background:transparent; color:var(--ink-2); font-size:var(--fs-xs); cursor:pointer; }
.chip:hover { color:var(--ink); border-color:var(--ink-3); }
.chip.on, .chip[aria-pressed="true"] { background:var(--ink); border-color:var(--ink); color:var(--paper); }
.pill { display:inline-flex; align-items:center; gap:6px; height:22px; padding:0 9px; border-radius:var(--r-pill);
  font-size:var(--fs-xs); font-weight:500; white-space:nowrap; }
.pill::before { content:""; width:6px; height:6px; border-radius:50%; background:currentColor; }
.pill.ok { background:var(--ok-bg); color:var(--ok); }
.pill.warn { background:var(--warn-bg); color:var(--warn); }
.pill.crit { background:var(--crit-bg); color:var(--crit); }
.pill.neutral { background:var(--neutral-bg); color:var(--ink-2); }
.note { padding:10px 14px; border-radius:var(--r-s); background:var(--surface); border:1px solid var(--line);
  border-left:3px solid var(--ink-3); color:var(--ink-2); }
.note.warn { border-left-color:var(--warn); } .note.crit { border-left-color:var(--crit); } .note.ok { border-left-color:var(--ok); }
.eyebrow { font-size:var(--fs-xs); letter-spacing:.14em; text-transform:uppercase; color:var(--ink-2); }
a:not([class]) { color:var(--ink); text-decoration:underline; text-decoration-color:var(--line-2);
  text-underline-offset:2px; }
a:not([class]):hover { text-decoration-color:var(--ink); }

@media (max-width:1200px) { .sap-ph.slim .ph-meta { display:none; } }
@media (max-width:900px) {
  .sap-ph.slim .eyebrow { display:none; }
  .sap-ph .ph-right { flex-wrap:wrap; }
}
@media (max-width:720px) {
  body { padding-left:0; }
  #rail, #rail.expanded { position:sticky; top:0; bottom:auto; width:auto; height:auto; padding:0; flex-direction:row;
    border-right:0; border-bottom:1px solid var(--acc-line); box-shadow:none; overflow-x:auto; }
  #rail .rail-top, #rail .rail-foot, #rail .sb-sep, #rail .sb-sub, #rail .sb-text { display:none !important; }
  #rail .rail-nav { flex-direction:row; margin:0; overflow:visible; }
  #rail .sb-item { width:auto; padding:8px 0; border-left:0; border-bottom:2px solid transparent; }
  #rail .sb-item.active { border-bottom-color:var(--acc); }
  #rail .sb-ic { width:52px; }
  .sap-ph { flex-wrap:wrap; padding:10px 16px; row-gap:6px; }
  .sap-ph.collapsible, .sap-ph.collapsible.slim { position:relative; height:auto; overflow:visible; }
  .sap-ph .ph-div { display:none; }
  .sap-ph .ph-center { flex-basis:100%; order:3; }
  .sap-ph .ph-right { margin-left:auto; }
}
@media (prefers-reduced-motion: reduce) { #rail, #rail * { transition:none !important; } }
"""

SAP_SHELL_JS = r"""
<script>
(function () {
  /* Chart.js: Schrift und Gerüst wie die App (Canvas kennt keine CSS-Variablen) */
  if (window.Chart && Chart.defaults) {
    Chart.defaults.font.family = '"Helvetica Neue", Helvetica, Arial, sans-serif';
    Chart.defaults.font.size = 11;
    Chart.defaults.color = '#5f5a54';
    Chart.defaults.borderColor = 'rgba(27,27,27,.10)';
  }
  var rail = document.getElementById('rail');
  if (!rail) return;
  var tog = rail.querySelector('.rail-label');
  function set(open) {
    rail.classList.toggle('expanded', open);
    tog.setAttribute('aria-expanded', open ? 'true' : 'false');
    try { localStorage.setItem('sap_rail', open ? '1' : '0'); } catch (e) {}
  }
  tog.addEventListener('click', function () { set(!rail.classList.contains('expanded')); });
  document.addEventListener('keydown', function (e) {
    if (e.key === 'Escape' && rail.classList.contains('expanded')) set(false);
  });
  document.addEventListener('click', function (e) {
    if (rail.classList.contains('expanded') && !rail.contains(e.target)) set(false);
  });
  /* Namensschild beim Überfahren, solange die Leiste zugeklappt ist */
  var tip = document.createElement('div');
  tip.id = 'rail-tip'; tip.setAttribute('aria-hidden', 'true');
  document.body.appendChild(tip);
  function hideTip() { tip.classList.remove('on'); }
  rail.querySelectorAll('.sb-item').forEach(function (a) {
    a.addEventListener('mouseenter', function () {
      if (rail.classList.contains('expanded') || window.matchMedia('(max-width:720px)').matches) return;
      var r = a.getBoundingClientRect();
      tip.innerHTML = '<b>' + a.querySelector('.sb-num').textContent + '</b>' + a.dataset.tip;
      tip.style.top = (r.top + r.height / 2) + 'px';
      tip.classList.add('on');
    });
    a.addEventListener('mouseleave', hideTip);
  });
  rail.querySelector('.rail-nav').addEventListener('scroll', hideTip, {passive: true});
  tog.addEventListener('click', hideTip);
  /* Unterpunkte 04.x: aktiven Reiter aus ?tab= markieren */
  var tab = new URLSearchParams(location.search).get('tab');
  rail.querySelectorAll('.sb-sub a').forEach(function (a) {
    if (a.dataset.tab && (a.dataset.tab === tab || (!tab && a.dataset.tab === 'katalog' && location.pathname === '/hub/module')))
      a.classList.add('on');
  });
  /* Großer Kopf schrumpft beim Scrollen zum schmalen (Lese-Seiten) */
  function collapsible() {
    var ph = document.querySelector('.sap-ph.collapsible');
    if (!ph || document.documentElement.classList.contains('embed')) return;   /* im iframe ist der Kopf aus */
    var root = document.documentElement, slim = false, lock = false;
    var shell = !!document.getElementById('frames');          /* Modul-Katalog: scrollt im iframe */
    var narrow = window.matchMedia('(max-width:720px)');
    function big() {
      if (slim) return;
      root.style.removeProperty('--ph-big');
      root.style.setProperty('--ph-big', ph.offsetHeight + 'px');
    }
    function set(v, comp) {
      ph.classList.toggle('comp', !!(v && comp));
      if (v === slim) return;
      slim = v; ph.classList.toggle('slim', v);
    }
    function y() {
      var f = shell && document.querySelector('#frames iframe.on');
      if (f) { try { return f.contentWindow.scrollY || 0; } catch (e) { return 0; } }
      return window.scrollY;
    }
    function check() {
      if (lock || narrow.matches) { set(true, false); return; }
      var v = y();
      if (!slim && v > 60) set(true, !shell);
      else if (slim && v < 4) set(false, false);
    }
    window.sapPh = { lock: function (on) { lock = !!on; if (!on) set(false, false); check(); }, check: check };
    window.addEventListener('scroll', check, {passive: true});
    document.querySelectorAll('#frames iframe').forEach(function (f) {
      f.addEventListener('load', function () {
        try { f.contentWindow.addEventListener('scroll', check, {passive: true}); } catch (e) {}
        check();
      });
    });
    window.addEventListener('resize', function () { big(); check(); });
    window.addEventListener('load', big);
    root.style.setProperty('--ph-stick', '52px');             /* eigene sticky-Leisten der Seite darunter */
    lock = shell && !!document.querySelector('.tab.on[data-tab="graph"]');   /* Start direkt auf Hierarchie */
    big(); check();
  }
  /* Kopfhöhe für Seiten mit calc(100vh - …) */
  function measure() {
    var ph = document.querySelector('.sap-ph');
    if (ph) document.documentElement.style.setProperty('--ph-h', Math.ceil(ph.getBoundingClientRect().bottom + window.scrollY) + 'px');
  }
  document.addEventListener('DOMContentLoaded', function () {
    collapsible();
    measure();
    var ph = document.querySelector('.sap-ph');
    if (ph && window.ResizeObserver) new ResizeObserver(measure).observe(ph);
  });
  window.addEventListener('load', measure);
})();
/* Modul-Visit-Schalter: global (sap_config.json). Seiten hören auf „mvmode". */
window.MV_MODE = null;
function mvInit() {
  var sel = document.getElementById('mv-mode');
  if (!sel) return;
  fetch('/api/settings/module-visits').then(function (r) { return r.json(); }).then(function (d) {
    sel.value = d.mode; window.MV_MODE = d.mode;
    sel.parentNode.classList.toggle('mv-on', d.mode !== 'ein');
    sel.parentNode.title = sel.parentNode.title.split(' · ')[0] + ' · ' + d.modul_visits + ' von ' + d.shop_visits + ' Shop Visits sind Modul-Visits';
    document.dispatchEvent(new CustomEvent('mvmode', {detail: d}));
  }).catch(function () {});
}
function mvSet(mode) {
  fetch('/api/settings/module-visits', {method: 'POST', headers: {'Content-Type': 'application/json'},
    body: JSON.stringify({mode: mode})}).then(function () { location.reload(); });
}
document.addEventListener('DOMContentLoaded', mvInit);
</script>
"""

SAP_PAGES = [
    # href, glyph, nr, label, key
    # Ein Zeichen für alle Seiten, nur Einstellungen hebt sich ab
    ('/dashboard',  '◆', '00', 'Firmen-Dashboard', 'dashboard'),
    ('/',           '◆', '01', 'Engine-Liste',     'engines'),
    ('/sql',        '◆', '02', 'MTU Hub SQL',      'sql'),
    ('/hub',        '◆', '03', 'Hub-Auswertungen', 'hub'),
    ('/hub/module', '◆', '04', 'Modul-Katalog',    'module'),
    ('/workscopes', '◆', '05', 'Workscopes',       'ws'),
    ('/priorisierung', '◆', '06', 'Priorisierung', 'triage'),
    ('/settings',   '◐', '07', 'Einstellungen',    'settings'),
    ('/check',      '◐', '08', 'Datencheck',       'check'),
]
SAP_MODULE_TABS = [('04.1', 'Katalog', 'katalog'), ('04.2', 'Modulvorschlag', 'vorschlag'),
                   ('04.3', 'Hierarchie', 'graph'), ('04.4', 'Einbaustruktur', 'struktur')]

# Werkzeugseiten mit fester Bildschirmhöhe: Kopf immer schmal. Alle anderen: groß, schrumpft beim Scrollen.
SAP_TOOL_PAGES = {'engines', 'sql'}

# Kopf je Seite: Nummer, Titel (→ und <br> wie in RFX_Battlefield), Meta-Zeile
SAP_HEADS = {
    'engines':   ('01', 'Shop Visits je Engine →<br>Verzug, Meilensteine und Claims',
                  'Engine-Liste · ZLSGS Dataexport × Claimexport Validation'),
    'dashboard': ('00', 'Delay-Analyse →<br>über alle Engines und Shops',
                  'Firmen-Dashboard · Zeitraum 2024–2026'),
    'sql':       ('02', 'Abfrage-Baukasten →<br>direkt auf msql-lud-core',
                  'MTU Hub SQL · Server, Datenbanken, Tabellen'),
    'hub':       ('03', 'Gespeicherte Abfragen →<br>als Diagramm',
                  'Hub-Auswertungen · laden sich selbst nach'),
    'module':    ('04', 'Teilenummern →<br>Module und Obergruppen',
                  'Modul-Katalog · Katalog, Modulvorschlag, Hierarchie, Einbaustruktur'),
    'ws':        ('05', 'Workscope-Klassen →<br>je Engine-Typ',
                  'Workscopes · Codes gelten pro Engine-Typ'),
    'triage':    ('06', 'Offene Incidents →<br>was zuerst gelöst werden muss',
                  'Priorisierung · Schwere, Zeit bis SA, Cycle, Häufung, Alter'),
    'settings':  ('07', 'Verbindung →<br>Server, Quellen, Zugang',
                  'Einstellungen'),
    'check':     ('08', 'Kann man den Zahlen trauen? →<br>Quellen, Zuordnung, Werte',
                  'Datencheck · Erstlauf-Prüfung mit Ampeln'),
}

SAP_MARK = ('<svg class="ph-mark" viewBox="0 0 28 28" fill="none" aria-hidden="true">'
            '<circle cx="14" cy="14" r="12" stroke="currentColor" stroke-width="1.4"/>'
            '<circle cx="14" cy="14" r="6.5" stroke="currentColor" stroke-width="1"/>'
            '<circle cx="14" cy="14" r="2" fill="currentColor"/></svg>')


def sap_sidebar_html(active):
    items = ''
    for i, (href, glyph, num, label, key) in enumerate(SAP_PAGES):
        cls = 'sb-item active' if key == active else 'sb-item'
        sep = '<div class="sb-sep"></div>' if i else ''
        items += (f'{sep}<a href="{href}" class="{cls}" aria-label="{num} {label}" data-tip="{label}">'
                  f'<span class="sb-ic"><span class="sb-glyph">{glyph}</span>'
                  f'<span class="sb-num">{num}</span></span>'
                  f'<span class="sb-text">{label}</span></a>')
        if key == 'module':
            items += '<div class="sb-sub">' + ''.join(
                f'<a href="/hub/module?tab={t}" data-tab="{t}"><span>{n}</span>{l}</a>'
                for n, l, t in SAP_MODULE_TABS) + '</div>'
    return ('<aside id="rail" aria-label="Navigation"><div class="rail-top">'
            '<button class="rail-label" aria-expanded="false">Navigation</button></div>'
            f'<nav class="rail-nav">{items}</nav><div class="rail-foot">V3</div></aside>')


def sap_page_header(key):
    num, title, meta = SAP_HEADS[key]
    title = title.replace('<br>', ' <br>')   # schmal: <br> wird ausgeblendet, das Leerzeichen bleibt
    return ('<div class="ph-left"><div class="eyebrow">SAP Claim Analyzer · V3</div>'
            f'<div class="ph-num">SAP<span>#{num}</span></div></div>'
            '<div class="ph-div"></div>'
            f'<div class="ph-center"><h1 class="ph-title">{title}</h1>'
            f'<div class="ph-meta">{meta}</div></div>')


# Seiten, die über viele Shop Visits auswerten — dort sitzt der Modul-Visit-Schalter
SAP_MV_PAGES = {'engines', 'dashboard', 'ws'}
SAP_MV_SWITCH = (
    '<label class="mv-switch" title="Modul-Shop-Visits: nur ein Modul im Haus, keine ganze Engine '
    '(Modul-SN im ESN-Feld wie 30X…, oder Workscope „- M -"). Gilt für Dashboard, Workscopes und Kennzahlen '
    'aller Seiten; Engine-Detail, Triage und Baukasten bleiben ungefiltert.">Modul-Visits'
    '<select id="mv-mode" onchange="mvSet(this.value)">'
    '<option value="aus">ausblenden</option><option value="ein">einbeziehen</option>'
    '<option value="nur">nur Module</option></select></label>')


def sap_shell(html, key):
    """Platzhalter eines Templates mit dem gemeinsamen Rahmen füllen."""
    if key in SAP_MV_PAGES:
        html = html.replace('<div class="ph-right">', '<div class="ph-right">' + SAP_MV_SWITCH, 1)
    return (html
            .replace('<!--SAP_BASE-->', '<style>' + SAP_BASE_CSS + '</style>', 1)
            .replace('<!--SAP_SHELL-->', '<style>' + SAP_SHELL_CSS + '</style>', 1)
            .replace('<!--SAP_RAIL-->', sap_sidebar_html(key) + SAP_SHELL_JS, 1)
            .replace('<header class="sap-ph">',
                     '<header class="sap-ph slim">' if key in SAP_TOOL_PAGES
                     else '<header class="sap-ph collapsible">', 1)
            .replace('<!--SAP_PH-->', sap_page_header(key), 1)
            .replace('<!--SAP_MARK-->', SAP_MARK, 1))


# ── HTML ──────────────────────────────────────────────────────────────────────

HTML = r"""<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SAP Claim Analyzer</title>
<script src="/static/chart.umd.min.js"></script>
<script src="/static/chartjs-adapter-date-fns.bundle.min.js"></script>
<!--SAP_BASE-->
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }

body { font-family: var(--font); background: var(--bg); color: var(--text); min-height: 100vh; }

/* Layout */
#app { display: flex; height: calc(100vh - var(--ph-h)); overflow: hidden; }
#sidebar { width: 420px; min-width: 320px; display: flex; flex-direction: column; border-right: 1px solid var(--border); background: var(--surface); }
#detail  { flex: 1; overflow-y: auto; background: var(--bg); }

/* Header */
#header { padding: 16px 16px 12px; border-bottom: 1px solid var(--border); }
#header h1 { font-size: 15px; font-weight: 700; letter-spacing: -.3px; }
#header p  { font-size: 11px; color: var(--muted); margin-top: 2px; }
#stats-bar { display: flex; gap: 12px; flex-wrap: wrap; }
.stat { font-size: 11px; }
.stat b { font-size: 15px; font-weight: 700; display: block; }
.stat-green b { color: var(--green); }
.stat-yellow b { color: var(--yellow); }
.stat-red   b { color: var(--red); }
.stat-blue  b { color: var(--blue); }

/* Filter bar */
#filters { padding: 10px 12px; border-bottom: 1px solid var(--border); display: flex; gap: 8px; flex-wrap: wrap; }
#filters input, #filters select {
  font-size: 13px; padding: 5px 8px; border: 1px solid var(--border);
  border-radius: var(--r-s); background: var(--bg); color: var(--text); flex: 1; min-width: 80px;
}
#filters input:focus, #filters select:focus { outline: 2px solid var(--ink-3); }
#filters button {
  font-size: 11px; padding: 5px 10px; border: none; border-radius: var(--r-s);
  background: var(--accent); color: var(--surface); cursor: pointer; white-space: nowrap;
}

/* Engine list */
#engine-list { flex: 1; overflow-y: auto; }
.engine-row {
  padding: 10px 14px; border-bottom: 1px solid var(--border); cursor: pointer;
  display: flex; align-items: flex-start; gap: 10px; transition: background .1s;
}
.engine-row:hover  { background: var(--row-hover); }
.engine-row.active { background: var(--sunk); }
.otd-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; margin-top: 4px; }
.otd-Green  { background: var(--green); }
.otd-Yellow { background: var(--yellow); }
.otd-Red    { background: var(--red); }
.otd-       { background: var(--ink-3); }
.engine-meta { flex: 1; min-width: 0; }
.engine-meta .title { font-size: 13px; font-weight: 600; }
.engine-meta .sub   { font-size: 11px; color: var(--muted); margin-top: 1px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.engine-delays { text-align: right; flex-shrink: 0; }
.delay-val { font-size: 15px; font-weight: 700; }
.delay-val.pos  { color: var(--red); }
.delay-val.neg  { color: var(--green); }
.delay-val.zero { color: var(--muted); }
.engine-delays .sub { font-size: 10px; color: var(--muted); }
.match-badge { font-size: 10px; padding: 1px 4px; border-radius: var(--r-s); margin-top: 3px; display: inline-block; }
.match-ok   { background: var(--ok-bg); color: var(--ok); }
.match-fail { background: var(--crit-bg); color: var(--crit); }
.sub-badge { font-size: 10px; padding: 1px 4px; border-radius: var(--r-s); background: var(--neutral-bg);
             color: var(--ink-2); font-weight: 600; vertical-align: middle; }
.mod-badge { font-size: 10px; padding: 1px 4px; border-radius: var(--r-s); border: 1px dashed var(--ink-3);
             color: var(--ink-2); font-weight: 600; vertical-align: middle; }

/* Nebenauftraege eines Shop Visits */
.sub-projects { margin-top: 14px; border: 1px solid var(--border); border-radius: var(--r-m); padding: 10px 12px; }
.sub-projects .sp-head { font-size: 11px; font-weight: 700; color: var(--muted);
                         text-transform: uppercase; letter-spacing: .04em; margin-bottom: 6px; }
.sp-table { width: 100%; border-collapse: collapse; font-size: 13px; }
.sp-table th { text-align: left; font-weight: 600; color: var(--muted);
               font-size: 10px; padding: 2px 6px; border-bottom: 1px solid var(--border); }
.sp-table td { padding: 3px 6px; border-bottom: 1px solid var(--border); }
.sp-table tr:last-child td { border-bottom: none; }
.sub-projects .sp-note { font-size: 10px; color: var(--muted); margin-top: 6px; }

/* Detail panel */
#detail-empty { display: flex; align-items: center; justify-content: center; height: 100%; color: var(--muted); font-size: 15px; }
#detail-content { padding: 24px; max-width: 900px; }

.section { background: var(--surface); border-radius: var(--r-m); border: 1px solid var(--border); margin-bottom: 20px; overflow: hidden; box-shadow: var(--shadow); }
.section-header { padding: 12px 16px; border-bottom: 1px solid var(--border); display: flex; align-items: center; justify-content: space-between; }
.section-header h2 { font-size: 13px; font-weight: 700; }
.section-body { padding: 16px; }

/* Engine info grid */
.info-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 12px; }
.info-item label { font-size: 10px; color: var(--muted); text-transform: uppercase; letter-spacing: .5px; display: block; }
.info-item span  { font-size: 13px; font-weight: 500; }

/* Delay comparison */
.delay-compare { display: flex; gap: 24px; flex-wrap: wrap; align-items: flex-end; }
.delay-block { text-align: center; padding: 12px 20px; border-radius: var(--r-m); background: var(--bg); }
.delay-block .label { font-size: 10px; color: var(--muted); text-transform: uppercase; letter-spacing: .5px; }
.delay-block .value { font-size: 28px; font-weight: 800; line-height: 1.1; }
.delay-block .unit  { font-size: 11px; color: var(--muted); }
.delay-block.highlight { background: var(--warn-bg); }
.arrow { font-size: 28px; color: var(--muted); align-self: center; }
.match-banner { padding: 8px 14px; border-radius: var(--r-m); font-size: 13px; font-weight: 600; }
.match-banner.ok   { background: var(--ok-bg); color: var(--ok); }
.match-banner.fail { background: var(--crit-bg); color: var(--crit); }

/* Milestone timeline */
.ms-timeline { display: flex; flex-direction: column; gap: 0; }
.ms-header { display: grid; grid-template-columns: 48px 1fr 88px 88px 88px 50px 88px; gap: 6px;
             padding: 5px 8px; font-size: 10px; font-weight: 600; color: var(--muted);
             text-transform: uppercase; letter-spacing: .4px; border-bottom: 1px solid var(--border); }
.ms-row { display: grid; grid-template-columns: 48px 1fr 88px 88px 88px 50px 88px; gap: 6px;
          align-items: center; font-size: 11px; padding: 5px 8px; border-radius: var(--r-s); }
.ms-row:nth-child(odd) { background: var(--bg); }
.ms-code  { font-weight: 700; font-family: var(--mono); font-size: 13px; }
.ms-label { color: var(--muted); font-size: 11px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ms-date  { font-size: 11px; font-variant-numeric: tabular-nums; }
.ms-delta { font-size: 11px; font-weight: 700; text-align: right; font-variant-numeric: tabular-nums; }
.ms-delta.ahead  { color: var(--green); }
.ms-delta.late   { color: var(--red); }
.ms-delta.same   { color: var(--muted); }
.ms-badge { font-size: 10px; padding: 1px 6px; border-radius: var(--r-m); text-align: center; white-space: nowrap; }
.ms-has-shift { background: var(--warn-bg); color: var(--warn); }
.ms-confirmed { background: var(--ok-bg); color: var(--ok); }
.ms-row-anchor { background: var(--info-bg) !important; border-left: 3px solid var(--blue); }
.ms-row-anchor .ms-code { color: var(--blue); }
.ms-row-anchor .ms-label { color: var(--blue); }
.ms-row-pre-i { opacity: .65; }
.ms-row-pre-i .ms-code { color: var(--muted); }
.ms-cycle-header { font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: .6px;
                   color: var(--muted); padding: 8px 8px 3px; border-top: 1px solid var(--border);
                   margin-top: 2px; display: flex; align-items: center; gap: 6px; }
.ms-cycle-header:first-child { border-top: none; margin-top: 0; }
.ms-cycle-header .cyc-name { background: var(--border); color: var(--text); border-radius: var(--r-s);
                              padding: 1px 6px; font-size: 10px; font-weight: 700; }
.ms-view-btn { font-size: 11px; padding: 3px 10px; border-radius: var(--r-m); border: 1px solid var(--border);
               cursor: pointer; background: var(--bg); color: var(--muted); white-space: nowrap; }
.ms-view-btn.on { background: var(--accent); color: var(--surface); border-color: var(--accent); }

/* Cycle overview */
.cycle-grid { display: flex; flex-direction: column; gap: 0; }
.cycle-header { display: grid; grid-template-columns: 72px 1fr 70px 55px 90px 60px 80px 50px;
                gap: 6px; padding: 5px 8px; font-size: 10px; font-weight: 600; color: var(--muted);
                text-transform: uppercase; letter-spacing: .4px; border-bottom: 1px solid var(--border); }
.cycle-row    { display: grid; grid-template-columns: 72px 1fr 70px 55px 90px 60px 80px 50px;
                gap: 6px; align-items: center; font-size: 13px; padding: 7px 8px; border-radius: var(--r-s); }
.cycle-row:nth-child(odd) { background: var(--bg); }
.cycle-name  { font-weight: 700; font-size: 11px; font-family: var(--mono); color: var(--blue); }
.cycle-label { color: var(--muted); font-size: 11px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.cycle-bound { font-family: var(--mono); font-size: 11px; color: var(--muted); }
.cycle-days  { font-variant-numeric: tabular-nums; font-size: 13px; }
.cycle-delta { font-weight: 700; font-variant-numeric: tabular-nums; font-size: 13px; }
.cycle-delta.late  { color: var(--red); }
.cycle-delta.ahead { color: var(--green); }
.cycle-delta.same  { color: var(--muted); }
.cycle-bar-wrap { position: relative; height: 8px; background: var(--neutral-bg); border-radius: var(--r-s); overflow: hidden; margin-top: 2px; }
.cycle-bar-soll { position: absolute; top: 0; left: 0; height: 8px; background: var(--info-2); border-radius: var(--r-s); }
.cycle-bar-ist  { position: absolute; top: 0; left: 0; height: 8px; background: var(--blue); border-radius: var(--r-s); opacity: .7; }

/* Wiki */
details.wiki { border: 1px solid var(--border); border-radius: var(--r-m); margin-bottom: 20px;
               background: var(--surface); box-shadow: var(--shadow); overflow: hidden; }
details.wiki summary { padding: 11px 16px; font-size: 13px; font-weight: 600; cursor: pointer;
                        list-style: none; display: flex; align-items: center; gap: 8px;
                        user-select: none; color: var(--text); }
details.wiki summary::-webkit-details-marker { display: none; }
details.wiki summary::before { content: '▶'; font-size: 10px; color: var(--muted);
                                 transition: transform .15s; display: inline-block; }
details[open].wiki summary::before { transform: rotate(90deg); }
details.wiki summary:hover { background: var(--bg); }
.wiki-body { padding: 16px 20px 20px; border-top: 1px solid var(--border);
             display: grid; grid-template-columns: 1fr 1fr; gap: 20px 32px; }
@media (max-width: 700px) { .wiki-body { grid-template-columns: 1fr; } }
.wiki-section h3 { font-size: 13px; font-weight: 700; text-transform: uppercase;
                   letter-spacing: .5px; color: var(--muted); margin-bottom: 8px; }
.wiki-section p, .wiki-section li { font-size: 13px; line-height: 1.65; color: var(--text); }
.wiki-section ul { padding-left: 14px; }
.wiki-section li { margin-bottom: 4px; }
.wiki-tag { display: inline-block; font-size: 10px; padding: 1px 6px; border-radius: var(--r-m);
            font-weight: 600; margin-right: 3px; vertical-align: middle; }
.wt-p7  { background: var(--violet-bg); color: var(--violet); }
.wt-p1  { background: var(--info-bg); color: var(--info); }
.wt-p0  { background: var(--neutral-bg); color: var(--ink); }
.wt-red { background: var(--crit-bg); color: var(--crit); }
.wt-grn { background: var(--ok-bg); color: var(--ok); }
.wt-ylw { background: var(--warn-bg); color: var(--warn); }

/* Claims table */
.claim-filter { display: flex; gap: 8px; margin-bottom: 12px; flex-wrap: wrap; }
.claim-filter button {
  font-size: 11px; padding: 4px 10px; border-radius: var(--r-m); border: 1px solid var(--border);
  cursor: pointer; background: var(--bg); color: var(--text); transition: all .1s;
}
.claim-filter button.active { background: var(--accent); color: var(--surface); border-color: var(--accent); }

.claim-card { border: 1px solid var(--border); border-radius: var(--r-m); margin-bottom: 8px; overflow: hidden; }
.claim-card-header { padding: 8px 12px; display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
.claim-card-body   { padding: 8px 12px; border-top: 1px solid var(--border); font-size: 13px; background: var(--bg); }
.claim-card-body .row { display: flex; gap: 16px; flex-wrap: wrap; margin-bottom: 4px; }
.claim-card-body .field label { font-size: 10px; color: var(--muted); display: block; }
.claim-card-body .field span  { font-size: 13px; }
.type-badge { font-size: 10px; padding: 2px 7px; border-radius: var(--r-m); font-weight: 600; white-space: nowrap; }
.type-P7 { background: var(--violet-bg); color: var(--violet); }
.type-P1 { background: var(--info-bg); color: var(--info); }
.type-P0 { background: var(--neutral-bg); color: var(--ink); }
.cycle-badge { font-size: 10px; padding: 2px 6px; background: var(--neutral-bg); border-radius: var(--r-m); color: var(--ink); }
.ms-badge-sm { font-size: 11px; padding: 1px 6px; background: var(--warn-bg); color: var(--warn); border-radius: var(--r-s); font-weight: 600; }
.date-arrow { display: inline-flex; align-items: center; gap: 6px; }
.date-arrow .old { text-decoration: line-through; color: var(--muted); }
.date-arrow .new { font-weight: 600; }
.delay-chip { font-size: 11px; padding: 2px 7px; border-radius: var(--r-m); font-weight: 700; }
.delay-chip.pos { background: var(--crit-bg); color: var(--crit); }
.delay-chip.neg { background: var(--ok-bg); color: var(--ok); }
.delay-chip.zero{ background: var(--neutral-bg); color: var(--ink-2); }

.empty-state { text-align: center; padding: 32px; color: var(--muted); font-size: 13px; }
.loading { text-align: center; padding: 20px; color: var(--muted); font-size: 13px; }

/* MTA chart */
.mta-ms-toggles { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 14px; }
.mta-ms-toggles button {
  font-size: 11px; padding: 3px 10px; border-radius: var(--r-m); border: 2px solid transparent;
  cursor: pointer; background: var(--bg); color: var(--muted); font-family: var(--mono); font-weight: 600;
}
.mta-ms-toggles button.on { color: var(--surface); }
.mta-legend { display: flex; gap: 14px; flex-wrap: wrap; margin-top: 10px; }
.mta-legend-item { display: flex; align-items: center; gap: 5px; font-size: 11px; }
.mta-legend-line { width: 18px; height: 3px; border-radius: var(--r-s); }
.mta-hint { font-size: 11px; color: var(--muted); margin-top: 8px; line-height: 1.6; }

/* scrollbar */
::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-thumb { background: var(--ink-3); border-radius: var(--r-s); }
/* Nav */







</style>
<!--SAP_SHELL-->
</head>
<body>
<!--SAP_RAIL-->
<header class="sap-ph"><!--SAP_PH-->
<div class="ph-right">
  <button class="btn sm" onclick="reload()">↺ Neu laden</button>
<!--SAP_MARK-->
</div>
</header>
<div id="app">
  <!-- Sidebar -->
  <div id="sidebar">
    <div id="header">
      <div id="stats-bar">
        <div class="stat stat-red">  <b id="st-red">–</b>Red</div>
        <div class="stat stat-yellow"><b id="st-yellow">–</b>Yellow</div>
        <div class="stat stat-green"> <b id="st-green">–</b>Green</div>
        <div class="stat stat-blue">  <b id="st-match">–</b>Match %</div>
        <div class="stat" title="ZMMP − ZMMT am Endtermin (TEC/SA/S)"><b id="st-end">–</b>Ø Endverzug</div>
        <div class="stat" title="SAP MTU Delay CD: Summe der Claim-Tage über alle Meilensteine (kaskadiert)"><b id="st-avg">–</b>Ø MTU Delay (Σ)</div>
      </div>
    </div>
    <div id="filters">
      <input  id="f-search"  type="text"    placeholder="Suche Projekt / ESN / Typ…" oninput="loadList()">
      <select id="f-otd"     onchange="loadList()">
        <option value="">Alle OTD</option>
        <option value="Red">Red</option>
        <option value="Yellow">Yellow</option>
        <option value="Green">Green</option>
      </select>
      <select id="f-kind"    onchange="loadList()" title="Engine- oder Modul-Shop-Visit (Modul: im ESN-Feld steht eine Modul-SN wie 30X…, oder Workscope „- M -")">
        <option value="">Engines + Module</option>
        <option value="engine">nur Engines</option>
        <option value="modul">nur Modul-Visits</option>
      </select>
      <select id="f-delay"   onchange="loadList()">
        <option value="-9999">Alle Delays</option>
        <option value="0">Delay > 0</option>
        <option value="5">Delay > 5</option>
        <option value="10">Delay > 10</option>
        <option value="20">Delay > 20</option>
      </select>
      <button onclick="reload()">↺</button>
    </div>
    <div id="engine-list"><div class="loading">Lade…</div></div>
  </div>

  <!-- Detail -->
  <div id="detail">
    <div id="detail-empty">← Engine auswählen</div>
    <div id="detail-content" style="display:none"></div>
  </div>
</div>

<script>
let engines = [];
let activeProject = null;
let claimFilter = 'P7';
let msViewMode = 'absolute';  // 'absolute' | 'offset'

const MS_COLS      = ${JSON.stringify(MILESTONE_COLS)};
const MS_LABELS    = ${JSON.stringify(MILESTONE_LABELS)};
const CYCLE_DEFS_JS = [
  {name:'Cycle 0', label:'Queue-Zeit (Wartezeit bis Induction)', start:'TSP', end:'I',   queue_only:true},
  {name:'Cycle 1', label:'Demontage & Inspektion',               start:'I',   end:'#'},
  {name:'Cycle 2', label:'Material & Dispo',                     start:'#',   end:'B0'},
  {name:'Cycle 3', label:'Aufbau & Test',                        start:'B0',  end:'SA'},
];

// Map each MS code to its cycle index (0=Queue, 1=Demontage, 2=Material, 3=Aufbau, 4=Post-SA)
(function buildCycleMap() {
  const boundaries = CYCLE_DEFS_JS.map(d => d.end);
  window.CYCLE_MAP = {};
  let ci = 0;
  for (const col of MS_COLS) {
    window.CYCLE_MAP[col] = ci;
    if (ci < boundaries.length && col === boundaries[ci]) ci++;
  }
})();

// ── Stats ─────────────────────────────────────────────────────────────────
async function loadStats() {
  const s = await fetch('/api/stats').then(r => r.json());
  document.getElementById('st-red').textContent    = s.otd?.Red    ?? 0;
  document.getElementById('st-yellow').textContent = s.otd?.Yellow ?? 0;
  document.getElementById('st-green').textContent  = s.otd?.Green  ?? 0;
  document.getElementById('st-match').textContent  = s.claim_match_rate + '%';
  document.getElementById('st-avg').textContent    = s.avg_mtu_delay_cd + ' CD';
  document.getElementById('st-end').textContent    = s.avg_end_delay_cd != null ? s.avg_end_delay_cd + ' CD' : '–';
}

// ── Engine list ───────────────────────────────────────────────────────────
// Startwert des Filters = globaler Schalter; danach frei wählbar (gilt nur für die Liste)
document.addEventListener('mvmode', function (ev) {
  const k = document.getElementById('f-kind');
  const want = {aus: 'engine', ein: '', nur: 'modul'}[ev.detail.mode] || '';
  if (k && k.value !== want) { k.value = want; loadList(); }
});

async function loadList() {
  const q   = document.getElementById('f-search').value;
  const otd = document.getElementById('f-otd').value;
  const del = document.getElementById('f-delay').value;
  const kind = document.getElementById('f-kind').value;
  const params = new URLSearchParams({ q, otd, min_delay: del, kind });
  const list = document.getElementById('engine-list');
  list.innerHTML = '<div class="loading">Lade…</div>';
  engines = await fetch('/api/engines?' + params).then(r => r.json());
  renderList();
}

function renderList() {
  const list = document.getElementById('engine-list');
  if (!engines.length) { list.innerHTML = '<div class="empty-state">Keine Ergebnisse</div>'; return; }
  list.innerHTML = engines.map(e => {
    const del = e.mtu_delay_cd;
    const cls = del > 0 ? 'pos' : del < 0 ? 'neg' : 'zero';
    const matchHtml = `<span class="match-badge ${e.claim_match ? 'match-ok' : 'match-fail'}">${e.claim_match ? '✓ Match' : '⚠ Diff'}</span>`;
    return `<div class="engine-row${e.project===activeProject?' active':''}" onclick="selectEngine('${e.project}')">
      <div class="otd-dot otd-${e.otd}"></div>
      <div class="engine-meta">
        <div class="title">${e.project} &nbsp;<span style="font-weight:400;font-size:11px">${e.esn}</span>${e.sub_count ? ` <span class="sub-badge" title="Nebenauftr\u00e4ge derselben Einlieferung, hier mitgez\u00e4hlt als ein Shop Visit">+${e.sub_count}</span>` : ''}${e.visit_kind === 'modul' ? ` <span class="mod-badge" title="Modul-Shop-Visit \u2014 nur ein Modul, keine ganze Engine (${e.visit_source})">Modul${e.unit_code ? ' ' + e.unit_code : ''}</span>` : ''}</div>
        <div class="sub">${e.engine_type} · ${e.customer} · ${e.workscope}</div>
        <div class="sub">${e.tat_start||'–'} → ${e.tec||'–'} · ${e.cpm||''}</div>
      </div>
      <div class="engine-delays">
        <div class="delay-val ${cls}">${del > 0 ? '+' : ''}${del.toFixed(1)}</div>
        <div class="sub">MTU CD</div>
        ${matchHtml}
      </div>
    </div>`;
  }).join('');
}

// ── Engine detail ─────────────────────────────────────────────────────────
async function selectEngine(project) {
  activeProject = project;
  renderList();
  document.getElementById('detail-empty').style.display = 'none';
  const dc = document.getElementById('detail-content');
  dc.style.display = 'block';
  dc.innerHTML = '<div class="loading">Lade Details…</div>';
  const e = await fetch('/api/engine/' + encodeURIComponent(project)).then(r => r.json());
  if (e.error) {
    dc.innerHTML = '<div class="empty-state">Projekt <b>' + project + '</b> steht nicht im SAP-Dataexport '
      + '(ZLSGS) — keine Meilensteine, Claims oder Delays vorhanden.'
      + (HI_INC ? '<br>Incident ' + HI_INC + ' kommt aus dem MTU Hub.' : '') + '</div>';
    return;
  }
  renderDetail(e);
}

function fmt(d) { return d || '–'; }
function fmtDel(v) {
  if (v === 0) return `<span class="delay-chip zero">±0</span>`;
  return `<span class="delay-chip ${v>0?'pos':'neg'}">${v>0?'+':''}${v.toFixed(2)} CD</span>`;
}

// Nebenauftraege: eigene SAP-Projektnummern auf derselben Einlieferung
// (gleiche ESN, gleicher TAT Start). Sie zaehlen nicht als eigener Shop Visit
// und ihre Delays werden nicht zum Hauptauftrag addiert \u2014 sie stecken
// bereits in dessen TAT. Hier nur zur Nachvollziehbarkeit.
function renderSubProjects(e) {
  const subs = e.sub_projects || [];
  if (!subs.length) return '';
  const rows = subs.map(s => `<tr>
      <td>${s.project}</td><td>${s.workscope || '\u2013'}</td>
      <td style="text-align:right">${(s.mtu_delay_cd ?? 0).toFixed(1)} CD</td>
      <td style="text-align:right">${s.total_claims ?? 0}</td>
    </tr>`).join('');
  return `<div class="sub-projects">
    <div class="sp-head">${subs.length} Nebenauftrag${subs.length > 1 ? '\u00e4ge' : ''} auf derselben Einlieferung</div>
    <table class="sp-table">
      <thead><tr><th>Projekt</th><th>Workscope</th><th style="text-align:right">MTU Delay</th><th style="text-align:right">Claims</th></tr></thead>
      <tbody>${rows}</tbody>
    </table>
    <div class="sp-note">Nicht als eigener Shop Visit gez\u00e4hlt; Delay nicht zum Hauptauftrag addiert.</div>
  </div>`;
}

function renderDetail(e) {
  const dc = document.getElementById('detail-content');

  // — Info section
  const infoHtml = `
  <div class="info-grid">
    <div class="info-item"><label>Projekt</label><span>${e.project}</span></div>
    <div class="info-item"><label>${e.visit_kind === 'modul' ? 'Modul-Seriennummer' : 'Engine Serial'}</label><span>${e.esn}</span></div>
    <div class="info-item"><label>Einlieferung</label><span>${e.visit_kind === 'modul'
      ? 'Modul-Shop-Visit' + (e.unit_code ? ' \u00b7 ' + e.unit_code + ' (CK' + e.unit_code.slice(0, 2) + ')' : '') + ' <span style="color:var(--muted);font-size:11px">\u2014 ' + e.visit_source + '</span>'
      : 'Engine'}</span></div>
    <div class="info-item"><label>Engine Type</label><span>${e.engine_type}</span></div>
    <div class="info-item"><label>Kunde</label><span>${e.customer}</span></div>
    <div class="info-item"><label>Workscope</label><span>${e.workscope}</span></div>
    <div class="info-item"><label>Status</label><span>${e.status}</span></div>
    <div class="info-item"><label>OTD</label><span style="color:${e.otd==='Green'?'var(--ok)':e.otd==='Yellow'?'var(--warn)':'var(--crit)'};font-weight:700">${e.otd}</span></div>
    <div class="info-item"><label>TAT Start</label><span>${fmt(e.tat_start)}</span></div>
    <div class="info-item"><label>CPM</label><span>${fmt(e.cpm)}</span></div>
    <div class="info-item"><label>PPE</label><span>${fmt(e.resp_ppe)}</span></div>
    <div class="info-item"><label>Budget TAT</label><span>${e.budget_tat} CD</span></div>
    <div class="info-item"><label>Total TAT CD</label><span>${e.total_tat_cd} CD</span></div>
  </div>` + renderSubProjects(e);

  // — Delay comparison
  const diff = e.claim_delay_cd - e.mtu_delay_cd;
  const matchClass = e.claim_match ? 'ok' : 'fail';
  const matchText  = e.claim_match
    ? '✓ Claims und Dataexport stimmen überein'
    : `⚠ Differenz: ${diff > 0 ? '+' : ''}${diff.toFixed(2)} CD`;
  // Delay decomposition: Queue vs. Process
  const queueCd   = e.queue_delay_cd   ?? 0;
  const procCd    = e.process_delay_cd ?? 0;
  const totalCd   = e.claim_delay_cd   ?? 0;
  const queuePct  = totalCd !== 0 ? Math.abs(queueCd  / totalCd * 100) : 0;
  const procPct   = totalCd !== 0 ? Math.abs(procCd   / totalCd * 100) : 0;

  function decompColor(v) { return v > 0 ? '#c0392b' : v < 0 ? '#2d7a2d' : '#888'; }
  function decompSign(v)  { return v > 0 ? '+' : ''; }

  const decompHtml = `
  <div style="margin-top:16px;padding:14px 16px;background:var(--bg);border-radius:var(--r-m);border:1px solid var(--border)">
    <div style="font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.5px;color:var(--muted);margin-bottom:10px">
      Delay-Zerlegung (formale P7-Claims nach Cycle)
    </div>
    <div style="display:grid;grid-template-columns:1fr 1fr;gap:12px">
      <div>
        <div style="display:flex;justify-content:space-between;align-items:baseline;margin-bottom:4px">
          <span style="font-size:13px;color:var(--yellow);font-weight:600">⏳ Formal geclaimte Queue (Cycle 0)</span>
          <span style="font-size:15px;font-weight:700;color:${decompColor(queueCd)}">${decompSign(queueCd)}${queueCd.toFixed(2)} CD</span>
        </div>
        <div style="height:8px;background:var(--neutral-bg);border-radius:var(--r-s);overflow:hidden">
          <div style="height:8px;width:${Math.min(queuePct,100).toFixed(1)}%;background:var(--yellow);border-radius:var(--r-s)"></div>
        </div>
        <div style="font-size:10px;color:var(--muted);margin-top:3px">
          ${e.queue_delay_count} Claims
          ${queueCd === 0 ? '· Typisch 0 — Queue-Zeit wird selten formal geclaimed (→ Cycle-Übersicht)' : '· Formal dokumentierte Wartezeit'}
        </div>
      </div>
      <div>
        <div style="display:flex;justify-content:space-between;align-items:baseline;margin-bottom:4px">
          <span style="font-size:13px;color:var(--blue);font-weight:600">⚙ Prozess-Delay (Cycle 1–4)</span>
          <span style="font-size:15px;font-weight:700;color:${decompColor(procCd)}">${decompSign(procCd)}${procCd.toFixed(2)} CD</span>
        </div>
        <div style="height:8px;background:var(--neutral-bg);border-radius:var(--r-s);overflow:hidden">
          <div style="height:8px;width:${Math.min(procPct,100).toFixed(1)}%;background:var(--blue);border-radius:var(--r-s)"></div>
        </div>
        <div style="font-size:10px;color:var(--muted);margin-top:3px">${e.process_delay_count} Claims · Fischgräten-Abweichung ab Induction</div>
      </div>
    </div>
    <div style="margin-top:10px;font-size:11px;color:var(--muted);border-top:1px solid var(--border);padding-top:8px">
      ⓘ Queue-Zeit (TSP→I) erscheint im <b>ZMMP als Cycle-0-Dauer</b>, wird aber in ~98% der Fälle <b>nicht formal geclaimed</b>
      — sichtbar in der Cycle-Übersicht unten, nicht im MTU Delay CD. Die Zerlegung summiert wie MTU Delay über alle Meilensteine (kaskadiert).
    </div>
  </div>`;

  // Endverzug = ZMMP − ZMMT am Endtermin; der tatsächliche Terminverzug
  const endCd  = e.end_delay_cd;
  const endAct = e.end_delay_actual_cd;
  const endHtml = endCd == null
    ? `<div class="delay-block" title="Kein auswertbarer Endtermin: keine Kundenzusage (Budget TAT 0 → Platzhalter in ZMMT), Endtermin fehlt oder Plantermin unplausibel">
         <div class="label">Endverzug</div>
         <div class="value" style="color:var(--ink-3)">–</div>
         <div class="unit">${parseFloat(e.budget_tat) > 0 ? 'kein plausibler Endtermin' : 'keine Kundenzusage'}</div>
       </div>`
    : `<div class="delay-block" style="border:2px solid var(--border)"
            title="ZMMP(${e.end_delay_ms}) − ZMMT(${e.end_delay_ms}): um so viele Kalendertage liegt der Endtermin hinter der Kundenzusage. Jeder Verzugstag zählt genau einmal.">
         <div class="label">Endverzug (${e.end_delay_ms})</div>
         <div class="value" style="color:${decompColor(endCd)}">${decompSign(endCd)}${endCd}</div>
         <div class="unit">Kalender-Tage · ZMMP − ZMMT${endAct != null
           ? ` · Ist: <b style="color:${decompColor(endAct)}">${decompSign(endAct)}${endAct}</b>` : ''}</div>
       </div>`;

  const delayHtml = `
  <div class="delay-compare">
    ${endHtml}
    <div class="arrow" style="font-size:15px">vs.</div>
    <div class="delay-block" title="Summe der Verschiebungen über alle Meilensteine. Verschiebt ein Verzug mehrere aufeinanderfolgende Meilensteine, wird er mehrfach gezählt — deshalb meist deutlich größer als der Endverzug.">
      <div class="label">MTU Delay (Dataexport)</div>
      <div class="value" style="color:${e.mtu_delay_cd>0?'var(--crit)':e.mtu_delay_cd<0?'var(--ok)':'var(--ink-3)'}">${e.mtu_delay_cd>0?'+':''}${e.mtu_delay_cd.toFixed(2)}</div>
      <div class="unit">Σ Verschiebungen, kaskadiert</div>
    </div>
    <div class="arrow">=</div>
    <div class="delay-block highlight">
      <div class="label">Summe P7-Claims</div>
      <div class="value" style="color:${e.claim_delay_cd>0?'var(--crit)':e.claim_delay_cd<0?'var(--ok)':'var(--ink-3)'}">${e.claim_delay_cd>0?'+':''}${e.claim_delay_cd.toFixed(2)}</div>
      <div class="unit">aus ${e.claims_p7.length} P7-Claims</div>
    </div>
    <div class="match-banner ${matchClass}">${matchText}</div>
  </div>
  <div style="margin-top:10px;font-size:11px;color:var(--muted)">
    ⓘ <b>Endverzug</b> = wie viel später der Endtermin gegenüber der Kundenzusage liegt.
    <b>MTU Delay</b> summiert die Claim-Tage aller Meilensteine; ein Verzug, der D2 → # → R → SA durchschiebt, zählt dort mehrfach.
    Für „wie viele Tage zu spät" den Endverzug lesen, MTU Delay nur für Ursachen-Zuordnung und Vergleiche.
  </div>
  ${decompHtml}
  <div style="margin-top:12px;font-size:13px;color:var(--muted)">
    OEM Delay: ${e.oem_delay_cd.toFixed(2)} CD &nbsp;|&nbsp;
    Customer/Excusable: ${e.cust_delay_cd.toFixed(2)} CD &nbsp;|&nbsp;
    MTU Delay WD: ${e.mtu_delay_wd.toFixed(2)} WD
  </div>`;

  // Milestone table is rendered separately by renderMsTable() to support view toggle

  // — Cycle overview
  const maxIst = Math.max(...(e.cycles_summary||[]).map(c => c.ist_days||0), 1);
  const cycleRowsHtml = (e.cycles_summary||[]).map(cyc => {
    const isQueue = cyc.queue_only;
    const isTotal = cyc.name === 'Gesamt';
    const ist   = cyc.ist_days  != null ? cyc.ist_days  + 'd' : '–';
    const soll  = cyc.soll_days != null ? cyc.soll_days + 'd' : (isQueue ? '<span style="color:var(--muted);font-style:italic">kein Template</span>' : '–');
    const delta = cyc.delta_days;
    const deltaCls  = delta == null ? 'same' : delta > 0 ? 'late' : delta < 0 ? 'ahead' : 'same';
    const deltaText = isQueue ? '<span style="color:var(--muted);font-style:italic">Queue</span>'
                    : delta == null ? '–'
                    : delta === 0   ? '±0d'
                    : (delta > 0 ? '+' : '') + delta + 'd';
    const p7 = e.cycles_p7?.[cyc.name] || {};
    const cd = p7.delay_cd || 0;
    const cdCls  = cd > 0 ? 'color:var(--red)' : cd < 0 ? 'color:var(--green)' : 'color:var(--muted)';
    const cdText = cd === 0 ? '–' : (cd > 0 ? '+' : '') + cd.toFixed(1) + ' CD';
    const shifts = p7.count || 0;
    const pctSoll = (!isQueue && cyc.soll_days > 0) ? Math.min(100, (cyc.soll_days / maxIst) * 100) : 0;
    const pctIst  = (cyc.ist_days  > 0) ? Math.min(100, (cyc.ist_days  / maxIst) * 100) : 0;
    const rowStyle = isTotal ? 'border-top:2px solid var(--border);font-weight:600'
                   : isQueue ? 'opacity:.75;border-left:3px solid var(--yellow)'
                   : '';
    return `<div class="cycle-row" style="${rowStyle}">
      <span class="cycle-name" style="${isQueue?'color:var(--yellow)':''}">${cyc.name}</span>
      <span class="cycle-label" title="${cyc.label}">${cyc.label}</span>
      <span class="cycle-bound">${cyc.start} → ${cyc.end}</span>
      <span class="cycle-days">${soll}</span>
      <span class="cycle-days">
        <div>${ist}</div>
        <div class="cycle-bar-wrap">
          <div class="cycle-bar-soll" style="width:${pctSoll.toFixed(1)}%"></div>
          <div class="cycle-bar-ist"  style="width:${pctIst.toFixed(1)}%;background:${isQueue?'var(--yellow)':'var(--blue)'}"></div>
        </div>
      </span>
      <span class="cycle-delta ${deltaCls}">${deltaText}</span>
      <span style="font-size:13px;font-weight:700;${cdCls}">${cdText}</span>
      <span style="font-size:11px;color:var(--muted)">${shifts > 0 ? shifts + ' ⟳' : '–'}</span>
    </div>`;
  }).join('');

  // — Claims section
  const allTypes = ['P7','P1','P0'];
  const claimCounts = { P7: e.claims_p7.length, P1: e.claims_p1.length, P0: e.claims_p0.length };
  const claimFilterHtml = allTypes.map(t =>
    `<button class="${claimFilter===t?'active':''}" onclick="setClaimFilter('${t}')">${t} — ${${JSON.stringify(NOTIF_TYPE_LABELS)}[t]||t} (${claimCounts[t]})</button>`
  ).join('');

  dc.innerHTML = `
  <details class="wiki">
    <summary>Interpretationshilfe — wie lese ich diese Daten?</summary>
    <div class="wiki-body">

      <div class="wiki-section">
        <h3>ZMMT · ZMMP · Actual ZMMP</h3>
        <ul>
          <li><b>ZMMT (Fischgräte)</b> = Standardprozess-Template, das bei Auftragseingang automatisch generiert wird. Basiert auf historischen Durchlaufzeiten für diesen Motortyp und Workscope. Unveränderlich — kein Kundenversprechen, sondern ein Prozessstandard.</li>
          <li><b>ZMMP (Plan)</b> = Aktueller operativer Planstand, motorspezifisch. Wird mit jedem P7-Claim aktualisiert und spiegelt Befunde, Materialverfügbarkeit und Kapazität wider.</li>
          <li><b>Actual ZMMP</b> = Tatsächlich erreichtes Datum (nur für abgeschlossene Meilensteine). Farbe und Pfeil zeigen die Abweichung zur <b>Fischgräte (ZMMT)</b>: Grün ▼ = früher als Standard, Rot ▲ = später als Standard. Leer = Meilenstein noch ausstehend.</li>
          <li><b>Δ Plan</b> = Reiner Prozess-Delay, <b>I-normiert</b>: (ZMMP-Offset von I_ZMMP) − (ZMMT-Offset von I_ZMMT). Anker ist Induction (I), nicht TSP — weil TSP und TSC in ZMMT durch echte Ankunftsdaten ersetzt werden und die Queue-Zeit (TSP→I) kein Fischgräten-Konzept ist. Nur für Post-Induction-Meilensteine berechnet. Vorgelagerte Milesteine (⬆) zeigen die Queue-Phase.</li>
          <li><b>Actual ZMMP</b> = Tatsächlich eingetretenes Datum. ▲ = später als ZMMP-Plan, ▼ = früher, ✓ = plangemäß. Leer = noch nicht erreicht.</li>
          <li><b>⏱ Prozess-Tage-Toggle</b>: Wechselt die ZMMT/ZMMP-Spalten von Kalenderdaten auf TSP+N-Offsets (z.B. "TSP+13d"). Zeigt den reinen Prozessplan unabhängig vom Ankunftsdatum — vergleichbar zwischen verschiedenen Motoren und Einschleusdaten.</li>
          <li>Hinweis: Actual ≈ ZMMP ist der Normalfall — Planer halten ZMMP via P7-Claims aktuell bis zur Fertigstellung. Starke Abweichung (Actual ≫ ZMMP) deutet auf "Shadow Delay" hin: Motor war faktisch in Verzug ohne formale Claims (z.B. Park-/Hold-Status).</li>
        </ul>
      </div>

      <div class="wiki-section">
        <h3>Claim-Typen (Notifications)</h3>
        <ul>
          <li><span class="wiki-tag wt-p7">P7 MRO</span> Meilensteinverschiebung — der eigentliche "Claim". Enthält Former Date → New Date und Delay-Tage. Nur P7-Claims erzeugen den MTU Delay CD — als Summe über alle Meilensteine, nicht als Endverzug.</li>
          <li><span class="wiki-tag wt-p1">P1 Confirmation</span> Bestätigung, dass ein Meilenstein <b>erreicht</b> wurde. Das tatsächliche Fertigstellungsdatum steht im Feld "Required Start Date".</li>
          <li><span class="wiki-tag wt-p0">P0 Customer Service</span> Allgemeine Benachrichtigungen (Ankunft, Datumsänderungen). Enthalten keine Delay-Berechnung.</li>
        </ul>
      </div>

      <div class="wiki-section">
        <h3>OTD-Ampel</h3>
        <ul>
          <li><span class="wiki-tag wt-grn">Green</span> Engine wird voraussichtlich innerhalb des vertraglich vereinbarten TAT (Budget TAT) geliefert.</li>
          <li><span class="wiki-tag wt-ylw">Yellow</span> Geringer Verzug oder erhöhtes Risiko erkannt. Aufmerksamkeit erforderlich.</li>
          <li><span class="wiki-tag wt-red">Red</span> Engine überschreitet den vereinbarten TAT. Aktives Eskalationsmanagement nötig.</li>
          <li>Hinweis: OTD vergleicht mit dem vertraglichen TAT — <b>nicht</b> mit der Fischgräte (ZMMT). ZMMT und OTD sind unabhängige Konzepte.</li>
        </ul>
      </div>

      <div class="wiki-section">
        <h3>Delay-Abgleich</h3>
        <ul>
          <li><b>Endverzug</b> = ZMMP − ZMMT am Endtermin (TEC, sonst SA, sonst S): um so viele Kalendertage liegt die Fertigstellung hinter der Kundenzusage. Jeder Verzugstag zählt genau einmal. „Ist“ = Actual − ZMMT, sobald der Endtermin erreicht ist. Nicht berechnet ohne Kundenzusage (Budget TAT 0 — dann steht in ZMMT ein Platzhalter-Endtermin) und bei unplausiblen Planterminen (|Verzug| &gt; 730 Tage oder Plan-Ende vor TAT Start).</li>
          <li><b>MTU Delay CD</b> = offizielle Zahl aus dem SAP-Dataexport, aber <b>kein Endverzug</b>: SAP summiert die Claim-Tage über <b>alle</b> Meilensteine. Schiebt ein Verzug mehrere aufeinanderfolgende Meilensteine (z.B. D2 → # → R → SA) und wird jeder davon geclaimt, zählt derselbe Tag mehrfach. Im Export ist MTU Delay im Median ~3× so groß wie der Endverzug (69,7 CD vs. 24 Tage). Geeignet für Ursachen-Zuordnung und relative Vergleiche, nicht für „wie viele Tage zu spät“.</li>
          <li><b>Summe P7-Claims</b> = Summe aller P7-Verschiebungen (inkl. negativer Korrekturen) über alle Meilensteine — gleiche Logik wie MTU Delay CD, daher Konsistenzprüfung zur SAP-Zahl.</li>
          <li><span class="wiki-tag wt-grn">✓ Match</span> Beide Werte stimmen überein — Datenlage konsistent.</li>
          <li><span class="wiki-tag wt-ylw">⚠ Differenz</span> Abweichung &gt; 0,1 CD. Kommt bei rund 3 von 4 Engines vor; die genaue SAP-Filterlogik (vermutlich Ursachen-Zuordnung je Claim) ist nicht bekannt. Löschkennzeichen scheiden als Erklärung aus — im Export trägt kein P7-Claim eines.</li>
          <li><b>⏳ Queue-Delay</b> = P7-Claims auf Cycle-0-Milesteine (TSP, I). Ursache: Kapazitätsengpässe, Einschleusteuerung, Priorisierung. Kein Fischgräten-Problem — Steuerungsebene Scheduling/Operations.</li>
          <li><b>⚙ Prozess-Delay</b> = P7-Claims auf Cycle 1–4. Ursache: Befunde, Materialverfügbarkeit, Aufbauqualität. Fischgräten-relevant — Steuerungsebene Prozess/Technik.</li>
          <li><b>Shadow Queue Delay:</b> Queue-Zeit (TSP→I) erscheint in der Cycle-0-Dauer des ZMMP, wird aber in ~98% der Motoren <b>nicht formal via P7-Claim dokumentiert</b>. Queue-Delay CD ist daher typischerweise 0 — die tatsächliche Wartezeit ist nur über die Cycle-Übersicht (ZMMP Ist-Dauer Cycle 0) erkennbar.</li>
          <li>Die Zerlegung ermöglicht zielgerichtete Maßnahmen: hoher Queue-Delay (Cycle-0-Dauer) → Kapazitätsplanung, Einschleusteuerung. Hoher Prozess-Delay CD → Prozessverbesserung, Materialsteuerung.</li>
        </ul>
      </div>

      <div class="wiki-section">
        <h3>MTA-Chart (Milestone Trend Analysis)</h3>
        <ul>
          <li><b>Durchgezogene Linie</b> = ZMMP-Verlauf (interner Plan) — jeder Punkt ist ein P7-Claim.</li>
          <li><b>Gestrichelte Linie</b> (gleiche Farbe, horizontal) = ZMMT-Datum (Fischgräte). Zeigt wo der Standardprozess diesen Meilenstein erwartet. Liegt ZMMP dauerhaft über dieser Linie, weicht der Motor strukturell vom Standard ab.</li>
          <li><b>Graue Diagonale</b> (y = x): Punkte auf dieser Linie bedeuten &ldquo;Meilenstein liegt heute&rdquo;. Darüber = Puffer vorhanden. Darunter = Meilenstein liegt in der Vergangenheit.</li>
          <li><b>Vertikale gestrichelte Linie</b> = Heute.</li>
          <li><b>▲ Dreieck</b> = Ursprungsplan (former_date des ersten Claims, als Näherungswert). <b>◆ Raute</b> = aktueller Stand (ZMMP).</li>
          <li>Trend parallel zur Diagonalen = Delay wächst in Echtzeit (kritisch). Horizontal = stabil. Fallend = Verbesserung.</li>
        </ul>
      </div>

      <div class="wiki-section">
        <h3>Milestone-Status-Badges</h3>
        <ul>
          <li><span class="wiki-tag wt-ylw">⟳ N Shifts</span> Dieser Meilenstein wurde N-mal verschoben. Der Δ-Wert zeigt den Netto-Effekt.</li>
          <li><span class="wiki-tag wt-grn">✓ Confirmed</span> Meilenstein wurde per P1-Claim bestätigt — also tatsächlich erreicht.</li>
          <li><b>Breakdown</b>: Ein P7-Claim mit gesetztem Breakdown-Flag bedeutet einen <b>ungeplanten Stopp</b> (nicht nur Umplanung).</li>
        </ul>
      </div>

    </div>
  </details>

  <section class="section">
    <div class="section-header"><h2>Engine-Info</h2></div>
    <div class="section-body">${infoHtml}</div>
  </section>

  <section class="section">
    <div class="section-header"><h2>Delay-Abgleich</h2></div>
    <div class="section-body">${delayHtml}</div>
  </section>

  <section class="section">
    <div class="section-header">
      <h2>Cycle-Übersicht — Soll / Ist Durchlaufzeiten</h2>
      <span style="font-size:11px;color:var(--muted)">Soll = ZMMT-Dauer (Fischgräte) &nbsp;·&nbsp; Ist = ZMMP-Dauer (aktueller Plan) &nbsp;·&nbsp; Δ = Prozess-Delay je Phase &nbsp;·&nbsp; P7 = dokumentierter Claim-Delay</span>
    </div>
    <div class="section-body">
      <div class="cycle-grid">
        <div class="cycle-header">
          <span>Cycle</span><span>Phase</span><span>Grenzen</span>
          <span>Soll (d)</span><span>Ist (d)</span><span>Δ Dauer</span>
          <span>P7-Delay</span><span>Shifts</span>
        </div>
        ${cycleRowsHtml}
      </div>
    </div>
  </section>

  <section class="section">
    <div class="section-header">
      <h2>Milestone-Übersicht</h2>
      <div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">
        <span id="ms-subtitle" style="font-size:11px;color:var(--muted)">ZMMT = Fischgräte · ZMMP = Plan · Δ Plan = I-normierter Prozess-Delay (ab Induction) · ⬆ = Queue-Phase</span>
        <button class="ms-view-btn" id="ms-view-toggle" onclick="toggleMsView(this)">⏱ Prozess-Tage</button>
      </div>
    </div>
    <div class="section-body">
      <div id="ms-timeline-inner"></div>
    </div>
  </section>

  <section class="section" id="mta-section">
    <div class="section-header">
      <h2>Milestone Trend Analysis (MTA)</h2>
      <span style="font-size:11px;color:var(--muted)">Lent 2013 · Punkte über der Diagonale = Puffer vorhanden</span>
    </div>
    <div class="section-body">
      <div class="mta-ms-toggles" id="mta-toggles"><span style="font-size:13px;color:var(--muted)">Lade…</span></div>
      <div style="position:relative;height:360px"><canvas id="mta-canvas"></canvas></div>
      <div class="mta-legend" id="mta-legend"></div>
      <div class="mta-hint">
        X-Achse: Datum der Claim-Erfassung (Berichtszeitpunkt) · Y-Achse: Geplantes Meilenstein-Datum zu diesem Zeitpunkt<br>
        Diagonale (grau): Y = X — Punkte auf der Linie bedeuten &ldquo;Meilenstein liegt genau heute&rdquo;.
        Drift nach rechts-unten = Verzögerung wächst. Drift nach links-oben = Verbesserung.
      </div>
    </div>
  </section>

  <section class="section" id="incidents-section">
    <div class="section-header">
      <h2>Cycle-3 Incidents (ServiceNow)</h2>
      <span style="font-size:11px;color:var(--muted)">${e.incident_count} Incidents
        · <span style="color:var(--crit);font-weight:700">${e.baustopp_count} Baustopp</span>
        · <span style="color:var(--warn);font-weight:700">${e.kritisch_count} Kritisch</span>
        ${e.incident_total_res_h ? '· Ø ' + (e.incident_avg_res_h||0) + 'h Lösungszeit' : ''}
      </span>
    </div>
    <div class="section-body">
      <div id="incident-list"></div>
    </div>
  </section>

  <section class="section" id="claims-section">
    <div class="section-header">
      <h2>Claims / Notifications</h2>
      <span style="font-size:11px;color:var(--muted)">${e.total_claims} gesamt</span>
    </div>
    <div class="section-body">
      <div class="claim-filter">${claimFilterHtml}</div>
      <div id="claim-list"></div>
    </div>
  </section>
  `;

  // Store for filter switching
  window._currentEngine = e;
  renderMsTable(e);
  renderIncidents(e);
  renderClaims();
  loadMTA(e.project);
}

function setClaimFilter(type) {
  claimFilter = type;
  // Update buttons
  document.querySelectorAll('.claim-filter button').forEach(b => {
    b.classList.toggle('active', b.textContent.startsWith(type));
  });
  renderClaims();
}

function renderClaims() {
  const e = window._currentEngine;
  if (!e) return;
  const claims = claimFilter === 'P7' ? e.claims_p7
               : claimFilter === 'P1' ? e.claims_p1
               : e.claims_p0;
  const container = document.getElementById('claim-list');
  if (!container) return;
  if (!claims || !claims.length) {
    container.innerHTML = '<div class="empty-state">Keine Claims dieses Typs</div>';
    return;
  }
  container.innerHTML = claims.map(c => {
    const hasShift = c.former_date && c.new_date;
    const dateHtml = hasShift
      ? `<span class="date-arrow"><span class="old">${c.former_date}</span> → <span class="new">${c.new_date}</span></span>`
      : '';
    const delayHtml = (c.delay_cd !== 0 || c.delay_wd !== 0)
      ? fmtDel(c.delay_cd) + (c.delay_wd ? ` / ${fmtDel(c.delay_wd)} WD` : '')
      : '';
    const msHtml = c.milestone ? `<span class="ms-badge-sm">${c.milestone}</span>` : '';
    const descText = [c.description, c.subject, c.cause_text].filter(Boolean).join(' · ');
    return `<div class="claim-card">
      <div class="claim-card-header">
        <span class="type-badge type-${c.notif_type}">${c.notif_type}</span>
        <span class="cycle-badge">${c.cycle}</span>
        ${msHtml}
        <span style="font-size:13px;font-weight:600">${c.notification}</span>
        <span style="font-size:11px;color:var(--muted);margin-left:auto">${c.created_on} · ${c.created_by}</span>
      </div>
      ${(dateHtml || delayHtml || descText) ? `<div class="claim-card-body">
        <div class="row">
          ${dateHtml ? `<div class="field"><label>Datumsverschiebung</label><span>${dateHtml}</span></div>` : ''}
          ${delayHtml ? `<div class="field"><label>Delay</label><span>${delayHtml}</span></div>` : ''}
          ${c.breakdown==='X' ? `<div class="field"><label>Breakdown</label><span style="color:var(--crit);font-weight:700">JA</span></div>` : ''}
        </div>
        ${descText ? `<div><span style="color:var(--muted);font-size:11px">Beschreibung: </span>${descText}</div>` : ''}
        ${c.cause_code ? `<div><span style="color:var(--muted);font-size:11px">Cause Code: </span>${c.cause_code}</div>` : ''}
      </div>` : ''}
    </div>`;
  }).join('');
}

// ── Milestone table ───────────────────────────────────────────────────────

function dateDiffDays(isoA, isoB) {
  if (!isoA || !isoB || isoA === '–' || isoB === '–') return null;
  const a = Date.parse(isoA), b = Date.parse(isoB);
  if (isNaN(a) || isNaN(b)) return null;
  return Math.round((b - a) / 86400000);
}

function fmtDeltaDays(days) {
  if (days == null) return '<span class="ms-delta same">–</span>';
  if (days === 0)   return '<span class="ms-delta same">±0</span>';
  const cls  = days > 0 ? 'late' : 'ahead';
  return `<span class="ms-delta ${cls}">${days > 0 ? '+' : ''}${days} d</span>`;
}

function fmtOffset(n) {
  if (n == null) return '–';
  if (n === 0)   return 'I';
  return `I${n > 0 ? '+' : ''}${n}d`;
}

function renderMsTable(e) {
  const container = document.getElementById('ms-timeline-inner');
  if (!container || !e) return;

  const isOffset = (msViewMode === 'offset');

  // Sync toggle button (recreated on each engine select)
  const toggleBtn = document.getElementById('ms-view-toggle');
  if (toggleBtn) {
    toggleBtn.classList.toggle('on', isOffset);
    toggleBtn.textContent = isOffset ? '📅 Absolut' : '⏱ Prozess-Tage';
  }
  const sub = document.getElementById('ms-subtitle');
  if (sub) {
    sub.textContent = isOffset
      ? 'Prozess-Tage-Ansicht: I+Nd Offset ab Induction · Δ Plan = reiner Prozess-Delay (I-normiert, nur ab Induction)'
      : 'ZMMT = Fischgräte · ZMMP = Plan · Δ Plan = I-normierter Prozess-Delay (ab Induction) · ⬆ = Queue-Phase';
  }
  const confirmedMs = new Set((e.claims_p1 || []).map(c => c.milestone));
  const shiftedMs   = new Set((e.claims_p7 || []).map(c => c.milestone));

  const col1Hdr = isOffset ? 'ZMMT (I+Nd)'   : 'ZMMT (Fischgräte)';
  const col2Hdr = isOffset ? 'ZMMP (I+Nd)'   : 'ZMMP (Plan)';

  const header = `<div class="ms-header">
    <span>MS</span><span>Bedeutung</span>
    <span>${col1Hdr}</span><span>${col2Hdr}</span>
    <span>Actual ZMMP</span>
    <span title="Prozess-Offset-Differenz (TSP-normiert): reiner Prozess-Delay ohne Ankunftszeitpunkt-Einfluss">Δ Plan</span>
    <span>Status</span>
  </div>`;

  // I-offset from ZMMT determines if a milestone is pre- or post-induction
  const zmmtIOffsets = e.milestones_zmmt_offset || {};

  function renderMsRow(col) {
    const isAnchor = (col === 'I');
    const iOffset  = zmmtIOffsets[col];
    const isPostI  = (iOffset != null && iOffset >= 0);
    const isPreI   = (iOffset != null && iOffset < 0);

    const anchorCls = isAnchor ? ' ms-row-anchor' : '';
    const preICls   = isPreI   ? ' ms-row-pre-i'  : '';

    const zmmt   = e.milestones_zmmt?.[col]  || '–';
    const zmmp   = e.milestones_zmmp?.[col]  || '–';
    const actual = e.milestones_actual?.[col] || '';

    let zmmtDisp, zmmpDisp;
    if (isOffset) {
      zmmtDisp = fmtOffset(e.milestones_zmmt_offset?.[col]);
      zmmpDisp = fmtOffset(e.milestones_zmmp_offset?.[col]);
    } else {
      zmmtDisp = zmmt;
      zmmpDisp = zmmp;
    }

    const hasShift = shiftedMs.has(col);
    const hasCon   = confirmedMs.has(col);
    const badge = hasShift
      ? `<span class="ms-badge ms-has-shift">⟳ ${(e.ms_shifts[col]||[]).length} Shifts</span>`
      : hasCon ? `<span class="ms-badge ms-confirmed">✓ Confirmed</span>` : '';

    const deltaPlanVal = e.milestones_delta?.[col];
    const deltaPlan = (deltaPlanVal != null)
      ? fmtDeltaDays(deltaPlanVal)
      : `<span class="ms-delta same">${isPreI ? '⬆' : '–'}</span>`;

    let actualHtml = '<span style="color:var(--muted)">–</span>';
    if (actual) {
      const diff   = dateDiffDays(zmmp, actual);
      const late   = diff != null && diff > 0;
      const early  = diff != null && diff < 0;
      const color  = late ? 'var(--red)' : early ? 'var(--green)' : 'inherit';
      const marker = late ? ' ▲' : early ? ' ▼' : ' ✓';
      actualHtml  = `<span style="color:${color};font-weight:600">${actual}${marker}</span>`;
    }

    const zmmpWeight = (!isOffset && zmmp !== zmmt) ? '600' : '400';

    return `<div class="ms-row${anchorCls}${preICls}">
      <span class="ms-code">${col}${isAnchor ? ' ⚓' : ''}</span>
      <span class="ms-label" title="${MS_LABELS[col]||''}">${MS_LABELS[col] || ''}</span>
      <span class="ms-date">${zmmtDisp}</span>
      <span class="ms-date" style="font-weight:${zmmpWeight}">${zmmpDisp}</span>
      ${actualHtml}
      ${deltaPlan}
      ${badge}
    </div>`;
  }

  // Group milestones by cycle and render with dividers
  const filteredCols = MS_COLS.filter(col => e.milestones_zmmt?.[col] || e.milestones_zmmp?.[col]);
  const CYCLE_LABELS_EXTRA = ['Nach Auslieferung'];
  let body = '', lastCi = -1;
  for (const col of filteredCols) {
    const ci = window.CYCLE_MAP[col] ?? 99;
    if (ci !== lastCi) {
      const cyc = CYCLE_DEFS_JS[ci];
      const cycName  = cyc ? cyc.name  : 'Sonstige';
      const cycLabel = cyc ? cyc.label : CYCLE_LABELS_EXTRA[ci - CYCLE_DEFS_JS.length] || '';
      body += `<div class="ms-cycle-header"><span class="cyc-name">${cycName}</span>${cycLabel}</div>`;
      lastCi = ci;
    }
    body += renderMsRow(col);
  }

  container.innerHTML = `<div class="ms-timeline">${header}${body || '<div class="empty-state">Keine Meilensteine</div>'}</div>`;
}

function renderIncidents(e) {
  const container = document.getElementById('incident-list');
  if (!container) return;
  const inc = e.incidents_c3 || [];
  // aus der Hierarchie verlinkter Incident: hervorheben — oder sagen, warum er fehlt
  const hiMiss = HI_INC && !inc.some(i => i.number === HI_INC)
    ? '<div class="empty-state" style="margin-bottom:8px">Incident <b>' + HI_INC + '</b> ist nicht in dieser Liste '
      + '— er liegt außerhalb des Zyklus-Filters der Einstellungen.</div>' : '';
  if (!inc.length) {
    container.innerHTML = hiMiss + '<div class="empty-state">Keine Cycle-3 Incidents für diese Engine.</div>';
    return;
  }
  const CRIT_COLOR = { 'Baustopp': '#c0392b', 'Kritisch': '#d4a017', 'Nicht kritisch': '#2d7a2d' };
  let html = `<table style="width:100%;border-collapse:collapse;font-size:11px">
    <thead><tr style="font-size:10px;text-transform:uppercase;letter-spacing:.4px;color:var(--muted);border-bottom:2px solid var(--border)">
      <th style="padding:5px 8px;text-align:left">Nr.</th>
      <th style="padding:5px 8px;text-align:left">Kritikalität</th>
      <th style="padding:5px 8px;text-align:left">Beschreibung</th>
      <th style="padding:5px 8px;text-align:left">Gruppe</th>
      <th style="padding:5px 8px;text-align:right">Erstellt</th>
      <th style="padding:5px 8px;text-align:right">Lösungszeit</th>
    </tr></thead><tbody>`;
  inc.forEach((i, idx) => {
    const color = CRIT_COLOR[i.criticality] || 'var(--muted)';
    const bg    = idx % 2 === 0 ? 'var(--bg)' : 'transparent';
    const res   = i.resolution_h != null ? i.resolution_h + 'h' : '–';
    const hi = HI_INC && i.number === HI_INC;
    html += `<tr style="background:${hi ? 'var(--warn-bg)' : bg}${hi ? ';outline:2px solid var(--warn)' : ''}"${hi ? ' id="hi-inc"' : ''}>
      <td style="padding:5px 8px;font-family:monospace;color:${hi ? 'var(--ink)' : 'var(--muted)'};font-weight:${hi ? 700 : 400}">${i.number}</td>
      <td style="padding:5px 8px;font-weight:700;color:${color};white-space:nowrap">${i.criticality}</td>
      <td style="padding:5px 8px;max-width:340px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${i.description}">${i.description}</td>
      <td style="padding:5px 8px;color:var(--muted);white-space:nowrap">${i.group.replace('Req_','').replace('-LUD','')}</td>
      <td style="padding:5px 8px;text-align:right;color:var(--muted)">${i.created_on}</td>
      <td style="padding:5px 8px;text-align:right;font-weight:${i.resolution_h>24?'700':'400'};color:${i.resolution_h>48?'var(--red)':'inherit'}">${res}</td>
    </tr>`;
  });
  html += '</tbody></table>';
  container.innerHTML = hiMiss + html;
  const h = document.getElementById('hi-inc');
  if (h) setTimeout(() => h.scrollIntoView({block: 'center', behavior: 'smooth'}), 150);
}

function toggleMsView(btn) {
  msViewMode = (msViewMode === 'absolute') ? 'offset' : 'absolute';
  const isOffset = (msViewMode === 'offset');
  btn.classList.toggle('on', isOffset);
  btn.textContent = isOffset ? '📅 Absolut' : '⏱ Prozess-Tage';
  const sub = document.getElementById('ms-subtitle');
  if (sub) {
    sub.textContent = isOffset
      ? 'Prozess-Tage-Ansicht: I+Nd Offset ab Induction · Δ Plan = reiner Prozess-Delay (I-normiert, nur ab Induction)'
      : 'ZMMT = Fischgräte · ZMMP = Plan · Δ Plan = I-normierter Prozess-Delay (ab Induction) · ⬆ = Queue-Phase';
  }
  renderMsTable(window._currentEngine);
}

// ── MTA ───────────────────────────────────────────────────────────────────
const MTA_COLORS = [
  '#c0392b','#1a5fa8','#2d7a2d','#6d28d9','#b07800',
  '#0e7490','#be185d','#166534','#92400e','#1e3a5f',
];
// Milestones shown by default (output-relevant)
const MTA_DEFAULT_ON = new Set(['B3','TS','TE','SA','S','TEC','B1R','R']);

let _mtaChart  = null;
let _mtaData   = {};
let _mtaActive = new Set();

async function loadMTA(project) {
  const toggleBox = document.getElementById('mta-toggles');
  if (!toggleBox) return;
  toggleBox.innerHTML = '<span style="font-size:13px;color:var(--muted)">Lade MTA…</span>';
  _mtaData = await fetch('/api/engine/' + encodeURIComponent(project) + '/mta').then(r => r.json());
  const available = Object.keys(_mtaData).sort();
  if (!available.length) {
    toggleBox.innerHTML = '<span style="font-size:13px;color:var(--muted)">Keine P7-Verschiebungen vorhanden</span>';
    return;
  }
  // Prefer output milestones; fall back to first 4 available
  _mtaActive = new Set(available.filter(ms => MTA_DEFAULT_ON.has(ms)));
  if (!_mtaActive.size) available.slice(0, 4).forEach(ms => _mtaActive.add(ms));
  renderMtaToggles(available);
  renderMtaChart();
}

function renderMtaToggles(available) {
  const box = document.getElementById('mta-toggles');
  if (!box) return;
  box.innerHTML = available.map((ms, i) => {
    const color  = MTA_COLORS[i % MTA_COLORS.length];
    const on     = _mtaActive.has(ms);
    const entry  = _mtaData[ms] || {};
    const nShifts = (entry.points || []).filter(p => p.type === 'shift').length;
    return `<button class="${on?'on':''}" style="${on?'background:'+color+';border-color:'+color:''}"
      title="${nShifts} Shift(s)" onclick="toggleMtaMs('${ms}',${i},this)">${ms} <sup>${nShifts}</sup></button>`;
  }).join('');
}

function toggleMtaMs(ms, idx, btn) {
  const color = MTA_COLORS[idx % MTA_COLORS.length];
  if (_mtaActive.has(ms)) {
    _mtaActive.delete(ms);
    btn.classList.remove('on');
    btn.style.background = '';
    btn.style.borderColor = '';
  } else {
    _mtaActive.add(ms);
    btn.classList.add('on');
    btn.style.background = color;
    btn.style.borderColor = color;
  }
  renderMtaChart();
}

function renderMtaChart() {
  const canvas = document.getElementById('mta-canvas');
  if (!canvas) return;
  if (_mtaChart) { _mtaChart.destroy(); _mtaChart = null; }

  const available = Object.keys(_mtaData).sort();
  const today = new Date().toISOString().slice(0, 10);
  const datasets = [];

  // Collect all dates to determine axis range
  let allDates = [];
  available.forEach(ms => {
    const entry = _mtaData[ms];
    if (!entry) return;
    (entry.points || []).forEach(p => { allDates.push(p.x); allDates.push(p.y); });
    if (entry.zmmt) allDates.push(entry.zmmt);
  });
  if (!allDates.length) return;
  allDates.sort();
  const minD = allDates[0];
  const maxD = allDates[allDates.length - 1];

  // Diagonal reference line (y = x)
  datasets.push({
    label: '__diagonal__',
    data: [{ x: minD, y: minD }, { x: maxD, y: maxD }],
    borderColor: 'rgba(0,0,0,0.15)',
    borderWidth: 1.5,
    borderDash: [6, 4],
    pointRadius: 0,
    showLine: true,
    fill: false,
    order: 99,
  });

  // "Today" vertical reference line (x = today, y from minD to maxD)
  datasets.push({
    label: '__today__',
    data: [{ x: today, y: minD }, { x: today, y: maxD }],
    borderColor: 'rgba(0,0,0,0.25)',
    borderWidth: 1,
    borderDash: [3, 3],
    pointRadius: 0,
    showLine: true,
    fill: false,
    order: 98,
  });

  // One dataset per active milestone + its ZMMT reference line
  available.forEach((ms, i) => {
    if (!_mtaActive.has(ms)) return;
    const color = MTA_COLORS[i % MTA_COLORS.length];
    const entry = _mtaData[ms] || {};
    const pts   = (entry.points || []).map(p => ({
      x: p.x, y: p.y,
      _desc: p.desc, _delay: p.delay_cd, _type: p.type,
    }));

    // Main ZMMP trend line
    datasets.push({
      label: ms,
      data: pts,
      borderColor: color,
      backgroundColor: color,
      pointRadius: pts.map(p =>
        p._type === 'initial' ? 5 : p._type === 'current' ? 6 : 4
      ),
      pointStyle: pts.map(p =>
        p._type === 'initial' ? 'triangle' : p._type === 'current' ? 'rectRot' : 'circle'
      ),
      borderWidth: 2,
      showLine: true,
      fill: false,
      tension: 0,
      order: i,
    });

    // ZMMT horizontal reference line (dashed, same color, thinner)
    const zmmt = entry.zmmt;
    if (zmmt) {
      datasets.push({
        label: `__zmmt_${ms}__`,
        data: [{ x: minD, y: zmmt }, { x: maxD, y: zmmt }],
        borderColor: color,
        borderWidth: 1,
        borderDash: [4, 4],
        pointRadius: 0,
        showLine: true,
        fill: false,
        order: 50 + i,
      });
    }
  });

  const ctx = canvas.getContext('2d');
  _mtaChart = new Chart(ctx, {
    type: 'scatter',
    data: { datasets },
    options: {
      responsive: true,
      maintainAspectRatio: false,
      interaction: { mode: 'nearest', intersect: false },
      plugins: {
        legend: { display: false },
        tooltip: {
          filter: (item) => !item.dataset.label.startsWith('__'),
          callbacks: {
            title: (items) => {
              const d = items[0];
              return `${d.dataset.label} — Bericht: ${d.raw.x}`;
            },
            label: (item) => {
              const r = item.raw;
              const typeMap = { initial: ' ▲ Ursprungsplan', current: ' ◆ Aktueller Stand (ZMMP)', shift: '' };
              const lines = [`Plan-Datum: ${r.y}${typeMap[r._type] || ''}`];
              if (r._delay != null && r._delay !== 0)
                lines.push(`Shift: ${r._delay > 0 ? '+' : ''}${r._delay} CD`);
              if (r._desc) lines.push(`Grund: ${r._desc}`);
              return lines;
            }
          }
        }
      },
      scales: {
        x: {
          type: 'time',
          time: { unit: 'month', tooltipFormat: 'dd.MM.yyyy', displayFormats: { month: 'MMM yy' } },
          title: { display: true, text: 'Berichtsdatum (Claim-Erfassung)', font: { size: 11 } },
          grid: { color: 'rgba(0,0,0,0.05)' },
        },
        y: {
          type: 'time',
          time: { unit: 'month', tooltipFormat: 'dd.MM.yyyy', displayFormats: { month: 'MMM yy' } },
          title: { display: true, text: 'Geplantes Meilenstein-Datum (ZMMP)', font: { size: 11 } },
          grid: { color: 'rgba(0,0,0,0.05)' },
        }
      }
    }
  });

  // Legend
  const legend = document.getElementById('mta-legend');
  if (legend) {
    const activeMsList = available.filter(ms => _mtaActive.has(ms));
    legend.innerHTML = [
      `<div class="mta-legend-item"><div class="mta-legend-line" style="border-top:1.5px dashed rgba(0,0,0,0.2);background:transparent"></div>Diagonale (y=x)</div>`,
      `<div class="mta-legend-item"><div class="mta-legend-line" style="border-top:1px dashed rgba(0,0,0,0.3);background:transparent"></div>Heute</div>`,
      ...activeMsList.map((ms, i) => {
        const color = MTA_COLORS[i % MTA_COLORS.length];
        const entry = _mtaData[ms] || {};
        const lastPt = (entry.points || []).filter(p => p.type === 'current' || p.type === 'shift').slice(-1)[0];
        const zmmp = lastPt?.y || entry.zmmp || '–';
        const zmmt = entry.zmmt || '';
        return `<div class="mta-legend-item">
          <div class="mta-legend-line" style="background:${color}"></div>
          <b>${ms}</b>: ZMMP&nbsp;${zmmp}
          ${zmmt ? `<span style="opacity:.6"> · ZMMT&nbsp;${zmmt} (gestrichelt)</span>` : ''}
        </div>`;
      })
    ].join('');
  }
}

async function reload() {
  await fetch('/api/reload', {method:'POST'});
  await loadStats();
  await loadList();
}

// ── Init ──────────────────────────────────────────────────────────────────
// Direktlink: /?project=L.20320&inc=INS000241124 (z. B. aus der Modul-Hierarchie)
const URLQ = new URLSearchParams(location.search);
var HI_INC = URLQ.get('inc') || '';
loadStats();
loadList().then(() => { if (URLQ.get('project')) selectEngine(URLQ.get('project')); });
</script>
</body>
</html>
""".replace(
    '${JSON.stringify(MILESTONE_COLS)}',
    json.dumps(MILESTONE_COLS)
).replace(
    '${JSON.stringify(MILESTONE_LABELS)}',
    json.dumps(MILESTONE_LABELS)
).replace(
    '${JSON.stringify(NOTIF_TYPE_LABELS)}',
    json.dumps(NOTIF_TYPE_LABELS)
)


@app.route('/')
def index():
    return HTML


# ── Dashboard APIs ────────────────────────────────────────────────────────────

@app.route('/api/engine/<path:project>/mta')
def api_engine_mta(project):
    """
    MTA history per milestone — traces ZMMP evolution via P7 claims.
    Returns: { milestone: { points: [...], zmmt: 'YYYY-MM-DD', tat_start: 'YYYY-MM-DD' } }
    """
    from datetime import date
    engines, _ = get_data()
    e = engines.get(project)
    if not e:
        return jsonify({'error': 'not found'}), 404

    today     = date.today().isoformat()
    tat_start = e.get('tat_start', '')
    mta = {}

    for ms, claims in e['ms_shifts'].items():
        sorted_claims = sorted(
            [c for c in claims if c['new_date'] and c['created_on']],
            key=lambda x: (x['created_on'], x['notification'])
        )
        if not sorted_claims:
            continue

        points = []
        first = sorted_claims[0]

        # Anchor at TAT start — original plan is best approximated by
        # former_date of the first claim (earliest known plan for this milestone)
        original_plan = first['former_date'] if first['former_date'] else first['new_date']
        if original_plan and tat_start:
            # Only add if TAT start is before first claim (avoid duplicate x)
            if tat_start < first['created_on']:
                points.append({
                    'x': tat_start,
                    'y': original_plan,
                    'desc': 'Ursprungsplan (Näherung: former_date erster Claim)',
                    'delay_cd': None,
                    'type': 'initial',
                })

        # One point per claim (deduplicated by date+new_date)
        seen = set()
        for c in sorted_claims:
            key = (c['created_on'], c['new_date'])
            if key in seen:
                continue
            seen.add(key)
            points.append({
                'x': c['created_on'],
                'y': c['new_date'],
                'desc': c['description'] or c['subject'] or '',
                'delay_cd': c['delay_cd'],
                'type': 'shift',
            })

        # Final point: current ZMMP date (NOT ZMMT — claims trace ZMMP)
        zmmp_current = e['milestones_zmmp'].get(ms, '')
        if zmmp_current:
            last_shift_date = sorted_claims[-1]['new_date']
            # Only add if ZMMP differs from last shift (avoids duplicate)
            if zmmp_current != last_shift_date:
                points.append({
                    'x': today,
                    'y': zmmp_current,
                    'desc': 'Aktueller Planstand (ZMMP)',
                    'delay_cd': None,
                    'type': 'current',
                })
        elif points:
            # No ZMMP available — pin last known shift to today
            last = points[-1]
            if last['x'] != today:
                points.append({
                    'x': today,
                    'y': last['y'],
                    'desc': 'Letzter bekannter Planstand',
                    'delay_cd': None,
                    'type': 'current',
                })

        if len(points) >= 2:
            mta[ms] = {
                'points':    points,
                'zmmt':      e['milestones_zmmt'].get(ms, ''),  # reference line
                'zmmp':      e['milestones_zmmp'].get(ms, ''),
                'tat_start': tat_start,
            }

    return jsonify(mta)


@app.route('/api/dashboard/delay-by-milestone')
def api_delay_by_milestone():
    """Per milestone: gross added delay, net delay, engine count, claim count."""
    engines, _ = dash_data()
    engines = _filter_engines(engines, request.args.get('year', 'all'))
    ms_data = defaultdict(lambda: {
        'gross_cd': 0.0, 'net_cd': 0.0,
        'claim_count': 0, 'engines': set(), 'breakdowns': 0
    })
    for e in engines.values():
        for ms, claims in e['ms_shifts'].items():
            for c in claims:
                ms_data[ms]['net_cd']      += c['delay_cd']
                ms_data[ms]['gross_cd']    += max(c['delay_cd'], 0)
                ms_data[ms]['claim_count'] += 1
                ms_data[ms]['engines'].add(e['project'])
                if c.get('breakdown') == 'X':
                    ms_data[ms]['breakdowns'] += 1

    result = []
    for ms, d in ms_data.items():
        n = len(d['engines'])
        result.append({
            'milestone':    ms,
            'label':        MILESTONE_LABELS.get(ms, ms),
            'gross_cd':     round(d['gross_cd'], 2),
            'net_cd':       round(d['net_cd'], 2),
            'avg_gross_cd': round(d['gross_cd'] / n, 2) if n else 0,
            'avg_net_cd':   round(d['net_cd']   / n, 2) if n else 0,
            'claim_count':  d['claim_count'],
            'engine_count': n,
            'breakdowns':   d['breakdowns'],
        })
    result.sort(key=lambda x: -x['gross_cd'])
    return jsonify(result)


@app.route('/api/dashboard/heatmap')
def api_heatmap():
    """Engine Type (or Family) × Workscope → Ø Verzug (only groups with ≥3 engines).

    ?metric=end (default) ranks by Endverzug — the whole date slip, whoever caused
    it, counted once; only engines with an end delay count. ?metric=mtu ranks by
    SAP MTU Delay CD — only what MTU is responsible for, but summed over all
    milestones (cascades count several times); every engine counts. The other
    metric rides along for comparison.

    ?group=family rolls the rows up via engine_families.json, which collapses the
    long tail of near-identical type designations into far fewer, denser rows.
    """
    engines, _ = dash_data()
    engines = _filter_engines(engines, request.args.get('year', 'all'))
    by_family = request.args.get('group', 'type') == 'family'
    metric    = 'mtu' if request.args.get('metric') == 'mtu' else 'end'

    groups  = defaultdict(list)
    ends    = defaultdict(list)
    mtu     = defaultdict(list)
    members = defaultdict(set)
    for e in engines.values():
        et = e['engine_type']
        ws = e['workscope']
        if ws.startswith(DUMMY_WORKSCOPE_PREFIX):
            continue          # MIGRATION_DUMMY carries no real shop work
        end = e.get('end_delay_cd')
        if not (et and ws) or (metric == 'end' and end is None):
            continue
        k = (_engine_family(et) if by_family else et, ws)
        groups[k].append(end if metric == 'end' else e['mtu_delay_cd'])
        mtu[k].append(e['mtu_delay_cd'])
        if end is not None:
            ends[k].append(end)
        members[k].add(et)

    result = []
    for (key, ws), delays in groups.items():
        if len(delays) < 3:
            continue
        result.append({
            'engine_type':      key,
            'workscope':        ws,
            'metric':           metric,
            'avg_delay_cd':     _avg1(delays),
            'median_delay_cd':  round(statistics.median(delays), 1),
            'max_delay_cd':     round(max(delays), 1),
            'avg_end_delay_cd': _avg1(ends[(key, ws)]),
            'end_delay_n':      len(ends[(key, ws)]),
            'avg_mtu_delay_cd': _avg1(mtu[(key, ws)]),
            'engine_count':     len(delays),
            'type_count':       len(members[(key, ws)]),
        })
    result.sort(key=lambda x: -x['avg_delay_cd'])
    return jsonify(result)


@app.route('/api/dashboard/workscope-mix')
def api_workscope_mix():
    """Which workscopes actually ran per engine type — full mix, no minimum group size.

    The heatmap answers "which combination runs worst" and needs >=3 engines for a
    meaningful mean. This answers "what was in the shop at all", so every workscope
    counts, including one-offs. Migration dummies are reported separately instead of
    being mixed into the real workscopes.
    """
    engines, _ = dash_data()
    engines = _filter_engines(engines, request.args.get('year', 'all'))
    by_family = request.args.get('group', 'type') == 'family'

    by_type = {}
    for e in engines.values():
        et = e.get('engine_type') or ''
        ws = e.get('workscope') or ''
        if not et:
            continue
        fam = _engine_family(et)
        key = fam if by_family else et
        t = by_type.setdefault(key, {'family': fam, 'count': 0, 'dummy_count': 0,
                                     'ws': defaultdict(list), 'types': set()})
        t['count'] += 1
        t['types'].add(et)
        if ws.startswith(DUMMY_WORKSCOPE_PREFIX):
            t['dummy_count'] += 1
            continue
        if ws:
            # Anteile zählen alle Engines; der Ø Endverzug nur die mit Endtermin,
            # der Ø MTU Delay alle (für den Metrik-Schalter im Frontend)
            t['ws'][ws].append((e.get('end_delay_cd'), e.get('mtu_delay_cd') or 0))

    result = []
    for et, t in by_type.items():
        real = sum(len(v) for v in t['ws'].values())

        def _metrics(pairs):
            ends = [d for d, _ in pairs if d is not None]
            mtus = [m for _, m in pairs]
            return {'avg_delay_cd':     _avg1(ends),          # Endverzug
                    'max_delay_cd':     max(ends, default=None),
                    'delay_n':          len(ends),
                    'avg_mtu_delay_cd': _avg1(mtus),
                    'max_mtu_delay_cd': round(max(mtus), 1) if mtus else None}

        scopes = sorted(
            ({'workscope':    ws,
              'count':        len(vals),
              'share':        round(100 * len(vals) / real, 1) if real else 0,
              **_metrics(vals)}
             for ws, vals in t['ws'].items()),
            key=lambda x: -x['count'])
        if not scopes:
            continue
        result.append({
            'engine_type':    et,
            'family':         t['family'],
            'types':          sorted(t['types']),
            'type_count':     len(t['types']),
            'count':          t['count'],
            'scoped_count':   real,
            'dummy_count':    t['dummy_count'],
            'distinct_count': len(scopes),
            **{k: v for k, v in _metrics([p for v in t['ws'].values() for p in v]).items()
               if not k.startswith('max_')},
            'workscopes':     scopes,
        })
    result.sort(key=lambda x: (-x['count'], x['engine_type']))
    return jsonify(result)


@app.route('/api/dashboard/top-descriptions')
def api_top_descriptions():
    """Most frequent P7 claim description texts with total delay impact."""
    engines, _ = dash_data()
    engines = _filter_engines(engines, request.args.get('year', 'all'))
    counts = defaultdict(lambda: {'count': 0, 'total_cd': 0.0, 'engines': set()})
    for e in engines.values():
        for c in e['claims_p7']:
            text = (c['description'] or c['subject'] or '').strip()
            if not text or len(text) < 3:
                continue
            counts[text]['count']    += 1
            counts[text]['total_cd'] += c['delay_cd']
            counts[text]['engines'].add(e['project'])

    result = []
    for text, d in counts.items():
        result.append({
            'text':        text,
            'count':       d['count'],
            'total_cd':    round(d['total_cd'], 1),
            'engine_count': len(d['engines']),
        })
    result.sort(key=lambda x: -x['count'])
    return jsonify(result[:60])


@app.route('/api/dashboard/breakdowns')
def api_breakdowns():
    """Breakdown claims grouped by milestone, sorted by count."""
    engines, _ = dash_data()
    engines = _filter_engines(engines, request.args.get('year', 'all'))
    bd = defaultdict(lambda: {'count': 0, 'total_cd': 0.0, 'engines': set()})
    for e in engines.values():
        for c in e['claims_p7']:
            if c.get('breakdown') != 'X':
                continue
            ms = c['milestone'] or '–'
            bd[ms]['count']    += 1
            bd[ms]['total_cd'] += c['delay_cd']
            bd[ms]['engines'].add(e['project'])

    result = []
    for ms, d in bd.items():
        result.append({
            'milestone':    ms,
            'label':        MILESTONE_LABELS.get(ms, ms),
            'count':        d['count'],
            'total_cd':     round(d['total_cd'], 1),
            'engine_count': len(d['engines']),
        })
    result.sort(key=lambda x: -x['count'])
    return jsonify(result)


@app.route('/api/dashboard/cycles')
def api_dashboard_cycles():
    """Per-cycle aggregate: avg Soll/Ist duration and P7 delay across all engines."""
    engines, _ = dash_data()
    engines = _filter_engines(engines, request.args.get('year', 'all'))
    agg = {cyc['name']: {
        'label':      cyc['label'],
        'queue_only': cyc.get('queue_only', False),
        'soll':       [], 'ist': [], 'delta': [],
        'p7_cd':      0.0, 'p7_count': 0, 'engines': 0,
    } for cyc in CYCLE_DEFS}

    n_engines = len(engines)
    for eng in engines.values():
        for cyc in (eng.get('cycles_summary') or []):
            a = agg[cyc['name']]
            if cyc['soll_days'] is not None:
                a['soll'].append(cyc['soll_days'])
            if cyc['ist_days'] is not None:
                a['ist'].append(cyc['ist_days'])
            if cyc['delta_days'] is not None:
                a['delta'].append(cyc['delta_days'])
        for cyc in CYCLE_DEFS:
            cp7 = (eng.get('cycles_p7') or {}).get(cyc['name'], {})
            agg[cyc['name']]['p7_cd']    += cp7.get('delay_cd', 0)
            agg[cyc['name']]['p7_count'] += cp7.get('count', 0)

    result = []
    for name, a in agg.items():
        n_soll = len(a['soll'])
        n_ist  = len(a['ist'])
        n_delta= len(a['delta'])
        result.append({
            'name':         name,
            'label':        a['label'],
            'queue_only':   a['queue_only'],
            'avg_soll':     round(sum(a['soll'])  / n_soll,  1) if n_soll  else None,
            'avg_ist':      round(sum(a['ist'])   / n_ist,   1) if n_ist   else None,
            'avg_delta':    round(sum(a['delta']) / n_delta, 1) if n_delta else None,
            'p7_cd_total':  round(a['p7_cd'], 1),
            'p7_count':     a['p7_count'],
            'engine_count': n_engines,
        })
    return jsonify(result)


@app.route('/api/dashboard/p7-by-family-year')
def api_p7_by_family_year():
    """P7 claims and CD per engine family per TAT-start year (mapping-only, no year filter)."""
    engines, _ = dash_data()
    mapping = _load_family_map()

    def family_from_map(et):
        if not et:
            return None
        for prefix in sorted(mapping.keys(), key=len, reverse=True):
            if et.startswith(prefix):
                return mapping[prefix]
        return None

    cycle_filter = request.args.get('cycle', 'all')  # 'all' or e.g. 'Cycle 1'

    # Collect all families and years present in data
    data = {}   # data[family][year] = {p7_count, p7_cd, engines}
    years_set = set()
    for eng in engines.values():
        fam = family_from_map(eng.get('engine_type') or '')
        if not fam:
            continue
        year = (eng.get('tat_start') or '')[:4]
        if not year.isdigit():
            continue
        years_set.add(year)
        if fam not in data:
            data[fam] = {}
        if year not in data[fam]:
            data[fam][year] = {'p7_count': 0, 'p7_cd': 0.0, 'engines': 0}
        claims = eng.get('claims_p7') or []
        if cycle_filter != 'all':
            claims = [c for c in claims if c.get('cycle') == cycle_filter]
        data[fam][year]['engines']  += 1
        data[fam][year]['p7_count'] += len(claims)
        data[fam][year]['p7_cd']    += sum(c.get('delay_cd', 0) for c in claims)

    families = sorted(data.keys())
    years    = sorted(years_set)

    # Round p7_cd
    for fam in data:
        for yr in data[fam]:
            data[fam][yr]['p7_cd'] = round(data[fam][yr]['p7_cd'], 1)

    return jsonify({'families': families, 'years': years, 'data': data})


@app.route('/api/dashboard/cycle-delta-by-family-year')
def api_cycle_delta_by_family_year():
    """Cycle Δ (Ist−Soll) per engine family per TAT-start year. cycle=all → Gesamt (I→SA)."""
    engines, _ = dash_data()
    mapping = _load_family_map()

    def family_from_map(et):
        if not et:
            return None
        for prefix in sorted(mapping.keys(), key=len, reverse=True):
            if et.startswith(prefix):
                return mapping[prefix]
        return None

    cycle_filter = request.args.get('cycle', 'all')
    target_name  = 'Gesamt' if cycle_filter == 'all' else cycle_filter

    raw   = {}   # raw[fam][year] = {delta: [], ist: [], soll: []}
    years_set = set()

    for eng in engines.values():
        fam  = family_from_map(eng.get('engine_type') or '')
        if not fam:
            continue
        year = (eng.get('tat_start') or '')[:4]
        if not year.isdigit():
            continue
        years_set.add(year)
        raw.setdefault(fam, {}).setdefault(year, {'delta': [], 'ist': [], 'soll': []})
        cyc = next((c for c in (eng.get('cycles_summary') or []) if c['name'] == target_name), None)
        if cyc:
            cell = raw[fam][year]
            if cyc.get('delta_days') is not None:
                cell['delta'].append(cyc['delta_days'])
            if cyc.get('ist_days') is not None:
                cell['ist'].append(cyc['ist_days'])
            if cyc.get('soll_days') is not None:
                cell['soll'].append(cyc['soll_days'])

    families = sorted(raw.keys())
    years    = sorted(years_set)

    data = {}
    for fam in raw:
        data[fam] = {}
        for yr, c in raw[fam].items():
            nd = len(c['delta'])
            ni = len(c['ist'])
            ns = len(c['soll'])
            data[fam][yr] = {
                'avg_delta': round(sum(c['delta']) / nd, 1) if nd else None,
                'avg_ist':   round(sum(c['ist'])   / ni, 1) if ni else None,
                'avg_soll':  round(sum(c['soll'])  / ns, 1) if ns else None,
                'n':         nd,
            }

    return jsonify({'families': families, 'years': years, 'data': data})


INC_YEAR_EDGE_SHARE = 0.25    # ältestes Jahr darunter wird als angebrochen markiert


INC_SCOPES = ('map', 'sap', 'hub')
INC_NO_TYPE = 'ohne Typ'


def inc_type(i: dict) -> tuple:
    """(Typ, Quelle) eines Incidents. Typ ∈ 'stoer' | 'anf'. Quelle 'feld' = LUD-Core-Spalte
    type; 'heuristik' = Spalte leer (alter Snapshot) → inc_side() entscheidet."""
    t = _sn(i.get('type'))
    if t.startswith('anfr'):
        return 'anf', 'feld'
    if t.startswith(('stör', 'stoer', 'stor')):
        return 'stoer', 'feld'
    return ('anf' if _inc_side_heur(i)[0] == 'anf' else 'stoer'), 'heuristik'


@app.route('/api/dashboard/incidents-by-family-year')
def api_incidents_by_family_year():
    """Incidents je Engine-Familie und Jahr, aufklappbar nach meldender Gruppe.

    Jahresanker ist das **Meldedatum** des Incidents — eine Engine steht deshalb
    in jedem Jahr, in dem sie gemeldet wurde, und die Jahresspalten summieren
    sich nicht auf die Flotte. Damit das lesbar bleibt, liefert jede Zelle den
    Bezug mit: betroffene Engines / Engines der Familie, deren Shop Visit in
    diesem Jahr überhaupt lief.

    scope: map = nur Typen aus engine_families.json (wie früher) · sap = alle
    Engines des SAP-Exports · hub = zusätzlich Incidents an Projekten, die nur
    in LUD Core stehen. Deren Engines haben keinen Nenner (Flotte, SLU) — sie
    zählen in den absoluten Werten, nie in den Raten.
    typ: '' alle · stoer · anf (LUD-Core-Spalte type).

    Baustopp-Stunden sind überlappungsfrei je Engine und nur in der Shop-Zeit
    gezählt (_bs_interval/_bs_merged) und nach Kalenderjahr verteilt, in dem die
    Stunden anfallen — nicht nach Meldejahr.
    """
    engines, _ = dash_data()
    mapping = _load_family_map()
    want_group = (request.args.get('group') or '').strip()   # leer = alle
    scope = request.args.get('scope') or 'map'
    scope = scope if scope in INC_SCOPES else 'map'
    want_typ = request.args.get('typ') or ''
    want_typ = want_typ if want_typ in ('stoer', 'anf') else ''

    def family_from_map(et):
        if not et:
            return None
        for prefix in sorted(mapping.keys(), key=len, reverse=True):
            if et.startswith(prefix):
                return mapping[prefix]
        return None

    def fam_of(et):
        if scope == 'map':
            return family_from_map(et)
        f = _engine_family(et or '')
        return INC_NO_TYPE if f in ('', '–') else f

    def _median(lst):
        if not lst: return None
        t = sorted(lst)
        n = len(t)
        return round((t[n//2] if n % 2 else (t[n//2-1] + t[n//2]) / 2), 1)

    def _present_years(eng):
        """Jahre, in denen der Shop Visit lief: TAT-Start bis Shop Acceptance,
        ohne SA bis heute (die Engine ist dann noch im Haus)."""
        start = (eng.get('tat_start') or '')[:4]
        if not start.isdigit():
            return ()
        end = ((eng.get('milestones_actual') or {}).get('SA') or '')[:4]
        last = int(end) if end.isdigit() else datetime.date.today().year
        return range(int(start), max(int(start), last) + 1)

    # ── Incidents flach einsammeln, damit die Jahresauswahl vor der
    #    Verdichtung feststeht. known = Engine steht im SAP-Export (hat Nenner).
    flat, year_count, group_count = [], defaultdict(int), defaultdict(int)
    type_count, type_src = Counter(), Counter()

    def _collect(fam, project, inc, known):
        co = inc.get('created_on', '')
        yr = co[:4] if len(co) >= 10 else ''        # ISO: YYYY-MM-DD
        if not yr.isdigit():
            return
        grp = (inc.get('group') or '').replace('Req_', '').replace('-LUD', '') or '–'
        typ, src = inc_type(inc)
        flat.append((fam, yr, project, grp, inc, known, typ))
        group_count[grp] += 1
        year_count[yr] += 1      # Jahresachse bleibt stabil, auch gefiltert
        type_count[typ] += 1
        type_src[src] += 1

    for project, eng in engines.items():
        fam = fam_of(eng.get('engine_type') or '')
        if not fam:
            continue
        for inc in (eng.get('incidents_c3') or []):
            _collect(fam, project, inc, True)

    hub_only = 0
    if scope == 'hub':
        # Alles, was einem Shop Visit des Exports gehört (auch Nebenaufträge und
        # vom Modul-Schalter ausgeblendete Visits), ist schon abgedeckt.
        full, _ = get_data()
        covered = set(full) | set(_get_cache()['aliases'])
        for project, lst in get_incidents().items():
            if project in covered:
                continue
            for inc in lst:
                et = inc.get('engine_type') or ''
                fam = (inc.get('engine_family') or '') if et else ''
                fam = INC_NO_TYPE if fam in ('', '–') else fam
                _collect(fam, project, inc, False)
                hub_only += 1

    # Jahre werden **nicht** ausgeblendet — ein automatisch verschwindendes Jahr
    # kostet mehr Vertrauen, als ein dünnes Randjahr an Platz kostet. Das älteste
    # Jahr wird nur markiert, wenn es deutlich dünner ist als sein Nachfolger.
    years = sorted(year_count)
    years_edge = []
    if len(years) >= 2 and year_count[years[0]] < INC_YEAR_EDGE_SHARE * year_count[years[1]]:
        years_edge = [{'year': years[0], 'incidents': year_count[years[0]]}]
    shown = set(years)

    # ── Exposure: Shop-Segmente für die Baustopp-Stunden, SLU für den Nenner ──
    X = shop_exposure()
    d0, ref = X['d0'], X['ref']
    segs = {v['e'].get('project'): v['seg'] for v in X['visits'] if v['seg']}
    OPEN_SEG = (-10 ** 9, 10 ** 9, 0.0)     # ohne Shop-Zeit: nur auf den Datenstand schneiden
    ybound = {y: ((datetime.date(int(y), 1, 1) - d0).days, (datetime.date(int(y) + 1, 1, 1) - d0).days)
              for y in years}

    # ── Verdichten: Familie × Jahr und Familie × Gruppe × Jahr ──────────
    def _new_cell():
        return {'incidents': 0, 'baustopps': 0, 'kritisch': 0, 'hub_only': 0,
                'k_baustopps': 0, 'bs_h': 0.0, 'k_bs_h': 0.0,
                'res_h': [], 'engine_set': set()}

    data, groups = {}, {}
    bs_ivs = defaultdict(list)     # (fam, grp|None, project, known) → [(s, e)]
    for fam, yr, project, grp, inc, known, typ in flat:
        if yr not in shown or (want_group and grp != want_group) or (want_typ and typ != want_typ):
            continue
        cells = (data.setdefault(fam, {}).setdefault(yr, _new_cell()),
                 groups.setdefault(fam, {}).setdefault(grp, {}).setdefault(yr, _new_cell()))
        is_bs = inc['criticality'] == 'Baustopp'
        for cell in cells:
            cell['incidents'] += 1
            cell['engine_set'].add(project)
            if not known:
                cell['hub_only'] += 1
            if is_bs:
                cell['baustopps'] += 1
                if known:
                    cell['k_baustopps'] += 1
            elif inc['criticality'] == 'Kritisch':
                cell['kritisch'] += 1
            if inc['resolution_h'] is not None:
                cell['res_h'].append(inc['resolution_h'])
        if is_bs:
            d = _d(inc.get('created_on'))
            iv = _bs_interval(inc, d, segs.get(project) or OPEN_SEG, d0, ref) if d else None
            if iv:
                bs_ivs[(fam, None, project, known)].append(iv)
                bs_ivs[(fam, grp, project, known)].append(iv)

    # Überlappende Baustopps derselben Engine = ein Stillstand; Stunden auf das
    # Kalenderjahr, in dem sie anfallen.
    for (fam, grp, project, known), ivs in bs_ivs.items():
        ivs.sort()
        m = [list(ivs[0])]
        for s, e in ivs[1:]:
            if s <= m[-1][1]:
                m[-1][1] = max(m[-1][1], e)
            else:
                m.append([s, e])
        target = data[fam] if grp is None else groups[fam][grp]
        for y, (lo, hi) in ybound.items():
            h = 24 * sum(max(0.0, min(e, hi) - max(s, lo)) for s, e in m)
            if h:
                cell = target.setdefault(y, _new_cell())
                cell['bs_h'] += h
                if known:
                    cell['k_bs_h'] += h

    # ── Flottenbezug: wie viele Engines der Familie waren im Jahr da? ───
    # Eine Meldung beweist Anwesenheit — sonst käme bei Engines ohne saubere
    # Meilensteine „1 von 0 betroffen" heraus. Nur Engines des SAP-Exports.
    # Bewusst ungefiltert: der Nenner ist die Familie, nicht Gruppe oder Typ.
    inc_years = defaultdict(set)
    for _fam, yr, project, _grp, _inc, known, _typ in flat:
        if known:
            inc_years[project].add(yr)

    fleet = defaultdict(lambda: defaultdict(set))
    for project, eng in engines.items():
        fam = fam_of(eng.get('engine_type') or '')
        if not fam:
            continue
        for y in ({str(y) for y in _present_years(eng)} | inc_years[project]):
            fleet[fam][y].add(project)

    # SLU im Shop je Familie und Kalenderjahr (über die Verweildauer verteilt).
    # shop_exposure rechnet mit _engine_family — im Modus map identisch, weil
    # family_from_map nur Präfixe der Map zulässt.
    def slu_in(fam, y):
        c = X['cum'].get((fam, '*'))
        if not c:
            return 0.0
        lo, hi = ybound[y]
        lo, hi = max(0, min(lo, len(c) - 1)), max(0, min(hi, len(c) - 1))
        return c[hi] - c[lo]

    def _summarise(cell, fleet_n, slu):
        n_eng = len(cell['engine_set'])
        rate = lambda v, den, nd=2: round(v / den, nd) if den and den > 0.05 else None
        return {
            'incidents':        cell['incidents'],
            'baustopps':        cell['baustopps'],
            'kritisch':         cell['kritisch'],
            'hub_only':         cell['hub_only'],
            'engines':          n_eng,
            'engines_total':    fleet_n,
            'slu':              round(slu, 2),
            'bs_h':             round(cell['bs_h'], 1),
            'median_res_h':     _median(cell['res_h']),
            'baustopp_rate':    (round(100.0 * cell['baustopps'] / cell['incidents'], 1)
                                 if cell['incidents'] else None),
            'baustopps_p_eng':  rate(cell['k_baustopps'], fleet_n),
            'baustopps_p_slu':  rate(cell['k_baustopps'], slu),
            'bs_h_p_eng':       rate(cell['k_bs_h'], fleet_n, 1),
            'bs_h_p_slu':       rate(cell['k_bs_h'], slu, 1),
        }

    def _merge(cells):
        """Gesamtspalte aus den Jahreszellen — Mediane werden aus der
        zusammengelegten Rohliste gezogen, nie aus Medianen gemittelt."""
        m = _new_cell()
        for c in cells:
            for k in ('incidents', 'baustopps', 'kritisch', 'hub_only', 'k_baustopps', 'bs_h', 'k_bs_h'):
                m[k] += c[k]
            m['res_h'].extend(c['res_h'])
            m['engine_set'] |= c['engine_set']
        return m

    out, fam_tot = {}, {}
    for fam, per_year in data.items():
        out[fam] = {}
        for yr in years:
            cell = per_year.get(yr)
            n_fleet = len(fleet[fam].get(yr, ()))
            if cell is not None or n_fleet:        # Engines da, aber kein Incident → Nullzelle
                out[fam][yr] = _summarise(cell or _new_cell(), n_fleet, slu_in(fam, yr))
        all_fleet = set().union(*fleet[fam].values()) if fleet[fam] else set()
        fam_tot[fam] = (len(all_fleet), sum(slu_in(fam, y) for y in years))

    groups_out = {}
    for fam, per_grp in groups.items():
        n_all, slu_all = fam_tot.get(fam, (0, 0.0))
        rows = []
        for grp, per_year in per_grp.items():
            rows.append({
                'group': grp,
                'total': _summarise(_merge(per_year.values()), n_all, slu_all),
                'years': {yr: _summarise(c, len(fleet[fam].get(yr, ())), slu_in(fam, yr))
                          for yr, c in per_year.items()},
            })
        rows.sort(key=lambda r: (-r['total']['baustopps'], -r['total']['incidents']))
        groups_out[fam] = rows

    families = sorted(out.keys(),
                      key=lambda f: -sum(v['incidents'] for v in out[f].values()))
    all_groups = [{'group': g, 'incidents': n}
                  for g, n in sorted(group_count.items(), key=lambda x: -x[1])]
    return jsonify({'families': families, 'years': years, 'data': out,
                    'groups': groups_out, 'years_edge': years_edge,
                    'all_groups': all_groups, 'group': want_group,
                    'scope': scope, 'typ': want_typ, 'hub_only': hub_only,
                    'types': {'stoer': type_count['stoer'], 'anf': type_count['anf'],
                              'feld': type_src['feld'], 'heuristik': type_src['heuristik']}})


@app.route('/api/dashboard/incident-p7-correlation')
def api_incident_p7_correlation():
    """Per-engine correlation data: Cycle-3 incidents vs. P7 claims and Cycle-3 delta."""
    engines, _ = dash_data()
    mapping     = _load_family_map()

    def family_from_map(et):
        if not et: return None
        for prefix in sorted(mapping.keys(), key=len, reverse=True):
            if et.startswith(prefix): return mapping[prefix]
        return None

    rows = []
    for project, eng in engines.items():
        fam = family_from_map(eng.get('engine_type') or '')
        if not fam:
            continue
        # Cycle-3 delta from cycles_summary
        c3 = next((c for c in (eng.get('cycles_summary') or []) if c['name'] == 'Cycle 3'), None)
        c3_delta = c3['delta_days'] if c3 else None
        c3_ist   = c3['ist_days']   if c3 else None
        c3_soll  = c3['soll_days']  if c3 else None
        # P7 cycle-3 claims
        p7_c3    = (eng.get('cycles_p7') or {}).get('Cycle 3', {})
        rows.append({
            'project':          project,
            'esn':              eng.get('esn', ''),
            'engine_type':      eng.get('engine_type', ''),
            'family':           fam,
            'otd':              eng.get('otd', ''),
            'baustopp_count':   eng.get('baustopp_count', 0),
            'incident_count':   eng.get('incident_count', 0),
            'incident_res_h':   eng.get('incident_total_res_h', 0.0),
            'c3_delta':         c3_delta,
            'c3_ist':           c3_ist,
            'c3_soll':          c3_soll,
            'p7_cd_c3':         round(p7_c3.get('delay_cd', 0), 1),
            'p7_count_c3':      p7_c3.get('count', 0),
        })
    return jsonify(rows)


@app.route('/api/dashboard/otd-by-family')
def api_otd_by_family():
    """OTD distribution (Red/Yellow/Green) per engine family (mapping-only engines)."""
    engines, _ = dash_data()
    engines = _filter_engines(engines, request.args.get('year', 'all'))
    mapping = _load_family_map()

    def family_from_map(et):
        if not et:
            return None
        for prefix in sorted(mapping.keys(), key=len, reverse=True):
            if et.startswith(prefix):
                return mapping[prefix]
        return None

    data = {}  # data[family] = {'Red': 0, 'Yellow': 0, 'Green': 0, '': 0, 'total': 0}
    for eng in engines.values():
        fam = family_from_map(eng.get('engine_type') or '')
        if not fam:
            continue
        if fam not in data:
            data[fam] = {'Red': 0, 'Yellow': 0, 'Green': 0, '': 0, 'total': 0}
        otd = eng.get('otd') or ''
        data[fam][otd if otd in ('Red', 'Yellow', 'Green') else ''] += 1
        data[fam]['total'] += 1

    families = sorted(data.keys(), key=lambda f: -data[f]['total'])
    return jsonify({'families': families, 'data': data})


@app.route('/api/dashboard/cycles-by-family')
def api_dashboard_cycles_by_family():
    """Per-cycle averages broken down by engine family (mapping-only engines)."""
    engines, _ = dash_data()
    engines = _filter_engines(engines, request.args.get('year', 'all'))
    mapping = _load_family_map()

    def family_from_map(et):
        if not et:
            return None
        for prefix in sorted(mapping.keys(), key=len, reverse=True):
            if et.startswith(prefix):
                return mapping[prefix]
        return None

    # Collect all known families (preserve sorted order by family name)
    families_set = set()
    for eng in engines.values():
        fam = family_from_map(eng.get('engine_type') or '')
        if fam:
            families_set.add(fam)
    families = sorted(families_set)

    # Build aggregation structure per cycle per family
    def empty_agg():
        return {'soll': [], 'ist': [], 'delta': [], 'p7_cd': 0.0, 'p7_count': 0, 'engines': 0}

    agg = {cyc['name']: {
        'label':      cyc['label'],
        'queue_only': cyc.get('queue_only', False),
        'by_family':  {fam: empty_agg() for fam in families},
    } for cyc in CYCLE_DEFS}

    fam_engine_counts = defaultdict(int)

    for eng in engines.values():
        fam = family_from_map(eng.get('engine_type') or '')
        if not fam:
            continue
        fam_engine_counts[fam] += 1
        for cyc in (eng.get('cycles_summary') or []):
            a = agg[cyc['name']]['by_family'][fam]
            if cyc['soll_days'] is not None:
                a['soll'].append(cyc['soll_days'])
            if cyc['ist_days'] is not None:
                a['ist'].append(cyc['ist_days'])
            if cyc['delta_days'] is not None:
                a['delta'].append(cyc['delta_days'])
        for cyc in CYCLE_DEFS:
            a = agg[cyc['name']]['by_family'][fam]
            cp7 = (eng.get('cycles_p7') or {}).get(cyc['name'], {})
            a['p7_cd']    += cp7.get('delay_cd', 0)
            a['p7_count'] += cp7.get('count', 0)

    # Assign correct engine count per family (not ×4 inflated)
    for cyc_name in agg:
        for fam in families:
            agg[cyc_name]['by_family'][fam]['engines'] = fam_engine_counts[fam]

    def summarise(a):
        ns, ni, nd = len(a['soll']), len(a['ist']), len(a['delta'])
        return {
            'avg_soll':  round(sum(a['soll'])  / ns, 1) if ns else None,
            'avg_ist':   round(sum(a['ist'])   / ni, 1) if ni else None,
            'avg_delta': round(sum(a['delta']) / nd, 1) if nd else None,
            'p7_cd':     round(a['p7_cd'], 1),
            'p7_count':  a['p7_count'],
            'engines':   a['engines'],
        }

    result = []
    for cyc_def in CYCLE_DEFS:
        name = cyc_def['name']
        a    = agg[name]
        result.append({
            'name':       name,
            'label':      a['label'],
            'queue_only': a['queue_only'],
            'by_family':  {fam: summarise(a['by_family'][fam]) for fam in families},
        })
    return jsonify({'families': families, 'cycles': result})


@app.route('/api/dashboard/engine-types')
def api_dashboard_engine_types():
    """Engine types grouped by family, with per-type breakdown."""
    engines, _ = get_data()
    engines = _filter_engines(engines, request.args.get('year', 'all'))
    # Aggregate per individual type
    by_type = {}
    for eng in engines.values():
        et  = eng.get('engine_type') or '–'
        fam = _engine_family(et)
        if et not in by_type:
            by_type[et] = {'family': fam, 'count': 0, 'delays': [], 'mtu': [], 'workscopes': set()}
        by_type[et]['count'] += 1
        by_type[et]['mtu'].append(eng.get('mtu_delay_cd') or 0)
        if eng.get('end_delay_cd') is not None:
            by_type[et]['delays'].append(eng['end_delay_cd'])
        ws = eng.get('workscope') or ''
        if ws:
            by_type[et]['workscopes'].add(ws)
    # Roll up to families
    families = {}
    for et, v in by_type.items():
        fam = v['family']
        if fam not in families:
            families[fam] = {'count': 0, 'delays': [], 'mtu': [], 'workscopes': set(), 'types': []}
        families[fam]['count']  += v['count']
        families[fam]['delays'] += v['delays']
        families[fam]['mtu']    += v['mtu']
        families[fam]['workscopes'] |= v['workscopes']
        families[fam]['types'].append({
            'engine_type':      et,
            'count':            v['count'],
            'avg_delay_cd':     _avg1(v['delays']),
            'delay_n':          len(v['delays']),
            'avg_mtu_delay_cd': _avg1(v['mtu']),
            'workscopes':       sorted(v['workscopes']),
        })
    # Ohne Endverzug-Daten ans Ende sortieren
    by_delay = lambda x: (x['avg_delay_cd'] is None, -(x['avg_delay_cd'] or 0))
    result = sorted([
        {'family':           fam,
         'count':            v['count'],
         'avg_delay_cd':     _avg1(v['delays']),
         'delay_n':          len(v['delays']),
         'avg_mtu_delay_cd': _avg1(v['mtu']),
         'workscopes':       sorted(v['workscopes']),
         'types':            sorted(v['types'], key=by_delay)}
        for fam, v in families.items()
    ], key=by_delay)
    return jsonify(result)


@app.route('/api/dashboard/family-map', methods=['GET'])
def api_family_map_get():
    mapping = _load_family_map()
    return jsonify({'mapping': mapping, 'path': FAMILY_MAP_PATH, 'count': len(mapping)})


@app.route('/api/dashboard/family-map/generate', methods=['POST'])
def api_family_map_generate():
    """Seed mapping with heuristic-derived prefixes for all distinct engine types (skips existing keys)."""
    engines, _ = get_data()
    mapping = _load_family_map()
    added = 0
    for eng in engines.values():
        et = (eng.get('engine_type') or '').strip()
        if not et:
            continue
        family = _engine_family_heuristic(et)
        # Use the family name itself as prefix key if not already covered
        already_covered = any(et.startswith(p) for p in mapping)
        if not already_covered:
            mapping[family] = family
            added += 1
    _save_family_map(mapping)
    return jsonify({'ok': True, 'total': len(mapping), 'added': added, 'path': FAMILY_MAP_PATH})


@app.route('/api/dashboard/family-map/update', methods=['POST'])
def api_family_map_update():
    """Add or update a prefix → family rule."""
    body = request.get_json()
    prefix = (body.get('prefix') or '').strip()
    family = (body.get('family') or '').strip()
    if not prefix or not family:
        return jsonify({'error': 'prefix and family required'}), 400
    mapping = _load_family_map()
    mapping[prefix] = family
    _save_family_map(mapping)
    return jsonify({'ok': True, 'prefix': prefix, 'family': family})


@app.route('/api/dashboard/b3-ts-probability')
def api_b3_ts_probability():
    """B3 and TS on-time probability by pre-B3 Baustopp count and engine family."""
    engines, _ = dash_data()
    incidents  = get_incidents()

    def inc_to_iso(d):
        # created_on kommt seit der SQL-Umstellung schon als YYYY-MM-DD
        return d if d and len(d) >= 10 and d[4] == '-' else ''

    def bkt(n):
        if n == 0:   return '0'
        if n <= 2:   return '1–2'
        if n <= 5:   return '3–5'
        return '>5'

    BUCKETS = ['0', '1–2', '3–5', '>5']

    rows = []
    for project, eng in engines.items():
        ms_zmmt = eng.get('milestones_zmmt', {})
        ms_zmmp = eng.get('milestones_zmmp', {})
        soll_b3 = ms_zmmt.get('B3', '')
        ist_b3  = ms_zmmp.get('B3', '')
        soll_ts = ms_zmmt.get('TS', '')
        ist_ts  = ms_zmmp.get('TS', '')
        if not soll_b3:
            continue
        inc_list = incidents.get(project, [])
        bs_before = sum(
            1 for i in inc_list
            if i.get('criticality') == 'Baustopp'
            and inc_to_iso(i.get('created_on', '')) != ''
            and inc_to_iso(i.get('created_on', '')) < soll_b3
        )
        rows.append({
            'family':      _engine_family(eng.get('engine_type', '')),
            'bs_before':   bs_before,
            'b3_on_time':  (ist_b3 <= soll_b3) if (soll_b3 and ist_b3) else None,
            'ts_on_time':  (ist_ts <= soll_ts) if (soll_ts and ist_ts) else None,
        })

    def agg(subset):
        b3k  = [r for r in subset if r['b3_on_time'] is not None]
        b3ok = [r for r in b3k    if r['b3_on_time']]
        b3lt = [r for r in b3k    if not r['b3_on_time']]
        ts_if_ok   = [r for r in b3ok if r['ts_on_time'] is not None]
        ts_ok_ok   = [r for r in ts_if_ok  if r['ts_on_time']]
        ts_if_lt   = [r for r in b3lt if r['ts_on_time'] is not None]
        ts_ok_lt   = [r for r in ts_if_lt  if r['ts_on_time']]
        return {
            'n':              len(subset),
            'n_b3_known':     len(b3k),
            'b3_rate':        round(len(b3ok)/len(b3k), 3) if b3k else None,
            'ts_rate_b3_ok':  round(len(ts_ok_ok)/len(ts_if_ok),  3) if ts_if_ok  else None,
            'ts_n_b3_ok':     len(ts_if_ok),
            'ts_rate_b3_late':round(len(ts_ok_lt)/len(ts_if_lt),  3) if ts_if_lt  else None,
            'ts_n_b3_late':   len(ts_if_lt),
        }

    by_bucket = []
    for b in BUCKETS:
        sub = [r for r in rows if bkt(r['bs_before']) == b]
        entry = {'bucket': b}
        entry.update(agg(sub))
        by_bucket.append(entry)

    families = sorted(set(r['family'] for r in rows))
    by_family = {}
    for fam in families:
        fam_rows = [r for r in rows if r['family'] == fam]
        by_family[fam] = []
        for b in BUCKETS:
            sub = [r for r in fam_rows if bkt(r['bs_before']) == b]
            entry = {'bucket': b}
            entry.update(agg(sub))
            by_family[fam].append(entry)

    return jsonify({'by_bucket': by_bucket, 'by_family': by_family, 'families': families})


@app.route('/api/dashboard/b2-b3-propagation')
def api_b2_b3_propagation():
    """Delay propagation: Δ B2 → Δ B3 per engine."""
    engines, _ = dash_data()

    def bkt(d):
        if d <= 0:   return '≤ 0 d (pünktlich)'
        if d <= 5:   return '1–5 d'
        if d <= 14:  return '6–14 d'
        return '> 14 d'

    BUCKETS = ['≤ 0 d (pünktlich)', '1–5 d', '6–14 d', '> 14 d']

    scatter = []
    for project, eng in engines.items():
        ms_t = eng.get('milestones_zmmt', {})
        ms_p = eng.get('milestones_zmmp', {})
        soll_b2 = ms_t.get('B2', '');  ist_b2 = ms_p.get('B2', '')
        soll_b3 = ms_t.get('B3', '');  ist_b3 = ms_p.get('B3', '')
        if not (soll_b2 and ist_b2 and soll_b3 and ist_b3):
            continue
        d_b2 = _date_delta_days(soll_b2, ist_b2)
        d_b3 = _date_delta_days(soll_b3, ist_b3)
        if d_b2 is None or d_b3 is None:
            continue
        scatter.append({
            'project':  project,
            'esn':      eng.get('esn', ''),
            'family':   _engine_family(eng.get('engine_type', '')),
            'delta_b2': d_b2,
            'delta_b3': d_b3,
        })

    by_bucket = []
    for b in BUCKETS:
        sub = [r for r in scatter if bkt(r['delta_b2']) == b]
        b3_ok  = [r for r in sub if r['delta_b3'] <= 0]
        deltas = [r['delta_b3'] for r in sub]
        by_bucket.append({
            'bucket':      b,
            'n':           len(sub),
            'b3_rate':     round(len(b3_ok)/len(sub), 3) if sub else None,
            'avg_delta_b3':round(sum(deltas)/len(deltas), 1) if deltas else None,
        })

    families = sorted(set(r['family'] for r in scatter))
    return jsonify({'scatter': scatter, 'by_bucket': by_bucket, 'families': families})


# ── Dashboard JSON Export ─────────────────────────────────────────────────────

_JSON_GLOSSARY = {
    "mtu_delay_cd": "Summe der MTU-verantworteten Claim-Tage über alle Meilensteine (SAP Dataexport). Kein Endverzug: ein Verzug, der mehrere aufeinanderfolgende Meilensteine verschiebt, zählt mehrfach — im Median ~3x so groß wie die tatsächliche Verschiebung des Endtermins. Für Vergleiche und Ursachen-Zuordnung geeignet, nicht als 'Tage zu spät'.",
    "heatmap_engine_type_workscope": "avg_delay_cd = Ø Endverzug (ZMMP − ZMMT am Endtermin) je Engine-Typ × Workscope, nur Gruppen mit ≥3 Engines mit Endverzug.",
    "avg_end_delay_cd": "Ø Endverzug = Mittel über ZMMP − ZMMT am Endtermin (TEC, sonst SA, sonst S) in Kalendertagen. Tatsächlicher Terminverzug gegenüber der Kundenzusage, jeder Tag zählt einmal. Bei laufenden Engines Prognose aus dem aktuellen Plan. Nur Engines mit Kundenzusage (Budget TAT > 0) und plausiblem Endtermin (|Verzug| <= 730 Tage, Plan-Ende nicht vor TAT Start). median_end_delay_cd = robuster gegen Ausreißer; end_delay_n = Engines mit Endtermin.",
    "oem_delay_cd": "Kalender-Tage Verzug durch OEM-Lieferanten (z.B. CFM International, IAE). Nicht MTU-verschuldet.",
    "cust_delay_cd": "Entschuldbarer Delay (Customer/Excusable): Force Majeure, Kundenwunsch, externe Ursachen.",
    "otd_red": "OTD Red = Engine außerhalb des Customer Committed Date (CCD). Bedeutet: Lieferung verspätet, direkter Kundenschaden.",
    "otd_yellow": "OTD Yellow = Engine pünktlich laut CCD, aber internes MTU-Zieldatum verfehlt.",
    "otd_green": "OTD Green = Engine pünktlich oder früher als CCD und internes Ziel.",
    "tat_start": "TAT Start Date (Technical Acceptance Test Start) = Datum der Shop-Einlieferung (Induction). Alle Jahresangaben beziehen sich auf dieses Datum (Vintage-Jahr-Prinzip).",
    "sa": "Shop Acceptance (SA) = Abnahmedatum, an dem die Engine den Shop verlässt. Fehlt dieses Datum, ist die Engine noch 'laufend'.",
    "cycle_0_queue": "Cycle 0: Queue-Zeit = Wartezeit von TAT Start Planning (TSP) bis Induction (I). Keine Fischgräte/ZMMT vorhanden, reine Wartezeit.",
    "cycle_1_demontage": "Cycle 1: Demontage & Inspektion = von Induction (I) bis Inspection Complete (#).",
    "cycle_2_material": "Cycle 2: Material & Dispo = von # bis Release (R). Materialengpässe schlagen hier durch.",
    "cycle_3_aufbau": "Cycle 3: Aufbau & Test = von Release (R) bis Shop Acceptance (SA). Letzte Phase vor Auslieferung.",
    "cycle_gesamt": "Gesamt-TAT = von Induction (I) bis SA. Cycle 0 (Queue) ist nicht enthalten.",
    "avg_soll": "Ø Soll-Durchlaufzeit in Tagen laut ZMMT (Fischgräte = Planvorlage).",
    "avg_ist": "Ø Ist-Durchlaufzeit in Tagen laut ZMMP (aktueller Plan / tatsächliche Termine).",
    "avg_delta": "Ø Δ = Ist − Soll in Tagen. Positiv = Verzug gegenüber Plan. Negativ = schneller als geplant.",
    "p7_claim": "P7 = Notification Type P7 im SAP = Meilensteinverschiebung mit Begründung. Jede P7 belegt einen Delay CD und einen Meilenstein.",
    "p7_delay_cd": "Summe der Kalender-Tage Delay aus allen P7-Claims einer Engine oder Gruppe.",
    "baustopp": "Baustopp = kritischer Stillstand im Cycle 3 (Aufbau), gemeldet über ServiceNow. Stoppt die Produktion physisch.",
    "incident_kritisch": "Kritisch = schwerwiegender Incident ohne vollständigen Produktionsstopp (unterhalb Baustopp-Schwelle).",
    "b3": "B3 = Meilenstein 'Aufbau-Freigabe' vor dem finalen Testlauf. Gilt als letzter major Gate vor TS.",
    "ts": "TS = Test Start = Beginn des Triebwerk-Testlaufs. Verzug hier = fast immer OTD-Risiko.",
    "b3_rate": "B3 on-time Rate = Anteil Engines bei denen ist_B3 ≤ soll_B3.",
    "ts_rate_b3_ok": "TS on-time Rate gegeben B3 pünktlich. Zeigt ob ein pünktliches B3 den TS-Termin sichert.",
    "ts_rate_b3_late": "TS on-time Rate gegeben B3 zu spät. Zeigt Kompensationsfähigkeit nach B3-Verzug.",
    "delay_gross_cd": "Bruttodelays an einem Meilenstein = Summe aller positiven Delay-CD (Rückholungen herausgerechnet).",
    "delay_net_cd": "Nettodelays = Summe aller Delay-CD inkl. negativer Werte (Kompensationen).",
    "workscope": "Instandhaltungsumfang einer Engine, z.B. 'SVCE' (Service), 'PRF' (Performance Restoration), 'GP' (General Performance).",
    "family": "Engine-Familie = Produktlinie (CFM56-5B, CFM56-7B, V2500-A5, etc.). Mapping aus engine_families.json.",
    "vintage_year_caveat": "Alle Jahreszahlen in 'by_year' beziehen sich auf das TAT-Start-Jahr (Induction-Jahrgang), NICHT das Lieferjahr (SA). Eine Engine die Nov 2024 eintrifft und April 2026 geliefert wird, zählt unter 2024. Laufende Engines ohne SA haben vorläufige/keine Delay-Werte.",
    "total_engines": "Anzahl Shop Visits (Einlieferungen), nicht physische Triebwerke. SAP fuehrt fuer eine Einlieferung teils mehrere Projektnummern (Hauptprojekt L./K. plus Nebenauftraege, meist V./WS90-INV); diese sind ueber (Engine Serial, TAT Start Date) zu einem Shop Visit gebuendelt und zaehlen einmal. MIGRATION_DUMMY (WS99) ist ausgeschlossen. Dieselbe ESN kann ueber die Jahre mehrere Shop Visits haben \u2014 siehe distinct_esn.",
    "distinct_esn": "Anzahl unterschiedlicher Engine Serials = physische Triebwerke. Immer kleiner als total_engines, weil ein Triebwerk mehrfach im Shop war. Modul-Shop-Visits zaehlen nicht mit: bei ihnen steht im ESN-Feld die Seriennummer eines Moduls (z. B. 30X7S4248) oder der Workscope traegt ' - M -'.",
    "module_visits": "Shop Visits, bei denen nur ein Modul im Haus war, keine ganze Engine. Erkannt an einer Modul-Seriennummer im ESN-Feld (zwei Ziffern + X/T, die Ziffern sind der CK des Moduls) oder am Workscope ' - M -'. Sie sind in total_engines enthalten, aber nicht in distinct_esn.",
    "sub_projects": "Anzahl gebuendelter Nebenauftraege. Ihre Delays sind NICHT aufaddiert \u2014 sie stecken bereits im TAT des Hauptauftrags.",
    "incomplete_count": "Anzahl Engines im Jahrgang die noch keine Shop Acceptance (SA) haben. Deren MTU Delay CD = 0 oder vorläufig — zieht den Ø-Delay dieses Jahrgangs nach unten.",
}


def _json_family_lookup(mapping):
    """Returns a closure that maps engine_type → family name using the prefix mapping."""
    sorted_keys = sorted(mapping.keys(), key=len, reverse=True)
    def lookup(et):
        if not et:
            return None
        for prefix in sorted_keys:
            if et.startswith(prefix):
                return mapping[prefix]
        return None
    return lookup


def _build_dashboard_json():
    engines_all, _ = dash_data()
    engines_all     = _real_shop_visits(engines_all)
    incidents_all   = get_incidents()
    mapping         = _load_family_map()
    fam_lookup      = _json_family_lookup(mapping)

    def _median(lst):
        s = sorted(x for x in lst if x is not None)
        if not s: return None
        m = len(s) // 2
        return s[m] if len(s) % 2 else round((s[m-1] + s[m]) / 2, 1)

    # ── Caveats: detect incomplete years ──────────────────────────────────────
    caveats = {}
    for yr in EXPORT_YEARS:
        eng_yr = _filter_engines(engines_all, yr)
        incomplete = sum(1 for e in eng_yr.values()
                         if not e.get('milestones_actual', {}).get('SA', ''))
        total = len(eng_yr)
        if incomplete:
            pct = round(incomplete / total * 100) if total else 0
            caveats[yr] = (f"{incomplete} von {total} Engines ({pct}%) noch ohne SA — "
                           f"MTU Delay CD und OTD für diesen Jahrgang sind vorläufig.")

    # ── by_year ───────────────────────────────────────────────────────────────
    by_year = {}
    for yr in EXPORT_YEARS:
        eng = _filter_engines(engines_all, yr)
        vals = list(eng.values())
        n = len(vals)
        if not n:
            by_year[yr] = None
            continue

        # KPIs
        otd_counts = {k: sum(1 for e in vals if e.get('otd') == k)
                      for k in ('Green', 'Yellow', 'Red')}
        incomplete  = sum(1 for e in vals
                          if not e.get('milestones_actual', {}).get('SA', ''))
        kpis = {
            'total_engines':     n,
            'incomplete_engines':incomplete,
            'avg_mtu_delay_cd':  round(sum(e['mtu_delay_cd'] for e in vals) / n, 1),
            'avg_oem_delay_cd':  round(sum((e.get('oem_delay_cd') or 0) for e in vals) / n, 1),
            'avg_cust_delay_cd': round(sum((e.get('cust_delay_cd') or 0) for e in vals) / n, 1),
            **_end_delay_stats(vals),
            'otd_green':  otd_counts['Green'],
            'otd_yellow': otd_counts['Yellow'],
            'otd_red':    otd_counts['Red'],
            'otd_green_pct':  round(otd_counts['Green']  / n * 100, 1),
            'otd_yellow_pct': round(otd_counts['Yellow'] / n * 100, 1),
            'otd_red_pct':    round(otd_counts['Red']    / n * 100, 1),
        }

        # Cycles
        cyc_agg = {c['name']: {'label': c['label'], 'soll': [], 'ist': [], 'delta': [],
                                'p7_cd': 0.0, 'p7_count': 0}
                   for c in CYCLE_DEFS}
        for e in vals:
            for cyc in (e.get('cycles_summary') or []):
                a = cyc_agg.get(cyc['name'])
                if not a: continue
                if cyc['soll_days']  is not None: a['soll'].append(cyc['soll_days'])
                if cyc['ist_days']   is not None: a['ist'].append(cyc['ist_days'])
                if cyc['delta_days'] is not None: a['delta'].append(cyc['delta_days'])
            for cyc in CYCLE_DEFS:
                cp7 = (e.get('cycles_p7') or {}).get(cyc['name'], {})
                cyc_agg[cyc['name']]['p7_cd']    += cp7.get('delay_cd', 0)
                cyc_agg[cyc['name']]['p7_count'] += cp7.get('count', 0)
        cycles = []
        for name, a in cyc_agg.items():
            ns, ni, nd = len(a['soll']), len(a['ist']), len(a['delta'])
            cycles.append({
                'name':       name,
                'label':      a['label'],
                'avg_soll_d': round(sum(a['soll'])  / ns, 1) if ns else None,
                'avg_ist_d':  round(sum(a['ist'])   / ni, 1) if ni else None,
                'avg_delta_d':round(sum(a['delta']) / nd, 1) if nd else None,
                'p7_cd_total':round(a['p7_cd'], 1),
                'p7_count':   a['p7_count'],
                'n_engines':  n,
            })

        # Delay by milestone
        ms_data = defaultdict(lambda: {'gross_cd': 0.0, 'net_cd': 0.0,
                                        'claim_count': 0, 'engines': set(), 'breakdowns': 0})
        for e in vals:
            for ms, claims in e['ms_shifts'].items():
                for c in claims:
                    ms_data[ms]['net_cd']      += c['delay_cd']
                    ms_data[ms]['gross_cd']    += max(c['delay_cd'], 0)
                    ms_data[ms]['claim_count'] += 1
                    ms_data[ms]['engines'].add(e['project'])
                    if c.get('breakdown') == 'X':
                        ms_data[ms]['breakdowns'] += 1
        delay_by_ms = []
        for ms, d in ms_data.items():
            ne = len(d['engines'])
            delay_by_ms.append({
                'milestone':    ms,
                'label':        MILESTONE_LABELS.get(ms, ms),
                'gross_cd':     round(d['gross_cd'], 1),
                'net_cd':       round(d['net_cd'], 1),
                'avg_gross_cd': round(d['gross_cd'] / ne, 1) if ne else 0,
                'claim_count':  d['claim_count'],
                'engine_count': ne,
                'breakdowns':   d['breakdowns'],
            })
        delay_by_ms.sort(key=lambda x: -x['gross_cd'])

        # Top 15 P7 descriptions
        desc_counts = defaultdict(lambda: {'count': 0, 'total_cd': 0.0, 'engines': set()})
        for e in vals:
            for c in e['claims_p7']:
                text = (c['description'] or c['subject'] or '').strip()
                if not text or len(text) < 3: continue
                desc_counts[text]['count']    += 1
                desc_counts[text]['total_cd'] += c['delay_cd']
                desc_counts[text]['engines'].add(e['project'])
        top_desc = sorted(
            [{'text': t, 'count': d['count'],
              'total_cd': round(d['total_cd'], 1),
              'engine_count': len(d['engines'])}
             for t, d in desc_counts.items()],
            key=lambda x: -x['count']
        )[:15]

        # Breakdowns by milestone
        bd_data = defaultdict(lambda: {'count': 0, 'total_cd': 0.0, 'engines': set()})
        for e in vals:
            for c in e['claims_p7']:
                if c.get('breakdown') != 'X': continue
                ms = c['milestone'] or '–'
                bd_data[ms]['count']    += 1
                bd_data[ms]['total_cd'] += c['delay_cd']
                bd_data[ms]['engines'].add(e['project'])
        breakdowns = sorted(
            [{'milestone': ms, 'label': MILESTONE_LABELS.get(ms, ms),
              'count': d['count'], 'total_cd': round(d['total_cd'], 1),
              'engine_count': len(d['engines'])}
             for ms, d in bd_data.items()],
            key=lambda x: -x['count']
        )

        # OTD by family
        fam_otd = {}
        for e in vals:
            fam = fam_lookup(e.get('engine_type') or '')
            if not fam: continue
            if fam not in fam_otd:
                fam_otd[fam] = {'Green': 0, 'Yellow': 0, 'Red': 0, 'total': 0}
            otd = e.get('otd') or ''
            fam_otd[fam][otd if otd in ('Green', 'Yellow', 'Red') else 'Red'] += 1
            fam_otd[fam]['total'] += 1
        otd_by_family = {
            fam: {
                **d,
                'green_pct': round(d['Green'] / d['total'] * 100, 1) if d['total'] else 0,
                'red_pct':   round(d['Red']   / d['total'] * 100, 1) if d['total'] else 0,
            }
            for fam, d in sorted(fam_otd.items())
        }

        # Heatmap: Engine Type × Workscope (≥3 engines)
        hm_groups = defaultdict(list)
        for e in vals:
            et, ws = e['engine_type'], e['workscope']
            if et and ws and e.get('end_delay_cd') is not None:
                hm_groups[(et, ws)].append(e['end_delay_cd'])
        heatmap = [
            {'engine_type': et, 'workscope': ws,
             'avg_delay_cd': round(sum(ds)/len(ds), 1),   # Ø Endverzug
             'max_delay_cd': round(max(ds), 1),
             'n': len(ds)}
            for (et, ws), ds in hm_groups.items() if len(ds) >= 3
        ]
        heatmap.sort(key=lambda x: -x['avg_delay_cd'])

        by_year[yr] = {
            'kpis':                    kpis,
            'cycles':                  cycles,
            'delay_by_milestone':      delay_by_ms,
            'top_15_p7_descriptions':  top_desc,
            'breakdowns_by_milestone': breakdowns,
            'otd_by_family':           otd_by_family,
            'heatmap_engine_type_workscope': heatmap,
        }

    # ── cross_year ────────────────────────────────────────────────────────────
    all_families = sorted(set(
        fam_lookup(e.get('engine_type') or '')
        for e in engines_all.values()
        if fam_lookup(e.get('engine_type') or '')
    ))

    def _family_year_matrix(metric_fn):
        """Generic Family × Year aggregation helper. metric_fn(eng, cell) mutates cell."""
        data, years_set = {}, set()
        for eng in engines_all.values():
            fam  = fam_lookup(eng.get('engine_type') or '')
            if not fam: continue
            year = (eng.get('tat_start') or '')[:4]
            if not year.isdigit(): continue
            years_set.add(year)
            data.setdefault(fam, {}).setdefault(year, {})
            metric_fn(eng, data[fam][year])
        return data, sorted(years_set)

    # P7 by Family × Year
    def _p7_fn(eng, cell):
        cell.setdefault('p7_count', 0)
        cell.setdefault('p7_cd', 0.0)
        cell.setdefault('engines', 0)
        claims = eng.get('claims_p7') or []
        cell['engines']  += 1
        cell['p7_count'] += len(claims)
        cell['p7_cd']    += sum(c.get('delay_cd', 0) for c in claims)
    p7_data, p7_years = _family_year_matrix(_p7_fn)
    for fam in p7_data:
        for yr in p7_data[fam]:
            p7_data[fam][yr]['p7_cd'] = round(p7_data[fam][yr]['p7_cd'], 1)

    # Cycle Δ by Family × Year (Gesamt I→SA)
    def _cyc_fn(eng, cell):
        cell.setdefault('delta', [])
        cell.setdefault('ist', [])
        cell.setdefault('soll', [])
        cyc = next((c for c in (eng.get('cycles_summary') or []) if c['name'] == 'Gesamt'), None)
        if cyc:
            if cyc.get('delta_days') is not None: cell['delta'].append(cyc['delta_days'])
            if cyc.get('ist_days')   is not None: cell['ist'].append(cyc['ist_days'])
            if cyc.get('soll_days')  is not None: cell['soll'].append(cyc['soll_days'])
    cyc_raw, cyc_years = _family_year_matrix(_cyc_fn)
    cyc_data = {}
    for fam in cyc_raw:
        cyc_data[fam] = {}
        for yr, c in cyc_raw[fam].items():
            nd, ni, ns = len(c['delta']), len(c['ist']), len(c['soll'])
            cyc_data[fam][yr] = {
                'avg_delta_d': round(sum(c['delta'])/nd, 1) if nd else None,
                'avg_ist_d':   round(sum(c['ist'])  /ni, 1) if ni else None,
                'avg_soll_d':  round(sum(c['soll']) /ns, 1) if ns else None,
                'n': nd,
            }

    # Incidents by Family × Year (creation year)
    inc_data = {}
    inc_years_set = set()
    for eng in engines_all.values():
        fam = fam_lookup(eng.get('engine_type') or '')
        if not fam: continue
        for inc in (eng.get('incidents_c3') or []):
            co   = inc.get('created_on', '')
            year = co[:4] if len(co) >= 10 else ''      # ISO: YYYY-MM-DD
            if not year.isdigit(): continue
            inc_years_set.add(year)
            inc_data.setdefault(fam, {}).setdefault(year, {
                'incidents': 0, 'baustopps': 0, 'kritisch': 0,
                'res_h': [], 'engine_set': set()
            })
            cell = inc_data[fam][year]
            cell['incidents'] += 1
            cell['engine_set'].add(eng.get('project', ''))
            if inc['criticality'] == 'Baustopp':  cell['baustopps'] += 1
            elif inc['criticality'] == 'Kritisch': cell['kritisch']  += 1
            if inc.get('resolution_h') is not None:
                cell['res_h'].append(inc['resolution_h'])
    inc_out = {}
    for fam in inc_data:
        inc_out[fam] = {}
        for yr, cell in inc_data[fam].items():
            n_eng = len(cell['engine_set'])
            inc_out[fam][yr] = {
                'incidents':         cell['incidents'],
                'baustopps':         cell['baustopps'],
                'kritisch':          cell['kritisch'],
                'engines_affected':  n_eng,
                'baustopps_per_eng': round(cell['baustopps'] / n_eng, 2) if n_eng else 0,
                'median_res_h':      _median(cell['res_h']),
            }

    # B3/TS Probability by Baustopp-Bucket
    def _inc_to_iso(d):
        return d if d and len(d) >= 10 and d[4] == '-' else ''

    def _bs_bucket(n):
        if n == 0: return '0'
        if n <= 2: return '1–2'
        if n <= 5: return '3–5'
        return '>5'

    b3_rows = []
    for project, eng in engines_all.items():
        ms_t, ms_p = eng.get('milestones_zmmt', {}), eng.get('milestones_zmmp', {})
        soll_b3, ist_b3 = ms_t.get('B3', ''), ms_p.get('B3', '')
        soll_ts, ist_ts = ms_t.get('TS', ''), ms_p.get('TS', '')
        if not soll_b3: continue
        bs_before = sum(
            1 for i in (incidents_all.get(project) or [])
            if i.get('criticality') == 'Baustopp'
            and _inc_to_iso(i.get('created_on', '')) < soll_b3
            and _inc_to_iso(i.get('created_on', '')) != ''
        )
        b3_rows.append({
            'bs_bucket':  _bs_bucket(bs_before),
            'b3_on_time': (ist_b3 <= soll_b3) if (soll_b3 and ist_b3) else None,
            'ts_on_time': (ist_ts <= soll_ts) if (soll_ts and ist_ts) else None,
        })

    def _agg_b3(subset):
        b3k  = [r for r in subset if r['b3_on_time'] is not None]
        b3ok = [r for r in b3k if r['b3_on_time']]
        b3lt = [r for r in b3k if not r['b3_on_time']]
        ts_ok  = [r for r in b3ok if r['ts_on_time'] is not None]
        ts_lt  = [r for r in b3lt if r['ts_on_time'] is not None]
        return {
            'n': len(subset),
            'b3_on_time_rate':      round(len(b3ok)/len(b3k), 3)               if b3k else None,
            'ts_rate_if_b3_ok':     round(sum(1 for r in ts_ok  if r['ts_on_time'])/len(ts_ok),  3) if ts_ok  else None,
            'ts_rate_if_b3_late':   round(sum(1 for r in ts_lt  if r['ts_on_time'])/len(ts_lt),  3) if ts_lt  else None,
        }

    BKTS = ['0', '1–2', '3–5', '>5']
    b3_probability = {
        'interpretation': (
            "b3_on_time_rate = Anteil Engines bei denen B3 pünktlich war. "
            "ts_rate_if_b3_ok = Wahrscheinlichkeit TS pünktlich gegeben B3 pünktlich. "
            "ts_rate_if_b3_late = Wahrscheinlichkeit TS pünktlich gegeben B3 zu spät. "
            "Zeigt wie stark ein B3-Verzug auf den TS-Termin durchschlägt."
        ),
        'by_baustopp_bucket': {
            b: _agg_b3([r for r in b3_rows if r['bs_bucket'] == b]) for b in BKTS
        },
    }

    # B2→B3 Delay Propagation
    def _bkt_b2(d):
        if d <= 0:  return '≤0d (pünktlich)'
        if d <= 5:  return '1–5d'
        if d <= 14: return '6–14d'
        return '>14d'

    B2_BKTS = ['≤0d (pünktlich)', '1–5d', '6–14d', '>14d']
    b2_rows = []
    for project, eng in engines_all.items():
        ms_t, ms_p = eng.get('milestones_zmmt', {}), eng.get('milestones_zmmp', {})
        sb2, ib2 = ms_t.get('B2',''), ms_p.get('B2','')
        sb3, ib3 = ms_t.get('B3',''), ms_p.get('B3','')
        if not (sb2 and ib2 and sb3 and ib3): continue
        d_b2 = _date_delta_days(sb2, ib2)
        d_b3 = _date_delta_days(sb3, ib3)
        if d_b2 is None or d_b3 is None: continue
        b2_rows.append({'delta_b2': d_b2, 'delta_b3': d_b3,
                        'family': _engine_family(eng.get('engine_type',''))})

    b2_b3_propagation = {
        'interpretation': (
            "Zeigt wie viel eines B2-Delays auf B3 durchschlägt. "
            "b3_on_time_rate = Anteil Engines mit pünktlichem B3 (Δ B3 ≤ 0) je B2-Delay-Bucket. "
            "avg_delta_b3_d = durchschnittliche B3-Verzögerung in Tagen je Bucket."
        ),
        'by_b2_delay_bucket': [],
    }
    for b in B2_BKTS:
        sub = [r for r in b2_rows if _bkt_b2(r['delta_b2']) == b]
        b3_ok  = [r for r in sub if r['delta_b3'] <= 0]
        deltas = [r['delta_b3'] for r in sub]
        b2_b3_propagation['by_b2_delay_bucket'].append({
            'bucket':        b,
            'n':             len(sub),
            'b3_on_time_rate': round(len(b3_ok)/len(sub), 3) if sub else None,
            'avg_delta_b3_d':  round(sum(deltas)/len(deltas), 1) if deltas else None,
        })

    return {
        '_meta': {
            'export_date':       datetime.date.today().isoformat(),
            'source':            'SAP ZLSGS Dataexport × Claimexport × ServiceNow Incidents',
            'years_included':    EXPORT_YEARS,
            'year_attribution':  _JSON_GLOSSARY['vintage_year_caveat'],
            'caveats_per_year':  caveats,
            'note_cross_year':   (
                'Abschnitte unter cross_year ignorieren den Jahresfilter und zeigen alle Jahrgänge. '
                'incidents_by_family_year verwendet das Incident-Erstelldatum, '
                'alle anderen Abschnitte das TAT-Start-Jahr der Engine.'
            ),
        },
        '_glossary': {k: v for k, v in _JSON_GLOSSARY.items() if k != 'vintage_year_caveat'},
        'by_year':   by_year,
        'cross_year': {
            'p7_claims_by_family_year': {
                'families': all_families,
                'years':    p7_years,
                'data':     p7_data,
            },
            'cycle_gesamt_delta_by_family_year': {
                'description': 'Ø Δ Gesamt-TAT (Ist−Soll in Tagen) je Engine-Familie je TAT-Start-Jahrgang.',
                'families': all_families,
                'years':    cyc_years,
                'data':     cyc_data,
            },
            'incidents_by_family_year': {
                'description': 'ServiceNow Incidents Cycle 3 je Familie je Incident-Erstelljahr (nicht TAT-Start-Jahr).',
                'families': all_families,
                'years':    sorted(inc_years_set),
                'data':     inc_out,
            },
            'b3_ts_probability':  b3_probability,
            'b2_b3_propagation':  b2_b3_propagation,
        },
    }


@app.route('/api/dashboard/export-json')
def api_dashboard_export_json():
    data  = _build_dashboard_json()
    fname = f'Dashboard_Export_{datetime.date.today().isoformat()}.json'
    body  = json.dumps(data, ensure_ascii=False, indent=2, default=str)
    return Response(
        body,
        mimetype='application/json',
        headers={'Content-Disposition': f'attachment; filename="{fname}"'}
    )


# ── Dashboard Export ──────────────────────────────────────────────────────────

def _xl_header(ws, cols, row=1):
    """Write a styled header row."""
    fill = PatternFill('solid', fgColor='1a5fa8')
    font = Font(bold=True, color='FFFFFF', size=10)
    for c, label in enumerate(cols, 1):
        cell = ws.cell(row=row, column=c, value=label)
        cell.fill = fill
        cell.font = font
        cell.alignment = Alignment(horizontal='center', wrap_text=True)

def _xl_autowidth(ws, min_w=8, max_w=40):
    for col in ws.columns:
        width = max(len(str(cell.value or '')) for cell in col)
        ws.column_dimensions[get_column_letter(col[0].column)].width = min(max(width + 2, min_w), max_w)

def _xl_zebra(ws, start_row, end_row):
    fill = PatternFill('solid', fgColor='EFF6FF')
    for r in range(start_row, end_row + 1):
        if r % 2 == 0:
            for cell in ws[r]:
                cell.fill = fill

EXPORT_YEARS = ['2024', '2025', '2026']

@app.route('/api/dashboard/export-xlsx')
def api_dashboard_export():
    engines_all, _ = dash_data()
    engines_all    = _real_shop_visits(engines_all)
    incidents_all  = get_incidents()

    wb = openpyxl.Workbook()
    wb.remove(wb.active)

    # ── Sheet 1: Jahres-KPIs ─────────────────────────────────────────────────
    ws = wb.create_sheet('Jahres-KPIs')
    cols = ['Jahr', 'Engines', 'Ø Endverzug (d)', 'Median Endverzug (d)',
            'Ø MTU Delay CD (Σ Verschiebungen)', 'OTD Green %', 'OTD Yellow %',
            'OTD Red %', 'Ø Gesamt-TAT Soll (d)', 'Ø Gesamt-TAT Ist (d)', 'Ø Δ Gesamt (d)',
            'Ø C1 Soll', 'Ø C1 Ist', 'Ø C2 Soll', 'Ø C2 Ist', 'Ø C3 Soll', 'Ø C3 Ist']
    _xl_header(ws, cols)
    r = 2
    for yr in EXPORT_YEARS:
        eng = _filter_engines(engines_all, yr)
        if not eng: continue
        vals = list(eng.values())
        avg = lambda key: round(sum(e.get(key,0) or 0 for e in vals)/len(vals), 1) if vals else ''
        otd_counts = {k: sum(1 for e in vals if e.get('otd')==k) for k in ('Green','Yellow','Red')}
        n = len(vals)
        def cyc_avg(cyc, field):
            def _get(e):
                return next((c.get(field) for c in (e.get('cycles_summary') or [])
                             if c.get('name') == cyc), None)
            vs = [v for e in vals for v in [_get(e)] if v is not None]
            return round(sum(vs)/len(vs),1) if vs else ''
        end = _end_delay_stats(vals)
        ws.append([yr, n, end['avg_end_delay_cd'] if end['end_delay_n'] else '',
                   end['median_end_delay_cd'] if end['end_delay_n'] else '',
                   avg('mtu_delay_cd'),
                   round(otd_counts['Green']/n*100,1), round(otd_counts['Yellow']/n*100,1), round(otd_counts['Red']/n*100,1),
                   cyc_avg('Gesamt','soll'), cyc_avg('Gesamt','ist'), cyc_avg('Gesamt','delta'),
                   cyc_avg('Cycle 1','soll'), cyc_avg('Cycle 1','ist'),
                   cyc_avg('Cycle 2','soll'), cyc_avg('Cycle 2','ist'),
                   cyc_avg('Cycle 3','soll'), cyc_avg('Cycle 3','ist')])
        r += 1
    _xl_zebra(ws, 2, r-1); _xl_autowidth(ws)

    # ── Sheet 2: OTD nach Familie × Jahr ─────────────────────────────────────
    ws2 = wb.create_sheet('OTD nach Familie')
    cols2 = ['Familie', 'Jahr', 'Engines', 'Green', 'Yellow', 'Red', 'Green %', 'Red %']
    _xl_header(ws2, cols2)
    r2 = 2
    families = sorted(set(_engine_family(e.get('engine_type','')) for e in engines_all.values()))
    for fam in families:
        for yr in EXPORT_YEARS:
            eng = {p: e for p, e in _filter_engines(engines_all, yr).items()
                   if _engine_family(e.get('engine_type','')) == fam}
            if not eng: continue
            vals = list(eng.values())
            n = len(vals)
            g = sum(1 for e in vals if e.get('otd')=='Green')
            y = sum(1 for e in vals if e.get('otd')=='Yellow')
            red = sum(1 for e in vals if e.get('otd')=='Red')
            ws2.append([fam, yr, n, g, y, red,
                        round(g/n*100,1), round(red/n*100,1)])
            r2 += 1
    _xl_zebra(ws2, 2, r2-1); _xl_autowidth(ws2)

    # ── Sheet 3: P7 Claims nach Familie × Jahr ───────────────────────────────
    ws3 = wb.create_sheet('P7 nach Familie')
    cols3 = ['Familie', 'Jahr', 'Engines mit P7', 'Gesamt P7-Claims', 'Ø P7-CD je Engine',
             'Ø P7-CD C1', 'Ø P7-CD C2', 'Ø P7-CD C3']
    _xl_header(ws3, cols3)
    r3 = 2
    for fam in families:
        for yr in EXPORT_YEARS:
            eng = {p: e for p, e in _filter_engines(engines_all, yr).items()
                   if _engine_family(e.get('engine_type','')) == fam}
            if not eng: continue
            vals = list(eng.values())
            with_p7 = [e for e in vals if e.get('claims_p7')]
            total_p7 = sum(len(e.get('claims_p7',[])) for e in vals)
            avg_cd = round(sum(e.get('claim_delay_cd',0) for e in vals)/len(vals),1)
            def cyc_p7(cyc):
                cs = [e.get('cycles_p7',{}).get(cyc,{}).get('cd',0) or 0 for e in vals]
                return round(sum(cs)/len(cs),1)
            ws3.append([fam, yr, len(with_p7), total_p7, avg_cd,
                        cyc_p7('Cycle 1'), cyc_p7('Cycle 2'), cyc_p7('Cycle 3')])
            r3 += 1
    _xl_zebra(ws3, 2, r3-1); _xl_autowidth(ws3)

    # ── Sheet 4: Incidents nach Familie × Jahr ───────────────────────────────
    ws4 = wb.create_sheet('Incidents nach Familie')
    cols4 = ['Familie', 'Jahr', 'Engines mit Incidents', 'Incidents gesamt',
             'Baustopps', 'Kritisch', 'Nicht kritisch',
             'Baustopps / Engine', 'Median Lösungszeit (h)']
    _xl_header(ws4, cols4)
    r4 = 2

    def _median(lst):
        s = sorted(x for x in lst if x is not None)
        if not s: return None
        m = len(s)//2
        return s[m] if len(s)%2 else round((s[m-1]+s[m])/2,1)

    for fam in families:
        for yr in EXPORT_YEARS:
            eng = {p: e for p, e in _filter_engines(engines_all, yr).items()
                   if _engine_family(e.get('engine_type','')) == fam}
            if not eng: continue
            vals = list(eng.values())
            all_inc = []
            for e in vals:
                for inc in e.get('incidents_c3', []):
                    co = inc.get('created_on','')
                    inc_yr = co[:4] if len(co) >= 10 else ''
                    if inc_yr == yr:
                        all_inc.append(inc)
            if not all_inc: continue
            n_eng = sum(1 for e in vals if any(
                (i.get('created_on','') or '')[:4] == yr for i in e.get('incidents_c3',[])))
            bs  = sum(1 for i in all_inc if i.get('criticality')=='Baustopp')
            kr  = sum(1 for i in all_inc if i.get('criticality')=='Kritisch')
            nk  = sum(1 for i in all_inc if i.get('criticality')=='Nicht kritisch')
            res = [i.get('resolution_h') for i in all_inc if i.get('resolution_h') is not None]
            ws4.append([fam, yr, n_eng, len(all_inc), bs, kr, nk,
                        round(bs/max(len(vals),1),2), _median(res)])
            r4 += 1
    _xl_zebra(ws4, 2, r4-1); _xl_autowidth(ws4)

    # ── Sheet 5: Engine-Typen Übersicht ──────────────────────────────────────
    ws5 = wb.create_sheet('Engine-Typen')
    cols5 = ['Engine Type', 'Familie', 'Jahr', 'Engines', 'Ø Gesamt-TAT Ist (d)',
             'Ø Endverzug (d)', 'Ø MTU Delay CD (Σ)', 'OTD Red %', 'Ø P7-CD']
    _xl_header(ws5, cols5)
    r5 = 2
    types = sorted(set(e.get('engine_type','') for e in engines_all.values()))
    for et in types:
        fam = _engine_family(et)
        for yr in EXPORT_YEARS:
            eng = {p: e for p, e in _filter_engines(engines_all, yr).items()
                   if e.get('engine_type','') == et}
            if not eng: continue
            vals = list(eng.values())
            n = len(vals)
            tat = [v for e in vals
                   for v in [next((c.get('ist') for c in (e.get('cycles_summary') or [])
                                   if c.get('name') == 'Gesamt'), None)]
                   if v is not None]
            red = sum(1 for e in vals if e.get('otd')=='Red')
            ws5.append([et, fam, yr, n,
                        round(sum(tat)/len(tat),1) if tat else '',
                        _avg1([e['end_delay_cd'] for e in vals if e.get('end_delay_cd') is not None]) if any(e.get('end_delay_cd') is not None for e in vals) else '',
                        round(sum(e.get('mtu_delay_cd',0) for e in vals)/n,1),
                        round(red/n*100,1),
                        round(sum(e.get('claim_delay_cd',0) for e in vals)/n,1)])
            r5 += 1
    _xl_zebra(ws5, 2, r5-1); _xl_autowidth(ws5)

    buf = io.BytesIO()
    wb.save(buf)
    buf.seek(0)
    fname = f'Dashboard_Export_{datetime.date.today().isoformat()}.xlsx'
    return send_file(buf, as_attachment=True, download_name=fname,
                     mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')


# ── Dashboard HTML ────────────────────────────────────────────────────────────

DASHBOARD_HTML = """<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SAP Dashboard — Delay Analyse</title>
<script src="/static/chart.umd.min.js"></script>
<!--SAP_BASE-->
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }

body { font-family: var(--font); background: var(--bg); color: var(--text); }

/* Nav */








/* Layout */
main { padding: 24px clamp(16px,3vw,36px) 72px; max-width: 1680px; margin: 0 auto; }

/* Abschnittsraster: sectionize() macht aus jeder Karte eine .sap-sec (links Kopf, rechts Inhalt) */
main > .grid-1, main > .grid-2 { display: block; margin: 0; }
main .card.sap-sec { background: transparent; border: 0; border-top: 1px solid var(--line-2); border-radius: 0;
                     box-shadow: none; overflow: visible; scroll-margin-top: calc(var(--ph-stick, 0px) + 72px); }
main .card.sap-sec .sap-sec-head { top: calc(var(--ph-stick, 0px) + 76px); }   /* unter Kopf + Jahrgangsleiste */
main .card.sap-sec .sap-sec-head h2 { font-size: 20px; font-weight: 500; }
main .card.sap-sec .sap-sec-head .card-q { padding: 0; background: none; border: 0; color: var(--ink-2);
                     font-size: 13px; line-height: 1.5; }
main .card.sap-sec .sap-sec-head .card-q b { color: var(--ink); font-weight: 600; }
main .card.sap-sec .sap-sec-ctl .hint { flex-basis: 100%; font-size: 11px; color: var(--ink-3); }
main .card.sap-sec .sap-sec-ctl div { flex-wrap: wrap !important; }
main .card.sap-sec > .sap-sec-body { background: var(--surface); border: 1px solid var(--line); border-radius: var(--r-m);
                     padding: 18px; }
main .card.sap-sec > .sap-sec-body > .card-help { margin: -18px -18px 16px; border-radius: var(--r-m) var(--r-m) 0 0; }
main .card.sap-sec > .sap-sec-body { overflow-x: auto; }      /* Sicherheitsnetz für breite Tabellen */
@media (max-width: 1600px) { main .sap-sec .bal-grid, main .sap-sec .bal-bs .grid { grid-template-columns: 1fr; } }
.sec-index { display: flex; flex-wrap: wrap; gap: 6px 18px; align-items: baseline; margin: 4px 0 20px; font-size: 13px; }
.sec-index .eyebrow { font-size: 10px; color: var(--ink-3); }
.sec-index a { color: var(--ink-2); text-decoration: none; border-bottom: 1px solid var(--line-2); padding-bottom: 1px; cursor: pointer; }
.sec-index a:hover { color: var(--ink); border-bottom-color: var(--ink); }
.sec-index a span { font-size: 10px; color: var(--ink-3); margin-left: 3px; font-variant-numeric: tabular-nums; }

/* KPI bar */
.kpi-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 1px; margin-bottom: 24px;
           background: var(--line); border: 1px solid var(--line); border-radius: var(--r-m); overflow: hidden; }
.kpi { background: var(--surface); padding: 14px 16px 12px; }
.kpi .label { font-size: 10px; color: var(--muted); text-transform: uppercase; letter-spacing: .14em; }
.kpi .value { font-size: 28px; font-weight: 500; letter-spacing: -.02em; line-height: 1.1; margin-top: 6px;
              font-variant-numeric: tabular-nums; }
.kpi .sub   { font-size: 11px; color: var(--muted); margin-top: 2px; }
.kpi.red    .value { color: var(--red); }
.kpi.yellow .value { color: var(--yellow); }
.kpi.green  .value { color: var(--green); }
.kpi.blue   .value { color: var(--blue); }

/* Grid */
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; margin-bottom: 20px; }
.grid-1 { margin-bottom: 20px; }
@media (max-width: 900px) { .grid-2 { grid-template-columns: 1fr; } }
/* Workscope-Mix über die Zeit + Häufungen */
.wsm-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; }
@media (max-width: 1000px) { .wsm-grid { grid-template-columns: 1fr; } }
.wsm-sub { font-size: 11px; font-weight: 700; margin-bottom: 2px; }
.wsm-note { font-size: 11px; color: var(--muted); margin-bottom: 6px; }
.wsm-dec { margin-top: 14px; padding: 12px 14px; background: var(--hover); border: 1px solid var(--border); border-radius: var(--r-m);
           font-size: 13px; line-height: 1.6; }
.wsm-dec .parts { display: flex; gap: 10px; flex-wrap: wrap; margin: 8px 0 4px; }
.wsm-dec .pt { background: var(--surface); border: 1px solid var(--border); border-radius: var(--r-m); padding: 6px 12px; min-width: 150px; }
.wsm-dec .pt b { font-size: 15px; display: block; font-variant-numeric: tabular-nums; }
.wsm-dec .pt span { font-size: 11px; color: var(--muted); }
table.cl { border-collapse: collapse; width: 100%; font-size: 13px; }
table.cl th { text-align: left; font-size: 11px; color: var(--muted); font-weight: 600; padding: 6px 8px;
              border-bottom: 1px solid var(--border); white-space: nowrap; }
table.cl td { padding: 6px 8px; border-top: 1px solid rgba(0,0,0,.05); vertical-align: middle; }
table.cl td.n { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
table.cl tr.row { cursor: pointer; }
table.cl tr.row:hover td { background: var(--hover); }
.cl-badge { font-size: 11px; font-weight: 700; padding: 2px 8px; border-radius: var(--r-m); white-space: nowrap; }
.cl-badge.alarm { background: var(--crit-bg); color: var(--crit); }
.cl-badge.watch { background: var(--warn-bg); color: var(--warn); }
.cl-badge.engine { background: var(--hover); color: var(--muted); border: 1px solid var(--border); }
.cl-detail td { background: var(--hover); font-size: 11px; }
.cl-detail .by { display: flex; gap: 22px; flex-wrap: wrap; margin-bottom: 8px; }
.cl-detail .by div { min-width: 170px; }
.cl-detail .by b { display: block; font-size: 11px; color: var(--muted); text-transform: uppercase; letter-spacing: .4px; }
.cl-detail table { width: 100%; border-collapse: collapse; }
.cl-detail table td { padding: 3px 6px; border-top: 1px solid rgba(0,0,0,.05); background: transparent; }
.cl-dq { margin-top: 10px; font-size: 11px; color: var(--warn); }
/* SLU-Bilanz */
.bal-key { font-size: 13px; line-height: 1.6; margin-bottom: 12px; }
.bal-key b.up { color: var(--red); }
.bal-grid { display: grid; grid-template-columns: 1fr 1.2fr; gap: 20px; margin-top: 16px; }
.bal-bs { margin-top: 18px; padding-top: 14px; border-top: 1px solid var(--border); }
.bal-bs .grid { display: grid; grid-template-columns: 1fr 1.3fr; gap: 20px; margin-top: 8px; }
@media (max-width: 1100px) { .bal-bs .grid { grid-template-columns: 1fr; } }
.bal-cov { font-size: 11px; color: var(--warn); margin-top: 4px; }
table.bal td.hot { color: var(--red); font-weight: 700; }
table.bal tr.fam td { border-top: 2px solid var(--border); }
.age-miss { padding: 10px 2px; font-size: 13px; line-height: 1.7; color: var(--ink); }
.age-miss code { background: var(--hover); padding: 1px 5px; border-radius: var(--r-s); }
@media (max-width: 1100px) { .bal-grid { grid-template-columns: 1fr; } }
table.bal { border-collapse: collapse; width: 100%; font-size: 13px; }
table.bal th { text-align: right; font-size: 11px; color: var(--muted); font-weight: 600; padding: 5px 7px;
               border-bottom: 1px solid var(--border); white-space: nowrap; }
table.bal th:first-child, table.bal td:first-child { text-align: left; }
table.bal td { padding: 5px 7px; border-top: 1px solid rgba(0,0,0,.05); text-align: right;
               font-variant-numeric: tabular-nums; white-space: nowrap; }
table.bal td.neg { color: var(--red); font-weight: 700; }
table.bal td.pos { color: var(--green); }
table.bal tr.ph td { background: var(--hover); font-weight: 700; font-size: 11px; color: var(--muted); text-align: left; }
.bal-bar { display: inline-block; height: 10px; background: var(--info); border-radius: var(--r-s); vertical-align: middle; opacity: .75; }
.bal-stale { margin-top: 14px; font-size: 13px; }
.bal-stale summary { cursor: pointer; color: var(--warn); font-weight: 600; }
.bal-stale table { margin-top: 8px; }

/* Card */
.card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--r-m); box-shadow: var(--shadow); overflow: hidden; }
.card-header { padding: 12px 18px; border-bottom: 1px solid var(--border); display: flex; align-items: center; justify-content: space-between; }
.card-header h2 { font-size: 13px; font-weight: 700; }
.card-header .hint { font-size: 11px; color: var(--muted); }
.help-btn { border: 1px solid var(--border); background: var(--surface); color: var(--muted); border-radius: 50%;
            width: 17px; height: 17px; font-size: 11px; line-height: 1; cursor: pointer; margin-left: 7px; padding: 0; }
.help-btn:hover { background: var(--bg); color: var(--text); }
.card-q { padding: 8px 18px; background: rgba(26,95,168,.045); border-bottom: 1px solid var(--border);
          font-size: 11px; color: var(--text); line-height: 1.5; }
.card-q b { color: var(--blue); }
.card-help { padding: 10px 18px 12px; background: rgba(26,95,168,.045); border-bottom: 1px solid var(--border);
             font-size: 11px; color: var(--muted); line-height: 1.6; display: none; }
.card-help.on { display: block; }
.card-help .lbl { display: inline-block; min-width: 96px; font-weight: 700; color: var(--text); }
.card-help .warn .lbl { color: var(--red); }
.card-q.sub, .card-help.sub { border: none; border-radius: var(--r-s); padding: 7px 12px; margin-bottom: 8px; }
#glossary { display: none; }
#glossary.on { display: block; }
#glossary dl { display: grid; grid-template-columns: 150px 1fr; gap: 5px 14px; font-size: 11px; line-height: 1.5; }
#glossary dt { font-weight: 700; }
#glossary dd { color: var(--muted); }
.card-body { padding: 18px; }
.chart-wrap { position: relative; }

/* Toggle */
.toggle-group { display: flex; gap: 4px; }
.toggle-group button { font-size: 10px; padding: 3px 8px; border: 1px solid var(--border); border-radius: var(--r-s);
                       background: var(--bg); cursor: pointer; color: var(--muted); }
.toggle-group button.on { background: var(--text); color: var(--surface); border-color: var(--text); }

/* Heatmap table */
.heatmap-scroll { overflow-x: auto; }
table.heatmap { border-collapse: collapse; font-size: 11px; width: 100%; }
table.heatmap th { padding: 6px 10px; text-align: left; font-weight: 600; color: var(--muted);
                   background: var(--bg); position: sticky; top: 0; white-space: nowrap; }
table.heatmap td { padding: 5px 10px; border-top: 1px solid var(--border); white-space: nowrap; }
table.heatmap tr:hover td { background: var(--hover); }
.heat-cell { display: inline-block; padding: 2px 8px; border-radius: var(--r-s); font-weight: 700; font-size: 13px;
             box-shadow: inset 0 0 0 1px rgba(0,0,0,.09); }

/* Heatmap rank list */
.heat-rank { display: flex; flex-direction: column; gap: 3px; }
.heat-row { display: grid; grid-template-columns: 150px 250px 1fr 72px 52px; gap: 10px;
            align-items: center; font-size: 11px; padding: 2px 0; }
.heat-row .hr-et { font-weight: 700; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.heat-row .hr-ws { color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.heat-row .hr-bar-wrap { background: rgba(0,0,0,.04); border-radius: var(--r-s); height: 15px; position: relative; }
.heat-row .hr-bar { height: 100%; border-radius: var(--r-s); }
.heat-row .hr-cd { text-align: right; font-weight: 700; font-variant-numeric: tabular-nums; }
.heat-row .hr-n  { text-align: right; color: var(--muted); font-variant-numeric: tabular-nums; }
@media (max-width: 900px) { .heat-row { grid-template-columns: 110px 1fr 56px 46px; }
                            .heat-row .hr-bar-wrap { display: none; } }

/* Workscope mix */
.ws-type { border-top: 1px solid var(--border); }
.ws-type:first-child { border-top: none; }
.ws-head { display: grid; grid-template-columns: 170px 1fr 132px 74px; gap: 10px; align-items: center;
           padding: 7px 0; cursor: pointer; font-size: 13px; }
.ws-head:hover { background: var(--hover); }
.ws-head .wh-et { font-weight: 700; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.ws-head .wh-n  { text-align: right; color: var(--muted); font-size: 11px; font-variant-numeric: tabular-nums;
                   white-space: nowrap; }
.ws-head .wh-cd { text-align: right; font-weight: 700; font-size: 11px; font-variant-numeric: tabular-nums; }
.ws-stack { display: flex; height: 16px; border-radius: var(--r-s); overflow: hidden; background: rgba(0,0,0,.05); }
.ws-seg { height: 100%; }
.ws-seg + .ws-seg { border-left: 2px solid var(--surface); }
.ws-detail { display: none; padding: 2px 0 12px 0; }
.ws-type.open .ws-detail { display: block; }
.ws-type.open .ws-head { background: var(--hover); }
.ws-drow { display: grid; grid-template-columns: 170px 1fr 56px 52px 62px 62px; gap: 10px;
           align-items: center; font-size: 11px; padding: 2px 0; }
.ws-drow .wd-ws { grid-column: 1 / 3; color: var(--text); white-space: nowrap;
                  overflow: hidden; text-overflow: ellipsis; }
.ws-drow .wd-n, .ws-drow .wd-sh, .ws-drow .wd-cd, .ws-drow .wd-mx {
    text-align: right; font-variant-numeric: tabular-nums; }
.ws-drow .wd-sh, .ws-drow .wd-mx { color: var(--muted); }
.ws-drow.hdr { color: var(--muted); font-size: 10px; text-transform: uppercase; letter-spacing: .4px;
               border-bottom: 1px solid var(--border); padding-bottom: 3px; margin-bottom: 3px; }
.ws-dummy { color: var(--muted); font-size: 10px; margin-top: 6px; }

/* Workscope mix — two-vintage comparison */
.ws-type.cmp .ws-head { align-items: center; }
.ws-bars { display: flex; flex-direction: column; gap: 3px; }
.ws-bar-line { display: grid; grid-template-columns: 52px 1fr 44px; gap: 6px; align-items: center; }
.ws-bar-lbl { font-size: 10px; color: var(--muted); text-align: right; white-space: nowrap; }
.ws-bar-n   { font-size: 10px; color: var(--muted); font-variant-numeric: tabular-nums; }
.ws-bars .ws-stack { height: 13px; }
.ws-stack-empty { background: repeating-linear-gradient(45deg, rgba(0,0,0,.04) 0 4px, transparent 4px 8px); }
.ws-drow.cmp { grid-template-columns: 210px 46px 52px 46px 52px 62px 58px 66px; }
.ws-drow.cmp .wd-ws { grid-column: auto; }
.ws-drow .wd-dl { text-align: right; font-variant-numeric: tabular-nums; color: var(--muted); }
.ws-drow .wd-dl.up   { color: var(--red); }
.ws-drow .wd-dl.down { color: var(--green); }
.ws-head .wh-cd .up   { color: var(--red); }
.ws-head .wh-cd .down { color: var(--green); }
.ws-head .wh-cd .nocmp { color: var(--muted); font-weight: 600; }

/* Descriptions */
.desc-list { display: flex; flex-direction: column; gap: 4px; max-height: 420px; overflow-y: auto; }
.desc-row { display: flex; align-items: center; gap: 10px; font-size: 13px; padding: 4px 0; }
.desc-bar-wrap { flex: 1; background: var(--line-2); border-radius: var(--r-s); height: 8px; overflow: hidden; }
.desc-bar { height: 8px; border-radius: var(--r-s); background: var(--purple); }
.desc-text { min-width: 0; flex: 2; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.desc-count { font-size: 11px; color: var(--muted); white-space: nowrap; flex-shrink: 0; min-width: 60px; text-align: right; }
.desc-cd { font-size: 11px; color: var(--red); white-space: nowrap; flex-shrink: 0; min-width: 70px; text-align: right; }

/* Breakdown */
.bd-list { display: flex; flex-direction: column; gap: 6px; }
.bd-row { display: flex; align-items: center; gap: 10px; font-size: 13px; }
.bd-ms { font-weight: 700; font-family: var(--mono); width: 52px; flex-shrink: 0; }
.bd-label { color: var(--muted); flex: 1; font-size: 11px; }
.bd-bar-wrap { flex: 2; background: var(--line-2); border-radius: var(--r-s); height: 10px; overflow: hidden; }
.bd-bar { height: 10px; border-radius: var(--r-s); background: var(--red); }
.bd-count { font-size: 13px; font-weight: 700; width: 40px; text-align: right; flex-shrink: 0; }
.bd-cd { font-size: 11px; color: var(--muted); width: 70px; text-align: right; flex-shrink: 0; }

/* OTD donut */
.otd-legend { display: flex; gap: 16px; justify-content: center; margin-top: 12px; flex-wrap: wrap; }
.otd-item { display: flex; align-items: center; gap: 6px; font-size: 13px; }
.otd-dot { width: 10px; height: 10px; border-radius: 50%; }

.loading { color: var(--muted); font-size: 13px; text-align: center; padding: 32px; }

/* Year filter */
.year-bar { display: flex; align-items: center; gap: 6px; flex-wrap: wrap;
            background: var(--surface); border: 1px solid var(--border);
            border-radius: var(--r-m); padding: 10px 14px; margin-bottom: 20px;
            box-shadow: var(--shadow);
            position: sticky; top: calc(var(--ph-stick, 0px) + 8px); z-index: 90;
            box-shadow: 0 -12px 0 4px var(--paper); }   /* deckt den Spalt zum Kopf ab */
.year-bar .year-label { font-size: 11px; color: var(--muted); font-weight: 600;
                        text-transform: uppercase; letter-spacing: .5px; margin-right: 4px; }
.year-pill { font-size: 13px; padding: 4px 12px; border-radius: var(--r-pill);
             border: 1px solid var(--border); background: var(--bg);
             color: var(--muted); cursor: pointer; white-space: nowrap;
             transition: all .12s; font-weight: 500; }
.year-pill:hover { border-color: var(--ink-3); color: var(--text); }
.year-pill.on { background: var(--text); color: var(--surface); border-color: var(--text); font-weight: 600; }
.year-pill.running { border-color: var(--green); color: var(--green); }
.year-pill.running.on { background: var(--green); color: var(--surface); }
.year-pill.older { border-style: dashed; }

/* Nav */







</style>
<!--SAP_SHELL-->
</head>
<body>

<!--SAP_RAIL-->
<header class="sap-ph"><!--SAP_PH-->
<div class="ph-right">
  <a href="/api/dashboard/export-json" download class="btn sm">
    ⬇ Export JSON 2024–2026
  </a>
  <button class="btn sm" onclick="toggleGlossary()" id="glossary-btn">? Glossar</button>
  <button class="btn sm" onclick="toggleAllHelp()" id="help-btn-all">ⓘ Erklärungen</button>
  <button class="btn sm" onclick="reload()">↺ Neu laden</button>
<!--SAP_MARK-->
</div>
</header>

<main>
  <!-- Glossar -->
  <div id="glossary" class="card" style="margin-bottom:14px">
    <div class="card-header"><h2>Glossar — wiederkehrende Begriffe</h2>
      <span class="hint">gilt für alle Karten dieser Seite</span></div>
    <div class="card-body"><dl id="glossary-list"></dl></div>
  </div>

  <!-- Year Filter Bar -->
  <div class="year-bar">
    <span class="year-label">Jahrgang (TSP)</span>
    <div id="year-pills" style="display:flex;gap:6px;flex-wrap:wrap">
      <div class="loading" style="padding:0">Lade…</div>
    </div>
  </div>

  <!-- Datenalter -->
  <div id="stale-banner" style="display:none;margin:0 0 12px;padding:9px 14px;border-radius:var(--r-m);font-size:13px;line-height:1.5"></div>

  <!-- Vintage Warning -->
  <div id="vintage-warning" style="display:none;margin:0 0 12px;padding:9px 14px;background:var(--warn-bg);border:1px solid #f9a825;border-radius:var(--r-m);font-size:13px;color:var(--warn);line-height:1.5">
    <strong>⚠ Vorläufige Daten</strong> — <span id="vintage-warning-text"></span>
    Engines ohne Shop Acceptance (SA) haben noch keinen finalen MTU Delay CD. Der Ø-Delay und OTD-Status dieses Jahrgangs sind <em>noch nicht belastbar</em>.
  </div>

  <!-- KPI Row -->
  <div class="kpi-row" id="kpi-row">
    <div class="kpi"><div class="label">Shop Visits</div><div class="value" id="k-total">–</div><div class="sub" id="k-total-delta"></div></div>
    <div class="kpi red">  <div class="label">OTD Red</div>    <div class="value" id="k-red">–</div></div>
    <div class="kpi yellow"><div class="label">OTD Yellow</div> <div class="value" id="k-yellow">–</div></div>
    <div class="kpi green"> <div class="label">OTD Green</div>  <div class="value" id="k-green">–</div></div>
    <div class="kpi blue" title="ZMMP − ZMMT am Endtermin (TEC/SA/S): um so viele Kalendertage liegt die Fertigstellung hinter der Kundenzusage. Laufende Engines: Prognose aus dem aktuellen Plan. Nur Engines mit Kundenzusage (Budget TAT > 0) und plausiblem Endtermin (|Verzug| ≤ 730 Tage)."><div class="label">Ø Endverzug</div><div class="value" id="k-end">–</div><div class="sub" id="k-end-delta">Kalender-Tage</div></div>
    <div class="kpi" title="SAP MTU Delay CD: Summe der Claim-Tage über alle Meilensteine — kaskadierte Verzüge zählen mehrfach, daher deutlich größer als der Endverzug."><div class="label">Ø MTU Delay (Σ)</div><div class="value" id="k-delay">–</div><div class="sub" id="k-delay-delta">Kalender-Tage</div></div>
    <div class="kpi">       <div class="label">Ø OEM Delay</div><div class="value" id="k-oem">–</div><div class="sub" id="k-oem-delta">Kalender-Tage</div></div>
    <div class="kpi">       <div class="label">Ø Customer/Excusable</div><div class="value" id="k-cust">–</div><div class="sub" id="k-cust-delta">Kalender-Tage</div></div>
  </div>

  <!-- SLU-Bilanz: Eingang − Ausgang = Bestandsveränderung -->
  <div class="grid-1">
    <div class="card">
      <div class="card-header">
        <h2>SLU-Bilanz — kommt mehr Arbeit herein als hinausgeht?</h2>
        <div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">
          <span class="hint" id="bal-stand"></span>
          <select id="bal-fam" onchange="loadSluBalance()" style="font-size:11px;padding:3px 6px;border:1px solid var(--border);border-radius:var(--r-s)">
            <option value="">alle Familien</option></select>
          <div class="toggle-group" id="bal-gran">
            <button class="on" onclick="setBalGran('quarter')">Quartale</button>
            <button onclick="setBalGran('year')">Jahre</button>
          </div>
          <label style="font-size:11px;color:var(--muted);display:flex;gap:5px;align-items:center"
                 title="Offene Engines, die wahrscheinlich längst ausgeliefert sind (SA fehlt)">
            <input type="checkbox" id="bal-stale" onchange="renderSluBalance()"> Datenleichen im Bestand zeigen</label>
        </div>
      </div>
      <div class="card-body">
        <div class="bal-key" id="bal-key"><div class="loading">Lade…</div></div>
        <div style="height:280px"><canvas id="bal-chart"></canvas></div>
        <div class="bal-grid">
          <div><div class="wsm-sub">Wer staut? — letzte 12 Monate je Familie</div>
            <div class="wsm-note" id="bal-win"></div><div id="bal-who"></div></div>
          <div><div class="wsm-sub">Wo stehen die offenen Engines? — letzter Ist-Meilenstein</div>
            <div class="wsm-note">Bereinigter Bestand (ohne Datenleichen) · „steht seit" = Median der Tage seit diesem Meilenstein</div>
            <div id="bal-ms"></div></div>
        </div>
        <div class="bal-bs">
          <div class="wsm-sub">Was kostet ein Baustopp? — ausgelieferte Engines mit und ohne Baustopp</div>
          <div class="wsm-note"><span id="bal-bs-note"></span>
            <label id="bal-age-wrap" style="display:none;margin-left:10px">zusätzlich nach Alter schichten:
              <select id="bal-age" onchange="loadSluBalance()" style="font-size:11px;padding:2px 5px;border:1px solid var(--border);border-radius:var(--r-s)">
                <option value="">nein</option></select></label></div>
          <div class="bal-key" id="bal-bs-key" style="margin:6px 0 0"></div>
          <div class="grid"><div id="bal-bs-dose"></div><div id="bal-bs-strata"></div></div>
        </div>
        <div id="bal-stale-box" class="bal-stale"></div>
      </div>
    </div>
  </div>

  <!-- Engine-Alter -->
  <div class="grid-1">
    <div class="card">
      <div class="card-header">
        <h2>Engine-Alter — brauchen ältere Engines mehr Arbeit und Zeit?</h2>
        <div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">
          <span class="hint" id="age-hint"></span>
          <div class="toggle-group" id="age-metric"></div>
        </div>
      </div>
      <div class="card-body"><div id="age-out"><div class="loading">Lade…</div></div></div>
    </div>
  </div>

  <!-- Durchsatz: Eingang vs. Ausgang (Ist) -->
  <div class="grid-1">
    <div class="card">
      <div class="card-header">
        <h2>Durchsatz — Eingang vs. Ausgang (nur Ist-Daten)</h2>
        <div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">
          <span class="hint" id="tp-hint">Eingang = Actual Induction · Ausgang = Actual SA</span>
          <div class="toggle-group" id="tp-unit-toggle">
            <button class="on" onclick="setTpUnit('slu',this)">SLU</button>
            <button onclick="setTpUnit('count',this)">Engines</button>
            <button onclick="setTpUnit('hours',this)">Stunden</button>
          </div>
          <div class="toggle-group" id="tp-gran-toggle">
            <button class="on" onclick="setTpGran('month',this)">Monatlich</button>
            <button onclick="setTpGran('quarter',this)">Quartal</button>
          </div>
        </div>
      </div>
      <div class="card-body">
        <div id="tp-kpi-row" style="display:flex;gap:16px;flex-wrap:wrap;margin-bottom:18px;padding:12px 14px;background:var(--bg);border-radius:var(--r-m)">
          <div class="loading" style="padding:0">Lade…</div>
        </div>
        <div class="chart-wrap" style="height:380px"><canvas id="tp-chart"></canvas></div>
        <div id="tp-method" style="margin-top:12px;font-size:11px;line-height:1.6;color:var(--muted)"></div>
        <div id="tp-table-wrap" style="margin-top:20px;overflow-x:auto">
          <div class="loading">Lade…</div>
        </div>
      </div>
    </div>
  </div>

  <!-- Werkstattlast: SLU über die Verweildauer verteilt -->
  <div class="grid-1">
    <div class="card">
      <div class="card-header">
        <h2>Werkstattlast — SLU über die Verweildauer verteilt</h2>
        <div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">
          <span class="hint" id="sl-hint">Arbeit fällt über die TAT an, nicht am Induction-Tag</span>
          <div class="toggle-group" id="sl-unit-toggle">
            <button class="on" onclick="setSlUnit('slu',this)">SLU</button>
            <button onclick="setSlUnit('hours',this)">Stunden</button>
          </div>
          <div class="toggle-group" id="sl-gran-toggle">
            <button class="on" onclick="setSlGran('month',this)">Monatlich</button>
            <button onclick="setSlGran('quarter',this)">Quartal</button>
          </div>
        </div>
      </div>
      <div class="card-body">
        <div id="sl-kpi-row" style="display:flex;gap:16px;flex-wrap:wrap;margin-bottom:18px;padding:12px 14px;background:var(--bg);border-radius:var(--r-m)">
          <div class="loading" style="padding:0">Lade…</div>
        </div>
        <div class="chart-wrap" style="height:380px"><canvas id="sl-chart"></canvas></div>
        <div id="sl-method" style="margin-top:12px;font-size:11px;line-height:1.6;color:var(--muted)"></div>
        <div id="sl-table-wrap" style="margin-top:20px;overflow-x:auto"><div class="loading">Lade…</div></div>
      </div>
    </div>
  </div>

  <!-- SLU Kapazitäts-Dashboard -->
  <div class="grid-1">
    <div class="card">
      <div class="card-header">
        <h2>SLU Kapazitätslast — Shop Load Units nach Induction-Datum</h2>
        <div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">
          <span class="hint" id="slu-hint">1 SLU = 3.000 Stunden · Ankerdatum: Induction (I) — Zulaufprofil, keine Werkstattlast</span>
          <div class="toggle-group" id="slu-unit-toggle">
            <button class="on" onclick="setSluUnit('slu',this)">SLU</button>
            <button onclick="setSluUnit('hours',this)">Stunden</button>
          </div>
          <div class="toggle-group" id="slu-gran-toggle">
            <button class="on" onclick="setSluGran('month',this)">Monatlich</button>
            <button onclick="setSluGran('quarter',this)">Quartal</button>
          </div>
        </div>
      </div>
      <div class="card-body">
        <!-- SLU KPI-Zeile -->
        <div id="slu-kpi-row" style="display:flex;gap:16px;flex-wrap:wrap;margin-bottom:18px;padding:12px 14px;background:var(--bg);border-radius:var(--r-m)">
          <div class="loading" style="padding:0">Lade…</div>
        </div>
        <!-- Haupt-Chart -->
        <div class="chart-wrap" style="height:380px"><canvas id="slu-chart"></canvas></div>
        <div id="slu-quality" style="margin-top:12px;font-size:11px;line-height:1.6;color:var(--muted)"></div>
        <!-- Family-Legende -->
        <div id="slu-legend" style="display:flex;gap:10px;flex-wrap:wrap;margin-top:14px;font-size:11px"></div>
        <!-- Datentabelle -->
        <div id="slu-table-wrap" style="margin-top:20px;overflow-x:auto">
          <div class="loading">Lade…</div>
        </div>
      </div>
    </div>
  </div>

  <!-- Row 1: Delay by Milestone + OTD Donut -->
  <div class="grid-2">
    <div class="card" style="grid-column: span 1">
      <div class="card-header">
        <h2>MTU Delay nach Meilenstein</h2>
        <div class="toggle-group">
          <button class="on" onclick="setMsMetric('gross_cd',this)">Gesamt CD</button>
          <button onclick="setMsMetric('avg_gross_cd',this)">Ø pro Engine</button>
          <button onclick="setMsMetric('engine_count',this)">Engines betr.</button>
        </div>
      </div>
      <div class="card-body">
        <div class="chart-wrap" style="height:320px"><canvas id="ms-chart"></canvas></div>
      </div>
    </div>

    <div class="card">
      <div class="card-header"><h2>OTD-Verteilung</h2><span class="hint">Customer Committed</span></div>
      <div class="card-body">
        <div class="chart-wrap" style="height:220px"><canvas id="otd-chart"></canvas></div>
        <div class="otd-legend" id="otd-legend"></div>
        <div style="margin-top:20px">
          <div class="card-header" style="padding:0 0 10px;border:none"><h2>OTD nach Engine Family</h2></div>
          <div id="otd-family-table"><div class="loading">Lade…</div></div>
        </div>
        <div style="margin-top:20px">
          <div class="card-header" style="padding:0 0 10px;border:none"><h2>Breakdown-Häufigkeit nach Meilenstein</h2></div>
          <div class="bd-list" id="bd-list"><div class="loading">Lade…</div></div>
        </div>
      </div>
    </div>
  </div>

  <!-- Row 2: Cycle Soll/Ist -->
  <div class="grid-1">
    <div class="card">
      <div class="card-header">
        <h2>Cycle-Analyse — Soll vs. Ist Durchlaufzeiten (Ø über alle Engines)</h2>
        <span class="hint">Soll = ZMMT-Fischgräte · Ist = ZMMP aktueller Plan · Δ = Prozess-Delay je Phase</span>
      </div>
      <div class="card-body">
        <div id="cycle-table"><div class="loading">Lade…</div></div>
      </div>
    </div>
  </div>

  <!-- Row 2b: Cycle by Family -->
  <div class="grid-1">
    <div class="card">
      <div class="card-header">
        <h2>Cycle-Analyse — nach Engine Family</h2>
        <span class="hint">Nur Engines aus engine_families.json · Ø Ist (d) und Ø Δ je Phase</span>
      </div>
      <div class="card-body">
        <div id="cycle-family-table"><div class="loading">Lade…</div></div>
      </div>
    </div>
  </div>

  <!-- Row 2b-extra: Cycle Delta by Family × Year -->
  <div class="grid-1">
    <div class="card">
      <div class="card-header">
        <h2>Cycle Δ-Plan — Engine Family × Jahr</h2>
        <div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">
          <span class="hint">Nur Engines aus engine_families.json · Jahresfilter wird hier ignoriert · n&lt;3 = gedimmt</span>
          <div class="toggle-group">
            <button class="on" onclick="setCycleDeltaMetric('avg_delta',this)">Ø Δ (d)</button>
            <button onclick="setCycleDeltaMetric('avg_ist',this)">Ø Ist (d)</button>
            <button onclick="setCycleDeltaMetric('avg_soll',this)">Ø Soll (d)</button>
          </div>
        </div>
      </div>
      <div class="card-body">
        <div id="cycle-delta-pills" style="display:flex;gap:6px;flex-wrap:wrap;margin-bottom:12px">
          <button class="year-pill on" onclick="setCycleDeltaCycle('all',this)">Gesamt (I→SA)</button>
          <button class="year-pill" onclick="setCycleDeltaCycle('Cycle 0',this)">Cycle 0 · Queue</button>
          <button class="year-pill" onclick="setCycleDeltaCycle('Cycle 1',this)">Cycle 1 · Demontage</button>
          <button class="year-pill" onclick="setCycleDeltaCycle('Cycle 2',this)">Cycle 2 · Material</button>
          <button class="year-pill" onclick="setCycleDeltaCycle('Cycle 3',this)">Cycle 3 · Aufbau</button>
        </div>
        <div id="cycle-delta-family-year-table"><div class="loading">Lade…</div></div>
      </div>
    </div>
  </div>

  <!-- Row 2c: P7 by Family × Year -->
  <div class="grid-1">
    <div class="card">
      <div class="card-header">
        <h2>P7 Claims &amp; CD — Engine Family × Jahr</h2>
        <div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">
          <span class="hint">Nur Engines aus engine_families.json · Jahresfilter wird hier ignoriert</span>
          <div class="toggle-group">
            <button class="on" onclick="setP7FamMetric('p7_cd',this)">CD gesamt</button>
            <button onclick="setP7FamMetric('p7_count',this)">Anzahl Claims</button>
            <button onclick="setP7FamMetric('avg_cd',this)">Ø CD / Engine</button>
          </div>
        </div>
      </div>
      <div class="card-body">
        <div id="p7-cycle-pills" style="display:flex;gap:6px;flex-wrap:wrap;margin-bottom:12px">
          <button class="year-pill on" onclick="setP7Cycle('all',this)">Alle Cycles</button>
          <button class="year-pill" onclick="setP7Cycle('Cycle 0',this)">Cycle 0 · Queue</button>
          <button class="year-pill" onclick="setP7Cycle('Cycle 1',this)">Cycle 1 · Demontage</button>
          <button class="year-pill" onclick="setP7Cycle('Cycle 2',this)">Cycle 2 · Material</button>
          <button class="year-pill" onclick="setP7Cycle('Cycle 3',this)">Cycle 3 · Aufbau</button>
        </div>
        <div id="p7-family-year-table"><div class="loading">Lade…</div></div>
      </div>
    </div>
  </div>

  <!-- Row 2d: Incidents by Family × Year -->
  <div class="grid-1">
    <div class="card">
      <div class="card-header">
        <h2>Cycle-3 Incidents (ServiceNow) — Engine Family × Jahr</h2>
        <div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">
          <span class="hint">Jahresfilter wird hier ignoriert</span>
          <select id="inc-scope-select" onchange="setIncScope(this.value)" style="font-size:11px;padding:3px 6px;border:1px solid var(--border);border-radius:var(--r-s);background:var(--surface)"
                  title="Welche Engines zählen? Nur LUD Core = Projekte, die nicht im SAP-Export stehen: zählen in den absoluten Werten, nicht in den Raten (kein Nenner)">
            <option value="map">Engines: nur engine_families.json</option>
            <option value="sap">Engines: alle aus SAP-Export</option>
            <option value="hub">Engines: + nur in LUD Core</option>
          </select>
          <select id="inc-typ-select" onchange="setIncTyp(this.value)" style="font-size:11px;padding:3px 6px;border:1px solid var(--border);border-radius:var(--r-s);background:var(--surface)"
                  title="LUD-Core-Typ des Incidents (Spalte type)">
            <option value="">Typ: alle</option>
            <option value="stoer">nur Störungen</option>
            <option value="anf">nur Anfragen</option>
          </select>
          <label style="display:flex;align-items:center;gap:5px;font-size:11px;color:var(--muted)">
            Meldende Gruppe
            <select id="inc-group-select" onchange="setIncGroup(this.value)"
                    style="font-size:11px;padding:3px 6px;border:1px solid var(--border);
                           border-radius:var(--r-s);background:var(--surface);max-width:230px">
              <option value="">— alle Gruppen —</option>
            </select>
          </label>
          <div class="toggle-group">
            <button class="on" onclick="setIncMetric('baustopps_p_slu',this)" title="Baustopps je SLU, die im Jahr im Shop lag — fair über Workscope-Mix">Baustopps / SLU</button>
            <button onclick="setIncMetric('baustopps_p_eng',this)" title="Baustopps je Engine, deren Shop Visit im Jahr lief">Baustopps / Engine</button>
            <button onclick="setIncMetric('baustopps',this)">Baustopps abs.</button>
            <button onclick="setIncMetric('baustopp_rate',this)">Baustopp-Anteil (%)</button>
            <button onclick="setIncMetric('bs_h_p_slu',this)" title="Stillstand: überlappungsfreie Baustopp-Stunden in der Shop-Zeit je SLU">Baustopp-Std. / SLU</button>
            <button onclick="setIncMetric('bs_h',this)" title="Überlappungsfreie Baustopp-Stunden in der Shop-Zeit, nach dem Jahr, in dem sie anfallen">Baustopp-Std. abs.</button>
            <button onclick="setIncMetric('median_res_h',this)" title="Ticket-Laufzeit gemeldet → gelöst, kein Stillstand">Median Lösungszeit (h)</button>
            <button onclick="setIncMetric('incidents',this)">Incidents gesamt</button>
          </div>
        </div>
      </div>
      <div class="card-body">
        <div id="incident-family-year-table"><div class="loading">Lade…</div></div>
      </div>
    </div>
  </div>

  <!-- Row 2e: Incident–P7 Korrelation -->
  <div class="grid-1">
    <div class="card">
      <div class="card-header">
        <h2>Incident–P7 Korrelation — Cycle 3</h2>
        <div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">
          <span class="hint">Nur Engines aus engine_families.json mit Incident- und Cycle-3-Daten</span>
          <div class="toggle-group">
            <button class="on" onclick="setCorrTab('scatter',this)">A · Scatter</button>
            <button onclick="setCorrTab('bucket',this)">B · Bucket-Analyse</button>
            <button onclick="setCorrTab('table',this)">C · Engine-Tabelle</button>
          </div>
        </div>
      </div>
      <div class="card-body">
        <div id="corr-scatter" style="display:block">
          <div style="display:flex;gap:12px;align-items:center;margin-bottom:8px;flex-wrap:wrap">
            <span style="font-size:11px;color:var(--muted)">X-Achse:</span>
            <div class="toggle-group">
              <button class="on" onclick="setCorrX('incident_res_h',this)">Stillstand (h)</button>
              <button onclick="setCorrX('baustopp_count',this)">Baustopps</button>
            </div>
            <span style="font-size:11px;color:var(--muted);margin-left:12px">Y-Achse:</span>
            <div class="toggle-group">
              <button class="on" onclick="setCorrY('c3_delta',this)">Cycle-3 Δ (d)</button>
              <button onclick="setCorrY('p7_cd_c3',this)">P7-CD Cycle 3</button>
            </div>
          </div>
          <div style="position:relative;height:360px"><canvas id="corr-chart"></canvas></div>
          <div style="display:flex;justify-content:space-between;align-items:center;margin-top:8px;flex-wrap:wrap;gap:6px">
            <div id="corr-legend" style="display:flex;gap:12px;flex-wrap:wrap;font-size:11px"></div>
            <div id="corr-r2" style="font-size:13px"></div>
          </div>
        </div>
        <div id="corr-bucket" style="display:none">
          <div id="corr-bucket-table"></div>
        </div>
        <div id="corr-table" style="display:none">
          <div style="margin-bottom:8px;display:flex;gap:8px;align-items:center;flex-wrap:wrap">
            <span style="font-size:11px;color:var(--muted)">Sortieren:</span>
            <div class="toggle-group">
              <button class="on" onclick="setCorrSort('baustopp_count',this)">Baustopps</button>
              <button onclick="setCorrSort('incident_res_h',this)">Stillstand</button>
              <button onclick="setCorrSort('c3_delta',this)">Cycle-3 Δ</button>
              <button onclick="setCorrSort('p7_cd_c3',this)">P7-CD</button>
            </div>
          </div>
          <div id="corr-engine-table"></div>
        </div>
      </div>
    </div>
  </div>

  <!-- Row 2f: B3 / TS Termintreue-Wahrscheinlichkeit -->
  <div class="grid-1">
    <div class="card">
      <div class="card-header">
        <h2>B3 &amp; TS — Termintreue-Wahrscheinlichkeit nach Vorgeschichte</h2>
        <div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">
          <span class="hint">Baustopp-Anzahl vor geplantem B3-Termin als Prädiktor · nur Engines mit B3-Soll</span>
          <select id="b3-family-sel" onchange="renderB3Table()"
            style="font-size:11px;padding:2px 6px;border:1px solid var(--border);border-radius:var(--r-s);background:var(--bg);color:var(--text)">
            <option value="">Alle Familien</option>
          </select>
        </div>
      </div>
      <div class="card-body">
        <div style="display:grid;grid-template-columns:1fr 1fr;gap:24px">
          <div>
            <div style="font-size:11px;font-weight:600;text-transform:uppercase;color:var(--muted);margin-bottom:8px">
              B3-Termintreue nach Baustopp-Vorgeschichte
            </div>
            <div id="b3-table"></div>
          </div>
          <div>
            <div style="font-size:11px;font-weight:600;text-transform:uppercase;color:var(--muted);margin-bottom:8px">
              TS-Wahrscheinlichkeit — bedingt auf B3-Ergebnis
            </div>
            <div id="ts-table"></div>
          </div>
        </div>
        <div id="b3-note" style="margin-top:12px;font-size:11px;color:var(--muted)"></div>
      </div>
    </div>
  </div>

  <!-- Row 2g: B2 → B3 Delay-Propagation -->
  <div class="grid-1">
    <div class="card">
      <div class="card-header">
        <h2>Delay-Propagation — Δ B2 → Δ B3</h2>
        <div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">
          <span class="hint">Wie viel des B2-Verzugs überträgt sich auf B3? · nur Engines mit Ist-B2 und Ist-B3</span>
          <div class="toggle-group">
            <button class="on" onclick="setPropTab('scatter',this)">Scatter</button>
            <button onclick="setPropTab('bucket',this)">Bucket-Tabelle</button>
          </div>
        </div>
      </div>
      <div class="card-body">
        <div id="prop-scatter-wrap">
          <div style="position:relative;height:340px"><canvas id="prop-chart"></canvas></div>
          <div style="display:flex;justify-content:space-between;align-items:center;margin-top:8px;flex-wrap:wrap;gap:6px">
            <div id="prop-legend" style="display:flex;gap:12px;flex-wrap:wrap;font-size:11px"></div>
            <div id="prop-r2" style="font-size:13px"></div>
          </div>
        </div>
        <div id="prop-bucket-wrap" style="display:none">
          <div id="prop-bucket-table"></div>
        </div>
      </div>
    </div>
  </div>

  <!-- Row 3: Heatmap -->
  <div class="grid-1">
    <div class="card">
      <div class="card-header">
        <h2>Ø Verzug — Engine-Typ × Workscope</h2>
        <div style="display:flex;align-items:center;gap:12px">
          <span class="hint" id="heat-hint">Nur Gruppen mit ≥ 3 Engines mit Endverzug.</span>
          <div class="toggle-group" id="heat-metric">
            <button class="on" data-metric="end" onclick="setHeatMetric('end')"
                    title="Endverzug: ZMMP − ZMMT am Endtermin. Ganzer Terminverzug, egal wer ihn verursacht hat, jeder Tag zählt einmal.">Endverzug</button>
            <button data-metric="mtu" onclick="setHeatMetric('mtu')"
                    title="SAP MTU Delay CD: nur MTU-verantworteter Verzug, aber über alle Meilensteine aufsummiert (kaskadiert, ~3× Endverzug).">MTU Delay (Σ)</button>
          </div>
          <div class="toggle-group grp-toggle">
            <button class="on" data-grp="type"   onclick="setGrpMode('type')">Typen</button>
            <button           data-grp="family" onclick="setGrpMode('family')">Familien</button>
          </div>
          <div class="toggle-group">
            <button class="on" onclick="setHeatView('rank',this)">Rangliste</button>
            <button onclick="setHeatView('matrix',this)">Matrix</button>
          </div>
        </div>
      </div>
      <div class="card-body">
        <div id="heat-rank-wrap"><div class="loading">Lade…</div></div>
        <div id="heat-matrix-wrap" style="display:none">
          <div style="margin-bottom:10px;display:flex;gap:8px;align-items:center;flex-wrap:wrap">
            <label style="font-size:11px;color:var(--muted);display:flex;align-items:center;gap:5px">
              <input type="checkbox" id="heat-dense" checked onchange="loadHeatmap()">
              <span id="heat-dense-label">Nur Engine-Typen mit ≥ 2 belegten Workscopes</span>
            </label>
            <span class="hint" id="heat-grid-info"></span>
          </div>
          <div class="heatmap-scroll"><div id="heatmap-container"></div></div>
        </div>
      </div>
    </div>
  </div>

  <!-- Row 3b: Workscope-Mix pro Engine-Typ -->
  <div class="grid-1">
    <div class="card">
      <div class="card-header">
        <h2>Workscope-Mix — was war pro Engine-Typ im Haus?</h2>
        <div style="display:flex;align-items:center;gap:12px">
          <span class="hint">Alle Workscopes, ohne Mindestgröße. Klick = Details.</span>
          <div class="toggle-group grp-toggle">
            <button class="on" data-grp="type"   onclick="setGrpMode('type')">Typen</button>
            <button           data-grp="family" onclick="setGrpMode('family')">Familien</button>
          </div>
          <div class="toggle-group" id="ws-metric">
            <button class="on" data-metric="end" onclick="setWsMetric('end')"
                    title="Endverzug: ZMMP − ZMMT am Endtermin. Ganzer Terminverzug, egal wer ihn verursacht hat, jeder Tag zählt einmal. Ø nur über Engines mit Endverzug.">Endverzug</button>
            <button data-metric="mtu" onclick="setWsMetric('mtu')"
                    title="SAP MTU Delay CD: nur MTU-verantworteter Verzug, aber über alle Meilensteine aufsummiert (kaskadiert, ~3× Endverzug). Ø über alle Engines.">MTU Delay (Σ)</button>
          </div>
          <div class="toggle-group">
            <button class="on" onclick="setWsSort('count',this)">Nach Menge</button>
            <button onclick="setWsSort('delay',this)">Nach Ø Delay</button>
            <button onclick="setWsSort('variety',this)">Nach Vielfalt</button>
          </div>
        </div>
      </div>
      <div class="card-body">
        <div style="margin-bottom:10px;display:flex;gap:8px;align-items:center;flex-wrap:wrap">
          <input type="text" id="ws-search" placeholder="Engine-Typ oder Workscope filtern…"
                 oninput="renderWorkscopeMix()"
                 style="font-size:11px;padding:4px 8px;border:1px solid var(--border);border-radius:var(--r-s);min-width:240px">
          <label style="font-size:11px;color:var(--muted);display:flex;align-items:center;gap:5px">
            <input type="checkbox" id="ws-cmp" onchange="toggleWsCompare()">
            Jahrgänge vergleichen
          </label>
          <select id="ws-cmp-year" onchange="setWsCompareYear(this.value)" disabled
                  style="font-size:11px;padding:3px 6px;border:1px solid var(--border);border-radius:var(--r-s)"></select>
          <select id="ws-cmp-min" onchange="setWsCmpMin(this.value)" disabled title="Kleine Gruppen ausblenden"
                  style="font-size:11px;padding:3px 6px;border:1px solid var(--border);border-radius:var(--r-s)">
            <option value="3" selected>ab 3 Engines</option>
            <option value="5">ab 5 Engines</option>
            <option value="10">ab 10 Engines</option>
            <option value="1">alle Gruppen</option>
          </select>
          <span class="hint" id="ws-summary"></span>
        </div>
        <div id="ws-mix-list"><div class="loading">Lade…</div></div>
      </div>
    </div>
  </div>

  <!-- Row 3c: Workscope-Mix über die Zeit + Incidents pro SLU -->
  <div class="grid-1">
    <div class="card">
      <div class="card-header">
        <h2>Workscope-Mix &amp; Incidents pro SLU — über die Zeit</h2>
        <div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">
          <span class="hint" id="wsm-stand"></span>
          <select id="wsm-fam" onchange="loadWsMixTime()" style="font-size:11px;padding:3px 6px;border:1px solid var(--border);border-radius:var(--r-s)">
            <option value="">alle Familien</option></select>
          <select id="wsm-side" onchange="loadWsMixTime()" style="font-size:11px;padding:3px 6px;border:1px solid var(--border);border-radius:var(--r-s)" title="Welche Incidents zählen? Störungen: Befund am Teil = Zustand der Kunden-Hardware; Material &amp; Lieferkette und Interner Ablauf = eigene Prozesse. Anfragen (LUD-Typ): mit Baustopp = Warten auf Entscheidung/Teil (Stillstand, kein Fehler), sonst Routine">
            <option value="">Incidents: alle</option><option value="fehler">Incidents: ohne Routine-Anfragen</option>
            <option value="stoer">Incidents: nur Störungen</option>
            <option value="hw">nur Befund am Teil</option><option value="mat">nur Material &amp; Lieferkette</option>
            <option value="int">nur Interner Ablauf</option><option value="wait">nur Warten auf Entscheidung/Teil</option>
            <option value="anf">nur Routine-Anfragen</option></select>
          <select id="wsm-age" onchange="loadWsMixTime()" style="display:none;font-size:11px;padding:3px 6px;border:1px solid var(--border);border-radius:var(--r-s)" title="Engine-Alter berücksichtigen (Terzile je Familie)">
            <option value="">Alter: nein</option></select>
          <div class="toggle-group" id="wsm-m" title="Anzahl = wie oft etwas passiert · Baustopp-Stunden = wie lange die Arbeit stand (Wirkung)">
            <button class="on" onclick="setWsmM('n')">Anzahl</button>
            <button onclick="setWsmM('bsh')">Baustopp-Std.</button>
          </div>
          <div class="toggle-group" id="wsm-q">
            <button onclick="setWsmQ(8)">8 Quartale</button>
            <button class="on" onclick="setWsmQ(12)">12</button>
            <button onclick="setWsmQ(20)">20</button>
          </div>
          <a href="/workscopes" style="font-size:11px">Klassen pflegen →</a>
        </div>
      </div>
      <div class="card-body">
        <div class="wsm-grid">
          <div><div class="wsm-sub">Zulauf je Eingangsquartal</div>
            <div class="wsm-note">Shop-Visits nach Workscope-Klasse (Balken) · Ø SLU pro Visit (Linie, rechte Achse)</div>
            <div style="height:260px"><canvas id="wsm-intake"></canvas></div></div>
          <div><div class="wsm-sub" id="wsm-rate-sub">Incidents pro SLU je Kalenderquartal</div>
            <div class="wsm-note" id="wsm-rate-note">Incidents nach Meldedatum ÷ SLU, die in dem Quartal im Shop lag (Linie) · dünn: je Seite · SLU im Shop (Fläche, rechte Achse)</div>
            <div style="height:260px"><canvas id="wsm-rate"></canvas></div>
            <div class="wsm-note" id="wsm-cov" style="margin-top:4px"></div></div>
        </div>
        <div id="wsm-dec" class="wsm-dec"><div class="loading">Lade…</div></div>
      </div>
    </div>
  </div>

  <!-- Row 3d: Häufungen -->
  <div class="grid-1">
    <div class="card">
      <div class="card-header">
        <h2>Häufungen — mehr Incidents als die Arbeit im Shop erwarten lässt</h2>
        <div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">
          <span class="hint" id="cl-win"></span>
          <select id="cl-side" onchange="_clOpen='';loadClusters()" style="font-size:11px;padding:3px 6px;border:1px solid var(--border);border-radius:var(--r-s)" title="Welche Incidents zählen? Störungen: Befund am Teil = Zustand der Kunden-Hardware; Material &amp; Lieferkette und Interner Ablauf = eigene Prozesse. Anfragen (LUD-Typ): mit Baustopp = Warten auf Entscheidung/Teil (Stillstand, kein Fehler), sonst Routine">
            <option value="">Incidents: alle</option><option value="fehler">Incidents: ohne Routine-Anfragen</option>
            <option value="stoer">Incidents: nur Störungen</option>
            <option value="hw">nur Befund am Teil</option><option value="mat">nur Material &amp; Lieferkette</option>
            <option value="int">nur Interner Ablauf</option><option value="wait">nur Warten auf Entscheidung/Teil</option>
            <option value="anf">nur Routine-Anfragen</option></select>
          <select id="cl-age" onchange="loadClusters()" style="display:none;font-size:11px;padding:3px 6px;border:1px solid var(--border);border-radius:var(--r-s)" title="Engine-Alter berücksichtigen (Terzile je Familie)">
            <option value="">Alter: nein</option></select>
          <div class="toggle-group" id="cl-w">
            <button class="on" onclick="setClWin(28)" title="Schnell reagieren: was ist in den letzten 4 Wochen auffällig?">4 Wochen</button>
            <button onclick="setClWin(91)" title="Schleichende Anstiege: ein Quartal gegen die 12 Monate davor">13 Wochen</button>
          </div>
        </div>
      </div>
      <div class="card-body"><div id="cl-out"><div class="loading">Lade…</div></div></div>
    </div>
  </div>

  <!-- Row 3: Top Descriptions + Claims per Milestone -->
  <div class="grid-2">
    <div class="card">
      <div class="card-header">
        <h2>Häufigste Claim-Beschreibungen (P7)</h2>
        <span class="hint">Top 40 nach Häufigkeit</span>
      </div>
      <div class="card-body">
        <div class="desc-list" id="desc-list"><div class="loading">Lade…</div></div>
      </div>
    </div>

    <div class="card">
      <div class="card-header">
        <h2>Claims pro Meilenstein</h2>
        <div class="toggle-group">
          <button class="on" onclick="setClaimMetric('claim_count',this)">Anzahl Claims</button>
          <button onclick="setClaimMetric('net_cd',this)">Netto CD</button>
          <button onclick="setClaimMetric('breakdowns',this)">Breakdowns</button>
        </div>
      </div>
      <div class="card-body">
        <div class="chart-wrap" style="height:340px"><canvas id="claim-chart"></canvas></div>
      </div>
    </div>
  </div>

  <!-- Row 4: Engine Type Übersicht -->
  <div class="grid-1">
    <div class="card">
      <div class="card-header">
        <h2>Engine Type Übersicht</h2>
        <div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">
          <span class="hint" id="family-map-status">Mapping wird geladen…</span>
          <div class="toggle-group" id="et-metric">
            <button class="on" data-metric="end" onclick="setEtMetric('end')"
                    title="Endverzug: ZMMP − ZMMT am Endtermin. Ganzer Terminverzug, egal wer ihn verursacht hat, jeder Tag zählt einmal. Ø nur über Engines mit Endverzug.">Endverzug</button>
            <button data-metric="mtu" onclick="setEtMetric('mtu')"
                    title="SAP MTU Delay CD: nur MTU-verantworteter Verzug, aber über alle Meilensteine aufsummiert (kaskadiert, ~3× Endverzug). Ø über alle Engines.">MTU Delay (Σ)</button>
          </div>
          <button class="toggle-btn" onclick="addPrefixRule()">+ Präfix-Regel</button>
          <button class="toggle-btn" onclick="generateFamilyMap()">⚙ Auto-generieren</button>
        </div>
      </div>
      <div class="card-body">
        <div id="engine-type-table"><div class="loading">Lade…</div></div>
      </div>
    </div>
  </div>

</main>

<script>
// ── State ─────────────────────────────────────────────────────────────────
let _msData = [], _claimData = [], _msMetric = 'gross_cd', _claimMetric = 'claim_count';
let _msChart = null, _claimChart = null, _otdChart = null;
let _activeYear = 'all';

let _yearsAvail = [];

async function loadYears() {
  const data = await fetch('/api/dashboard/years').then(r => r.json());
  const pills = document.getElementById('year-pills');
  const years = data.years;
  _yearsAvail = years;
  fillWsCompareYears();
  const single = years.filter(y => parseInt(y) >= 2020);
  const older  = years.filter(y => parseInt(y) < 2020);
  let html = `<button class="year-pill on" onclick="setYear('all',this)">Alle (${data.total})</button>`;
  html += `<button class="year-pill running" onclick="setYear('running',this)">Laufend (${data.running_count})</button>`;
  single.forEach(y => { html += `<button class="year-pill" onclick="setYear('${y}',this)">${y}</button>`; });
  if (older.length) {
    html += `<button class="year-pill older" onclick="setYear('older',this)">≤ 2019 (${older.length}J)</button>`;
  }
  pills.innerHTML = html;
  // restore active selection
  pills.querySelectorAll('.year-pill').forEach(b => {
    const match = b.textContent.startsWith(_activeYear === 'all' ? 'Alle' :
                  _activeYear === 'running' ? 'Laufend' : _activeYear);
    if (match) b.classList.add('on'); else b.classList.remove('on');
  });
}

function setYear(year, btn) {
  _activeYear = year;
  fillWsCompareYears();
  document.querySelectorAll('.year-pill').forEach(b => b.classList.remove('on'));
  btn.classList.add('on');
  init();
}

const PALETTE_RED    = 'rgba(192,57,43,0.8)';
const PALETTE_BLUE   = 'rgba(26,95,168,0.8)';
const PALETTE_PURPLE = 'rgba(109,40,217,0.8)';
const PALETTE_GREEN  = 'rgba(45,122,45,0.8)';

// ── Helpers ───────────────────────────────────────────────────────────────
function esc(s) {
  return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;')
                  .replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}

// Categorical slots for workscope shares — fixed order, never cycled;
// an 8th scope folds into "Sonstige" (grey) rather than inventing a hue.
const WS_COLORS = ['#2a78d6', '#eb6834', '#1baf7a', '#eda100',
                   '#e87ba4', '#008300', '#4a3aa7'];
const WS_REST_COLOR = '#9a948e';

function heatColor(val, max) {
  // always returns {bg, fg} — callers destructure
  if (val <= 0) return { bg: '#f0f9ee', fg: '#1a1815' };
  const ratio = Math.min(Math.max(val / max, 0), 1);
  const r = Math.round(255 - ratio * (255 - 192));
  const g = Math.round(255 - ratio * (255 - 57));
  const b = Math.round(255 - ratio * (255 - 43));
  const bg = `rgb(${r},${g},${b})`;
  const fg = ratio > 0.55 ? '#fff' : '#1a1815';
  return { bg, fg };
}

// ── Stats KPIs ────────────────────────────────────────────────────────────
// higherIsBad=true: delay metrics — increase = red; false: neutral grey
function _fmtDelta(curr, prev, unit = ' CD', higherIsBad = true) {
  if (prev == null || curr == null) return '';
  const d    = Math.round((curr - prev) * 10) / 10;
  if (d === 0) return '± 0 vs. Vorjahr';
  const sign  = d > 0 ? '+' : '';
  const color = !higherIsBad ? 'var(--muted)'
              : d > 0 ? 'var(--red)' : 'var(--green)';
  return `<span style="color:${color}">${sign}${d}${unit} vs. Vorjahr</span>`;
}

async function loadStats() {
  const isYear = /^\d{4}$/.test(_activeYear);
  const [s, prev] = await Promise.all([
    fetch(`/api/stats?year=${_activeYear}`).then(r => r.json()),
    isYear
      ? fetch(`/api/stats?year=${parseInt(_activeYear) - 1}`).then(r => r.json()).catch(() => null)
      : Promise.resolve(null),
  ]);
  document.getElementById('k-total').textContent  = s.total_engines.toLocaleString('de');
  document.getElementById('k-red').textContent    = (s.otd?.Red    ?? 0).toLocaleString('de');
  document.getElementById('k-yellow').textContent = (s.otd?.Yellow ?? 0).toLocaleString('de');
  document.getElementById('k-green').textContent  = (s.otd?.Green  ?? 0).toLocaleString('de');
  document.getElementById('k-delay').textContent  = s.avg_mtu_delay_cd + ' CD';
  document.getElementById('k-end').textContent    = s.avg_end_delay_cd != null ? s.avg_end_delay_cd + ' CD' : '–';
  document.getElementById('k-oem').textContent    = s.avg_oem_delay_cd  + ' CD';
  document.getElementById('k-cust').textContent   = s.avg_cust_delay_cd + ' CD';

  // Δ-zum-Vorjahr (nur bei konkretem Jahrgang und wenn Vorjahreswerte vorhanden)
  const p = (prev && prev.total_engines > 0) ? prev : null;
  // Ein Shop Visit ist eine Einlieferung, kein Triebwerk: dieselbe ESN kommt
  // ueber die Jahre mehrfach. Darum steht die ESN-Zahl daneben.
  const esnLine = (s.module_visit_mode === 'nur'
      ? `${(s.module_visits || 0).toLocaleString('de')} Modul-Visits`
      : s.distinct_esn ? `${s.distinct_esn.toLocaleString('de')} Triebwerke (ESN)` : '')
    + (s.module_visit_mode === 'ein' && s.module_visits ? ` · davon ${s.module_visits.toLocaleString('de')} Modul-Visits` : '')
    + (s.module_visits_hidden ? ` · ${s.module_visits_hidden.toLocaleString('de')} Modul-Visits ausgeblendet` : '');
  document.getElementById('k-total-delta').innerHTML =
    p ? (_fmtDelta(s.total_engines, p.total_engines, ' Shop Visits', false) || esnLine) : esnLine;
  document.getElementById('k-delay-delta').innerHTML =
    p ? (_fmtDelta(s.avg_mtu_delay_cd, p.avg_mtu_delay_cd, ' CD', true) || 'Kalender-Tage') : 'Kalender-Tage';
  // Endverzug nur für Engines mit Kundenzusage und plausiblem Endtermin — Abdeckung mit angeben
  const endMedian = s.median_end_delay_cd != null
    ? `Median ${s.median_end_delay_cd} CD · ${s.end_delay_n.toLocaleString('de')} von ${s.total_engines.toLocaleString('de')}`
    : 'Kalender-Tage';
  document.getElementById('k-end-delta').innerHTML =
    (p && p.avg_end_delay_cd != null && s.avg_end_delay_cd != null)
      ? (_fmtDelta(s.avg_end_delay_cd, p.avg_end_delay_cd, ' CD', true) || endMedian) : endMedian;
  document.getElementById('k-oem-delta').innerHTML =
    p ? (_fmtDelta(s.avg_oem_delay_cd, p.avg_oem_delay_cd, ' CD', true) || 'Kalender-Tage') : 'Kalender-Tage';
  document.getElementById('k-cust-delta').innerHTML =
    p ? (_fmtDelta(s.avg_cust_delay_cd, p.avg_cust_delay_cd, ' CD', true) || 'Kalender-Tage') : 'Kalender-Tage';

  // Vintage warning: only for a specific year with incomplete engines
  const warn = document.getElementById('vintage-warning');
  const inc  = s.incomplete_count ?? 0;
  if (_activeYear !== 'all' && _activeYear !== 'running' && inc > 0) {
    document.getElementById('vintage-warning-text').textContent =
      `${inc} von ${s.total_engines} Shop Visits (Jahrgang ${_activeYear}) noch ohne SA. `;
    warn.style.display = '';
  } else {
    warn.style.display = 'none';
  }

  // OTD Donut
  const red = s.otd?.Red ?? 0, yel = s.otd?.Yellow ?? 0, grn = s.otd?.Green ?? 0, unk = s.otd?.[''] ?? 0;
  const ctx = document.getElementById('otd-chart').getContext('2d');
  if (_otdChart) _otdChart.destroy();
  _otdChart = new Chart(ctx, {
    type: 'doughnut',
    data: {
      labels: ['Red','Yellow','Green','Unbekannt'],
      datasets: [{ data: [red,yel,grn,unk], backgroundColor: ['#c0392b','#d4a017','#2d7a2d','#ccc'], borderWidth: 1 }]
    },
    options: { plugins: { legend: { display: false } }, cutout: '65%', responsive: true, maintainAspectRatio: false }
  });
  const lg = document.getElementById('otd-legend');
  lg.innerHTML = [
    ['#c0392b','Red',red], ['#d4a017','Yellow',yel], ['#2d7a2d','Green',grn]
  ].map(([c,l,n]) => `<div class="otd-item"><div class="otd-dot" style="background:${c}"></div>${l}: <b>${n.toLocaleString('de')}</b></div>`).join('');
}

// ── Delay by Milestone ────────────────────────────────────────────────────
async function loadMsChart() {
  _msData = await fetch(`/api/dashboard/delay-by-milestone?year=${_activeYear}`).then(r => r.json());
  renderMsChart();
}

function setMsMetric(metric, btn) {
  _msMetric = metric;
  document.querySelectorAll('.toggle-group button').forEach(b => {
    if (b.closest('.card') === btn.closest('.card')) b.classList.remove('on');
  });
  btn.classList.add('on');
  renderMsChart();
}

function renderMsChart() {
  const top = _msData.slice(0, 18);
  const labels = top.map(d => d.milestone);
  const vals   = top.map(d => d[_msMetric]);
  const unitMap = { gross_cd: 'CD gesamt', avg_gross_cd: 'Ø CD/Engine', engine_count: 'Engines' };
  const ctx = document.getElementById('ms-chart').getContext('2d');
  if (_msChart) _msChart.destroy();
  _msChart = new Chart(ctx, {
    type: 'bar',
    data: {
      labels,
      datasets: [{
        label: unitMap[_msMetric] || _msMetric,
        data: vals,
        backgroundColor: PALETTE_RED,
        borderRadius: 3,
      }]
    },
    options: {
      indexAxis: 'y',
      responsive: true, maintainAspectRatio: false,
      plugins: {
        legend: { display: false },
        tooltip: {
          callbacks: {
            afterLabel: (ctx) => {
              const d = _msData[ctx.dataIndex];
              return `${d.label}\\nEngines: ${d.engine_count}  Claims: ${d.claim_count}\\nBreakdowns: ${d.breakdowns}`;
            }
          }
        }
      },
      scales: { x: { grid: { color: 'rgba(0,0,0,.05)' } }, y: { ticks: { font: { family: 'monospace', size: 12 } } } }
    }
  });
}

// ── Claims per Milestone ──────────────────────────────────────────────────
function setClaimMetric(metric, btn) {
  _claimMetric = metric;
  document.querySelectorAll('.toggle-group button').forEach(b => {
    if (b.closest('.card') === btn.closest('.card')) b.classList.remove('on');
  });
  btn.classList.add('on');
  renderClaimChart();
}

function renderClaimChart() {
  const top = [..._msData].sort((a,b) => b[_claimMetric] - a[_claimMetric]).slice(0,18);
  const labels = top.map(d => d.milestone);
  const vals   = top.map(d => d[_claimMetric]);
  const unitMap = { claim_count: 'Anzahl Claims', net_cd: 'Netto CD', breakdowns: 'Breakdowns' };
  const color = _claimMetric === 'breakdowns' ? PALETTE_RED : PALETTE_BLUE;
  const ctx = document.getElementById('claim-chart').getContext('2d');
  if (_claimChart) _claimChart.destroy();
  _claimChart = new Chart(ctx, {
    type: 'bar',
    data: {
      labels,
      datasets: [{ label: unitMap[_claimMetric]||_claimMetric, data: vals, backgroundColor: color, borderRadius: 3 }]
    },
    options: {
      indexAxis: 'y', responsive: true, maintainAspectRatio: false,
      plugins: { legend: { display: false } },
      scales: { x: { grid: { color: 'rgba(0,0,0,.05)' } }, y: { ticks: { font: { family: 'monospace', size: 12 } } } }
    }
  });
}

// ── Heatmap: Rangliste (default) + Matrix ─────────────────────────
let _heatView = 'rank';
let _heatData = null;
let _heatMetric = 'end';     // 'end' = Endverzug, 'mtu' = SAP MTU Delay CD (Σ)
const HEAT_METRIC_LABEL = { end: '\u00d8 Endverzug', mtu: '\u00d8 MTU Delay (\u03a3)' };

function setHeatMetric(m) {
  if (_heatMetric === m) return;
  _heatMetric = m;
  document.querySelectorAll('#heat-metric button').forEach(b =>
    b.classList.toggle('on', b.dataset.metric === m));
  updateHeatHint();
  loadHeatmap();
}

function updateHeatHint() {
  const base = _heatMetric === 'end' ? '\u2265 3 Engines mit Endverzug' : '\u2265 3 Engines';
  document.getElementById('heat-hint').textContent = _heatView === 'rank'
    ? `Top 25 Kombinationen, nur Gruppen mit ${base}.`
    : `Nur Gruppen mit ${base}. Farbe = Delay-Intensit\u00e4t.`;
}

// Tooltip: gewählte Metrik vorn, die andere zum Vergleich
function heatTitle(d, head) {
  const lbl   = HEAT_METRIC_LABEL[d.metric || _heatMetric];
  const other = (d.metric || _heatMetric) === 'end'
    ? `zum Vergleich ${HEAT_METRIC_LABEL.mtu}: ${d.avg_mtu_delay_cd} CD`
    : `zum Vergleich ${HEAT_METRIC_LABEL.end}: ${d.avg_end_delay_cd ?? '\u2013'} CD (${d.end_delay_n} von ${d.engine_count} Engines)`;
  return `${head}\n${lbl} ${d.avg_delay_cd} CD \u00b7 Median ${d.median_delay_cd} CD \u00b7 max ${d.max_delay_cd} CD\n${other}`;
}

// Shared row-granularity lens for both workscope cards: individual engine types,
// or rolled up via engine_families.json. One switch, kept in sync in both headers.
let _grpMode = 'type';
const isFam = () => _grpMode === 'family';

function setGrpMode(mode) {
  if (_grpMode === mode) return;
  _grpMode = mode;
  document.querySelectorAll('.grp-toggle button').forEach(b =>
    b.classList.toggle('on', b.dataset.grp === mode));
  _wsOpen.clear();            // row keys differ between the two lenses
  loadHeatmap();
  loadWorkscopeMix();
}

function setHeatView(v, btn) {
  _heatView = v;
  btn.parentNode.querySelectorAll('button').forEach(b => b.classList.remove('on'));
  btn.classList.add('on');
  document.getElementById('heat-rank-wrap').style.display   = v === 'rank'   ? '' : 'none';
  document.getElementById('heat-matrix-wrap').style.display = v === 'matrix' ? '' : 'none';
  updateHeatHint();
  renderHeat();
}

async function loadHeatmap() {
  _heatData = await fetch(`/api/dashboard/heatmap?year=${_activeYear}&group=${_grpMode}&metric=${_heatMetric}`).then(r => r.json());
  renderHeat();
}

function renderHeat() {
  if (!_heatData) return;
  if (_heatView === 'rank') renderHeatRank(); else renderHeatMatrix();
}

// Ranked list — answers "which combination runs worst" without a 95%-empty grid.
function renderHeatRank() {
  const wrap = document.getElementById('heat-rank-wrap');
  const data = _heatData;
  if (!data.length) {
    wrap.innerHTML = '<div class="loading">Keine Gruppe mit \u2265 3 Engines in dieser Auswahl</div>';
    return;
  }
  const top = data.slice(0, 25);
  const maxDelay = Math.max(...top.map(d => d.avg_delay_cd), 1);
  const minDelay = Math.min(...top.map(d => d.avg_delay_cd), 0);
  const span = Math.max(maxDelay - Math.min(minDelay, 0), 1);
  const zero = Math.max(0, -Math.min(minDelay, 0)) / span * 100;

  let html = '<div class="heat-rank">';
  top.forEach(d => {
    const { bg } = heatColor(d.avg_delay_cd, maxDelay);
    const w = Math.abs(d.avg_delay_cd) / span * 100;
    const left = d.avg_delay_cd >= 0 ? zero : zero - w;
    const fam = isFam() && d.type_count > 1 ? ` \u00b7 ${d.type_count} Typen` : '';
    const title = heatTitle(d, `${d.engine_type} \u00b7 ${d.workscope} \u00b7 ${d.engine_count} Engines${fam}`);
    html += `<div class="heat-row" title="${esc(title)}">
      <div class="hr-et">${esc(d.engine_type)}${fam ? `<span style="font-weight:400;color:var(--muted)">${esc(fam)}</span>` : ''}</div>
      <div class="hr-ws">${esc(d.workscope)}</div>
      <div class="hr-bar-wrap"><div class="hr-bar" style="background:${bg};width:${w}%;margin-left:${left}%"></div></div>
      <div class="hr-cd">${d.avg_delay_cd > 0 ? '+' : ''}${d.avg_delay_cd} CD</div>
      <div class="hr-n">n=${d.engine_count}</div>
    </div>`;
  });
  html += '</div>';
  if (data.length > top.length) {
    html += `<div class="hint" style="margin-top:8px">\u2026 ${data.length - top.length} weitere Kombinationen (Matrix-Ansicht zeigt alle)</div>`;
  }
  wrap.innerHTML = html;
}

function renderHeatMatrix() {
  const container = document.getElementById('heatmap-container');
  const data = _heatData;
  const info = document.getElementById('heat-grid-info');
  if (!data.length) {
    container.innerHTML = '<div class="loading">Keine Daten</div>';
    info.textContent = '';
    return;
  }
  const lookup = {};
  data.forEach(d => lookup[d.engine_type + '|' + d.workscope] = d);

  // Rows by engine volume (not alphabetically) so the dense rows sit on top.
  const rowN = {}, colN = {};
  data.forEach(d => {
    rowN[d.engine_type] = (rowN[d.engine_type] || 0) + d.engine_count;
    colN[d.workscope]   = (colN[d.workscope]   || 0) + d.engine_count;
  });
  const rowCells = {};
  data.forEach(d => rowCells[d.engine_type] = (rowCells[d.engine_type] || 0) + 1);

  const dense = document.getElementById('heat-dense').checked;
  let engineTypes = [...new Set(data.map(d => d.engine_type))];
  if (dense) engineTypes = engineTypes.filter(et => rowCells[et] >= 2);
  engineTypes.sort((a, b) => rowN[b] - rowN[a] || a.localeCompare(b));

  const workscopes = [...new Set(data.map(d => d.workscope))]
    .filter(ws => engineTypes.some(et => lookup[et + '|' + ws]))
    .sort((a, b) => colN[b] - colN[a] || a.localeCompare(b));

  const maxDelay = Math.max(...data.map(d => d.avg_delay_cd), 1);
  const wsLabel = ws => ws.length > 28 ? ws.slice(0, 28) + '\u2026' : ws;

  let html = `<table class="heatmap"><thead><tr><th>${isFam() ? 'Family' : 'Engine Type'}</th>`;
  workscopes.forEach(ws => { html += `<th title="${esc(ws)}">${esc(wsLabel(ws))}</th>`; });
  html += '</tr></thead><tbody>';
  engineTypes.forEach(et => {
    html += `<tr><td style="font-weight:600">${esc(et)}</td>`;
    workscopes.forEach(ws => {
      const d = lookup[et + '|' + ws];
      if (!d) { html += '<td></td>'; return; }
      const { bg, fg } = heatColor(d.avg_delay_cd, maxDelay);
      const title = heatTitle(d, `${et} \u00b7 ${ws} \u00b7 ${d.engine_count} Engines`
                  + (isFam() && d.type_count > 1 ? ` aus ${d.type_count} Engine-Typen` : ''));
      html += `<td><span class="heat-cell" style="background:${bg};color:${fg}" title="${esc(title)}">${d.avg_delay_cd}</span></td>`;
    });
    html += '</tr>';
  });
  html += '</tbody></table>';
  container.innerHTML = html;

  const filled = engineTypes.reduce((s, et) => s + workscopes.filter(ws => lookup[et + '|' + ws]).length, 0);
  const total  = engineTypes.length * workscopes.length;
  info.textContent = `${engineTypes.length} \u00d7 ${workscopes.length} Raster, ${filled} belegt `
                   + `(${total ? (100 * filled / total).toFixed(1) : 0} %)`;
  document.getElementById('heat-dense-label').textContent =
    isFam() ? 'Nur Familien mit \u2265 2 belegten Workscopes'
            : 'Nur Engine-Typen mit \u2265 2 belegten Workscopes';
}

// ── Workscope-Mix pro Engine-Typ ──────────────────────────────
let _wsData = null;
let _wsSort = 'count';
const _wsOpen = new Set();

// Optional second vintage, for this card only. Off by default: the page year rules.
let _wsCmp     = false;
let _wsCmpYear = null;
let _wsDataB   = null;
let _wsCmpHidden = 0;
let _wsCmpPicked = false;   // true once the user chose a vintage by hand
let _wsCmpMin    = 3;       // hide groups below this many engines across both vintages

function setWsCmpMin(v) {
  _wsCmpMin = parseInt(v) || 1;
  renderWorkscopeMix();
}

function _prevYearOf(y) {
  const n = parseInt(y);
  if (!isNaN(n) && _yearsAvail.includes(String(n - 1))) return String(n - 1);
  return _yearsAvail.find(v => v !== y) || null;
}

// Default vintage follows the page year (2026 -> 2025) until the user picks one.
function fillWsCompareYears() {
  const sel = document.getElementById('ws-cmp-year');
  if (!sel) return;
  if (sel.options.length !== _yearsAvail.length) {
    sel.innerHTML = _yearsAvail.map(y => `<option value="${y}">${y}</option>`).join('');
  }
  if (!_wsCmpPicked || !_yearsAvail.includes(_wsCmpYear)) _wsCmpYear = _prevYearOf(_activeYear);
  if (_wsCmpYear) sel.value = _wsCmpYear;
}

function toggleWsCompare() {
  _wsCmp = document.getElementById('ws-cmp').checked;
  document.getElementById('ws-cmp-year').disabled = !_wsCmp;
  document.getElementById('ws-cmp-min').disabled  = !_wsCmp;
  fillWsCompareYears();
  loadWorkscopeMix();
}

function setWsCompareYear(y) {
  _wsCmpYear = y;
  _wsCmpPicked = true;
  if (_wsCmp) loadWorkscopeMix();
}

// Label for the page-year pill, so both vintages read the same way.
function _yearLabel(y) {
  return y === 'all' ? 'Alle' : y === 'running' ? 'Laufend' : y === 'older' ? '≤ 2019' : y;
}

// Metrik für Ø/max Delay im Workscope-Mix — nur Anzeige, die Daten tragen beide
let _wsMetric = 'end';
const avgD = x => x == null ? null : (_wsMetric === 'mtu' ? x.avg_mtu_delay_cd : x.avg_delay_cd);
const maxD = x => x == null ? null : (_wsMetric === 'mtu' ? x.max_mtu_delay_cd : x.max_delay_cd);
const wsDelayLbl = () => _wsMetric === 'mtu' ? '\u00d8 MTU (\u03a3)' : '\u00d8 Endverzug';

function setWsMetric(m) {
  if (_wsMetric === m) return;
  _wsMetric = m;
  document.querySelectorAll('#ws-metric button').forEach(b =>
    b.classList.toggle('on', b.dataset.metric === m));
  renderWorkscopeMix();
}

function setWsSort(mode, btn) {
  _wsSort = mode;
  btn.parentNode.querySelectorAll('button').forEach(b => b.classList.remove('on'));
  btn.classList.add('on');
  renderWorkscopeMix();
}

function toggleWsType(et) {
  if (_wsOpen.has(et)) _wsOpen.delete(et); else _wsOpen.add(et);
  renderWorkscopeMix();
}

async function loadWorkscopeMix() {
  const base = fetch(`/api/dashboard/workscope-mix?year=${_activeYear}&group=${_grpMode}`).then(r => r.json());
  if (_wsCmp && _wsCmpYear) {
    const [a, b] = await Promise.all([base,
      fetch(`/api/dashboard/workscope-mix?year=${_wsCmpYear}&group=${_grpMode}`).then(r => r.json())]);
    _wsData = a; _wsDataB = b;
  } else {
    _wsData = await base; _wsDataB = null;
  }
  renderWorkscopeMix();
}

function renderWorkscopeMix() {
  if (!_wsData) return;
  document.getElementById('ws-search').placeholder =
    isFam() ? 'Familie oder Workscope filtern…' : 'Engine-Typ oder Workscope filtern…';
  const wrap = document.getElementById('ws-mix-list');
  const q = (document.getElementById('ws-search').value || '').toLowerCase().trim();

  let rows = _wsData;
  if (q) {
    rows = rows.filter(t => t.engine_type.toLowerCase().includes(q)
                         || (t.family || '').toLowerCase().includes(q)
                         || t.workscopes.some(w => w.workscope.toLowerCase().includes(q)));
  }
  const sorters = {
    count:   (a, b) => b.count - a.count,
    delay:   (a, b) => (avgD(b) ?? -Infinity) - (avgD(a) ?? -Infinity) || 0,
    variety: (a, b) => b.distinct_count - a.distinct_count,
  };
  rows = [...rows].sort((a, b) => sorters[_wsSort](a, b) || a.engine_type.localeCompare(b.engine_type));

  const totalEng = rows.reduce((s, t) => s + t.scoped_count, 0);
  const allWs = new Set();
  rows.forEach(t => t.workscopes.forEach(w => allWs.add(w.workscope)));
  const cmp = _wsCmp && _wsDataB;
  const byKeyB = {};
  if (cmp) {
    _wsDataB.forEach(t => byKeyB[t.engine_type] = t);
    // A group that ran only in the comparison year is part of the change, so it
    // gets a row with an empty current bar rather than being dropped.
    const haveA = new Set(rows.map(t => t.engine_type));
    const onlyB = _wsDataB
      .filter(t => !haveA.has(t.engine_type))
      .filter(t => !q || t.engine_type.toLowerCase().includes(q)
                      || (t.family || '').toLowerCase().includes(q)
                      || t.workscopes.some(w => w.workscope.toLowerCase().includes(q)))
      .map(t => ({engine_type: t.engine_type, family: t.family, types: t.types,
                  type_count: t.type_count, count: 0, scoped_count: 0, dummy_count: 0,
                  distinct_count: 0, avg_delay_cd: null, avg_mtu_delay_cd: null, workscopes: []}));
    rows = [...rows, ...onlyB].sort(
      (a, b) => sorters[_wsSort](a, b) || a.engine_type.localeCompare(b.engine_type));
    if (_wsCmpMin > 1) {
      const before = rows.length;
      rows = rows.filter(t => t.scoped_count + (byKeyB[t.engine_type]?.scoped_count || 0) >= _wsCmpMin);
      _wsCmpHidden = before - rows.length;
    } else { _wsCmpHidden = 0; }
  }

  document.getElementById('ws-summary').textContent =
    `${rows.length} ${isFam() ? 'Familien' : 'Engine-Typen'} \u00b7 ${allWs.size} verschiedene Workscopes `
  + `\u00b7 ${totalEng} Engines`
  + (cmp ? ` \u00b7 Vergleich ${_yearLabel(_activeYear)} gegen ${_wsCmpYear}` : '')
  + (cmp && _wsCmpHidden ? ` \u00b7 ${_wsCmpHidden} kleine Gruppen ausgeblendet` : '');

  if (!rows.length) { wrap.innerHTML = '<div class="loading">Kein Treffer</div>'; return; }

  const pct = (n, tot) => tot ? Math.round(1000 * n / tot) / 10 : 0;
  const sign = v => v == null ? '\u2013' : (v > 0 ? '+' : '') + v;

  wrap.innerHTML = rows.map(t => {
    const open = _wsOpen.has(t.engine_type);
    const tb   = cmp ? byKeyB[t.engine_type] : null;

    // Colour follows the workscope, not its rank: in compare mode both bars must
    // use the same slot per workscope, so the order comes from the union.
    const mapA = {}, mapB = {};
    t.workscopes.forEach(w => mapA[w.workscope] = w);
    if (tb) tb.workscopes.forEach(w => mapB[w.workscope] = w);
    const order = [...new Set([...t.workscopes.map(w => w.workscope),
                               ...(tb ? tb.workscopes.map(w => w.workscope) : [])])]
      .sort((x, y) => ((mapA[y]?.count || 0) + (mapB[y]?.count || 0))
                    - ((mapA[x]?.count || 0) + (mapB[x]?.count || 0)));
    const slot  = {};
    order.forEach((ws, i) => slot[ws] = i);
    const color = ws => slot[ws] < WS_COLORS.length ? WS_COLORS[slot[ws]] : WS_REST_COLOR;

    // Segments for one vintage, drawn in the shared order so slots line up.
    const segsOf = (grp, label) => {
      if (!grp || !grp.scoped_count) return '';
      const m = {};
      grp.workscopes.forEach(w => m[w.workscope] = w);
      let restN = 0, segs = '';
      order.forEach(ws => {
        const w = m[ws];
        if (!w) return;
        if (slot[ws] >= WS_COLORS.length) { restN += w.count; return; }
        segs += `<div class="ws-seg" style="background:${color(ws)};width:${pct(w.count, grp.scoped_count)}%"`
              + ` title="${esc(ws)}${label ? ' \u2014 ' + esc(label) : ''}: ${w.count} Engines `
              + `(${pct(w.count, grp.scoped_count)} %), ${wsDelayLbl()} ${avgD(w) ?? '\u2013'} CD"></div>`;
      });
      if (restN) {
        segs += `<div class="ws-seg" style="background:${WS_REST_COLOR};width:${pct(restN, grp.scoped_count)}%"`
              + ` title="weitere Workscopes${label ? ' \u2014 ' + esc(label) : ''}: ${restN} Engines `
              + `(${pct(restN, grp.scoped_count)} %)"></div>`;
      }
      return segs;
    };

    const bar = (grp, label) => {
      const empty = !grp || !grp.scoped_count;
      return `<div class="ws-bar-line"><span class="ws-bar-lbl">${esc(label)}</span>`
           + `<div class="ws-stack${empty ? ' ws-stack-empty' : ''}"`
           + `${empty ? ` title="keine Engines in ${esc(label)}"` : ''}>${segsOf(grp, label)}</div>`
           + `<span class="ws-bar-n">${empty ? 0 : grp.scoped_count}</span></div>`;
    };

    const stackCell = cmp
      ? `<div class="ws-bars">${bar(t, _yearLabel(_activeYear))}${bar(tb, _wsCmpYear)}</div>`
      : `<div class="ws-stack">${segsOf(t, '')}</div>`;

    // Detail table: one row per workscope, both vintages side by side when comparing.
    const detail = cmp ? `<div class="ws-detail">
      <div class="ws-drow cmp hdr"><div class="wd-ws">Workscope</div>
        <div class="wd-n">${esc(_yearLabel(_activeYear))}</div><div class="wd-sh">Anteil</div>
        <div class="wd-n">${esc(_wsCmpYear)}</div><div class="wd-sh">Anteil</div>
        <div class="wd-dl">\u0394 Anteil</div><div class="wd-cd">${wsDelayLbl()}</div><div class="wd-dl">\u0394 Delay</div></div>
      ${order.map(ws => {
        const a = mapA[ws], bb = mapB[ws];
        const dShare = Math.round(((a ? a.share : 0) - (bb ? bb.share : 0)) * 10) / 10;
        const dDelay = (a && bb && avgD(a) != null && avgD(bb) != null)
          ? Math.round(((avgD(a) - avgD(bb))) * 10) / 10 : null;
        const cls = v => v > 0 ? 'up' : v < 0 ? 'down' : '';
        return `<div class="ws-drow cmp">
          <div class="wd-ws"><span style="display:inline-block;width:8px;height:8px;border-radius:var(--r-s);
               background:${color(ws)};margin-right:6px"></span>${esc(ws)}</div>
          <div class="wd-n">${a ? a.count : '\u2014'}</div>
          <div class="wd-sh">${a ? a.share + ' %' : ''}</div>
          <div class="wd-n">${bb ? bb.count : '\u2014'}</div>
          <div class="wd-sh">${bb ? bb.share + ' %' : ''}</div>
          <div class="wd-dl ${cls(dShare)}">${sign(dShare)} pp</div>
          <div class="wd-cd">${a ? sign(avgD(a)) : ''}</div>
          <div class="wd-dl ${cls(dDelay)}">${dDelay === null ? '' : sign(dDelay) + ' CD'}</div></div>`;
      }).join('')}
      ${isFam() && t.type_count > 1 ? `<div class="ws-dummy">Engine-Typen in dieser Familie (${t.type_count}): ${esc(t.types.join(', '))}</div>` : ''}
    </div>` : `<div class="ws-detail">
      <div class="ws-drow hdr"><div class="wd-ws">Workscope</div>
        <div class="wd-n">Engines</div><div class="wd-sh">Anteil</div>
        <div class="wd-cd">${wsDelayLbl()}</div><div class="wd-mx">max</div></div>
      ${t.workscopes.map(w => `<div class="ws-drow">
        <div class="wd-ws"><span style="display:inline-block;width:8px;height:8px;border-radius:var(--r-s);
             background:${color(w.workscope)};margin-right:6px"></span>${esc(w.workscope)}</div>
        <div class="wd-n">${w.count}</div>
        <div class="wd-sh">${w.share} %</div>
        <div class="wd-cd">${sign(avgD(w))}</div>
        <div class="wd-mx">${maxD(w) ?? '\u2013'}</div></div>`).join('')}
      ${isFam() && t.type_count > 1 ? `<div class="ws-dummy">Engine-Typen in dieser Familie (${t.type_count}): ${esc(t.types.join(', '))}</div>` : ''}
      ${t.dummy_count ? `<div class="ws-dummy">+ ${t.dummy_count} Engines mit MIGRATION_DUMMY (WS99) \u2014 keine reale Shop-Arbeit, hier nicht mitgez\u00e4hlt.</div>` : ''}
    </div>`;

    const dDelayHead = (tb && avgD(t) != null && avgD(tb) != null)
      ? Math.round((avgD(t) - avgD(tb)) * 10) / 10 : null;
    const meta = cmp
      ? `${t.scoped_count} / ${tb ? tb.scoped_count : 0} Eng. / ${order.length} WS`
      : `${t.scoped_count} Eng. / ${isFam() && t.type_count > 1 ? t.type_count + ' Typen / ' : ''}${t.distinct_count} WS`;
    // No counterpart in the other vintage means there is no delta to show; the
    // absolute value is marked with * so it is never read as a change.
    const lone = cmp && (dDelayHead === null || !t.scoped_count);
    const headCd = !cmp
      ? `${sign(avgD(t))} CD`
      : lone
        ? `<span class="nocmp" title="Kein Gegenwert im jeweils anderen Jahrgang \u2014 absoluter \u00d8">`
          + `${sign(t.scoped_count ? avgD(t) : (tb ? avgD(tb) : 0))} CD*</span>`
        : `<span title="${wsDelayLbl()} ${_yearLabel(_activeYear)} gegen ${_wsCmpYear}" `
          + `class="${dDelayHead > 0 ? 'up' : dDelayHead < 0 ? 'down' : ''}">${sign(dDelayHead)} CD</span>`;

    return `<div class="ws-type${open ? ' open' : ''}${cmp ? ' cmp' : ''}">
      <div class="ws-head" onclick="toggleWsType(${JSON.stringify(t.engine_type).replace(/"/g, '&quot;')})">
        <div class="wh-et">${open ? '\u25be' : '\u25b8'} ${esc(t.engine_type)}</div>
        ${stackCell}
        <div class="wh-n">${meta}</div>
        <div class="wh-cd">${headCd}</div>
      </div>
      ${detail}
    </div>`;
  }).join('');
}

// ── Top Descriptions ──────────────────────────────────────────────────────
async function loadDescriptions() {
  const data = await fetch(`/api/dashboard/top-descriptions?year=${_activeYear}`).then(r => r.json());
  const top = data.slice(0, 40);
  const maxCount = top[0]?.count || 1;
  const container = document.getElementById('desc-list');
  container.innerHTML = top.map(d => {
    const barW = Math.round(d.count / maxCount * 100);
    const cdStr = d.total_cd > 0 ? `+${d.total_cd}` : d.total_cd.toFixed(0);
    return `<div class="desc-row">
      <div class="desc-text" title="${d.text}">${d.text}</div>
      <div class="desc-bar-wrap"><div class="desc-bar" style="width:${barW}%"></div></div>
      <div class="desc-count">${d.count}×</div>
      <div class="desc-cd">${cdStr} CD</div>
    </div>`;
  }).join('');
}

// ── Breakdowns ────────────────────────────────────────────────────────────
async function loadBreakdowns() {
  const data = await fetch(`/api/dashboard/breakdowns?year=${_activeYear}`).then(r => r.json());
  const top = data.slice(0, 12);
  const maxCount = top[0]?.count || 1;
  const container = document.getElementById('bd-list');
  container.innerHTML = top.map(d => {
    const barW = Math.round(d.count / maxCount * 100);
    return `<div class="bd-row">
      <div class="bd-ms">${d.milestone}</div>
      <div class="bd-label">${d.label}</div>
      <div class="bd-bar-wrap"><div class="bd-bar" style="width:${barW}%"></div></div>
      <div class="bd-count">${d.count}</div>
      <div class="bd-cd">${d.total_cd > 0 ? '+' : ''}${d.total_cd} CD</div>
    </div>`;
  }).join('') || '<div class="loading">Keine Breakdowns</div>';
}

// ── Cycle Analysis ────────────────────────────────────────────────────────
async function loadCycles() {
  const data = await fetch(`/api/dashboard/cycles?year=${_activeYear}`).then(r => r.json());
  const container = document.getElementById('cycle-table');
  if (!data.length) { container.innerHTML = '<div class="loading">Keine Daten</div>'; return; }

  const maxIst = Math.max(...data.map(d => d.avg_ist || 0), 1);

  let html = `<table style="width:100%;border-collapse:collapse;font-size:13px">
    <thead><tr style="font-size:10px;text-transform:uppercase;letter-spacing:.4px;color:var(--muted)">
      <th style="padding:5px 8px;text-align:left">Cycle</th>
      <th style="padding:5px 8px;text-align:left">Phase</th>
      <th style="padding:5px 8px;text-align:right">Ø Soll (d)</th>
      <th style="padding:5px 8px;text-align:right">Ø Ist (d)</th>
      <th style="padding:5px 8px;text-align:right">Ø Δ (d)</th>
      <th style="padding:5px 8px;min-width:140px">Soll vs. Ist</th>
      <th style="padding:5px 8px;text-align:right">P7 CD gesamt</th>
      <th style="padding:5px 8px;text-align:right">P7 Claims</th>
      <th style="padding:5px 8px;text-align:right">Engines</th>
    </tr></thead><tbody>`;

  data.forEach((d, i) => {
    const soll  = d.avg_soll  != null ? d.avg_soll.toFixed(1)  : '–';
    const ist   = d.avg_ist   != null ? d.avg_ist.toFixed(1)   : '–';
    const delta = d.avg_delta != null ? d.avg_delta.toFixed(1) : '–';
    const isQueue = d.queue_only;
    const isTotal = d.name === 'Gesamt';
    const accentColor = isQueue ? 'var(--yellow)' : isTotal ? 'var(--text)' : 'var(--blue)';
    const deltaColor = isQueue ? 'var(--muted)'
      : d.avg_delta == null ? 'inherit'
      : d.avg_delta > 0 ? 'var(--red)' : d.avg_delta < 0 ? 'var(--green)' : 'var(--muted)';
    const deltaSign = (d.avg_delta || 0) > 0 ? '+' : '';
    const deltaDisp = isQueue ? '<i style="color:var(--muted)">Wartezeit</i>'
      : delta !== '–' ? `<span style="color:${deltaColor};font-weight:700">${deltaSign}${delta}</span>` : '–';
    const pctSoll = (!isQueue && d.avg_soll) ? Math.min(100, d.avg_soll / maxIst * 100) : 0;
    const pctIst  = d.avg_ist  ? Math.min(100, d.avg_ist  / maxIst * 100) : 0;
    const cdColor = d.p7_cd_total > 0 ? 'var(--red)' : 'var(--muted)';
    const cdSign  = d.p7_cd_total > 0 ? '+' : '';
    const bg = isTotal ? 'var(--hover)' : i % 2 === 0 ? 'var(--bg)' : 'transparent';
    const rowBorder = isQueue ? 'border-left:3px solid var(--yellow)' : isTotal ? 'border-top:2px solid var(--border)' : '';
    html += `<tr style="background:${bg};${rowBorder}">
      <td style="padding:8px 8px;font-weight:700;font-family:monospace;color:${accentColor}">${d.name}</td>
      <td style="padding:8px 8px;color:var(--muted);font-style:${isQueue?'italic':'normal'}">${d.label}</td>
      <td style="padding:8px 8px;text-align:right;font-variant-numeric:tabular-nums">${isQueue ? '<i style="color:var(--muted)">–</i>' : soll}</td>
      <td style="padding:8px 8px;text-align:right;font-variant-numeric:tabular-nums;font-weight:600">${ist}</td>
      <td style="padding:8px 8px;text-align:right">${deltaDisp}</td>
      <td style="padding:8px 8px">
        <div style="position:relative;height:14px;background:var(--neutral-bg);border-radius:var(--r-s);overflow:hidden;width:100%">
          <div style="position:absolute;top:0;left:0;height:14px;width:${pctSoll.toFixed(1)}%;background:var(--info-2);border-radius:var(--r-s)"></div>
          <div style="position:absolute;top:3px;left:0;height:8px;width:${pctIst.toFixed(1)}%;background:${accentColor};border-radius:var(--r-s);opacity:.75"></div>
        </div>
      </td>
      <td style="padding:8px 8px;text-align:right;font-weight:700;color:${cdColor}">${cdSign}${d.p7_cd_total.toLocaleString('de')} CD</td>
      <td style="padding:8px 8px;text-align:right;color:var(--muted)">${d.p7_count.toLocaleString('de')}</td>
      <td style="padding:8px 8px;text-align:right;color:var(--muted)">${d.engine_count.toLocaleString('de')}</td>
    </tr>`;
  });
  html += '</tbody></table>';
  container.innerHTML = html;
}

// ── OTD by Family ─────────────────────────────────────────────────────────
async function loadOtdByFamily() {
  const data = await fetch(`/api/dashboard/otd-by-family?year=${_activeYear}`).then(r => r.json());
  const container = document.getElementById('otd-family-table');
  if (!data.families || !data.families.length) {
    container.innerHTML = '<div class="empty-state">Kein Family-Mapping definiert.</div>';
    return;
  }
  let html = `<table style="width:100%;border-collapse:collapse;font-size:13px">
    <thead><tr style="font-size:10px;text-transform:uppercase;letter-spacing:.4px;color:var(--muted);border-bottom:2px solid var(--border)">
      <th style="padding:5px 8px;text-align:left">Family</th>
      <th style="padding:5px 8px;text-align:right">Engines</th>
      <th style="padding:5px 8px;text-align:right;color:var(--crit)">Red</th>
      <th style="padding:5px 8px;text-align:right;color:var(--warn)">Yellow</th>
      <th style="padding:5px 8px;text-align:right;color:var(--ok)">Green</th>
      <th style="padding:5px 8px;min-width:120px">Verteilung</th>
    </tr></thead><tbody>`;

  data.families.forEach((fam, i) => {
    const d     = data.data[fam];
    const red   = d.Red    || 0;
    const yel   = d.Yellow || 0;
    const grn   = d.Green  || 0;
    const unk   = d['']    || 0;
    const total = d.total  || 1;
    const pRed = (red / total * 100).toFixed(1);
    const pYel = (yel / total * 100).toFixed(1);
    const pGrn = (grn / total * 100).toFixed(1);
    const bg = i % 2 === 0 ? 'var(--bg)' : 'transparent';
    html += `<tr style="background:${bg}">
      <td style="padding:6px 8px;font-weight:700">${fam}</td>
      <td style="padding:6px 8px;text-align:right;color:var(--muted)">${total}</td>
      <td style="padding:6px 8px;text-align:right;color:var(--crit);font-weight:600">${red} <span style="font-size:10px;opacity:.7">${pRed}%</span></td>
      <td style="padding:6px 8px;text-align:right;color:var(--warn);font-weight:600">${yel} <span style="font-size:10px;opacity:.7">${pYel}%</span></td>
      <td style="padding:6px 8px;text-align:right;color:var(--ok);font-weight:600">${grn} <span style="font-size:10px;opacity:.7">${pGrn}%</span></td>
      <td style="padding:6px 8px">
        <div style="display:flex;height:10px;border-radius:var(--r-s);overflow:hidden;gap:1px">
          <div style="flex:${red};background:var(--crit);opacity:.8"></div>
          <div style="flex:${yel};background:var(--warn);opacity:.8"></div>
          <div style="flex:${grn};background:var(--ok);opacity:.8"></div>
          ${unk ? `<div style="flex:${unk};background:var(--ink-3)"></div>` : ''}
        </div>
      </td>
    </tr>`;
  });
  html += '</tbody></table>';
  container.innerHTML = html;
}

// ── B2 → B3 Delay-Propagation ────────────────────────────────────────────
let _propData = null, _propTab = 'scatter', _propChart = null;
const PROP_COLORS = ['#1a5fa8','#c0392b','#2d7a2d','#8e44ad','#d4a017','#16a085','#e67e22','#2c3e50'];

function setPropTab(tab, btn) {
  _propTab = tab;
  document.querySelectorAll('#prop-scatter-wrap,#prop-bucket-wrap').forEach(el => el.style.display = 'none');
  document.getElementById(tab === 'scatter' ? 'prop-scatter-wrap' : 'prop-bucket-wrap').style.display = '';
  btn.closest('.toggle-group').querySelectorAll('button').forEach(b => b.classList.remove('on'));
  btn.classList.add('on');
}

function renderPropScatter() {
  const rows = _propData.scatter;
  const families = [...new Set(rows.map(r => r.family))].sort();
  const pts = rows.map(r => ({x: r.delta_b2, y: r.delta_b3}));
  const reg = _linReg(pts);

  const datasets = families.map((fam, i) => ({
    label: fam,
    data: rows.filter(r => r.family === fam).map(r => ({
      x: r.delta_b2, y: r.delta_b3, proj: r.project, esn: r.esn,
    })),
    backgroundColor: PROP_COLORS[i % PROP_COLORS.length] + 'aa',
    borderColor:     PROP_COLORS[i % PROP_COLORS.length],
    pointRadius: 4, pointHoverRadius: 7, type: 'scatter',
  }));

  if (reg) {
    const xs = pts.map(p => p.x);
    const xMin = Math.min(...xs), xMax = Math.max(...xs);
    datasets.push({
      label: '_trend', type: 'line',
      data: [{x: xMin, y: reg.slope*xMin+reg.intercept}, {x: xMax, y: reg.slope*xMax+reg.intercept}],
      borderColor: '#8a847c', borderWidth: 1.5, borderDash: [6,4],
      pointRadius: 0, fill: false, tension: 0,
    });
    // Reference line y=x (perfect propagation)
    datasets.push({
      label: '_ref', type: 'line',
      data: [{x: xMin, y: xMin}, {x: xMax, y: xMax}],
      borderColor: 'rgba(27,27,27,.18)', borderWidth: 1, borderDash: [3,3],
      pointRadius: 0, fill: false, tension: 0,
    });
  }

  const ctx = document.getElementById('prop-chart').getContext('2d');
  if (_propChart) _propChart.destroy();
  _propChart = new Chart(ctx, {
    type: 'scatter', data: {datasets},
    options: {
      responsive: true, maintainAspectRatio: false,
      plugins: {
        legend: {display: false},
        tooltip: {callbacks: {label: item => {
          if (!item.raw.proj) return null;
          const d = item.raw;
          const sign = v => v > 0 ? `+${v}` : `${v}`;
          return [`${d.proj} (${d.esn})`, `Δ B2: ${sign(item.parsed.x)}d   Δ B3: ${sign(item.parsed.y)}d`];
        }}}
      },
      scales: {
        x: {title: {display:true, text:'Δ B2 (Ist − Soll, Tage)'},
            grid: {color: ctx2 => ctx2.tick.value === 0 ? '#8a847c' : 'rgba(27,27,27,.10)'}},
        y: {title: {display:true, text:'Δ B3 (Ist − Soll, Tage)'},
            grid: {color: ctx2 => ctx2.tick.value === 0 ? '#8a847c' : 'rgba(27,27,27,.10)'}},
      }
    }
  });

  const r2El = document.getElementById('prop-r2');
  if (reg) {
    const col = reg.r2 < 0.1 ? 'var(--muted)' : reg.r2 < 0.3 ? 'var(--warn)' : 'var(--green)';
    const lbl = reg.r2 < 0.1 ? 'kein linearer Zusammenhang' : reg.r2 < 0.3 ? 'schwacher Zusammenhang' : 'moderater Zusammenhang';
    const slope = reg.slope.toFixed(2);
    r2El.innerHTML = `<span style="color:${col};font-weight:600">R² = ${reg.r2.toFixed(3)}</span>
      <span style="color:var(--muted);font-size:10px;margin-left:6px">(${lbl} · Steigung ${slope})</span>`;
  }

  document.getElementById('prop-legend').innerHTML = families.map((f,i) =>
    `<span style="display:flex;align-items:center;gap:4px">
      <span style="width:10px;height:10px;border-radius:50%;background:${PROP_COLORS[i%PROP_COLORS.length]};display:inline-block"></span>${f}
    </span>`).join('');
}

function renderPropBucket() {
  const rows = _propData.by_bucket;
  const sign = v => v == null ? '–' : (v > 0 ? `+${v.toFixed(1)}` : `${v.toFixed(1)}`);
  const rateCell = (rate, n) => {
    if (rate === null || !n) return '<td style="padding:7px 10px;text-align:right;color:var(--muted)">–</td>';
    const pct = (rate*100).toFixed(1);
    const col = rate >= 0.7 ? 'var(--green)' : rate >= 0.45 ? 'var(--warn)' : 'var(--red)';
    return `<td style="padding:7px 10px;text-align:right;font-weight:600;color:${col}">${pct}%</td>`;
  };
  let h = `<table style="width:100%;border-collapse:collapse;font-size:13px">
    <thead><tr style="font-size:10px;text-transform:uppercase;color:var(--muted);border-bottom:2px solid var(--border)">
      <th style="padding:7px 10px;text-align:left">B2-Verzug</th>
      <th style="padding:7px 10px;text-align:right">Engines</th>
      <th style="padding:7px 10px;text-align:right">B3 pünktlich</th>
      <th style="padding:7px 10px;text-align:right">Ø Δ B3 (Tage)</th>
    </tr></thead><tbody>`;
  rows.forEach((r, i) => {
    const dCol = r.avg_delta_b3 == null ? '' : r.avg_delta_b3 > 10 ? 'var(--red)' : r.avg_delta_b3 > 0 ? 'var(--warn)' : 'var(--green)';
    h += `<tr style="${i%2 ? 'background:var(--hover)' : ''}">
      <td style="padding:7px 10px;font-weight:600">${r.bucket}</td>
      <td style="padding:7px 10px;text-align:right;color:var(--muted)">${r.n}</td>
      ${rateCell(r.b3_rate, r.n)}
      <td style="padding:7px 10px;text-align:right;font-weight:600;color:${dCol}">${sign(r.avg_delta_b3)}</td>
    </tr>`;
  });
  h += '</tbody></table>';
  document.getElementById('prop-bucket-table').innerHTML = h;
}

async function loadB2B3Propagation() {
  _propData = await fetch('/api/dashboard/b2-b3-propagation').then(r => r.json());
  renderPropScatter();
  renderPropBucket();
}

// ── B3 / TS Termintreue-Wahrscheinlichkeit ────────────────────────────────
let _b3Data = null;

function _rateCell(rate, n) {
  if (rate === null || rate === undefined || n === 0) return '<td style="padding:6px 10px;text-align:right;color:var(--muted)">–</td>';
  const pct = (rate * 100).toFixed(1);
  const col = rate >= 0.7 ? 'var(--green)' : rate >= 0.45 ? 'var(--warn)' : 'var(--red)';
  return `<td style="padding:6px 10px;text-align:right;font-weight:600;color:${col}">${pct}%
    <span style="font-weight:400;color:var(--muted);font-size:10px">(n=${n})</span></td>`;
}

function renderB3Table() {
  if (!_b3Data) return;
  const fam = document.getElementById('b3-family-sel').value;
  const rows = fam ? _b3Data.by_family[fam] : _b3Data.by_bucket;
  if (!rows) return;

  // B3 table
  let h = `<table style="width:100%;border-collapse:collapse;font-size:13px">
    <thead><tr style="font-size:10px;text-transform:uppercase;color:var(--muted);border-bottom:2px solid var(--border)">
      <th style="padding:6px 10px;text-align:left">Baustopps vor B3</th>
      <th style="padding:6px 10px;text-align:right">Engines</th>
      <th style="padding:6px 10px;text-align:right">B3 pünktlich</th>
    </tr></thead><tbody>`;
  rows.forEach((r, i) => {
    const bg = i % 2 ? 'background:var(--hover)' : '';
    h += `<tr style="${bg}">
      <td style="padding:6px 10px;font-weight:600">${r.bucket} Baustopps</td>
      <td style="padding:6px 10px;text-align:right;color:var(--muted)">${r.n_b3_known}</td>
      ${_rateCell(r.b3_rate, r.n_b3_known)}
    </tr>`;
  });
  h += '</tbody></table>';
  document.getElementById('b3-table').innerHTML = h;

  // TS conditional table
  let t = `<table style="width:100%;border-collapse:collapse;font-size:13px">
    <thead><tr style="font-size:10px;text-transform:uppercase;color:var(--muted);border-bottom:2px solid var(--border)">
      <th style="padding:6px 10px;text-align:left">Baustopps vor B3</th>
      <th style="padding:6px 10px;text-align:right">TS wenn B3 ok</th>
      <th style="padding:6px 10px;text-align:right">TS wenn B3 spät</th>
    </tr></thead><tbody>`;
  rows.forEach((r, i) => {
    const bg = i % 2 ? 'background:var(--hover)' : '';
    t += `<tr style="${bg}">
      <td style="padding:6px 10px;font-weight:600">${r.bucket} Baustopps</td>
      ${_rateCell(r.ts_rate_b3_ok,   r.ts_n_b3_ok)}
      ${_rateCell(r.ts_rate_b3_late, r.ts_n_b3_late)}
    </tr>`;
  });
  t += '</tbody></table>';
  document.getElementById('ts-table').innerHTML = t;

  document.getElementById('b3-note').textContent =
    `Farbskala: grün ≥ 70 % · gelb ≥ 45 % · rot < 45 %. ` +
    `Nur Engines mit vorhandenem B3-Soll-Termin (n=${_b3Data.by_bucket.reduce((s,r)=>s+r.n,0)} gesamt).`;
}

async function loadB3TsProbability() {
  _b3Data = await fetch('/api/dashboard/b3-ts-probability').then(r => r.json());
  const sel = document.getElementById('b3-family-sel');
  _b3Data.families.forEach(f => {
    const o = document.createElement('option');
    o.value = f; o.textContent = f;
    sel.appendChild(o);
  });
  renderB3Table();
}

// ── Incident–P7 Korrelation ───────────────────────────────────────────────
let _corrData = null, _corrTab = 'scatter', _corrX = 'incident_res_h',
    _corrY = 'c3_delta', _corrSort = 'baustopp_count', _corrChart = null;

const CORR_COLORS = ['#1a5fa8','#c0392b','#2d7a2d','#8e44ad','#d4a017','#16a085','#e67e22','#2c3e50'];

function setCorrTab(tab, btn) {
  _corrTab = tab;
  btn.closest('.toggle-group').querySelectorAll('button').forEach(b => b.classList.remove('on'));
  btn.classList.add('on');
  ['scatter','bucket','table'].forEach(t => {
    document.getElementById('corr-' + t).style.display = t === tab ? 'block' : 'none';
  });
  if (_corrData) renderCorr();
}
function setCorrX(v, btn) {
  _corrX = v;
  btn.closest('.toggle-group').querySelectorAll('button').forEach(b => b.classList.remove('on'));
  btn.classList.add('on');
  if (_corrData) renderCorrScatter();
}
function setCorrY(v, btn) {
  _corrY = v;
  btn.closest('.toggle-group').querySelectorAll('button').forEach(b => b.classList.remove('on'));
  btn.classList.add('on');
  if (_corrData) renderCorrScatter();
}
function setCorrSort(v, btn) {
  _corrSort = v;
  btn.closest('.toggle-group').querySelectorAll('button').forEach(b => b.classList.remove('on'));
  btn.classList.add('on');
  if (_corrData) renderCorrTable();
}

function renderCorr() {
  if (_corrTab === 'scatter') renderCorrScatter();
  else if (_corrTab === 'bucket') renderCorrBucket();
  else renderCorrTable();
}

function _linReg(pts) {
  const n = pts.length;
  if (n < 2) return null;
  const mx = pts.reduce((s,p)=>s+p.x,0)/n;
  const my = pts.reduce((s,p)=>s+p.y,0)/n;
  const num = pts.reduce((s,p)=>s+(p.x-mx)*(p.y-my),0);
  const den = pts.reduce((s,p)=>s+(p.x-mx)**2,0);
  if (den === 0) return null;
  const slope = num/den, intercept = my - slope*mx;
  const ssTot = pts.reduce((s,p)=>s+(p.y-my)**2,0);
  const ssRes = pts.reduce((s,p)=>s+(p.y-(slope*p.x+intercept))**2,0);
  const r2 = ssTot === 0 ? 0 : 1 - ssRes/ssTot;
  return { slope, intercept, r2 };
}

function renderCorrScatter() {
  const rows = _corrData.filter(r => r[_corrX] != null && r[_corrY] != null);
  const families = [...new Set(rows.map(r => r.family))].sort();
  const pts = rows.map(r => ({ x: r[_corrX], y: r[_corrY] }));
  const reg = _linReg(pts);

  const datasets = families.map((fam, i) => ({
    label: fam,
    data:  rows.filter(r => r.family === fam).map(r => ({
      x: r[_corrX], y: r[_corrY],
      esn: r.esn, proj: r.project, otd: r.otd,
      bs: r.baustopp_count, rh: r.incident_res_h,
    })),
    backgroundColor: CORR_COLORS[i % CORR_COLORS.length] + 'aa',
    borderColor:     CORR_COLORS[i % CORR_COLORS.length],
    pointRadius: 5, pointHoverRadius: 8,
    type: 'scatter',
  }));

  if (reg) {
    const xs = pts.map(p => p.x);
    const xMin = Math.min(...xs), xMax = Math.max(...xs);
    datasets.push({
      label: '_trend',
      data: [
        { x: xMin, y: reg.slope*xMin + reg.intercept },
        { x: xMax, y: reg.slope*xMax + reg.intercept },
      ],
      type: 'line',
      borderColor: '#888',
      borderWidth: 1.5,
      borderDash: [6, 4],
      pointRadius: 0,
      fill: false,
      tension: 0,
    });
  }

  const xLabel = {incident_res_h:'Stillstand gesamt (h)', baustopp_count:'Anzahl Baustopps'}[_corrX];
  const yLabel = {c3_delta:'Cycle-3 Δ Ist−Soll (Tage)', p7_cd_c3:'P7 CD Cycle 3'}[_corrY];
  const r2Txt  = reg ? `R² = ${reg.r2.toFixed(3)}` : '';
  const r2Color = !reg ? '' : reg.r2 < 0.1 ? 'var(--muted)' : reg.r2 < 0.3 ? 'var(--warn)' : 'var(--green)';

  const ctx = document.getElementById('corr-chart').getContext('2d');
  if (_corrChart) _corrChart.destroy();
  _corrChart = new Chart(ctx, {
    type: 'scatter',
    data: { datasets },
    options: {
      responsive: true, maintainAspectRatio: false,
      plugins: {
        legend: { display: false },
        tooltip: { callbacks: { label: item => {
          if (!item.raw.proj) return null;
          const d = item.raw;
          const xVal = _corrX === 'baustopp_count'
            ? `Baustopps: ${d.bs}`
            : `Stillstand: ${d.rh}h`;
          const yVal = _corrY === 'c3_delta'
            ? `C3 Δ: ${item.parsed.y}d`
            : `P7-CD: ${item.parsed.y}`;
          return [`${d.proj} (${d.esn})`, `${xVal}   ${yVal}`, `OTD: ${d.otd||'?'}`];
        }}}
      },
      scales: {
        x: { title: { display: true, text: xLabel } },
        y: { title: { display: true, text: yLabel } }
      }
    }
  });

  // R² badge
  const r2El = document.getElementById('corr-r2');
  if (r2El) r2El.innerHTML = reg
    ? `<span style="color:${r2Color};font-weight:600">${r2Txt}</span>
       <span style="color:var(--muted);font-size:10px;margin-left:6px">${
         reg.r2 < 0.1 ? '(kein linearer Zusammenhang)' :
         reg.r2 < 0.3 ? '(schwacher Zusammenhang)' : '(moderater Zusammenhang)'
       }</span>` : '';

  // Legend
  const lg = document.getElementById('corr-legend');
  lg.innerHTML = families.map((f,i) =>
    `<span style="display:flex;align-items:center;gap:4px">
      <span style="width:10px;height:10px;border-radius:50%;background:${CORR_COLORS[i%CORR_COLORS.length]};display:inline-block"></span>${f}
    </span>`).join('');
}

function renderCorrBucket() {
  const rows = _corrData.filter(r => r.c3_delta != null);
  const buckets = [
    {label: '0 Baustopps',  filter: r => r.baustopp_count === 0},
    {label: '1–2 Baustopps', filter: r => r.baustopp_count >= 1 && r.baustopp_count <= 2},
    {label: '3–5 Baustopps', filter: r => r.baustopp_count >= 3 && r.baustopp_count <= 5},
    {label: '>5 Baustopps',  filter: r => r.baustopp_count > 5},
  ];
  const avg = arr => arr.length ? (arr.reduce((s,v)=>s+v,0)/arr.length) : null;
  const fmt1 = v => v != null ? v.toFixed(1) : '–';

  let html = `<table style="width:100%;border-collapse:collapse;font-size:13px">
    <thead><tr style="font-size:10px;text-transform:uppercase;color:var(--muted);border-bottom:2px solid var(--border)">
      <th style="padding:6px 8px;text-align:left">Gruppe</th>
      <th style="padding:6px 8px;text-align:right">Engines</th>
      <th style="padding:6px 8px;text-align:right">Ø Cycle-3 Δ (d)</th>
      <th style="padding:6px 8px;text-align:right">Ø Stillstand (h)</th>
      <th style="padding:6px 8px;text-align:right">Ø P7-CD Cycle 3</th>
      <th style="padding:6px 8px;text-align:right">OTD Red %</th>
    </tr></thead><tbody>`;

  buckets.forEach((b, i) => {
    const grp   = rows.filter(b.filter);
    const n     = grp.length;
    if (!n) return;
    const c3d   = avg(grp.map(r => r.c3_delta).filter(v => v != null));
    const rh    = avg(grp.map(r => r.incident_res_h).filter(v => v != null));
    const p7cd  = avg(grp.map(r => r.p7_cd_c3).filter(v => v != null));
    const redPct= n ? (grp.filter(r => r.otd === 'Red').length / n * 100).toFixed(1) + '%' : '–';
    const bg    = i % 2 === 0 ? 'var(--bg)' : 'transparent';
    const c3col = c3d != null && c3d > 0 ? 'var(--red)' : c3d != null && c3d < 0 ? 'var(--green)' : 'inherit';
    html += `<tr style="background:${bg}">
      <td style="padding:8px 8px;font-weight:700">${b.label}</td>
      <td style="padding:8px 8px;text-align:right;color:var(--muted)">${n}</td>
      <td style="padding:8px 8px;text-align:right;font-weight:700;color:${c3col}">${c3d!=null?(c3d>0?'+':'')+fmt1(c3d)+'d':'–'}</td>
      <td style="padding:8px 8px;text-align:right">${fmt1(rh)}h</td>
      <td style="padding:8px 8px;text-align:right">${fmt1(p7cd)} CD</td>
      <td style="padding:8px 8px;text-align:right;color:var(--crit);font-weight:700">${redPct}</td>
    </tr>`;
  });
  html += '</tbody></table>';
  document.getElementById('corr-bucket-table').innerHTML = html;
}

function renderCorrTable() {
  const rows = [..._corrData].sort((a,b) => (b[_corrSort]||0) - (a[_corrSort]||0));
  const OTD_COLOR = {Red:'#c0392b', Yellow:'#d4a017', Green:'#2d7a2d'};
  let html = `<div style="overflow-x:auto;max-height:420px;overflow-y:auto">
    <table style="width:100%;border-collapse:collapse;font-size:11px;min-width:700px">
    <thead style="position:sticky;top:0;background:var(--surface)"><tr style="font-size:10px;text-transform:uppercase;color:var(--muted);border-bottom:2px solid var(--border)">
      <th style="padding:5px 8px;text-align:left">Engine</th>
      <th style="padding:5px 8px;text-align:left">Family</th>
      <th style="padding:5px 8px;text-align:right">Baustopps</th>
      <th style="padding:5px 8px;text-align:right">Stillstand (h)</th>
      <th style="padding:5px 8px;text-align:right">Cycle-3 Δ (d)</th>
      <th style="padding:5px 8px;text-align:right">P7-CD C3</th>
      <th style="padding:5px 8px;text-align:right">OTD</th>
    </tr></thead><tbody>`;
  rows.forEach((r, i) => {
    const bg   = i % 2 === 0 ? 'var(--bg)' : 'transparent';
    const c3c  = r.c3_delta != null ? (r.c3_delta > 0 ? 'var(--red)' : r.c3_delta < 0 ? 'var(--green)' : 'inherit') : 'inherit';
    const c3d  = r.c3_delta != null ? (r.c3_delta > 0 ? '+' : '') + r.c3_delta.toFixed(1) + 'd' : '–';
    const otdc = OTD_COLOR[r.otd] || 'var(--muted)';
    html += `<tr style="background:${bg}">
      <td style="padding:4px 8px;font-family:monospace;font-size:10px">${r.project}<br><span style="color:var(--muted)">${r.esn}</span></td>
      <td style="padding:4px 8px;white-space:nowrap">${r.family}</td>
      <td style="padding:4px 8px;text-align:right;font-weight:${r.baustopp_count>0?'700':'400'};color:${r.baustopp_count>3?'var(--crit)':'inherit'}">${r.baustopp_count}</td>
      <td style="padding:4px 8px;text-align:right">${r.incident_res_h ? r.incident_res_h.toFixed(1)+'h' : '–'}</td>
      <td style="padding:4px 8px;text-align:right;font-weight:700;color:${c3c}">${c3d}</td>
      <td style="padding:4px 8px;text-align:right">${r.p7_cd_c3 ? r.p7_cd_c3.toLocaleString('de')+' CD' : '–'}</td>
      <td style="padding:4px 8px;text-align:right;font-weight:700;color:${otdc}">${r.otd||'–'}</td>
    </tr>`;
  });
  html += '</tbody></table></div>';
  document.getElementById('corr-engine-table').innerHTML = html;
}

async function loadIncidentP7Correlation() {
  _corrData = await fetch('/api/dashboard/incident-p7-correlation').then(r => r.json());
  renderCorr();
}

// ── Incidents by Family × Year ────────────────────────────────────────────
let _incFamData = null, _incMetric = 'baustopps_p_slu', _incGroup = '', _incScope = 'map', _incTyp = '';

function setIncGroup(group) {
  _incGroup = group;
  loadIncidentsByFamilyYear();
}
function setIncScope(v) { _incScope = v; loadIncidentsByFamilyYear(); }
function setIncTyp(v)   { _incTyp = v;   loadIncidentsByFamilyYear(); }

function setIncMetric(metric, btn) {
  _incMetric = metric;
  btn.closest('.toggle-group').querySelectorAll('button').forEach(b => b.classList.remove('on'));
  btn.classList.add('on');
  if (_incFamData) renderIncFamilyYear(_incFamData);
}

const INC_METRIC_LABEL = {
  baustopps_p_slu:  'Baustopps / SLU',
  baustopps_p_eng:  'Baustopps / Engine im Haus',
  baustopps:        'Baustopps absolut',
  baustopp_rate:    'Baustopp-Anteil',
  bs_h_p_slu:       'Baustopp-Std. / SLU',
  bs_h:             'Baustopp-Std. absolut',
  median_res_h:     'Median Lösungszeit',
  incidents:        'Incidents gesamt',
};
// Zählgrößen: hier ist der Gruppenwert ein echter Anteil am Familienwert.
const INC_METRIC_COUNTS = ['incidents', 'baustopps', 'bs_h'];

function renderIncFamilyYear(data) {
  const container = document.getElementById('incident-family-year-table');
  const { families, years, data: d, groups, years_edge, group } = data;
  if (!families.length) {
    container.innerHTML = '<div class="empty-state">'
      + (group ? 'Keine Meldungen der Gruppe „' + group + '".'
               : 'Kein Family-Mapping oder keine Incidents.') + '</div>';
    return;
  }
  const metric   = _incMetric;
  const isTime   = metric === 'median_res_h' || metric === 'bs_h' || metric === 'bs_h_p_slu';
  const isRate   = metric === 'baustopps_p_eng' || metric === 'baustopps_p_slu';
  const isPct    = metric === 'baustopp_rate';
  const isCount  = INC_METRIC_COUNTS.indexOf(metric) >= 0;
  const getValue = (fam, yr) => d[fam]?.[yr]?.[metric] ?? null;

  let maxVal = 1;
  families.forEach(f => years.forEach(y => { const v = getValue(f,y); if(v && v > maxVal) maxVal = v; }));

  const fmtVal = v => {
    if (v == null) return '–';
    if (isPct)   return v.toLocaleString('de', {maximumFractionDigits:1}) + ' %';
    if (isTime)  return v.toLocaleString('de', {maximumFractionDigits:1}) + 'h';
    if (isRate)  return v.toLocaleString('de', {minimumFractionDigits:2, maximumFractionDigits:2});
    return v.toLocaleString('de');
  };

  let html = `<div style="overflow-x:auto"><table style="border-collapse:collapse;font-size:13px;min-width:500px;width:100%">
    <thead><tr style="font-size:10px;text-transform:uppercase;letter-spacing:.4px;color:var(--muted);border-bottom:2px solid var(--border)">
      <th style="padding:5px 10px;text-align:left">Family</th>`;
  years.forEach(y => {
    html += `<th style="padding:5px 10px;text-align:right">${y}<div style="font-weight:400;font-size:10px;opacity:.6">betroffen / da</div></th>`;
  });
  html += `</tr></thead><tbody>`;

  families.forEach((fam, fi) => {
    const bg    = fi % 2 === 0 ? 'var(--bg)' : 'transparent';
    const famId = 'grp-' + fam.replace(/[^a-z0-9]/gi, '_');
    html += `<tr style="background:${bg};cursor:pointer" onclick="const el=document.getElementById('${famId}');el.style.display=el.style.display==='none'?'':'none'">
      <td style="padding:7px 10px;font-weight:700;white-space:nowrap">▸ ${fam}</td>`;
    years.forEach(y => {
      const cell  = d[fam]?.[y];
      const v     = getValue(fam, y);
      const eng   = cell?.engines ?? 0;
      const tot   = cell?.engines_total ?? 0;
      const ratio = v != null ? Math.min(v / maxVal, 1) : 0;
      const bgC   = v != null && v > 0 ? `rgba(192,57,43,${(ratio * 0.7).toFixed(2)})` : 'transparent';
      const fg    = ratio > 0.5 ? '#fff' : 'inherit';
      const quota = tot ? `${eng} / ${tot}` : (eng || '–');
      const share = tot ? ` (${Math.round(eng / tot * 100)} % der ${tot} Engines, die ${y} im Haus waren)` : '';
      const extra = (cell ? ` · ${cell.incidents} Incidents · ${cell.baustopps} Baustopps · `
                     + `${Math.round(cell.bs_h).toLocaleString('de')} Baustopp-Std. · SLU im Shop ${(cell.slu||0).toLocaleString('de',{maximumFractionDigits:1})}` : '')
                  + (cell && cell.hub_only ? ` · davon ${cell.hub_only} Meldungen nur in LUD Core (nicht in den Raten)` : '');
      html += `<td style="padding:6px 10px;text-align:right;background:${bgC};color:${fg}${cell && cell.hub_only ? ';box-shadow:inset 3px 0 0 var(--muted)' : ''}"
                   title="${eng} betroffene Engines${share}${extra}">
        <div style="font-weight:700">${fmtVal(v)}</div>
        <div style="font-size:10px;opacity:.65">${quota}</div>
      </td>`;
    });
    html += `</tr>`;

    // ── Meldende Gruppen, nach denselben Jahren aufgeteilt ──
    const grpList = (groups && groups[fam]) || [];
    const gv = c => (c && c[metric] != null) ? c[metric] : null;
    // Balkenlänge: bei Zählgrößen der echte Anteil am Familienjahr, sonst der
    // Abstand zur stärksten Gruppe derselben Spalte.
    const colMax = {};
    years.forEach(y => {
      let mx = 0;
      grpList.forEach(g => { const v = gv(g.years?.[y]); if (v && v > mx) mx = v; });
      colMax[y] = isCount ? (getValue(fam, y) || mx || 1) : (mx || 1);
    });
    let totMax = 0;
    grpList.forEach(g => { const v = gv(g.total); if (v && v > totMax) totMax = v; });
    const totDen = isCount
      ? (years.reduce((a, y) => a + (getValue(fam, y) || 0), 0) || totMax || 1)
      : (totMax || 1);

    const gcell = (c, den, pad) => {
      const v   = gv(c);
      const pc  = v != null && den ? Math.max(0, Math.min(100, v / den * 100)) : 0;
      const sub = !c ? ''
                : (isCount ? Math.round(pc) + ' %' : c.incidents + ' Inc.');
      const hot = isPct && v != null && v > 50;
      return `<td style="padding:${pad};text-align:right;background:linear-gradient(to left,`
        + `rgba(192,57,43,.16) ${pc.toFixed(0)}%, transparent ${pc.toFixed(0)}%)"
        title="${c ? c.incidents + ' Incidents · ' + c.baustopps + ' Baustopps · '
                     + c.engines + ' Engines' : 'keine Meldungen'}">
        <div style="font-weight:${hot ? '700' : '400'};color:${hot ? 'var(--crit)' : 'inherit'}">${fmtVal(v)}</div>
        <div style="font-size:10px;opacity:.55">${sub}</div></td>`;
    };

    html += `<tr id="${famId}" style="display:none"><td colspan="${years.length + 1}" style="padding:0 0 4px 0">
      <div style="padding:5px 10px 3px 28px;font-size:10px;color:var(--muted)">
        Meldende Gruppe · ${INC_METRIC_LABEL[metric] || metric} je Jahr
        ${isCount ? '· Balken und Prozent = Anteil an der Familie in diesem Jahr'
                  : '· Balken = Abstand zur stärksten Gruppe der Spalte'}</div>
      <table style="width:100%;border-collapse:collapse;font-size:11px;background:var(--hover)">
        <thead><tr style="font-size:10px;text-transform:uppercase;color:var(--muted);border-bottom:1px solid var(--border)">
          <th style="padding:4px 10px 4px 28px;text-align:left">Gruppe</th>`;
    years.forEach(y => { html += `<th style="padding:4px 8px;text-align:right">${y}</th>`; });
    html += `<th style="padding:4px 10px 4px 8px;text-align:right">Gesamt</th>
        </tr></thead><tbody>`;
    grpList.forEach((g, gi) => {
      const gbg = gi % 2 === 0 ? 'transparent' : 'rgba(0,0,0,.03)';
      html += `<tr style="background:${gbg}">
        <td style="padding:4px 10px 4px 28px;color:var(--muted)">${g.group}</td>`;
      years.forEach(y => { html += gcell(g.years?.[y], colMax[y], '4px 8px'); });
      html += gcell(g.total, totDen, '4px 10px 4px 8px');
      html += `</tr>`;
    });
    html += `</tbody></table></td></tr>`;
  });
  html += '</tbody></table>';
  html += `<div style="font-size:10px;color:var(--muted);margin-top:6px;line-height:1.5">
    Jahresanker ist das Meldedatum — eine Engine zählt in jedem Jahr, in dem sie gemeldet wurde.
    Die Jahresspalten addieren sich deshalb <b>nicht</b>. „betroffen / da" = Engines mit mindestens
    einer Meldung gegenüber allen Engines der Familie, deren Shop Visit in dem Jahr lief.
    <b>SLU</b> = Arbeitsumfang, über die Verweildauer im Shop aufs Jahr verteilt — „/ SLU" ist fair,
    wenn sich der Workscope-Mix ändert. <b>Baustopp-Std.</b> = überlappungsfrei je Engine, nur in der
    Shop-Zeit, im Jahr, in dem die Stunden anfallen; offene Baustopps laufen bis zum Datenstand.
    <b>Median Lösungszeit</b> ist Ticket-Laufzeit, kein Stillstand.`;
  const T = data.types || {};
  if (T.stoer != null) {
    html += `<br>Typ laut LUD Core: ${(T.stoer||0).toLocaleString('de')} Störungen · ${(T.anf||0).toLocaleString('de')} Anfragen`
      + (T.heuristik ? ` · <b>${T.heuristik.toLocaleString('de')} ohne Typ-Feld</b> (per Text-Heuristik eingeordnet)` : '')
      + (data.typ ? ` — <b>gefiltert auf ${data.typ === 'anf' ? 'Anfragen' : 'Störungen'}</b>` : '')
      + '. Achtung: auch Anfragen können Baustopps sein (z. B. Befundbeauftragung).';
  }
  if (data.scope === 'hub') {
    html += `<br><b>${(data.hub_only||0).toLocaleString('de')} Meldungen</b> an Projekten, die nur in LUD Core stehen
      (Zellen mit grauem Rand) — Familie aus dem LUD-Core-Projekt; sie zählen in den absoluten Werten,
      nicht in den Raten, weil Flotte und SLU dieser Engines unbekannt sind.`;
  } else if (data.scope === 'sap') {
    html += `<br>Alle Engines des SAP-Exports; Typen ohne Eintrag in engine_families.json per Heuristik gruppiert.`;
  } else {
    html += `<br>Nur Engine-Typen aus engine_families.json.`;
  }
  if (group) {
    html += `<br><b>Gefiltert auf die Gruppe „${group}"</b> — „da" bleibt die gesamte Familie,
      „betroffen" zählt nur Engines mit Meldungen dieser Gruppe.`;
  }
  if (years_edge && years_edge.length) {
    html += '<br>' + years_edge.map(h =>
      `${h.year} ist ein angebrochenes Randjahr (${h.incidents.toLocaleString('de')} Meldungen, `
      + `unter 25 % des Folgejahres) — Werte dort nicht mit vollen Jahren vergleichen.`).join(' ');
  }
  html += '</div></div>';
  container.innerHTML = html;
}

async function loadIncidentsByFamilyYear() {
  const q = new URLSearchParams({scope: _incScope});
  if (_incGroup) q.set('group', _incGroup);
  if (_incTyp)   q.set('typ', _incTyp);
  _incFamData = await fetch('/api/dashboard/incidents-by-family-year?' + q).then(r => r.json());
  fillIncGroupSelect(_incFamData);
  renderIncFamilyYear(_incFamData);
}

// Die Gruppenliste kommt ungefiltert mit, damit die Auswahl nicht zusammen-
// schrumpft, sobald man eine Gruppe gewählt hat.
function fillIncGroupSelect(data) {
  const sel = document.getElementById('inc-group-select');
  if (!sel || !data.all_groups) return;
  const cur = data.group || '';
  sel.innerHTML = '<option value="">— alle Gruppen —</option>'
    + data.all_groups.map(g =>
        `<option value="${g.group.replace(/"/g,'&quot;')}"${g.group === cur ? ' selected' : ''}>`
        + `${g.group} (${g.incidents.toLocaleString('de')})</option>`).join('');
}

// ── Cycle Delta by Family × Year ─────────────────────────────────────────
let _cycleDeltaData = null, _cycleDeltaCycle = 'all', _cycleDeltaMetric = 'avg_delta';

function setCycleDeltaCycle(cycle, btn) {
  _cycleDeltaCycle = cycle;
  document.querySelectorAll('#cycle-delta-pills .year-pill').forEach(b => b.classList.remove('on'));
  btn.classList.add('on');
  loadCycleDeltaByFamilyYear();
}

function setCycleDeltaMetric(metric, btn) {
  _cycleDeltaMetric = metric;
  btn.closest('.toggle-group').querySelectorAll('button').forEach(b => b.classList.remove('on'));
  btn.classList.add('on');
  if (_cycleDeltaData) renderCycleDeltaFamilyYear(_cycleDeltaData);
}

function renderCycleDeltaFamilyYear(data) {
  const container = document.getElementById('cycle-delta-family-year-table');
  const { families, years, data: d } = data;
  if (!families.length) {
    container.innerHTML = '<div class="empty-state">Kein Family-Mapping definiert.</div>';
    return;
  }

  const metric = _cycleDeltaMetric;
  const getValue = (fam, yr) => {
    const cell = d[fam]?.[yr];
    if (!cell) return null;
    return cell[metric] ?? null;
  };

  // For Δ: symmetric color scale. For Ist/Soll: one-sided red intensity.
  const isDelta = metric === 'avg_delta';
  let maxAbs = 1;
  families.forEach(f => years.forEach(y => {
    const v = getValue(f, y);
    if (v != null && Math.abs(v) > maxAbs) maxAbs = Math.abs(v);
  }));

  const cellColor = (v, n) => {
    const dim = n < 3;
    if (v == null) return { bg: 'transparent', fg: 'var(--muted)', opacity: 1 };
    if (isDelta) {
      const ratio = Math.min(Math.abs(v) / maxAbs, 1);
      const alpha = (ratio * 0.65 * (dim ? 0.4 : 1)).toFixed(2);
      const bg = v > 0
        ? `rgba(192,57,43,${alpha})`
        : `rgba(45,122,45,${alpha})`;
      const fg = ratio > 0.55 && !dim ? '#fff' : 'inherit';
      return { bg, fg, opacity: dim ? 0.5 : 1 };
    } else {
      const ratio = Math.min(v / maxAbs, 1);
      const alpha = (ratio * 0.55 * (dim ? 0.4 : 1)).toFixed(2);
      return { bg: `rgba(26,95,168,${alpha})`, fg: ratio > 0.6 && !dim ? '#fff' : 'inherit', opacity: dim ? 0.5 : 1 };
    }
  };

  const fmtVal = v => v == null ? '–' : (v > 0 && isDelta ? '+' : '') + v.toLocaleString('de', {minimumFractionDigits:1, maximumFractionDigits:1}) + ' d';

  // Column totals (row avg across years per family)
  let html = `<div style="overflow-x:auto"><table style="border-collapse:collapse;font-size:13px;min-width:500px">
    <thead><tr style="font-size:10px;text-transform:uppercase;letter-spacing:.4px;color:var(--muted);border-bottom:2px solid var(--border)">
      <th style="padding:5px 10px;text-align:left;white-space:nowrap">Family</th>
      <th style="padding:5px 8px;text-align:right;color:var(--muted)">Ø gesamt</th>`;
  years.forEach(y => { html += `<th style="padding:5px 10px;text-align:right">${y}</th>`; });
  html += `</tr></thead><tbody>`;

  families.forEach((fam, fi) => {
    const rowBg = fi % 2 === 0 ? 'var(--bg)' : 'transparent';
    const vals  = years.map(y => getValue(fam, y)).filter(v => v != null);
    const rowAvg = vals.length ? Math.round(vals.reduce((a, b) => a + b, 0) / vals.length * 10) / 10 : null;
    const rowN   = years.reduce((s, y) => s + (d[fam]?.[y]?.n || 0), 0);

    html += `<tr style="background:${rowBg}">
      <td style="padding:7px 10px;font-weight:700;white-space:nowrap">${fam}</td>
      <td style="padding:7px 8px;text-align:right;font-weight:700;color:var(--muted)">${fmtVal(rowAvg)}</td>`;

    years.forEach(y => {
      const v    = getValue(fam, y);
      const n    = d[fam]?.[y]?.n ?? 0;
      const { bg, fg, opacity } = cellColor(v, n);
      html += `<td style="padding:6px 10px;text-align:right;background:${bg};color:${fg};opacity:${opacity}">
        <div style="font-weight:600">${fmtVal(v)}</div>
        ${n ? `<div style="font-size:10px;opacity:.7">n=${n}</div>` : ''}
      </td>`;
    });
    html += `</tr>`;
  });
  html += '</tbody></table></div>';
  if (isDelta) {
    html += `<div style="margin-top:8px;font-size:10px;color:var(--muted)">
      <span style="color:var(--red)">■</span> Verzug (Ist &gt; Soll) &nbsp;
      <span style="color:var(--green)">■</span> Besser als Plan &nbsp;
      Gedimmt = n &lt; 3 Engines</div>`;
  }
  container.innerHTML = html;
}

async function loadCycleDeltaByFamilyYear() {
  const params = new URLSearchParams({ cycle: _cycleDeltaCycle });
  _cycleDeltaData = await fetch(`/api/dashboard/cycle-delta-by-family-year?${params}`).then(r => r.json());
  renderCycleDeltaFamilyYear(_cycleDeltaData);
}

// ── P7 by Family × Year ───────────────────────────────────────────────────
let _p7FamData = null, _p7FamMetric = 'p7_cd', _p7Cycle = 'all';

function setP7FamMetric(metric, btn) {
  _p7FamMetric = metric;
  btn.closest('.toggle-group').querySelectorAll('button').forEach(b => b.classList.remove('on'));
  btn.classList.add('on');
  if (_p7FamData) renderP7FamilyYear(_p7FamData);
}

function setP7Cycle(cycle, btn) {
  _p7Cycle = cycle;
  document.querySelectorAll('#p7-cycle-pills .year-pill').forEach(b => b.classList.remove('on'));
  btn.classList.add('on');
  loadP7ByFamilyYear();
}

function renderP7FamilyYear(data) {
  const container = document.getElementById('p7-family-year-table');
  const { families, years, data: d } = data;
  if (!families.length) {
    container.innerHTML = '<div class="empty-state">Kein Family-Mapping definiert.</div>';
    return;
  }

  const metric = _p7FamMetric;
  const getValue = (fam, yr) => {
    const cell = d[fam]?.[yr];
    if (!cell) return null;
    if (metric === 'p7_cd')    return cell.p7_cd;
    if (metric === 'p7_count') return cell.p7_count;
    if (metric === 'avg_cd')   return cell.engines ? Math.round(cell.p7_cd / cell.engines * 10) / 10 : null;
    return null;
  };

  // Find max for heat scaling
  let maxVal = 1;
  families.forEach(f => years.forEach(y => {
    const v = getValue(f, y);
    if (v && v > maxVal) maxVal = v;
  }));

  const fmtVal = v => {
    if (v == null) return '–';
    if (metric === 'p7_count') return v.toLocaleString('de');
    return v.toLocaleString('de', {minimumFractionDigits: metric === 'avg_cd' ? 1 : 0, maximumFractionDigits: metric === 'avg_cd' ? 1 : 0}) + ' CD';
  };

  let html = `<div style="overflow-x:auto"><table style="border-collapse:collapse;font-size:13px;min-width:500px">
    <thead><tr style="font-size:10px;text-transform:uppercase;letter-spacing:.4px;color:var(--muted);border-bottom:2px solid var(--border)">
      <th style="padding:5px 10px;text-align:left;white-space:nowrap">Family</th>
      <th style="padding:5px 8px;text-align:right;color:var(--muted)">Gesamt</th>`;
  years.forEach(y => { html += `<th style="padding:5px 10px;text-align:right">${y}</th>`; });
  html += `</tr></thead><tbody>`;

  families.forEach((fam, fi) => {
    const bg = fi % 2 === 0 ? 'var(--bg)' : 'transparent';
    // Row total
    const total = years.reduce((s, y) => s + (getValue(fam, y) || 0), 0);
    const totalEngines = years.reduce((s, y) => s + (d[fam]?.[y]?.engines || 0), 0);
    const totalDisp = metric === 'avg_cd'
      ? (totalEngines ? (total / years.filter(y => d[fam]?.[y]).length).toFixed(1) + ' CD' : '–')
      : fmtVal(total);

    html += `<tr style="background:${bg}">
      <td style="padding:7px 10px;font-weight:700;white-space:nowrap">${fam}</td>
      <td style="padding:7px 8px;text-align:right;font-weight:700;color:var(--muted)">${totalDisp}</td>`;

    years.forEach(y => {
      const v = getValue(fam, y);
      const eng = d[fam]?.[y]?.engines || 0;
      const ratio = v != null ? Math.min(v / maxVal, 1) : 0;
      const intensity = Math.round(ratio * 180);
      const bg2 = v != null && v > 0
        ? `rgba(192,57,43,${(ratio * 0.6).toFixed(2)})`
        : 'transparent';
      const fg = ratio > 0.5 ? '#fff' : 'inherit';
      html += `<td style="padding:6px 10px;text-align:right;background:${bg2};color:${fg}">
        <div style="font-weight:600">${fmtVal(v)}</div>
        ${eng ? `<div style="font-size:10px;opacity:.7">${eng} Eng.</div>` : ''}
      </td>`;
    });
    html += `</tr>`;
  });
  html += '</tbody></table></div>';
  container.innerHTML = html;
}

async function loadP7ByFamilyYear() {
  const params = new URLSearchParams({cycle: _p7Cycle});
  _p7FamData = await fetch(`/api/dashboard/p7-by-family-year?${params}`).then(r => r.json());
  renderP7FamilyYear(_p7FamData);
}

// ── Cycles by Family ──────────────────────────────────────────────────────
async function loadCyclesByFamily() {
  const data = await fetch(`/api/dashboard/cycles-by-family?year=${_activeYear}`).then(r => r.json());
  const container = document.getElementById('cycle-family-table');
  if (!data.families || !data.families.length) {
    container.innerHTML = '<div class="empty-state">Kein Family-Mapping definiert — bitte engine_families.json befüllen.</div>';
    return;
  }
  const families = data.families;
  const cycles   = data.cycles;

  // Max Ist across all families/cycles for bar scaling
  let maxIst = 1;
  cycles.forEach(c => families.forEach(f => {
    const v = c.by_family[f]?.avg_ist;
    if (v && v > maxIst) maxIst = v;
  }));

  let html = `<div style="overflow-x:auto"><table style="width:100%;border-collapse:collapse;font-size:13px;min-width:600px">
    <thead><tr style="font-size:10px;text-transform:uppercase;letter-spacing:.4px;color:var(--muted);border-bottom:2px solid var(--border)">
      <th style="padding:5px 8px;text-align:left;white-space:nowrap">Cycle</th>
      <th style="padding:5px 8px;text-align:left">Phase</th>`;
  families.forEach(f => {
    html += `<th style="padding:5px 8px;text-align:right;white-space:nowrap">${f}</th>`;
  });
  html += `</tr></thead><tbody>`;

  cycles.forEach((c, i) => {
    const isQueue = c.queue_only;
    const isTotal = c.name === 'Gesamt';
    const accentColor = isQueue ? 'var(--yellow)' : isTotal ? 'var(--text)' : 'var(--blue)';
    const rowBorder = isTotal ? 'border-top:2px solid var(--border)' : isQueue ? 'border-left:3px solid var(--yellow)' : '';
    const bg = isTotal ? 'var(--hover)' : i % 2 === 0 ? 'var(--bg)' : 'transparent';

    html += `<tr style="background:${bg};${rowBorder}">
      <td style="padding:8px 8px;font-weight:700;font-family:monospace;color:${accentColor};white-space:nowrap">${c.name}</td>
      <td style="padding:8px 8px;color:var(--muted);font-style:${isQueue?'italic':'normal'};white-space:nowrap">${c.label}</td>`;

    families.forEach(f => {
      const v = c.by_family[f];
      if (!v || v.engines === 0) {
        html += `<td style="padding:8px 8px;text-align:right;color:var(--muted)">–</td>`;
        return;
      }
      const ist   = v.avg_ist   != null ? v.avg_ist.toFixed(1)   : '–';
      const delta = v.avg_delta != null ? v.avg_delta.toFixed(1) : null;
      const deltaColor = isQueue || !delta ? 'var(--muted)'
        : v.avg_delta > 0 ? 'var(--red)' : v.avg_delta < 0 ? 'var(--green)' : 'var(--muted)';
      const deltaStr = isQueue ? '' : delta != null
        ? `<div style="font-size:10px;color:${deltaColor};font-weight:700">${v.avg_delta > 0 ? '+' : ''}${delta}d</div>` : '';
      const pctIst = v.avg_ist ? Math.min(100, v.avg_ist / maxIst * 100) : 0;
      html += `<td style="padding:6px 8px;text-align:right">
        <div style="font-weight:600">${ist}d</div>
        ${deltaStr}
        <div style="margin-top:3px;height:4px;background:var(--neutral-bg);border-radius:var(--r-s);overflow:hidden">
          <div style="height:4px;width:${pctIst.toFixed(1)}%;background:${accentColor};opacity:.7;border-radius:var(--r-s)"></div>
        </div>
        <div style="font-size:10px;color:var(--muted)">${v.engines} Eng.</div>
      </td>`;
    });
    html += `</tr>`;
  });
  html += '</tbody></table></div>';
  container.innerHTML = html;
}

// ── Werkstattlast ─────────────────────────────────────────────────────────
let _slUnit = 'slu', _slGran = 'month', _slChart = null, _slData = null;

function setSlUnit(u, btn) {
  _slUnit = u;
  document.querySelectorAll('#sl-unit-toggle button').forEach(b => b.classList.remove('on'));
  btn.classList.add('on');
  if (_slData) { renderSlChart(_slData); renderSlTable(_slData); }
}

function setSlGran(g, btn) {
  _slGran = g;
  document.querySelectorAll('#sl-gran-toggle button').forEach(b => b.classList.remove('on'));
  btn.classList.add('on');
  loadShopLoad();
}

async function loadShopLoad() {
  const data = await fetch(`/api/dashboard/shop-load?year=${_activeYear}&gran=${_slGran}`).then(r => r.json());
  _slData = data;
  renderSlKpi(data);
  renderSlChart(data);
  renderSlTable(data);
  const s = data.summary;
  document.getElementById('sl-method').innerHTML =
    `<b>Methode:</b> ${s.note} Grundlage: ${s.engines_actual} abgeschlossene Engines (Ist), ` +
    `${s.engines_wip} im laufenden Bestand, ${s.engines_plan} geplanter Zulauf; ` +
    `${s.skipped_stale} inaktive und ${s.skipped_dummy} Dummy-Datensätze aussortiert, ` +
    `${s.slu_imputed} SLU-Werte über den Familien-Median ergänzt (sonst fehlte rund ein Viertel der Arbeit). ` +
    `Belastbar bis ${s.valid_through} — danach fehlt der noch nicht gebuchte Zulauf und die Kurve läuft künstlich aus.`;
  document.getElementById('sl-hint').textContent =
    `Arbeit fällt über die TAT an (Median ${s.tat_median_d} Tage) · Datenstand ${data.data_through || '–'}`;
}

function _slTot(d) { return _slUnit === 'hours' ? d.total_hours : d.total_slu; }
function _slPart(d, k) { return _slUnit === 'hours' ? Math.round(d[k] * 3000) : d[k]; }
function _slFmt(v) { return _slUnit === 'hours' ? v.toLocaleString('de-DE') + ' h' : v.toFixed(2) + ' SLU'; }

function renderSlKpi(data) {
  const { periods } = data, s = data.summary;
  const recent = periods.filter(p => !data.data[p].is_forecast).slice(-12);
  const avg = recent.length ? recent.reduce((a, p) => a + data.data[p].total_slu, 0) / recent.length : 0;
  const eng = recent.length ? recent.reduce((a, p) => a + data.data[p].avg_engines, 0) / recent.length : 0;
  const peak = recent.reduce((a, p) => data.data[p].total_slu > (data.data[a] || {}).total_slu ? p : a, recent[0]);
  document.getElementById('sl-kpi-row').innerHTML = `
    <div><div style="font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:.5px">Ø Last (12 Ist-Perioden)</div>
         <div style="font-size:28px;font-weight:800;color:var(--blue)">${avg.toFixed(1)}</div>
         <div style="font-size:11px;color:var(--muted)">SLU · ${Math.round(avg * 3000).toLocaleString('de-DE')} h</div></div>
    <div style="width:1px;background:var(--border)"></div>
    <div><div style="font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:.5px">Ø Engines im Shop</div>
         <div style="font-size:28px;font-weight:800">${eng.toFixed(0)}</div>
         <div style="font-size:11px;color:var(--muted)">gleichzeitig, im selben Zeitraum</div></div>
    <div style="width:1px;background:var(--border)"></div>
    <div><div style="font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:.5px">Spitze</div>
         <div style="font-size:28px;font-weight:800">${peak ? data.data[peak].total_slu.toFixed(1) : '–'}</div>
         <div style="font-size:11px;color:var(--muted)">${peak || ''}</div></div>
    <div style="width:1px;background:var(--border)"></div>
    <div><div style="font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:.5px">Modellanteil</div>
         <div style="font-size:28px;font-weight:800;color:var(--purple)">${s.engines_wip + s.engines_plan}</div>
         <div style="font-size:11px;color:var(--muted)">Engines ohne Ist-SA im Modell</div></div>
  `;
}

function renderSlChart(data) {
  const ctx = document.getElementById('sl-chart').getContext('2d');
  const { periods } = data;
  if (_slChart) _slChart.destroy();
  _slChart = new Chart(ctx, {
    type: 'bar',
    data: {
      labels: periods,
      datasets: [
        { label: 'Ist (abgeschlossen)', backgroundColor: '#1a5fa8', stack: 'l', order: 3,
          data: periods.map(p => _slPart(data.data[p], 'actual')) },
        { label: 'Laufender Bestand (Modell)', backgroundColor: 'rgba(109,40,217,.55)', stack: 'l', order: 3,
          data: periods.map(p => _slPart(data.data[p], 'wip')) },
        { label: 'Geplanter Zulauf', backgroundColor: 'rgba(26,95,168,.28)', stack: 'l', order: 3,
          data: periods.map(p => _slPart(data.data[p], 'plan')) },
        { label: 'Ø Engines im Shop', type: 'line', yAxisID: 'y2', borderColor: '#c0392b',
          borderWidth: 2, pointRadius: 0, tension: .25, order: 1,
          data: periods.map(p => data.data[p].avg_engines) },
      ],
    },
    options: {
      responsive: true, maintainAspectRatio: false,
      plugins: {
        legend: { display: true, labels: { boxWidth: 12, font: { size: 11 } } },
        tooltip: {
          mode: 'index',
          callbacks: {
            label: it => it.dataset.yAxisID === 'y2'
              ? ` ${it.dataset.label}: ${it.raw}`
              : ` ${it.dataset.label}: ${_slFmt(it.raw)}`,
            footer: items => {
              const d = data.data[items[0].label];
              const parts = [`Gesamt: ${_slFmt(_slTot(d))}`];
              if (items[0].label > data.summary.valid_through) parts.push('jenseits einer TAT-Länge → Untergrenze');
              else if (d.is_forecast) parts.push('überwiegend Modell, nicht Ist');
              return parts;
            },
          },
        },
      },
      scales: {
        x: { stacked: true, ticks: { font: { size: 11 }, maxRotation: 45 } },
        y: { stacked: true, title: { display: true, font: { size: 11 },
             text: _slUnit === 'hours' ? 'Arbeitsstunden im Monat' : 'Shop Load Units (SLU) im Monat' },
             ticks: { font: { size: 11 } } },
        y2: { position: 'right', grid: { drawOnChartArea: false }, title: { display: true, text: 'Engines im Shop', font: { size: 11 } },
              ticks: { font: { size: 11 } } },
      },
    },
  });
}

function renderSlTable(data) {
  const { periods } = data;
  const wrap = document.getElementById('sl-table-wrap');
  if (!periods.length) { wrap.innerHTML = '<div class="loading">Keine Daten</div>'; return; }
  let html = `<table class="heatmap"><thead><tr><th>Periode</th><th>Gesamt</th><th>Ist</th>
    <th>Laufender Bestand</th><th>Geplanter Zulauf</th><th>Ø Engines im Shop</th></tr></thead><tbody>`;
  periods.forEach(p => {
    const d = data.data[p];
    const beyond = p > data.summary.valid_through;
    html += `<tr${d.is_forecast ? ' style="opacity:.65"' : ''}>
      <td style="font-weight:600">${p}${beyond ? ' <span style="font-weight:400;font-size:10px;color:var(--red)">(Untergrenze)</span>'
                                        : d.is_forecast ? ' <span style="font-weight:400;font-size:10px;color:var(--purple)">(Modell)</span>' : ''}</td>
      <td style="text-align:right;font-weight:700;color:var(--blue)">${_slFmt(_slTot(d))}</td>
      <td style="text-align:right">${d.actual.toFixed(2)}</td>
      <td style="text-align:right;color:var(--purple)">${d.wip.toFixed(2)}</td>
      <td style="text-align:right;color:var(--muted)">${d.plan.toFixed(2)}</td>
      <td style="text-align:right">${d.avg_engines.toFixed(1)}</td></tr>`;
  });
  wrap.innerHTML = html + '</tbody></table>';
}

// ── SLU Kapazitäts-Dashboard ──────────────────────────────────────────────
let _sluChart = null;
let _sluUnit  = 'slu';
// ── Karten-Erklärungen ────────────────────────────────────────────────────
// Keyed by the exact <h2> text of each card, so cards can be reordered freely.
const CARD_HELP = {
  'Engine-Alter — brauchen ältere Engines mehr Arbeit und Zeit?': {
    q: 'Brauchen ältere oder länger gelaufene Engines bei GLEICHEM Workscope mehr Arbeit, mehr Zeit und erzeugen mehr Incidents — und wird unser Zulauf älter?',
    read: 'Altersbänder sind Terzile <b>je Familie</b> (jung / mittel / alt = unteres / mittleres / oberes Drittel), weil Stunden und Zyklen je Typ Verschiedenes bedeuten. <b>SLU-Index</b> und <b>TAT-Index</b> vergleichen jede Engine nur mit Engines derselben Familie und Workscope-Klasse (1,0 = typisch). Steigen sie von jung zu alt, kostet das Alter bei gleichem Workscope mehr Arbeit bzw. Zeit. <b>Voll-Anteil</b>: bekommen ältere Engines öfter einen Voll-Workscope? <b>Zulauf</b>: Alters-Index des Eingangs je Jahr (1,0 = Familien-Median) — steigt er, wird die Nachfrage „schwerer".',
    data: 'Alters-Spalten aus dem ZLSGS-Export, automatisch erkannt: CSLSV, CSN, TSLSV, TSN, CSO, TSO — in dieser Reihenfolge vorausgewählt (Zyklen vor Stunden), die anderen per Schalter (auch „Time Since New", „CSN at Induction", „TSN (h)"). Ausgelieferte Engines der letzten 36 Monate, Eingang = Ist-Induction. Der SLU-Index nutzt nur echte SAP-SLU — geschätzte (leere SLU → Median) würden ihn Richtung 1,0 ziehen.',
    warn: 'Die Werte gelten ab Einlastung und bleiben im Shop stehen — sie beschreiben den Zustand beim Eingang. Industrie-Familien (LM) zählen Betriebsstunden, nicht Flugstunden.',
  },
  'SLU-Bilanz — kommt mehr Arbeit herein als hinausgeht?': {
    q: 'Warum wird weniger SLU ausgeliefert: kommt weniger Arbeit herein (Nachfrage) — oder wird die Arbeit im Haus nicht fertig (Durchsatz)? Und wenn Letzteres: bei welcher Familie und an welcher Stelle im Ablauf?',
    read: '<b>Balken</b>: SLU-Eingang (blau) und SLU-Ausgang (grün) je Periode, blasser Teil = SLU geschätzt, weil in SAP leer. <b>Linie</b>: SLU im Bestand am Periodenende. Liegt Blau dauerhaft über Grün, steigt die Linie — Arbeit staut sich. <b>Wer staut</b>: Ausgang der letzten 12 Monate gegen die 12 davor, sortiert nach dem größten Rückgang. <b>Wo</b>: die offenen Engines nach ihrem letzten Ist-Meilenstein, gruppiert nach Phase — mit ihrer offenen Incident-Last (Engines mit offenen Incidents, davon mit offenem Baustopp, mit offener „fehlt"-Meldung, Median der Baustopp-Stunden). <b>Was kostet ein Baustopp</b>: ausgelieferte Engines der letzten 24 Monate, verglichen nur innerhalb derselben Familie und Workscope-Klasse; TAT-Index 1,0 = so lange wie vergleichbare Engines ohne Baustopp. Steigt der Index mit der Zahl der Baustopps, bremsen Incidents den Ablauf messbar.',
    data: 'Wie die Durchsatz-Karte: Eingang = Ist-Induction, Ausgang = Ist-SA (ohne Ist-I: TAT-Start). Migrations-Dummies und Ausschluss-Workscopes fehlen. SLU leer → Median gleicher Familie × Workscope (im SLU-Index des Baustopp-Vergleichs zählen nur echte SAP-SLU). <b>Datenleichen</b> = offen ohne SA, aber: spätere Meilensteine schon gebucht, oder Projekt geschlossen (CLSD), oder länger offen als ein Jahr UND als das Doppelte der typischen TAT der Familie. Sie stehen standardmäßig nicht im Bestand.',
    warn: 'Der Baustopp-Vergleich zeigt einen Zusammenhang, keinen Beweis: eine Engine mit schwierigem Zustand hat mehr Baustopps UND dauert länger. Der SLU-Index daneben prüft, ob die Gruppen vergleichbar viel Arbeit hatten. „Fehlt" kommt aus dem Grund-Feld; nur wenn der Grund leer ist, aus dem Text. SLU-Ausgang = SLU der ausgelieferten Engines zum Auslieferdatum — wenn die Firma SLU nach Baufortschritt verdient, verschiebt sich das Bild zeitlich. Ob SLU in SAP nachgezogen wird, ist offen. Die letzte Periode ist angebrochen (Datenstand des SAP-Exports).',
  },
  'Workscope-Mix & Incidents pro SLU — über die Zeit': {
    q: 'Kommen schwerere oder leichtere Workscopes herein — und steigen die Incidents, weil mehr Arbeit im Haus ist, weil der Mix schwerer wird, oder weil pro Arbeit wirklich mehr passiert?',
    read: '<b>Links</b> jeder Shop-Visit einmal im Quartal seines Eingangs, gefärbt nach Workscope-Klasse; die Linie ist die Ø SLU pro Visit; mit Alter zusätzlich der Alters-Index des Zulaufs (violett, 1,0 = Familien-Median). <b>Rechts</b> Incidents pro SLU im Kalenderquartal — die SLU jeder Engine ist über ihre Verweildauer verteilt. <b>Unten</b> die letzten 4 vollen Quartale gegen die 4 davor, zerlegt in Arbeit (mehr SLU im Shop), Mix (Anteile der Klassen), mit Alter zusätzlich Alters-Mix (mehr/weniger alte Engines innerhalb der Klassen), und Rate (Incidents je SLU innerhalb der Klassen). <b>Incidents: …</b> wählt, was gezählt wird — die dünnen Linien rechts zeigen die Rate je Seite: <b>Befund am Teil</b> (Zustand der Kunden-Hardware, steigt mit altem/verschlissenem Zulauf), <b>Material &amp; Lieferkette</b> und <b>Interner Ablauf</b> (die eigenen Prozesse). Für „was läuft bei uns schief" die beiden letzten ansehen. Anfrage oder Störung entscheidet die LUD-Core-Spalte Typ: <b>Warten auf Entscheidung/Teil</b> (blau) sind Anfragen mit Baustopp — Kunden- oder technische Entscheidung, Gaptausch, Befundbeauftragung, Bauteilbestellung: echter Stillstand, aber kein Fehler; <b>Routine-Anfragen</b> (Bestellung, Zertifizierung, Back to Birth) ohne Baustopp. „ohne Routine-Anfragen" behält das Warten, „nur Störungen" nimmt auch das heraus. <b>Anzahl / Baustopp-Std.</b>: Anzahl zählt, wie oft etwas passiert; Baustopp-Stunden messen die Wirkung — wie lange die Arbeit stand. Zehn Kleinigkeiten und ein 400-h-Stopp sind in der Anzahl zehn zu eins, in der Wirkung umgekehrt.',
    data: 'Seite = aus Ursache, sonst Grund, sonst Kategorie (strukturierte Listen im MTU-Hub); fehlen die Felder, aus dem Kurztext (Stichwortregeln), zuletzt aus der Meldegruppe (Wareneingang/Lager/Einkauf → Material). Wie viel woher kommt, steht unter der Grafik. Workscope = beim Auftrag vergebener Workscope (ZLSGS), Klassen aus der Seite „Workscopes". Ob SLU in SAP nachgezogen wird oder beim Auftrag feststeht, ist noch zu prüfen; ohne SLU gilt der Familien-Median der Klasse. Incidents: ServiceNow (Zyklus-Filter der Einstellungen). Ausschluss-Klasse (Migrations-Dummy, leer) fehlt überall.',
    warn: 'Baustopp-Stunden = Ticket-Laufzeit (Meldung bis Lösung), nicht gemessener Stillstand der Engine; offene Baustopps zählen bis zum Stand, gelöste ohne Lösungsdatum fehlen. Je Engine überlappungsfrei und nur, solange die Engine im Shop lag (für offene Engines: typische TAT der Familie) — ein vergessenes Ticket an einer Datenleiche läuft so nicht ewig weiter. Die jüngsten Eingangsquartale haben viele offene Engines (blasse Balken) — falls SLU nachgezogen wird, wächst deren Wert noch. Die Rate rechts ist davon kaum betroffen, das angebrochene Quartal ist trotzdem gestrichelt. Stand = der ältere von SAP-Export und Incidents.',
  },
  'Häufungen — mehr Incidents als die Arbeit im Shop erwarten lässt': {
    q: 'Wo passiert gerade mehr, als die Arbeit im Shop erklären kann — je Familie und je Modul?',
    read: 'Erwartet = Incidents je SLU aus den 12 Monaten vor dem Fenster × SLU, die im Fenster im Shop lag. <b>Alarm</b>: mindestens 3 Incidents, mindestens 1,5-fach, Zufall unter 1 % (Poisson). <b>Beobachten</b>: Zufall unter 5 %. Beides erst ab 3 verschiedenen Engines — sonst <b>Einzel-Engine</b>: auffällig, aber eine Problem-Engine, keine Häufung. Spalte Engines: wie viele Engines beitragen, in Klammern der Anteil der stärksten, wenn sie die Hälfte oder mehr stellt. <b>Incidents: …</b> filtert nach Seite (Befund am Teil / Material / Interner Ablauf / Warten auf Entscheidung/Teil / Routine-Anfragen; Anfrage oder Störung aus der LUD-Core-Spalte Typ) — Grundlinie und Fenster zählen dann nur diese Seite. Sortiert nach Überraschung. Die Mini-Grafik zeigt 13 Wochen: Balken = Incidents, Linie = erwartet. Klick auf eine Zeile zeigt die Incidents mit Meldegruppe, Kategorie und Ursache — „Fehlt" in einer Gruppe spricht für Logistik, Befunde an einem Modul für Technik.',
    data: 'Modul = CK aus der Incident-WBS (Projekt.OG.CK), sonst „nicht modulgenau". Mehr SLU im Shop hebt die Erwartung automatisch — ein schwererer Workscope-Mix erzeugt deshalb keinen Fehlalarm. <b>Alter</b> (wenn der Export TSN/CSN … hat): die Erwartung wird je Altersband gerechnet — viele alte Engines im Shop heben sie an; dünn besetzte Bänder werden zum Familienwert gezogen. In Klammern steht die Erwartung ohne Alter.',
    warn: 'Die Seite ist ohne Grund-/Ursache-Feld (CSV-Kopie) nur aus dem Kurztext geschätzt — Stichproben im Aufklapper prüfen. Kein Alarm heißt nicht „alles gut" bei kleinen Familien — wenig SLU im Shop, wenig Aussagekraft. Ändert sich die Buchungspraxis (CK00-Anteil), sieht das aus wie eine Häufung: dafür der Hinweis unter der Tabelle.',
  },
  'Durchsatz — Eingang vs. Ausgang (nur Ist-Daten)': {
    q: 'Wie viele Engines und wie viel Arbeit sind pro Periode tatsächlich hereingekommen und ausgeliefert worden — und wächst der Bestand?',
    read: 'Blau = Eingang (Ist-Induction), grün = Ausgang (Ist-SA), rote Linie = Differenz. Rote Linie über null heißt: es kommt mehr rein als raus, der Bestand wächst.',
    data: 'Ausschließlich Ist-Termine. Plantermine, Sammeltermin-Platzhalter und Migrations-Dummies sind dadurch automatisch draußen.',
    warn: 'Die letzte Ist-Periode ist angeschnitten. Blasse Balken rechts sind Prognose, kein Ist — Details in der Methodenzeile unter dem Chart.',
  },
  'Werkstattlast — SLU über die Verweildauer verteilt': {
    q: 'Wie viel Arbeit lag in einem bestimmten Monat tatsächlich in der Werkstatt?',
    read: 'Jede Engine verteilt ihre SLU gleichmäßig über ihre Verweildauer statt sie auf den Einlieferungstag zu buchen. Rote Linie = wie viele Engines im Schnitt gleichzeitig im Haus waren.',
    data: 'Die Stapel sagen, woher die Zahl kommt: <b>Ist</b> = Ein- und Ausgangstermin bekannt, reine Rechnung. <b>Laufender Bestand (Modell)</b> = Engine ist noch da, das Enddatum ist unbekannt und wird aus der empirischen Restlaufzeit ihres Meilensteins geschätzt. <b>Geplanter Zulauf</b> = Engine ist noch gar nicht angekommen, Start aus dem Plantermin.',
    warn: 'Je weiter rechts, desto größer der Modellanteil — und ab „belastbar bis" (Datenstand plus eine TAT-Länge) fehlt der noch nicht gebuchte Zulauf, die Kurve läuft künstlich aus.',
  },
  'SLU Kapazitätslast — Shop Load Units nach Induction-Datum': {
    q: 'Wann kommt wie viel Arbeit herein? Ein Zulaufprofil, aufgeschlüsselt nach Triebwerksfamilie.',
    read: 'Die volle SLU einer Engine steht im Monat ihrer Induction, egal wie lange sie danach im Haus ist.',
    data: 'Plan-Induction (ZMMP) mit ZMMT als Rückfall. Migrations-Dummies und Sammeltermine sind ausgeschlossen.',
    warn: 'Das ist <b>keine</b> Auslastung. Zulauf und tatsächliche Werkstattlast korrelieren gemessen mit 0,03 — für die Auslastung die Karte „Werkstattlast" nehmen. Außerdem schwankt die SLU-Abdeckung über die Jahre stark, siehe Datengrundlage-Zeile.',
  },
  'MTU Delay nach Meilenstein': {
    q: 'An welchen Meilensteinen entsteht der von MTU verantwortete Verzug?',
    read: 'Brutto = Summe aller Verschiebungen an diesem Meilenstein. Netto = Verschiebungen abzüglich Rückholungen. Große Lücke zwischen beiden heißt: viel Hin und Her.',
    data: 'P7-Claims (MTU-verantwortete Terminverschiebungen) des gewählten Jahrgangs.',
    warn: 'Ein Meilenstein taucht hier nur auf, wenn auf ihn geclaimt wurde — nicht jede Verzögerung erzeugt einen Claim. '
        + 'Die Balken nicht addieren und als Verzug lesen: ein Verzug, der mehrere Meilensteine durchschiebt, steht an jedem davon.',
  },
  'OTD-Verteilung': {
    q: 'Wie viele Engines des gewählten Jahrgangs stehen auf Red, Yellow oder Green?',
    read: 'Der OTD-Status kommt direkt aus SAP und bewertet die Termintreue gegenüber der Kundenzusage.',
    data: 'Feld OTD aus dem ZLSGS-Export.',
    warn: 'Laufende Engines ohne Shop Acceptance haben noch keinen endgültigen Status — bei jungen Jahrgängen ist die Verteilung vorläufig.',
  },
  'OTD nach Engine Family': {
    q: 'Welche Triebwerksfamilie hat den größten Anteil kritischer Engines?',
    read: 'Anteile je Familie, nicht absolute Zahlen — eine kleine Familie mit drei Engines kann 100 % Rot zeigen.',
    data: 'Nur Engines, deren Typ über eine Präfix-Regel einer Familie zugeordnet ist (siehe Engine Type Übersicht).',
    warn: 'Familien mit sehr wenigen Engines sind statistisch nicht belastbar.',
  },
  'Breakdown-Häufigkeit nach Meilenstein': {
    q: 'An welchen Meilensteinen kommt es am häufigsten zu Breakdowns?',
    read: 'Sortiert nach Häufigkeit — die oberste Zeile ist der Problem-Meilenstein.',
    data: 'Claims mit gesetztem Breakdown-Kennzeichen.',
    warn: 'Häufigkeit sagt nichts über die Schwere; ein seltener Breakdown kann mehr Tage kosten als zehn häufige.',
  },
  'Cycle-Analyse — Soll vs. Ist Durchlaufzeiten (Ø über alle Engines)': {
    q: 'Welcher Prozessabschnitt zieht die Durchlaufzeit — und wie weit liegt er über der Fischgräte?',
    read: 'Soll = Fischgräten-Vorgabe (ZMMT), Ist = aktueller Plan bzw. Ist (ZMMP). Δ ist die Abweichung in Tagen.',
    data: 'Cycle 0 ist die Warteschlange vor der Induction und hat bewusst kein Soll — dafür gibt es keine Fischgräte.',
    warn: 'Mittelwerte über alle Engines; Ausreißer mit sehr langer Standzeit ziehen sie nach oben.',
  },
  'Cycle-Analyse — nach Engine Family': {
    q: 'Welche Familie ist in welchem Prozessabschnitt langsam?',
    read: 'Dieselbe Soll/Ist-Rechnung wie darüber, aufgeschlüsselt je Familie.',
    data: 'Nur Engines mit Familien-Zuordnung.',
    warn: 'Wenige Engines je Zelle machen die Zahl instabil — auf die Fallzahl achten.',
  },
  'Cycle Δ-Plan — Engine Family × Jahr': {
    q: 'Wird eine Familie über die Jahre schneller oder langsamer?',
    read: 'Jede Zelle ist die Abweichung Ist minus Soll in Tagen für einen Abschnitt. Rot = langsamer als die Fischgräte.',
    data: 'Jahr = TAT-Start-Jahrgang, nicht Lieferjahr.',
    warn: 'Der jüngste Jahrgang enthält viele noch laufende Engines und ist deshalb unvollständig.',
  },
  'P7 Claims & CD — Engine Family × Jahr': {
    q: 'Wie viel MTU-Verzug in Kalendertagen fällt je Familie und Jahrgang an?',
    read: 'CD = Kalendertage Verzug aus P7-Claims, dazu die Anzahl der Claims.',
    data: 'Jahr = TAT-Start-Jahrgang.',
    warn: 'Viele Claims bei wenig CD heißt kleinteilige Verschiebungen, wenige Claims bei viel CD einen großen Einschlag — beide Zahlen zusammen lesen.',
  },
  'Cycle-3 Incidents (ServiceNow) — Engine Family × Jahr': {
    q: 'Wo häufen sich Baustopps und kritische Störungen in Cycle 3?',
    read: 'Baustopps je Familie und Jahr — standardmäßig je SLU, die im Jahr im Shop lag, damit ein wechselnder Workscope-Mix nicht wie Qualität aussieht. Baustopp-Stunden messen die Wirkung: überlappungsfrei je Engine und nur in der Shop-Zeit. <b>Typ</b> filtert nach der LUD-Core-Spalte (Störung / Anfrage), <b>Engines</b> wählt, ob nur Typen aus engine_families.json, alle Engines des SAP-Exports oder zusätzlich Projekte zählen, die nur in LUD Core stehen.',
    data: 'LUD_Core · MTUhub.x_mtuae_ins_shop_incident, über die WBS dem Shop Visit zugeordnet; SLU und Shop-Zeit aus dem SAP-Export.',
    warn: 'Nur Cycle 3 ist erfasst. Projekte, die nur in LUD Core stehen, haben weder Flotte noch SLU — sie zählen nur in den absoluten Werten. Auch Anfragen können Baustopps sein.',
  },
  'Incident–P7 Korrelation — Cycle 3': {
    q: 'Schlagen Störungen in Cycle 3 messbar auf den Verzug durch?',
    read: 'Jeder Punkt ist eine Engine: Anzahl Incidents gegen Claim-Verzug bzw. Cycle-3-Abweichung. Eine steigende Punktwolke stützt den Zusammenhang.',
    data: 'Nur Engines mit Cycle-3-Incidents und Familien-Zuordnung.',
    warn: 'Zusammenhang ist keine Ursache — beides kann eine gemeinsame Ursache haben, etwa einen schwierigen Workscope.',
  },
  'B3 & TS — Termintreue-Wahrscheinlichkeit nach Vorgeschichte': {
    q: 'Wie wahrscheinlich hält eine Engine B3 und TS, wenn sie vorher x Baustopps hatte?',
    read: 'Je Baustopp-Anzahl der Anteil der Engines, die den Meilenstein pünktlich erreicht haben. Fällt die Quote mit steigender Zahl, ist der Baustopp ein Frühwarnsignal.',
    data: 'Baustopps vor B3 aus ServiceNow, Termintreue aus dem Meilensteinvergleich.',
    warn: 'Bei hohen Baustopp-Zahlen wird die Fallzahl klein — die Quote springt dann stark.',
  },
  'Delay-Propagation — Δ B2 → Δ B3': {
    q: 'Überträgt sich Verzug an B2 auf B3, oder wird er im Prozess wieder aufgeholt?',
    read: 'Jede Engine mit ihrer B2-Abweichung gegen die B3-Abweichung. Punkte auf der Diagonalen heißen: der Verzug wandert eins zu eins durch. Punkte darunter heißen: aufgeholt.',
    data: 'Soll aus ZMMT, Ist aus ZMMP, nur Engines mit allen vier Terminen.',
    warn: 'Zeigt die Weitergabe, nicht die Ursache des B2-Verzugs.',
  },
  'Ø Verzug — Engine-Typ × Workscope': {
    q: 'Welche Kombination aus Triebwerkstyp und Workscope läuft am schlechtesten — und wie viel davon hat MTU zu verantworten?',
    read: 'Schalter Endverzug / MTU Delay (Σ) wählt die Metrik. <b>Endverzug</b> (ZMMP − ZMMT am Endtermin TEC/SA/S) = wie viel später '
        + 'die Engine fertig wird, egal wer den Verzug verursacht hat (MTU, OEM oder Kunde); jeder Tag zählt einmal. '
        + '<b>MTU Delay (Σ)</b> = SAP MTU Delay CD: nur der MTU-verantwortete Verzug, aber über alle Meilensteine aufsummiert — '
        + 'kaskadierende Verschiebungen zählen mehrfach, die Tage liegen im Median ~3× über dem Endverzug. '
        + 'Hoher Endverzug bei MTU Delay ≈ 0 heißt: spät, aber nicht durch MTU. Tooltip zeigt Median und die jeweils andere Metrik. '
        + 'Schalter Typen/Familien wählt die Zeilenebene — einzelne Engine-Typen oder die Familien aus engine_families.json. '
        + 'Rangliste = die 25 schlechtesten Kombinationen, Balkenlänge und Farbe = gewählte Metrik in Kalendertagen. '
        + 'Die Matrix-Ansicht zeigt alle Kombinationen als Raster; sie ist naturgemäß dünn besetzt, weil ein Engine-Typ '
        + 'typischerweise nur 3–7 Workscopes fährt, das Raster aber alle Workscopes als Spalten führt.',
    data: 'Nur Kombinationen mit mindestens 3 Engines (bei Endverzug: mit Endverzug), sonst wäre die Zelle Zufall. Kein Endverzug haben Engines ohne '
        + 'Kundenzusage (Budget TAT 0 → Platzhalter-Endtermin in ZMMT) und mit unplausiblem Plantermin. MIGRATION_DUMMY (WS99) ist ausgeschlossen.',
    warn: 'In der Familien-Ebene mittelt eine Zelle über mehrere Engine-Typen — Unterschiede zwischen ihnen verschwinden darin. '
        + 'Die WS-Nummern sind pro Engine-Typ vergeben, nicht global — WS3 heißt beim PW206B2 „REP“ und beim CF34-10E '
        + '„FULL LLP SHOP VISIT“. Spalten sind deshalb NICHT über Zeilen hinweg vergleichbar. Und der Mittelwert '
        + 'verdeckt Ausreißer — eine Zelle kann von einer einzigen Problem-Engine dominiert sein; im Tooltip mit dem Median abgleichen. '
        + 'Bei laufenden Engines ist der Endverzug eine Prognose aus dem aktuellen Plan.',
  },

  'Workscope-Mix — was war pro Engine-Typ im Haus?': {
    q: 'Welche Instandhaltungsumfänge sind bei einem Triebwerkstyp tatsächlich gelaufen — und wie oft?',
    read: 'Ein Balken je Engine-Typ — oder je Familie, wenn der Schalter Typen/Familien auf Familien steht. Segmentbreite = Anteil der Engines mit diesem Workscope. '
        + '„Jahrgänge vergleichen“ stellt dem Seiten-Jahrgang einen zweiten gegenüber: zwei Balken je Zeile, gleiche Farbe = gleicher Workscope, '
        + 'im Detail Anteil und Ø Delay beider Jahrgänge samt Differenz in Prozentpunkten. Gilt nur für diese Karte. '
        + 'Zeile anklicken für die Tabelle mit Anzahl, Anteil, Ø und maximalem Delay. Schalter Endverzug / MTU Delay (Σ) wählt, '
        + 'welcher Delay gezeigt und sortiert wird: <b>Endverzug</b> (ZMMP − ZMMT am Endtermin) = ganzer Terminverzug, egal wer ihn '
        + 'verursacht hat, Ø nur über Engines mit Endverzug — „–“ heißt: keine davon hat einen. <b>MTU Delay (Σ)</b> = nur '
        + 'MTU-verantwortet, aber über alle Meilensteine aufsummiert (~3× Endverzug), Ø über alle Engines. Die Anteile ändern sich nicht.',
    data: 'Familien kommen aus engine_families.json (längster Prefix gewinnt); nicht gemappte Typen bekommen eine heuristische Familie. '
        + 'Alle Workscopes ohne Mindestgröße — anders als die Heatmap, die für einen belastbaren Mittelwert ≥ 3 Engines '
        + 'verlangt. MIGRATION_DUMMY (WS99) zählt nicht zum Mix, wird pro Typ aber ausgewiesen.',
    warn: 'Im Jahrgangsvergleich sind Anteile vergleichbar, absolute Engine-Zahlen nicht: der laufende Jahrgang ist noch unvollständig. '
        + 'Gruppen ohne Gegenstück im anderen Jahrgang zeigen keinen Delta-, sondern einen Absolutwert, markiert mit *. '
        + 'Anteile beschreiben, was im Haus war — nicht, was wirtschaftlich wiegt. Ein seltener Großumfang kann mehr Shop-Zeit '
        + 'binden als ein häufiger kleiner. Ab dem 8. Workscope werden die restlichen zu „Sonstige“ (grau) zusammengefasst. '
        + 'Die Farben sind zeilenintern nach Häufigkeit vergeben — Blau ist in jeder Zeile der häufigste Workscope '
        + 'dieses Typs, NICHT derselbe Workscope wie eine Zeile darüber. Farben also nie über Zeilen hinweg vergleichen.',
  },
  'Häufigste Claim-Beschreibungen (P7)': {
    q: 'Welche Begründungen werden am häufigsten für Terminverschiebungen angegeben?',
    read: 'Sortiert nach Häufigkeit, mit der Summe der dadurch verursachten Verzugstage.',
    data: 'Freitextfeld der P7-Claims, exakt gruppiert.',
    warn: 'Freitext: unterschiedliche Schreibweisen derselben Ursache erscheinen als verschiedene Zeilen.',
  },
  'Claims pro Meilenstein': {
    q: 'An welchen Meilensteinen wird am meisten geclaimt?',
    read: 'Umschaltbar zwischen Anzahl Claims, Netto-Verzug und Breakdowns — die Reihenfolge ändert sich je nach Maß deutlich.',
    data: 'Dieselbe Claim-Basis wie „MTU Delay nach Meilenstein", nur anders sortiert.',
    warn: 'Zeigt die 18 obersten Meilensteine, nicht alle.',
  },
  'Engine Type Übersicht': {
    q: 'Welche Triebwerkstypen stecken im Bestand, und wie sind sie zu Familien zusammengefasst?',
    read: 'Hier wird auch das Familien-Mapping gepflegt: eine Präfix-Regel ordnet alle Typen zu, die mit diesem Präfix beginnen.',
    data: 'Grundlage für jede Karte, die nach Engine Family aufschlüsselt.',
    warn: 'Typen ohne passende Regel fehlen in allen Familien-Auswertungen — Mapping aktuell halten. '
        + 'Schalter Endverzug / MTU Delay (Σ) wählt Spalte und Sortierung: Ø Endverzug zählt nur Engines mit Endverzug (Anzahl im Tooltip), '
        + 'Ø MTU Delay (Σ) alle Engines, ist aber über alle Meilensteine aufsummiert (~3× Endverzug). Die andere Metrik steht jeweils im Tooltip.',
  },
};

const GLOSSARY = [
  ['Ist', 'Ein tatsächlich eingetretener, in SAP gebuchter Termin. Alles andere ist Plan oder Modell.'],
  ['Plan', 'Ein Wunschtermin aus der Planung (ZMMP) oder der Kundenzusage (ZMMT). Er wird in SAP überschrieben, sobald der Ist-Termin eintritt.'],
  ['Modell', 'Eine gerechnete Schätzung, kein Wert aus SAP. Für Engines, die noch im Haus sind, ist das Auslieferdatum unbekannt — es wird aus der empirischen Restlaufzeit von Engines geschätzt, die denselben Meilenstein bereits hinter sich haben.'],
  ['Band', 'Spanne zwischen dem schnellsten und dem langsamsten Szenario (Q25 bis Q75 der Restlaufzeit). Bewusst kein Konfidenzintervall — es sagt „so früh bis so spät", nicht „mit x % Wahrscheinlichkeit".'],
  ['Rückstand', 'Engines, die nach dem Modell schon überfällig sind. Sie stehen in keinem Prognosebalken, weil niemand weiß, wann sie kommen — sie würden sonst den ersten Monat aufblähen.'],
  ['angeschnitten', 'Die Periode ist im Export nur teilweise enthalten, weil der Datenstand mitten hineinfällt. Ihr Balken ist deshalb zwangsläufig zu niedrig.'],
  ['Untergrenze', 'Ab einer TAT-Länge nach dem Datenstand fehlen Engines, die noch gar nicht eingelaufen sind. Die Werte können danach nur noch steigen.'],
  ['SLU', 'Shop Load Unit — Arbeitsmenge, nicht Stückzahl. 1 SLU = 3.000 Arbeitsstunden; eine typische Engine liegt bei 0,2 SLU.'],
  ['Abdeckung', 'Anteil der Engines mit gepflegtem SLU-Wert. Fehlt er, zählt die Engine in der Stückzahl, aber nicht in der Arbeitsmenge — bei niedriger Abdeckung sind SLU-Vergleiche über die Zeit unfair.'],
  ['TAT', 'Turnaround Time, die Verweildauer von Induction bis Shop Acceptance. Median aktuell rund 140 Tage.'],
  ['Induction (I)', 'Der Meilenstein, an dem die Engine in die Werkstatt geht. Beginn der Bearbeitung, Ende der Warteschlange.'],
  ['SA', 'Shop Acceptance — die Engine ist fertig und geht raus. Ende der TAT.'],
  ['B2 / B3 / TS', 'Meilensteine im hinteren Prozessdrittel; B3 ist der Shop Finish, TS der Teststart.'],
  ['ZMMT / ZMMP', 'ZMMT = eingefrorene Kundenzusage („Fischgräte", das Soll). ZMMP = die laufend fortgeschriebene Planung, die beim Erreichen zum Ist wird.'],
  ['P7 / P1 / P0', 'Claim-Arten. P7 sind die von MTU verantworteten Terminverschiebungen und die Basis fast aller Delay-Auswertungen.'],
  ['CD / WD', 'Kalendertage bzw. Arbeitstage Verzug.'],
  ['Cycle 0–4', 'Prozessabschnitte: Cycle 0 ist die Warteschlange bis zur Induction, Cycle 1 Demontage und Inspektion, danach folgen Reparatur, Montage und Test.'],
  ['Sammeltermin', 'Ein Plantermin, den auffällig viele Engines ohne echte Induction teilen (meist der 01.12.). Ein Platzhalter für „Termin noch offen", kein echter Slot — solche Engines werden ausgefiltert.'],
  ['Jahrgang (TSP)', 'Der Jahresfilter oben bezieht sich auf das TAT-Start-Jahr, nicht auf das Lieferjahr. Eine Engine von November 2024, geliefert im April 2026, zählt zu 2024.'],
];

// ── Abschnittsraster (UI-Sprache, wie /workscopes und /settings) ───────────
// Aus jeder Karte wird eine .sap-sec: links Kategorie, Titel, „Beantwortet"-Frage und Schalter
// (klebt beim Scrollen), rechts der Inhalt. Die IDs bleiben, nur Knoten wandern.
// Kategorie je Kartentitel; neue Karten ohne Eintrag bekommen keine Kategorie-Zeile.
const SEC_CAT = {
  'SLU-Bilanz — kommt mehr Arbeit herein als hinausgeht?': 'Bilanz',
  'Engine-Alter — brauchen ältere Engines mehr Arbeit und Zeit?': 'Bilanz',
  'Durchsatz — Eingang vs. Ausgang (nur Ist-Daten)': 'Durchsatz',
  'Werkstattlast — SLU über die Verweildauer verteilt': 'Durchsatz',
  'SLU Kapazitätslast — Shop Load Units nach Induction-Datum': 'Durchsatz',
  'MTU Delay nach Meilenstein': 'Verzug',
  'OTD-Verteilung': 'Verzug',
  'Cycle-Analyse — Soll vs. Ist Durchlaufzeiten (Ø über alle Engines)': 'Verzug',
  'Cycle-Analyse — nach Engine Family': 'Verzug',
  'Cycle Δ-Plan — Engine Family × Jahr': 'Verzug',
  'P7 Claims & CD — Engine Family × Jahr': 'Verzug',
  'Cycle-3 Incidents (ServiceNow) — Engine Family × Jahr': 'Incidents',
  'Incident–P7 Korrelation — Cycle 3': 'Incidents',
  'B3 & TS — Termintreue-Wahrscheinlichkeit nach Vorgeschichte': 'Termintreue',
  'Delay-Propagation — Δ B2 → Δ B3': 'Termintreue',
  'Ø Verzug — Engine-Typ × Workscope': 'Workscope',
  'Workscope-Mix — was war pro Engine-Typ im Haus?': 'Workscope',
  'Workscope-Mix & Incidents pro SLU — über die Zeit': 'Workscope',
  'Häufungen — mehr Incidents als die Arbeit im Shop erwarten lässt': 'Workscope',
  'Häufigste Claim-Beschreibungen (P7)': 'Claims',
  'Claims pro Meilenstein': 'Claims',
  'Engine Type Übersicht': 'Stammdaten',
};

function sectionize() {
  if (window._sectioned) return;
  window._sectioned = true;
  const main = document.querySelector('main');
  const index = [];
  let last = null;
  main.querySelectorAll(':scope > .grid-1 > .card, :scope > .grid-2 > .card').forEach((card, i) => {
    const hd = card.querySelector(':scope > .card-header');
    const body = card.querySelector(':scope > .card-body');
    const h2 = hd && hd.querySelector('h2');
    if (!h2 || !body) return;
    const cat = SEC_CAT[h2.textContent.trim()] || '';
    card.classList.add('sap-sec');
    const head = document.createElement('div');
    head.className = 'sap-sec-head';
    if (cat) {
      const e = document.createElement('span');
      e.className = 'eyebrow';
      e.textContent = cat;
      head.appendChild(e);
    }
    head.appendChild(h2);
    const ctl = document.createElement('div');
    ctl.className = 'sap-sec-ctl';
    while (hd.firstChild) ctl.appendChild(hd.firstChild);
    if (ctl.childNodes.length) head.appendChild(ctl);   // auch leer behalten: wird später per JS gefüllt
    hd.replaceWith(head);
    body.classList.add('sap-sec-body');
    if (cat && cat !== last) {
      if (!card.id) card.id = 'sec-' + i;
      index.push({cat, id: card.id, n: 0});
    }
    if (cat) index[index.length - 1].n++;
    last = cat;
  });
  // Gliederung unter den Kennzahlen: springt zum ersten Abschnitt jeder Kategorie
  const kpis = document.getElementById('kpi-row');
  if (kpis && index.length) {
    const nav = document.createElement('nav');
    nav.className = 'sec-index';
    nav.innerHTML = '<span class="eyebrow">Gliederung</span>' + index.map(x =>
      `<a data-id="${x.id}">${x.cat}<span>${x.n}</span></a>`).join('');
    nav.addEventListener('click', ev => {
      const a = ev.target.closest('a[data-id]');
      if (a) document.getElementById(a.dataset.id).scrollIntoView({behavior: 'smooth', block: 'start'});
    });
    kpis.after(nav);
  }
}
document.addEventListener('DOMContentLoaded', sectionize);

function attachCardHelp() {
  sectionize();
  document.querySelectorAll('main .card').forEach(card => {
    if (card.id === 'glossary' || card.dataset.helped) return;
    const h2 = card.querySelector('.sap-sec-head > h2, .card-header h2');
    const body = card.querySelector('.card-body');
    if (!h2 || !body) return;
    const help = CARD_HELP[h2.textContent.trim()];
    if (!help) return;
    card.dataset.helped = '1';
    const btn = document.createElement('button');
    btn.className = 'help-btn';
    btn.textContent = 'i';
    btn.title = 'Details zu dieser Karte';
    h2.appendChild(btn);
    const q = document.createElement('div');
    q.className = 'card-q';
    q.innerHTML = '<b>Beantwortet:</b> ' + help.q;
    const d = document.createElement('div');
    d.className = 'card-help';
    d.innerHTML =
      '<div><span class="lbl">So lesen:</span>' + help.read + '</div>' +
      '<div><span class="lbl">Datenbasis:</span>' + help.data + '</div>' +
      '<div class="warn"><span class="lbl">Vorsicht:</span>' + help.warn + '</div>';
    if (card.classList.contains('sap-sec')) {   // Frage in die linke Spalte, Erklärung über den Inhalt
      h2.after(q);
      body.prepend(d);
    } else {
      body.parentNode.insertBefore(q, body);
      body.parentNode.insertBefore(d, body);
    }
    btn.onclick = () => d.classList.toggle('on');
  });
  attachSubHelp();
}

function attachSubHelp() {
  // Two sections live inside another card's body rather than in a card of their own.
  document.querySelectorAll('main .card-body .card-header h2').forEach(h2 => {
    const header = h2.parentNode;
    if (header.dataset.helped) return;
    const help = CARD_HELP[h2.textContent.trim()];
    if (!help) return;
    header.dataset.helped = '1';
    const btn = document.createElement('button');
    btn.className = 'help-btn';
    btn.textContent = 'i';
    btn.title = 'Details zu dieser Auswertung';
    h2.appendChild(btn);
    const q = document.createElement('div');
    q.className = 'card-q sub';
    q.innerHTML = '<b>Beantwortet:</b> ' + help.q;
    const d = document.createElement('div');
    d.className = 'card-help sub';
    d.innerHTML =
      '<div><span class="lbl">So lesen:</span>' + help.read + '</div>' +
      '<div><span class="lbl">Datenbasis:</span>' + help.data + '</div>' +
      '<div class="warn"><span class="lbl">Vorsicht:</span>' + help.warn + '</div>';
    header.parentNode.insertBefore(d, header.nextSibling);
    header.parentNode.insertBefore(q, header.nextSibling);
    btn.onclick = () => d.classList.toggle('on');
  });
}

function toggleAllHelp() {
  const boxes = document.querySelectorAll('.card-help');
  const anyOff = [...boxes].some(b => !b.classList.contains('on'));
  boxes.forEach(b => b.classList.toggle('on', anyOff));
  document.getElementById('help-btn-all').textContent = anyOff ? 'ⓘ Erklärungen aus' : 'ⓘ Erklärungen';
}

function toggleGlossary() {
  const g = document.getElementById('glossary');
  if (!g.dataset.filled) {
    g.dataset.filled = '1';
    document.getElementById('glossary-list').innerHTML =
      GLOSSARY.map(([t, d]) => '<dt>' + t + '</dt><dd>' + d + '</dd>').join('');
  }
  g.classList.toggle('on');
}

// ── Durchsatz Eingang/Ausgang ─────────────────────────────────────────────
let _tpUnit = 'slu', _tpGran = 'month', _tpChart = null, _tpData = null, _tpFc = null;

function setTpUnit(u, btn) {
  _tpUnit = u;
  document.querySelectorAll('#tp-unit-toggle button').forEach(b => b.classList.remove('on'));
  btn.classList.add('on');
  if (_tpData) { renderTpChart(_tpData); renderTpTable(_tpData); renderTpKpi(_tpData); }
}

function setTpGran(g, btn) {
  _tpGran = g;
  document.querySelectorAll('#tp-gran-toggle button').forEach(b => b.classList.remove('on'));
  btn.classList.add('on');
  loadThroughput();
}

function renderStaleBanner(through) {
  const el = document.getElementById('stale-banner');
  if (!through) { el.style.display = 'none'; return; }
  const age = Math.floor((Date.now() - new Date(through + 'T00:00:00')) / 86400000);
  if (age <= 10) {
    el.style.display = 'none';
    return;
  }
  // A stale export still produces forecasts — for periods that are already history.
  const hard = age > 45;
  el.style.display = 'block';
  el.style.background = hard ? 'var(--crit-bg)' : 'var(--warn-bg)';
  el.style.border     = `1px solid ${hard ? 'var(--crit)' : 'var(--warn-line)'}`;
  el.style.color      = hard ? 'var(--crit)' : 'var(--warn)';
  const fp = _tpFc ? _tpFc.periods.join(', ') : '–';
  el.innerHTML = `<strong>${hard ? '⚠ Datenstand veraltet' : 'ℹ Datenstand'}</strong> — der Export reicht bis ${through}, das ist <strong>${age} Tage</strong> her. ` +
    `Die Prognose bezieht sich damit auf ${fp}` +
    (hard ? ` — Perioden, die inzwischen ganz oder teilweise Vergangenheit sind. Bitte den Export auf dem Netzlaufwerk aktualisieren und „Neu laden" drücken.`
          : `. Für eine Aussage über den laufenden Monat einen frischeren Export einlesen.`);
}

function renderTpMethod() {
  const el = document.getElementById('tp-method');
  if (!_tpFc) { el.textContent = ''; return; }
  const m = _tpFc.method, w = _tpFc.wip, c = _tpFc.current, b = _tpFc.bias;
  el.innerHTML =
    `<b>Prognose-Methode:</b> ${m.note} Kohorten: ${m.cohorts} Meilensteine mit ≥ ${m.cohort_min_n} abgeschlossenen Engines, ` +
    `Ist-TAT-Median ${m.tat_median_d} Tage. Bestand: ${w.active} aktiv, ` +
    `${w.stale} ohne Ist-Aktivität seit ${m.stale_days} Tagen und ${w.closed_no_sa} abgeschlossen ohne SA-Datum aussortiert, ` +
    `${w.slu_imputed} SLU-Werte über den Familien-Median ergänzt. ` +
    `${w.due_now} Engines (${w.due_now_slu.toFixed(1)} SLU) sind laut Modell bereits überfällig und stecken in <i>keinem</i> Balken. ` +
    `Belastbar bis ${m.valid_through} (eine TAT-Länge) — danach fehlt der noch nicht eingelaufene Zulauf, die Werte sind dann eine Untergrenze.` +
    `<br><b>Gemessene Güte</b> (Backtest über 8 zurückgeschnittene Datenstände, 2024-06 bis 2026-02, Prognose/Ist): ` +
    `M+1 ${b['1'].slu.toFixed(2)}× SLU und ${b['1'].count.toFixed(2)}× Stück (Band trifft ${b['1'].band_hits}), ` +
    `M+2 ${b['2'].slu.toFixed(2)}× / ${b['2'].count.toFixed(2)}× (${b['2'].band_hits}), ` +
    `M+3 ${b['3'].slu.toFixed(2)}× / ${b['3'].count.toFixed(2)}× (${b['3'].band_hits}). ` +
    `<b>Die erste Prognoseperiode fällt bei SLU historisch rund ${Math.round((b['1'].slu - 1) * 100)}% zu hoch aus</b> — die Arbeit verschwindet nicht, sie verschiebt sich nach hinten. Werte sind bewusst nicht heimlich herunterskaliert.` +
    `<br><b>Laufende Periode ${c.period}:</b> ${c.actual.count} Engines (${c.actual.slu.toFixed(1)} SLU) bereits geliefert, ` +
    `${c.days_left} von ${c.days_total} Tagen offen; Modell erwartet noch ${c.rest.count.mid} (${c.rest.slu.mid.toFixed(1)} SLU, Band ${c.rest.count.lo}–${c.rest.count.hi}). ` +
    `Der Rückstand von ${w.due_now} Engines ist darin nicht enthalten.`;
}

async function loadThroughput() {
  const url = `/api/dashboard/throughput-by-month?year=${_activeYear}&gran=${_tpGran}`;
  const data = await fetch(url).then(r => r.json());
  // Forecast only makes sense when the view actually reaches the present.
  const nowYear = (data.data_through || '').slice(0, 4);
  _tpFc = (_activeYear === 'all' || _activeYear === nowYear)
    ? await fetch(`/api/dashboard/forecast?gran=${_tpGran}&horizon=3`).then(r => r.json()).catch(() => null)
    : null;
  if (_tpFc && _tpFc.error) _tpFc = null;
  _tpData = data;
  renderTpKpi(data);
  renderTpChart(data);
  renderTpTable(data);
  renderTpMethod();
  renderStaleBanner(data.data_through);
  document.getElementById('tp-hint').textContent =
    `Eingang = Actual Induction · Ausgang = Actual SA · Datenstand ${data.data_through || '–'}`;
}

function _tpVal(d, dir) {
  if (_tpUnit === 'count') return d[dir + '_count'];
  if (_tpUnit === 'hours') return d[dir + '_hours'];
  return d[dir + '_slu'];
}

function _tpFmt(v) {
  if (_tpUnit === 'count') return v.toString();
  if (_tpUnit === 'hours') return v.toLocaleString('de-DE') + ' h';
  return v.toFixed(2) + ' SLU';
}

function renderTpKpi(data) {
  const s = data.summary;
  const el = document.getElementById('tp-kpi-row');
  const unit = _tpGran === 'quarter' ? 'Quartal' : 'Monat';
  const covColor = s.slu_coverage >= 85 ? 'var(--green)' : s.slu_coverage >= 70 ? 'var(--yellow)' : 'var(--red)';
  el.innerHTML = `
    <div><div style="font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:.5px">Eingang gesamt</div>
         <div style="font-size:28px;font-weight:800;color:var(--blue)">${s.total_in}</div>
         <div style="font-size:11px;color:var(--muted)">Ø ${s.avg_in} / ${unit} · ${s.total_in_slu.toFixed(1)} SLU</div></div>
    <div style="width:1px;background:var(--border)"></div>
    <div><div style="font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:.5px">Ausgang gesamt</div>
         <div style="font-size:28px;font-weight:800;color:var(--green)">${s.total_out}</div>
         <div style="font-size:11px;color:var(--muted)">Ø ${s.avg_out} / ${unit} · ${s.total_out_slu.toFixed(1)} SLU</div></div>
    <div style="width:1px;background:var(--border)"></div>
    <div><div style="font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:.5px">Δ Bestandsaufbau</div>
         <div style="font-size:28px;font-weight:800">${s.total_in - s.total_out > 0 ? '+' : ''}${s.total_in - s.total_out}</div>
         <div style="font-size:11px;color:var(--muted)">Eingang − Ausgang</div></div>
    <div style="width:1px;background:var(--border)"></div>
    <div><div style="font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:.5px">SLU-Abdeckung</div>
         <div style="font-size:28px;font-weight:800;color:${covColor}">${s.slu_coverage.toFixed(0)}%</div>
         <div style="font-size:11px;color:var(--muted)">${s.slu_missing} Engines ohne SLU-Wert</div></div>
    <div style="width:1px;background:var(--border)"></div>
    <div><div style="font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:.5px">Vollständige Perioden</div>
         <div style="font-size:28px;font-weight:800">${s.periods_full}</div>
         <div style="font-size:11px;color:var(--muted)">letzte Periode angeschnitten</div></div>
    ${_tpFc ? `
    <div style="width:1px;background:var(--border)"></div>
    <div><div style="font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:.5px">Aktiver Bestand</div>
         <div style="font-size:28px;font-weight:800;color:var(--purple)">${_tpFc.wip.active}</div>
         <div style="font-size:11px;color:var(--muted)">von ${_tpFc.wip.total} nominell · ${_tpFc.wip.stale + _tpFc.wip.closed_no_sa} inaktiv aussortiert</div></div>
    <div style="width:1px;background:var(--border)"></div>
    <div><div style="font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:.5px">Rückstand</div>
         <div style="font-size:28px;font-weight:800;color:var(--red)">${_tpFc.wip.due_now}</div>
         <div style="font-size:11px;color:var(--muted)">${_tpFc.wip.due_now_slu.toFixed(1)} SLU bereits überfällig</div></div>` : ''}
  `;
}

function renderTpChart(data) {
  const ctx = document.getElementById('tp-chart').getContext('2d');
  const { periods } = data;
  const fp = _tpFc ? _tpFc.periods : [];
  const labels = periods.concat(fp);
  const nH = periods.length, nF = fp.length;
  const pad = (arr, n, at) => at === 'front' ? new Array(n).fill(null).concat(arr)
                                             : arr.concat(new Array(n).fill(null));
  const partial = p => data.data[p].partial;
  // The last bucket is cut off by the export date — draw it pale, not solid.
  const shade = (full, pale) => periods.map(p => partial(p) ? pale : full).concat(new Array(nF).fill(pale));
  const fcVal = (blk, p, k) => blk[p][_tpUnit][k];

  // Running period: the booked part stays solid, the projected rest stacks on top.
  const curIdx = _tpFc ? periods.indexOf(_tpFc.current.period) : -1;
  const atCur  = v => labels.map((_, i) => i === curIdx ? v : null);

  const ds = [
    { label: 'Eingang', backgroundColor: shade('#1a5fa8', 'rgba(26,95,168,.35)'), order: 3, stack: 'in',
      data: pad(periods.map(p => _tpVal(data.data[p], 'in')), nF, 'back') },
    { label: 'Ausgang', backgroundColor: shade('#2d7a2d', 'rgba(45,122,45,.35)'), order: 3, stack: 'out',
      data: pad(periods.map(p => _tpVal(data.data[p], 'out')), nF, 'back') },
    { label: 'Δ (Ein − Aus)', type: 'line', borderColor: '#c0392b', backgroundColor: '#c0392b',
      borderWidth: 2, pointRadius: 2, tension: .25, order: 1, spanGaps: false,
      data: pad(periods.map(p => _tpVal(data.data[p], 'in') - _tpVal(data.data[p], 'out')), nF, 'back') },
  ];
  if (_tpFc && curIdx >= 0) {
    ds.push(
      { label: 'Hochrechnung Restperiode (Zulauf)', backgroundColor: 'rgba(26,95,168,.22)', stack: 'in',
        borderColor: '#1a5fa8', borderWidth: 1, borderSkipped: false, order: 3,
        data: atCur(_tpFc.current.rest_in[_tpUnit === 'count' ? 'count' : _tpUnit].mid) },
      { label: 'Hochrechnung Restperiode (Ausgang)', backgroundColor: 'rgba(45,122,45,.22)', stack: 'out',
        borderColor: '#2d7a2d', borderWidth: 1, borderSkipped: false, order: 3,
        data: atCur(_tpFc.current.rest[_tpUnit === 'count' ? 'count' : _tpUnit].mid) },
    );
  }
  if (_tpFc) {
    // Scenario band: fast (Q25) to slow (Q75) remaining time — not a confidence interval.
    ds.push(
      { label: 'Prognose Ausgang (Median)', backgroundColor: 'rgba(45,122,45,.35)', stack: 'fc',
        borderColor: '#2d7a2d', borderWidth: 1.5, borderSkipped: false, order: 3,
        data: pad(fp.map(p => fcVal(_tpFc.out, p, 'mid')), nH, 'front') },
      { label: 'Prognose-Band Ausgang', type: 'line', order: 0, fill: '+1', spanGaps: false,
        borderWidth: 0, pointRadius: 0, backgroundColor: 'rgba(45,122,45,.16)',
        data: pad(fp.map(p => fcVal(_tpFc.out, p, 'hi')), nH, 'front') },
      { label: '_bandLo', type: 'line', order: 0, spanGaps: false,
        borderWidth: 0, pointRadius: 0, backgroundColor: 'transparent',
        data: pad(fp.map(p => fcVal(_tpFc.out, p, 'lo')), nH, 'front') },
      { label: 'Prognose Eingang (Ø 12 Mon.)', type: 'line', borderColor: '#1a5fa8',
        borderWidth: 2, borderDash: [5, 4], pointRadius: 2, order: 1, spanGaps: false,
        data: pad(fp.map(p => fcVal(_tpFc.in, p, 'mid')), nH, 'front') },
    );
  }

  if (_tpChart) _tpChart.destroy();
  _tpChart = new Chart(ctx, {
    type: 'bar',
    data: { labels, datasets: ds },
    options: {
      responsive: true,
      maintainAspectRatio: false,
      plugins: {
        legend: { display: true, labels: { boxWidth: 12, font: { size: 11 },
                  filter: it => it.text !== '_bandLo' } },
        tooltip: {
          mode: 'index',
          filter: it => it.dataset.label !== '_bandLo',
          callbacks: {
            label: item => {
              if (item.dataset.label === 'Prognose-Band Ausgang') {
                const p = item.label;
                return ` Band: ${_tpFmt(fcVal(_tpFc.out, p, 'lo'))} – ${_tpFmt(fcVal(_tpFc.out, p, 'hi'))}`;
              }
              return ` ${item.dataset.label}: ${_tpFmt(item.raw)}`;
            },
            footer: items => {
              const p = items[0].label;
              if (!data.data[p]) {
                const h = _tpFc.periods.indexOf(p) + 1;
                const bi = _tpFc.bias[String(h)];
                const parts = ['Prognose aus dem laufenden Bestand'];
                if (_tpFc.in[p].order_book) parts.push(`${_tpFc.in[p].order_book} Eingänge bereits gebucht`);
                if (bi) parts.push(`Backtest M+${h}: ${_tpUnit === 'count' ? bi.count.toFixed(2) : bi.slu.toFixed(2)}× Ist (Band ${bi.band_hits})`);
                if (p > _tpFc.method.valid_through) parts.push('jenseits einer TAT-Länge → Untergrenze');
                return parts.join('  |  ');
              }
              const d = data.data[p];
              const parts = [`${d.in_count} rein · ${d.out_count} raus`];
              if (d.in_no_slu) parts.push(`${d.in_no_slu} Eingänge ohne SLU-Wert`);
              if (d.partial && _tpFc) {
                const c = _tpFc.current, u = _tpUnit === 'count' ? 'count' : _tpUnit;
                parts.push(`Hochrechnung ${c.period}: ${_tpFmt(c.actual[u === 'count' ? 'count' : u] + c.rest[u].mid)} (${c.days_left} Tage offen)`);
              } else if (d.partial) {
                parts.push(`angeschnitten (Datenstand ${data.data_through})`);
              }
              return parts.join('  |  ');
            },
          },
        },
      },
      scales: {
        x: { ticks: { font: { size: 11 }, maxRotation: 45 } },
        y: { title: { display: true, font: { size: 11 },
                      text: _tpUnit === 'count' ? 'Engines' : _tpUnit === 'hours' ? 'Arbeitsstunden' : 'Shop Load Units (SLU)' },
             ticks: { font: { size: 11 } } },
      },
    },
  });
}

function renderTpTable(data) {
  const { periods } = data;
  const wrap = document.getElementById('tp-table-wrap');
  if (!periods.length) { wrap.innerHTML = '<div class="loading">Keine Ist-Daten im gewählten Zeitraum</div>'; return; }
  let html = `<table class="heatmap"><thead><tr>
    <th>Periode</th><th>Eingang</th><th>Eingang SLU</th>
    <th>Ausgang</th><th>Ausgang SLU</th><th>Δ Stück</th><th>SLU-Abdeckung Eingang</th></tr></thead><tbody>`;
  periods.forEach(p => {
    const d = data.data[p];
    const cov = d.in_count ? 100 * (d.in_count - d.in_no_slu) / d.in_count : 0;
    const covColor = cov >= 85 ? 'var(--green)' : cov >= 70 ? 'var(--yellow)' : 'var(--red)';
    const dColor = d.delta > 0 ? 'var(--red)' : d.delta < 0 ? 'var(--green)' : 'var(--muted)';
    html += `<tr${d.partial ? ' style="opacity:.55"' : ''}>
      <td style="font-weight:600">${p}${d.partial ? ' <span style="font-weight:400;font-size:10px;color:var(--muted)">(angeschnitten)</span>' : ''}</td>
      <td style="text-align:right">${d.in_count}</td>
      <td style="text-align:right;color:var(--muted)">${d.in_slu.toFixed(2)}</td>
      <td style="text-align:right">${d.out_count}</td>
      <td style="text-align:right;color:var(--muted)">${d.out_slu.toFixed(2)}</td>
      <td style="text-align:right;font-weight:700;color:${dColor}">${d.delta > 0 ? '+' : ''}${d.delta}</td>
      <td style="text-align:right;color:${covColor}">${cov.toFixed(0)}%</td>
    </tr>`;
  });
  if (_tpFc) {
    const c = _tpFc.current;
    html += `<tr style="background:rgba(26,95,168,.05)">
      <td style="font-weight:600;color:var(--blue)">${c.period} <span style="font-weight:400;font-size:10px">(Hochrechnung)</span></td>
      <td style="text-align:right">${c.actual_in.count + c.rest_in.count.mid}</td>
      <td style="text-align:right;color:var(--muted)">${(c.actual_in.slu + c.rest_in.slu.mid).toFixed(2)}</td>
      <td style="text-align:right">${c.actual.count + c.rest.count.mid}</td>
      <td style="text-align:right;color:var(--muted)">${(c.actual.slu + c.rest.slu.mid).toFixed(2)}</td>
      <td style="text-align:right">–</td>
      <td style="text-align:right;font-size:11px;color:var(--muted)">${c.actual.count} gebucht + ${c.rest.count.mid} erwartet · ${c.days_left}/${c.days_total} Tage offen</td>
    </tr>`;
    _tpFc.periods.forEach(p => {
      const o = _tpFc.out[p], i = _tpFc.in[p];
      const h = _tpFc.periods.indexOf(p) + 1, bi = _tpFc.bias[String(h)];
      html += `<tr style="background:rgba(109,40,217,.05)">
        <td style="font-weight:600;color:var(--purple)">${p} <span style="font-weight:400;font-size:10px">(Prognose)</span></td>
        <td style="text-align:right">${i.count.mid}</td>
        <td style="text-align:right;color:var(--muted)">${i.slu.mid.toFixed(2)}</td>
        <td style="text-align:right">${o.count.mid}</td>
        <td style="text-align:right;color:var(--muted)">${o.slu.mid.toFixed(2)}</td>
        <td style="text-align:right;font-weight:700">${i.count.mid - o.count.mid > 0 ? '+' : ''}${i.count.mid - o.count.mid}</td>
        <td style="text-align:right;font-size:11px;color:var(--muted)">Band ${o.slu.lo.toFixed(1)}–${o.slu.hi.toFixed(1)} SLU${bi ? ` · bias-korrigiert ${(o.slu.mid / bi.slu).toFixed(1)}` : ''}</td>
      </tr>`;
    });
  }
  const s = data.summary;
  html += `<tr style="background:var(--bg);font-weight:700;border-top:2px solid var(--border)">
    <td>Gesamt (Ist)</td><td style="text-align:right">${s.total_in}</td>
    <td style="text-align:right">${s.total_in_slu.toFixed(2)}</td>
    <td style="text-align:right">${s.total_out}</td>
    <td style="text-align:right">${s.total_out_slu.toFixed(2)}</td>
    <td style="text-align:right">${s.total_in - s.total_out > 0 ? '+' : ''}${s.total_in - s.total_out}</td>
    <td style="text-align:right">${s.slu_coverage.toFixed(0)}%</td></tr>`;
  html += '</tbody></table>';
  wrap.innerHTML = html;
}

let _sluGran  = 'month';
let _sluData  = null;

const DUMMY_WS_LABEL = 'WS99';
const SLU_PALETTE = [
  '#1a5fa8','#2d7a2d','#c0392b','#d4a017','#6d28d9',
  '#0891b2','#9a3412','#4f46e5','#065f46','#be185d',
  '#854d0e','#1e3a5f','#166534','#7c2d12','#312e81',
];

function setSluUnit(unit, btn) {
  _sluUnit = unit;
  document.querySelectorAll('#slu-unit-toggle button').forEach(b => b.classList.remove('on'));
  btn.classList.add('on');
  if (_sluData) renderSluChart(_sluData);
}

function setSluGran(gran, btn) {
  _sluGran = gran;
  document.querySelectorAll('#slu-gran-toggle button').forEach(b => b.classList.remove('on'));
  btn.classList.add('on');
  loadSluCapacity();
}

async function loadSluCapacity() {
  const url = `/api/dashboard/slu-capacity?year=${_activeYear}&gran=${_sluGran}`;
  const data = await fetch(url).then(r => r.json());
  _sluData = data;
  renderSluKpi(data.summary);
  renderSluChart(data);
  renderSluTable(data);
  renderSluLegend(data.families);
  renderSluQuality(data);
  document.getElementById('slu-hint').textContent =
    `1 SLU = 3.000 Stunden · Ankerdatum: Induction (I) — Zulaufprofil, keine Werkstattlast · Datenstand ${data.data_through || '–'}`;
}

function renderSluKpi(s) {
  const el = document.getElementById('slu-kpi-row');
  const fmt = n => n >= 1000 ? (n/1000).toFixed(1)+'k' : n.toFixed(0);
  el.innerHTML = `
    <div><div style="font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:.5px">Gesamt SLU</div>
         <div style="font-size:28px;font-weight:800;color:var(--blue)">${s.total_slu.toFixed(1)}</div>
         <div style="font-size:11px;color:var(--muted)">${fmt(s.total_hours)} Stunden</div></div>
    <div style="width:1px;background:var(--border)"></div>
    <div><div style="font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:.5px">Engines mit SLU</div>
         <div style="font-size:28px;font-weight:800">${s.engines_with_slu}</div></div>
    <div style="width:1px;background:var(--border)"></div>
    <div><div style="font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:.5px">Ø SLU / Engine</div>
         <div style="font-size:28px;font-weight:800">${s.avg_slu_per_engine.toFixed(3)}</div>
         <div style="font-size:11px;color:var(--muted)">${(s.avg_slu_per_engine*3000).toFixed(0)} h / Shop</div></div>
    <div style="width:1px;background:var(--border)"></div>
    <div><div style="font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:.5px">Gesamtstunden</div>
         <div style="font-size:28px;font-weight:800;color:var(--blue)">${fmt(s.total_hours)}</div>
         <div style="font-size:11px;color:var(--muted)">Werker-Arbeitsstunden</div></div>
    <div style="width:1px;background:var(--border)"></div>
    <div><div style="font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:.5px">SLU-Abdeckung</div>
         <div style="font-size:28px;font-weight:800;color:${s.coverage >= 85 ? 'var(--green)' : s.coverage >= 70 ? 'var(--yellow)' : 'var(--red)'}">${s.coverage.toFixed(0)}%</div>
         <div style="font-size:11px;color:var(--muted)">${s.engines_no_slu} von ${s.engines_total} ohne SLU-Wert</div></div>
  `;
}

function renderSluQuality(data) {
  const s = data.summary;
  const el = document.getElementById('slu-quality');
  const low = data.periods.filter(p => data.data[p].engines >= 5 && data.data[p].coverage < 70);
  el.innerHTML =
    `<b>Datengrundlage:</b> ${s.engines_no_slu} von ${s.engines_total} Engines (${(100 - s.coverage).toFixed(0)}%) tragen keinen SLU-Wert und zählen in der Stückzahl, aber nicht in der Last — ` +
    `der Kurvenverlauf über die Jahre ist deshalb nur eingeschränkt vergleichbar. ` +
    `Ausgeschlossen: ${s.excluded_dummy} Migrations-Dummies (${DUMMY_WS_LABEL}) und ` +
    `${s.excluded_open_date} Engines auf ${s.collective_dates} Sammelterminen ohne echte Induction (${s.excluded_open_slu.toFixed(1)} SLU, „Termin noch offen"). ` +
    `Balken nach ${data.data_through} sind Plan, nicht Ist (blass dargestellt).` +
    (low.length ? `<br><span style="color:var(--red)">${low.length} Perioden unter 70% Abdeckung: ${low.slice(0, 8).join(', ')}${low.length > 8 ? ' …' : ''}</span>` : '');
}

function renderSluChart(data) {
  const ctx = document.getElementById('slu-chart').getContext('2d');
  const useHours = _sluUnit === 'hours';
  const { periods, families } = data;

  // Plan buckets (after the export date) are drawn pale — they are not facts.
  const pale = hex => `rgba(${parseInt(hex.slice(1,3),16)},${parseInt(hex.slice(3,5),16)},${parseInt(hex.slice(5,7),16)},.32)`;
  const datasets = families.map((fam, i) => ({
    label: fam,
    backgroundColor: periods.map(p => data.data[p].is_plan
      ? pale(SLU_PALETTE[i % SLU_PALETTE.length]) : SLU_PALETTE[i % SLU_PALETTE.length]),
    data: periods.map(p => {
      const v = data.data[p].by_family[fam] || 0;
      return useHours ? Math.round(v * 3000) : Math.round(v * 1000) / 1000;
    }),
    stack: 'slu',
  }));

  if (_sluChart) _sluChart.destroy();
  _sluChart = new Chart(ctx, {
    type: 'bar',
    data: { labels: periods, datasets },
    options: {
      responsive: true,
      maintainAspectRatio: false,
      plugins: {
        legend: { display: false },
        tooltip: {
          mode: 'index',
          callbacks: {
            title: items => items[0].label,
            label: item => {
              const v = item.raw;
              return useHours
                ? ` ${item.dataset.label}: ${v.toLocaleString('de-DE')} h`
                : ` ${item.dataset.label}: ${v.toFixed(3)} SLU`;
            },
            footer: items => {
              const total = items.reduce((s, i) => s + i.raw, 0);
              const d = data.data[items[0].label];
              const parts = [useHours
                ? `Gesamt: ${total.toLocaleString('de-DE')} h  |  ${d.engines} Engines`
                : `Gesamt: ${total.toFixed(3)} SLU  =  ${(total*3000).toFixed(0)} h  |  ${d.engines} Engines`];
              if (d.no_slu) parts.push(`${d.no_slu} davon ohne SLU-Wert (${d.coverage.toFixed(0)}% Abdeckung)`);
              if (d.slu_plan > 0 && d.slu_actual > 0)
                parts.push(`Ist ${d.slu_actual.toFixed(2)} / Plan ${d.slu_plan.toFixed(2)} SLU`);
              else if (d.is_plan) parts.push('Plantermine (noch keine Induction)');
              return parts;   // Chart.js renders a string[] footer as multiple lines
            },
          },
        },
      },
      scales: {
        x: { stacked: true, ticks: { font: { size: 11 }, maxRotation: 45 } },
        y: {
          stacked: true,
          title: {
            display: true,
            text: useHours ? 'Arbeitsstunden' : 'Shop Load Units (SLU)',
            font: { size: 11 },
          },
          ticks: { font: { size: 11 } },
        },
      },
    },
  });
}

function renderSluLegend(families) {
  const el = document.getElementById('slu-legend');
  el.innerHTML = families.map((f, i) =>
    `<span style="display:flex;align-items:center;gap:4px">
       <span style="width:10px;height:10px;border-radius:var(--r-s);background:${SLU_PALETTE[i % SLU_PALETTE.length]};flex-shrink:0"></span>
       ${f}
     </span>`
  ).join('');
}

function renderSluTable(data) {
  const { periods, families } = data;
  if (!periods.length) {
    document.getElementById('slu-table-wrap').innerHTML = '<div class="loading">Keine Daten</div>';
    return;
  }
  const useHours = _sluUnit === 'hours';
  let html = '<table class="heatmap"><thead><tr>';
  html += '<th>Periode</th><th>Engines</th><th>Abdeckung</th>';
  html += `<th>Gesamt ${useHours ? 'Stunden' : 'SLU'}</th>`;
  families.forEach(f => { html += `<th>${f}</th>`; });
  html += '</tr></thead><tbody>';
  let grandTotal = 0;
  periods.forEach(p => {
    const d = data.data[p];
    const total = useHours ? d.total_hours : d.total_slu;
    grandTotal += total;
    html += `<tr${d.is_plan ? ' style="opacity:.65"' : ''}>
      <td style="font-weight:600">${p}${d.is_plan ? ' <span style="font-weight:400;font-size:10px;color:var(--muted)">(Plan)</span>' : ''}</td>
      <td style="text-align:right">${d.engines}</td>
      <td style="text-align:right;color:${d.coverage >= 85 ? 'var(--green)' : d.coverage >= 70 ? 'var(--yellow)' : 'var(--red)'}">${d.coverage.toFixed(0)}%</td>
      <td style="text-align:right;font-weight:700;color:var(--blue)">
        ${useHours ? d.total_hours.toLocaleString('de-DE') : d.total_slu.toFixed(3)}
      </td>`;
    families.forEach(f => {
      const v = d.by_family[f] || 0;
      const disp = useHours ? Math.round(v * 3000).toLocaleString('de-DE') : v.toFixed(3);
      html += `<td style="text-align:right;color:${v > 0 ? 'var(--text)' : 'var(--muted)'}">${v > 0 ? disp : '–'}</td>`;
    });
    html += '</tr>';
  });
  // Summary row
  html += `<tr style="background:var(--bg);font-weight:700;border-top:2px solid var(--border)">
    <td>Gesamt</td><td style="text-align:right">${data.summary.engines_total}</td>
    <td style="text-align:right">${data.summary.coverage.toFixed(0)}%</td>
    <td style="text-align:right;color:var(--blue)">
      ${useHours ? Math.round(grandTotal).toLocaleString('de-DE') : grandTotal.toFixed(3)}
    </td>`;
  families.forEach(f => {
    const v = periods.reduce((s, p) => s + (data.data[p].by_family[f] || 0), 0);
    const disp = useHours ? Math.round(v * 3000).toLocaleString('de-DE') : v.toFixed(3);
    html += `<td style="text-align:right">${disp}</td>`;
  });
  html += '</tr></tbody></table>';
  document.getElementById('slu-table-wrap').innerHTML = html;
}

// ── Init / Reload ─────────────────────────────────────────────────────────
async function reload() {
  await fetch('/api/reload', { method: 'POST' });
  init();
}

let _initialized = false;
// ── Engine-Alter ──────────────────────────────────────────────────────
let _ageMetric = '';
async function loadEngineAge() {
  const d = await fetch('/api/dashboard/engine-age' + (_ageMetric ? '?metric=' + _ageMetric : '')).then(r => r.json());
  const out = document.getElementById('age-out'), tg = document.getElementById('age-metric');
  const bsel = document.getElementById('bal-age');
  if (d.metrics.length && bsel.options.length <= 1) {
    bsel.innerHTML = '<option value="">nein</option>' + d.metrics.map(m => `<option value="${m.key}">${esc(m.label)}</option>`).join('');
    document.getElementById('bal-age-wrap').style.display = '';
  }
  if (!d.found) {
    tg.innerHTML = '';
    document.getElementById('age-hint').textContent = 'keine Alters-Spalte im Export';
    out.innerHTML = '<div class="age-miss">Der aktuelle ZLSGS-Export enthält <b>keine Alters-Spalte</b>. Gesucht wird automatisch nach '
      + d.searched.map(x => `<code>${esc(x)}</code>`).join(' ')
      + ' — auch mit Zusätzen wie „TSN (h)", „CSN at Induction" oder ausgeschrieben („Time Since New", „Cycles Since Last Shop Visit").'
      + '<br>Sobald eine davon im Export steht, erscheint hier die Auswertung von selbst (Server neu starten). '
      + 'Vorausgewählt wird der erste gefundene Wert in der Reihenfolge <b>CSLSV → CSN → TSLSV → TSN</b>: Zyklen treiben den LLP- und Heißteil-Verschleiß, „seit letztem Shop-Visit" beschreibt den Zustand beim Eingang.</div>';
    return;
  }
  _ageMetric = d.metric;
  tg.innerHTML = d.metrics.map(m => `<button class="${m.key === d.metric ? 'on' : ''}" title="${esc(m.hint)} · Spalte „${esc(m.column)}&quot;" onclick="_ageMetric='${m.key}';loadEngineAge()">${esc(m.label)}</button>`).join('');
  const cov = d.coverage;
  document.getElementById('age-hint').textContent = `Auslieferungen seit ${d.since} · ${d.metric.toUpperCase()} an ${cov[d.metric]} von ${cov.visits} Shop-Visits`;
  let fam = '', rows = '';
  d.table.forEach(t => {
    const first = t.family !== fam; fam = t.family;
    const cut = d.cuts[t.family];
    rows += `<tr class="${first ? 'fam' : ''}"><td>${first ? '<b>' + esc(t.family === 'Alle' ? 'Alle Familien' : t.family) + '</b>' : ''}</td>`
      + `<td style="text-align:left" title="${cut ? 'Grenzen ' + nfmt(cut[0]) + ' / ' + nfmt(cut[1]) : ''}">${esc(t.band)}</td>`
      + `<td>${t.n}</td><td>${nfmt(t.val_med)}</td><td>${t.voll_share} %</td>`
      + `<td class="${t.slu_idx > 1.1 ? 'hot' : ''}">${nfmt(t.slu_idx, 2)}</td><td class="${t.tat_idx > 1.1 ? 'hot' : ''}">${nfmt(t.tat_idx, 2)}</td>`
      + `<td>${t.inc_per_slu == null ? '–' : nfmt(t.inc_per_slu, 1)}</td><td>${nfmt(t.bs_per_visit, 2)}</td></tr>`;
  });
  const I = d.intake;
  out.innerHTML = '<div class="bal-grid" style="margin-top:0"><div><div class="wsm-sub">Je Familie und Altersband</div>'
    + '<div class="wsm-note">Terzile je Familie · Indizes innerhalb Familie × Workscope-Klasse (1,0 = typisch)</div>'
    + '<table class="bal"><thead><tr><th>Familie</th><th style="text-align:left">Band</th><th>Engines</th><th>Median ' + esc(d.metric.toUpperCase()) + '</th>'
    + '<th title="Anteil Voll-Workscopes">Voll-Anteil</th><th title="SLU gegenüber gleicher Familie × Klasse">SLU-Index</th>'
    + '<th title="TAT gegenüber gleicher Familie × Klasse">TAT-Index</th><th>Incidents / SLU</th><th>Baustopps / Visit</th></tr></thead><tbody>'
    + rows + '</tbody></table></div>'
    + '<div><div class="wsm-sub">Wird der Zulauf älter?</div><div class="wsm-note">Alters-Index des Eingangs je Jahr (Wert ÷ Familien-Median) · Anteil deutlich alter Engines (> 1,25×)</div>'
    + '<table class="bal"><thead><tr><th>Eingangsjahr</th><th>Engines</th><th>Alters-Index</th><th></th><th>deutlich alt</th></tr></thead><tbody>'
    + I.map(x => `<tr><td>${esc(x.year)}</td><td>${x.n}</td><td class="${x.idx > 1.1 ? 'hot' : ''}">${nfmt(x.idx, 2)}</td>`
        + `<td style="width:110px"><span class="bal-bar" style="width:${Math.round(70 * Math.min(x.idx, 1.6))}px"></span></td><td>${x.alt_share} %</td></tr>`).join('')
    + '</tbody></table></div></div>';
}

// ── SLU-Bilanz ────────────────────────────────────────────────────────
let _balGran = 'quarter', _balData = null, _balChart = null;
function setBalGran(g) {
  _balGran = g;
  document.querySelectorAll('#bal-gran button').forEach(b => b.classList.toggle('on', (g === 'year') === b.textContent.startsWith('Jahr')));
  loadSluBalance();
}
async function loadSluBalance() {
  const sel = document.getElementById('bal-fam');
  _balData = await fetch(`/api/dashboard/slu-balance?gran=${_balGran}&family=${encodeURIComponent(sel.value)}`
                         + `&age=${encodeURIComponent(document.getElementById('bal-age').value)}`).then(r => r.json());
  if (sel.options.length <= 1)
    sel.innerHTML = '<option value="">alle Familien</option>' + _balData.families.map(f => `<option>${esc(f)}</option>`).join('');
  document.getElementById('bal-stand').textContent = 'Stand SAP ' + _balData.ref;
  renderSluBalance();
}
function renderSluBalance() {
  const d = _balData, P = d.periods, withStale = document.getElementById('bal-stale').checked;
  const stock = P.map(p => withStale ? p.stock_slu : p.stock_slu - p.stale_slu);
  const labels = P.map(p => p.p + (p.partial ? ' (lfd.)' : ''));
  // Kernsatz: letzte volle 4 Quartale bzw. letztes volles Jahr gegen davor
  const full = P.filter(p => !p.partial), k = _balGran === 'year' ? 1 : 4;
  let key = '';
  if (full.length >= 2 * k) {
    const cur = full.slice(-k), prev = full.slice(-2 * k, -k);
    const sum = (a, f) => a.reduce((s, p) => s + p[f], 0);
    const iC = sum(cur, 'in_slu'), oC = sum(cur, 'out_slu'), iP = sum(prev, 'in_slu'), oP = sum(prev, 'out_slu');
    const idx = P.indexOf(cur[cur.length - 1]), idx0 = P.indexOf(prev[prev.length - 1]);
    const sNow = stock[idx], s0 = stock[idx0];
    const lab = _balGran === 'year' ? cur[0].p + ' gegen ' + prev[0].p
                                    : `${cur[0].p}–${cur[k - 1].p} gegen ${prev[0].p}–${prev[k - 1].p}`;
    key = `<b>${esc(lab)}:</b> Eingang <b>${nfmt(iP, 1)} → ${nfmt(iC, 1)} SLU</b> (${sgn(iC - iP, 1)}), `
      + `Ausgang <b>${nfmt(oP, 1)} → ${nfmt(oC, 1)} SLU</b> (<b class="${oC < oP ? 'up' : ''}">${sgn(oC - oP, 1)}</b>). `
      + `Bestand ${withStale ? '' : '(bereinigt) '}${nfmt(s0, 1)} → <b class="${sNow > s0 ? 'up' : ''}">${nfmt(sNow, 1)} SLU</b>`
      + (s0 > 0 ? ` (${sgn(100 * (sNow - s0) / s0, 0)} %)` : '') + '. '
      + (iC >= oC && oC < oP ? '<br>&#8594; Es kommt nicht weniger Arbeit herein, aber weniger wird fertig: <b>Durchsatz, nicht Nachfrage.</b>'
         : iC < iP && oC < oP ? '<br>&#8594; Eingang und Ausgang sinken beide: <b>weniger Nachfrage</b> ist zumindest ein Teil der Erklärung.'
         : '');
  }
  document.getElementById('bal-key').innerHTML = key || '<span class="hint">Zu wenige volle Perioden für den Vergleich.</span>';
  if (_balChart) _balChart.destroy();
  _balChart = new Chart(document.getElementById('bal-chart'), {
    data: {labels, datasets: [
      {type: 'bar', label: 'SLU Eingang', stack: 'in', data: P.map(p => p.in_slu - p.in_est), backgroundColor: 'rgba(26,95,168,.8)'},
      {type: 'bar', label: 'Eingang geschätzt', stack: 'in', data: P.map(p => p.in_est), backgroundColor: 'rgba(26,95,168,.3)'},
      {type: 'bar', label: 'SLU Ausgang', stack: 'out', data: P.map(p => p.out_slu - p.out_est), backgroundColor: 'rgba(45,122,45,.8)'},
      {type: 'bar', label: 'Ausgang geschätzt', stack: 'out', data: P.map(p => p.out_est), backgroundColor: 'rgba(45,122,45,.3)'},
      {type: 'line', label: withStale ? 'Bestand (inkl. Datenleichen)' : 'Bestand (bereinigt)', yAxisID: 'y2', data: stock,
       borderColor: '#c0392b', backgroundColor: '#c0392b', pointRadius: 2.5, tension: .2,
       segment: {borderDash: ctx => P[ctx.p1DataIndex].partial ? [4, 3] : undefined}}]},
    options: {maintainAspectRatio: false, interaction: {mode: 'index', intersect: false},
      plugins: {legend: {labels: {boxWidth: 10, font: {size: 10}}},
        tooltip: {callbacks: {afterBody: items => { const p = P[items[0].dataIndex];
          return [`Eingang ${p.in_n} Visits · Ausgang ${p.out_n} Visits · Δ ${sgn(p.delta, 1)} SLU`,
                  `Bestand ${p.stock_n} Visits, davon ${p.stale_n} Datenleichen (${nfmt(p.stale_slu, 1)} SLU)`]
                 .concat(p.partial ? ['Periode angebrochen (Datenstand)'] : []); }}}},
      scales: {x: {stacked: true, ticks: {font: {size: 10}}},
               y: {stacked: true, beginAtZero: true, title: {display: true, text: 'SLU je Periode', font: {size: 10}}},
               y2: {position: 'right', beginAtZero: true, grid: {drawOnChartArea: false},
                    title: {display: true, text: 'SLU im Bestand', font: {size: 10}}}}}});
  // Wer staut
  document.getElementById('bal-win').textContent = `${d.window.from} – ${d.window.to} gegen die 12 Monate davor · sortiert nach Rückgang des Ausgangs`;
  document.getElementById('bal-who').innerHTML = '<table class="bal"><thead><tr><th>Familie</th><th>Eingang</th><th>Ausgang</th>'
    + '<th>Ausgang Vorjahr</th><th>Δ Ausgang</th><th>Visits aus</th><th>Bestand</th><th>Ø Alter / TAT</th></tr></thead><tbody>'
    + d.who.map(w => `<tr><td><b>${esc(w.family)}</b></td><td>${nfmt(w.in, 1)}</td><td>${nfmt(w.out, 1)}</td><td>${nfmt(w.out_prev, 1)}</td>`
        + `<td class="${w.out_change < -0.5 ? 'neg' : w.out_change > 0.5 ? 'pos' : ''}">${sgn(w.out_change, 1)}</td>`
        + `<td>${w.out_n_prev} → ${w.out_n}</td>`
        + `<td title="${w.stale_n} Datenleichen mit ${nfmt(w.stale_slu, 1)} SLU nicht enthalten">${w.stock_n} · ${nfmt(w.stock_slu, 1)}</td>`
        + `<td>${w.age_med == null ? '–' : w.age_med + ' T'} / ${w.tat_med || '–'} T</td></tr>`).join('')
    + '</tbody></table>';
  // Wo stehen die offenen
  const mx = Math.max(1, ...d.by_ms.map(x => x.slu));
  let ph = '', ms = '<table class="bal"><thead><tr><th>Meilenstein</th><th>Engines</th><th>SLU</th><th></th><th>steht seit</th>'
    + '<th title="Engines mit mindestens einem offenen Incident">offene Inc.</th><th title="Engines mit offenem Baustopp">Baustopp</th>'
    + '<th title="Engines mit offener Meldung mit Grund „Fehlt" (ohne Grund: Text enthält „fehlt")">fehlt</th><th title="Median der Baustopp-Stunden je Engine mit Baustopp (offen: bis Datenstand)">BS-h</th></tr></thead><tbody>';
  d.by_ms.forEach(x => {
    if (x.phase !== ph) { ph = x.phase; ms += `<tr class="ph"><td colspan="9">${esc(ph)}</td></tr>`; }
    const pct = v => x.n ? Math.round(100 * v / x.n) : 0;
    ms += `<tr><td title="${esc(x.label)} · ${x.fams.map(f => esc(f[0]) + ' ' + f[1]).join(', ')}">${esc(x.ms)} <span class="hint">${esc(x.label === x.ms ? '' : x.label)}</span></td>`
      + `<td>${x.n}</td><td>${nfmt(x.slu, 1)}</td><td style="width:84px"><span class="bal-bar" style="width:${Math.round(76 * x.slu / mx)}px"></span></td>`
      + `<td title="${x.still_long} davon seit ≥ ${d.rules.still_days} Tagen ohne neuen Meilenstein">${x.still_med} T${x.still_long ? ' <span class="hint">(' + x.still_long + ')</span>' : ''}</td>`
      + `<td title="${x.open} offene Incidents">${x.open_eng ? x.open_eng + ' <span class="hint">' + pct(x.open_eng) + ' %</span>' : '–'}</td>`
      + `<td class="${pct(x.open_bs_eng) >= 30 ? 'hot' : ''}">${x.open_bs_eng || '–'}</td>`
      + `<td class="${pct(x.open_fehlt_eng) >= 30 ? 'hot' : ''}">${x.open_fehlt_eng || '–'}</td>`
      + `<td>${x.bs_h_med == null ? '–' : nfmt(x.bs_h_med)}</td></tr>`;
  });
  const cov = d.inc_coverage;
  document.getElementById('bal-ms').innerHTML = ms + '</tbody></table>'
    + (cov.with_inc < 0.5 * cov.visits ? `<div class="bal-cov">&#9651; Incident-Daten nur an ${cov.with_inc} von ${cov.visits} Shop-Visits — Incident-Spalten und Baustopp-Vergleich sind entsprechend dünn (Zyklus-Filter der Einstellungen, Demo-Daten?).</div>` : '');
  // Was kostet ein Baustopp?
  const E = d.bs_effect;
  document.getElementById('bal-bs-note').textContent = `Auslieferungen seit ${E.since}; verglichen nur innerhalb derselben Familie × Workscope-Klasse`
    + (E.age_metric ? ` × Altersband (${E.age_metric.toUpperCase()})` : '') + ' (mind. 3 Engines je Gruppe).';
  document.getElementById('bal-bs-key').innerHTML = E.days_per_engine == null
    ? '<span class="hint">Zu wenige vergleichbare Engines mit Baustopp.</span>'
    : `Engines mit Baustopp brauchten im Mittel <b class="${E.days_per_engine > 0 ? 'up' : ''}">${sgn(E.days_per_engine, 0)} Tage</b> länger `
      + `als vergleichbare ohne (gewichtet über ${E.engines_with} Engines mit Baustopp).`;
  const dm = Math.max(1.5, ...E.dose.map(x => x.tat_idx || 0));
  document.getElementById('bal-bs-dose').innerHTML = '<table class="bal"><thead><tr><th>Baustopps je Engine</th><th>Engines</th>'
    + '<th>TAT-Index</th><th></th><th title="SLU gegenüber vergleichbaren Engines ohne Baustopp — nahe 1,0 = ähnlich viel Arbeit, der Vergleich ist fair">SLU-Index</th><th>Ø Endverzug</th></tr></thead><tbody>'
    + E.dose.map(x => `<tr><td>${esc(x.bucket)}</td><td>${x.n}</td><td class="${x.tat_idx > 1.1 ? 'hot' : ''}">${x.tat_idx == null ? '–' : nfmt(x.tat_idx, 2)}</td>`
        + `<td style="width:110px">${x.tat_idx == null ? '' : `<span class="bal-bar" style="width:${Math.round(100 * x.tat_idx / dm)}px;background:${x.tat_idx > 1.05 ? 'var(--crit)' : 'var(--info)'}"></span>`}</td>`
        + `<td>${x.slu_idx == null ? '–' : nfmt(x.slu_idx, 2)}</td><td>${x.delay_med == null ? '–' : nfmt(x.delay_med, 0) + ' T'}</td></tr>`).join('')
    + '</tbody></table>';
  document.getElementById('bal-bs-strata').innerHTML = !E.strata.length ? '' :
    '<table class="bal"><thead><tr><th>Familie · Klasse</th><th>ohne BS</th><th>TAT</th><th>mit BS</th><th>TAT</th><th>Δ Tage</th><th>SLU ohne / mit</th></tr></thead><tbody>'
    + E.strata.map(t => `<tr><td><b>${esc(t.family)}</b> <span class="hint">${esc(t.cls)}</span></td><td>${t.n0}</td><td>${t.tat0} T</td>`
        + `<td>${t.n1} <span class="hint">(Ø ${nfmt(t.bs_med, 1)} BS)</span></td><td>${t.tat1} T</td>`
        + `<td class="${t.diff > 5 ? 'neg' : t.diff < -5 ? 'pos' : ''}">${sgn(t.diff, 0)}</td><td>${nfmt(t.slu0, 2)} / ${nfmt(t.slu1, 2)}</td></tr>`).join('')
    + '</tbody></table>';
  // Datenleichen
  const S = d.stale;
  document.getElementById('bal-stale-box').innerHTML = !S.n ? '' :
    `<details><summary>&#9651; ${S.n} Datenleichen mit ${nfmt(S.slu, 1)} SLU — offen ohne SA, aber vermutlich nicht mehr im Shop `
    + `(${S.nach_sa} mit späteren Meilensteinen, ${S.clsd} Projekt geschlossen, ${S.alt} ungewöhnlich lange offen). In SAP prüfen.</summary>`
    + '<table class="bal"><thead><tr><th>Projekt</th><th>ESN</th><th>Familie</th><th>Workscope</th><th>Ist-I</th><th>letzter Ist-MS</th><th>SLU</th><th style="text-align:left">Grund</th></tr></thead><tbody>'
    + S.list.map(r => `<tr><td>${esc(r.project)}</td><td>${esc(r.esn)}</td><td>${esc(r.family)}</td><td>${esc(r.workscope)}</td>`
        + `<td>${esc(r.in)}</td><td>${esc(r.last_ms)} ${esc(r.last_date)}</td><td>${nfmt(r.slu, 2)}${r.est ? '*' : ''}</td>`
        + `<td style="text-align:left;white-space:normal">${esc(r.why)}</td></tr>`).join('')
    + '</tbody></table>' + (S.n > S.list.length ? `<div class="hint">… ${S.n - S.list.length} weitere</div>` : '')
    + '<div class="hint">* SLU geschätzt</div></details>';
}

function fillAgeSelect(id, metrics, cur) {
  const el = document.getElementById(id);
  if (!metrics || !metrics.length) { el.style.display = 'none'; return; }
  el.style.display = '';
  if (el.options.length <= 1)
    el.innerHTML = '<option value="">Alter: nein</option>' + metrics.map(m => `<option value="${m.key}" title="${esc(m.hint)}">Alter: ${esc(m.label)}</option>`).join('');
  el.value = cur || '';
}

// ── Workscope-Mix über die Zeit ───────────────────────────────────────
const WSM_COL = {voll: '#7a2e12', modular: '#c8671c', befund: '#3b6ea5'};
const INC_SIDE_COL = {hw: '#1f7a6b', mat: '#b7791f', int: '#7b3fa0', wait: '#2b6cb0', anf: '#8a847c'};
let _wsmQ = 12, _wsmM = 'n', _wsmChart1 = null, _wsmChart2 = null;
function setWsmM(m) {
  _wsmM = m;
  document.querySelectorAll('#wsm-m button').forEach(b => b.classList.toggle('on', b.getAttribute('onclick').includes(`'${m}'`)));
  loadWsMixTime();
}
function setWsmQ(n) {
  _wsmQ = n;
  document.querySelectorAll('#wsm-q button').forEach(b => b.classList.toggle('on', b.textContent.startsWith(String(n))));
  loadWsMixTime();
}
function nfmt(v, d) { return v == null ? '–' : Number(v).toLocaleString('de-DE', {minimumFractionDigits: d || 0, maximumFractionDigits: d || 0}); }
function sgn(v, d) { return (v > 0 ? '+' : v < 0 ? '−' : '±') + nfmt(Math.abs(v), d); }
async function loadWsMixTime() {
  const sel = document.getElementById('wsm-fam');
  const ageSel = document.getElementById('wsm-age');
  const d = await fetch(`/api/dashboard/ws-mix-time?quarters=${_wsmQ}&family=${encodeURIComponent(sel.value)}`
                        + `&age=${encodeURIComponent(ageSel.value)}&side=${document.getElementById('wsm-side').value}&metric=${_wsmM}`).then(r => r.json());
  fillAgeSelect('wsm-age', d.age_metrics, d.age_metric);
  if (sel.options.length <= 1)
    sel.innerHTML = '<option value="">alle Familien</option>' + d.families.map(f => `<option>${esc(f)}</option>`).join('');
  document.getElementById('wsm-stand').textContent = 'Stand ' + d.ref
    + (d.stand && d.stand.sap !== d.stand.incidents ? ` (SAP ${d.stand.sap}, Incidents ${d.stand.incidents})` : '');
  const Q = d.quarters, labels = Q.map(q => q.q + (q.partial ? ' (lfd.)' : ''));
  const cls = d.classes.filter(c => c.key !== 'aus');
  if (_wsmChart1) _wsmChart1.destroy();
  _wsmChart1 = new Chart(document.getElementById('wsm-intake'), {
    data: {labels, datasets: cls.map(c => ({type: 'bar', label: c.label, stack: 's', yAxisID: 'y',
        data: Q.map(q => q.visits[c.key] || 0),
        backgroundColor: Q.map(q => WSM_COL[c.key] + (q.open_share > 0.5 ? '66' : 'dd'))}))
      .concat([{type: 'line', label: 'Ø SLU / Visit', yAxisID: 'y2', data: Q.map(q => q.slu_mean),
                borderColor: '#1a1815', backgroundColor: '#1a1815', pointRadius: 2.5, tension: .25,
                segment: {borderDash: ctx => Q[ctx.p1DataIndex].open_share > 0.5 ? [4, 3] : undefined}}])
      .concat(d.age_metric ? [{type: 'line', label: 'Alters-Index Zulauf (' + d.age_metric.toUpperCase() + ')', yAxisID: 'y3',
                data: Q.map(q => q.age_idx), borderColor: '#6d28d9', backgroundColor: '#6d28d9', pointRadius: 2.5,
                borderDash: [2, 2], tension: .25}] : [])},
    options: {maintainAspectRatio: false, interaction: {mode: 'index', intersect: false},
      plugins: {legend: {labels: {boxWidth: 10, font: {size: 10}}},
        tooltip: {callbacks: {afterBody: items => { const q = Q[items[0].dataIndex];
          return `${q.n} Shop-Visits · ${nfmt(100 * q.open_share)} % noch offen`
            + (q.age_alt != null ? ` · ${q.age_alt} % im oberen Altersdrittel` : ''); }}}},
      scales: {x: {stacked: true, ticks: {font: {size: 10}}}, y: {stacked: true, title: {display: true, text: 'Shop-Visits', font: {size: 10}}},
               y2: {position: 'right', grid: {drawOnChartArea: false}, title: {display: true, text: 'Ø SLU', font: {size: 10}}},
               y3: {display: !!d.age_metric, position: 'right', grid: {drawOnChartArea: false},
                    title: {display: true, text: 'Alters-Index', font: {size: 10}}, suggestedMin: 0.6, suggestedMax: 1.4}}}});
  if (_wsmChart2) _wsmChart2.destroy();
  const sideLbl = Object.fromEntries(d.sides.map(x => [x.key, x.label]));
  const BSH = d.metric === 'bsh', unitY = BSH ? 'Baustopp-Std. / SLU' : 'Incidents / SLU';
  const mainLbl = (!d.side ? (BSH ? 'Baustopp-Std.' : 'Incidents') : d.side === 'fehler' ? 'ohne Routine-Anfragen' : d.side === 'stoer' ? 'nur Störungen' : sideLbl[d.side]) + ' pro SLU';
  document.getElementById('wsm-rate-sub').textContent = (BSH ? 'Baustopp-Stunden' : 'Incidents') + ' pro SLU je Kalenderquartal';
  document.getElementById('wsm-rate-note').textContent = BSH
    ? 'Baustopp-Stunden im Quartal ÷ SLU im Shop (Linie) · je Engine überlappungsfrei, nur solange die Engine im Shop lag, offene bis Stand · dünn: je Seite · SLU im Shop (Fläche, rechte Achse)'
    : 'Incidents nach Meldedatum ÷ SLU, die in dem Quartal im Shop lag (Linie) · dünn: je Seite · SLU im Shop (Fläche, rechte Achse)';
  const sideSets = (d.side === '' || d.side === 'fehler' ? ['hw', 'mat', 'int', 'wait'] : d.side === 'stoer' ? ['hw', 'mat', 'int'] : []).map(k => ({type: 'line', label: sideLbl[k],
       yAxisID: 'y', data: Q.map(q => (BSH ? q.bs_side_rate : q.side_rate)[k]), borderColor: INC_SIDE_COL[k], backgroundColor: INC_SIDE_COL[k],
       borderWidth: 1.4, pointRadius: 1.5, tension: .25, segment: {borderDash: ctx => Q[ctx.p1DataIndex].partial ? [4, 3] : undefined}}));
  _wsmChart2 = new Chart(document.getElementById('wsm-rate'), {
    data: {labels, datasets: [
      {type: 'line', label: mainLbl, yAxisID: 'y', data: Q.map(q => BSH ? q.bs_rate : q.rate), borderColor: '#c0392b',
       backgroundColor: '#c0392b', pointRadius: 3, tension: .25,
       segment: {borderDash: ctx => Q[ctx.p1DataIndex].partial ? [4, 3] : undefined}}, ...sideSets,
      {type: 'line', label: 'SLU im Shop', yAxisID: 'y2', data: Q.map(q => q.expo), fill: true, pointRadius: 0,
       borderColor: 'rgba(26,95,168,.35)', backgroundColor: 'rgba(26,95,168,.08)', tension: .25,
       segment: {borderDash: ctx => Q[ctx.p1DataIndex].partial ? [4, 3] : undefined}}]},
    options: {maintainAspectRatio: false, interaction: {mode: 'index', intersect: false},
      plugins: {legend: {labels: {boxWidth: 10, font: {size: 10}}},
        tooltip: {callbacks: {afterBody: items => { const q = Q[items[0].dataIndex];
          return [BSH ? `${nfmt(q.bs_h)} Baustopp-Stunden auf ${nfmt(q.expo, 1)} SLU (${q.incidents} Incidents)`
                      : `${q.incidents} Incidents auf ${nfmt(q.expo, 1)} SLU`,
            d.sides.map(x => `${x.label} ${q.sides[x.key] || 0}`).join(' · ') + (q.sides[''] ? ` · unklar ${q.sides['']}` : '')]
            .concat(q.partial ? ['Quartal läuft noch (SLU nur bis zum Stand, daher niedriger)'] : []); }}}},
      scales: {x: {ticks: {font: {size: 10}}}, y: {beginAtZero: true, title: {display: true, text: unitY, font: {size: 10}}},
               y2: {position: 'right', beginAtZero: true, grid: {drawOnChartArea: false}, title: {display: true, text: 'SLU', font: {size: 10}}}}}});
  const C = d.side_cov, pc = n => C.n ? nfmt(100 * (n || 0) / C.n) + ' %' : '–';
  document.getElementById('wsm-cov').innerHTML = !C.n ? '' :
    'Seite: ' + d.sides.map(x => `<span style="color:${INC_SIDE_COL[x.key]}" title="${esc(x.hint)}">■</span> ${esc(x.label)} ${pc(C.by[x.key])}`).join(' · ')
    + ` · nicht zuordenbar ${pc(C.by[''])}<br>Zuordnung aus LUD-Typ Anfrage ${pc(C.src.typ)}, aus Grund/Ursache-Feld ${pc(C.src.feld)}, aus dem Kurztext ${pc(C.src.text)}, aus der Meldegruppe ${pc(C.src.gruppe)}`
    + (C.src.feld ? '' : ' — <b>die Datenquelle hat keine Grund-/Ursache-Felder, die Seite ist nur aus dem Text geschätzt</b>')
    + (BSH && C.src.bs ? `<br>Baustopps im Zeitraum: ${nfmt(C.src.bs)}, davon mit Dauer in der Shop-Zeit der Engine ${nfmt(C.src.bs_iv || 0)} (${nfmt(100 * (C.src.bs_iv || 0) / C.src.bs)} %)`
        + ' — der Rest liegt vor Eingang/nach Auslieferung oder ist ohne Lösungsdatum geschlossen' : '');
  const D = d.decomposition, box = document.getElementById('wsm-dec');
  if (!D) { box.innerHTML = '<span class="hint">Für die Zerlegung braucht es 8 volle Quartale.</span>'; return; }
  const diff = D.i1 - D.i0;
  const shareTxt = cls.map(c => `${c.label} ${nfmt(D.share0[c.key], 0)} → ${nfmt(D.share1[c.key], 0)} %`).join(' · ');
  const rateTxt = cls.map(c => `${c.label} ${nfmt(D.r0[c.key], 1)} → ${nfmt(D.r1[c.key], 1)}`).join(' · ');
  const U = BSH ? 'Baustopp-Std.' : 'Incidents';
  box.innerHTML = (d.side || BSH ? `<span class="hint">Gezählt: ${esc(mainLbl.replace(' pro SLU', ''))}${d.side && BSH ? ' (Baustopp-Std.)' : ''}</span><br>` : '')
    + `<b>${esc(D.bis)}</b> gegen <b>${esc(D.von)}</b> (je 12 Monate): ${U} <b>${nfmt(D.i0)} → ${nfmt(D.i1)}</b> (${sgn(diff)}), `
    + `SLU im Shop ${nfmt(D.slu0, 1)} → ${nfmt(D.slu1, 1)}, ${U} pro SLU ${nfmt(D.rate0, 1)} → ${nfmt(D.rate1, 1)}.`
    + '<div class="parts">'
    + `<div class="pt"><b>${sgn(D.arbeit, 0)}</b><span>Arbeit — mehr/weniger SLU im Shop</span></div>`
    + `<div class="pt"><b>${sgn(D.mix, 0)}</b><span>Mix — Anteile der Workscope-Klassen</span></div>`
    + (D.age_mix != null ? `<div class="pt"><b>${sgn(D.age_mix, 0)}</b><span>Alters-Mix — ältere/jüngere Engines (${esc(d.age_metric.toUpperCase())})</span></div>` : '')
    + `<div class="pt"><b style="color:${D.rate > 0 ? 'var(--red)' : 'var(--green)'}">${sgn(D.rate, 0)}</b><span>Rate — ${U} je SLU innerhalb der Klassen</span></div>`
    + '</div>'
    + `<div class="hint">SLU-Anteile: ${shareTxt}<br>${U} je SLU: ${rateTxt}`
    + (D.age_share0 ? '<br>SLU nach Altersband: ' + ['jung', 'mittel', 'alt', '?'].filter(b => D.age_share0[b] || D.age_share1[b])
        .map(b => `${b === '?' ? 'ohne Wert' : b} ${nfmt(D.age_share0[b], 0)} → ${nfmt(D.age_share1[b], 0)} %`).join(' · ') : '')
    + '</div>';
}

// ── Häufungen ─────────────────────────────────────────────────────────
let _clWin = 28, _clOpen = '';
function setClWin(w) {
  _clWin = w; _clOpen = '';
  document.querySelectorAll('#cl-w button').forEach(b => b.classList.toggle('on', b.textContent.startsWith(w === 28 ? '4' : '13')));
  loadClusters();
}
function spark(obs, exp) {
  const W = 110, H = 26, n = obs.length, bw = W / n, mx = Math.max(1, ...obs, ...exp);
  const bars = obs.map((v, i) => `<rect x="${i * bw + 1}" y="${H - v / mx * H}" width="${bw - 2}" height="${v / mx * H}" fill="#c0392b" opacity=".75"/>`).join('');
  const line = exp.map((v, i) => `${i * bw + bw / 2},${H - v / mx * H}`).join(' ');
  return `<svg width="${W}" height="${H}" style="vertical-align:middle">${bars}<polyline points="${line}" fill="none" stroke="#1a1815" stroke-width="1.3" stroke-dasharray="3 2"/></svg>`;
}
async function loadClusters() {
  const d = await fetch(`/api/dashboard/clusters?window=${_clWin}&age=${encodeURIComponent(document.getElementById('cl-age').value)}`
                        + `&side=${document.getElementById('cl-side').value}`).then(r => r.json());
  fillAgeSelect('cl-age', d.age_metrics, d.age_metric);
  document.getElementById('cl-win').textContent = `Fenster ${d.win_from} – ${d.win_to} · Grundlinie ${d.base_from} – ${d.base_to}`
    + (d.age_metric ? ` · Erwartung je Altersband (${d.age_metric.toUpperCase()})` : '');
  const out = document.getElementById('cl-out');
  let html = '';
  if (!d.rows.length)
    html = `<div class="hint" style="padding:8px 0">Keine Häufung: in keiner Familie und keinem Modul liegen die Incidents im Fenster deutlich über dem, was die SLU im Shop erwarten lässt.</div>`;
  else html = '<table class="cl"><thead><tr><th></th><th>Familie</th><th>Modul</th><th style="text-align:right">beobachtet</th>'
    + '<th style="text-align:right">erwartet</th><th style="text-align:right">Faktor</th><th style="text-align:right">Zufall</th>'
    + '<th style="text-align:right" title="Verschiedene Engines mit Incidents in dieser Zelle im Fenster — Alarm erst ab ' + d.rules.min_eng + '">Engines</th>'
    + '<th>13 Wochen</th><th style="text-align:right">SLU im Fenster</th></tr></thead><tbody>'
    + d.rows.map(r => {
        const key = r.family + '|' + r.ck;
        return `<tr class="row" onclick="toggleCluster('${esc(r.family)}','${esc(r.ck)}')">`
          + `<td><span class="cl-badge ${r.level}" ${r.level === 'engine' ? `title="Statistisch auffällig, aber nur an ${r.engines} Engine(s) — Einzelfall, keine Häufung"` : ''}>`
          + `${r.level === 'alarm' ? 'Alarm' : r.level === 'watch' ? 'Beobachten' : 'Einzel-Engine'}</span></td>`
          + `<td><b>${esc(r.family)}</b></td><td>${r.ck === '*' ? '<i>ganze Familie</i>' : esc(r.ck === '–' ? '' : r.ck) + ' ' + esc(r.name)}`
          + (r.base_new ? ' <span class="hint" title="In den 12 Monaten davor kein einziger Incident">neu</span>' : '') + '</td>'
          + `<td class="n"><b>${r.obs}</b></td><td class="n" title="${d.age_metric ? 'ohne Alter: ' + nfmt(r.exp_plain, 1) : ''}">${nfmt(r.exp, 1)}`
          + (d.age_metric && Math.abs(r.exp - r.exp_plain) >= 0.1 ? ` <span class="hint">(${nfmt(r.exp_plain, 1)})</span>` : '') + `</td><td class="n">${nfmt(r.factor, 1)}×</td>`
          + `<td class="n" title="Wahrscheinlichkeit, so viele oder mehr rein zufällig zu sehen">${r.p < 0.001 ? '< 0,1' : nfmt(100 * r.p, 1)} %</td>`
          + `<td class="n" title="${r.top_share >= 0.5 ? nfmt(100 * r.top_share) + ' % der Incidents an einer einzigen Engine' : ''}">${r.engines}`
          + (r.top_share >= 0.5 && r.engines > 1 ? ' <span class="hint">(eine ' + nfmt(100 * r.top_share) + ' %)</span>' : '') + '</td>'
          + `<td>${spark(r.weeks, r.weeks_exp)}</td><td class="n">${nfmt(r.slu_win, 1)}</td></tr>`
          + (_clOpen === key ? `<tr class="cl-detail"><td colspan="10" id="cl-detail"><div class="loading">Lade…</div></td></tr>` : '');
      }).join('') + '</tbody></table>';
  if (d.dq.length) html += '<div class="cl-dq">&#9651; Buchungspraxis geändert? CK00-Anteil (ganze Engine) im Fenster vs. Grundlinie: '
    + d.dq.map(x => `${esc(x.family)} ${x.ck00_win} % statt ${x.ck00_base} %`).join(' · ') + '</div>';
  out.innerHTML = html;
  if (_clOpen) loadClusterDetail();
}
function toggleCluster(f, ck) {
  const key = f + '|' + ck;
  _clOpen = _clOpen === key ? '' : key;
  loadClusters();
}
async function loadClusterDetail() {
  const [f, ck] = [_clOpen.slice(0, _clOpen.lastIndexOf('|')), _clOpen.slice(_clOpen.lastIndexOf('|') + 1)];
  const d = await fetch(`/api/dashboard/clusters/detail?window=${_clWin}&family=${encodeURIComponent(f)}&ck=${encodeURIComponent(ck)}`
                        + `&side=${document.getElementById('cl-side').value}`).then(r => r.json());
  const cell = document.getElementById('cl-detail');
  if (!cell) return;
  cell.innerHTML = `<div style="margin-bottom:6px"><b>${d.n} Incidents an ${d.engines} Engines</b>`
    + (d.by_class.length ? ' · Klassen: ' + d.by_class.map(([k, n]) => `${esc(k)} ${n}`).join(', ') : '')
    + (d.top_engines.length ? ' · meiste an: ' + d.top_engines.map(t => `${esc(t.project)} <span class="hint">${esc(t.esn || '')}</span> ${t.n}`).join(', ') : '') + '</div>'
    + '<div class="by">' + Object.entries(Object.assign({'Seite': d.by_side}, d.by)).map(([k, v]) =>
        `<div><b>${esc(k)}</b>${v.map(([x, n]) => `${esc(x)} <span class="hint">${n}</span>`).join('<br>')}</div>`).join('') + '</div>'
    + '<table>' + d.incidents.map(i => `<tr><td>${esc(i.date)}</td><td>${esc(i.number || '')}</td>`
        + `<td><a href="/hub/module/graph?scope=project&sv=${encodeURIComponent(i.project)}&heat=1&depth=1" title="Module dieser Engine im Hierarchie-Graph">${esc(i.project)}</a> <span class="hint">${esc(i.esn || '')}</span></td>`
        + `<td>${esc(i.ck || '')}</td><td>${esc(i.crit || '')}</td><td>${esc(i.group || '')}</td><td>${esc(i.side || '–')}</td><td>${esc(i.desc)}</td></tr>`).join('')
    + '</table>' + (d.n > d.incidents.length ? `<div class="hint">… und ${d.n - d.incidents.length} weitere</div>` : '');
}

async function init() {
  if (!_initialized) { _initialized = true; loadYears(); loadFamilyMapStatus(); attachCardHelp(); }
  await loadStats();
  await loadMsChart();
  renderClaimChart();
  loadHeatmap();
  loadWorkscopeMix();
  loadWsMixTime();
  loadClusters();
  loadSluBalance();
  loadEngineAge();
  loadCycles();
  loadDescriptions();
  loadBreakdowns();
  loadEngineTypes();
  loadCyclesByFamily();
  loadCycleDeltaByFamilyYear();
  loadP7ByFamilyYear();
  loadIncidentsByFamilyYear();
  loadOtdByFamily();
  loadIncidentP7Correlation();
  loadB3TsProbability();
  loadB2B3Propagation();
  loadThroughput();
  loadShopLoad();
  loadSluCapacity();
}

async function loadFamilyMapStatus() {
  const data = await fetch('/api/dashboard/family-map').then(r => r.json());
  const el = document.getElementById('family-map-status');
  if (data.count === 0) {
    el.innerHTML = '<span style="color:var(--red)">Kein Mapping — bitte auto-generieren oder Präfix-Regel hinzufügen</span>';
  } else {
    const rules = Object.entries(data.mapping)
      .sort((a, b) => b[0].length - a[0].length)
      .map(([p, f]) => `<code style="background:var(--border);padding:1px 5px;border-radius:var(--r-s);font-size:10px">${p} → ${f}</code>`)
      .join(' ');
    el.innerHTML = `${rules}`;
  }
}

async function generateFamilyMap() {
  const el = document.getElementById('family-map-status');
  el.textContent = 'Generiere…';
  const data = await fetch('/api/dashboard/family-map/generate', {method:'POST'}).then(r => r.json());
  el.innerHTML = `<span style="color:var(--green)">${data.total} Types gemappt</span> · ${data.added} neu hinzugefügt · <span style="color:var(--muted);font-size:11px">${data.path}</span>`;
  loadEngineTypes();
}

async function addPrefixRule() {
  const prefix = prompt('Präfix (z.B. "PW8", "CFM56-7B"):');
  if (!prefix) return;
  const family = prompt(`Family-Name für Präfix "${prefix}":`, prefix);
  if (!family) return;
  await fetch('/api/dashboard/family-map/update', {
    method: 'POST',
    headers: {'Content-Type': 'application/json'},
    body: JSON.stringify({prefix, family})
  });
  loadFamilyMapStatus();
  loadEngineTypes();
}

// Metrik der Engine-Typen-Übersicht — nur Anzeige, die Daten tragen beide
let _etData   = null;
let _etMetric = 'end';
const _etClosed = new Set();   // zugeklappte Familien überleben das Umschalten

function setEtMetric(m) {
  if (_etMetric === m) return;
  _etMetric = m;
  document.querySelectorAll('#et-metric button').forEach(b =>
    b.classList.toggle('on', b.dataset.metric === m));
  renderEngineTypes();
}

function toggleEtFamily(famId) {
  if (_etClosed.has(famId)) _etClosed.delete(famId); else _etClosed.add(famId);
  document.getElementById(famId).style.display = _etClosed.has(famId) ? 'none' : '';
}

async function loadEngineTypes() {
  _etData = await fetch(`/api/dashboard/engine-types?year=${_activeYear}`).then(r => r.json());
  renderEngineTypes();
}

function renderEngineTypes() {
  const container = document.getElementById('engine-type-table');
  if (!_etData) return;
  if (!_etData.length) { container.innerHTML = '<div class="empty-state">Keine Daten</div>'; return; }
  const isMtu = _etMetric === 'mtu';
  const val   = x => isMtu ? x.avg_mtu_delay_cd : x.avg_delay_cd;
  // Ohne Wert ans Ende, sonst absteigend nach gewählter Metrik
  const byVal = (a, b) => (val(a) == null) - (val(b) == null) || (val(b) ?? 0) - (val(a) ?? 0);
  const cd  = v => v == null ? '–' : v.toLocaleString('de') + ' CD';
  const cdT = x => isMtu
    ? `Ø über alle ${x.count} Engines · zum Vergleich Ø Endverzug: ${cd(x.avg_delay_cd)} (${x.delay_n} Engines mit Endverzug)`
    : `${x.delay_n} von ${x.count} Engines mit Endverzug · zum Vergleich Ø MTU Delay (Σ): ${cd(x.avg_mtu_delay_cd)}`;
  const colHead = isMtu
    ? `<th style="padding:6px 8px;text-align:right" title="SAP MTU Delay CD: nur MTU-verantwortet, aber über alle Meilensteine aufsummiert (kaskadiert). Ø über alle Engines">Ø MTU Delay (Σ)</th>`
    : `<th style="padding:6px 8px;text-align:right" title="ZMMP − ZMMT am Endtermin, nur Engines mit Kundenzusage und plausiblem Endtermin">Ø Endverzug</th>`;
  let html = `<table style="width:100%;border-collapse:collapse;font-size:13px">
    <thead><tr style="border-bottom:2px solid var(--border);text-align:left">
      <th style="padding:6px 8px">Family / Engine Type</th>
      <th style="padding:6px 8px;text-align:right">Engines</th>
      ${colHead}
      <th style="padding:6px 8px">Workscopes</th>
    </tr></thead><tbody>`;
  [..._etData].sort(byVal).forEach(fam => {
    const delayColor = val(fam) > 0 ? 'var(--red)' : 'var(--muted)';
    const famId = 'fam-' + fam.family.replace(/[^a-z0-9]/gi, '_');
    const closed = _etClosed.has(famId);
    html += `<tr style="background:var(--surface);cursor:pointer;border-top:2px solid var(--border)"
                onclick="toggleEtFamily('${famId}')">
      <td style="padding:7px 8px;font-weight:700;font-size:13px">
        ▸ ${fam.family}
        <span style="font-size:10px;color:var(--muted);font-weight:400;margin-left:6px">${fam.types.length} Type${fam.types.length>1?'s':''}</span>
      </td>
      <td style="padding:7px 8px;text-align:right;font-weight:700">${fam.count}</td>
      <td style="padding:7px 8px;text-align:right;color:${delayColor};font-weight:700" title="${cdT(fam)}">${cd(val(fam))}</td>
      <td style="padding:7px 8px;color:var(--muted);font-size:11px">${fam.workscopes.join(', ')}</td>
    </tr>`;
    html += `<tbody id="${famId}"${closed ? ' style="display:none"' : ''}>`;
    [...fam.types].sort(byVal).forEach(t => {
      const tc = val(t) > 0 ? 'var(--red)' : 'var(--muted)';
      html += `<tr style="background:var(--bg)">
        <td style="padding:4px 8px 4px 24px;font-family:monospace;color:var(--muted)">${t.engine_type}</td>
        <td style="padding:4px 8px;text-align:right;color:var(--muted)">${t.count}</td>
        <td style="padding:4px 8px;text-align:right;color:${tc}" title="${cdT(t)}">${cd(val(t))}</td>
        <td style="padding:4px 8px;color:var(--muted);font-size:11px">${t.workscopes.join(', ')}</td>
      </tr>`;
    });
    html += `</tbody>`;
  });
  html += '</tbody></table>';
  container.innerHTML = html;
}

init();
</script>
</body>
</html>
"""


@app.route('/dashboard')
def dashboard():
    return DASHBOARD_HTML


# ══════════════════════════════════════════════════════════════════════════════
# MTU Hub — SQL Layer (msql-lud-core)
# Standalone: kein Import aus sql_explorer_V3.py, pyodbc wird lazy geladen,
# damit der Analyzer auch ohne Treiber/Netz startet.
# ══════════════════════════════════════════════════════════════════════════════

DB_SERVER   = os.environ.get('MTUHUB_SERVER', 'msql-lud-core')
DB_USER     = os.environ.get('MTUHUB_USER',   'YJ_SVC_MTUHubReaderProd')
DB_PASS     = os.environ.get('MTUHUB_PASSWORD', 't.5r#2?HJumZZ8ey!n')
ODBC_DRIVER = os.environ.get('ODBC_DRIVER', '')     # leer = Autoerkennung
DB_DEFAULT  = os.environ.get('MTUHUB_DATABASE', 'master')

# Die Incident-Tabelle, um die es fachlich geht. Datenbank wird per
# /api/sql/find-table gesucht und in sql_state.json gemerkt.
INCIDENT_TABLE = 'mtuae_ins_shop_incident'
SQL_STATE_PATH = (os.path.join(SQL_CACHE_DIR, 'sql_state.json') if MTUHUB_DEMO
                  else os.path.join(BASE_DIR, 'sql_state.json'))

SQL_ROW_CAP   = 5000    # Serverseitige Notbremse für SELECTs ohne TOP
SQL_TIMEOUT   = 30      # Sekunden pro Query

_sql_executor   = ThreadPoolExecutor(max_workers=4)
_sql_db_cache   = {'databases': None, 'ts': 0.0}
_sql_db_lock    = threading.Lock()

# Treiberkandidaten in absteigender Präferenz — der erste installierte gewinnt.
_DRIVER_PREFS = [
    'ODBC Driver 18 for SQL Server',
    'ODBC Driver 17 for SQL Server',
    'ODBC Driver 13 for SQL Server',
    'FreeTDS',
    'SQL Server',
]


def _pyodbc():
    """Lazy import — fehlt pyodbc, startet der Analyzer trotzdem."""
    try:
        import pyodbc
        return pyodbc
    except ImportError as e:
        raise RuntimeError(
            'pyodbc ist nicht installiert. Im venv nachziehen: pip install pyodbc '
            '(macOS zusätzlich: brew install unixodbc msodbcsql18)'
        ) from e


def _sql_driver() -> str:
    """Konfigurierter Treiber, sonst der beste installierte."""
    if ODBC_DRIVER:
        return ODBC_DRIVER
    try:
        installed = _pyodbc().drivers()
    except Exception:
        return 'ODBC Driver 18 for SQL Server'
    for cand in _DRIVER_PREFS:
        if cand in installed:
            return cand
    return installed[0] if installed else 'ODBC Driver 18 for SQL Server'


# ── Demo-Verbindung: SQLite statt SQL Server ─────────────────────────────────
# Drei Dateien aus mtuhub_demo_data.py, angehängt unter den Schemanamen, die
# der Code benutzt (MTUhub.…, INFORMATION_SCHEMA.…, sys.…). Übersetzt wird nur,
# was der Analyzer wirklich schickt — kein allgemeiner T-SQL-Übersetzer. Was
# hier durchgeht, ist deshalb KEIN Beweis, dass das SQL am SQL Server läuft.
DEMO_FILES = {'MTUhub': 'mtuhub.sqlite', 'INFORMATION_SCHEMA': 'information_schema.sqlite',
              'sys': 'sys.sqlite'}


def _demo_ts(v):
    if v is None:
        return None
    s = str(v).strip()
    try:
        return datetime.datetime.fromisoformat(s[:19]) if len(s) >= 10 else None
    except ValueError:
        return None


def _demo_charindex(sub, s, start=1):
    if s is None or sub is None:
        return 0
    return str(s).find(str(sub), max(int(start or 1), 1) - 1) + 1


def _demo_convert(typ, v, style=None):
    if v is None:
        return None
    t = str(typ).lower()
    if t == 'date':
        return str(v)[:10]
    m = re.search(r'\((\d+)\)', t)
    return str(v)[:int(m.group(1))] if m else str(v)


def _demo_datepart(part, v):
    d = _demo_ts(v)
    if d is None:
        return None
    p = str(part).lower()
    return {'iso_week': d.isocalendar()[1], 'isowk': d.isocalendar()[1],
            'quarter': (d.month - 1) // 3 + 1, 'year': d.year, 'month': d.month,
            'day': d.day}.get(p)


def _demo_datediff(unit, a, b):
    a, b = _demo_ts(a), _demo_ts(b)
    if a is None or b is None:
        return None
    u = str(unit).lower()
    if u == 'day':
        return (b.date() - a.date()).days
    sec = (b - a).total_seconds()
    return int(sec // 3600) if u == 'hour' else int(sec // 60)


def _demo_patindex(pattern, s):
    """T-SQL PATINDEX: LIKE-Muster mit %, _ und [..] → erste Fundstelle (1-basiert)."""
    if s is None or pattern is None:
        return 0
    rx, i, p = '', 0, str(pattern)
    while i < len(p):
        ch = p[i]
        if ch == '%':
            rx += '.*?'
        elif ch == '_':
            rx += '.'
        elif ch == '[':
            j = p.index(']', i)
            rx += p[i:j + 1]
            i = j
        else:
            rx += re.escape(ch)
        i += 1
    rx = rx[3:] if rx.startswith('.*?') else '^' + rx      # führendes % = irgendwo
    m = re.search(rx, str(s), re.S)
    return m.start() + 1 if m else 0


def _demo_parsename(v, n):
    """T-SQL PARSENAME: n-tes Segment von rechts, höchstens 4 Segmente, sonst NULL."""
    if v is None:
        return None
    parts = str(v).split('.')
    n = int(n)
    if len(parts) > 4 or not 1 <= n <= len(parts):
        return None
    return parts[-n] or None


_DEMO_LITERAL = re.compile(r"'(?:[^']|'')*'")


def _demo_translate(sql: str) -> str:
    """Nur außerhalb von Textliterale übersetzen — ein Engine-Typ wie
    'LM6000-PF+' darf nicht zur Textverkettung umgebaut werden."""
    parts, pos = [], 0                       # abwechselnd Code, Literal, Code, …
    for m in _DEMO_LITERAL.finditer(sql):
        parts += [sql[pos:m.start()], m.group(0)]
        pos = m.end()
    parts.append(sql[pos:])
    tops = []
    for i in range(0, len(parts), 2):        # nur Code-Stücke
        c = parts[i]
        c = re.sub(r'@@VERSION', "'MTU-Hub DEMO (SQLite " + sqlite3.sqlite_version + ")'", c)
        c = re.sub(r'\bCONVERT\(\s*(date|n?(?:var)?char\(\d+\))\s*,',
                   lambda m: f"CONVERT_T('{m.group(1)}',", c, flags=re.I)
        c = re.sub(r'\bDATEPART\(\s*(\w+)\s*,', lambda m: f"DATEPART_T('{m.group(1)}',", c, flags=re.I)
        c = re.sub(r'\bDATEDIFF\(\s*(\w+)\s*,', lambda m: f"DATEDIFF_T('{m.group(1)}',", c, flags=re.I)
        # Textverkettung: T-SQL '+' direkt neben einem Literal → SQLite '||'
        if i > 0:
            c = re.sub(r'^(\s*)\+', r'\1||', c)
        if i + 1 < len(parts):
            c = re.sub(r'\+(\s*)$', r'||\1', c)
        tops += re.findall(r'\bSELECT\s+TOP\s+\(?(\d+)\)?', c, flags=re.I)
        parts[i] = re.sub(r'\b(SELECT)\s+TOP\s+\(?\d+\)?', r'\1', c, flags=re.I)
    s = ''.join(parts)
    # TOP n → LIMIT n (das äußere, also das letzte TOP — Unterabfragen haben keins)
    if tops:
        s = s.rstrip().rstrip(';') + f' LIMIT {tops[-1]}'
    return s


class _DemoCursor:
    def __init__(self, con):
        self._c = con.cursor()
        self.description = None

    def execute(self, sql, params=None):
        self._c.execute(_demo_translate(sql), list(params or []))
        self.description = self._c.description
        return self

    def fetchall(self):
        return self._c.fetchall()

    def fetchmany(self, n):
        return self._c.fetchmany(n)


class _DemoConn:
    timeout = 0

    def __init__(self):
        missing = [f for f in DEMO_FILES.values() if not os.path.exists(os.path.join(DEMO_DIR, f))]
        if missing:
            raise RuntimeError('Demo-Modus aktiv, aber keine Demo-Daten in .demo/ — '
                               'einmal „python mtuhub_demo_data.py" ausführen.')
        self._con = sqlite3.connect('file::memory:', uri=True, check_same_thread=False)
        for schema, fname in DEMO_FILES.items():
            path = os.path.join(DEMO_DIR, fname)
            self._con.execute(f"ATTACH DATABASE 'file:{path}?mode=ro' AS {schema}")
        f = self._con.create_function
        f('CHARINDEX', 2, _demo_charindex)
        f('CHARINDEX', 3, _demo_charindex)
        f('LEFT', 2, lambda v, n: None if v is None else str(v)[:max(int(n), 0)])
        f('RIGHT', 2, lambda v, n: None if v is None else (str(v)[-int(n):] if int(n) > 0 else ''))
        f('LEN', 1, lambda v: None if v is None else len(str(v).rstrip()))
        f('ISNULL', 2, lambda a, b: b if a is None else a)
        f('YEAR', 1, lambda v: _demo_datepart('year', v))
        f('MONTH', 1, lambda v: _demo_datepart('month', v))
        f('GETDATE', 0, lambda: datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S'))
        f('CONVERT_T', 2, _demo_convert)
        f('CONVERT_T', 3, _demo_convert)
        f('DATEPART_T', 2, _demo_datepart)
        f('DATEDIFF_T', 3, _demo_datediff)
        f('PATINDEX', 2, _demo_patindex)
        f('PARSENAME', 2, _demo_parsename)

    def cursor(self):
        return _DemoCursor(self._con)

    def close(self):
        self._con.close()

    def __enter__(self):
        return self

    def __exit__(self, *exc):
        self.close()


DEMO_BANNER = ('<div id="demo-banner" style="background:#f5ead0;color:#946300;'
               'border-bottom:1px solid #e3cf9c;'
               'font:500 11px/1.4 \'Helvetica Neue\',Helvetica,Arial,sans-serif;padding:6px 16px;'
               'letter-spacing:.04em"><b style="letter-spacing:.14em">DEMO-DATEN</b> · Incidents, Module und Teile sind erfunden '
               '(.demo/, erzeugt von mtuhub_demo_data.py), nicht aus der MTU-Hub-SQL. '
               'Zurück zu echten Daten: MTUHUB_DEMO nicht setzen und neu starten.</div>')


@app.after_request
def _demo_banner(resp):
    """Im Demo-Modus jede HTML-Seite unübersehbar markieren."""
    if MTUHUB_DEMO and resp.mimetype == 'text/html' and not resp.direct_passthrough \
            and 'embed' not in request.args:
        html = resp.get_data(as_text=True)
        if 'id="demo-banner"' not in html and '<body' in html:
            resp.set_data(re.sub(r'(<body[^>]*>)', lambda m: m.group(1) + DEMO_BANNER,
                                 html, count=1))
    return resp


def _sql_conn(database: str = None):
    if MTUHUB_DEMO:
        return _DemoConn()
    pyodbc = _pyodbc()
    conn_str = (
        f'DRIVER={{{_sql_driver()}}};'
        f'SERVER={DB_SERVER};'
        f'DATABASE={database or DB_DEFAULT};'
        f'UID={DB_USER};'
        f'PWD={DB_PASS};'
        'TrustServerCertificate=yes;'
        'Encrypt=no;'
        'Connection Timeout=10;'
    )
    return pyodbc.connect(conn_str, autocommit=True, readonly=True)


def _sql_jsonable(v):
    """pyodbc-Typen → JSON (datetime, Decimal, bytes, UUID)."""
    if v is None or isinstance(v, (str, int, float, bool)):
        return v
    if isinstance(v, (datetime.datetime, datetime.date, datetime.time)):
        return v.isoformat(sep=' ')
    if isinstance(v, bytes):
        return v.hex()
    return str(v)


def _sql_query(sql, params=None, database=None, timeout=SQL_TIMEOUT, cap=SQL_ROW_CAP):
    """Query im Threadpool mit hartem Timeout. Liefert (cols, rows, truncated)."""
    def _run():
        with _sql_conn(database) as con:
            cur = con.cursor()
            cur.execute(sql, params or [])
            if not cur.description:
                return [], [], False
            cols = [d[0] for d in cur.description]
            if cap:
                raw = cur.fetchmany(cap + 1)
                truncated = len(raw) > cap
                raw = raw[:cap]
            else:
                raw = cur.fetchall()
                truncated = False
            return cols, [[_sql_jsonable(v) for v in r] for r in raw], truncated

    future = _sql_executor.submit(_run)
    try:
        return future.result(timeout=timeout)
    except FuturesTimeout:
        future.cancel()
        raise RuntimeError(f'Timeout nach {timeout}s — Abfrage eingrenzen (TOP / WHERE).')


def _sql_state() -> dict:
    """Gemerkte Fundstellen (z. B. welche DB die Incident-Tabelle hält)."""
    if os.path.exists(SQL_STATE_PATH):
        try:
            with open(SQL_STATE_PATH, encoding='utf-8') as f:
                content = f.read().strip()
                if content:
                    return json.loads(content)
        except Exception:
            pass
    return {}


def _sql_state_save(patch: dict):
    state = _sql_state()
    state.update(patch)
    try:
        os.makedirs(os.path.dirname(SQL_STATE_PATH), exist_ok=True)
        with open(SQL_STATE_PATH, 'w', encoding='utf-8') as f:
            json.dump(state, f, indent=2, ensure_ascii=False)
    except Exception:
        pass
    return state


_SQL_FORBIDDEN = {'DROP', 'DELETE', 'TRUNCATE', 'ALTER', 'CREATE', 'INSERT',
                  'UPDATE', 'MERGE', 'GRANT', 'REVOKE', 'EXEC', 'EXECUTE',
                  'BACKUP', 'RESTORE', 'SHUTDOWN'}


def _sql_guard(sql: str):
    """Read-only-Wache. Gibt eine Fehlermeldung zurück oder None wenn erlaubt."""
    stripped = re.sub(r'/\*.*?\*/', ' ', sql, flags=re.S)
    stripped = re.sub(r'--[^\n]*', ' ', stripped).strip().rstrip(';').strip()
    if not stripped:
        return 'Leere SQL-Abfrage.'
    first = re.split(r'\s|\(', stripped, 1)[0].upper()
    if first in _SQL_FORBIDDEN:
        return f"Statement '{first}' ist gesperrt — dieser Zugang ist nur lesend."
    if first not in {'SELECT', 'WITH'}:
        return f"Nur SELECT/WITH erlaubt (erkannt: '{first}')."
    if ';' in stripped:
        return 'Mehrere Statements in einer Abfrage sind nicht erlaubt.'
    for tok in re.findall(r'\b[A-Z]+\b', stripped.upper()):
        if tok in _SQL_FORBIDDEN:
            return f"Schlüsselwort '{tok}' ist in diesem Explorer gesperrt."
    return None


# ── API ──────────────────────────────────────────────────────────────────────

@app.route('/api/sql/status', methods=['GET', 'POST'])
def api_sql_status():
    """Treiber, Verbindung, gemerkter Zustand — die Seite zeigt das im Header."""
    out = {'server': DB_SERVER, 'user': DB_USER, 'state': _sql_state(), 'demo': MTUHUB_DEMO}
    if MTUHUB_DEMO:
        out.update(server='DEMO (.demo/*.sqlite)', drivers=['SQLite'], driver='SQLite (Demo)')
    try:
        if MTUHUB_DEMO:
            raise LookupError
        pyodbc = _pyodbc()
        out['drivers'] = pyodbc.drivers()
        out['driver']  = _sql_driver()
    except LookupError:
        pass
    except RuntimeError as e:
        out['ok'] = False
        out['error'] = str(e)
        return jsonify(out)
    try:
        t0 = time.time()
        _sql_query('SELECT @@VERSION', timeout=12, cap=1)
        out['ok'] = True
        out['ms'] = int((time.time() - t0) * 1000)
    except Exception as e:
        out['ok'] = False
        out['error'] = str(e)
    return jsonify(out)


@app.route('/api/sql/databases', methods=['POST'])
def api_sql_databases():
    """Alle Online-DBs; jede einmal antesten, ob dieser Login sie öffnen darf.
    Das Ergebnis wird im Prozess gehalten (Probe kostet pro DB eine Verbindung)."""
    force = (request.get_json(silent=True) or {}).get('force', False)
    with _sql_db_lock:
        if _sql_db_cache['databases'] is not None and not force:
            return jsonify({'databases': _sql_db_cache['databases'],
                            'cached': True, 'ts': _sql_db_cache['ts']})
        try:
            _, rows, _ = _sql_query(
                "SELECT name FROM sys.databases WHERE state_desc='ONLINE' ORDER BY name",
                cap=0)
        except Exception as e:
            return jsonify({'error': str(e)})
        databases = []
        for (name,) in rows:
            try:
                _sql_query('SELECT 1', database=name, timeout=6, cap=1)
                ok = True
            except Exception:
                ok = False
            databases.append({'name': name, 'accessible': ok})
        _sql_db_cache['databases'] = databases
        _sql_db_cache['ts'] = time.time()
    return jsonify({'databases': databases, 'cached': False, 'ts': _sql_db_cache['ts']})


@app.route('/api/sql/tables', methods=['POST'])
def api_sql_tables():
    data = request.get_json(silent=True) or {}
    db   = (data.get('database') or '').strip()
    if not db:
        return jsonify({'error': 'Keine Datenbank angegeben.'})
    try:
        _, rows, _ = _sql_query(
            "SELECT TABLE_SCHEMA, TABLE_NAME, TABLE_TYPE "
            "FROM INFORMATION_SCHEMA.TABLES "
            "WHERE TABLE_TYPE IN ('BASE TABLE','VIEW') "
            "ORDER BY TABLE_SCHEMA, TABLE_NAME", database=db, cap=0)
        return jsonify({'tables': [
            {'name': f'{s}.{t}', 'view': ty == 'VIEW'} for s, t, ty in rows]})
    except Exception as e:
        return jsonify({'error': str(e)})


@app.route('/api/sql/columns', methods=['POST'])
def api_sql_columns():
    """Spaltenliste einer Tabelle — Grundlage für das Schema-Mapping später."""
    data  = request.get_json(silent=True) or {}
    db    = (data.get('database') or '').strip()
    table = (data.get('table') or '').strip()
    if not db or not table:
        return jsonify({'error': 'Datenbank und Tabelle angeben.'})
    schema, _, name = table.rpartition('.')
    try:
        cols, rows, _ = _sql_query(
            "SELECT COLUMN_NAME, DATA_TYPE, CHARACTER_MAXIMUM_LENGTH, IS_NULLABLE, "
            "       ORDINAL_POSITION "
            "FROM INFORMATION_SCHEMA.COLUMNS "
            "WHERE TABLE_NAME = ? AND (? = '' OR TABLE_SCHEMA = ?) "
            "ORDER BY ORDINAL_POSITION",
            [name, schema, schema], database=db, cap=0)
        return jsonify({'columns': [
            {'name': r[0], 'type': r[1], 'length': r[2], 'nullable': r[3] == 'YES',
             'pos': r[4]} for r in rows]})
    except Exception as e:
        return jsonify({'error': str(e)})


@app.route('/api/sql/find-table', methods=['POST'])
def api_sql_find_table():
    """Tabellennamen über alle zugänglichen DBs suchen.
    Beantwortet die offene Frage, in welcher DB mtuae_ins_shop_incident liegt."""
    data    = request.get_json(silent=True) or {}
    pattern = (data.get('pattern') or INCIDENT_TABLE).strip()
    if not pattern:
        return jsonify({'error': 'Kein Suchmuster angegeben.'})
    dbs = _sql_db_cache['databases']
    if dbs is None:
        try:
            _, rows, _ = _sql_query(
                "SELECT name FROM sys.databases WHERE state_desc='ONLINE' ORDER BY name",
                cap=0)
            dbs = [{'name': r[0], 'accessible': True} for r in rows]
        except Exception as e:
            return jsonify({'error': str(e)})
    hits, scanned, failed = [], 0, []
    for entry in dbs:
        if not entry.get('accessible', True):
            continue
        db = entry['name']
        try:
            _, rows, _ = _sql_query(
                "SELECT TABLE_SCHEMA, TABLE_NAME, TABLE_TYPE "
                "FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_NAME LIKE ?",
                ['%' + pattern + '%'], database=db, timeout=15, cap=0)
            scanned += 1
            for sch, t, ty in _rank_tables([tuple(r) for r in rows], pattern):
                hits.append({'database': db, 'table': f'{sch}.{t}',
                             'view': (ty or '').upper() == 'VIEW',
                             'helper': _is_noise_table(t)})
        except Exception:
            failed.append(db)

    # Nur eine echte Tabelle merken — niemals _tmp/_bak & Co.
    remembered = None
    if hits and pattern.lower() in (INCIDENT_TABLE.lower(), 'x_' + INCIDENT_TABLE.lower()):
        best = next((h for h in hits if not h['helper'] and not h['view']), None)
        if best:
            _sql_state_save({'incident_database': best['database'],
                             'incident_table':    best['table']})
            _inc_table_cache.clear()
            _inc_schema_cache.clear()
            _inc_extras_cache.clear()
            remembered = best
    return jsonify({'hits': hits, 'scanned': scanned, 'failed': failed,
                    'pattern': pattern, 'state': _sql_state(),
                    'remembered': remembered})


@app.route('/api/sql/query', methods=['POST'])
def api_sql_query():
    data = request.get_json(silent=True) or {}
    sql  = (data.get('sql') or '').strip()
    db   = (data.get('database') or DB_DEFAULT).strip()
    err  = _sql_guard(sql)
    if err:
        return jsonify({'error': err})
    try:
        t0 = time.time()
        cols, rows, truncated = _sql_query(sql, database=db)
        return jsonify({'columns': cols, 'rows': rows, 'truncated': truncated,
                        'cap': SQL_ROW_CAP, 'ms': int((time.time() - t0) * 1000),
                        'database': db})
    except Exception as e:
        return jsonify({'error': str(e)})


@app.route('/api/sql/export-csv', methods=['POST'])
def api_sql_export_csv():
    """Ergebnis einer Abfrage als CSV — für Zwischenstände ohne Copy&Paste."""
    data = request.get_json(silent=True) or {}
    sql  = (data.get('sql') or '').strip()
    db   = (data.get('database') or DB_DEFAULT).strip()
    err  = _sql_guard(sql)
    if err:
        return jsonify({'error': err}), 400
    try:
        cols, rows, _ = _sql_query(sql, database=db, timeout=120, cap=100000)
    except Exception as e:
        return jsonify({'error': str(e)}), 400
    buf = io.StringIO()
    w   = csv.writer(buf, delimiter=';', quoting=csv.QUOTE_MINIMAL)
    w.writerow(cols)
    for r in rows:
        w.writerow(['' if v is None else v for v in r])
    stamp = datetime.datetime.now().strftime('%Y-%m-%d_%H%M')
    return Response(
        buf.getvalue().encode('utf-8-sig'),
        mimetype='text/csv; charset=utf-8',
        headers={'Content-Disposition': f'attachment; filename=mtuhub_{stamp}.csv'})


SQL_HTML = """<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title>SAP Claim Analyzer – MTU Hub SQL</title>
<!--SAP_BASE-->
<style>
  *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
  
  html, body { height: 100%; }
  body { font-family: var(--font); background: var(--bg); color: var(--text);
         font-size: 13px; display: flex; flex-direction: column; }






  #conn { display: flex; align-items: center; gap: 7px; font-size: 11px; color: var(--muted); }
  #dot { width: 8px; height: 8px; border-radius: 50%; background: var(--ink-3); flex-shrink: 0; }
  #dot.ok { background: var(--green); } #dot.err { background: var(--red); }

  main { flex: 1; display: flex; overflow: hidden; }
  #side { width: 250px; flex-shrink: 0; background: var(--surface);
          border-right: 1px solid var(--border); display: flex; flex-direction: column;
          overflow: hidden; }
  #server-head { padding: 9px 12px; border-bottom: 1px solid var(--border);
                 display: flex; align-items: center; gap: 9px; cursor: pointer;
                 background: var(--hover); }
  #server-head:hover { background: var(--hover); }
  #server-head .sicon { font-size: 15px; color: var(--ink-2); }
  #server-head .stext { flex: 1; min-width: 0; line-height: 1.25; }
  #server-head b { display: block; font-size: 13px; }
  #server-head span#server-sub { font-size: 11px; color: var(--muted);
                                 font-family: var(--mono); }
  #server-head button { font-size: 11px; padding: 2px 7px; }
  #crumb { padding: 5px 12px; font-size: 11px; color: var(--muted);
           border-bottom: 1px solid var(--border); display: none;
           white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
  #crumb b { color: var(--text); }
  #crumb a { color: var(--acc); cursor: pointer; text-decoration: none; }
  .grp { padding: 7px 12px 3px; font-size: 10px; font-weight: 700; color: var(--muted);
         text-transform: uppercase; letter-spacing: .07em; background: var(--hover);
         border-top: 1px solid var(--border); border-bottom: 1px solid var(--hover);
         position: sticky; top: 0; z-index: 1; }
  .grp:first-child { border-top: none; }
  .grp .n { float: right; font-weight: 400; letter-spacing: 0; }
  .side-scroll { overflow-y: auto; flex: 1; }
  .side-filter { padding: 6px 8px; border-bottom: 1px solid var(--border); }
  .side-filter input { width: 100%; padding: 5px 8px; font-size: 13px;
                       border: 1px solid var(--border); border-radius: var(--r-s); }
  .db-item { padding: 6px 12px; cursor: pointer; border-left: 3px solid transparent;
             white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
  .db-item:hover { background: var(--bg); }
  .db-item.active { border-left-color: var(--acc); color: var(--acc); font-weight: 600;
                    background: var(--bg); }
  .db-item.no-access { opacity: .4; cursor: not-allowed; font-style: italic; }
  .db-item.no-access:hover { background: transparent; }
  .tbl-item { padding: 4px 10px 4px 22px; cursor: pointer; font-size: 13px;
              color: var(--muted); white-space: nowrap; overflow: hidden;
              text-overflow: ellipsis; }
  .tbl-item:hover { background: var(--bg); color: var(--text); }
  .tbl-item.active { color: var(--acc); font-weight: 600; }
  .tbl-item .vw { font-size: 10px; color: var(--amber); margin-left: 4px; }

  #content { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
  #qbar { padding: 10px 14px; background: var(--surface);
          border-bottom: 1px solid var(--border); display: flex; gap: 10px;
          align-items: flex-end; flex-shrink: 0; }
  #qbar .grow { flex: 1; display: flex; flex-direction: column; gap: 4px; }
  #dblabel { font-size: 11px; color: var(--muted); }
  #dblabel b { color: var(--text); }
  #sql { width: 100%; min-height: 62px; resize: vertical; padding: 8px 10px;
         font-family: var(--mono); font-size: 13px;
         border: 1px solid var(--border); border-radius: var(--r-s); background: var(--surface);
         color: var(--text); }
  #sql:focus { outline: none; border-color: var(--acc); }
  .btncol { display: flex; flex-direction: column; gap: 6px; }
  button { background: var(--acc); color: var(--on-acc); border: 1px solid var(--acc); padding: 7px 14px;
           border-radius: var(--r-s); font-size: 13px; font-weight: 500; cursor: pointer; white-space: nowrap;
           transition: background .15s, border-color .15s; }
  button:hover { background: #3a3835; border-color: #3a3835; }
  button.sec { background: var(--surface); color: var(--ink); border: 1px solid var(--line-2); }
  button.sec:hover { background: var(--hover); border-color: var(--ink-3); }
  button:disabled { opacity: .45; cursor: not-allowed; }

  #snips { padding: 6px 14px; background: var(--surface);
           border-bottom: 1px solid var(--border); display: flex; gap: 6px;
           align-items: center; flex-wrap: wrap; flex-shrink: 0; }
  #snips span.lbl { font-size: 10px; text-transform: uppercase; letter-spacing: .05em;
                    color: var(--muted); font-weight: 700; margin-right: 2px; }
  .chip { font-size: 11px; padding: 3px 9px; border-radius: var(--r-m); cursor: pointer;
          border: 1px solid var(--border); background: var(--bg); color: var(--text); }
  .chip:hover { border-color: var(--acc); color: var(--acc); }

  #resbar { padding: 6px 14px; border-bottom: 1px solid var(--border);
            background: var(--surface); display: none; flex-direction: column; gap: 6px;
            flex-shrink: 0; }
  #resbar .rrow { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
  #resbar input[type=text], #resbar select { padding: 5px 9px; font-size: 13px;
            border: 1px solid var(--border); border-radius: var(--r-s); background: var(--surface);
            color: var(--text); }
  #resbar #filter { flex: 1; max-width: 300px; }
  #resbar .slbl { font-size: 10px; font-weight: 700; color: var(--muted);
                  text-transform: uppercase; letter-spacing: .07em; }
  #resbar .chk { font-size: 11px; color: var(--muted); display: flex; align-items: center;
                 gap: 4px; cursor: pointer; white-space: nowrap; }
  #filter-rows { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; }
  .frow { display: flex; gap: 4px; align-items: center; }
  .frow select { max-width: 190px; }
  .frow input { width: 150px; }
  .frow .x { border: none; background: none; color: var(--muted); cursor: pointer;
             font-size: 13px; padding: 0 3px; font-weight: 700; }
  .frow .x:hover { color: var(--red); }
  #info { font-size: 11px; color: var(--muted); }
  #results { flex: 1; overflow: auto; padding: 0; }
  table { border-collapse: collapse; width: 100%; font-size: 13px; }
  th { background: var(--hover); color: var(--muted); padding: 6px 10px; text-align: left;
       position: sticky; top: 0; z-index: 1; white-space: nowrap; font-weight: 700;
       border-bottom: 1px solid var(--border); cursor: pointer; user-select: none; }
  th:hover { color: var(--text); }
  th .ord { color: var(--acc); font-size: 10px; }
  td { padding: 4px 10px; border-bottom: 1px solid var(--hover); max-width: 380px;
       overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
       vertical-align: top; }
  td.null { color: var(--ink-3); font-style: italic; }
  tr:hover td { background: var(--hover); }
  tr.sel td { background: var(--info-bg); }
  .pad { padding: 18px; }
  .err { background: var(--crit-bg); border: 1px solid var(--red); color: var(--red);
         padding: 10px 14px; border-radius: var(--r-s); font-family: var(--mono);
         font-size: 13px; white-space: pre-wrap; margin: 14px; }
  .warn { background: var(--warn-bg); border: 1px solid var(--amber); color: var(--warn);
          padding: 8px 12px; border-radius: var(--r-s); font-size: 13px; margin: 14px; }
  .empty { color: var(--muted); padding: 24px 18px; }
  #detail { position: fixed; right: 0; top: 0; bottom: 0; width: 420px;
            background: var(--surface); border-left: 1px solid var(--border);
            box-shadow: -8px 0 32px rgba(27,27,27,.10); overflow-y: auto; display: none;
            z-index: 950; }
  #detail .dh { padding: 10px 14px; border-bottom: 1px solid var(--border);
                display: flex; justify-content: space-between; align-items: center;
                font-weight: 700; position: sticky; top: 0; background: var(--surface); }
  #detail .row { padding: 7px 14px; border-bottom: 1px solid var(--hover); }
  #detail .k { font-size: 10px; text-transform: uppercase; letter-spacing: .05em;
               color: var(--muted); font-weight: 700; margin-bottom: 2px; }
  #detail .v { font-family: var(--mono); font-size: 13px;
               word-break: break-word; white-space: pre-wrap; }
</style>
<!--SAP_SHELL-->
</head>
<body>
<!--SAP_RAIL-->
<header class="sap-ph"><!--SAP_PH-->
<div class="ph-right">
  <div id="conn"><span id="dot"></span><span id="conntext">verbinde…</span></div>
<!--SAP_MARK-->
</div>
</header>

<main>
  <div id="side">
    <div id="server-head" onclick="loadDatabases(false)" title="zurück zur Datenbankliste">
      <span class="sicon">&#9707;</span>
      <div class="stext">
        <b id="server-name">LUD Core</b>
        <span id="server-sub">msql-lud-core</span>
      </div>
      <button class="sec" onclick="event.stopPropagation();loadDatabases(true)"
              title="Zugriffsprobe wiederholen">&#8635;</button>
    </div>
    <div id="crumb"></div>
    <div class="side-filter"><input id="sidefilter" placeholder="filtern…" oninput="renderSide()"></div>
    <div class="side-scroll" id="sidelist"><div class="pad">Lade…</div></div>
  </div>

  <div id="content">
    <div id="qbar">
      <div class="grow">
        <div id="dblabel">Keine Datenbank gewählt</div>
        <textarea id="sql" spellcheck="false" placeholder="SELECT TOP 100 * FROM …   (Strg/Cmd+Enter)"></textarea>
      </div>
      <div class="btncol">
        <button id="runbtn" onclick="runQuery()">Ausführen</button>
        <button class="sec" onclick="exportCsv()">CSV</button>
      </div>
      <div class="btncol">
        <button class="sec" onclick="findIncident()">Incident-Tabelle finden</button>
        <button class="sec" id="snapbtn" onclick="snapshotZip()"
                title="Alle Tabellen von LUD_Core.MTUhub: Spalten, Zeilenzahl, Join-Kandidaten und die 100 neuesten Zeilen als ZIP">Schema-Snapshot MTUhub (ZIP)</button>
        <button class="sec" onclick="toHub()">Als Auswertung speichern</button>
        <button class="sec" onclick="clearAll()">Leeren</button>
      </div>
    </div>

    <div id="snips">
      <span class="lbl">Bausteine</span>
      <span class="chip" onclick="snipTop()">TOP 100 *</span>
      <span class="chip" onclick="snipCount()">COUNT(*)</span>
      <span class="chip" onclick="showColumns()">Spalten</span>
      <span class="chip" onclick="snipDistinct()">DISTINCT Spalte</span>
      <span class="chip" onclick="snipSchema()">Tabellen + Zeilen</span>
      <span class="lbl" style="margin-left:10px">Verlauf</span>
      <select id="hist" onchange="pickHist()" style="font-size:11px;padding:3px 6px;max-width:260px;border:1px solid var(--border);border-radius:var(--r-s)">
        <option value="">– zuletzt ausgeführt –</option>
      </select>
    </div>

    <div id="resbar">
      <div class="rrow">
        <input id="filter" placeholder="Geladene Zeilen lokal filtern…" oninput="renderRows()">
        <label class="chk" title="number, short_description, caller, assignment_group … nach vorne">
          <input type="checkbox" id="prio" checked onchange="applyPrio()"> wichtige Spalten zuerst</label>
        <button class="sec" onclick="loadAll()" title="TOP-Grenze aus der Abfrage nehmen">Alle laden</button>
        <span id="info"></span>
      </div>
      <div class="rrow" id="srow">
        <span class="slbl">Server-Suche</span>
        <div id="filter-rows"></div>
        <button class="sec" onclick="addFilterRow()">+ Filter</button>
        <button class="sec" onclick="serverSearch()">Suchen</button>
      </div>
    </div>
    <div id="results"><div class="empty">Datenbank links wählen, dann Tabelle anklicken oder SQL eingeben.</div></div>
  </div>
</main>

<div id="detail">
  <div class="dh"><span>Datensatz</span><button class="sec" onclick="closeDetail()">schließen</button></div>
  <div id="detailbody"></div>
</div>

<script>
const NL = String.fromCharCode(10);
let DBS = [], TABLES = null, CURDB = null, CURTBL = null;
let RES = null, SORT = {col: null, dir: 1};
const HIST_KEY = 'mtuhub_sql_hist';

// ── Verbindung ────────────────────────────────────────────────────────────
async function checkStatus() {
  const dot = document.getElementById('dot'), txt = document.getElementById('conntext');
  try {
    const s = await (await fetch('/api/sql/status')).json();
    if (s.server) {
      document.getElementById('server-name').textContent = prettyServer(s.server);
      document.getElementById('server-sub').textContent  = s.server;
    }
    if (s.ok) {
      dot.className = 'ok';
      txt.textContent = s.server + ' · ' + s.user + ' · ' + s.driver + ' · ' + s.ms + 'ms';
      if (s.state && s.state.incident_database) {
        txt.textContent += ' · Incidents: ' + s.state.incident_database;
      }
      return true;
    }
    dot.className = 'err';
    txt.textContent = 'keine Verbindung';
    showError('Verbindung fehlgeschlagen:\\n\\n' + (s.error || 'unbekannt') +
      (s.drivers ? '\\n\\nInstallierte ODBC-Treiber: ' + (s.drivers.join(', ') || 'keine') : ''));
    return false;
  } catch (e) {
    dot.className = 'err'; txt.textContent = 'Server nicht erreichbar';
    return false;
  }
}

// ── Sidebar ───────────────────────────────────────────────────────────────
async function loadDatabases(force) {
  document.getElementById('sidelist').innerHTML = '<div class="pad">Lade Datenbanken…</div>';
  TABLES = null; CURTBL = null;
  renderCrumb();
  const d = await (await fetch('/api/sql/databases', {
    method: 'POST', headers: {'Content-Type': 'application/json'},
    body: JSON.stringify({force: !!force})
  })).json();
  if (d.error) { document.getElementById('sidelist').innerHTML =
      '<div class="pad" style="color:var(--red)">' + esc(d.error) + '</div>'; return; }
  DBS = d.databases || [];
  renderSide();
}

async function loadTables(db) {
  CURDB = db; CURTBL = null;
  localStorage.setItem('mtuhub_db', db);
  document.getElementById('dblabel').innerHTML = 'Datenbank <b>' + esc(db) + '</b>';
  renderCrumb();
  document.getElementById('sidelist').innerHTML = '<div class="pad">Lade Tabellen…</div>';
  const d = await (await fetch('/api/sql/tables', {
    method: 'POST', headers: {'Content-Type': 'application/json'},
    body: JSON.stringify({database: db})
  })).json();
  if (d.error) { document.getElementById('sidelist').innerHTML =
      '<div class="pad" style="color:var(--red)">' + esc(d.error) + '</div>'; return; }
  TABLES = d.tables || [];
  renderSide();
}

function prettyServer(host) {
  // 'msql-lud-core' → 'LUD Core'
  return host.split(/[-_.]/).filter(p => !/^m?sql$/i.test(p))
    .map(p => p.length <= 3 ? p.toUpperCase() : p.charAt(0).toUpperCase() + p.slice(1))
    .join(' ') || host;
}

function renderCrumb() {
  const el = document.getElementById('crumb');
  if (!CURDB) { el.style.display = 'none'; return; }
  el.style.display = 'block';
  el.innerHTML = '<a onclick="loadDatabases(false)">Datenbanken</a> &rsaquo; <b>'
    + esc(CURDB) + '</b>' + (CURTBL ? ' &rsaquo; ' + esc(CURTBL) : '');
}

function renderSide() {
  const q  = document.getElementById('sidefilter').value.toLowerCase();
  const el = document.getElementById('sidelist');
  renderCrumb();

  // Ebene 1: Datenbanken, geclustert nach Zugriff
  if (TABLES === null) {
    const hit  = DBS.filter(d => d.name.toLowerCase().includes(q));
    const open = hit.filter(d => d.accessible), lock = hit.filter(d => !d.accessible);
    let html = '';
    if (open.length) {
      html += grpHead('Zugänglich', open.length)
        + open.map(d => `<div class="db-item ${CURDB === d.name ? 'active' : ''}"
             onclick="loadTables('${esc(d.name)}')">${esc(d.name)}</div>`).join('');
    }
    if (lock.length) {
      html += grpHead('Kein Zugriff', lock.length)
        + lock.map(d => `<div class="db-item no-access"
             title="Dieser Login darf die Datenbank nicht öffnen">${esc(d.name)}</div>`).join('');
    }
    el.innerHTML = html || '<div class="pad">Nichts gefunden.</div>';
    return;
  }

  // Ebene 2: Tabellen, geclustert nach Schema (Views zuletzt)
  const hit = TABLES.filter(t => t.name.toLowerCase().includes(q));
  if (!hit.length) { el.innerHTML = '<div class="pad">Nichts gefunden.</div>'; return; }
  const groups = {};
  hit.forEach(t => {
    const key = (t.view ? 'Views · ' : '') + (t.name.includes('.') ? t.name.split('.')[0] : 'ohne Schema');
    (groups[key] = groups[key] || []).push(t);
  });
  const keys = Object.keys(groups).sort((a, b) =>
    (a.startsWith('Views') - b.startsWith('Views')) || a.localeCompare(b, 'de'));
  el.innerHTML = keys.map(k => grpHead(k, groups[k].length) + groups[k].map(t =>
    `<div class="tbl-item ${CURTBL === t.name ? 'active' : ''}"
          title="${esc(t.name)}" onclick="pickTable('${esc(t.name)}')">`
    + esc(t.name.includes('.') ? t.name.split('.').slice(1).join('.') : t.name)
    + (t.view ? '<span class="vw">VIEW</span>' : '') + '</div>').join('')).join('');
}

function grpHead(label, n) {
  return `<div class="grp">${esc(label)}<span class="n">${n}</span></div>`;
}

function pickTable(t) {
  CURTBL = t;
  document.getElementById('filter-rows').innerHTML = '';   // Spalten der alten Tabelle verwerfen
  renderSide();
  document.getElementById('sql').value = `SELECT TOP 100 *\\nFROM ${t}`;
  runQuery();
}

// ── Bausteine ─────────────────────────────────────────────────────────────
function needTable() {
  if (!CURTBL) { alert('Erst links eine Tabelle wählen.'); return false; }
  return true;
}
function snipTop()   { if (needTable()) { setSql(`SELECT TOP 100 *\\nFROM ${CURTBL}`); runQuery(); } }
function snipCount() { if (needTable()) { setSql(`SELECT COUNT(*) AS zeilen\\nFROM ${CURTBL}`); runQuery(); } }
function snipDistinct() {
  if (!needTable()) return;
  const c = prompt('Spaltenname für DISTINCT + Häufigkeit:');
  if (!c) return;
  setSql(`SELECT [${c}] AS wert, COUNT(*) AS anzahl\\nFROM ${CURTBL}\\nGROUP BY [${c}]\\nORDER BY anzahl DESC`);
  runQuery();
}
function snipSchema() {
  if (!CURDB) { alert('Erst eine Datenbank wählen.'); return; }
  setSql("SELECT s.name AS schema_name, t.name AS tabelle, SUM(p.rows) AS zeilen\\n"
    + "FROM sys.tables t\\n"
    + "JOIN sys.schemas s ON s.schema_id = t.schema_id\\n"
    + "JOIN sys.partitions p ON p.object_id = t.object_id AND p.index_id IN (0,1)\\n"
    + "GROUP BY s.name, t.name\\nORDER BY zeilen DESC");
  runQuery();
}
async function showColumns() {
  if (!needTable()) return;
  const d = await (await fetch('/api/sql/columns', {
    method: 'POST', headers: {'Content-Type': 'application/json'},
    body: JSON.stringify({database: CURDB, table: CURTBL})
  })).json();
  if (d.error) { showError(d.error); return; }
  RES = {columns: ['#', 'Spalte', 'Typ', 'Länge', 'NULL?'],
         rows: d.columns.map(c => [c.pos, c.name, c.type, c.length, c.nullable ? 'ja' : 'nein']),
         ms: 0, truncated: false};
  SORT = {col: null, dir: 1};
  document.getElementById('srow').style.display = 'none';
  document.getElementById('info').textContent = d.columns.length + ' Spalten in ' + CURTBL;
  document.getElementById('resbar').style.display = 'flex';
  renderRows();
}

async function findIncident() {
  const pattern = prompt('Tabellenname suchen (über alle zugänglichen Datenbanken):',
                         'mtuae_ins_shop_incident');
  if (!pattern) return;
  document.getElementById('results').innerHTML =
    '<div class="empty">Suche „' + esc(pattern) + '" in allen Datenbanken — das dauert einen Moment…</div>';
  const d = await (await fetch('/api/sql/find-table', {
    method: 'POST', headers: {'Content-Type': 'application/json'},
    body: JSON.stringify({pattern})
  })).json();
  if (d.error) { showError(d.error); return; }
  if (!d.hits.length) {
    document.getElementById('results').innerHTML =
      '<div class="warn">Keine Tabelle mit „' + esc(pattern) + '" gefunden. '
      + d.scanned + ' Datenbanken durchsucht'
      + (d.failed.length ? ', ' + d.failed.length + ' ohne Zugriff' : '') + '.</div>';
    return;
  }
  RES = {columns: ['Datenbank', 'Tabelle', 'Typ', 'Bewertung'],
         rows: d.hits.map(h => [h.database, h.table, h.view ? 'VIEW' : 'TABLE',
                                h.helper ? 'Hilfstabelle — nicht verwenden'
                                         : (h.view ? 'Sicht' : 'Quelle')]),
         ms: 0, truncated: false};
  SORT = {col: null, dir: 1};
  document.getElementById('srow').style.display = 'none';
  document.getElementById('resbar').style.display = 'flex';
  document.getElementById('info').textContent =
    d.hits.length + ' Treffer in ' + d.scanned + ' Datenbanken. Zeile anklicken zum Öffnen.'
    + (d.remembered ? '  Gemerkt als Incident-Quelle: ' + d.remembered.database
                      + ' · ' + d.remembered.table : '');
  renderRows(h => { loadTables(h[0]).then(() => pickTable(h[1])); });
  checkStatus();
}

// ── Query ─────────────────────────────────────────────────────────────────
function setSql(s) { document.getElementById('sql').value = s; }

async function runQuery() {
  const sql = document.getElementById('sql').value.trim();
  if (!sql) return;
  if (!CURDB) { alert('Erst links eine Datenbank wählen.'); return; }
  const btn = document.getElementById('runbtn');
  btn.disabled = true; btn.textContent = 'läuft…';
  document.getElementById('results').innerHTML = '<div class="empty">Abfrage läuft…</div>';
  try {
    const d = await (await fetch('/api/sql/query', {
      method: 'POST', headers: {'Content-Type': 'application/json'},
      body: JSON.stringify({sql, database: CURDB})
    })).json();
    if (d.error) { showError(d.error); document.getElementById('resbar').style.display = 'none'; return; }
    RES = d;
    RES.raw = {columns: d.columns.slice(), rows: d.rows};   // Originalreihenfolge merken
    SORT = {col: null, dir: 1};
    pushHist(sql);
    document.getElementById('resbar').style.display = 'flex';
    document.getElementById('srow').style.display = CURTBL ? 'flex' : 'none';
    if (!document.querySelectorAll('#filter-rows .frow').length) addFilterRow();
    applyPrio();
  } catch (e) {
    showError('Netzwerkfehler: ' + e.message);
  } finally {
    btn.disabled = false; btn.textContent = 'Ausführen';
  }
}

function exportCsv() {
  const sql = document.getElementById('sql').value.trim();
  if (!sql || !CURDB) { alert('Erst Datenbank wählen und Abfrage eingeben.'); return; }
  const f = document.createElement('form');
  f.method = 'POST'; f.action = '/api/sql/export-csv-form'; f.style.display = 'none';
  const add = (n, v) => { const i = document.createElement('input'); i.name = n; i.value = v; f.appendChild(i); };
  add('sql', sql); add('database', CURDB);
  document.body.appendChild(f); f.submit(); setTimeout(() => f.remove(), 2000);
}

async function snapshotZip() {
  const btn = document.getElementById('snapbtn');
  const label = btn.textContent;
  btn.disabled = true; btn.textContent = 'Snapshot läuft…';
  document.getElementById('results').innerHTML =
    '<div class="empty">Lese alle Tabellen aus LUD_Core.MTUhub (je 100 neueste Zeilen) — das kann ein, zwei Minuten dauern…</div>';
  try {
    const r = await fetch('/api/sql/snapshot', {
      method: 'POST', headers: {'Content-Type': 'application/json'}, body: '{}'});
    if (!r.ok) {
      let msg = 'HTTP ' + r.status;
      try { msg = (await r.json()).error || msg; } catch (e) {}
      showError('Snapshot fehlgeschlagen: ' + msg);
      return;
    }
    const blob = await r.blob();
    const cd = r.headers.get('Content-Disposition') || '';
    const m = cd.match(/filename=([^;]+)/);
    const a = document.createElement('a');
    a.href = URL.createObjectURL(blob);
    a.download = m ? m[1].trim() : 'snapshot_MTUhub.zip';
    document.body.appendChild(a); a.click();
    setTimeout(() => { URL.revokeObjectURL(a.href); a.remove(); }, 2000);
    document.getElementById('results').innerHTML =
      '<div class="empty">Snapshot heruntergeladen: ' + esc(a.download) + ' (' + Math.round(blob.size / 1024)
      + ' KB). Einstieg: _uebersicht.md im ZIP.</div>';
  } catch (e) {
    showError('Netzwerkfehler: ' + e.message);
  } finally {
    btn.disabled = false; btn.textContent = label;
  }
}

// ── Übergabe an die Auswertungsseite ──────────────────────────────────────
function toHub() {
  const sql = document.getElementById('sql').value.trim();
  if (!sql || !CURDB) { alert('Erst Datenbank wählen und Abfrage eingeben.'); return; }
  const title = CURTBL ? CURTBL.split('.').pop() : 'Neue Auswertung';
  sessionStorage.setItem('hub_new', JSON.stringify({sql, database: CURDB, title}));
  location.href = '/hub';
}

// ── Spalten-Priorisierung ─────────────────────────────────────────────────
// Die Incident-Tabelle hat 125 Spalten; ohne das hier scrollt man sich zu Tode.
const PRIO = ['number', 'short_description', 'description', 'wbs', 'criticality',
              'stage', 'state', 'caller', 'requester_group', 'assignment_group',
              'sys_created_on', 'resolved_at', 'comments_and_work_notes', 'sys_id'];

function applyPrio() {
  if (!RES || !RES.raw) return;
  const on = document.getElementById('prio').checked;
  if (!on) { RES.columns = RES.raw.columns.slice(); RES.rows = RES.raw.rows; }
  else {
    const cols = RES.raw.columns;
    const head = [];
    PRIO.forEach(pp => {
      const i = cols.findIndex(c => c.toLowerCase() === pp);
      if (i >= 0 && head.indexOf(i) < 0) head.push(i);
    });
    const rest  = cols.map((_, i) => i).filter(i => head.indexOf(i) < 0);
    const order = head.concat(rest);
    RES.columns = order.map(i => cols[i]);
    RES.rows    = RES.raw.rows.map(r => order.map(i => r[i]));
  }
  SORT = {col: null, dir: 1};
  renderRows(window._rowClick);
}

// ── Server-Suche (Spalte + Begriff, UND-verknüpft) ────────────────────────
function colOptions(sel) {
  const cols = RES ? (RES.raw ? RES.raw.columns : RES.columns) : [];
  return '<option value="">— Spalte —</option>' + cols.map(c =>
    `<option value="${esc(c)}" ${c === sel ? 'selected' : ''}>${esc(c)}</option>`).join('');
}

function addFilterRow(col, term) {
  const box = document.getElementById('filter-rows');
  const row = document.createElement('div');
  row.className = 'frow';
  row.innerHTML = `<select class="f-col">${colOptions(col)}</select>`
    + `<input type="text" class="f-term" placeholder="enthält…" value="${esc(term || '')}"`
    + ` onkeydown="if(event.key==='Enter')serverSearch()">`
    + `<button class="x" onclick="this.closest('.frow').remove()" title="entfernen">&times;</button>`;
  box.appendChild(row);
}

function serverSearch() {
  if (!CURTBL) { alert('Server-Suche braucht eine Tabelle — links eine anklicken.'); return; }
  const conds = [];
  document.querySelectorAll('#filter-rows .frow').forEach(r => {
    const col  = r.querySelector('.f-col').value;
    const term = r.querySelector('.f-term').value.trim();
    if (col && term) conds.push('[' + col + "] LIKE '%" + term.replace(/'/g, "''") + "%'");
  });
  if (!conds.length) { alert('Mindestens eine Spalte mit Suchbegriff angeben.'); return; }
  setSql('SELECT TOP 1000 *' + NL + 'FROM ' + CURTBL + NL + 'WHERE ' + conds.join(NL + '  AND '));
  runQuery(true);
}

function loadAll() {
  const sql = document.getElementById('sql').value;
  if (!/SELECT\s+TOP\s+\d+/i.test(sql)) { alert('Die Abfrage hat keine TOP-Grenze.'); return; }
  setSql(sql.replace(/SELECT\s+TOP\s+\d+/i, 'SELECT'));
  runQuery(true);
}

// ── Ergebnis ──────────────────────────────────────────────────────────────
function filteredRows() {
  if (!RES) return [];
  const q = document.getElementById('filter').value.toLowerCase();
  let rows = RES.rows;
  if (q) rows = rows.filter(r => r.some(v => v != null && String(v).toLowerCase().includes(q)));
  if (SORT.col !== null) {
    const i = SORT.col;
    rows = rows.slice().sort((a, b) => {
      const x = a[i], y = b[i];
      if (x == null) return 1; if (y == null) return -1;
      const nx = Number(x), ny = Number(y);
      if (!isNaN(nx) && !isNaN(ny) && x !== '' && y !== '') return (nx - ny) * SORT.dir;
      return String(x).localeCompare(String(y), 'de') * SORT.dir;
    });
  }
  return rows;
}

function renderRows(onClick) {
  if (!RES) return;
  const rows = filteredRows();
  const info = document.getElementById('info');
  if (RES.ms !== undefined && RES.columns) {
    info.textContent = rows.length + ' / ' + RES.rows.length + ' Zeilen'
      + (RES.ms ? ' · ' + RES.ms + ' ms' : '')
      + (RES.truncated ? ' · gekappt bei ' + RES.cap + ' — TOP/WHERE ergänzen' : '');
  }
  if (!RES.columns.length) {
    document.getElementById('results').innerHTML = '<div class="empty">Abfrage ohne Ergebnismenge.</div>';
    return;
  }
  const head = '<tr>' + RES.columns.map((c, i) =>
    `<th onclick="sortBy(${i})">${esc(c)}${SORT.col === i ? '<span class="ord">' + (SORT.dir > 0 ? ' ▲' : ' ▼') + '</span>' : ''}</th>`
  ).join('') + '</tr>';
  const body = rows.map((r, ri) =>
    `<tr onclick="rowClick(${ri})">` + r.map(v =>
      v === null ? '<td class="null">NULL</td>'
                 : `<td title="${esc(String(v))}">${esc(String(v))}</td>`
    ).join('') + '</tr>'
  ).join('');
  document.getElementById('results').innerHTML =
    '<table><thead>' + head + '</thead><tbody>' + body + '</tbody></table>';
  window._rowClick = onClick || null;
  window._visRows  = rows;
}

function rowClick(i) {
  const r = window._visRows[i];
  if (window._rowClick) { window._rowClick(r); return; }
  document.getElementById('detailbody').innerHTML = RES.columns.map((c, ci) =>
    `<div class="row"><div class="k">${esc(c)}</div><div class="v">${
      r[ci] === null ? '<span style="color:var(--ink-3)">NULL</span>' : esc(String(r[ci]))}</div></div>`
  ).join('');
  document.getElementById('detail').style.display = 'block';
}
function closeDetail() { document.getElementById('detail').style.display = 'none'; }

function sortBy(i) {
  if (SORT.col === i) SORT.dir = -SORT.dir; else { SORT.col = i; SORT.dir = 1; }
  renderRows(window._rowClick);
}

function showError(msg) {
  document.getElementById('results').innerHTML = '<div class="err">' + esc(msg) + '</div>';
}
function clearAll() {
  document.getElementById('sql').value = '';
  document.getElementById('filter').value = '';
  document.getElementById('filter-rows').innerHTML = '';
  document.getElementById('resbar').style.display = 'none';
  document.getElementById('results').innerHTML = '<div class="empty">Bereit.</div>';
  RES = null; closeDetail();
}
function esc(s) {
  return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;')
                  .replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}

// ── Verlauf ───────────────────────────────────────────────────────────────
function pushHist(sql) {
  let h = [];
  try { h = JSON.parse(localStorage.getItem(HIST_KEY) || '[]'); } catch (e) {}
  h = h.filter(x => x !== sql); h.unshift(sql); h = h.slice(0, 25);
  try { localStorage.setItem(HIST_KEY, JSON.stringify(h)); } catch (e) {}
  renderHist();
}
function renderHist() {
  let h = [];
  try { h = JSON.parse(localStorage.getItem(HIST_KEY) || '[]'); } catch (e) {}
  document.getElementById('hist').innerHTML = '<option value="">– zuletzt ausgeführt –</option>'
    + h.map((s, i) => `<option value="${i}">${esc(s.replace(/\\s+/g, ' ').slice(0, 70))}</option>`).join('');
}
function pickHist() {
  const sel = document.getElementById('hist');
  if (sel.value === '') return;
  let h = [];
  try { h = JSON.parse(localStorage.getItem(HIST_KEY) || '[]'); } catch (e) {}
  setSql(h[+sel.value] || ''); sel.value = '';
}

document.addEventListener('keydown', e => {
  if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { e.preventDefault(); runQuery(); }
  if (e.key === 'Escape') closeDetail();
});

(async function init() {
  renderHist();
  const ok = await checkStatus();
  if (!ok) { document.getElementById('sidelist').innerHTML =
      '<div class="pad" style="color:var(--red)">Keine Verbindung.</div>'; return; }
  await loadDatabases(false);
  const last = localStorage.getItem('mtuhub_db');
  if (last && DBS.some(d => d.name === last && d.accessible)) loadTables(last);
})();
</script>
</body>
</html>
"""


@app.route('/sql')
def sql_page():
    return SQL_HTML


@app.route('/api/sql/export-csv-form', methods=['POST'])
def api_sql_export_csv_form():
    """Formular-Variante von /api/sql/export-csv — löst den Browser-Download aus."""
    sql = (request.form.get('sql') or '').strip()
    db  = (request.form.get('database') or DB_DEFAULT).strip()
    err = _sql_guard(sql)
    if err:
        return Response(err, status=400, mimetype='text/plain; charset=utf-8')
    try:
        cols, rows, _ = _sql_query(sql, database=db, timeout=120, cap=100000)
    except Exception as e:
        return Response(str(e), status=400, mimetype='text/plain; charset=utf-8')
    buf = io.StringIO()
    w   = csv.writer(buf, delimiter=';', quoting=csv.QUOTE_MINIMAL)
    w.writerow(cols)
    for r in rows:
        w.writerow(['' if v is None else v for v in r])
    stamp = datetime.datetime.now().strftime('%Y-%m-%d_%H%M')
    return Response(
        buf.getvalue().encode('utf-8-sig'),
        mimetype='text/csv; charset=utf-8',
        headers={'Content-Disposition': f'attachment; filename=mtuhub_{stamp}.csv'})


# ── Schema-Snapshot ──────────────────────────────────────────────────────────
# Ein Knopf: alle Tabellen eines Schemas mit Struktur, Zeilenzahl und den
# neuesten Zeilen als ZIP — damit sich die Zusammenhänge offline besprechen
# lassen, ohne Verbindung zu msql-lud-core. Beziehungen sind im ServiceNow-
# Spiegel meist nicht als FK deklariert, deshalb zusätzlich Join-Kandidaten
# nach Namenskonvention (…_sys_id → sys_id, gleiche Spaltennamen).
SNAPSHOT_DATABASE  = 'LUD_Core'
SNAPSHOT_SCHEMA    = 'MTUhub'
SNAPSHOT_ROWS      = 100
SNAPSHOT_TIMEOUT   = 60                  # Sekunden je Abfrage
SNAPSHOT_ORDER_BY  = ('sys_created_on', 'sys_updated_on', 'opened_at')
# Audit-Spalten stehen in jeder Tabelle — als Join-Kandidat wertlos.
_SNAPSHOT_GENERIC  = {'sys_id', 'sys_created_on', 'sys_created_by', 'sys_updated_on',
                      'sys_updated_by', 'sys_mod_count', 'sys_tags', 'sys_domain',
                      'sys_domain_path', 'sys_class_name', 'import_at'}


def _snapshot_qi(name: str) -> str:
    return '[' + name.replace(']', ']]') + ']'


def _snapshot_csv(cols, rows) -> bytes:
    buf = io.StringIO()
    w   = csv.writer(buf, delimiter=';', quoting=csv.QUOTE_MINIMAL)
    w.writerow(cols)
    for r in rows:
        w.writerow(['' if v is None else v for v in r])
    return buf.getvalue().encode('utf-8-sig')


def _snapshot_build(database: str, schema: str, top: int) -> bytes:
    """Baut das ZIP. Eine Verbindung für alles (schont das Session-Budget),
    Fehler je Tabelle werden protokolliert statt abzubrechen."""
    import zipfile
    t_start = time.time()
    log     = []
    with _sql_conn(database) as con:
        con.timeout = SNAPSHOT_TIMEOUT
        cur = con.cursor()

        def q(sql, params=None):
            cur.execute(sql, params or [])
            cols = [d[0] for d in cur.description] if cur.description else []
            return cols, [[_sql_jsonable(v) for v in r] for r in cur.fetchall()]

        _, trows = q("SELECT TABLE_NAME, TABLE_TYPE FROM INFORMATION_SCHEMA.TABLES "
                     "WHERE TABLE_SCHEMA = ? ORDER BY TABLE_NAME", [schema])
        if not trows:
            raise RuntimeError(f'Schema {schema} in {database} hat keine sichtbaren Tabellen.')

        col_head = ['tabelle', 'pos', 'spalte', 'typ', 'max_laenge', 'precision',
                    'scale', 'nullable', 'pk']
        _, crows = q(
            "SELECT c.TABLE_NAME, c.ORDINAL_POSITION, c.COLUMN_NAME, c.DATA_TYPE, "
            "c.CHARACTER_MAXIMUM_LENGTH, c.NUMERIC_PRECISION, c.NUMERIC_SCALE, c.IS_NULLABLE, "
            "CASE WHEN k.COLUMN_NAME IS NULL THEN '' ELSE 'PK' END "
            "FROM INFORMATION_SCHEMA.COLUMNS c "
            "LEFT JOIN (SELECT ku.TABLE_NAME, ku.COLUMN_NAME "
            "           FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS tc "
            "           JOIN INFORMATION_SCHEMA.KEY_COLUMN_USAGE ku "
            "             ON ku.CONSTRAINT_NAME = tc.CONSTRAINT_NAME "
            "            AND ku.TABLE_SCHEMA = tc.TABLE_SCHEMA "
            "           WHERE tc.CONSTRAINT_TYPE = 'PRIMARY KEY' AND tc.TABLE_SCHEMA = ?) k "
            "  ON k.TABLE_NAME = c.TABLE_NAME AND k.COLUMN_NAME = c.COLUMN_NAME "
            "WHERE c.TABLE_SCHEMA = ? ORDER BY c.TABLE_NAME, c.ORDINAL_POSITION",
            [schema, schema])
        cols_by_table = defaultdict(list)
        for r in crows:
            cols_by_table[r[0]].append(r)

        # Zeilenzahlen aus den Metadaten — COUNT(*) auf großen Tabellen wäre teuer.
        counts = {}
        try:
            _, rows = q("SELECT t.name, SUM(p.rows) FROM sys.tables t "
                        "JOIN sys.schemas s ON s.schema_id = t.schema_id "
                        "JOIN sys.partitions p ON p.object_id = t.object_id "
                        "AND p.index_id IN (0, 1) WHERE s.name = ? GROUP BY t.name", [schema])
            counts = {r[0]: r[1] for r in rows}
        except Exception as e:
            log.append(f'Zeilenzahlen nicht lesbar: {e}')

        fk_head = ['fk_name', 'von_tabelle', 'von_spalte', 'nach_schema',
                   'nach_tabelle', 'nach_spalte']
        fk_rows = []
        try:
            _, fk_rows = q(
                "SELECT fk.name, tp.name, cp.name, sr.name, tr.name, cr.name "
                "FROM sys.foreign_keys fk "
                "JOIN sys.foreign_key_columns fc ON fc.constraint_object_id = fk.object_id "
                "JOIN sys.tables tp ON tp.object_id = fc.parent_object_id "
                "JOIN sys.schemas sp ON sp.schema_id = tp.schema_id "
                "JOIN sys.columns cp ON cp.object_id = fc.parent_object_id "
                "  AND cp.column_id = fc.parent_column_id "
                "JOIN sys.tables tr ON tr.object_id = fc.referenced_object_id "
                "JOIN sys.schemas sr ON sr.schema_id = tr.schema_id "
                "JOIN sys.columns cr ON cr.object_id = fc.referenced_object_id "
                "  AND cr.column_id = fc.referenced_column_id "
                "WHERE sp.name = ? ORDER BY tp.name, fk.name", [schema])
        except Exception as e:
            log.append(f'Fremdschlüssel nicht lesbar: {e}')

        files, overview, samples = {}, [], {}
        for tname, ttype in trows:
            names = [c[2] for c in cols_by_table.get(tname, [])]
            lower = {n.lower(): n for n in names}
            order = next((lower[c] for c in SNAPSHOT_ORDER_BY if c in lower), '')
            full  = f'{_snapshot_qi(schema)}.{_snapshot_qi(tname)}'
            sql   = f'SELECT TOP {top} * FROM {full}'
            t0, err, n = time.time(), '', 0
            try:
                try:
                    cols, rows = q(sql + (f' ORDER BY {_snapshot_qi(order)} DESC' if order else ''))
                except Exception as e:
                    if not order:
                        raise
                    # Sortieren über eine unindizierte Spalte kann zu lange dauern —
                    # lieber beliebige Zeilen als gar keine.
                    log.append(f'{tname}: ORDER BY {order} fehlgeschlagen ({e}), ohne Sortierung wiederholt')
                    order = f'(ohne — {order} zu langsam)'
                    cols, rows = q(sql)
                n = len(rows)
                files[f'daten/{schema}.{tname}.csv'] = _snapshot_csv(cols, rows)
                samples[tname] = (cols, rows)
            except Exception as e:
                err = str(e)
                log.append(f'{tname}: {err}')
            overview.append([tname, 'VIEW' if ttype == 'VIEW' else 'TABLE',
                             'ja' if _is_noise_table(tname) else '',
                             counts.get(tname, ''), len(names), n, order,
                             int((time.time() - t0) * 1000), err])

    # Join-Kandidaten — nur Hinweise, die Stichprobe bestätigt oder nicht.
    sys_ids = {}                                  # Tabelle → Menge der sys_id-Werte
    for tname, (cols, rows) in samples.items():
        idx = next((i for i, c in enumerate(cols) if c.lower() == 'sys_id'), None)
        if idx is not None:
            sys_ids[tname] = {r[idx] for r in rows if r[idx]}

    def col_values(tname, col):
        cols, rows = samples.get(tname, ([], []))
        idx = next((i for i, c in enumerate(cols) if c == col), None)
        return {r[idx] for r in rows if idx is not None and r[idx] not in (None, '')}

    tables_with_sysid = [t for t, c in cols_by_table.items()
                         if any(x[2].lower() == 'sys_id' for x in c)]
    join_head = ['regel', 'von_tabelle', 'von_spalte', 'nach_tabelle', 'nach_spalte',
                 'werte_in_stichprobe', 'davon_gefunden']
    joins = []
    by_name = defaultdict(list)
    for tname, cl in cols_by_table.items():
        for c in cl:
            col = c[2]
            if col.lower().endswith('_sys_id') and col.lower() != 'sys_id':
                vals = col_values(tname, col)
                found = [(o, len(vals & sys_ids.get(o, set())))
                         for o in tables_with_sysid if o != tname]
                found = [f for f in found if f[1]]
                if not found:
                    # Kein Beleg in der Stichprobe → Ziel nach Namen raten
                    # (requester_group_sys_id → Tabelle mit „group" im Namen).
                    stem  = col.lower()[:-len('_sys_id')]
                    words = [stem] + [w for w in stem.split('_') if len(w) > 2][::-1]
                    guess = next(([o for o in tables_with_sysid
                                   if o != tname and w in o.lower()] for w in words
                                  if any(o != tname and w in o.lower()
                                         for o in tables_with_sysid)), [])
                    found = [(o, 0) for o in guess] or [('?', 0)]
                for other, hit in found:
                    joins.append(['sys_id-Referenz', tname, col, other, 'sys_id', len(vals), hit])
            if col.lower() not in _SNAPSHOT_GENERIC:
                by_name[col.lower()].append((tname, col))
    for key, occ in by_name.items():
        for i, (ta, ca) in enumerate(occ):
            va = col_values(ta, ca)
            for tb, cb in occ[i + 1:]:
                joins.append(['gleicher Spaltenname', ta, ca, tb, cb, len(va),
                              len(va & col_values(tb, cb))])
    joins.sort(key=lambda j: (j[0], -j[6], j[1], j[2]))

    ov_head = ['tabelle', 'typ', 'hilfstabelle', 'zeilen_gesamt', 'spalten',
               'zeilen_im_snapshot', 'sortiert_nach', 'ms', 'fehler']
    files['_tabellen.csv']         = _snapshot_csv(ov_head, overview)
    files['_spalten.csv']          = _snapshot_csv(col_head, crows)
    files['_fremdschluessel.csv']  = _snapshot_csv(fk_head, fk_rows)
    files['_join_kandidaten.csv']  = _snapshot_csv(join_head, joins)

    # Kompakte Übersicht zum Lesen ohne Tabellenkalkulation.
    stamp = datetime.datetime.now().strftime('%Y-%m-%d %H:%M')
    md = [f'# Schema-Snapshot {database}.{schema}', '',
          f'Server `{DB_SERVER}` · erstellt {stamp} · {len(trows)} Tabellen · '
          f'je Tabelle bis zu {top} neueste Zeilen (sortiert nach '
          f'{" / ".join(SNAPSHOT_ORDER_BY)}, was zuerst vorhanden ist) · '
          f'Dauer {time.time() - t_start:.0f} s', '',
          '## Dateien', '',
          '- `_tabellen.csv` — Tabellen mit Zeilenzahl (aus sys.partitions), Spaltenzahl, Fehler',
          '- `_spalten.csv` — alle Spalten mit Typ, Länge, nullable, PK',
          '- `_fremdschluessel.csv` — deklarierte FKs (im ServiceNow-Spiegel meist leer)',
          '- `_join_kandidaten.csv` — vermutete Verknüpfungen nach Namenskonvention; '
          '`davon_gefunden` = wie viele Stichprobenwerte im Ziel (ebenfalls Stichprobe) '
          'wiedergefunden wurden. 0 heißt nicht „falsch", nur „in der Stichprobe nicht belegt" — '
          'dann ist `nach_tabelle` nach dem Spaltennamen geraten (`?` = keine Idee).',
          '- `daten/<schema>.<tabelle>.csv` — die Stichprobe (UTF-8, Trennzeichen `;`)', '',
          '## Tabellen', '']
    for row in overview:
        tname, ttype, helper, total, ncols, n, order, ms, err = row
        md.append(f'### {tname}' + (' (VIEW)' if ttype == 'VIEW' else '')
                  + (' — Hilfstabelle' if helper else ''))
        md.append(f'{total if total != "" else "?"} Zeilen gesamt · {ncols} Spalten · '
                  f'{n} im Snapshot' + (f' · sortiert nach {order}' if order else '')
                  + (f' · **Fehler:** {err}' if err else ''))
        md.append('')
        md.append(', '.join(
            f'`{c[2]}` {c[3]}' + (f'({c[4]})' if c[4] not in (None, '') else '')
            + (' PK' if c[8] else '')
            for c in cols_by_table.get(tname, [])))
        md.append('')
    if log:
        md += ['## Protokoll', ''] + [f'- {l}' for l in log] + ['']
    files['_uebersicht.md'] = '\n'.join(md).encode('utf-8')

    out = io.BytesIO()
    with zipfile.ZipFile(out, 'w', zipfile.ZIP_DEFLATED) as z:
        for name, data in files.items():
            z.writestr(name, data)
    return out.getvalue()


@app.route('/api/sql/snapshot', methods=['POST'])
def api_sql_snapshot():
    """Ein Knopf auf /sql: Schema-Snapshot von LUD_Core.MTUhub als ZIP."""
    data   = request.get_json(silent=True) or {}
    db     = (data.get('database') or SNAPSHOT_DATABASE).strip()
    schema = (data.get('schema') or SNAPSHOT_SCHEMA).strip()
    try:
        top = max(1, min(int(data.get('top') or SNAPSHOT_ROWS), 1000))
    except (TypeError, ValueError):
        top = SNAPSHOT_ROWS
    try:
        payload = _snapshot_build(db, schema, top)
    except Exception as e:
        return jsonify({'error': str(e)}), 400
    stamp = datetime.datetime.now().strftime('%Y-%m-%d_%H%M')
    return Response(
        payload, mimetype='application/zip',
        headers={'Content-Disposition':
                 f'attachment; filename=snapshot_{db}_{schema}_{stamp}.zip'})


# ══════════════════════════════════════════════════════════════════════════════
# MTU Hub — Auswertungen (dauerhaft gespeicherte SQL-Abfragen + Diagramme)
# Definitionen in hub_charts.json, letzte Ergebnisse als Snapshot im Parse-Cache,
# damit die Seite auch ohne DB-Verbindung noch etwas zeigt.
# ══════════════════════════════════════════════════════════════════════════════

HUB_CHARTS_PATH = os.path.join(BASE_DIR, 'hub_charts.json')
HUB_TTL_DEFAULT = 900          # Sekunden, bis eine Auswertung erneut die DB fragt
HUB_ROW_CAP     = 5000

_hub_lock      = threading.Lock()
_hub_runcache  = {}            # id → {'ts':float, 'payload':dict}

HUB_VIZ = {'bar', 'hbar', 'line', 'area', 'pie', 'doughnut', 'kpi', 'table'}


def hub_load() -> list:
    if os.path.exists(HUB_CHARTS_PATH):
        try:
            with open(HUB_CHARTS_PATH, encoding='utf-8') as f:
                content = f.read().strip()
                if content:
                    data = json.loads(content)
                    if isinstance(data, list):
                        return data
        except Exception:
            pass
    return []


def hub_save(charts: list):
    with open(HUB_CHARTS_PATH, 'w', encoding='utf-8') as f:
        json.dump(charts, f, indent=2, ensure_ascii=False)


def _hub_snapshot_path(cid: str) -> str:
    safe = re.sub(r'[^A-Za-z0-9_-]', '_', cid)[:40]
    return os.path.join(SQL_CACHE_DIR, f'hub_{safe}.json')


def _hub_snapshot_write(cid: str, payload: dict):
    try:
        os.makedirs(SQL_CACHE_DIR, exist_ok=True)
        tmp = _hub_snapshot_path(cid) + '.tmp'
        with open(tmp, 'w', encoding='utf-8') as f:
            json.dump(payload, f, ensure_ascii=False)
        os.replace(tmp, _hub_snapshot_path(cid))
    except Exception:
        pass


def _hub_snapshot_read(cid: str):
    try:
        with open(_hub_snapshot_path(cid), encoding='utf-8') as f:
            return json.load(f)
    except Exception:
        return None


_SPEC_TEXT = ('metric', 'group', 'grain', 'split', 'facet', 'sort')


def _hub_spec_clean(spec):
    """Die Baukasten-Auswahl säubern. Sie wird neben dem SQL gespeichert, damit
    der Editor eine Auswertung später wieder aufgeklappt zeigt, statt bei den
    Vorgaben zu landen — das SQL selbst wird nie zurückgelesen."""
    if not isinstance(spec, dict):
        return None
    out = {}
    for k in _SPEC_TEXT:
        v = spec.get(k)
        if isinstance(v, str):
            out[k] = v.strip()[:60]
    for k in ('split_values', 'facet_values'):
        v = spec.get(k)
        if isinstance(v, list):
            out[k] = [str(x)[:200] for x in v[:40]]
    try:
        out['limit'] = max(0, int(spec.get('limit') or 0))
    except (TypeError, ValueError):
        out['limit'] = 0
    flt = []
    for f in (spec.get('filters') or [])[:20]:
        if isinstance(f, dict) and f.get('field'):
            flt.append({'field':  str(f.get('field') or '')[:60],
                        'op':     str(f.get('op') or '')[:30],
                        'values': [str(x)[:200] for x in (f.get('values') or [])[:40]]})
    out['filters'] = flt
    return out or None


def _hub_norm(c: dict, existing: dict = None) -> dict:
    """Eine Auswertungsdefinition säubern und mit Vorgaben auffüllen."""
    base = dict(existing or {})
    cid  = (c.get('id') or base.get('id') or
            'a' + hashlib.md5(f'{time.time()}{c.get("title","")}'.encode()).hexdigest()[:10])
    viz  = (c.get('viz') or base.get('viz') or 'bar').strip()
    out = {
        'id':        cid,
        'title':     (c.get('title') or base.get('title') or 'Ohne Titel').strip()[:120],
        'note':      (c.get('note')  or base.get('note')  or '').strip()[:400],
        'database':  (c.get('database') or base.get('database') or '').strip(),
        'sql':       (c.get('sql') or base.get('sql') or '').strip(),
        'viz':       viz if viz in HUB_VIZ else 'bar',
        'label_col': (c.get('label_col') or base.get('label_col') or '').strip(),
        'value_cols': [str(v).strip() for v in
                       (c.get('value_cols') if c.get('value_cols') is not None
                        else base.get('value_cols') or []) if str(v).strip()],
        'facet_col': (c.get('facet_col') if c.get('facet_col') is not None
                      else base.get('facet_col') or '').strip(),
        'spec':      _hub_spec_clean(c.get('spec') or base.get('spec')),
        'spec_sql':  (c.get('spec_sql') or base.get('spec_sql') or '').strip(),
        'stacked':   bool(c.get('stacked', base.get('stacked', False))),
        'unit':      (c.get('unit') or base.get('unit') or '').strip()[:24],
        'width':     'full' if (c.get('width') or base.get('width')) == 'full' else 'half',
        'ttl_s':     max(0, int(c.get('ttl_s', base.get('ttl_s', HUB_TTL_DEFAULT)) or 0)),
        'order':     int(c.get('order', base.get('order', 0)) or 0),
        'created':   base.get('created') or datetime.datetime.now().isoformat(timespec='seconds'),
        'updated':   datetime.datetime.now().isoformat(timespec='seconds'),
    }
    return out


def _hub_run(chart: dict, force: bool = False) -> dict:
    """Abfrage einer Auswertung ausführen — mit TTL-Cache und Snapshot-Fallback."""
    cid = chart['id']
    ttl = chart.get('ttl_s', HUB_TTL_DEFAULT)
    now = time.time()
    hit = _hub_runcache.get(cid)
    if hit and not force and ttl and (now - hit['ts']) < ttl:
        out = dict(hit['payload'])
        out['cached'] = True
        out['age_s']  = int(now - hit['ts'])
        return out

    err = _sql_guard(chart.get('sql', ''))
    if err:
        return {'error': err, 'id': cid}
    if not chart.get('database'):
        return {'error': 'Keine Datenbank hinterlegt.', 'id': cid}

    try:
        t0 = time.time()
        cols, rows, truncated = _sql_query(chart['sql'], database=chart['database'],
                                           cap=HUB_ROW_CAP)
        payload = {
            'id': cid, 'columns': cols, 'rows': rows, 'truncated': truncated,
            'ms': int((time.time() - t0) * 1000),
            'ts': datetime.datetime.now().isoformat(timespec='seconds'),
            'cached': False, 'age_s': 0, 'stale': False,
        }
        _hub_runcache[cid] = {'ts': now, 'payload': payload}
        _hub_snapshot_write(cid, payload)
        return payload
    except Exception as e:
        snap = _hub_snapshot_read(cid)
        if snap:                       # Letzter guter Stand statt leerer Karte
            snap = dict(snap)
            snap['stale'] = True
            snap['error'] = str(e)
            snap['cached'] = True
            return snap
        return {'error': str(e), 'id': cid}


@app.route('/api/hub/charts', methods=['GET'])
def api_hub_charts():
    charts = sorted(hub_load(), key=lambda c: (c.get('order', 0), c.get('created', '')))
    return jsonify({'charts': charts})


@app.route('/api/hub/charts', methods=['POST'])
def api_hub_charts_save():
    data = request.get_json(force=True, silent=True) or {}
    if not (data.get('sql') or '').strip():
        return jsonify({'error': 'Ohne SQL geht es nicht.'}), 400
    err = _sql_guard(data['sql'])
    if err:
        return jsonify({'error': err}), 400
    with _hub_lock:
        charts   = hub_load()
        existing = next((c for c in charts if c['id'] == data.get('id')), None)
        norm     = _hub_norm(data, existing)
        if existing:
            charts = [norm if c['id'] == norm['id'] else c for c in charts]
        else:
            norm['order'] = max([c.get('order', 0) for c in charts], default=0) + 1
            charts.append(norm)
        hub_save(charts)
    _hub_runcache.pop(norm['id'], None)      # geänderte Abfrage → Cache verwerfen
    return jsonify({'ok': True, 'chart': norm})


@app.route('/api/hub/charts/delete', methods=['POST'])
def api_hub_charts_delete():
    cid = (request.get_json(force=True, silent=True) or {}).get('id', '')
    with _hub_lock:
        charts = [c for c in hub_load() if c['id'] != cid]
        hub_save(charts)
    _hub_runcache.pop(cid, None)
    try:
        os.remove(_hub_snapshot_path(cid))
    except OSError:
        pass
    return jsonify({'ok': True})


@app.route('/api/hub/charts/reorder', methods=['POST'])
def api_hub_charts_reorder():
    ids = (request.get_json(force=True, silent=True) or {}).get('ids') or []
    with _hub_lock:
        charts = hub_load()
        pos    = {cid: i for i, cid in enumerate(ids)}
        for c in charts:
            c['order'] = pos.get(c['id'], 999)
        hub_save(charts)
    return jsonify({'ok': True})


@app.route('/api/hub/run', methods=['POST'])
def api_hub_run():
    data  = request.get_json(force=True, silent=True) or {}
    cid   = data.get('id', '')
    force = bool(data.get('force'))
    chart = next((c for c in hub_load() if c['id'] == cid), None)
    if not chart:
        return jsonify({'error': 'Auswertung nicht gefunden.'}), 404
    return jsonify(_hub_run(chart, force))


@app.route('/api/hub/preview', methods=['POST'])
def api_hub_preview():
    """Ad-hoc-Lauf im Editor — gleiche Wache wie der SQL-Explorer, kleinere Kappung."""
    data = request.get_json(force=True, silent=True) or {}
    sql  = (data.get('sql') or '').strip()
    db   = (data.get('database') or '').strip()
    err  = _sql_guard(sql)
    if err:
        return jsonify({'error': err})
    if not db:
        return jsonify({'error': 'Keine Datenbank gewählt.'})
    try:
        t0 = time.time()
        cols, rows, truncated = _sql_query(sql, database=db, cap=500)
        numeric = []
        for i, c in enumerate(cols):
            vals = [r[i] for r in rows[:200] if r[i] is not None]
            ok   = bool(vals) and all(isinstance(v, (int, float)) and not isinstance(v, bool)
                                      for v in vals)
            numeric.append(ok)
        return jsonify({'columns': cols, 'rows': rows, 'numeric': numeric,
                        'truncated': truncated, 'ms': int((time.time() - t0) * 1000)})
    except Exception as e:
        return jsonify({'error': str(e)})


@app.route('/api/hub/export-csv-form', methods=['POST'])
def api_hub_export_csv_form():
    cid   = (request.form.get('id') or '').strip()
    chart = next((c for c in hub_load() if c['id'] == cid), None)
    if not chart:
        return Response('Auswertung nicht gefunden.', status=404,
                        mimetype='text/plain; charset=utf-8')
    res = _hub_run(chart, force=False)
    if res.get('error') and not res.get('columns'):
        return Response(res['error'], status=400, mimetype='text/plain; charset=utf-8')
    buf = io.StringIO()
    w   = csv.writer(buf, delimiter=';', quoting=csv.QUOTE_MINIMAL)
    w.writerow(res['columns'])
    for r in res['rows']:
        w.writerow(['' if v is None else v for v in r])
    slug  = re.sub(r'[^A-Za-z0-9]+', '_', chart['title']).strip('_')[:40] or 'auswertung'
    stamp = datetime.datetime.now().strftime('%Y-%m-%d_%H%M')
    return Response(
        buf.getvalue().encode('utf-8-sig'),
        mimetype='text/csv; charset=utf-8',
        headers={'Content-Disposition': f'attachment; filename={slug}_{stamp}.csv'})


# ══════════════════════════════════════════════════════════════════════════════
# Abfrage-Baukasten — erzeugt SQL aus einer Feldauswahl
# Fachnutzer klicken Kennzahl / Gruppierung / Aufteilung / Filter zusammen,
# der Generator schreibt daraus SELECT … GROUP BY … ORDER BY. Das erzeugte SQL
# bleibt sichtbar und editierbar; eine Auswertung ist weiterhin nur „SQL + Typ".
# ══════════════════════════════════════════════════════════════════════════════

HUB_FIELDS_PATH = os.path.join(BASE_DIR, 'hub_fields.json')

# Feldkatalog: fachlicher Name → Spaltenkandidaten (erster vorhandener gewinnt).
# Bewusst kurz gehalten — 125 Spalten in einem Auswahlfeld sind unbenutzbar.
_HUB_FIELDS_DEFAULT = [
    {'key': 'meldedatum',  'label': 'Meldedatum',          'kind': 'zeit',
     'cols': ['sys_created_on', 'opened_at']},
    {'key': 'loesedatum',  'label': 'Lösedatum',           'kind': 'zeit',
     'cols': ['resolved_at', 'closed_at']},
    {'key': 'schlussdatum', 'label': 'Abschlussdatum',     'kind': 'zeit',
     'cols': ['closed_at']},
    {'key': 'faellig',     'label': 'Fälligkeit',          'kind': 'zeit',
     'cols': ['due_date']},
    {'key': 'ladedatum',   'label': 'Ladedatum (Mirror)',  'kind': 'zeit',
     'cols': ['import_at'], 'hint': 'Zeitpunkt der Spiegelung, kein Fachdatum'},
    {'key': 'kritikalitaet', 'label': 'Kritikalität',      'kind': 'kategorie',
     'cols': ['criticality']},
    {'key': 'gruppe',      'label': 'Meldende Gruppe',     'kind': 'kategorie',
     'cols': ['requester_group']},
    {'key': 'zustaendig',  'label': 'Zuständige Gruppe',   'kind': 'kategorie',
     'cols': ['assignment_group']},
    {'key': 'status',      'label': 'Status',              'kind': 'kategorie',
     'cols': ['task_state', 'state', 'stage'],
     'hint': 'aus der ServiceNow-Tabelle task — stage ist im Mirror leer'},
    {'key': 'status_gruppe', 'label': 'Offen / Erledigt',  'kind': 'kategorie',
     'cols': ['status_gruppe'],
     'hint': 'Neu/In Bearbeitung/Angehalten = offen, Gelöst/Abgeschlossen = erledigt'},
    {'key': 'zyklus',      'label': 'Zyklus',              'kind': 'kategorie',
     'cols': ['cycle_of_wbs', 'cycle']},
    {'key': 'kategorie',   'label': 'Kategorie',           'kind': 'kategorie',
     'cols': ['category']},
    {'key': 'ursache',     'label': 'Ursache',             'kind': 'kategorie',
     'cols': ['cause']},
    {'key': 'prioritaet',  'label': 'Priorität',           'kind': 'kategorie',
     'cols': ['priority']},
    {'key': 'standort',    'label': 'Standort',            'kind': 'kategorie',
     'cols': ['location']},
    {'key': 'melder',      'label': 'Melder',              'kind': 'kategorie',
     'cols': ['caller']},
    {'key': 'wbs',         'label': 'WBS-Element',         'kind': 'kategorie',
     'cols': ['wbs']},
    {'key': 'reopen',      'label': 'Wiedereröffnungen',   'kind': 'zahl',
     'cols': ['reopen_count']},
    # ── aus dem SAP-Projekt (x_mtuae_ins_project, Join über die WBS-Basis)
    {'key': 'engine_typ',  'label': 'Engine-Typ',          'kind': 'kategorie',
     'cols': ['prj_engine'], 'hint': 'aus dem SAP-Projekt der WBS'},
    {'key': 'engine_familie', 'label': 'Engine-Familie',   'kind': 'kategorie',
     'cols': ['prj_family'], 'hint': 'wie im Analyzer: engine_families.json, sonst Heuristik'},
    {'key': 'kunde',       'label': 'Kunde',               'kind': 'kategorie',
     'cols': ['prj_customer']},
    {'key': 'esn',         'label': 'Triebwerk (ESN)',     'kind': 'kategorie',
     'cols': ['prj_esn']},
    {'key': 'einlieferung', 'label': 'Einlieferung',       'kind': 'kategorie',
     'cols': ['prj_einlieferung'],
     'hint': 'Engine oder Modul-Shop-Visit (nur ein Modul im Haus: Modul-SN im ESN-Feld wie „30X…" '
             'oder Workscope „- M -")'},
    {'key': 'projektstatus', 'label': 'Projektstatus',     'kind': 'kategorie',
     'cols': ['prj_state']},
    {'key': 'wbs_art',     'label': 'WBS-Art',             'kind': 'kategorie',
     'cols': ['wbs_art'],
     'hint': 'Anfangsbuchstabe: L/K/V = Engine-Projekte, P = Controlling-Projekt'},
    {'key': 'modulgruppe', 'label': 'Modulgruppe (CK)',    'kind': 'kategorie',
     'cols': ['modulgruppe'],
     'hint': 'CK-Modul aus dem Modul-Katalog (Namensvarianten vereinheitlicht). CK-Nummern '
             'sind je Engine-Familie anders belegt — für Vergleiche Familie als Kachel wählen'},
    {'key': 'modul_abgeleitet', 'label': 'Modul (abgeleitet)', 'kind': 'kategorie',
     'cols': ['modul_abgeleitet'],
     'hint': 'Gebuchtes Modul; bei CK00/ohne Modul abgeleitet — erst über die ECMS-Einheit des Teils '
             '(Modul-Seriennummer, bei mehreren Teilen der gemeinsame Vorfahr im Einbaubaum), dann über '
             'das Teil (gelernt), dann über den ATA-Code des Auftrags. Ein Vorschlag, keine Buchung: '
             'Herkunft steht im Feld „Modul-Herkunft"'},
    {'key': 'modul_herkunft', 'label': 'Modul-Herkunft',   'kind': 'kategorie',
     'cols': ['modul_herkunft'],
     'hint': 'gebucht / abgeleitet über Teil / über Teile + ATA-Code / über ATA-Code / '
             '… nur Obergruppe (Modul offen) / nicht bestimmbar'},
    {'key': 'hauptmodul', 'label': 'Hauptmodul (Einbau)', 'kind': 'kategorie',
     'cols': ['hauptmodul_abgeleitet'],
     'hint': 'Oberste Einheit unter der Engine laut Einbaubaum aus ECMS (04.4), z. B. 53X → 30X CORE MODULE. '
             'Aus dem gebuchten CK, sonst aus „Modul (abgeleitet)". Physisch, nicht nach WBS-Obergruppe'},
    {'key': 'obergruppe', 'label': 'Obergruppe (gebucht)', 'kind': 'kategorie',
     'cols': ['obergruppe'],
     'hint': 'Mittleres Segment des WBS-Codes (Projekt.OG.CK) des gebuchten Moduls. OG-Nummern '
             'sind je Engine-Familie anders belegt — für Vergleiche Familie als Kachel wählen'},
    {'key': 'obergruppe_abgeleitet', 'label': 'Obergruppe (abgeleitet)', 'kind': 'kategorie',
     'cols': ['obergruppe_abgeleitet'],
     'hint': 'Gebuchte Obergruppe; bei CK00/ohne Modul die OG des abgeleiteten Moduls oder — wenn der '
             'ATA-Code nur die Obergruppe eindeutig trifft — diese. Herkunft im Feld „Modul-Herkunft"'},
    {'key': 'modul_genau', 'label': 'Modul-Genauigkeit',   'kind': 'kategorie',
     'cols': ['modul_genauigkeit'],
     'hint': 'Modul = auf ein CK-Modul gebucht; Engine gesamt = CK00 (z. B. Test, Schüttware — '
             'oft gar nicht anders möglich); anderes WBS-Element = Projekt-/Sonderebene'},
    # ── Prozess: CS-Order und Arbeitsvorgang am Meldepunkt
    {'key': 'auftragsart', 'label': 'Auftragsart',         'kind': 'kategorie',
     'cols': ['proc_auftragsart'], 'hint': 'SAP-Auftragsart der CS-Order (CS01, CS07, CS99 …)'},
    {'key': 'wartungsart', 'label': 'Wartungsart',         'kind': 'kategorie',
     'cols': ['proc_wartungsart']},
    {'key': 'vorgang',     'label': 'Vorgang',             'kind': 'kategorie',
     'cols': ['proc_vorgang'],
     'hint': 'Arbeitsvorgang der CS-Order, an dem gemeldet wurde (z. B. ASSEMBLE, INSPECT)'},
    {'key': 'arbeitsplatz', 'label': 'Arbeitsplatz',       'kind': 'kategorie',
     'cols': ['proc_arbpl']},
    # ── betroffene Teile (m2m, mehrere je Incident → Teile-Modus)
    {'key': 'teil_nr',     'label': 'Teil · Nummer',       'kind': 'kategorie',
     'cols': ['teil_nr']},
    {'key': 'teil_bez',    'label': 'Teil · Bezeichnung',  'kind': 'kategorie',
     'cols': ['teil_bez']},
    {'key': 'teil_herkunft', 'label': 'Teil · Herkunft',   'kind': 'kategorie',
     'cols': ['teil_herkunft'], 'hint': 'MPL = Katalogteil, ECMS = Einzelteil mit Seriennummer'},
    {'key': 'teil_sn',     'label': 'Teil · Seriennummer', 'kind': 'kategorie',
     'cols': ['teil_sn']},
    {'key': 'teil_menge',  'label': 'Teil · Menge',        'kind': 'zahl',
     'cols': ['teil_menge']},
]

# CK00 = ganze Engine. Kein Modul, sondern „nicht modulgenau" — am Prüfstand die
# Regel, weil dort niemand weiß, welches Modul die Ursache ist.
MODULE_CK00_LABEL  = 'CK00: Engine gesamt (nicht modulgenau)'
MODULE_GENAU_CK00  = 'Engine gesamt (CK00)'
MODULE_GENAU_MODUL = 'Modul'

# Dauer in Stunden — Bausteine der Zeit-Kennzahlen.
_DUR = 'CAST(DATEDIFF(minute, [{created}], [{resolved}]) AS float) / 60.0'
# Alter bis heute in Tagen (für offene Incidents)
_AGE = 'CAST(DATEDIFF(hour, [{created}], GETDATE()) AS float) / 24.0'

# Kennzahlen. 'expr' = ohne Aufteilung, 'cexpr' = je Aufteilungswert ({c} = Bedingung).
# Absichtlich im Code und nicht in der JSON: hier steht SQL drin.
_HUB_METRICS = [
    {'key': 'anzahl', 'label': 'Anzahl Incidents', 'unit': '', 'needs': [],
     'expr': 'COUNT(*)',
     'cexpr': 'SUM(CASE WHEN {c} THEN 1 ELSE 0 END)',
     # Teile-Modus: eine Zeile je Teil → jeden Incident nur einmal zählen
     'texpr': 'COUNT(DISTINCT [sys_id])',
     'tcexpr': 'COUNT(DISTINCT CASE WHEN {c} THEN [sys_id] END)'},
    {'key': 'wbs_distinct', 'label': 'Betroffene WBS (verschiedene)', 'unit': '',
     'needs': ['wbs'], 'teil_ok': True,
     'expr': 'COUNT(DISTINCT [{wbs}])',
     'cexpr': 'COUNT(DISTINCT CASE WHEN {c} THEN [{wbs}] END)'},
    {'key': 'esn_distinct', 'label': 'Betroffene Triebwerke (ESN, ohne Modul-Visits)', 'unit': '',
     'needs': ['esn'], 'teil_ok': True,
     'expr': 'COUNT(DISTINCT [{esn}])',
     'cexpr': 'COUNT(DISTINCT CASE WHEN {c} THEN [{esn}] END)'},
    {'key': 'je_esn', 'label': 'Incidents je betroffenem Triebwerk (ohne Modul-Visits)', 'unit': '',
     'needs': ['esn'],
     # Zähler nur Incidents mit ESN — sonst blähen projektlose Incidents die Rate auf.
     # Nenner sind Triebwerke mit mindestens einem Incident, nicht die ganze Flotte.
     'expr': "ROUND(1.0 * COUNT(NULLIF([{esn}], '')) / NULLIF(COUNT(DISTINCT NULLIF([{esn}], '')), 0), 2)",
     'cexpr': "ROUND(1.0 * SUM(CASE WHEN {c} AND NULLIF([{esn}], '') IS NOT NULL THEN 1 ELSE 0 END)"
              " / NULLIF(COUNT(DISTINCT CASE WHEN {c} THEN NULLIF([{esn}], '') END), 0), 2)",
     'texpr': "ROUND(1.0 * COUNT(DISTINCT CASE WHEN NULLIF([{esn}], '') IS NOT NULL THEN [sys_id] END)"
              " / NULLIF(COUNT(DISTINCT NULLIF([{esn}], '')), 0), 2)",
     'tcexpr': "ROUND(1.0 * COUNT(DISTINCT CASE WHEN {c} AND NULLIF([{esn}], '') IS NOT NULL THEN [sys_id] END)"
               " / NULLIF(COUNT(DISTINCT CASE WHEN {c} THEN NULLIF([{esn}], '') END), 0), 2)"},
    # Anteil CK00 an allen Buchungen auf CK-Ebene. Neutral benannt: am Prüfstand
    # ist CK00 die einzig mögliche Buchung, das ist kein Fehler der Meldenden.
    {'key': 'pct_ck00', 'label': 'Anteil Engine gesamt (CK00) an Modulbuchungen (%)', 'unit': '%',
     'needs': ['modul_genau'],
     'expr': "ROUND(100.0 * SUM(CASE WHEN [{modul_genau}] = '" + MODULE_GENAU_CK00 + "' THEN 1 ELSE 0 END)"
             " / NULLIF(SUM(CASE WHEN [{modul_genau}] IN ('" + MODULE_GENAU_CK00 + "', '"
             + MODULE_GENAU_MODUL + "') THEN 1 ELSE 0 END), 0), 1)",
     'cexpr': "ROUND(100.0 * SUM(CASE WHEN {c} AND [{modul_genau}] = '" + MODULE_GENAU_CK00 + "' THEN 1 ELSE 0 END)"
              " / NULLIF(SUM(CASE WHEN {c} AND [{modul_genau}] IN ('" + MODULE_GENAU_CK00 + "', '"
              + MODULE_GENAU_MODUL + "') THEN 1 ELSE 0 END), 0), 1)"},
    {'key': 'offen_anzahl', 'label': 'Offene Incidents', 'unit': '',
     'needs': ['status_gruppe'],
     'expr': "SUM(CASE WHEN [{status_gruppe}] = 'Offen' THEN 1 ELSE 0 END)",
     'cexpr': "SUM(CASE WHEN {c} AND [{status_gruppe}] = 'Offen' THEN 1 ELSE 0 END)",
     'texpr': "COUNT(DISTINCT CASE WHEN [{status_gruppe}] = 'Offen' THEN [sys_id] END)",
     'tcexpr': "COUNT(DISTINCT CASE WHEN {c} AND [{status_gruppe}] = 'Offen' THEN [sys_id] END)"},
    {'key': 'offen_alter', 'label': 'Ø Alter offener Incidents (Tage)', 'unit': 'Tage',
     'needs': ['status_gruppe', 'meldedatum'],
     'expr': "ROUND(AVG(CASE WHEN [{status_gruppe}] = 'Offen' THEN " + _AGE + " END), 1)",
     'cexpr': "ROUND(AVG(CASE WHEN {c} AND [{status_gruppe}] = 'Offen' THEN " + _AGE + " END), 1)"},
    {'key': 'offen_alter_max', 'label': 'Ältester offener Incident (Tage)', 'unit': 'Tage',
     'needs': ['status_gruppe', 'meldedatum'],
     'expr': "ROUND(MAX(CASE WHEN [{status_gruppe}] = 'Offen' THEN " + _AGE + " END), 1)",
     'cexpr': "ROUND(MAX(CASE WHEN {c} AND [{status_gruppe}] = 'Offen' THEN " + _AGE + " END), 1)"},
    {'key': 'teile_menge', 'label': 'Betroffene Teile (Stück)', 'unit': 'Stk',
     'needs': ['teil_menge'], 'scope': 'teil',
     'expr': 'SUM([{teil_menge}])',
     'cexpr': 'SUM(CASE WHEN {c} THEN [{teil_menge}] ELSE 0 END)'},
    {'key': 'teile_pos', 'label': 'Teilepositionen', 'unit': '',
     'needs': ['teil_nr'], 'scope': 'teil',
     'expr': 'COUNT(*)',
     'cexpr': 'SUM(CASE WHEN {c} THEN 1 ELSE 0 END)'},
    {'key': 'avg_res_h', 'label': 'Ø Lösungszeit (h)', 'unit': 'h',
     'needs': ['meldedatum', 'loesedatum'],
     'expr': 'ROUND(AVG(' + _DUR + '), 1)',
     'cexpr': 'ROUND(AVG(CASE WHEN {c} THEN ' + _DUR + ' END), 1)'},
    {'key': 'sum_res_h', 'label': 'Summe Stillstand (h)', 'unit': 'h',
     'needs': ['meldedatum', 'loesedatum'],
     'expr': 'ROUND(SUM(' + _DUR + '), 1)',
     'cexpr': 'ROUND(SUM(CASE WHEN {c} THEN ' + _DUR + ' END), 1)'},
    {'key': 'max_res_h', 'label': 'Längste Lösungszeit (h)', 'unit': 'h',
     'needs': ['meldedatum', 'loesedatum'],
     'expr': 'ROUND(MAX(' + _DUR + '), 1)',
     'cexpr': 'ROUND(MAX(CASE WHEN {c} THEN ' + _DUR + ' END), 1)'},
    {'key': 'pct_baustopp', 'label': 'Anteil Baustopps (%)', 'unit': '%',
     'needs': ['kritikalitaet'],
     'expr': "ROUND(100.0 * SUM(CASE WHEN [{kritikalitaet}] = 'Baustopp' THEN 1 ELSE 0 END)"
             " / NULLIF(COUNT(*), 0), 1)",
     'cexpr': "ROUND(100.0 * SUM(CASE WHEN {c} AND [{kritikalitaet}] = 'Baustopp' THEN 1 ELSE 0 END)"
              " / NULLIF(SUM(CASE WHEN {c} THEN 1 ELSE 0 END), 0), 1)",
     'texpr': "ROUND(100.0 * COUNT(DISTINCT CASE WHEN [{kritikalitaet}] = 'Baustopp' THEN [sys_id] END)"
              " / NULLIF(COUNT(DISTINCT [sys_id]), 0), 1)",
     'tcexpr': "ROUND(100.0 * COUNT(DISTINCT CASE WHEN {c} AND [{kritikalitaet}] = 'Baustopp' THEN [sys_id] END)"
               " / NULLIF(COUNT(DISTINCT CASE WHEN {c} THEN [sys_id] END), 0), 1)"},
]

# Zeitraster: Beschriftung als sortierbarer Text bzw. Datum.
_HUB_GRAINS = {
    'tag':     {'label': 'Tag',     'expr': 'CONVERT(date, [{c}])'},
    'woche':   {'label': 'Woche',   'expr': "CONVERT(char(4), YEAR([{c}])) + '-KW' + "
                                            "RIGHT('0' + CONVERT(varchar(2), DATEPART(ISO_WEEK, [{c}])), 2)"},
    'monat':   {'label': 'Monat',   'expr': 'CONVERT(char(7), [{c}], 126)'},
    'quartal': {'label': 'Quartal', 'expr': "CONVERT(char(4), YEAR([{c}])) + '-Q' + "
                                            "CONVERT(char(1), DATEPART(quarter, [{c}]))"},
    'jahr':    {'label': 'Jahr',    'expr': 'CONVERT(char(4), YEAR([{c}]))'},
}

_HUB_OPS = {
    'ist':        {'label': 'ist',          'kinds': ['kategorie']},
    'ist_nicht':  {'label': 'ist nicht',    'kinds': ['kategorie']},
    'enthaelt':   {'label': 'enthält',      'kinds': ['kategorie']},
    'gefuellt':   {'label': 'ist gefüllt',  'kinds': ['kategorie', 'zeit', 'zahl']},
    'leer':       {'label': 'ist leer',     'kinds': ['kategorie', 'zeit', 'zahl']},
    'ab':         {'label': 'ab',           'kinds': ['zeit']},
    'bis':        {'label': 'bis',          'kinds': ['zeit']},
    'min':        {'label': 'mindestens',   'kinds': ['zahl']},
    'max':        {'label': 'höchstens',    'kinds': ['zahl']},
}


# Virtuelle Spalte der Hub-Quelle: Status aus der task-Tabelle (siehe
# _incident_state_source). Kataloge von vor dem Schema-Snapshot kennen sie
# noch nicht — beim Laden wird sie dem Statusfeld vorangestellt.
HUB_TASK_STATE = 'task_state'


def _hub_fields_upgrade(data: list) -> list:
    for f in data:
        cols = f.get('cols') or []
        if f.get('key') == 'status' and HUB_TASK_STATE not in cols:
            f['cols'] = [HUB_TASK_STATE] + cols
    # Felder, die es in der Vorgabe neu gibt (Projekt, Teile), anhängen —
    # eigene Änderungen an vorhandenen Feldern bleiben unangetastet.
    have = {f.get('key') for f in data}
    return data + [dict(f) for f in _HUB_FIELDS_DEFAULT if f['key'] not in have]


def hub_fields_load() -> list:
    """Feldkatalog aus hub_fields.json, sonst die Vorgabe (und einmal schreiben)."""
    if os.path.exists(HUB_FIELDS_PATH):
        try:
            with open(HUB_FIELDS_PATH, encoding='utf-8') as f:
                content = f.read().strip()
                if content:
                    data = json.loads(content)
                    if isinstance(data, list) and data:
                        return _hub_fields_upgrade(data)
        except Exception:
            pass
    try:
        with open(HUB_FIELDS_PATH, 'w', encoding='utf-8') as f:
            json.dump(_HUB_FIELDS_DEFAULT, f, indent=2, ensure_ascii=False)
    except Exception:
        pass
    return list(_HUB_FIELDS_DEFAULT)


def _hub_source() -> tuple:
    """(Datenbank, aufgelöste Tabelle) der Incident-Quelle."""
    db, hint = _incident_target()
    if not db:
        raise RuntimeError('Keine Incident-Datenbank hinterlegt — siehe Einstellungen.')
    return db, _incident_resolve(db, hint)


# Virtuelle Spalten aus den Nachbartabellen. Teile-Spalten gibt es nur im
# Teile-Modus (eine Zeile je Teil) bzw. in EXISTS-Filtern.
_HUB_PART_COLS = ('teil_nr', 'teil_herkunft', 'teil_bez', 'teil_menge', 'teil_sn')
# Spalten, deren Join nur bei Bedarf in die Abfrage kommt — die Operation-
# Tabelle hat 17,6 Mio. Zeilen, die soll keine Abfrage anfassen, die sie nicht braucht.
_HUB_LAZY = {'modulgruppe': 'wbs_elem', 'modul_genauigkeit': 'wbs_elem',
             'modul_abgeleitet': 'wbs_elem', 'modul_herkunft': 'wbs_elem',
             'obergruppe': 'wbs_elem', 'obergruppe_abgeleitet': 'wbs_elem',
             'hauptmodul_abgeleitet': 'wbs_elem',
             'proc_auftragsart': 'cso', 'proc_wartungsart': 'cso',
             'proc_vorgang': 'op', 'proc_arbpl': 'op'}
_hub_family_cache = {}       # (db, table) → (ts, CASE-Ausdruck oder '')
HUB_FAMILY_TTL    = 3600


def _hub_family_case(db: str, prj: dict, col: str) -> str:
    """Engine-Familie als CASE über alle vorkommenden Engine-Typen — gleiche
    Zuordnung wie im Analyzer (engine_families.json + Heuristik), die in SQL
    nicht nachzubauen wäre. Die Typenliste ist kurz und wird stündlich neu gelesen."""
    key = (db, prj['table'])
    hit = _hub_family_cache.get(key)
    if hit and time.time() - hit[0] < HUB_FAMILY_TTL:
        return hit[1].replace('{col}', col)
    ecol = prj['cols']['engine']
    try:
        _, rows, _ = _sql_query(f'SELECT DISTINCT [{ecol}] FROM {prj["table"]} '
                                f'WHERE [{ecol}] IS NOT NULL', database=db, timeout=60, cap=0)
    except Exception as e:
        print(f'[hub] Engine-Typen nicht lesbar: {e}')
        return ''
    whens = []
    for (et,) in rows:
        fam = _engine_family(str(et)) if str(et).strip() else ''
        if fam and fam != '–':
            whens.append(f'WHEN {_hub_lit(et)} THEN {_hub_lit(fam)}')
    tpl = ('CASE {col} ' + ' '.join(whens) + ' END') if whens else ''
    _hub_family_cache[key] = (time.time(), tpl)
    return tpl.replace('{col}', col)


def _hub_status_group_sql(ex: dict) -> str:
    """Offen/Erledigt. Erste Wahl task.state_value (ServiceNow: 1 Neu, 2 In
    Bearbeitung, 3 Angehalten = offen; 6 Gelöst, 7 Abgeschlossen, 8 Abgebrochen
    = erledigt), dann der Klartext, zuletzt — Incident fehlt in task — das
    Lösedatum der Incident-Tabelle."""
    whens = []
    if ex['task']:
        if ex['task'].get('value'):
            whens += ["WHEN t.[state_value] IN (6, 7, 8) THEN 'Erledigt'",
                      "WHEN t.[state_value] IS NOT NULL THEN 'Offen'"]
        whens += ["WHEN t.[state] IN ('Gelöst', 'Abgeschlossen', 'Geschlossen', "
                  "'Abgebrochen') THEN 'Erledigt'",
                  "WHEN t.[state] IS NOT NULL AND t.[state] <> '' THEN 'Offen'"]
    res = ex['inc'].get('resolved_at') or ex['inc'].get('closed_at')
    if res:
        whens += [f"WHEN i.[{res}] IS NOT NULL THEN 'Erledigt'", "ELSE 'Offen'"]
    return ('CASE ' + ' '.join(whens) + ' END') if whens else ''


DERIVED_MAX = 4000     # mehr passt nicht sinnvoll in ein CASE — Rest bleibt „nicht bestimmbar"


def _derived_case_sql(id_expr: str) -> tuple:
    """(CASE für den Modulnamen, CASE für die Herkunft) aus der Matrix — nur
    die Incidents, die ein Modul abgeleitet bekommen. Ohne Matrix: (None, None)
    und der Aufbau startet im Hintergrund."""
    data = module_matrix()
    _matrix_upgrade(data)
    if data is None or 'derived' not in data:
        module_matrix_start(force=data is not None)
        return None, None
    items = list(_derived_all(data).items())
    if not items:
        return None, None
    if len(items) > DERIVED_MAX:
        print(f'[hub] {len(items)} abgeleitete Module — nur die ersten {DERIVED_MAX} im SQL')
        items = items[:DERIVED_MAX]
    name = 'CASE ' + id_expr + ' ' + ' '.join(f'WHEN {_hub_lit(k)} THEN {_hub_lit(v[0])}' for k, v in items) + ' END'
    src  = 'CASE ' + id_expr + ' ' + ' '.join(f'WHEN {_hub_lit(k)} THEN {_hub_lit(v[1])}' for k, v in items) + ' END'
    return name, src


HM_ENGINE = 'Engine gesamt (CK00)'
HM_OPEN   = 'nicht bestimmbar'


def _hm_maps(sdata: dict = None) -> dict:
    """Familie → Vorlage ('' = Haupt) → CK → „30X CORE MODULE" (oberste Einheit über dem CK)."""
    sdata = sdata or struct_catalog() or {}
    out = {}
    for f in sdata.get('families', []):
        for i, t in enumerate(f['templates']):
            if t['root'] == '?' or not t['units']:
                continue
            units = {u['code']: u for u in t['units']}
            tree = {'units': units, 'parent': {u['code']: u['parent'] for u in t['units']}}
            m = {}
            for code in sorted(units, key=lambda c: c[2] != 'X'):          # X vor T
                top = _struct_path(tree, code)[-1]
                m.setdefault('CK' + code[:2], _unit_label(units[top]))
            out.setdefault(f['family'], {})['' if i == 0 else t['root']] = m
    return out


def _hm_by_ck(sdata: dict = None) -> dict:
    """Familie → CK → Hauptmodul der Hauptvorlage."""
    return {fam: m.get('', {}) for fam, m in _hm_maps(sdata).items()}


def _derived_hm_all(data: dict) -> dict:
    """Incident → Hauptmodul für alle abgeleiteten Incidents."""
    sdata = struct_catalog() or {}
    hm = _hm_by_ck(sdata)
    fam_of = {isid: fam for isid, (fam, _) in data.get('derived_ata', {}).items()}
    fam_of.update({isid: fam for isid, (fam, _) in data.get('derived_multi', {}).items()})
    fam_of.update(data.get('derived_fam', {}))
    units = data.get('derived_units', {})
    fam_of.update({isid: v[0] for isid, v in units.items()})
    out = {}
    for isid, (name, src) in _derived_all(data).items():
        fam = fam_of.get(isid)
        if not fam:
            continue
        if isid in units and 'ECMS' in src:
            fam_, proj, codes = units[isid]
            tree = _struct_tree(fam_, proj, sdata)
            code = codes[0] if len(codes) == 1 else _struct_lca(tree, codes)
            if tree and code in tree['units']:
                out[isid] = _unit_label(tree['units'][_struct_path(tree, code)[-1]])
                continue
        ck = _ck_of(name)
        if ck and ck in hm.get(fam, {}):
            out[isid] = hm[fam][ck]
    return out


def _derived_og_case_sql(id_expr: str):
    """CASE Incident → „OG 20" für abgeleitete Incidents (None ohne Matrix/Katalog)."""
    data = module_matrix()
    if data is None or 'derived' not in data:
        return None
    items = list(_derived_og_all(data).items())[:DERIVED_MAX]
    if not items:
        return None
    return 'CASE ' + id_expr + ' ' + ' '.join(f"WHEN {_hub_lit(k)} THEN {_hub_lit('OG ' + v[0])}"
                                              for k, v in items) + ' END'


def _hub_module_visit_sql(number_expr: str, esn_expr: str) -> str:
    """Bedingung „Modul-Shop-Visit" in SQL: Modul-SN im ESN-Feld (gleiche Regel wie
    MODULE_SN_RX) ODER Projekt ist im SAP-Export als Modul-Visit erkannt (Workscope
    „- M -" — den Workscope kennt die Projekttabelle nicht). PATINDEX statt LIKE […],
    damit es auch im Demo-Modus (SQLite) greift."""
    cond = f"PATINDEX('[0-9][0-9][XTxt]%', LTRIM({esn_expr})) = 1"
    try:
        engines, _ = get_data()
        nums = sorted({n for p_, e in engines.items() if e.get('visit_kind') == VISIT_MODULE
                       and not e.get('unit_code')
                       for n in [p_] + [s_['project'] for s_ in e.get('sub_projects', [])]})
    except Exception:
        nums = []
    if nums:
        cond += f" OR {number_expr} IN (" + ', '.join(_hub_lit(n) for n in nums[:2000]) + ')'
    return f'({cond})'


def _hauptmodul_sql(ex: dict, modular: str) -> str:
    """Gebucht: CASE Familie → CASE CK → Hauptmodul. Abgeleitet: CASE über sys_id.
    Ohne Einbaubaum (noch nicht gebaut) → „nicht bestimmbar" und Bau im Hintergrund."""
    sdata = struct_catalog()
    if sdata is None:
        struct_catalog_start()
        return _hub_lit(HM_OPEN)
    maps = _hm_maps(sdata)
    hm = {f: m.get('', {}) for f, m in maps.items()}
    try:
        db, _ = _hub_source()
        fam = _hub_family_case(db, ex['project'], f"p.[{ex['project']['cols']['engine']}]")
    except Exception:
        fam = ''

    def ck_case(m):
        return ('CASE LEFT(w.[name], 4) ' + ' '.join(f'WHEN {_hub_lit(ck)} THEN {_hub_lit(lbl)}'
                                                     for ck, lbl in m.items()) + ' END')
    booked = ''
    if fam and hm:
        inner = ' '.join(f"WHEN {_hub_lit(f)} THEN {ck_case(m)}" for f, m in hm.items() if m)
        booked = f'CASE {fam} {inner} END'
        # Projekte einer Nebenvorlage: deren eigener Baum (CK81 = Fan Blades statt Booster Blades)
        st_ = struct_templates(sdata)
        minor = []
        for f, t in st_.items():
            for root, projs in t['minor'].items():
                m = maps.get(f, {}).get(root)
                if projs and m:
                    minor.append(f"WHEN p.[number] IN (" + ', '.join(_hub_lit(x) for x in sorted(projs)[:3000])
                                 + f") THEN {ck_case(m)}")
        if minor:
            booked = 'CASE ' + ' '.join(minor) + f' ELSE {booked} END'
    data = module_matrix()
    items = list(_derived_hm_all(data).items())[:DERIVED_MAX] if data and 'derived' in data else []
    dcase = ('CASE i.[sys_id] ' + ' '.join(f'WHEN {_hub_lit(k)} THEN {_hub_lit(v)}' for k, v in items) + ' END'
             if items else '')
    return ('CASE ' + (f'WHEN {modular} AND {booked} IS NOT NULL THEN {booked} ' if booked else '')
            + (f'WHEN {dcase} IS NOT NULL THEN {dcase} ' if dcase else '')
            + f"WHEN w.[name] LIKE 'CK00%' THEN {_hub_lit(HM_ENGINE)} "
            + f'ELSE {_hub_lit(HM_OPEN)} END')


def _hub_lazy_joins(ex: dict, table: str, need) -> tuple:
    """(SELECT-Teile, JOINs) für WBS-Element, CS-Order, Operation. Jede Nachbar-
    tabelle wird vorab auf die Schlüssel eingeschränkt, die Incidents überhaupt
    referenzieren, und per GROUP BY auf eine Zeile je Schlüssel gebracht."""
    sel, joins = [], []
    w = ex.get('wbs_elem')
    if 'wbs_elem' in need and w:
        name = (_module_name_sql('w.[name]', f"p.[{ex['project']['cols']['engine']}]")
                if ex.get('project') else _module_name_sql('w.[name]', None))
        sel.append(f'{name} AS [modulgruppe]')
        # Modul (abgeleitet): gebucht, sonst aus der Matrix (Teil, ATA-Code)
        modular = "w.[name] LIKE 'CK%:%' AND w.[name] NOT LIKE 'CK00%'"
        dname, dsrc = _derived_case_sql('i.[sys_id]')
        sel.append(f"CASE WHEN {modular} THEN {name} "
                   + (f"WHEN {dname} IS NOT NULL THEN {dname} " if dname else '')
                   + f"WHEN w.[name] LIKE 'CK00%' THEN {_hub_lit(MODULE_CK00_LABEL)} "
                   + "WHEN w.[sys_id] IS NOT NULL THEN 'kein Modul (Projekt-/Sonder-WBS)' "
                   + "ELSE 'ohne WBS' END AS [modul_abgeleitet]")
        sel.append(f"CASE WHEN {modular} THEN 'gebucht' "
                   + (f"WHEN {dsrc} IS NOT NULL THEN {dsrc} " if dsrc else '')
                   + "WHEN w.[name] LIKE 'CK00%' THEN 'nicht bestimmbar (Engine gesamt)' "
                   + "WHEN w.[sys_id] IS NOT NULL THEN 'kein Modul (Projekt-/Sonder-WBS)' "
                   + "ELSE 'ohne WBS' END AS [modul_herkunft]")
        if w.get('wbs'):
            og = _WBS_OG_SQL.format(c='w.[wbs]')
            sel.append(f"CASE WHEN {modular} AND {og} IS NOT NULL THEN 'OG ' + {og} "
                       + f"WHEN {modular} THEN 'ohne Obergruppe' "
                       + f"WHEN w.[name] LIKE 'CK00%' THEN {_hub_lit(MODULE_GENAU_CK00)} "
                       + "WHEN w.[sys_id] IS NOT NULL THEN 'kein Modul (Projekt-/Sonder-WBS)' "
                       + "ELSE 'ohne WBS' END AS [obergruppe]")
            dog = _derived_og_case_sql('i.[sys_id]')
            sel.append(f"CASE WHEN {modular} AND {og} IS NOT NULL THEN 'OG ' + {og} "
                       + f"WHEN {modular} THEN 'ohne Obergruppe' "
                       + (f"WHEN {dog} IS NOT NULL THEN {dog} " if dog else '')
                       + "WHEN w.[name] LIKE 'CK00%' THEN 'nicht bestimmbar (Engine gesamt)' "
                       + "WHEN w.[sys_id] IS NOT NULL THEN 'kein Modul (Projekt-/Sonder-WBS)' "
                       + "ELSE 'ohne WBS' END AS [obergruppe_abgeleitet]")
        if ex.get('project'):
            sel.append(_hauptmodul_sql(ex, modular) + ' AS [hauptmodul_abgeleitet]')
        wbs = ex.get('wbs')
        sel.append("CASE WHEN w.[name] LIKE 'CK00%' THEN " + _hub_lit(MODULE_GENAU_CK00)
                   + " WHEN w.[name] LIKE 'CK%' THEN " + _hub_lit(MODULE_GENAU_MODUL)
                   + " WHEN w.[sys_id] IS NOT NULL THEN 'anderes WBS-Element'"
                   + (f" WHEN i.[{wbs}] IS NULL OR i.[{wbs}] = '' THEN 'ohne WBS'" if wbs else '')
                   + " ELSE 'WBS-Element nicht gefunden' END AS [modul_genauigkeit]")
        wsel = f", MAX([{w['wbs']}]) AS [wbs]" if w.get('wbs') else ''
        joins.append(f"LEFT JOIN (SELECT [sys_id], MAX([name]) AS [name]{wsel} FROM {w['table']} "
                     f"WHERE [sys_id] IN (SELECT [{w['key']}] FROM {table}) GROUP BY [sys_id]) w "
                     f"ON w.[sys_id] = i.[{w['key']}]")
    c = ex.get('cso')
    if 'cso' in need and c:
        agg = ', '.join(f'MAX([{v}]) AS [{k}]' for k, v in c['cols'].items())
        sel.append('co.[type] AS [proc_auftragsart]')
        if 'maintenance_type' in c['cols']:
            sel.append('co.[maintenance_type] AS [proc_wartungsart]')
        joins.append(f"LEFT JOIN (SELECT [sys_id], {agg} FROM {c['table']} "
                     f"WHERE [sys_id] IN (SELECT [{c['key']}] FROM {table}) GROUP BY [sys_id]) co "
                     f"ON co.[sys_id] = i.[{c['key']}]")
    o = ex.get('op')
    if 'op' in need and o:
        agg = ', '.join(f'MAX([{v}]) AS [{k}]' for k, v in o['cols'].items())
        sel.append('o.[name] AS [proc_vorgang]')
        if 'arbpl' in o['cols']:
            sel.append('o.[arbpl] AS [proc_arbpl]')
        joins.append(f"LEFT JOIN (SELECT [cs_order_id], [vornr], {agg} FROM {o['table']} "
                     f"WHERE [cs_order_id] IN (SELECT [{o['key']}] FROM {table}) "
                     f"GROUP BY [cs_order_id], [vornr]) o "
                     f"ON o.[cs_order_id] = i.[{o['key']}] AND o.[vornr] = i.[{o['opn']}]")
    return sel, joins


def _hub_from(db: str, table: str, parts: bool = False, need=()) -> str:
    """FROM-Ausdruck der Baukasten-Abfragen: die Incident-Tabelle plus Status
    (task), Projektdaten, bei Bedarf WBS-Element/CS-Order/Operation und — im
    Teile-Modus — eine Zeile je betroffenem Teil.
    Immer als Unterabfrage 'src', damit alle Feldverweise unqualifiziert [spalte]
    bleiben und EXISTS-Filter sich auf src.[sys_id] beziehen können."""
    ex  = _incident_extras(db, table)
    sel, joins = ['i.*'], []
    if ex['task']:
        sel.append(f't.[state] AS [{HUB_TASK_STATE}]')
        joins.append(_incident_state_join(ex['task']))
    grp = _hub_status_group_sql(ex)
    if grp:
        sel.append(f'{grp} AS [status_gruppe]')
    if ex['wbs']:
        sel.append(f"LEFT(i.[{ex['wbs']}], 1) AS [wbs_art]")
    if ex['project']:
        pc = ex['project']['cols']
        sel.append(f"p.[{pc['engine']}] AS [prj_engine]")
        fam = _hub_family_case(db, ex['project'], f"p.[{pc['engine']}]")
        if fam:
            sel.append(f'{fam} AS [prj_family]')
        for logical in ('customer', 'esn', 'state'):
            if logical in pc:
                sel.append(f'p.[{pc[logical]}] AS [prj_{logical}]')
        if 'esn' in pc:
            mod = _hub_module_visit_sql('p.[number]', f"p.[{pc['esn']}]")
            sel.append(f"CASE WHEN {mod} THEN 'Modul-Shop-Visit' ELSE 'Engine' END AS [prj_einlieferung]")
            sel.append(f"CASE WHEN {mod} THEN NULL ELSE p.[{pc['esn']}] END AS [prj_esn_engine]")
        joins.append(_incident_project_join(ex['project'], ex['wbs']))
    lsel, ljoins = _hub_lazy_joins(ex, table, set(need))
    sel += lsel
    joins += ljoins
    if parts and ex['parts']:
        sel.append('m.[' + '], m.['.join(_hub_part_cols(ex)) + ']')
        joins.append(f"INNER JOIN {_incident_parts_sql(ex['parts'])} m "
                     f"ON m.[inc_sys_id] = i.[sys_id]")
    return f'(SELECT {", ".join(sel)} FROM {table} i ' + ' '.join(joins) + ') src'


def _hub_part_cols(ex: dict) -> list:
    have = {'part_description': 'teil_bez', 'quantity': 'teil_menge', 'serialnumber': 'teil_sn'}
    return ['teil_nr', 'teil_herkunft'] + [v for k, v in have.items()
                                           if k in ex['parts']['cols']]


def _hub_present_columns(db: str, table: str) -> dict:
    """{kleingeschrieben: Originalschreibweise} aller Spalten der Tabelle plus
    der virtuellen Spalten aus task, Projekt und Teilen."""
    schema, _, name = table.rpartition('.')
    _, rows, _ = _sql_query(
        "SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS "
        "WHERE TABLE_NAME = ? AND (? = '' OR TABLE_SCHEMA = ?)",
        [name, schema, schema], database=db, cap=0)
    present = {r[0].lower(): r[0] for r in rows}
    ex = _incident_extras(db, table)
    virt = []
    if ex['task']:
        virt.append(HUB_TASK_STATE)
    if _hub_status_group_sql(ex):
        virt.append('status_gruppe')
    if ex['wbs']:
        virt.append('wbs_art')
    if ex.get('wbs_elem'):
        virt += ['modulgruppe', 'modul_genauigkeit', 'modul_abgeleitet', 'modul_herkunft']
        if ex['wbs_elem'].get('wbs'):
            virt += ['obergruppe', 'obergruppe_abgeleitet']
        if ex.get('project'):
            virt.append('hauptmodul_abgeleitet')
    if ex.get('cso'):
        virt.append('proc_auftragsart')
        if 'maintenance_type' in ex['cso']['cols']:
            virt.append('proc_wartungsart')
    if ex.get('op'):
        virt.append('proc_vorgang')
        if 'arbpl' in ex['op']['cols']:
            virt.append('proc_arbpl')
    if ex['project']:
        pc = ex['project']['cols']
        virt.append('prj_engine')
        if _hub_family_case(db, ex['project'], 'x'):
            virt.append('prj_family')
        virt += [f'prj_{k}' for k in ('customer', 'esn', 'state') if k in pc]
        if 'esn' in pc:
            virt += ['prj_einlieferung', 'prj_esn_engine']
    if ex['parts']:
        virt += _hub_part_cols(ex)
    for v in virt:
        present[v] = v
    return present


def _hub_resolved_fields(db: str, table: str) -> list:
    """Katalogfelder, deren Spalte es wirklich gibt — mit echter Spalte dran."""
    present = _hub_present_columns(db, table)
    out = []
    for f in hub_fields_load():
        col = next((present[c.lower()] for c in f.get('cols', []) if c.lower() in present), None)
        if col:
            out.append({**f, 'col': col,
                        'scope': 'teil' if col in _HUB_PART_COLS else 'incident',
                        'join': _HUB_LAZY.get(col, ''),
                        'bereich': ('Betroffene Teile' if col in _HUB_PART_COLS else
                                    'Prozess (CS-Order)' if col.startswith('proc_') else
                                    'SAP-Projekt' if col.startswith('prj_')
                                    or col in ('wbs_art', 'modulgruppe', 'modul_genauigkeit',
                                               'modul_abgeleitet', 'modul_herkunft',
                                               'obergruppe', 'obergruppe_abgeleitet',
                                               'hauptmodul_abgeleitet')
                                    else 'Incident')})
    return out


def _hub_lit(v) -> str:
    """Wert als T-SQL-Textliteral. Hochkommas verdoppeln, Steuerzeichen raus."""
    s = '' if v is None else str(v)
    for bad in (chr(0), chr(10), chr(13), chr(9)):
        s = s.replace(bad, ' ')
    s = s[:400]
    return "'" + s.replace("'", "''") + "'"


def _hub_alias(s: str) -> str:
    """Spaltenalias aus einem Wert — nur Buchstaben, Ziffern, Unterstrich."""
    a = re.sub(r'[^0-9A-Za-zäöüÄÖÜß ]+', ' ', str(s)).strip().replace(' ', '_')
    a = re.sub(r'_+', '_', a)[:40]
    return a or 'wert'


def hub_build_sql(spec: dict) -> dict:
    """Aus der Auswahl SQL + passende Diagrammeinstellungen erzeugen."""
    db, table = _hub_source()
    fields  = {f['key']: f for f in _hub_resolved_fields(db, table)}
    metrics = {m['key']: m for m in _HUB_METRICS}

    mkey = spec.get('metric') or 'anzahl'
    if mkey not in metrics:
        raise ValueError(f'Unbekannte Kennzahl: {mkey}')
    metric = metrics[mkey]
    for need in metric['needs']:
        if need not in fields:
            raise ValueError(
                'Die Kennzahl ' + metric['label'] + ' braucht das Feld "' + need
                + '", das es in dieser Tabelle nicht gibt.')

    # ── Teile-Modus ───────────────────────────────────────────────────────
    # Teile hängen 1:n am Incident. Sobald ein Teilefeld eine Achse ist (oder
    # die Kennzahl Teile zählt), wird je Teil eine Zeile gebildet — Incidents
    # werden dann DISTINCT gezählt. Als reiner Filter bleibt ein Teilefeld ein
    # EXISTS und vervielfacht nichts.
    axis_keys  = [spec.get('group'), spec.get('split'), spec.get('facet')]
    parts_mode = metric.get('scope') == 'teil' or any(
        k and k in fields and fields[k]['scope'] == 'teil' for k in axis_keys)
    if parts_mode and not ('texpr' in metric or metric.get('teil_ok')
                           or metric.get('scope') == 'teil'):
        raise ValueError(
            'Die Kennzahl ' + metric['label'] + ' lässt sich nicht mit Teilefeldern '
            'kombinieren: ein Incident mit drei Teilen ginge dreifach in den '
            'Durchschnitt/die Summe ein. Teilefeld als Filter nutzen oder '
            '„Anzahl Incidents" wählen.')
    # Bedarfs-Joins (WBS-Element, CS-Order, Operation) nur, wenn ein Feld sie nutzt
    used = ([k for k in axis_keys if k] + [f.get('field') for f in (spec.get('filters') or [])]
            + list(metric['needs']))
    need = {fields[k]['join'] for k in used if k in fields and fields[k].get('join')}
    m_expr  = metric['texpr']  if parts_mode and 'texpr'  in metric else metric['expr']
    m_cexpr = metric['tcexpr'] if parts_mode and 'tcexpr' in metric else metric['cexpr']

    # Spaltennamen für die Kennzahl-Vorlagen
    subst = {k: fields[k]['col'] for k in fields}
    # „Triebwerke" zählen nur Engines — die ESN eines Modul-Shop-Visits ist eine Modul-SN
    if 'esn' in subst and 'einlieferung' in fields:
        subst['esn'] = 'prj_esn_engine'
    subst.update({'created':  fields.get('meldedatum', {}).get('col', ''),
                  'resolved': fields.get('loesedatum', {}).get('col', '')})

    # ── Gruppierung (x-Achse) ─────────────────────────────────────────────
    gkey  = spec.get('group') or ''
    grain = spec.get('grain') or 'monat'
    if gkey and gkey not in fields:
        raise ValueError(f'Unbekanntes Gruppierungsfeld: {gkey}')
    if gkey:
        gf = fields[gkey]
        if gf['kind'] == 'zeit':
            if grain not in _HUB_GRAINS:
                raise ValueError(f'Unbekanntes Zeitraster: {grain}')
            gexpr  = _HUB_GRAINS[grain]['expr'].format(c=gf['col'])
            galias = grain
            is_time = True
        else:
            gexpr  = f"[{gf['col']}]"
            galias = _hub_alias(gf['key'])
            is_time = False
    else:
        gexpr, galias, is_time = '', '', False

    # ── Aufteilung (mehrere Reihen) ───────────────────────────────────────
    skey    = spec.get('split') or ''
    svals   = [v for v in (spec.get('split_values') or []) if str(v).strip() != ''][:10]
    select, value_cols, value_exprs = [], [], []
    if skey:
        if skey not in fields:
            raise ValueError(f'Unbekanntes Aufteilungsfeld: {skey}')
        if not svals:
            raise ValueError('Für die Aufteilung mindestens einen Wert wählen.')
        scol = fields[skey]['col']
        for v in svals:
            cond  = f'[{scol}] = {_hub_lit(v)}'
            alias = _hub_alias(v)
            while alias in value_cols:
                alias += '_'
            vexpr = m_cexpr.format(c=cond, **subst)
            select.append(vexpr + f' AS [{alias}]')
            value_cols.append(alias)
            value_exprs.append(vexpr)
    else:
        alias = mkey
        vexpr = m_expr.format(**subst)
        select.append(vexpr + f' AS [{alias}]')
        value_cols.append(alias)
        value_exprs.append(vexpr)

    # ── Facette (kleine Vielfache: je Wert eine Kachel) ───────────────────
    fkey   = spec.get('facet') or ''
    fvals  = [v for v in (spec.get('facet_values') or []) if str(v).strip() != ''][:12]
    fexpr, falias = '', ''
    if fkey:
        if fkey not in fields:
            raise ValueError(f'Unbekanntes Kachelfeld: {fkey}')
        if not gkey:
            raise ValueError('Kleine Vielfache brauchen eine Gruppierung — '
                             'sonst stünde in jeder Kachel nur eine einzelne Zahl.')
        if fields[fkey]['kind'] != 'kategorie':
            raise ValueError('Als Kachelfeld eignen sich nur Kategoriefelder.')
        if fkey in (gkey, skey):
            raise ValueError('Das Kachelfeld muss ein anderes sein als Gruppierung '
                             'und Aufteilung.')
        fexpr  = f"[{fields[fkey]['col']}]"
        falias = 'kachel_' + _hub_alias(fkey)
        while falias == galias or falias in value_cols:
            falias += '_'

    # ── Filter ────────────────────────────────────────────────────────────
    where, warnings = [], []
    if fkey and fvals:
        # Ohne Begrenzung erzeugt ein Feld mit 300 Werten 300 Kacheln.
        where.append(f'{fexpr} IN (' + ', '.join(_hub_lit(v) for v in fvals) + ')')
    for flt in (spec.get('filters') or []):
        fk, op = flt.get('field'), flt.get('op')
        if fk not in fields or op not in _HUB_OPS:
            continue
        col  = fields[fk]['col']
        kind = fields[fk]['kind']
        if kind not in _HUB_OPS[op]['kinds']:
            continue
        vals = [v for v in (flt.get('values') or []) if str(v).strip() != '']
        # Teilefeld ohne Teile-Modus: „ein Teil des Incidents erfüllt …" als
        # EXISTS; Verneinungen als NOT EXISTS der Bejahung, sonst hieße
        # „Teil ist nicht X" nur „irgendein anderes Teil ist nicht X".
        as_exists = fields[fk]['scope'] == 'teil' and not parts_mode
        negate    = as_exists and op in ('ist_nicht', 'leer')
        eop       = {'ist_nicht': 'ist', 'leer': 'gefuellt'}.get(op, op) if negate else op
        cond = None
        if eop == 'ist' and vals:
            cond = f'[{col}] IN (' + ', '.join(_hub_lit(v) for v in vals) + ')'
        elif eop == 'ist_nicht' and vals:
            cond = f'[{col}] NOT IN (' + ', '.join(_hub_lit(v) for v in vals) + ')'
        elif eop == 'enthaelt' and vals:
            ors = ' OR '.join(f'[{col}] LIKE {_hub_lit("%" + str(v) + "%")}' for v in vals)
            cond = '(' + ors + ')'
        elif eop == 'gefuellt':
            cond = f"[{col}] IS NOT NULL" + (f" AND [{col}] <> ''" if kind == 'kategorie' else '')
        elif eop == 'leer':
            cond = f"([{col}] IS NULL" + (f" OR [{col}] = ''" if kind == 'kategorie' else '') + ')'
        elif eop in ('ab', 'bis') and vals:
            cond = f'[{col}] {">=" if eop == "ab" else "<"} ' + _hub_lit(vals[0])
        elif eop in ('min', 'max') and vals:
            try:
                num = float(str(vals[0]).replace(',', '.'))
            except ValueError:
                continue
            cond = f'[{col}] {">=" if eop == "min" else "<="} {num:g}'
        if cond and as_exists:
            ex = _incident_extras(db, table)
            cond = (('NOT ' if negate else '') + 'EXISTS (SELECT 1 FROM '
                    + _incident_parts_sql(ex['parts'])
                    + ' m WHERE m.[inc_sys_id] = src.[sys_id] AND ' + cond + ')')
        if cond:
            where.append(cond)
        elif not vals:
            # Ein Filter ohne Wert filtert nichts — ohne Hinweis sähe das
            # Ergebnis wie eine Antwort aus, wäre aber die ungefilterte Menge.
            warnings.append('Die Bedingung "' + fields[fk]['label'] + ' '
                            + _HUB_OPS[op]['label'] + ' …" hat keinen Wert und '
                            'wurde weggelassen.')

    # ── Zusammensetzen ────────────────────────────────────────────────────
    limit = int(spec.get('limit') or 0)
    sort  = spec.get('sort') or ('label' if is_time else 'wert')
    NL    = '\n'
    IND   = '       '
    if sort == 'label':
        order_alias, order_expr = f'[{galias}]', gexpr
    elif sort == 'label_desc':
        order_alias, order_expr = f'[{galias}] DESC', gexpr + ' DESC'
    else:
        order_alias, order_expr = f'[{value_cols[0]}] DESC', value_exprs[0] + ' DESC'

    cols = (([f'{fexpr} AS [{falias}]'] if fkey else [])
            + ([f'{gexpr} AS [{galias}]'] if gkey else []) + select)

    def _body(sel_cols, top):
        s  = 'SELECT' + (f' TOP {top}' if top > 0 else '')
        s += NL + IND + (',' + NL + IND).join(sel_cols)
        s += NL + f'FROM {_hub_from(db, table, parts_mode, need)}'
        if where:
            s += NL + 'WHERE ' + (NL + '  AND ').join(where)
        if gkey:
            s += NL + 'GROUP BY ' + ', '.join(([fexpr] if fkey else []) + [gexpr])
        return s

    if fkey and limit > 0:
        # Die Begrenzung gilt je Kachel — ein globales TOP würde die hinteren
        # Kacheln ganz verschlucken statt sie zu kürzen.
        inner = _body(cols + [f'ROW_NUMBER() OVER (PARTITION BY {fexpr}'
                              f' ORDER BY {order_expr}) AS [rang]'], 0)
        outer = ', '.join(f'[{a}]' for a in [falias, galias] + value_cols)
        sql   = ('SELECT ' + outer + NL + 'FROM (' + NL
                 + NL.join('  ' + ln for ln in inner.split(NL)) + NL + ') q' + NL
                 + f'WHERE [rang] <= {limit}' + NL
                 + f'ORDER BY [{falias}], ' + order_alias)
    else:
        sql = _body(cols, limit)
        if gkey:
            sql += NL + 'ORDER BY ' + (f'[{falias}], ' if fkey else '') + order_alias

    suggest = None
    mod_key = next((k for k in ('modulgruppe', 'modul_abgeleitet') if k in axis_keys), None)
    if mod_key:
        excluded = any(f.get('field') == mod_key and f.get('op') == 'ist_nicht'
                       and MODULE_CK00_LABEL in (f.get('values') or [])
                       for f in (spec.get('filters') or []))
        if not excluded:
            warnings.append('„' + MODULE_CK00_LABEL + '" ist kein Modul, sondern die ganze '
                            'Engine (u. a. alle Buchungen vom Prüfstand). Für einen Modulvergleich '
                            'besser ausblenden.')
            suggest = {'field': mod_key, 'op': 'ist_nicht', 'values': [MODULE_CK00_LABEL],
                       'label': 'CK00 ausblenden'}
        if mod_key == 'modul_abgeleitet':
            warnings.append('„Modul (abgeleitet)" enthält Vorschläge für nicht modulgenau gebuchte '
                            'Incidents — für eine reine Buchungssicht nach „Modul-Herkunft" = gebucht filtern.')
    if parts_mode:
        warnings.append('Teile-Modus: nur Incidents mit erfasstem Teil zählen mit; '
                        'ein Incident mit mehreren Teilen erscheint in jedem seiner '
                        'Teile-Balken, wird je Balken aber nur einmal gezählt.')

    # ── Diagrammvorschlag ─────────────────────────────────────────────────
    if not gkey:
        viz = 'kpi'
    elif is_time:
        viz = 'line' if not skey else 'bar'
    else:
        viz = 'bar'
    title = metric['label'] + (f" je {fields[gkey]['label']}" if gkey else '')
    if gkey and is_time:
        title = metric['label'] + f" je {_HUB_GRAINS[grain]['label']}"
    if skey:
        title += f" nach {fields[skey]['label']}"
    if fkey:
        title += f" · je {fields[fkey]['label']}"

    return {
        'sql':        sql,
        'label_col':  galias,
        'value_cols': value_cols,
        'facet_col':  falias,
        'wide':       bool(fkey),
        'viz':        viz,
        'stacked':    bool(skey and viz == 'bar'),
        'unit':       metric['unit'],
        'title':      title,
        'database':   db,
        'table':      table,
        'is_time':    is_time,
        'parts_mode': parts_mode,
        'warnings':   warnings,
        'suggest_filter': suggest,
    }


@app.route('/api/hub/catalog')
def api_hub_catalog():
    """Felder, Kennzahlen, Zeitraster und Operatoren für den Baukasten."""
    try:
        db, table = _hub_source()
        fields = [{k: v for k, v in f.items() if k != 'cols'}
                  for f in _hub_resolved_fields(db, table)]
    except Exception as e:
        return jsonify({'error': str(e), 'fields': [], 'metrics': [], 'grains': {}, 'ops': {}})
    keys = {f['key'] for f in fields}
    return jsonify({
        'database': db, 'table': table, 'fields': fields,
        'metrics': [{**{k: m[k] for k in ('key', 'label', 'unit', 'needs')},
                     'scope': m.get('scope', 'incident'),
                     'teil_ok': bool('texpr' in m or m.get('teil_ok') or m.get('scope') == 'teil')}
                    for m in _HUB_METRICS if all(n in keys for n in m['needs'])],
        'grains': {k: v['label'] for k, v in _HUB_GRAINS.items()},
        'ops':    {k: v for k, v in _HUB_OPS.items()},
    })


# Füllgrad je Katalogfeld — 58 der 125 Incident-Spalten sind im Mirror leer.
# Wer ein fast leeres Feld wählt, bekommt sonst kommentarlos einen einzigen
# „(leer)"-Balken. Teuer genug für einen Stundencache, deshalb auch ein eigener
# Endpunkt, den die Seite nach dem Katalog im Hintergrund abruft.
HUB_FILL_TTL   = 3600
_hub_fill_cache = {}         # (db, table) → (ts, {key: Prozent}, info)
_hub_fill_lock  = threading.Lock()


def _hub_fill_expr(f: dict) -> str:
    c = f"[{f['col']}]"
    if f['kind'] == 'kategorie':
        return f"SUM(CASE WHEN NULLIF(LTRIM(CAST({c} AS nvarchar(4000))), '') IS NOT NULL THEN 1 ELSE 0 END)"
    return f'SUM(CASE WHEN {c} IS NOT NULL THEN 1 ELSE 0 END)'


def hub_fill_rates(db: str, table: str, force: bool = False) -> tuple:
    key = (db, table)
    hit = _hub_fill_cache.get(key)
    if hit and not force and time.time() - hit[0] < HUB_FILL_TTL:
        return hit[1], hit[2]
    with _hub_fill_lock:
        fields = _hub_resolved_fields(db, table)
        fill, info = {}, {}
        # drei Blöcke: Incident (+ Projekt, Status, WBS-Element), Prozess, Teile —
        # damit ein langsamer Operation-Join die anderen Zahlen nicht mitreißt
        blocks = [('incident', [f for f in fields if f['scope'] != 'teil'
                                and f.get('join') in ('', 'wbs_elem')], {'wbs_elem'}, False),
                  ('prozess',  [f for f in fields if f.get('join') in ('cso', 'op')],
                   {'cso', 'op'}, False),
                  ('teile',    [f for f in fields if f['scope'] == 'teil'], set(), True)]
        for name, flist, need, parts in blocks:
            if not flist:
                continue
            if parts:
                src = _incident_parts_sql(_incident_extras(db, table)['parts']) + ' src'
            else:
                src = _hub_from(db, table, False, need)
            sql = ('SELECT COUNT(*), ' + ', '.join(_hub_fill_expr(f) for f in flist)
                   + ' FROM ' + src)
            try:
                _, rows, _ = _sql_query(sql, database=db, timeout=120, cap=0)
                total = rows[0][0] or 0
                info[name] = total
                for f, n in zip(flist, rows[0][1:]):
                    fill[f['key']] = round(100.0 * (n or 0) / total, 1) if total else 0.0
            except Exception as e:
                info[name] = 'Fehler: ' + str(e)[:200]
        _hub_fill_cache[key] = (time.time(), fill, info)
        return fill, info


@app.route('/api/hub/fill')
def api_hub_fill():
    try:
        db, table = _hub_source()
        fill, info = hub_fill_rates(db, table, force=request.args.get('force') == '1')
        return jsonify({'fill': fill, 'basis': info})
    except Exception as e:
        return jsonify({'error': str(e), 'fill': {}})


@app.route('/api/hub/values', methods=['POST'])
def api_hub_values():
    """Vorkommende Werte eines Kategoriefeldes — damit niemand Namen raten muss."""
    data = request.get_json(silent=True) or {}
    key  = (data.get('field') or '').strip()
    try:
        db, table = _hub_source()
        fields = {f['key']: f for f in _hub_resolved_fields(db, table)}
        if key not in fields:
            return jsonify({'error': f'Unbekanntes Feld: {key}'})
        col = fields[key]['col']
        is_part = fields[key]['scope'] == 'teil'
        need    = [fields[key]['join']] if fields[key].get('join') else []
        _, rows, _ = _sql_query(
            f'SELECT TOP 200 [{col}] AS wert, COUNT(*) AS anzahl FROM {_hub_from(db, table, is_part, need)} '
            f'WHERE [{col}] IS NOT NULL AND [{col}] <> {_hub_lit("")} '
            f'GROUP BY [{col}] ORDER BY COUNT(*) DESC',
            database=db, timeout=60, cap=0)
        return jsonify({'values': [{'v': r[0], 'n': r[1]} for r in rows]})
    except Exception as e:
        return jsonify({'error': str(e)})


@app.route('/api/hub/build', methods=['POST'])
def api_hub_build():
    """Auswahl → SQL. Reine Erzeugung, fragt die Datenbank nur nach dem Schema."""
    spec = (request.get_json(silent=True) or {}).get('spec') or {}
    try:
        return jsonify(hub_build_sql(spec))
    except Exception as e:
        return jsonify({'error': str(e)})


# ══════════════════════════════════════════════════════════════════════════════
# Modul-Katalog
# ServiceNow hat keine Modul-Stammliste. Die Module sind die CK-Elemente der
# SAP-PSP-Struktur (x_mtuae_ins_wbs), die für JEDES Projekt aus einer Vorlage
# je Engine-Familie angelegt werden (CF34-10E: 41 CKs in 521 von 522 Projekten).
# Daraus wird hier die Stammliste gebaut. CK-Nummern sind je Familie anders
# belegt (CK21 = Fan Rotor bei CF34, anderswo FAN CASE MODULE) — Schlüssel ist
# deshalb nie die bloße Nummer. Einzelprojekte mit abweichender Vorlage
# („CK99: Nozzle Stg 3-6" neben 521× „CK99: LPT Nozzle Stg. 2-4") werden auf den
# häufigsten Namen abgebildet.
# Achtung: ein CK-Element heißt NICHT, dass das Modul bearbeitet wurde — jede
# Vorlage enthält alle Module. Projekte je Modul sind deshalb kein Nenner für Raten.
# ══════════════════════════════════════════════════════════════════════════════

MODULE_CATALOG_PATH = os.path.join(SQL_CACHE_DIR, 'module_catalog.json')
MODULE_CATALOG_TTL  = 6 * 3600
MODULE_MIN_SUPPORT  = 5        # darunter entscheidet der Name der Familie
MODULE_REMAP_MAX    = 2000     # mehr Umbenennungen passen nicht sinnvoll in ein CASE

# Obergruppe = MITTELSEGMENT des WBS-Codes: L.20000.20.80 = Projekt · OG 20 · CK80.
# So steht die Hierarchie in LUD_Core (die Spalte Parent ist leer). Belegt am
# CFM56-7-Projekt L.20000 (10/10 deckungsgleich mit der Modulstruktur-Folie),
# CF34 (L.21108) und GTF. Nur bei genau vier Segmenten — sonst keine OG, nicht raten.
_WBS_OG_SQL = ("CASE WHEN {c} LIKE '_%.%.%.%' AND {c} NOT LIKE '%.%.%.%.%' "
               "THEN PARSENAME({c}, 2) END")
# Teilesatz (Schaufeln, Leitschaufeln, Segmente): steht NICHT in LUD_Core. Heuristik
# aus CK 80–99 + Name, in CFM/CF34/GTF stimmig — wird immer als Vermutung gezeigt.
_TSET_RX = re.compile(r'BLADE|VANE|SEGM|NOZZLE STG|\bAF\b|AIRFOIL', re.I)

_module_cat   = {'data': None, 'ts': 0.0}
_module_lock  = threading.Lock()
_module_bg    = {'running': False}


def _ck_of(name) -> str:
    m = re.match(r'\s*(CK\d+)\s*:', str(name or ''), re.I)
    return m.group(1).upper() if m else ''


def _ck_sort(ck: str) -> int:
    m = re.search(r'\d+', ck or '')
    return int(m.group()) if m else 999


def _mode(counter: dict) -> str:
    """Häufigster Name; bei Gleichstand der kürzere, dann alphabetisch — stabil."""
    return sorted(counter.items(), key=lambda kv: (-kv[1], len(kv[0]), kv[0]))[0][0]


def struct_templates(sdata: dict = None) -> dict:
    """Familie → {'main': Wurzel der Hauptvorlage, 'minor': {Wurzel: [Projekte]}} aus dem
    Einbaubaum (04.4). Hauptvorlage = die mit den meisten Projekten; sie behält im
    Katalog den leeren Schlüssel '', damit Bestehendes (Graph, Heatmap, Baukasten) sie
    unverändert sieht. Projekte ohne ECMS-Einheiten zählen zur Hauptvorlage."""
    sdata = sdata or struct_catalog() or {}
    out = {}
    fam_of_tpl = {}
    for f in sdata.get('families', []):
        if not f['templates']:
            continue
        roots = [t['root'] for t in f['templates'] if t['root'] != '?']
        if not roots:
            continue
        out[f['family']] = {'main': roots[0], 'minor': {r: [] for r in roots[1:]}}
        for r in roots[1:]:
            fam_of_tpl[r] = fam_of_tpl.get(r, []) + [f['family']]
    for proj, tpl in (sdata.get('project_tpl') or {}).items():
        for fam in fam_of_tpl.get(tpl, []):
            out[fam]['minor'][tpl].append(proj)
    return out


def _tpl_case_sql(proj_expr: str, st: dict = None) -> str:
    """SQL: Vorlage eines Projekts ('' = Hauptvorlage) über IN-Listen der Nebenvorlagen."""
    st = st if st is not None else struct_templates()
    whens = []
    for fam, t in st.items():
        for root, projs in t['minor'].items():
            if projs:
                whens.append(f'WHEN {proj_expr} IN (' + ', '.join(_hub_lit(x) for x in sorted(projs)[:3000])
                             + f') THEN {_hub_lit(root)}')
    return ('CASE ' + ' '.join(whens) + " ELSE '' END") if whens else "''"


def module_catalog_build(db: str, table: str) -> dict:
    ex = _incident_extras(db, table)
    we, prj = ex.get('wbs_elem'), ex.get('project')
    if not we or not we.get('wbs') or not prj:
        raise RuntimeError('Für den Modul-Katalog fehlen WBS- oder Projekt-Tabelle.')
    t0   = time.time()
    ecol = prj['cols']['engine']
    base = _WBS_BASE_SQL.format(c='w.[wbs]')
    ogx  = _WBS_OG_SQL.format(c='w.[wbs]')
    # Vorlage je Projekt aus dem Einbaubaum: dieselbe CK-Nummer bedeutet in der
    # CFM56-7-Vorlage „M…" etwas anderes (CK81 Fan Blades statt Booster Blades) —
    # ohne diese Trennung würde der häufigste Name die andere Vorlage überschreiben.
    st_  = struct_templates()
    if struct_catalog() is None:
        struct_catalog_start()                 # erst ohne Vorlagen; danach baut der Katalog neu
    tplx = _tpl_case_sql(base, st_)
    tpli = _tpl_case_sql('p.[number]', st_)
    # 1) alle CK-Elemente (+ MD-Kopfknoten): Engine-Typ × Vorlage × Name × Obergruppe × Projekte
    _, rows, _ = _sql_query(
        f"SELECT pr.[engine], {tplx} AS tpl, w.[name], {ogx} AS og, COUNT(DISTINCT {base}) AS projekte "
        f"FROM (SELECT MAX([name]) AS [name], MAX([{we['wbs']}]) AS [wbs] FROM {we['table']} "
        f"      WHERE [name] LIKE 'CK%:%' OR [name] LIKE 'MD%:%' GROUP BY [sys_id]) w "
        f"JOIN {_prj_latest(prj)} pr "
        f"  ON pr.[number] = {base} "
        f"GROUP BY pr.[engine], {tplx}, w.[name], {ogx}", database=db, timeout=240, cap=0)
    # 2) Incidents je Engine-Typ × Name × Meldegruppe (alle Zyklen) — die
    #    Meldegruppe zeigt, wer auf CK00 bucht (Prüfstand: immer, systembedingt)
    gcol = ex['inc'].get('requester_group')
    gsel = f'i.[{gcol}]' if gcol else 'NULL'
    _, irows, _ = _sql_query(
        f"SELECT p.[{ecol}], {tpli}, w.[name], {gsel}, COUNT(*) FROM {table} i "
        f"JOIN (SELECT [sys_id], MAX([name]) AS [name] FROM {we['table']} "
        f"      WHERE [name] LIKE 'CK%:%' GROUP BY [sys_id]) w ON w.[sys_id] = i.[{we['key']}] "
        f"{_incident_project_join(prj, ex['wbs'])} "
        f"GROUP BY p.[{ecol}], {tpli}, w.[name], {gsel}", database=db, timeout=240, cap=0)

    by_type, by_fam = defaultdict(lambda: defaultdict(int)), defaultdict(lambda: defaultdict(int))
    og_cnt = defaultdict(lambda: defaultdict(int))       # (Familie, Vorlage, CK) → OG → Projekte
    md_heads = defaultdict(lambda: defaultdict(int))     # (Familie, Vorlage, OG) → MD-Name → Projekte
    elements = 0
    for et, tpl, raw, og, n in rows:
        et, raw, ck, tpl = (et or '').strip(), (raw or '').strip(), _ck_of(raw), tpl or ''
        og = (og or '').strip()
        if not et:
            continue
        if not ck:
            m = re.match(r'\s*MD(\d+)\s*:', raw, re.I)
            if m and og:
                md_heads[(_engine_family(et), tpl, og)][raw] += n or 0
            continue
        by_type[(et, tpl, ck)][raw] += n or 0
        by_fam[(_engine_family(et), tpl, ck)][raw] += n or 0
        og_cnt[(_engine_family(et), tpl, ck)][og or '–'] += n or 0
        elements += n or 0

    def canonical(et: str, ck: str, raw: str, tpl: str = '') -> str:
        own = by_type.get((et, tpl, ck))
        if own and sum(own.values()) >= MODULE_MIN_SUPPORT:
            return _mode(own)
        fam = by_fam.get((_engine_family(et), tpl, ck))
        if fam:
            return _mode(fam)
        return _mode(own) if own else raw

    mods, remap = {}, []
    for (et, tpl, ck), names in by_type.items():
        fam = _engine_family(et)
        for raw, n in names.items():
            canon = canonical(et, ck, raw, tpl)
            m = mods.setdefault((fam, tpl, ck, canon), {
                'family': fam, 'template': tpl, 'ck': ck, 'name': canon, 'projects': 0, 'incidents': 0,
                'types': set(), 'variants': defaultdict(int)})
            m['projects'] += n
            m['types'].add(et)
            if raw != canon:
                m['variants'][raw] += n
                remap.append([et, raw, canon, tpl])
    # Umbenennung im Baukasten: nur, wenn ein Rohname in allen Vorlagen gleich abgebildet wird
    seen_rm = defaultdict(set)
    for et, raw, canon, tpl in remap:
        seen_rm[(et, raw)].add(canon)
    remap = [[et, raw, canon] for et, raw, canon, tpl in remap if len(seen_rm[(et, raw)]) == 1]
    inc_total = inc_unmapped = 0
    groups = defaultdict(lambda: defaultdict(lambda: [0, 0]))   # Familie → Gruppe → [CK00, alle]
    for et, tpl, raw, grp, n in irows:
        et, raw, ck, tpl = (et or '').strip(), (raw or '').strip(), _ck_of(raw), tpl or ''
        inc_total += n or 0
        if not et or not ck:
            inc_unmapped += n or 0
            continue
        g = groups[_engine_family(et)][(grp or '–').strip() or '–']
        g[1] += n or 0
        if ck == 'CK00':
            g[0] += n or 0
        canon = canonical(et, ck, raw, tpl)
        key = (_engine_family(et), tpl, ck, canon)
        if key in mods:
            mods[key]['incidents'] += n or 0
        else:
            inc_unmapped += n or 0

    # Obergruppe je (Familie, CK): häufigste; Abweichler werden gezeigt, nicht versteckt
    def og_info(fam, ck, tpl=''):
        c = og_cnt.get((fam, tpl, ck)) or {}
        real = {k: v for k, v in c.items() if k != '–'}
        if not real:
            return {'og': None, 'og_share': None, 'og_other': []}
        tot = sum(c.values())
        best = sorted(real.items(), key=lambda kv: (-kv[1], kv[0]))[0]
        return {'og': best[0], 'og_share': round(best[1] / tot, 3) if tot else None,
                'og_other': [{'og': k, 'projects': v} for k, v in
                             sorted(c.items(), key=lambda kv: -kv[1]) if k != best[0]]}

    def kind_of(m, og):
        if m['ck'] == 'CK00':
            return 'engine'
        if og and m['ck'][2:].lstrip('0') == og.lstrip('0'):
            return 'kopf'
        if _ck_sort(m['ck']) >= 80 and _TSET_RX.search(m['name'] or ''):
            return 'teilesatz?'
        return ''

    # 3) Auftragsprofil je Modul: welche Auftragsarten hängen an diesem CK?
    #    (Wartungsart aus cs_order) — zeigt Prozess- vs. Hardware-Elemente, ohne zu klassifizieren
    cso = ex.get('cso')
    orders = defaultdict(lambda: defaultdict(int))
    if cso and {'wbs', 'wbs_sys_id'} <= set(cso.get('extra', {})) and cso['cols'].get('maintenance_type'):
        ce, mt = cso['extra'], cso['cols']['maintenance_type']
        obase = _WBS_BASE_SQL.format(c=f"o.[{ce['wbs']}]")
        try:
            _, orows, _ = _sql_query(
                f"SELECT pr.[engine], {_tpl_case_sql(obase, st_)}, w.[name], o.[{mt}], COUNT(DISTINCT o.[sys_id]) "
                f"FROM {cso['table']} o "
                f"JOIN (SELECT [sys_id], MAX([name]) AS [name] FROM {we['table']} "
                f"      WHERE [name] LIKE 'CK%:%' GROUP BY [sys_id]) w ON w.[sys_id] = o.[{ce['wbs_sys_id']}] "
                f"JOIN {_prj_latest(prj)} pr ON pr.[number] = {obase} "
                f"GROUP BY pr.[engine], {_tpl_case_sql(obase, st_)}, w.[name], o.[{mt}]", database=db, timeout=300, cap=0)
            for et, tpl, raw, mtype, n in orows:
                et, raw, ck, tpl = (et or '').strip(), (raw or '').strip(), _ck_of(raw), tpl or ''
                if et and ck:
                    orders[(_engine_family(et), tpl, ck, canonical(et, ck, raw, tpl))][
                        (mtype or '–').strip() or '–'] += n or 0
        except Exception as e:
            print(f'[module] Auftragsprofil übersprungen: {e}')

    for m in mods.values():
        oi = og_info(m['family'], m['ck'], m['template'])
        m.update(oi)
        m['kind'] = kind_of(m, oi['og'])
        prof = orders.get((m['family'], m['template'], m['ck'], m['name']), {})
        m['orders'] = [{'type': k, 'n': v} for k, v in sorted(prof.items(), key=lambda kv: -kv[1])]

    # Obergruppen je Familie. Ein Name steht in LUD_Core nirgends — nur der
    # Kopfknoten (CK == OG oder MDxx) wird genannt, als Kopf und nicht als Name
    # (bei CFM ist der Kopf von OG 61 „IGB & No.3 Brg.", die Gruppe aber das Getriebe).
    obergruppen = defaultdict(dict)            # Schlüssel: Familie (Hauptvorlage) bzw. „Familie|Vorlage"
    for m in mods.values():
        if not m['og']:
            continue
        gk = m['family'] if not m['template'] else f"{m['family']}|{m['template']}"
        g = obergruppen[gk].setdefault(m['og'], {
            'og': m['og'], 'cks': [], 'incidents': 0, 'projects': 0, 'head': None})
        if m['ck'] not in g['cks']:
            g['cks'].append(m['ck'])
        g['incidents'] += m['incidents']
        g['projects'] = max(g['projects'], m['projects'])
        if m['kind'] == 'kopf' and (not g['head'] or g['head']['projects'] < m['projects']):
            g['head'] = {'ck': m['ck'], 'name': m['name'], 'projects': m['projects'], 'md': False}
    for (fam, tpl, og), names_ in md_heads.items():
        g = obergruppen.get(fam if not tpl else f'{fam}|{tpl}', {}).get(og)
        if g and not g['head']:
            g['head'] = {'ck': f'MD{og}', 'name': _mode(names_), 'projects': sum(names_.values()),
                         'md': True}
    for fam, gs in obergruppen.items():
        for g in gs.values():
            g['cks'].sort(key=_ck_sort)
            g['engine_level'] = g['cks'] == ['CK00']

    modules = sorted(({**m, 'types': sorted(m['types']), 'engine_level': m['ck'] == 'CK00',
                       'variants': sorted(({'name': k, 'projects': v}
                                           for k, v in m['variants'].items()),
                                          key=lambda x: -x['projects'])}
                      for m in mods.values()),
                     key=lambda m: (m['family'], _ck_sort(m['ck']), -m['projects']))
    fams = defaultdict(lambda: {'modules': 0, 'incidents': 0, 'ck00': 0, 'types': set()})
    tpls = defaultdict(lambda: defaultdict(lambda: {'modules': 0, 'incidents': 0, 'projects': 0}))
    for m in modules:
        t_ = tpls[m['family']][m['template']]
        t_['modules'] += 0 if m['engine_level'] else 1
        t_['incidents'] += m['incidents']
        t_['projects'] = max(t_['projects'], m['projects'])
        f = fams[m['family']]
        f['modules'] += 0 if m['engine_level'] else 1
        f['incidents'] += m['incidents']
        f['ck00'] += m['incidents'] if m['engine_level'] else 0
        f['types'].update(m['types'])
    return {
        'ts': datetime.datetime.now().isoformat(timespec='seconds'),
        'database': db, 'wbs_table': we['table'],
        'families': sorted(({'family': k, 'modules': v['modules'], 'incidents': v['incidents'],
                             'ck00': v['ck00'], 'types': sorted(v['types']),
                             'templates': [{'tpl': t, 'label': t or (st_.get(k) or {}).get('main') or 'Hauptvorlage',
                                            **v_} for t, v_ in sorted(tpls[k].items(), key=lambda kv: kv[0] != '')],
                             'groups': sorted(({'group': g, 'ck00': c[0], 'total': c[1]}
                                               for g, c in groups.get(k, {}).items()),
                                              key=lambda x: -x['total'])}
                            for k, v in fams.items()),
                           key=lambda f: -f['incidents']),
        'modules': modules,
        'obergruppen': {fam: sorted(gs.values(), key=lambda g: _ck_sort(g['og']))
                        for fam, gs in obergruppen.items()},
        'remap': remap,
        'templates_ready': struct_catalog() is not None,
        'stats': {'ck_elemente': elements, 'module': len(modules), 'umbenannt': len(remap),
                  'module_nebenvorlagen': sum(1 for m in modules if m['template']),
                  'incidents_mit_ck': inc_total, 'incidents_ohne_zuordnung': inc_unmapped,
                  'module_ohne_og': sum(1 for m in modules if not m.get('og')),
                  'module_og_uneinheitlich': sum(1 for m in modules if m.get('og_other')),
                  'sekunden': round(time.time() - t0, 1)},
    }


def module_catalog(force: bool = False, build: bool = True):
    """Katalog aus dem Speicher, sonst neu gebaut, sonst von der Platte.
    build=False: nie die DB fragen (für den Baukasten — der darf nicht warten)."""
    c = _module_cat
    if c['data'] and not force and time.time() - c['ts'] < MODULE_CATALOG_TTL:
        return c['data']
    if not build:
        if c['data']:
            return c['data']
        try:
            with open(MODULE_CATALOG_PATH, encoding='utf-8') as f:
                c['data'], c['ts'] = json.load(f), 0.0
            if 'obergruppen' not in c['data']:
                _module_catalog_background()        # alter Stand ohne Obergruppen → nachbauen
            return c['data']
        except Exception:
            return None
    with _module_lock:
        if c['data'] and not force and time.time() - c['ts'] < MODULE_CATALOG_TTL:
            return c['data']
        try:
            db, table = _hub_source()
            data = module_catalog_build(db, table)
            c['data'], c['ts'] = data, time.time()
            try:
                os.makedirs(SQL_CACHE_DIR, exist_ok=True)
                tmp = MODULE_CATALOG_PATH + '.tmp'
                with open(tmp, 'w', encoding='utf-8') as f:
                    json.dump(data, f, ensure_ascii=False)
                os.replace(tmp, MODULE_CATALOG_PATH)
            except Exception:
                pass
            return data
        except Exception as e:
            old = module_catalog(build=False)
            if old:
                return {**old, 'stale': True, 'error': str(e)}
            raise


def _module_catalog_background(force: bool = False):
    """Katalog im Hintergrund bauen, damit der Baukasten beim nächsten Mal umbenennt."""
    if _module_bg['running']:
        return
    _module_bg['running'] = True

    def run():
        try:
            module_catalog(force=force)
        except Exception as e:
            print(f'[module] Katalog nicht gebaut: {e}')
        finally:
            _module_bg['running'] = False
    threading.Thread(target=run, daemon=True).start()


def _module_name_sql(name_expr: str, engine_expr) -> str:
    """Modulname mit Varianten → kanonischer Name. CK00 heißt über alle Familien
    gleich („Engine gesamt"), nur die wenigen Abweichler stehen im CASE, alle
    anderen Namen laufen unverändert durch."""
    ck00 = f"WHEN {name_expr} LIKE 'CK00%' THEN {_hub_lit(MODULE_CK00_LABEL)}"
    cat = module_catalog(build=False)
    if cat is None:
        _module_catalog_background()
    remap = (cat or {}).get('remap') or []
    remap = [r for r in remap if _ck_of(r[1]) != 'CK00'] if engine_expr else []
    if not remap:
        return f'CASE {ck00} ELSE {name_expr} END'
    if len(remap) > MODULE_REMAP_MAX:
        print(f'[module] {len(remap)} Umbenennungen — nur die ersten {MODULE_REMAP_MAX} im CASE')
        remap = remap[:MODULE_REMAP_MAX]
    whens = ' '.join(f'WHEN {engine_expr} = {_hub_lit(et)} AND {name_expr} = {_hub_lit(raw)} '
                     f'THEN {_hub_lit(canon)}' for et, raw, canon in remap)
    return f'CASE {ck00} {whens} ELSE {name_expr} END'


# ══════════════════════════════════════════════════════════════════════════════
# Modulvorschlag: Teilenummer → CK-Modul je Engine-Familie
# Am Prüfstand wird systembedingt auf CK00 gebucht (Modul unbekannt; die
# CK00-Aufträge kennen es auch nicht — Abfrage E2: 866 von 866). Die Matrix
# schlägt beim Melden ein Modul vor. Zwei Quellen:
#   ecms — Einbauhistorie serialisierter Teile (x_mtuae_ins_part, WBS-Endung = CK);
#          Abfrage F (CF34-10): 97,6 % der Einbauten eindeutig einem CK zugeordnet
#   inc  — modulgenau gebuchte Incidents mit betroffenen Teilen (auch MPL-Teile,
#          die ECMS nicht kennt)
# Rückrechnung: für jeden modulgenauen Incident mit Teil wird das Modul
# vorhergesagt und mit der Buchung verglichen; die Incident-Quelle dabei ohne den
# geprüften Incident selbst (leave-one-out), sonst wäre die Quote geschönt.
# ══════════════════════════════════════════════════════════════════════════════

# ATA-Abschnitt (72-43) aus dem Auftragstext. Formate aus dem Snapshot:
#   K72-35-00BU01E · G57-72-33-20C120 · J72-54-7141C001E  → mit Bindestrichen
#   V724320-01060R1X                                    → ohne (Buchstabe + 6 Ziffern + „-")
#   A00-00-00-00I020 (Metallographie)                    → kein 7x, also keins
_ATA_PAT_DASH   = "'%7[0-9]-[0-9][0-9]-[0-9][0-9]%'"
_ATA_PAT_NODASH = "'%[A-Z]7[0-9][0-9][0-9][0-9][0-9]-%'"
# Pseudo-ATA (Abfrage H, 28.09.2026): Marshalling-Aufträge heißen
#   „G57-72-09-00F001 = MARSHALLING OF 09X" — an der ATA-Stelle steht die CK-Nummer,
# kein ATA-Kapitel. Mitgelernt ergäbe das Zirkelschlüsse („72-93 → CK93") und schönte
# die Rückrechnung (in H traf das 117 von 117 solcher Zeilen). Solche Texte liefern NULL.
_ATA_PSEUDO = ("'%MARSHALLING OF [0-9][0-9][XT]%'", "'%-00F0[0-9][0-9] = %'")


def _ata_pseudo_sql(sp: str) -> str:
    return ' OR '.join(f'PATINDEX({p}, {sp}) > 0' for p in _ATA_PSEUDO)


def _ata_sql(sp: str) -> str:
    return (f"CASE WHEN {_ata_pseudo_sql(sp)} THEN NULL "
            f"WHEN PATINDEX({_ATA_PAT_DASH}, {sp}) > 0 "
            f"THEN SUBSTRING({sp}, PATINDEX({_ATA_PAT_DASH}, {sp}), 5) "
            f"WHEN PATINDEX({_ATA_PAT_NODASH}, {sp}) > 0 "
            f"THEN SUBSTRING({sp}, PATINDEX({_ATA_PAT_NODASH}, {sp}) + 1, 2) + '-' + "
            f"SUBSTRING({sp}, PATINDEX({_ATA_PAT_NODASH}, {sp}) + 3, 2) END")


MODULE_MATRIX_PATH = os.path.join(SQL_CACHE_DIR, 'module_matrix.json')
# Aufbau-Version: ändert sich, wenn der Matrix-Aufbau neue Teile bekommt. Ein
# älterer Cache wird weiter gezeigt, aber im Hintergrund neu gebaut — sonst
# fehlen neue Felder still (so aufgefallen bei den ATA-Korrekturen).
MATRIX_VERSION = 9
MATRIX_CLEAR       = 0.8       # ab diesem Anteil gilt ein Vorschlag als eindeutig
MATRIX_MIN_N       = 3         # darunter: zu wenig Belege für einen Vorschlag

_matrix = {'data': None, 'ts': 0.0, 'running': False, 'error': '', 'started': 0.0}
_matrix_lock = threading.Lock()


def _pn(v) -> str:
    """Teilenummer vergleichbar machen: Groß, ohne Leerzeichen."""
    return re.sub(r'\s+', '', str(v or '')).upper()


def _bez(v) -> str:
    """'1800M58P01 - CLAMP, TUBE' → 'CLAMP, TUBE'."""
    s = str(v or '').strip()
    return s.split(' - ', 1)[1].strip() if ' - ' in s else s


def _matrix_verdict(ck: dict, skip: tuple = None) -> tuple:
    """(bester CK, Anteil, Belege) aus {ck: [ecms, inc]}; skip=(ck, n) zieht
    Incident-Belege ab (leave-one-out)."""
    tot = {}
    for k, (a, b) in ck.items():
        b -= skip[1] if skip and skip[0] == k else 0
        if a + b > 0:
            tot[k] = a + b
    n = sum(tot.values())
    if not n:
        return None, 0.0, 0
    best = sorted(tot.items(), key=lambda kv: (-kv[1], kv[0]))[0]
    return best[0], best[1] / n, n


def module_matrix_build(db: str, table: str) -> dict:
    ex = _incident_extras(db, table)
    prj, we, ecms, parts = ex.get('project'), ex.get('wbs_elem'), ex.get('ecms'), ex.get('parts')
    if not prj or not we:
        raise RuntimeError('Für die Matrix fehlen Projekt- oder WBS-Tabelle.')
    t0   = time.time()
    ecol = prj['cols']['engine']
    prj_dedup = _prj_latest(prj)
    # fam → Teilenummer → {'bez', 'ck': {CKnn: [ecms, inc]}}
    mat = defaultdict(dict)

    def slot(fam, pn, bez):
        e = mat[fam].get(pn)
        if e is None:
            e = mat[fam][pn] = {'bez': '', 'ck': defaultdict(lambda: [0, 0])}
        if bez and not e['bez']:
            e['bez'] = bez
        return e

    stats = {'ecms_zeilen': 0, 'inc_zeilen': 0}
    ck00_parts = defaultdict(dict)        # fam → Teilenummer → CK00-Incidents
    # ── Quelle 1: ECMS-Einbauten ──────────────────────────────────────────
    # Ein Durchlauf, fein gruppiert (CK × Baugruppe × Teil × Position): daraus
    # entstehen die Matrix (summiert) UND der Hierarchie-Baum für den Graphen.
    # fam → CK → Baugruppe → Teilenummer → {n, sn, bez, pos: {Position: n}}
    tree = defaultdict(lambda: defaultdict(lambda: defaultdict(dict)))
    if ecms:
        c = ecms['cols']
        base = _WBS_BASE_SQL.format(c=f"t.[{c['wbs']}]")
        name = f"MAX(t.[{c['name']}])" if 'name' in c else 'NULL'
        asm  = f"t.[{c['text_of_module_group']}]" if 'text_of_module_group' in c else 'NULL'
        pos  = f"t.[{c['build_up_position']}]" if 'build_up_position' in c else 'NULL'
        sn   = (f"COUNT(DISTINCT NULLIF(t.[{c['serial_number']}], ''))"
                if 'serial_number' in c else '0')
        _, rows, _ = _sql_query(
            f"SELECT pr.[engine], t.[{c['part_number']}], RIGHT(t.[{c['wbs']}], 2), "
            f"COUNT(DISTINCT t.[sys_id]), {name}, {asm}, {pos}, {sn} "
            f"FROM {ecms['table']} t JOIN {prj_dedup} pr ON pr.[number] = {base} "
            f"WHERE t.[{c['wbs']}] LIKE '_.%.%.%' AND RIGHT(t.[{c['wbs']}], 2) <> '00' "
            f"AND NULLIF(t.[{c['part_number']}], '') IS NOT NULL "
            f"GROUP BY pr.[engine], t.[{c['part_number']}], RIGHT(t.[{c['wbs']}], 2), {asm}, {pos}",
            database=db, timeout=900, cap=0)
        for et, pn, ck2, n, bez, asmv, posv, snv in rows:
            if not et or not str(ck2 or '').isdigit():
                continue
            fam, pnn, ckk = _engine_family(str(et).strip()), _pn(pn), 'CK' + ck2
            slot(fam, pnn, _bez(bez))['ck'][ckk][0] += n or 0
            leaf = tree[fam][ckk][(asmv or '').strip() or '(ohne Baugruppe)'].setdefault(
                pnn, {'n': 0, 'sn': 0, 'bez': _bez(bez), 'pos': {}})
            leaf['n'] += n or 0
            leaf['sn'] += snv or 0
            if posv:
                leaf['pos'][str(posv)] = leaf['pos'].get(str(posv), 0) + (n or 0)
            stats['ecms_zeilen'] += 1
    # ── Quelle 2: modulgenaue Incidents mit Teilen ────────────────────────
    inc_rows = []
    if parts:
        _, inc_rows, _ = _sql_query(
            f"SELECT p.[{ecol}], m.[teil_nr], w.[name], COUNT(DISTINCT i.[sys_id]), MAX(m.[teil_bez]) "
            f"FROM {table} i "
            f"JOIN (SELECT [sys_id], MAX([name]) AS [name] FROM {we['table']} GROUP BY [sys_id]) w "
            f"  ON w.[sys_id] = i.[{we['key']}] "
            f"JOIN {_incident_parts_sql(parts)} m ON m.[inc_sys_id] = i.[sys_id] "
            f"{_incident_project_join(prj, ex['wbs'])} "
            f"WHERE w.[name] LIKE 'CK%:%' "
            f"AND NULLIF(m.[teil_nr], '') IS NOT NULL "
            f"GROUP BY p.[{ecol}], m.[teil_nr], w.[name]", database=db, timeout=300, cap=0)
        agg = defaultdict(int)       # (fam, pn, ck) → Incidents
        for et, pn, wname, n, bez in inc_rows:
            ck = _ck_of(wname)
            if not et or not ck:
                continue
            fam = _engine_family(str(et).strip())
            if ck == 'CK00':
                # CK00 ist kein Modul (Prüfstand, Schüttware) — nicht in die Matrix,
                # aber am Teil zeigen: dahin zeigen die Befunde ohne Modul
                ck00_parts[fam][_pn(pn)] = ck00_parts[fam].get(_pn(pn), 0) + (n or 0)
                continue
            slot(fam, _pn(pn), (bez or '').strip())['ck'][ck][1] += n or 0
            agg[(fam, _pn(pn), ck)] += n or 0
            stats['inc_zeilen'] += 1
    else:
        agg = {}

    # ── Quelle 3: ATA-Abschnitt aus dem Auftragstext → Modul ─────────────
    # „G57-72-23-31I001: FAN FRAME SHROUD" → 72-23. Gelernt je Familie aus allen
    # Aufträgen, die an einem echten Modul hängen; angewendet auf Aufträge, die an
    # CK00 hängen (Abfrage G: Reparaturaufträge mit Modul-ATA auf der ganzen Engine).
    ata_map = defaultdict(lambda: defaultdict(lambda: defaultdict(int)))   # fam → ATA → CK → Aufträge
    ata_ck00 = defaultdict(lambda: defaultdict(int))                     # fam → ATA → CK00-Incidents
    cso = ex.get('cso')
    if cso and {'serviceproduct', 'wbs', 'wbs_sys_id'} <= set(cso.get('extra', {})):
        ce  = cso['extra']
        sp  = f"o.[{ce['serviceproduct']}]"
        ata_sql = _ata_sql(sp)
        wbd = f"(SELECT [sys_id], MAX([name]) AS [name] FROM {we['table']} GROUP BY [sys_id])"
        obase = _WBS_BASE_SQL.format(c=f"o.[{ce['wbs']}]")
        try:
            _, rows, _ = _sql_query(
                f"SELECT pr.[engine], w.[name], {ata_sql}, COUNT(DISTINCT o.[sys_id]) "
                f"FROM {cso['table']} o JOIN {wbd} w ON w.[sys_id] = o.[{ce['wbs_sys_id']}] "
                f"JOIN {prj_dedup} pr ON pr.[number] = {obase} "
                f"WHERE w.[name] LIKE 'CK%:%' AND {ata_sql} IS NOT NULL "
                f"GROUP BY pr.[engine], w.[name], {ata_sql}", database=db, timeout=900, cap=0)
            for et, wname, ata, n in rows:
                ck = _ck_of(wname)
                if not et or not ck or ck == 'CK00' or not ata or ata.endswith('-00'):
                    continue            # engineweite Aufträge lehren kein Modul
                ata_map[_engine_family(str(et).strip())][ata][ck] += n or 0
            # sichtbar machen, wie viele Modul-Aufträge als Pseudo-ATA draußen bleiben
            _, prow, _ = _sql_query(
                f"SELECT COUNT(DISTINCT o.[sys_id]) FROM {cso['table']} o "
                f"JOIN {wbd} w ON w.[sys_id] = o.[{ce['wbs_sys_id']}] "
                f"WHERE w.[name] LIKE 'CK%:%' AND ({_ata_pseudo_sql(sp)})", database=db, timeout=900, cap=0)
            stats['ata_pseudo'] = int(prow[0][0] or 0) if prow else 0
            _, rows, _ = _sql_query(
                f"SELECT p.[{ecol}], {ata_sql}, COUNT(DISTINCT i.[sys_id]) FROM {table} i "
                f"JOIN {wbd} w ON w.[sys_id] = i.[{we['key']}] "
                f"JOIN {cso['table']} o ON o.[sys_id] = i.[{cso['key']}] "
                f"{_incident_project_join(prj, ex['wbs'])} "
                f"WHERE w.[name] LIKE 'CK00%' AND {ata_sql} IS NOT NULL "
                f"GROUP BY p.[{ecol}], {ata_sql}", database=db, timeout=300, cap=0)
            for et, ata, n in rows:
                if et and ata:
                    ata_ck00[_engine_family(str(et).strip())][ata] += n or 0
        except Exception as e:
            stats['ata_fehler'] = str(e)[:300]
            print(f'[matrix] Quelle 3 (ATA) übersprungen: {e}')

    # ── Rückrechnung ──────────────────────────────────────────────────────
    def bt_new():
        return {'bewertet': 0, 'mit_vorschlag': 0, 'eindeutig': 0,
                'treffer_eindeutig': 0, 'treffer_bester': 0}
    bt = {'gesamt': bt_new(), 'nur_ecms': bt_new(), 'familien': defaultdict(bt_new)}
    for (fam, pn, ck), n in agg.items():
        entry = mat[fam][pn]['ck']
        for key, ckmap, skip in (
                ('gesamt', entry, (ck, 1)),                                  # beide Quellen, ohne sich selbst
                ('nur_ecms', {k: [v[0], 0] for k, v in entry.items()}, None)):
            best, share, belege = _matrix_verdict(ckmap, skip)
            for b in ((bt[key], bt['familien'][fam]) if key == 'gesamt' else (bt[key],)):
                b['bewertet'] += n
                if best and belege >= MATRIX_MIN_N:
                    b['mit_vorschlag'] += n
                    b['treffer_bester'] += n if best == ck else 0
                    if share >= MATRIX_CLEAR:
                        b['eindeutig'] += n
                        b['treffer_eindeutig'] += n if best == ck else 0

    # Namen aus dem Modul-Katalog (kanonisch je Familie × CK)
    names = {}                 # „Familie|CK" = Hauptvorlage, „Familie|Vorlage|CK" = Nebenvorlage
    cat = module_catalog(build=False) or {}
    for m in sorted(cat.get('modules', []), key=lambda m: -m['projects']):
        tpl = m.get('template') or ''
        names.setdefault(f"{m['family']}|{tpl + '|' if tpl else ''}{m['ck']}", m['name'])

    # ── Modul (abgeleitet) für Incidents OHNE Modulbuchung ────────────────
    # (CK00, Projekt-/Sonder-WBS, ohne WBS). Reihenfolge nach Verlässlichkeit:
    # erst das betroffene Teil (Rückrechnung ~97 %), dann der ATA-Code des
    # Auftrags. Mehrere Teile mit verschiedenen Modulen → bewusst KEIN Modul.
    derived, dstats = {}, {'geprueft_mit_teil': 0, 'ueber_teil': 0, 'teile_widerspruechlich': 0,
                          'geprueft_mit_ata': 0, 'ueber_ata': 0}
    derived_ata = {}          # Incident → [Familie, ATA] (Auflösung beim Lesen)
    derived_fam = {}          # Incident → Familie (für die Obergruppe des abgeleiteten Moduls)
    derived_multi = {}        # Incident → [Familie, [CKs]]: Teile zeigen auf verschiedene Module —
                              # ob sie sich eine Obergruppe teilen, entscheidet _derived_all() beim Lesen
    derived_units = {}        # Incident → [Familie, Projekt, [Einheiten-Codes]] aus ECMS (Weg 0, exakt)
    unit_bt = {'bewertet': 0, 'gleich': 0, 'ungleich': 0, 'beispiele': []}
    not_modular = ("(w.[name] IS NULL OR w.[name] NOT LIKE 'CK%:%' OR w.[name] LIKE 'CK00%')")
    wbd = f"(SELECT [sys_id], MAX([name]) AS [name] FROM {we['table']} GROUP BY [sys_id])"

    def canon(fam, ck):
        return names.get(f'{fam}|{ck}') or ck
    try:
        if parts:
            _, rows, _ = _sql_query(
                f"SELECT i.[sys_id], p.[{ecol}], m.[teil_nr] FROM {table} i "
                f"LEFT JOIN {wbd} w ON w.[sys_id] = i.[{we['key']}] "
                f"JOIN {_incident_parts_sql(parts)} m ON m.[inc_sys_id] = i.[sys_id] "
                f"{_incident_project_join(prj, ex['wbs'])} "
                f"WHERE {not_modular} AND NULLIF(m.[teil_nr], '') IS NOT NULL",
                database=db, timeout=300, cap=0)
            per_inc = defaultdict(set)
            fam_of = {}
            for isid, et, pn in rows:
                if not et:
                    continue
                fam = _engine_family(str(et).strip())
                fam_of[isid] = fam
                e = mat.get(fam, {}).get(_pn(pn))
                best, share, belege = _matrix_verdict(e['ck']) if e else (None, 0, 0)
                per_inc[isid]            # auch Incidents ohne klares Teil zählen als geprüft
                if best and belege >= MATRIX_MIN_N and share >= MATRIX_CLEAR:
                    per_inc[isid].add(best)
            dstats['geprueft_mit_teil'] = len(per_inc)
            for isid, cks_ in per_inc.items():
                if len(cks_) == 1:
                    ck = next(iter(cks_))
                    derived[isid] = [canon(fam_of[isid], ck), 'abgeleitet über Teil']
                    derived_fam[isid] = fam_of[isid]
                elif len(cks_) > 1:
                    dstats['teile_widerspruechlich'] += 1
                    derived_multi[isid] = [fam_of[isid], sorted(cks_, key=_ck_sort)]
        # ── Weg 0: Einheit des betroffenen ECMS-Teils (module_group_serial_number) ──
        # Teil → seine ECMS-Zeile → Modul-SN „53X…" → Einheit 53X. Ist das Teil selbst
        # eine Einheit (SN „93T…"), zählt seine eigene SN. Kein Lernen, keine Statistik.
        ecm = ex.get('ecms')
        if (parts and parts.get('ecms_key') and ecm
                and {'module_group_serial_number', 'serial_number'} <= set(ecm['cols'])):
            ec = ecm['cols']
            code_m = STRUCT_CODE.format(x='ep.[mgsn]')
            code_s = STRUCT_CODE.format(x='ep.[sn]')
            _, rows, _ = _sql_query(
                f"SELECT DISTINCT i.[sys_id], p.[{ecol}], p.[number], w.[name], "
                f"CASE WHEN {code_m} THEN UPPER(LEFT(ep.[mgsn], 3)) "
                f"WHEN {code_s} THEN UPPER(LEFT(ep.[sn], 3)) END "
                f"FROM {table} i LEFT JOIN {wbd} w ON w.[sys_id] = i.[{we['key']}] "
                f"JOIN {_incident_parts_sql(parts)} m ON m.[inc_sys_id] = i.[sys_id] "
                f"JOIN (SELECT [sys_id], MAX([{ec['module_group_serial_number']}]) AS [mgsn], "
                f"MAX([{ec['serial_number']}]) AS [sn] FROM {ecm['table']} "
                f"WHERE [sys_id] IN (SELECT [{parts['ecms_key']}] FROM {parts['table']}) "
                f"GROUP BY [sys_id]) ep ON ep.[sys_id] = m.[ecms_sys_id] "
                f"{_incident_project_join(prj, ex['wbs'])}", database=db, timeout=600, cap=0)
            per = defaultdict(set)
            meta = {}
            for isid, et, proj, wname, code in rows:
                if not et or not code:
                    continue
                per[isid].add(code)
                meta[isid] = (_engine_family(str(et).strip()), proj or '', wname or '')
            for isid, codes in per.items():
                fam, proj, wname = meta[isid]
                ck = _ck_of(wname)
                if ck and ck != 'CK00':
                    # modulgenau gebucht → Rückrechnung: passt die Einheit zur Buchung?
                    if len(codes) == 1:
                        unit_bt['bewertet'] += 1
                        if next(iter(codes))[:2] == ck[2:]:
                            unit_bt['gleich'] += 1
                        else:
                            unit_bt['ungleich'] += 1
                            if len(unit_bt['beispiele']) < 8:
                                unit_bt['beispiele'].append([fam, ck, next(iter(codes))])
                else:
                    derived_units[isid] = [fam, proj, sorted(codes)]
            dstats['geprueft_mit_einheit'] = len(derived_units)
        if cso and {'serviceproduct', 'wbs_sys_id'} <= set(cso.get('extra', {})):
            sp  = f"o.[{cso['extra']['serviceproduct']}]"
            _, rows, _ = _sql_query(
                f"SELECT DISTINCT i.[sys_id], p.[{ecol}], {_ata_sql(sp)} "
                f"FROM {table} i LEFT JOIN {wbd} w ON w.[sys_id] = i.[{we['key']}] "
                f"JOIN {cso['table']} o ON o.[sys_id] = i.[{cso['key']}] "
                f"{_incident_project_join(prj, ex['wbs'])} "
                f"WHERE {not_modular} AND {_ata_sql(sp)} IS NOT NULL", database=db, timeout=300, cap=0)
            # Nur vormerken, welcher Incident welchen Modul-ATA trägt — WELCHES Modul
            # daraus folgt, entscheidet _ata_effective() beim Lesen. So wirken
            # fachliche Korrekturen sofort, ohne die Matrix neu aufzubauen.
            for isid, et, ata in rows:
                if not et or not ata or ata.endswith('-00') or isid in derived:
                    continue
                derived_ata.setdefault(isid, [_engine_family(str(et).strip()), ata])
            dstats['geprueft_mit_ata'] = len({r[0] for r in rows if r[2] and not r[2].endswith('-00')})
    except Exception as e:
        dstats['fehler'] = str(e)[:300]
        print(f'[matrix] Modul (abgeleitet) übersprungen: {e}')
    dstats['ueber_teil'] = sum(1 for v in derived.values() if v[1].endswith('Teil'))

    out_mat = {fam: {pn: {'bez': e['bez'], 'ck': {k: list(v) for k, v in e['ck'].items()}}
                     for pn, e in d.items()} for fam, d in mat.items()}
    fam_stats = []
    for fam, d in out_mat.items():
        clear = 0
        for e in d.values():
            best, share, belege = _matrix_verdict(e['ck'])
            clear += 1 if best and belege >= MATRIX_MIN_N and share >= MATRIX_CLEAR else 0
        fam_stats.append({'family': fam, 'teilenummern': len(d), 'eindeutig': clear,
                          'rueckrechnung': bt['familien'].get(fam, bt_new())})
    fam_stats.sort(key=lambda f: -f['teilenummern'])
    # Rückrechnung Quelle 3: jeden Modul-Auftrag aus seinem ATA vorhersagen,
    # ohne sich selbst (leave-one-out) — und was sie für CK00-Incidents brächte
    bt['ata'] = bt_new()
    ata_rec = {'ck00_mit_ata': 0, 'eindeutig': 0, 'mehrdeutig': 0, 'nur_engine_ata': 0}
    for fam, amap in ata_map.items():
        for ata, cks_ in amap.items():
            m = {k: [0, v] for k, v in cks_.items()}
            for ck, n in cks_.items():
                best, share, belege = _matrix_verdict(m, (ck, 1))
                b = bt['ata']
                b['bewertet'] += n
                if best and belege >= MATRIX_MIN_N:
                    b['mit_vorschlag'] += n
                    b['treffer_bester'] += n if best == ck else 0
                    if share >= MATRIX_CLEAR:
                        b['eindeutig'] += n
                        b['treffer_eindeutig'] += n if best == ck else 0
    for fam, amap in ata_ck00.items():
        for ata, n in amap.items():
            ata_rec['ck00_mit_ata'] += n
            if ata.endswith('-00'):
                ata_rec['nur_engine_ata'] += n        # Test Run, Build up … — kein Modul
                continue
            best, share, belege = _matrix_verdict({k: [0, v] for k, v in ata_map[fam].get(ata, {}).items()})
            if best and belege >= MATRIX_MIN_N:
                ata_rec['eindeutig' if share >= MATRIX_CLEAR else 'mehrdeutig'] += n
    stats.update({'teilenummern': sum(len(d) for d in out_mat.values()),
                  'sekunden': round(time.time() - t0, 1)})
    return {'ts': datetime.datetime.now().isoformat(timespec='seconds'), 'version': MATRIX_VERSION,
            'stats': stats, 'families': fam_stats, 'names': names,
            'backtest': {'gesamt': bt['gesamt'], 'nur_ecms': bt['nur_ecms'], 'ata': bt['ata']},
            'ata_rec': ata_rec,
            'ata': {f: {a: dict(c) for a, c in m.items()} for f, m in ata_map.items()},
            'ata_ck00': {f: dict(m) for f, m in ata_ck00.items()},
            'derived': derived, 'derived_ata': derived_ata, 'derived_fam': derived_fam,
            'derived_multi': derived_multi, 'derived_units': derived_units, 'unit_backtest': unit_bt,
            'derived_stats': dstats,
            'matrix': out_mat,
            'tree': {f: {ck: dict(asms) for ck, asms in cks.items()} for f, cks in tree.items()},
            'ck00_parts': {f: dict(d) for f, d in ck00_parts.items()},
            'rules': {'eindeutig_ab': MATRIX_CLEAR, 'min_belege': MATRIX_MIN_N}}


def module_matrix():
    """Matrix aus Speicher oder Platte — nie blockierend; der Bau läuft im Hintergrund."""
    m = _matrix
    if m['data'] is None:
        try:
            with open(MODULE_MATRIX_PATH, encoding='utf-8') as f:
                m['data'] = json.load(f)
        except Exception:
            pass
    return m['data']


def _matrix_upgrade(data):
    """Veralteten Cache im Hintergrund neu bauen (einmal, nicht bei Fehlern)."""
    if (data is not None and data.get('version') != MATRIX_VERSION
            and not _matrix['running'] and not _matrix['error']):
        module_matrix_start(force=True)


def module_matrix_start(force: bool = False) -> bool:
    """Bau im Hintergrund anstoßen. False, wenn schon einer läuft."""
    with _matrix_lock:
        if _matrix['running']:
            return False
        if module_matrix() is not None and not force:
            return False
        _matrix.update(running=True, error='', started=time.time())

    def run():
        try:
            db, table = _hub_source()
            data = module_matrix_build(db, table)
            _matrix.update(data=data, ts=time.time())
            try:
                os.makedirs(SQL_CACHE_DIR, exist_ok=True)
                tmp = MODULE_MATRIX_PATH + '.tmp'
                with open(tmp, 'w', encoding='utf-8') as f:
                    json.dump(data, f, ensure_ascii=False)
                os.replace(tmp, MODULE_MATRIX_PATH)
            except Exception:
                pass
        except Exception as e:
            _matrix['error'] = str(e)
            print(f'[matrix] Aufbau fehlgeschlagen: {e}')
        finally:
            _matrix['running'] = False
    threading.Thread(target=run, daemon=True).start()
    return True


def _matrix_candidates(e: dict, fam: str, names: dict) -> list:
    tot = sum(a + b for a, b in e['ck'].values()) or 1
    return sorted(({'ck': k, 'name': names.get(f'{fam}|{k}', k),
                    'n': a + b, 'ecms': a, 'inc': b, 'share': round(100.0 * (a + b) / tot, 1)}
                   for k, (a, b) in e['ck'].items()), key=lambda c: -c['n'])


@app.route('/api/modules/matrix')
def api_modules_matrix():
    if request.args.get('force') == '1':
        module_matrix_start(force=True)
    data = module_matrix()
    _matrix_upgrade(data)
    if data is None and not _matrix['running'] and not _matrix['error']:
        module_matrix_start()
    base = {'running': _matrix['running'], 'error': _matrix['error'],
            'laeuft_seit_s': int(time.time() - _matrix['started']) if _matrix['running'] else 0}
    if data is None:
        return jsonify({**base, 'status': 'building' if _matrix['running'] else 'missing'})
    return jsonify({**base, 'status': 'ok', 'ts': data['ts'], 'stats': data['stats'],
                    'families': data['families'], 'backtest': data['backtest'],
                    'ata_rec': _ata_rec(data), 'ata_og_backtest': _ata_og_backtest(data),
                    'derived_stats': _derived_counts(data),
                    'rules': data['rules']})


@app.route('/api/modules/suggest')
def api_modules_suggest():
    """Suche in der Matrix: Teilenummer (auch Teilstück) oder Wort aus der Bezeichnung."""
    data = module_matrix()
    if data is None:
        return jsonify({'error': 'Matrix noch nicht aufgebaut.', 'results': []})
    fam = request.args.get('family', '')
    q   = (request.args.get('q') or '').strip()
    if len(q) < 2:
        return jsonify({'results': []})
    qpn, qtx = _pn(q), q.lower()
    fams = [fam] if fam in data['matrix'] else list(data['matrix'])
    hits = []
    for f in fams:
        for pn, e in data['matrix'][f].items():
            if pn == qpn:
                rank = 0
            elif pn.startswith(qpn):
                rank = 1
            elif qpn in pn:
                rank = 2
            elif qtx in (e['bez'] or '').lower():
                rank = 3
            else:
                continue
            cands = _matrix_candidates(e, f, data['names'])
            n = sum(c['n'] for c in cands)
            top = cands[0] if cands else None
            verdict = ('wenig Belege' if n < MATRIX_MIN_N else
                       'eindeutig' if top and top['share'] >= 100 * MATRIX_CLEAR else 'mehrdeutig')
            hits.append({'family': f, 'part': pn, 'bez': e['bez'], 'n': n,
                         'verdict': verdict, 'candidates': cands[:5], '_r': (rank, -n)})
    hits.sort(key=lambda h: h.pop('_r'))
    # passende Module aus dem Katalog („HPT", „Fan Case") — für Befunde ohne Teilenummer
    mods = []
    if not qpn.isdigit():
        for k, v in data['names'].items():
            f, ck = k.split('|', 1)
            if (not fam or f == fam) and qtx in v.lower():
                mods.append({'family': f, 'ck': ck, 'name': v})
    # Quelle 3a: ATA-Code im Suchtext („72-23", „G57-72-23-31I001")
    ata_hits = []
    m = _ATA_IN_TEXT.search(q)
    if m:
        ata = f'{m.group(1)}-{m.group(2)}'
        ovr = ata_overrides_load()
        for f in fams:
            r = _ata_candidates(data, f, ata, ovr)
            if r:
                ata_hits.append(r)
    # Quelle 3b: MPL-Katalog — Teile ohne ECMS-Einbau bekommen ihr Modul über
    # das ATA-Kapitel ihrer IPC-Nummer (nur bei Teilenummer-artiger Eingabe)
    ipc = []
    ex = None
    try:
        db, table = _hub_source()
        ex = _incident_extras(db, table)
    except Exception:
        pass
    if ex and ex.get('mpl') and len(qpn) >= 3 and ' ' not in q:
        c = ex['mpl']['cols']
        bez = f"[{c['name_of_part']}]" if 'name_of_part' in c else 'NULL'
        try:
            _, rows, _ = _sql_query(
                f"SELECT TOP 40 [{c['engine_type']}], [{c['part_number']}], "
                f"[{c['catalog_sequence_number']}], {bez} FROM {ex['mpl']['table']} "
                f"WHERE [{c['part_number']}] LIKE ?", [q + '%'], database=db, timeout=30, cap=0)
            seen_ = set()
            for et, pn, csn, bz in rows:
                f, ata = _engine_family(str(et or '').strip()), _ata_of_csn(csn)
                if (fam and f != fam) or not ata or (f, _pn(pn), ata) in seen_:
                    continue
                seen_.add((f, _pn(pn), ata))
                r = _ata_candidates(data, f, ata)
                ipc.append({'part': _pn(pn), 'bez': bz or '', 'family': f, 'ata': ata,
                            'csn': str(csn or '').strip(), **({k: r[k] for k in ('verdict', 'candidates', 'n')}
                                                              if r else {'verdict': 'ATA nicht gelernt',
                                                                         'candidates': [], 'n': 0})})
        except Exception as e:
            print(f'[matrix] MPL-Suche: {e}')
    return jsonify({'results': hits[:60], 'more': max(0, len(hits) - 60),
                    'modules': sorted(mods, key=lambda m: (m['family'], _ck_sort(m['ck'])))[:30],
                    'ata': ata_hits, 'ipc': ipc[:20]})


_ATA_IN_TEXT = re.compile(r'(?<!\d)(7\d)[-\s]?(\d{2})(?:[-\s]?\d{2})?(?!\d)')


# ── ATA-Korrekturen je Familie ───────────────────────────────────────────────
# Die gelernte Zuordnung ATA-Abschnitt → Modul kann fachlich falsch oder zu
# vorsichtig sein (72-51 teilen sich CK53 und CK55 — laut Handbuch ist es aber
# eindeutig CK53). Korrekturen liegen in einer eigenen Datei, wirken sofort
# (Suche, Prüftabelle, „Modul (abgeleitet)") und überschreiben nie die Lerndaten:
# die gelernten Anteile bleiben daneben sichtbar. Im Demo-Modus eigene Datei.
ATA_OVERRIDES_PATH = (os.path.join(SQL_CACHE_DIR, 'ata_overrides.json') if MTUHUB_DEMO
                      else os.path.join(BASE_DIR, 'ata_overrides.json'))
_ata_ovr_lock = threading.Lock()
_ATA_KEY = re.compile(r'^7\d-\d{2}$')


def ata_overrides_load() -> dict:
    try:
        with open(ATA_OVERRIDES_PATH, encoding='utf-8') as f:
            data = json.load(f)
            return data if isinstance(data, dict) else {}
    except Exception:
        return {}


def ata_overrides_save(data: dict):
    os.makedirs(os.path.dirname(ATA_OVERRIDES_PATH), exist_ok=True)
    tmp = ATA_OVERRIDES_PATH + '.tmp'
    with open(tmp, 'w', encoding='utf-8') as f:
        json.dump(data, f, indent=2, ensure_ascii=False, sort_keys=True)
    os.replace(tmp, ATA_OVERRIDES_PATH)


def _og_map(fam: str, tpl: str = '') -> dict:
    """CK → Obergruppe einer Familie aus dem Modul-Katalog (WBS-Mittelsegment), je Vorlage
    ('' = Hauptvorlage). Leer, solange der Katalog keine Obergruppen kennt."""
    cat = module_catalog(build=False) or {}
    out, best = {}, {}
    for m in cat.get('modules', []):
        if (m['family'] == fam and (m.get('template') or '') == tpl and m.get('og')
                and m['projects'] > best.get(m['ck'], -1)):
            out[m['ck']], best[m['ck']] = m['og'], m['projects']
    return out


def _og_head(fam: str, og: str) -> str:
    """„Kopf CK20: Fan Module" — Kopfknoten, KEIN Name der Gruppe (SAP führt keinen)."""
    cat = module_catalog(build=False) or {}
    g = next((g for g in (cat.get('obergruppen') or {}).get(fam, []) if g['og'] == og), None)
    return f"Kopf {g['head']['name']}" if g and g.get('head') else ''


def _og_open_label(og: str) -> str:
    return f'OG {og} (Modul offen)'


def _og_votes(cks: dict, ogm: dict) -> dict:
    """Aufträge je Modul → Aufträge je Obergruppe. Module ohne bekannte OG zählen
    als „–" mit und drücken den Anteil — lieber offen als geraten."""
    votes = defaultdict(int)
    for ck, n in cks.items():
        votes[ogm.get(ck, '–')] += n
    return votes


def _ata_effective(data: dict, fam: str, ata: str, ovr: dict = None, ogm: dict = None) -> dict:
    """Wirksame Zuordnung: Korrektur vor Gelerntem; ist das Modul mehrdeutig, aber
    die Obergruppe eindeutig, gilt die Obergruppe (Rückfallebene).
    → {'ck', 'og', 'verdict', 'source': gelernt|korrigiert|ignoriert, 'override', 'og_share'}"""
    ovr = ata_overrides_load() if ovr is None else ovr
    ogm = _og_map(fam) if ogm is None else ogm
    o = ovr.get(fam, {}).get(ata)
    if o:
        if o.get('ignore'):
            return {'ck': None, 'og': None, 'verdict': 'ignoriert', 'source': 'ignoriert', 'override': o}
        if o.get('ck'):
            return {'ck': o['ck'], 'og': ogm.get(o['ck']), 'verdict': 'korrigiert', 'source': 'korrigiert',
                    'override': o}
        if o.get('og'):
            return {'ck': None, 'og': o['og'], 'verdict': 'korrigiert (Obergruppe)', 'source': 'korrigiert',
                    'override': o}
    cks = data.get('ata', {}).get(fam, {}).get(ata, {})
    best, share, belege = _matrix_verdict({k: [0, v] for k, v in cks.items()})
    if not best or belege < MATRIX_MIN_N:
        return {'ck': None, 'og': None, 'verdict': 'wenig Belege' if belege else 'nicht gelernt',
                'source': 'gelernt', 'override': None}
    if share >= MATRIX_CLEAR:
        return {'ck': best, 'og': ogm.get(best), 'verdict': 'eindeutig', 'source': 'gelernt', 'override': None}
    # Rückfallebene: 72-21 verteilt sich bei CFM auf CK21/80/81 (alle OG 20) + CK11
    votes = _og_votes(cks, ogm)
    og, n = max(((k, v) for k, v in votes.items() if k != '–'), key=lambda kv: kv[1], default=(None, 0))
    og_share = n / belege if belege else 0
    if og and og_share >= MATRIX_CLEAR:
        return {'ck': None, 'og': og, 'verdict': 'nur Obergruppe', 'source': 'gelernt', 'override': None,
                'og_share': round(og_share, 3)}
    return {'ck': None, 'og': None, 'verdict': 'mehrdeutig', 'source': 'gelernt', 'override': None,
            'og_share': round(og_share, 3) if og else None}


def _ata_og_backtest(data: dict) -> dict:
    """Rückrechnung der Rückfallebene: jeden Modul-Auftrag, dessen ATA KEIN
    eindeutiges Modul ergibt, aus den übrigen Aufträgen (leave-one-out) auf eine
    Obergruppe vorhersagen und mit der OG seines tatsächlichen Moduls vergleichen."""
    bt = {'bewertet': 0, 'eindeutig': 0, 'treffer': 0}
    for fam, amap in data.get('ata', {}).items():
        ogm = _og_map(fam)
        if not ogm:
            continue
        for ata, cks in amap.items():
            for ck, n in cks.items():
                if ck not in ogm:
                    continue
                rest = dict(cks)
                rest[ck] -= 1
                best, share, belege = _matrix_verdict({k: [0, v] for k, v in rest.items() if v > 0})
                if not best or belege < MATRIX_MIN_N or share >= MATRIX_CLEAR:
                    continue             # zu wenig Belege, oder die Modulebene trägt schon
                bt['bewertet'] += n
                votes = _og_votes({k: v for k, v in rest.items() if v > 0}, ogm)
                og, m = max(((k, v) for k, v in votes.items() if k != '–'),
                            key=lambda kv: kv[1], default=(None, 0))
                if og and m / belege >= MATRIX_CLEAR:
                    bt['eindeutig'] += n
                    bt['treffer'] += n if og == ogm[ck] else 0
    return bt


def _derived_all(data: dict) -> dict:
    """Incident → [Modulname, Herkunft], vom Verlässlichsten zum Gröbsten:
      1. Teil zeigt eindeutig auf EIN Modul                     → Modul (fest aus der Matrix)
      2. Teile zeigen auf mehrere Module derselben Obergruppe:
         trifft der ATA-Code des Auftrags eins davon            → dieses Modul
         sonst                                                  → „OG 20 (Modul offen)" über Teil
         (Teile aus verschiedenen Obergruppen bleiben offen — auch der ATA-Code
          überstimmt sie nicht, das wären zwei widersprechende Belege)
      3. ATA-Code: Modul eindeutig → Modul, sonst Obergruppe eindeutig → „OG … (Modul offen)"
    ATA und Obergruppen werden beim Lesen aufgelöst, damit Korrekturen und ein
    neuer Katalog sofort wirken."""
    out = {}
    ovr, ogms = ata_overrides_load(), {}
    ata_of = data.get('derived_ata', {})
    multi = data.get('derived_multi', {})
    # Weg 0: Einheit(en) des ECMS-Teils über den Einbaubaum — vor allem anderen
    sdata, trees = struct_catalog(), {}
    blocked = set()
    if sdata is None and data.get('derived_units'):
        struct_catalog_start()                # Baum fehlt → im Hintergrund bauen, bis dahin ohne Vorfahren
    for isid, (fam, proj, codes) in data.get('derived_units', {}).items():
        tree = trees.get((fam, proj))
        if tree is None:
            tree = trees[(fam, proj)] = _struct_tree(fam, proj, sdata) if sdata else {}
        code = codes[0] if len(codes) == 1 else _struct_lca(tree, codes) if tree else ''
        if not code:
            # Einheiten aus verschiedenen Ästen (nur die Engine gemeinsam): die exakten
            # Belege widersprechen sich → offen lassen. Nur ohne Baum dürfen die
            # schwächeren Wege ran, sonst würden sie den Widerspruch überstimmen.
            if tree:
                ogm = ogms.setdefault(fam, _og_map(fam))
                ogs = {ogm.get('CK' + c[:2]) for c in codes}
                if len(ogs) == 1 and None not in ogs:
                    out[isid] = [_og_open_label(next(iter(ogs))),
                                 'abgeleitet über ECMS-Einheiten, nur Obergruppe']
                else:
                    blocked.add(isid)
            continue
        u = (tree.get('units') or {}).get(code)
        ck = 'CK' + code[:2]
        name = (data['names'].get(f'{fam}|{ck}') if not tree or tree.get('main')
                else data['names'].get(f"{fam}|{tree['tpl']}|{ck}")) or (f"{ck}: {u['name']}" if u else ck)
        out[isid] = [name, 'abgeleitet über ECMS-Einheit' if len(codes) == 1
                     else 'abgeleitet über ECMS-Einheiten (gemeinsamer Vorfahr)']
    for isid, v in data.get('derived', {}).items():
        if isid not in blocked:
            out.setdefault(isid, v)
    for isid, (fam, cks) in multi.items():
        if isid in out or isid in blocked:
            continue
        ogm = ogms.setdefault(fam, _og_map(fam))
        ogs = {ogm.get(ck) for ck in cks}
        if len(ogs) != 1 or None in ogs:
            continue
        og = next(iter(ogs))
        if isid in ata_of:
            eff = _ata_effective(data, ata_of[isid][0], ata_of[isid][1], ovr, ogm)
            if eff['ck'] in cks:
                kor = ' (korrigiert)' if eff['source'] == 'korrigiert' else ''
                out[isid] = [data['names'].get(f"{fam}|{eff['ck']}", eff['ck']),
                             'abgeleitet über Teile + ATA-Code' + kor]
                continue
        out[isid] = [_og_open_label(og), 'abgeleitet über Teil, nur Obergruppe']
    for isid, (fam, ata) in ata_of.items():
        if isid in out or isid in multi or isid in blocked:
            continue
        ogm = ogms.setdefault(fam, _og_map(fam))
        eff = _ata_effective(data, fam, ata, ovr, ogm)
        kor = ' (korrigiert)' if eff['source'] == 'korrigiert' else ''
        if eff['ck']:
            out[isid] = [data['names'].get(f"{fam}|{eff['ck']}", eff['ck']), 'abgeleitet über ATA-Code' + kor]
        elif eff['og']:
            out[isid] = [_og_open_label(eff['og']), 'abgeleitet über ATA-Code, nur Obergruppe' + kor]
    return out


def _derived_counts(data: dict) -> dict:
    """Kennzahlen zu „Modul (abgeleitet)" nach Herkunft — mit aktuellen Korrekturen."""
    c = defaultdict(int)
    for _, src in _derived_all(data).values():
        c[src.replace(' (korrigiert)', '')] += 1
    return {**(data.get('derived_stats') or {}),
            'ueber_ata': c['abgeleitet über ATA-Code'],
            'ueber_ata_og': c['abgeleitet über ATA-Code, nur Obergruppe'],
            'ueber_teil_og': c['abgeleitet über Teil, nur Obergruppe'],
            'ueber_teil_ata': c['abgeleitet über Teile + ATA-Code'],
            'ueber_einheit': c['abgeleitet über ECMS-Einheit'],
            'ueber_einheit_lca': c['abgeleitet über ECMS-Einheiten (gemeinsamer Vorfahr)'],
            'ueber_einheit_og': c['abgeleitet über ECMS-Einheiten, nur Obergruppe'],
            'einheit_widerspruch': sum(1 for v in data.get('derived_units', {}).values() if len(v[2]) > 1)
                                   - c['abgeleitet über ECMS-Einheiten (gemeinsamer Vorfahr)']
                                   - c['abgeleitet über ECMS-Einheiten, nur Obergruppe'],
            'ueber_teil': c['abgeleitet über Teil'],
            'unit_backtest': data.get('unit_backtest')}


def _derived_og_all(data: dict) -> dict:
    """Incident → [Obergruppe, Herkunft] für alle abgeleiteten Incidents: aus dem
    abgeleiteten Modul (dessen OG) oder direkt aus der ATA-Rückfallebene."""
    fam_of = {isid: fam for isid, (fam, _) in data.get('derived_ata', {}).items()}
    fam_of.update({isid: fam for isid, (fam, _) in data.get('derived_multi', {}).items()})
    fam_of.update({isid: v[0] for isid, v in data.get('derived_units', {}).items()})
    fam_of.update(data.get('derived_fam', {}))
    out, ogms = {}, {}
    for isid, (name, src) in _derived_all(data).items():
        fam = fam_of.get(isid)
        if not fam:
            continue
        ogm = ogms.setdefault(fam, _og_map(fam))
        m = re.match(r'OG (\S+) \(Modul offen\)$', name)
        og = m.group(1) if m else ogm.get(_ck_of(name))
        if og:
            out[isid] = [og, src]
    return out


def _ata_rec(data: dict) -> dict:
    """Was Quelle 3 für CK00-Incidents bringt — mit den aktuellen Korrekturen."""
    rec = {'ck00_mit_ata': 0, 'eindeutig': 0, 'nur_obergruppe': 0, 'mehrdeutig': 0, 'nur_engine_ata': 0,
           'davon_korrigiert': 0}
    ovr = ata_overrides_load()
    for fam, amap in data.get('ata_ck00', {}).items():
        ogm = _og_map(fam)
        for ata, n in amap.items():
            rec['ck00_mit_ata'] += n
            if ata.endswith('-00'):
                rec['nur_engine_ata'] += n
                continue
            eff = _ata_effective(data, fam, ata, ovr, ogm)
            if eff['ck'] or eff['og']:
                rec['eindeutig' if eff['ck'] else 'nur_obergruppe'] += n
                rec['davon_korrigiert'] += n if eff['source'] == 'korrigiert' else 0
            elif eff['verdict'] == 'mehrdeutig':
                rec['mehrdeutig'] += n
    return rec


def _ata_candidates(data: dict, fam: str, ata: str, ovr: dict = None) -> dict:
    """Quelle 3: ATA-Abschnitt → Modul-Kandidaten einer Familie (gelernt) plus Korrektur."""
    cks = data.get('ata', {}).get(fam, {}).get(ata, {})
    ogm = _og_map(fam)
    eff = _ata_effective(data, fam, ata, ovr, ogm)
    tot = sum(cks.values())
    og_cands = sorted(({'og': k, 'n': v, 'share': round(100.0 * v / tot, 1),
                        'head': _og_head(fam, k) if k != '–' else ''}
                       for k, v in _og_votes(cks, ogm).items()), key=lambda c: -c['n']) if tot and ogm else []
    if not tot and not eff['override']:
        return None
    cands = sorted(({'ck': k, 'name': data['names'].get(f'{fam}|{k}', k), 'n': v,
                     'share': round(100.0 * v / tot, 1)} for k, v in cks.items()), key=lambda c: -c['n'])
    if eff['source'] == 'korrigiert':
        # das korrigierte Modul zuerst, auch wenn die Aufträge es nie zeigen
        own = next((c for c in cands if c['ck'] == eff['ck']), None)
        cands = [own or {'ck': eff['ck'], 'name': data['names'].get(f"{fam}|{eff['ck']}", eff['ck']),
                         'n': 0, 'share': 0.0}] + [c for c in cands if c['ck'] != eff['ck']]
    for c in cands:
        c['og'] = ogm.get(c['ck'])
    return {'family': fam, 'ata': ata, 'n': tot, 'verdict': eff['verdict'], 'source': eff['source'],
            'ck': eff['ck'], 'og': eff['og'], 'og_head': _og_head(fam, eff['og']) if eff['og'] else '',
            'og_candidates': og_cands[:4],
            'override': eff['override'], 'candidates': cands[:5],
            'learned_best': (cands and max(cands, key=lambda c: c['n'])['ck']) or None,
            'ck00_incidents': data.get('ata_ck00', {}).get(fam, {}).get(ata, 0)}


def _ata_of_csn(csn) -> str:
    """IPC-Nummer „724100 02   060" → „72-41"."""
    d = re.sub(r'\D', '', str(csn or ''))
    return f'{d[:2]}-{d[2:4]}' if len(d) >= 4 and d[0] == '7' else ''


@app.route('/api/modules/ata')
def api_modules_ata():
    """Gelernte Zuordnung ATA-Abschnitt → Modul einer Familie — zum fachlichen Prüfen."""
    data = module_matrix()
    fam  = request.args.get('family', '')
    if data is None:
        return jsonify({'error': 'Matrix noch nicht aufgebaut.', 'rows': []})
    ovr  = ata_overrides_load()
    keys = sorted(set(data.get('ata', {}).get(fam, {})) | set(ovr.get(fam, {})))
    rows = [_ata_candidates(data, fam, a, ovr) for a in keys]
    mods = sorted(((k.split('|', 1)[1], v) for k, v in data.get('names', {}).items()
                   if k.startswith(fam + '|') and k.count('|') == 1 and not k.endswith('|CK00')),
                  key=lambda x: _ck_sort(x[0]))
    cat = module_catalog(build=False) or {}
    return jsonify({'family': fam, 'rows': [r for r in rows if r],
                    'modules': [{'ck': c, 'name': n} for c, n in mods],
                    'obergruppen': [{'og': g['og'], 'head': _og_head(fam, g['og']), 'cks': g['cks']}
                                    for g in (cat.get('obergruppen') or {}).get(fam, [])
                                    if not g.get('engine_level')],
                    'korrekturen': len(ovr.get(fam, {}))})


@app.route('/api/modules/ata/override', methods=['POST'])
def api_modules_ata_override():
    """Korrektur setzen ({ck} oder {ignore: true}) oder entfernen ({reset: true})."""
    d    = request.get_json(silent=True) or {}
    fam  = str(d.get('family') or '').strip()
    ata  = str(d.get('ata') or '').strip()
    m    = _ATA_IN_TEXT.search(ata)
    ata  = f'{m.group(1)}-{m.group(2)}' if m else ata
    if not fam or not _ATA_KEY.match(ata):
        return jsonify({'error': 'Familie und ATA-Abschnitt (z. B. 72-51) angeben.'}), 400
    if ata.endswith('-00'):
        return jsonify({'error': '72-00 ist die ganze Engine — daraus lässt sich kein Modul ableiten.'}), 400
    with _ata_ovr_lock:
        ovr = ata_overrides_load()
        if d.get('reset'):
            ovr.get(fam, {}).pop(ata, None)
            if fam in ovr and not ovr[fam]:
                ovr.pop(fam)
        else:
            ck = str(d.get('ck') or '').strip().upper()
            og = str(d.get('og') or '').strip()
            if not d.get('ignore') and not re.match(r'^CK\d+$', ck) and not re.match(r'^\d{2}$', og):
                return jsonify({'error': 'Modul (CKnn) oder Obergruppe (zweistellig) wählen oder „ignorieren".'}), 400
            ovr.setdefault(fam, {})[ata] = {
                **({'ignore': True} if d.get('ignore') else {'ck': ck} if ck else {'og': og}),
                'note': str(d.get('note') or '').strip()[:300],
                'ts': datetime.datetime.now().isoformat(timespec='seconds')}
        ata_overrides_save(ovr)
    return jsonify({'ok': True, 'override': ovr.get(fam, {}).get(ata)})


@app.route('/api/modules/sheet')
def api_modules_sheet():
    """Aushang je Familie: je Modul die häufigsten eindeutig zuordenbaren Teile."""
    data = module_matrix()
    fam  = request.args.get('family', '')
    if data is None or fam not in data['matrix']:
        return jsonify({'error': 'Matrix oder Familie nicht vorhanden.', 'modules': []})
    per = defaultdict(list)
    for pn, e in data['matrix'][fam].items():
        best, share, n = _matrix_verdict(e['ck'])
        if best and n >= MATRIX_MIN_N and share >= MATRIX_CLEAR:
            per[best].append({'part': pn, 'bez': e['bez'], 'n': n, 'share': round(100 * share)})
    top = int(request.args.get('top') or 15)
    return jsonify({'family': fam, 'ts': data['ts'], 'modules': [
        {'ck': ck, 'name': data['names'].get(f'{fam}|{ck}', ck),
         'parts': sorted(v, key=lambda x: -x['n'])[:top], 'weitere': max(0, len(v) - top)}
        for ck, v in sorted(per.items(), key=lambda kv: _ck_sort(kv[0]))]})


# ── Hierarchie-Graph: Familie → CK-Modul → Baugruppe → Teilenummer ─────────────
# Gleiche Idee wie das Dependency-Overlay in RFX_Battlefield: vis-network,
# Knoten-ID = voller Pfad (dieselbe Teilenummer steckt in mehreren Baugruppen),
# Tiefe 1/2/3, ab GRAPH_MAX_NODES erst fragen. Statt Messwerten stehen
# Incidents an den Knoten. Die Einbauposition ist bewusst keine eigene Ebene —
# sie steht im Popup, sonst wird der Baum unlesbar breit.
GRAPH_MAX_NODES = 300
# Ausreißer: Einbauten, die unter einem anderen Modul gebucht sind als die
# Mehrheit (Abfrage F: ~2 %). Ohne Filter taucht jede Fehlbuchung als eigener
# Ast auf — eine „AGB INSTALLATION" unter CK49. Standard: ausblenden und zählen.
GRAPH_STRAY_ASM = 0.10       # Baugruppe mit < 10 % ihrer Einbauten in diesem Modul


def _graph_ck_info(fam: str) -> dict:
    """CK → {name, incidents, projects} aus dem Modul-Katalog (auch Module ohne Einbau)."""
    out = {}
    cat = module_catalog(build=False) or {}
    for m in sorted(cat.get('modules', []), key=lambda m: -m['projects']):
        if m['family'] != fam or m.get('template'):
            continue                          # Hierarchie/Heatmap zeigen die Hauptvorlage
        e = out.setdefault(m['ck'], {'name': m['name'], 'incidents': 0, 'projects': 0,
                                     'og': m.get('og'), 'og_share': m.get('og_share'),
                                     'og_other': m.get('og_other') or [], 'kind': m.get('kind', ''),
                                     'orders': m.get('orders') or []})
        e['incidents'] += m['incidents']
        e['projects'] += m['projects']
    return out


def _graph_og_info(fam: str) -> dict:
    """OG → {head, cks, incidents} aus dem Katalog."""
    cat = module_catalog(build=False) or {}
    return {g['og']: g for g in (cat.get('obergruppen') or {}).get(fam, [])}


# Obergruppe statt geratener Sektion: das Mittelsegment des WBS-Codes (siehe
# _WBS_OG_SQL). Früher per Regex aus dem Modulnamen — das ordnete z. B. „NMP Core"
# dem Fan zu und HPT/LPT gemeinsam der „Turbine", obwohl CFM 53X unter OG 50 bucht.
OG_NONE = 'ohne OG'


def _og_label(og: str, g: dict) -> str:
    if og == OG_NONE:
        return 'ohne Obergruppe'
    head = (g or {}).get('head')
    return f'OG {og}' + (f"\nKopf {head['ck']}: {_short(head['name'])[:22]}" if head else '')


def _short(name: str) -> str:
    return re.sub(r'^\s*CK\d+\s*:\s*', '', name or '')


# ── Bereich des Graphen: Familie je Jahr · ein Projekt · eine ESN ──────────────
# Das Gerüst (Module → Baugruppen → Teile) bleibt immer der Familienbaum aus
# ECMS; nur die Zahlen an den Knoten wechseln. So sieht jedes Projekt gleich
# aufgebaut aus und ist mit anderen vergleichbar.
#   Jahr    — alle Familien × Jahr × CK (× Teil) in einem Durchlauf, gecacht:
#             Jahr- und Familienwechsel kosten danach keine Abfrage mehr.
#   Projekt — Projektnummer = WBS-Basis (L.20000 aus L.20000.20.80), live.
#   ESN     — alle Projekte mit dieser ESN (eine Engine kommt mit neuer
#             Projektnummer wieder) → je Knoten Aufschlüsselung je Projekt,
#             Wiederholer = Knoten mit Incidents in ≥ 2 Projekten.
# ECMS zeigt im Projekt/ESN-Bereich, welche Teile in DIESER Engine verbaut
# waren; der Rest des Familienbaums bleibt sichtbar, aber blass.
GRAPH_YEARS_PATH = os.path.join(SQL_CACHE_DIR, 'module_graph_years.json')
GRAPH_YEARS_TTL  = 6 * 3600
GRAPH_SCOPE_TTL  = 600
GRAPH_ESN_MAX    = 20          # mehr Projekte je ESN sind ein Datenfehler (z. B. ESN leer)

_graph_years = {'data': None, 'ts': 0.0, 'running': False, 'error': '', 'started': 0.0}
_graph_years_lock = threading.Lock()
_graph_scope_cache = {}        # (Art, Wert) → (ts, Ergebnis)


def _graph_wbd(we: dict) -> str:
    return f"(SELECT [sys_id], MAX([name]) AS [name] FROM {we['table']} GROUP BY [sys_id])"


def _graph_date_col(ex: dict):
    inc = ex.get('inc') or {}
    return next((inc[c] for c in ('sys_created_on', 'opened_at') if c in inc), None)


def graph_years_build(db: str, table: str) -> dict:
    """Familie → Jahr → {'ck': {CK: n}, 'part': {'CK|Teil': n}} über alle Incidents."""
    ex = _incident_extras(db, table)
    prj, we, parts = ex.get('project'), ex.get('wbs_elem'), ex.get('parts')
    dc = _graph_date_col(ex)
    if not prj or not we or not dc:
        raise RuntimeError('Für den Jahresfilter fehlen Projekt-, WBS-Tabelle oder Meldedatum.')
    t0, ecol, yr = time.time(), prj['cols']['engine'], f'YEAR(i.[{dc}])'
    out = defaultdict(lambda: defaultdict(lambda: {'ck': defaultdict(int), 'part': defaultdict(int)}))
    _, rows, _ = _sql_query(
        f"SELECT p.[{ecol}], {yr}, w.[name], COUNT(DISTINCT i.[sys_id]) FROM {table} i "
        f"JOIN {_graph_wbd(we)} w ON w.[sys_id] = i.[{we['key']}] "
        f"{_incident_project_join(prj, ex['wbs'])} "
        f"WHERE w.[name] LIKE 'CK%:%' AND i.[{dc}] IS NOT NULL "
        f"GROUP BY p.[{ecol}], {yr}, w.[name]", database=db, timeout=300, cap=0)
    for et, y, wname, n in rows:
        ck = _ck_of(wname)
        if et and y and ck:
            out[_engine_family(str(et).strip())][str(int(y))]['ck'][ck] += n or 0
    if parts:
        _, rows, _ = _sql_query(
            f"SELECT p.[{ecol}], {yr}, m.[teil_nr], w.[name], COUNT(DISTINCT i.[sys_id]) FROM {table} i "
            f"JOIN {_graph_wbd(we)} w ON w.[sys_id] = i.[{we['key']}] "
            f"JOIN {_incident_parts_sql(parts)} m ON m.[inc_sys_id] = i.[sys_id] "
            f"{_incident_project_join(prj, ex['wbs'])} "
            f"WHERE w.[name] LIKE 'CK%:%' AND i.[{dc}] IS NOT NULL AND NULLIF(m.[teil_nr], '') IS NOT NULL "
            f"GROUP BY p.[{ecol}], {yr}, m.[teil_nr], w.[name]", database=db, timeout=300, cap=0)
        for et, y, pn, wname, n in rows:
            ck = _ck_of(wname)
            if et and y and ck:
                out[_engine_family(str(et).strip())][str(int(y))]['part'][f'{ck}|{_pn(pn)}'] += n or 0
    return {'ts': datetime.datetime.now().isoformat(timespec='seconds'),
            'sekunden': round(time.time() - t0, 1),
            'families': {f: {y: {'ck': dict(v['ck']), 'part': dict(v['part'])} for y, v in ys.items()}
                         for f, ys in out.items()}}


def graph_years(start: bool = True):
    """Jahres-Zahlen aus Speicher/Platte; fehlt oder veraltet → Bau im Hintergrund."""
    g = _graph_years
    if g['data'] is None:
        try:
            with open(GRAPH_YEARS_PATH, encoding='utf-8') as f:
                g['data'] = json.load(f)
            g['ts'] = os.path.getmtime(GRAPH_YEARS_PATH)
        except Exception:
            pass
    if start and (g['data'] is None or time.time() - g['ts'] > GRAPH_YEARS_TTL):
        graph_years_start()
    return g['data']


def graph_years_start():
    with _graph_years_lock:
        if _graph_years['running']:
            return
        _graph_years.update(running=True, error='', started=time.time())

    def run():
        try:
            db, table = _hub_source()
            data = graph_years_build(db, table)
            _graph_years.update(data=data, ts=time.time())
            try:
                os.makedirs(SQL_CACHE_DIR, exist_ok=True)
                tmp = GRAPH_YEARS_PATH + '.tmp'
                with open(tmp, 'w', encoding='utf-8') as f:
                    json.dump(data, f, ensure_ascii=False)
                os.replace(tmp, GRAPH_YEARS_PATH)
            except Exception:
                pass
        except Exception as e:
            _graph_years['error'] = str(e)[:300]
            print(f'[graph] Jahreszahlen nicht gebaut: {e}')
        finally:
            _graph_years['running'] = False
    threading.Thread(target=run, daemon=True).start()


GRAPH_INDEX_TTL = 3600
_graph_index = {'data': None, 'ts': 0.0}
_graph_index_lock = threading.Lock()


def _norm_key(v) -> str:
    return re.sub(r'\s+', '', str(v or '')).upper()


def graph_project_index() -> list:
    """Alle Projekte (neuester Datensatz) mit ESN, Typ, Kunde + Incidents und erstem
    Meldejahr — für die Live-Vorschläge. Zwei Abfragen, danach sucht Python im Speicher
    (jeder Tastendruck eine SQL-Abfrage wäre auf 100 k Projekten zu träge)."""
    g = _graph_index
    if g['data'] is not None and time.time() - g['ts'] < GRAPH_INDEX_TTL:
        return g['data']
    with _graph_index_lock:
        if g['data'] is not None and time.time() - g['ts'] < GRAPH_INDEX_TTL:
            return g['data']
        db, table = _hub_source()
        ex = _incident_extras(db, table)
        prj = ex.get('project')
        if not prj:
            raise RuntimeError('Keine Projekttabelle — Vorschläge nicht möglich.')
        pc = prj['cols']
        cols = [k for k in ('engine', 'esn', 'customer') if k in pc]
        src = _dedup(prj, 'number', ', '.join(['[number]'] + [f'[{pc[k]}]' for k in cols]))
        _, rows, _ = _sql_query(f"SELECT d.[number], " + ', '.join(f'd.[{pc[k]}]' for k in cols)
                                + f" FROM {src} d WHERE d.rn = 1", database=db, timeout=120, cap=0)
        inc = {}
        dc, wcol = _graph_date_col(ex), ex.get('wbs')
        if wcol:
            base = _WBS_BASE_SQL.format(c=f'i.[{wcol}]')
            _, irows, _ = _sql_query(
                f"SELECT {base}, COUNT(DISTINCT i.[sys_id]), {f'MIN(i.[{dc}])' if dc else 'NULL'} "
                f"FROM {table} i WHERE i.[{wcol}] IS NOT NULL GROUP BY {base}",
                database=db, timeout=120, cap=0)
            inc = {_norm_key(b): (n or 0, str(d or '')[:4]) for b, n, d in irows}
        out = []
        for r in rows:
            e = dict(zip(['number'] + cols, [str(v).strip() if v is not None else '' for v in r]))
            n, y = inc.get(_norm_key(e['number']), (0, ''))
            out.append({'number': e['number'], 'nu': _norm_key(e['number']), 'engine': e.get('engine', ''),
                        'family': _engine_family(e.get('engine') or ''), 'esn': e.get('esn', ''),
                        'su': _norm_key(e.get('esn')), 'customer': e.get('customer', ''),
                        'inc': n, 'year': y})
        g['data'], g['ts'] = out, time.time()
        return out


def graph_suggest(q: str, prefer: str = 'project', limit: int = 8) -> list:
    """Passende Projekte und ESNs zum Tippstand. Rang: exakt < Anfang < irgendwo,
    dann mehr Incidents zuerst (die interessanten Engines oben)."""
    qn = _norm_key(q)
    if len(qn) < 2:
        return []
    idx = graph_project_index()
    trees = set(((module_matrix() or {}).get('tree') or {}))

    def rank(key):
        if key == qn:
            return 0
        tail = key.split('.', 1)[-1]           # „20320" trifft L.20320 am Anfang
        return 1 if key.startswith(qn) or tail.startswith(qn) else 2

    prj = sorted(((rank(p['nu']), -p['inc'], p['nu'], p) for p in idx if qn in p['nu']),
                 key=lambda t: t[:3])[:limit]
    projects = [{'kind': 'project', 'value': p['number'], 'engine': p['engine'], 'family': p['family'],
                 'esn': p['esn'], 'customer': p['customer'], 'inc': p['inc'], 'year': p['year'],
                 'tree': p['family'] in trees} for _, _, _, p in prj]
    by_esn = defaultdict(list)
    for p in idx:
        if p['su'] and qn in p['su']:
            by_esn[p['su']].append(p)
    esn_rank = sorted(((rank(k), -sum(p['inc'] for p in v), k, v) for k, v in by_esn.items()),
                      key=lambda t: t[:3])[:limit]
    esns = []
    for _, _, k, v in esn_rank:
        v = sorted(v, key=lambda p: (p['year'] or '9999', p['number']))
        fams = Counter(p['family'] for p in v)
        esns.append({'kind': 'esn', 'value': v[0]['esn'], 'visits': [[p['number'], p['year'], p['inc']] for p in v],
                     'engine': ', '.join(sorted({p['engine'] for p in v if p['engine']})),
                     'family': fams.most_common(1)[0][0], 'inc': sum(p['inc'] for p in v),
                     'tree': fams.most_common(1)[0][0] in trees})
    return esns + projects if prefer == 'esn' else projects + esns


def _project_base(v: str) -> str:
    """'l.20000.20.80 ' → 'L.20000' (Projekt = WBS bis vor den zweiten Punkt)."""
    return '.'.join(re.sub(r'\s+', '', str(v or '')).upper().split('.')[:2])


def graph_scope(kind: str, value: str) -> dict:
    """Projekte zu Projektnummer bzw. ESN + ihre Incidents je CK/Teil + ECMS-Einbauten.
    Ergebnis: {'projects': [...], 'ck': {CK: {Projekt: n}}, 'part': {'CK|Teil': {Projekt: n}},
               'ecms': {'CK|Baugruppe|Teil': {Projekt: [Einbauten, SN-Anzahl, eine SN]}}}"""
    key = (kind, value)
    hit = _graph_scope_cache.get(key)
    if hit and time.time() - hit[0] < GRAPH_SCOPE_TTL:
        return hit[1]
    db, table = _hub_source()
    ex = _incident_extras(db, table)
    prj, we, parts, ecms = ex.get('project'), ex.get('wbs_elem'), ex.get('parts'), ex.get('ecms')
    if not prj or not we:
        raise RuntimeError('Für Projekt/ESN fehlen Projekt- oder WBS-Tabelle.')
    pc = prj['cols']
    if kind == 'esn' and 'esn' not in pc:
        raise RuntimeError('Die Projekttabelle hat keine ESN-Spalte.')
    meta_cols = [k for k in ('engine', 'customer', 'esn', 'state', 'name') if k in pc]
    src = _dedup(prj, 'number', ', '.join(['[number]'] + [f'[{pc[k]}]' for k in meta_cols]))
    src_sel = f"SELECT d.[number], " + ', '.join(f"d.[{pc[k]}]" for k in meta_cols) + f" FROM {src} d WHERE d.rn = 1"
    if kind == 'project':
        num = _project_base(value)
        _, rows, _ = _sql_query(src_sel + " AND UPPER(d.[number]) = ?", [num], database=db, cap=0)
    else:
        esn_norm = re.sub(r'\s+', '', value).upper()
        _, rows, _ = _sql_query(src_sel + f" AND UPPER(REPLACE(d.[{pc['esn']}], ' ', '')) = ?",
                                [esn_norm], database=db, cap=0)
    projects = [dict(zip(['number'] + meta_cols, [str(v).strip() if v is not None else '' for v in r]))
                for r in rows]
    if not projects:
        # Vorschläge statt „nicht gefunden": Teilstring in Nummer bzw. ESN
        col = 'number' if kind == 'project' else pc['esn']
        q = _project_base(value) if kind == 'project' else re.sub(r'\s+', '', value).upper()
        _, cand, _ = _sql_query(
            f"SELECT TOP 15 d.[number], d.[{pc['engine']}]" + (f", d.[{pc['esn']}]" if 'esn' in pc else '')
            + f" FROM {src} d WHERE d.rn = 1 AND UPPER(REPLACE(d.[{col}], ' ', '')) LIKE ? ORDER BY d.[number]",
            ['%' + q + '%'], database=db, cap=0)
        res = {'projects': [], 'candidates': [{'number': c[0], 'engine': c[1],
                                               'esn': c[2] if len(c) > 2 else ''} for c in cand]}
        _graph_scope_cache[key] = (time.time(), res)
        return res
    if len(projects) > GRAPH_ESN_MAX:
        raise RuntimeError(f'{len(projects)} Projekte zu ESN „{value}" — das ist keine einzelne Engine '
                           f'(leere oder Platzhalter-ESN?).')
    nums = [p['number'] for p in projects]
    ph = ', '.join('?' * len(nums))
    # Präfix-Filter auf die Incident-WBS (L.20000 oder L.20000.…) — indexfähig, anders
    # als der WBS-Basis-Ausdruck; der Projekt-Join bleibt als genaue Bedingung
    wcol = ex['wbs']
    wf = lambda a: '(' + ' OR '.join(f'{a}.[{wcol}] = ? OR {a}.[{wcol}] LIKE ?' for _ in nums) + ')'
    wf_p = [x for n in nums for x in (n, n + '.%')]
    dc = _graph_date_col(ex)
    dsel = f"MIN(i.[{dc}]), MAX(i.[{dc}])" if dc else 'NULL, NULL'
    ck = defaultdict(lambda: defaultdict(int))
    part = defaultdict(lambda: defaultdict(int))
    ecms_out = defaultdict(dict)
    pinfo = {n: {'inc': 0, 'ck_inc': 0, 'first': '', 'last': ''} for n in nums}
    # Incidents je Projekt × WBS-Element (auch ohne CK — für „ohne Modul")
    _, rows, _ = _sql_query(
        f"SELECT p.[number], w.[name], COUNT(DISTINCT i.[sys_id]), {dsel} FROM {table} i "
        f"LEFT JOIN {_graph_wbd(we)} w ON w.[sys_id] = i.[{we['key']}] "
        f"{_incident_project_join(prj, ex['wbs'])} "
        f"WHERE {wf('i')} AND p.[number] IN ({ph}) GROUP BY p.[number], w.[name]", wf_p + nums,
        database=db, timeout=120, cap=0)
    for num, wname, n, d1, d2 in rows:
        pi = pinfo.get(num)
        if pi is None:
            continue
        pi['inc'] += n or 0
        for k, d, better in (('first', d1, min), ('last', d2, max)):
            d = str(d or '')[:10]
            if d:
                pi[k] = better(pi[k], d) if pi[k] else d
        c = _ck_of(wname)
        if c:
            ck[c][num] += n or 0
            pi['ck_inc'] += n or 0
    if parts:
        # Teile-Tabelle VOR dem Durchnummerieren (Dedup) auf diese Incidents eingrenzen —
        # sonst läuft die Fensterfunktion über alle Teile-Zeilen der Datenbank
        pf = {**parts, 'table': f"(SELECT * FROM {parts['table']} WHERE [{parts['key']}] IN "
                                f"(SELECT i2.[sys_id] FROM {table} i2 WHERE {wf('i2')})) pf"}
        _, rows, _ = _sql_query(
            f"SELECT p.[number], m.[teil_nr], w.[name], COUNT(DISTINCT i.[sys_id]) FROM {table} i "
            f"JOIN {_graph_wbd(we)} w ON w.[sys_id] = i.[{we['key']}] "
            f"JOIN {_incident_parts_sql(pf)} m ON m.[inc_sys_id] = i.[sys_id] "
            f"{_incident_project_join(prj, ex['wbs'])} "
            f"WHERE {wf('i')} AND p.[number] IN ({ph}) AND w.[name] LIKE 'CK%:%' "
            f"AND NULLIF(m.[teil_nr], '') IS NOT NULL "
            f"GROUP BY p.[number], m.[teil_nr], w.[name]", wf_p + wf_p + nums,
            database=db, timeout=120, cap=0)
        for num, pn, wname, n in rows:
            c = _ck_of(wname)
            if c:
                part[f'{c}|{_pn(pn)}'][num] += n or 0
    if ecms:
        # Einbauten dieser Projekte — Präfix-LIKE je Projekt statt WBS-Basis-Ausdruck,
        # damit ein Index auf der WBS greifen kann (9 Mio. Zeilen)
        c = ecms['cols']
        asm = f"t.[{c['text_of_module_group']}]" if 'text_of_module_group' in c else 'NULL'
        sn = (f"COUNT(DISTINCT NULLIF(t.[{c['serial_number']}], '')), MAX(t.[{c['serial_number']}])"
              if 'serial_number' in c else '0, NULL')
        base = _WBS_BASE_SQL.format(c=f"t.[{c['wbs']}]")
        like = ' OR '.join(f"t.[{c['wbs']}] LIKE ?" for _ in nums)
        _, rows, _ = _sql_query(
            f"SELECT {base}, t.[{c['part_number']}], RIGHT(t.[{c['wbs']}], 2), {asm}, "
            f"COUNT(DISTINCT t.[sys_id]), {sn} FROM {ecms['table']} t "
            f"WHERE ({like}) AND NULLIF(t.[{c['part_number']}], '') IS NOT NULL "
            f"GROUP BY {base}, t.[{c['part_number']}], RIGHT(t.[{c['wbs']}], 2), {asm}",
            [n + '.%' for n in nums], database=db, timeout=180, cap=0)
        upmap = {n.upper(): n for n in nums}
        for num, pn, ck2, asmv, n, snc, sn1 in rows:
            num = upmap.get(str(num or '').upper())
            if not num or not str(ck2 or '').isdigit():
                continue
            k = f"CK{ck2}|{(asmv or '').strip() or '(ohne Baugruppe)'}|{_pn(pn)}"
            old = ecms_out[k].get(num, [0, 0, ''])
            ecms_out[k][num] = [old[0] + (n or 0), old[1] + (snc or 0), old[2] or str(sn1 or '')]
    for p in projects:
        p.update(pinfo[p['number']])
        p['family'] = _engine_family(p.get('engine') or '')
    projects.sort(key=lambda p: (p['first'] or '9999', p['number']))
    res = {'projects': projects, 'ck': {k: dict(v) for k, v in ck.items()},
           'part': {k: dict(v) for k, v in part.items()}, 'ecms': dict(ecms_out)}
    _graph_scope_cache[key] = (time.time(), res)
    return res


# ── Incidents hinter einer Kachel — nur auf Klick, live aus der SQL ──────────
# Die Matrix speichert nur Zählwerte. Die Liste wird mit denselben Regeln wie die
# Kachelzahlen gefragt: Incident → WBS-Element „CKnn: …" (Modul/Obergruppe/Familie),
# für Baugruppe/Teil zusätzlich über die Teile-Tabelle. Bereich (Jahr bzw.
# Projekte) wie in der Ansicht. Kleine Abweichungen zur Kachel sind möglich: die
# Kachel ist der Stand des letzten Matrix-Baus und zählt nur die Hauptvorlage.
GRAPH_INC_LIMIT = 50
_fam_types_cache = {}          # (db, Familie) → (ts, [Engine-Typen])


def _family_engine_types(db: str, prj: dict, fam: str) -> list:
    hit = _fam_types_cache.get((db, fam))
    if hit and time.time() - hit[0] < GRAPH_SCOPE_TTL:
        return hit[1]
    ecol = prj['cols']['engine']
    _, rows, _ = _sql_query(f"SELECT DISTINCT [{ecol}] FROM {prj['table']} WHERE [{ecol}] IS NOT NULL",
                            database=db, timeout=60, cap=0)
    types = sorted({str(r[0]).strip() for r in rows
                    if str(r[0] or '').strip() and _engine_family(str(r[0]).strip()) == fam})
    _fam_types_cache[(db, fam)] = (time.time(), types)
    return types


@app.route('/api/modules/graph/incidents')
def api_modules_graph_incidents():
    a = request.args
    fam, level = a.get('family', ''), a.get('level', '')
    ck, og, asm, part = (a.get('ck') or '').upper(), a.get('og', ''), a.get('asm', ''), _pn(a.get('part'))
    year = (a.get('year') or '').strip()
    projects = [p for p in (a.get('projects') or '').split(',') if p][:GRAPH_ESN_MAX]
    try:
        db, table = _hub_source()
        ex = _incident_extras(db, table)
        prj, we, parts = ex.get('project'), ex.get('wbs_elem'), ex.get('parts')
        if not prj or not we:
            raise RuntimeError('Projekt- oder WBS-Tabelle fehlt — Incidents nicht zuordenbar.')
        cols = _incident_columns(db, table)
        where, params = ["w.[name] LIKE 'CK%:%'"], []
        # Modul-Filter je Ebene
        if level in ('ck', 'asm', 'part'):
            if not ck:
                raise RuntimeError('Modul fehlt.')
            cks = [ck]
        elif level == 'section':
            cki = _graph_ck_info(fam)
            cks = [c for c in cki if (cki[c].get('og') or OG_NONE) == og]
            if not cks:
                return jsonify({'total': 0, 'rows': [], 'limit': GRAPH_INC_LIMIT})
        else:
            cks = []
        if cks:
            where.append('(' + ' OR '.join('w.[name] LIKE ?' for _ in cks) + ')')
            params += [c + ':%' for c in cks]
        # Teile-Filter
        pns = []
        if level == 'part':
            pns = [part]
        elif level == 'asm':
            tree = ((module_matrix() or {}).get('tree') or {}).get(fam, {})
            pns = sorted((tree.get(ck) or {}).get(asm) or {})
            if not pns:
                return jsonify({'total': 0, 'rows': [], 'limit': GRAPH_INC_LIMIT})
        join_parts = ''
        if pns:
            if not parts:
                raise RuntimeError('Teile-Tabelle fehlt — Incidents je Teil nicht abrufbar.')
            join_parts = f"JOIN {_incident_parts_sql(parts)} m ON m.[inc_sys_id] = i.[sys_id] "
            where.append("UPPER(REPLACE(m.[teil_nr], ' ', '')) IN (" + ', '.join('?' * len(pns)) + ')')
            params += pns
        # Bereich
        if projects:
            where.append('p.[number] IN (' + ', '.join('?' * len(projects)) + ')')
            params += projects
        else:
            types = _family_engine_types(db, prj, fam)
            if not types:
                return jsonify({'total': 0, 'rows': [], 'limit': GRAPH_INC_LIMIT})
            where.append(f"p.[{prj['cols']['engine']}] IN (" + ', '.join('?' * len(types)) + ')')
            params += types
        dc = _graph_date_col(ex)
        if year and dc and year.isdigit():
            where.append(f'YEAR(i.[{dc}]) = ?')
            params.append(int(year))

        def c(logical):
            return f"MAX(i.[{cols[logical]}])" if logical in cols else 'NULL'
        state = (f"MAX(COALESCE(NULLIF(t.[state], ''), {'i.[' + cols['state'] + ']' if 'state' in cols else 'NULL'}))"
                 if ex.get('task') else c('state'))
        pc = prj['cols']
        frm = (f"FROM {table} i "
               f"JOIN {_graph_wbd(we)} w ON w.[sys_id] = i.[{we['key']}] "
               f"{join_parts}"
               f"{_incident_project_join(prj, ex['wbs'])} "
               + (_incident_state_join(ex['task']) + ' ' if ex.get('task') else '')
               + 'WHERE ' + ' AND '.join(where))
        _, cnt, _ = _sql_query(f"SELECT COUNT(DISTINCT i.[sys_id]) {frm}", params,
                               database=db, timeout=120, cap=0)
        crit = c('criticality')
        _, rows, _ = _sql_query(
            f"SELECT TOP {GRAPH_INC_LIMIT} {c('number')}, {c('created')}, {crit}, {state}, "
            f"{c('description')}, MAX(p.[number]), "
            + (f"MAX(p.[{pc['esn']}])" if 'esn' in pc else 'NULL') + ", MAX(w.[name]), "
            + ("MAX(m.[teil_nr])" if pns else 'NULL') + " "
            f"{frm} GROUP BY i.[sys_id] "
            f"ORDER BY CASE {crit} WHEN 'Baustopp' THEN 0 WHEN 'Kritisch' THEN 1 ELSE 2 END, "
            f"{c('created')} DESC", params, database=db, timeout=120, cap=0)
        out = [{'nr': r[0] or '', 'date': _inc_date(r[1]), 'crit': (r[2] or '').strip(),
                'state': (r[3] or '').strip(), 'txt': (r[4] or '').strip(), 'project': r[5] or '',
                'esn': (r[6] or '').strip() if r[6] else '', 'ck': _ck_of(r[7]),
                'part': (r[8] or '').strip() if r[8] else ''} for r in rows]
        return jsonify({'total': int(cnt[0][0] or 0) if cnt else 0, 'rows': out, 'limit': GRAPH_INC_LIMIT})
    except Exception as e:
        return jsonify({'error': str(e)})


@app.route('/api/modules/graph/suggest')
def api_modules_graph_suggest():
    try:
        return jsonify({'items': graph_suggest(request.args.get('q') or '',
                                               request.args.get('kind') or 'project')})
    except Exception as e:
        return jsonify({'items': [], 'error': str(e)})


@app.route('/api/modules/graph/years')
def api_modules_graph_years():
    """Nur die Jahresliste für das Auswahlfeld — läuft der Bau noch, fragt die Seite nach."""
    yd = graph_years()
    fam = request.args.get('family') or ''
    return jsonify({'years': sorted(((yd or {}).get('families') or {}).get(fam, {}), reverse=True),
                    'running': _graph_years['running'], 'error': _graph_years['error']})


@app.route('/api/modules/graph')
def api_modules_graph():
    data = module_matrix()
    if data is None or 'tree' not in data:
        # alter Cache ohne Baum → im Hintergrund neu bauen
        module_matrix_start(force=data is not None)
        return jsonify({'status': 'building', 'running': _matrix['running'],
                        'error': _matrix['error'],
                        'laeuft_seit_s': int(time.time() - _matrix['started']) if _matrix['running'] else 0})
    fams = sorted(data['tree'])
    fam  = request.args.get('family') or (fams[0] if fams else '')
    # Bereich: Projekt/ESN schlägt Jahr; die Familie folgt dann dem Projekt
    skind = request.args.get('scope') if request.args.get('scope') in ('project', 'esn') else ''
    sval  = (request.args.get('sv') or '').strip() if skind else ''
    skind = skind if sval else ''
    year  = '' if skind else (request.args.get('year') or '').strip()
    only  = {x for x in (request.args.get('only') or '').split(',') if x}
    scope = {'kind': skind or ('year' if year else 'family'), 'value': sval, 'year': year}
    sc_ck = sc_part = sc_by_ck = sc_by_part = sc_ecms = None
    if skind:
        try:
            gs = graph_scope(skind, sval)
        except Exception as e:
            return jsonify({'status': 'scope_error', 'error': str(e), 'scope': scope, 'families': fams})
        if not gs['projects']:
            return jsonify({'status': 'scope_missing', 'scope': scope, 'families': fams,
                            'candidates': gs.get('candidates', [])})
        pfams = Counter(p['family'] for p in gs['projects'])
        fam = pfams.most_common(1)[0][0]
        if fam not in data['tree']:
            return jsonify({'status': 'scope_error', 'scope': scope, 'families': fams,
                            'error': f'Familie {fam} hat keinen ECMS-Baum — kein Gerüst zum Einfärben.'})
        sel = [p['number'] for p in gs['projects'] if not only or p['number'] in only] \
            or [p['number'] for p in gs['projects']]
        scope.update(projects=gs['projects'], selected=sel,
                     mixed_families=sorted(pfams) if len(pfams) > 1 else [])

        def pick(src):
            by = {k: {n: v[n] for n in sel if v.get(n)} for k, v in src.items()}
            return {k: v for k, v in by.items() if v}
        sc_by_ck, sc_by_part = pick(gs['ck']), pick(gs['part'])
        sc_ck = {k: sum(v.values()) for k, v in sc_by_ck.items()}
        sc_part = {k: sum(v.values()) for k, v in sc_by_part.items()}
        sc_ecms = pick(gs['ecms'])
    elif year:
        yd = graph_years()
        if yd is None:
            g = _graph_years
            return jsonify({'status': 'building', 'what': 'years', 'running': g['running'],
                            'error': g['error'], 'scope': scope,
                            'laeuft_seit_s': int(time.time() - g['started']) if g['running'] else 0})
        yv = (yd['families'].get(fam) or {}).get(year) or {'ck': {}, 'part': {}}
        sc_ck, sc_part = yv['ck'], yv['part']
    depth = max(1, min(int(request.args.get('depth') or 2), 3))
    focus = (request.args.get('ck') or '').upper()
    q     = _pn(request.args.get('q') or '')
    force = request.args.get('force') == '1'
    show_stray = request.args.get('strays') == '1' or bool(q)
    tree  = data['tree'].get(fam, {})
    mat   = data['matrix'].get(fam, {})
    ck00p = data.get('ck00_parts', {}).get(fam, {})
    names = data.get('names', {})
    cki   = _graph_ck_info(fam)
    cks   = sorted(set(tree) | set(cki), key=_ck_sort)

    # Zählstellen — ohne Bereich die gecachten Familienwerte wie bisher
    def ck_inc(ck):
        return sc_ck.get(ck, 0) if sc_ck is not None else cki.get(ck, {}).get('incidents', 0)

    def part_inc(ck, pn):
        if sc_part is not None:
            return sc_part.get(f'{ck}|{pn}', 0)
        return mat.get(pn, {}).get('ck', {}).get(ck, [0, 0])[1]

    def ck00_inc(pn):
        return sc_part.get(f'CK00|{pn}', 0) if sc_part is not None else ck00p.get(pn, 0)

    multi = skind == 'esn' and len(scope.get('selected') or []) > 1

    def by_txt(by):
        """{Projekt: n} → 'L.1 (2019): 2 · L.2 (2023): 1' in Besuchsreihenfolge."""
        return ' · '.join(f"{p['number']} ({(p['first'] or '')[:4] or '–'}): {by.get(p['number'], 0)}"
                          for p in scope['projects'] if p['number'] in scope['selected'])

    def scope_extra(by, inst=None, inst_label='In diesem Projekt verbaut'):
        """Zusatzfelder am Knoten im Projekt/ESN-Bereich: Aufschlüsselung, Wiederholer, ECMS."""
        if not skind:
            return {}, {}
        info, attrs = {}, {}
        by = by or {}
        if multi and by:
            info['Je Projekt'] = by_txt(by)
            rep_ = sum(1 for v in by.values() if v)
            if rep_ >= 2:
                attrs['rep'] = rep_
                info['Wiederholer'] = f'Incidents in {rep_} Shop-Visits'
        if inst is not None:
            attrs['inst'] = inst
            info[inst_label.replace('diesem Projekt', 'diesen Projekten') if multi else inst_label] = inst
        return info, attrs

    def inst_ck_asm(ck, asm=None):
        """Verbaute Teilenummern (ECMS) unter einem Modul bzw. einer Baugruppe im Bereich."""
        pre = f'{ck}|' if asm is None else f'{ck}|{asm}|'
        return sum(1 for k in sc_ecms if k.startswith(pre))

    # Wo steckt jede Teilenummer? (für „auch in …" und die Pfadsuche)
    where = defaultdict(list)
    asm_tot = defaultdict(int)
    for ck, asms in tree.items():
        for asm, parts in asms.items():
            asm_tot[asm] += sum(p['n'] for p in parts.values())
            for pn in parts:
                where[pn].append((ck, asm))

    def asm_stray(ck, asm):
        here = sum(p['n'] for p in tree[ck][asm].values())
        return asm_tot[asm] and here / asm_tot[asm] < GRAPH_STRAY_ASM

    def part_stray(ck, pn):
        best, share, belege = _matrix_verdict(mat.get(pn, {'ck': {}})['ck'])
        return bool(best) and best != ck and share >= MATRIX_CLEAR and belege >= MATRIX_MIN_N

    hidden = {'baugruppen': 0, 'teile': 0}
    nodes, edges, seen = [], [], set()

    def node(nid, **kw):
        if nid not in seen:
            seen.add(nid)
            if isinstance(kw.get('info'), dict):
                kw['info'] = [[k, v] for k, v in kw['info'].items()]   # Reihenfolge erhalten
            nodes.append({'id': nid, **kw})

    def ck_name(ck):
        return cki.get(ck, {}).get('name') or names.get(f'{fam}|{ck}', ck)

    ogi = _graph_og_info(fam)

    def og_of(ck):
        return cki.get(ck, {}).get('og') or OG_NONE

    sec_inc = defaultdict(int)
    sec_cnt = defaultdict(int)
    sec_cks = defaultdict(list)
    for ck in cks:
        sec_inc[og_of(ck)] += ck_inc(ck)
        sec_cnt[og_of(ck)] += 1
        sec_cks[og_of(ck)].append(ck)

    def og_rank(og):
        return (og == OG_NONE, _ck_sort(og))

    def sec_node(og):
        sid_ = f'{fam}|§{og}'
        if sid_ not in seen:
            g = ogi.get(og) or {}
            head = g.get('head')
            engine_only = sec_cks[og] == ['CK00']
            node(sid_, level='section', group='ck00' if engine_only else 'section',
                 label=_og_label(og, g), inc=sec_inc[og],
                 section='Engine gesamt' if engine_only else og,
                 info={'Obergruppe': 'keine (WBS nicht vierstufig)' if og == OG_NONE else og,
                       'Kopfknoten': (f"{head['ck']}: {_short(head['name'])}"
                                      + (' (MD-Element)' if head.get('md') else ''))
                                     if head else '– (kein Element mit CK = OG)',
                       'Module': sec_cnt[og], 'Incidents': sec_inc[og],
                       'Herkunft': 'Mittelsegment des WBS-Codes (Projekt.OG.CK) in LUD_Core — '
                                   'der Kopf ist kein Gruppenname, SAP führt keinen'})
            edges.append({'from': fam, 'to': sid_})
        return sid_

    def ck_node(ck):
        info = cki.get(ck, {})
        name = ck_name(ck)
        nparts = sum(len(p) for p in tree.get(ck, {}).values())
        kind = info.get('kind', '')
        other = info.get('og_other') or []
        sx, sa = scope_extra((sc_by_ck or {}).get(ck),
                             inst_ck_asm(ck) if sc_ecms is not None and ck != 'CK00' else None,
                             'Verbaute Teilenummern (ECMS)')
        node(f'{fam}|{ck}', level='ck', group='ck00' if ck == 'CK00' else 'ck',
             label=f'{ck}\n{_short(name)[:26]}', inc=ck_inc(ck), kind=kind, **sa,
             info={'Modul': name, 'Obergruppe': og_of(ck)
                                               + (f" (in {round(100 * info['og_share'])} % der Projekte; "
                                                  + ', '.join(f"OG {o['og']}: {o['projects']}" for o in other[:4])
                                                  + ')' if other and info.get('og_share') is not None else ''),
                   'Incidents (auf das Modul gebucht)': ck_inc(ck),
                   **sx,
                   **({'Incidents Familie gesamt': info.get('incidents', 0)} if sc_ck is not None else {}),
                   'Projekte mit dem Modul in der Vorlage': info.get('projects', 0),
                   'Aufträge nach Wartungsart': ', '.join(f"{o['type']} {o['n']}"
                                                          for o in (info.get('orders') or [])[:5]) or '–',
                   'Baugruppen': len(tree.get(ck, {})), 'Teilenummern': nparts,
                   **({'Hinweis': 'CK00 = ganze Engine, kein Modul (u. a. alle Prüfstand-Meldungen)'}
                      if ck == 'CK00' else {}),
                   **({'Kopfknoten': 'CK-Nummer = Obergruppe (z. B. CK20 unter .20.20)'}
                      if kind == 'kopf' else {}),
                   **({'Teilesatz?': 'vermutlich Teilesatz (Schaufeln/Segmente) — Heuristik aus '
                                     'CK ≥ 80 und Name, steht nicht in LUD_Core'}
                      if kind == 'teilesatz?' else {})},
             ck=ck)
        if f'{fam}|{ck}' in seen and not any(e['to'] == f'{fam}|{ck}' for e in edges):
            edges.append({'from': sec_node(og_of(ck)), 'to': f'{fam}|{ck}'})

    def asm_node(ck, asm):
        parts = tree[ck][asm]
        inc = sum(part_inc(ck, pn) for pn in parts)
        here = sum(p['n'] for p in parts.values())
        stray = asm_stray(ck, asm)
        by = defaultdict(int)
        for pn in parts:
            for k, v in ((sc_by_part or {}).get(f'{ck}|{pn}') or {}).items():
                by[k] += v
        sx, sa = scope_extra(by, inst_ck_asm(ck, asm) if sc_ecms is not None else None,
                             'Verbaute Teilenummern (ECMS)')
        node(f'{fam}|{ck}|{asm}', level='asm', group='stray' if stray else 'asm',
             label=asm[:30], inc=inc, **sa,
             info={'Baugruppe': asm, 'Modul': ck, 'Teilenummern': len(parts),
                   'Einbauten (Familie)': here, 'Teile-Nennungen in Incidents': inc, **sx,
                   **({'Ausreißer': f'nur {here} von {asm_tot[asm]} Einbauten dieser Baugruppe '
                                    f'sind unter {ck} gebucht'} if stray else {})}, ck=ck)
        edges.append({'from': f'{fam}|{ck}', 'to': f'{fam}|{ck}|{asm}'})

    def part_node(ck, asm, pn, target=False):
        leaf = tree[ck][asm][pn]
        e = mat.get(pn, {'ck': {}})
        best, share, belege = _matrix_verdict(e['ck'])
        inc = part_inc(ck, pn)
        other = [f'{c} · {a}' for c, a in where[pn] if (c, a) != (ck, asm)]
        inst_txt = None
        if sc_ecms is not None:
            ins = sc_ecms.get(f'{ck}|{asm}|{pn}') or {}
            if not ins:
                inst_txt = 'nein — nur in anderen Projekten der Familie'
            else:
                inst_txt = ' · '.join(
                    (f'{num}: ' if multi else '') + f'{v[0]}× eingebaut'
                    + (f', SN {v[2]}' if v[1] == 1 and v[2] else f', {v[1]} SN' if v[1] else '')
                    for num, v in ins.items())
        sx, sa = scope_extra((sc_by_part or {}).get(f'{ck}|{pn}'), inst_txt)
        if sc_ecms is not None:
            sa['inst'] = sum(v[0] for v in (sc_ecms.get(f'{ck}|{asm}|{pn}') or {}).values())
        ambiguous = belege >= MATRIX_MIN_N and share < MATRIX_CLEAR
        pos = sorted(leaf['pos'].items(), key=lambda kv: -kv[1])
        stray = part_stray(ck, pn)
        node(f'{fam}|{ck}|{asm}|{pn}', level='part',
             group='target' if target else ('stray' if stray else
                                            'norm' if ambiguous or len(other) > 2 else 'part'),
             label=f"{pn}\n{leaf['bez'][:24]}", inc=inc, ck=ck, part=pn, **sa,
             info={'Teilenummer': pn, 'Bezeichnung': leaf['bez'], 'Modul · Baugruppe': f'{ck} · {asm}',
                   **sx,
                   'Einbauten hier (Familie)': leaf['n'], 'davon mit Seriennummer': leaf['sn'],
                   'Einbaupositionen': ', '.join(f'{p} ({n}×)' for p, n in pos[:8])
                                       + (f' … +{len(pos) - 8}' if len(pos) > 8 else ''),
                   'Incidents an diesem Modul': inc,
                   'Meldungen auf CK00 (z. B. Prüfstand)': ck00_inc(pn),
                   'Modulvorschlag': (f"{best} · {round(100 * share)} % von {belege}"
                                      + (' — mehrdeutig' if ambiguous else '')) if best else '–',
                   'auch in': '; '.join(other[:6]) + (f' … +{len(other) - 6}' if len(other) > 6 else '')
                              if other else '–',
                   **({'Ausreißer': f'gehört laut Mehrheit zu {best} — Einbau hier vermutlich '
                                    f'unter dem falschen Modul gebucht'} if stray else {})})
        edges.append({'from': f'{fam}|{ck}|{asm}', 'to': f'{fam}|{ck}|{asm}|{pn}'})

    fam_inc = sum(sc_ck.values()) if sc_ck is not None else sum(v.get('incidents', 0) for v in cki.values())
    node(fam, level='family', group='family', label=f'{fam}', inc=fam_inc,
         info={'Familie': fam, 'Module': len([c for c in cks if c != 'CK00']),
               'Incidents auf CK-Ebene': fam_inc})
    hits = []
    if q:
        # Pfadsuche: jede Fundstelle der Teilenummer bis hinauf zur Familie
        hits = sorted((pn for pn in where if pn == q or pn.startswith(q)),
                      key=lambda pn: (pn != q, pn))[:12]
        for pn in hits:
            for ck, asm in sorted(where[pn], key=lambda x: (og_rank(og_of(x[0])), _ck_sort(x[0]))):
                ck_node(ck)
                asm_node(ck, asm)
                part_node(ck, asm, pn, target=True)
    else:
        # Kopfknoten zuerst in seiner Obergruppe, dann nach CK
        order = sorted(cks, key=lambda c: (og_rank(og_of(c)), cki.get(c, {}).get('kind') != 'kopf',
                                           _ck_sort(c)))
        for ck in order:
            if focus and ck != focus:
                continue
            ck_node(ck)
            if depth >= 2 or focus:
                for asm in sorted(tree.get(ck, {})):
                    if not show_stray and asm_stray(ck, asm):
                        hidden['baugruppen'] += 1
                        continue
                    asm_node(ck, asm)
                    if depth >= 3 or focus:
                        for pn in sorted(tree[ck][asm], key=lambda x: -tree[ck][asm][x]['n']):
                            if not show_stray and part_stray(ck, pn):
                                hidden['teile'] += 1
                                continue
                            part_node(ck, asm, pn)
    # Heatmap-Skala je Ebene über die GANZE Familie — sonst wäre im Modul-Fokus
    # der einzige sichtbare Knoten automatisch das Maximum (und immer dunkel).
    def span(vals):
        nz = [v for v in vals if v]
        return [min(nz), max(nz)] if nz else [0, 0]
    scale = {'section': span([v for k, v in sec_inc.items() if sec_cks[k] != ['CK00']]),
             'ck': span([ck_inc(c) for c in cks if c != 'CK00']),
             'asm': span([sum(part_inc(c, pn) for pn in parts)
                          for c, asms in tree.items() for a, parts in asms.items()
                          if not asm_stray(c, a)]),
             'part': span([part_inc(c, pn)
                           for c, asms in tree.items() for parts in asms.values() for pn in parts])}
    yd = graph_years(start=not skind)
    scope['years'] = sorted(((yd or {}).get('families') or {}).get(fam, {}), reverse=True)
    scope['years_building'] = _graph_years['running']
    if skind:
        scope['ohne_modul'] = sum(p['inc'] - p['ck_inc'] for p in scope['projects']
                                  if p['number'] in scope['selected'])
    resp = {'status': 'ok', 'family': fam, 'families': fams, 'depth': depth, 'focus': focus,
            'scale': scale, 'scope': scope,
            'q': q, 'hits': hits, 'cks': [{'ck': c, 'name': _short(cki.get(c, {}).get('name')
                                                                    or names.get(f'{fam}|{c}', ''))}
                                          for c in cks],
            'ts': data['ts'], 'count': len(nodes), 'hidden': hidden, 'strays': show_stray}
    if len(nodes) > GRAPH_MAX_NODES and not force:
        return jsonify({**resp, 'too_many': True})
    uniq = list({(e['from'], e['to']): e for e in edges}.values())
    return jsonify({**resp, 'nodes': nodes, 'edges': uniq})


# ══════════════════════════════════════════════════════════════════════════════
# Einbaustruktur aus ECMS (Abfragen J/K/L, 28.09.2026)
# Jede Einheit (Modul, Teilesatz, Restmodul) steht selbst als Teil in
# x_mtuae_ins_part: serial_number = Code (CK + X/T) + Kennung (≈ ESN), und
# module_group / module_group_serial_number nennen Material und SN des Elternteils.
# Oberste Einheiten hängen an der Engine: Eltern-SN = ESN, Material = Wurzel
# („CFM7-0000", „M0000") — die Wurzel unterscheidet die WBS-VORLAGEN einer Familie
# (CFM56-7: „CFM7-…" = Folie Rev 10, „M…" = zweite Vorlage mit anderen CK-Bedeutungen).
# Regeln aus den echten Daten:
#   • Elternknoten = häufigster über alle Projekte — ~40 % der Einheiten haben keinen
#     (nicht gepflegt, KEIN Zerlegezustand; L3), das verdünnt nur die Belege
#   • install_status 7 (vermutlich ServiceNow „Retired") zählt nicht für die Struktur
#   • Material mit Eltern-Code = eigener Code (CFM7-2100RM in 21X) = Restmodul/Untereinheit
# ══════════════════════════════════════════════════════════════════════════════
STRUCT_PATH    = os.path.join(SQL_CACHE_DIR, 'struct_catalog.json')
STRUCT_VERSION = 2
STRUCT_CODE    = "PATINDEX('[0-9][0-9][XTxt]%', {x}) = 1"
STRUCT_MIN_SHARE = 0.5       # darunter gilt der Elternknoten als unsicher
_struct = {'data': None, 'running': False, 'error': '', 'started': 0.0}
_struct_lock = threading.Lock()


def _struct_rows_sql(e: dict, prj: dict) -> tuple:
    c = e['cols']
    st = f"[{c['install_status']}]" if 'install_status' in c else '1'
    base = _WBS_BASE_SQL.format(c='u.[wbs]')
    code_mg  = STRUCT_CODE.format(x='u.[mgsn]')
    code_raw = STRUCT_CODE.format(x=f"[{c['serial_number']}]")
    cte = (f"WITH u AS (SELECT DISTINCT [sys_id], [{c['wbs']}] AS [wbs], [{c['part_number']}] AS [pn], "
           f"[{c.get('name', c['part_number'])}] AS [nm], UPPER([{c['serial_number']}]) AS [sn], "
           f"[{c['module_group']}] AS [mg], UPPER([{c['module_group_serial_number']}]) AS [mgsn], {st} AS [st] "
           f"FROM {e['table']} WHERE {code_raw} "
           f"AND [{c['wbs']}] LIKE '_%.%.%.%' AND [{c['wbs']}] NOT LIKE '%.%.%.%.%'), "
           f"r AS (SELECT {base} AS [projekt], MAX(u.[mg]) AS [root] FROM u "
           f"WHERE NULLIF(u.[mgsn], '') IS NOT NULL AND NOT ({code_mg}) AND NULLIF(u.[mg], '') IS NOT NULL "
           f"GROUP BY {base}) ")
    parent = (f"CASE WHEN {code_mg} THEN LEFT(u.[mgsn], 3) WHEN NULLIF(u.[mgsn], '') IS NULL THEN '' "
              f"ELSE '*' END")
    frm = (f"FROM u JOIN {_prj_latest(prj)} pr ON pr.[number] = {base} "
           f"LEFT JOIN r ON r.[projekt] = {base} ")
    rows_sql = (cte + f"SELECT pr.[engine], COALESCE(r.[root], ''), LEFT(u.[sn], 3), u.[pn], MAX(u.[nm]), "
                f"{parent}, COALESCE(u.[mg], ''), PARSENAME(u.[wbs], 2), PARSENAME(u.[wbs], 1), u.[st], "
                f"COUNT(DISTINCT {base}), COUNT(*) " + frm +
                f"GROUP BY pr.[engine], COALESCE(r.[root], ''), LEFT(u.[sn], 3), u.[pn], {parent}, "
                f"COALESCE(u.[mg], ''), PARSENAME(u.[wbs], 2), PARSENAME(u.[wbs], 1), u.[st]")
    # je Projekt: Wurzel + ein paar Materialien (für Projekte ohne gepflegte Wurzel)
    prj_sql = (cte + f"SELECT pr.[engine], COALESCE(MAX(r.[root]), ''), {base}, "
               f"MAX(CASE WHEN NULLIF(u.[mgsn], '') IS NOT NULL THEN 1 ELSE 0 END), "
               f"MIN(u.[pn]), MAX(u.[pn]), MIN(NULLIF(u.[mg], '')), MAX(NULLIF(u.[mg], '')) " + frm +
               f"GROUP BY pr.[engine], {base}")
    return rows_sql, prj_sql


def _struct_name(nm: str, pn: str) -> str:
    """„CFM7-3200 - HPC STATOR FRONT" → „HPC STATOR FRONT"."""
    nm = (nm or '').strip()
    return nm.split(' - ', 1)[1].strip() if ' - ' in nm else nm or pn


def struct_catalog_build(db: str, table: str) -> dict:
    ex = _incident_extras(db, table)
    e, prj = ex.get('ecms'), ex.get('project')
    if not e or not prj or not {'module_group', 'module_group_serial_number', 'serial_number'} <= set(e['cols']):
        raise RuntimeError('Für die Einbaustruktur fehlen ECMS-Spalten (module_group, '
                           'module_group_serial_number, serial_number) oder die Projekttabelle.')
    t0 = time.time()
    rows_sql, prj_sql = _struct_rows_sql(e, prj)
    _, rows, _ = _sql_query(rows_sql, database=db, timeout=900, cap=0)
    _, prows, _ = _sql_query(prj_sql, database=db, timeout=900, cap=0)

    # Vorlage = Wurzel-Material; Projekte ohne Wurzel über ihre Materialien zuordnen
    mat_tpl = defaultdict(Counter)
    for et, root, code, pn, nm, par, mg, og, ck, st, n, cnt in rows:
        if root:
            fam = _engine_family((et or '').strip())
            mat_tpl[(fam, pn)][root] += n or 0
            if mg:
                mat_tpl[(fam, mg)][root] += n or 0

    def tpl_of(fam, root, pn, mg):
        if root:
            return root
        for m in (pn, mg):
            c = mat_tpl.get((fam, m))
            if c:
                return c.most_common(1)[0][0]
        return '?'

    nodes = defaultdict(lambda: {'mats': Counter(), 'rm': Counter(), 'par': Counter(), 'par_mat': Counter(),
                                 'names': Counter(), 'booked': Counter(), 'n': 0, 'st7': 0, 'child_mg': Counter()})
    for et, root, code, pn, nm, par, mg, og, ck, st, n, cnt in rows:
        et = (et or '').strip()
        fam = _engine_family(et)
        code = (code or '').upper()
        if not fam or len(code) != 3:
            continue
        tpl = tpl_of(fam, root, pn, mg)
        nd = nodes[(fam, tpl, code)]
        if str(st) == '7':
            nd['st7'] += n or 0
            continue
        if par == code:                       # Restmodul / Untereinheit im eigenen Code
            nd['rm'][pn] += n or 0
            continue
        nd['mats'][pn] += n or 0
        nd['names'][_struct_name(nm, pn)] += n or 0
        nd['n'] += n or 0
        nd['booked'][f'{og}.{ck}'] += n or 0
        if par:
            nd['par'][par] += n or 0
            if par != '*' and mg:
                nodes[(fam, tpl, par)]['child_mg'][mg] += n or 0   # Material des Elternteils
            elif par == '*' and mg:
                nd['par_mat'][mg] += n or 0

    tpl_prj = defaultdict(lambda: [0, 0])
    fam_prj = defaultdict(lambda: [0, 0])
    project_tpl = {}                          # Projekt → Vorlage (für die Ableitung je Incident)
    for et, root, _proj, has_par, pn1, pn2, mg1, mg2 in prows:
        fam = _engine_family((et or '').strip())
        tpl = root or next((mat_tpl[(fam, m)].most_common(1)[0][0] for m in (mg1, mg2, pn1, pn2)
                            if m and mat_tpl.get((fam, m))), '?')
        if _proj:
            project_tpl[_proj] = tpl
        tpl_prj[(fam, tpl)][0] += 1
        tpl_prj[(fam, tpl)][1] += 1 if has_par else 0
        fam_prj[fam][0] += 1

    fams = defaultdict(list)
    for (fam, tpl), (np_, npar) in sorted(tpl_prj.items(), key=lambda kv: -kv[1][0]):
        units = []
        for (f2, t2, code), nd in nodes.items():
            if f2 != fam or t2 != tpl or not (nd['n'] or nd['rm']):
                continue
            main = (nd['child_mg'].most_common(1)[0][0] if nd['child_mg']
                    else nd['mats'].most_common(1)[0][0] if nd['mats'] else nd['rm'].most_common(1)[0][0])
            psum = sum(nd['par'].values())
            par, pn_ = nd['par'].most_common(1)[0] if nd['par'] else ('', 0)
            units.append({
                'code': code, 'ck': 'CK' + code[:2], 'xt': code[2], 'material': main,
                'name': nd['names'].most_common(1)[0][0] if nd['names'] else main,
                'kind': 'RE' if main.upper().endswith('RE') else ('T' if code[2] == 'T' else 'X'),
                'n': nd['n'], 'st7': nd['st7'],
                'parent': '' if par == '*' else par, 'at_engine': par == '*',
                'parent_share': round(pn_ / psum, 3) if psum else None, 'parent_n': psum,
                'parent_other': [{'code': k if k != '*' else 'Engine', 'n': v}
                                 for k, v in nd['par'].most_common() if k != par][:4],
                'materials': [{'m': k, 'n': v} for k, v in nd['mats'].most_common(6)],
                'rm': [{'m': k, 'n': v} for k, v in nd['rm'].most_common(8)],
                'booked': [{'wbs': k, 'n': v} for k, v in nd['booked'].most_common(3)],
            })
        codes = {u['code'] for u in units}
        for u in units:                        # Elternteil außerhalb der Vorlage → oben einhängen
            if u['parent'] and u['parent'] not in codes:
                u['parent_missing'] = u['parent']
                u['parent'] = ''
        units.sort(key=lambda u: (u['code'][:2], u['code'][2]))
        fams[fam].append({'root': tpl, 'projects': np_, 'projects_with_parent': npar,
                          'share': round(np_ / fam_prj[fam][0], 3) if fam_prj[fam][0] else None,
                          'units': units})
    out = sorted(({'family': f, 'projects': fam_prj[f][0], 'templates': ts_}
                  for f, ts_ in fams.items()), key=lambda f: -f['projects'])
    return {'ts': datetime.datetime.now().isoformat(timespec='seconds'), 'version': STRUCT_VERSION,
            'families': out, 'project_tpl': project_tpl,
            'stats': {'zeilen': len(rows), 'familien': len(out),
                      'vorlagen': sum(len(f['templates']) for f in out),
                      'einheiten': sum(len(t['units']) for f in out for t in f['templates']),
                      'sekunden': round(time.time() - t0, 1)}}


def _struct_tree(fam: str, project: str = '', data: dict = None) -> dict:
    """Baum der Vorlage, die das Projekt nutzt (sonst Hauptvorlage der Familie):
    {'tpl', 'main': bool, 'units': {code: unit}, 'parent': {code: code|''}}."""
    data = data or struct_catalog() or {}
    fam_d = next((f for f in data.get('families', []) if f['family'] == fam), None)
    if not fam_d or not fam_d['templates']:
        return {}
    tpl = (data.get('project_tpl') or {}).get(project) or fam_d['templates'][0]['root']
    t = next((x for x in fam_d['templates'] if x['root'] == tpl), fam_d['templates'][0])
    return {'tpl': t['root'], 'main': t is fam_d['templates'][0],
            'units': {u['code']: u for u in t['units']},
            'parent': {u['code']: u['parent'] for u in t['units']}}


def _struct_path(tree: dict, code: str) -> list:
    """Vorfahren von code bis unter die Engine, code zuerst."""
    path, seen = [], set()
    while code and code not in seen:
        seen.add(code)
        path.append(code)
        code = tree.get('parent', {}).get(code, '')
    return path


def _struct_lca(tree: dict, codes) -> str:
    """Kleinster gemeinsamer Vorfahr mehrerer Einheiten ('' = nur die Engine)."""
    paths = [_struct_path(tree, c) for c in codes]
    if not paths:
        return ''
    common = set(paths[0]).intersection(*map(set, paths[1:]))
    return next((c for c in paths[0] if c in common), '')


def _unit_label(u: dict) -> str:
    return f"{u['code']} {u['name']}"


def struct_catalog():
    s_ = _struct
    if s_['data'] is None:
        try:
            with open(STRUCT_PATH, encoding='utf-8') as f:
                s_['data'] = json.load(f)
        except Exception:
            pass
    return s_['data']


def struct_catalog_start(force: bool = False) -> bool:
    with _struct_lock:
        if _struct['running'] or (struct_catalog() is not None and not force):
            return False
        _struct.update(running=True, error='', started=time.time())

    def run():
        try:
            db, table = _hub_source()
            data = struct_catalog_build(db, table)
            _struct['data'] = data
            os.makedirs(SQL_CACHE_DIR, exist_ok=True)
            tmp = STRUCT_PATH + '.tmp'
            with open(tmp, 'w', encoding='utf-8') as f:
                json.dump(data, f, ensure_ascii=False)
            os.replace(tmp, STRUCT_PATH)
            # Katalog kennt die Vorlagen erst jetzt → einmal mit Vorlagen neu bauen
            cat = module_catalog(build=False)
            if cat is not None and not cat.get('templates_ready'):
                _module_catalog_background(force=True)
        except Exception as e:
            _struct['error'] = str(e)
            print(f'[struktur] Aufbau fehlgeschlagen: {e}')
        finally:
            _struct['running'] = False
    threading.Thread(target=run, daemon=True).start()
    return True


STRUCT_HTML = r"""<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title>SAP Claim Analyzer – Einbaustruktur</title>
<!--SAP_BASE-->
<style>
  *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
  body { font-family: var(--font); background: var(--bg); color: var(--text); font-size: 13px; }
  h2 { font-size: 20px; font-weight: 500; letter-spacing: -.01em; margin-bottom: 4px; }
  .subtitle { color: var(--muted); margin-bottom: 12px; max-width: 920px; line-height: 1.45; }
  .toolbar { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin-bottom: 10px; }
  select, input { padding: 6px 9px; font-size: 13px; border: 1px solid var(--line-2); border-radius: var(--r-s);
                  background: var(--surface); color: var(--ink); }
  button { background: var(--surface); color: var(--ink); border: 1px solid var(--line-2); padding: 6px 12px;
           border-radius: var(--r-s); font-size: 13px; cursor: pointer; }
  button:hover { background: var(--hover); }
  #info { font-size: 11px; color: var(--muted); }
  .tpls { display: flex; gap: 6px; flex-wrap: wrap; margin: 4px 0 12px; }
  .tpl { background: var(--surface); border: 1px solid var(--border); border-radius: var(--r-pill); padding: 4px 12px;
         cursor: pointer; font-size: 13px; }
  .tpl.on { background: var(--ink); color: var(--surface); border-color: var(--ink); }
  .tpl small { opacity: .7; margin-left: 6px; }
  .warn { background: var(--warn-bg); border: 1px solid var(--warn-line); color: var(--warn); padding: 8px 12px;
          border-radius: var(--r-s); margin-bottom: 10px; }
  .tree { background: var(--surface); border: 1px solid var(--border); border-radius: var(--r-m); padding: 8px 0; }
  .node { display: grid; grid-template-columns: minmax(0, 1fr) 150px 190px; gap: 10px; align-items: center;
          padding: 4px 14px; border-bottom: 1px solid var(--hover); }
  .node:hover { background: var(--acc-soft); }
  .node .lbl { display: flex; align-items: center; gap: 8px; min-width: 0; }
  .node .lbl .tw { color: var(--ink-3); font-family: ui-monospace, monospace; white-space: pre; }
  .code { font-weight: 700; font-variant-numeric: tabular-nums; border: 1.5px solid var(--info); color: var(--info);
          border-radius: var(--r-s); padding: 0 6px; font-size: 12px; }
  .code.T { border-style: dashed; }
  .code.RE, .code.root { border-color: var(--ink-3); color: var(--ink-2); }
  .nm { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
  .mat { font-size: 11px; color: var(--muted); white-space: nowrap; }
  .rm { font-size: 10.5px; color: var(--ink-2); border: 1px solid var(--line-2); border-radius: var(--r-s);
        padding: 0 5px; white-space: nowrap; }
  .ev { font-size: 11px; color: var(--muted); text-align: right; font-variant-numeric: tabular-nums; }
  .ev i { display: inline-block; height: 6px; background: var(--info); opacity: .7; border-radius: 1px;
          vertical-align: middle; margin-left: 6px; }
  .flag { font-size: 11px; color: var(--ink-2); }
  .flag.og { color: var(--warn); }
  .flag.unsure { color: var(--crit); }
  .root { padding: 8px 14px; font-weight: 600; border-bottom: 1px solid var(--border); }
  details.hint { font-size: 12px; color: var(--ink-2); margin-bottom: 10px; max-width: 920px; }
  details.hint p { margin-top: 6px; line-height: 1.5; }
  .empty { padding: 40px; text-align: center; color: var(--muted); }
  a.gl { font-size: 11px; color: var(--ink-3); text-decoration: none; margin-left: 4px; }
  a.gl:hover { color: var(--ink); }
</style>
<!--SAP_SHELL-->
</head>
<body>
<!--SAP_RAIL-->
<header class="sap-ph"><!--SAP_PH-->
<div class="ph-right">
<!--SAP_MARK-->
</div>
</header>
<div class="sap-page">
<section class="sap-sec">
 <div class="sap-sec-head">
  <span class="eyebrow">04.4 Einbaustruktur</span>
  <h2>Was steckt worin?</h2>
  <p>Der physische Baum je Engine-Familie und WBS-Vorlage — rekursiv aus ECMS gebaut, nicht gepflegt.</p>
 </div>
 <div class="sap-sec-body">
  <p class="subtitle">Jede Einheit (Modul, Teilesatz, Restmodul) steht in ECMS selbst als Teil. Ihre Seriennummer
    trägt den Code (CK + <b>X</b> Modul / <b>T</b> Teilesatz, z. B. 51X, 93T), und <i>module_group</i> +
    <i>module_group_serial_number</i> nennen das Teil, in dem sie steckt. Aus diesen Kanten entsteht der Baum —
    Elternteil ist jeweils der häufigste über alle Projekte. Die <b>Vorlage</b> erkennt man an der Wurzel
    (Material, an dem die obersten Einheiten hängen, z. B. CFM7-0000 oder M0000); dieselbe CK-Nummer kann in
    zwei Vorlagen etwas anderes bedeuten.</p>
  <details class="hint"><summary>Wie belastbar ist das?</summary>
   <p>• Rund 40 % der Einheiten haben in ECMS keinen Elternbezug (nicht gepflegt, kein Zerlegezustand) — das
   senkt die Zahl der Belege, verfälscht den Baum aber nicht. „Belege" = Projekte mit Elternbezug.<br>
   • Einheiten mit install_status 7 (vermutlich „Retired") zählen nicht.<br>
   • <span class="flag unsure">unsicher</span>: das häufigste Elternteil hat weniger als 50 % der Belege.<br>
   • <span class="flag og">WBS ≠ Einbau</span>: das Modul ist unter einer anderen Obergruppe gebucht, als sein
   Elternteil — z. B. CFM 53X: gebucht OG 50 (LPT), steckt physisch im Core (30X). Gilt nur für die
   Hauptvorlage der Familie, weil die Obergruppen aus deren WBS stammen.<br>
   • Restmodule (…RM) und Untereinheiten stehen als kleine Kästchen am Modul, in dem sie stecken.</p>
  </details>
  <div id="stale"></div>
  <div class="toolbar">
    <select id="fam" onchange="pickFam(this.value)"></select>
    <input id="q" placeholder="Code, Name oder Material filtern…" oninput="render()">
    <button onclick="load(true)">Neu aufbauen</button>
    <span id="info"></span>
  </div>
  <div class="tpls" id="tpls"></div>
  <div id="out"><div class="empty">Lade…</div></div>
 </div>
</section>
</div>
<script>
let D = null, FAM = new URLSearchParams(location.search).get('family') || '', TPL = 0;
const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;'}[c]));
const fmt = n => (n || 0).toLocaleString('de-DE');

async function load(force) {
  const q = new URLSearchParams();
  if (FAM) q.set('family', FAM);
  if (force) q.set('force', '1');
  const d = await (await fetch('/api/modules/struktur?' + q)).json();
  if (d.status !== 'ok') {
    document.getElementById('out').innerHTML = '<div class="empty">' + (d.error ? 'Fehler: ' + esc(d.error)
      : '&#8987; Einbaustruktur wird aus ECMS aufgebaut' + (d.laeuft_seit_s ? ' (' + d.laeuft_seit_s + ' s)' : '') + ' …') + '</div>';
    if (!d.error) setTimeout(() => load(false), 3000);
    return;
  }
  D = d; FAM = d.family;
  document.getElementById('fam').innerHTML = d.families.map(f =>
    `<option value="${esc(f.family)}"${f.family === FAM ? ' selected' : ''}>${esc(f.family)} · ${fmt(f.projects)} Projekte${f.templates > 1 ? ' · ' + f.templates + ' Vorlagen' : ''}</option>`).join('');
  document.getElementById('info').textContent = 'Stand ' + d.ts.replace('T', ' ') + ' · ' + fmt(d.stats.einheiten)
    + ' Einheiten in ' + d.stats.vorlagen + ' Vorlagen' + (d.running ? ' · wird neu aufgebaut …' : '');
  if (TPL >= d.templates.length) TPL = 0;
  if (window.modReportUrl) window.modReportUrl('struktur', 'family=' + encodeURIComponent(FAM));
  render();
}

function pickFam(f) { FAM = f; TPL = 0; load(false); }
function pickTpl(i) { TPL = i; render(); }

function render() {
  if (!D) return;
  const ts = D.templates;
  document.getElementById('tpls').innerHTML = ts.map((t, i) =>
    `<span class="tpl${i === TPL ? ' on' : ''}" onclick="pickTpl(${i})" title="${fmt(t.projects_with_parent)} davon mit Elternbezug">`
    + `Vorlage ${esc(t.root)}<small>${fmt(t.projects)} Projekte${t.share != null ? ' · ' + Math.round(100 * t.share) + ' %' : ''} · ${t.units.length} Einheiten</small></span>`).join('');
  const t = ts[TPL];
  if (!t || !t.units.length) {
    document.getElementById('out').innerHTML = '<div class="empty">Für diese Familie gibt es keine Einheiten mit Modulcode in ECMS.</div>';
    return;
  }
  const q = document.getElementById('q').value.trim().toLowerCase();
  const kids = {}, by = {};
  t.units.forEach(u => { by[u.code] = u; (kids[u.parent || ''] = kids[u.parent || ''] || []).push(u); });
  // Treffer + alle Vorfahren sichtbar
  let show = null;
  if (q) {
    show = new Set();
    t.units.forEach(u => {
      if ((u.code + ' ' + u.name + ' ' + u.material + ' ' + u.rm.map(r => r.m).join(' ')).toLowerCase().includes(q)) {
        let x = u, guard = 0;
        while (x && guard++ < 20) { show.add(x.code); x = by[x.parent]; }
      }
    });
  }
  const main = TPL === 0, og = D.og || {};
  const maxN = Math.max(1, ...t.units.map(u => u.n));
  const rows = [];
  function walk(code, prefix, depth) {
    const list = (kids[code] || []).slice().sort((a, b) => a.code.localeCompare(b.code));
    list.forEach((u, i) => {
      if (show && !show.has(u.code)) return;
      const last = i === list.length - 1;
      const tw = prefix + (last ? '└─ ' : '├─ ');
      const flags = [];
      if (u.parent_share != null && u.parent_share < 0.5)
        flags.push(`<span class="flag unsure" title="${esc(u.parent_other.map(o => o.code + ' ' + o.n).join(', '))}">unsicher (${Math.round(100 * u.parent_share)} %)</span>`);
      if (u.parent_missing) flags.push(`<span class="flag unsure">Elternteil ${esc(u.parent_missing)} fehlt in der Vorlage</span>`);
      const oOwn = og[u.ck], oPar = u.parent ? og['CK' + u.parent.slice(0, 2)] : null;
      if (main && oOwn && oPar && oOwn !== oPar)
        flags.push(`<span class="flag og" title="Buchung laut WBS unter OG ${esc(oOwn)}, das Elternteil ${esc(u.parent)} unter OG ${esc(oPar)}">WBS OG ${esc(oOwn)} ≠ Einbau in ${esc(u.parent)}</span>`);
      rows.push(`<div class="node"><div class="lbl"><span class="tw">${tw}</span>`
        + `<span class="code ${u.kind}">${esc(u.code)}</span><span class="nm">${esc(u.name)}</span>`
        + `<span class="mat">${esc(u.material)}</span>`
        + u.rm.map(r => `<span class="rm" title="Restmodul/Untereinheit im eigenen Code · ${fmt(r.n)} Projekte">${esc(r.m)}</span>`).join('')
        + (main ? `<a class="gl" href="/hub/module/graph?family=${encodeURIComponent(FAM)}&ck=${encodeURIComponent(u.ck)}" title="Modul im Hierarchie-Graph">↗</a>` : '')
        + `</div><div class="flag">${flags.join(' ')}</div>`
        + `<div class="ev" title="${fmt(u.n)} Projekte mit dieser Einheit · ${fmt(u.parent_n)} mit Elternbezug${u.st7 ? ' · ' + fmt(u.st7) + ' mit Status 7' : ''}">`
        + `${fmt(u.n)} Proj.${u.parent_share != null && u.parent_share < 1 ? ' · ' + Math.round(100 * u.parent_share) + ' %' : ''}`
        + `<i style="width:${Math.round(60 * u.n / maxN)}px"></i></div></div>`);
      walk(u.code, prefix + (last ? '   ' : '│  '), depth + 1);
    });
  }
  walk('', '', 0);
  document.getElementById('out').innerHTML = `<div class="tree"><div class="root"><span class="code root">Engine</span> Wurzel-Material ${esc(t.root)}`
    + ` <span class="mat">· ${fmt(t.projects)} Projekte, ${fmt(t.projects_with_parent)} mit Elternbezug</span></div>`
    + (rows.join('') || '<div class="empty">Kein Treffer.</div>') + '</div>';
}
load(false);
</script>
</body>
</html>
"""


@app.route('/hub/module/struktur')
def modstruct_page():
    return _module_page(STRUCT_HTML, 'struktur')


@app.route('/api/modules/struktur')
def api_modules_struktur():
    """Einbaustruktur je Familie und Vorlage (aus ECMS). Baut im Hintergrund."""
    force = request.args.get('force') == '1'
    data = struct_catalog()
    if force or data is None or data.get('version') != STRUCT_VERSION:
        struct_catalog_start(force=True)
    base = {'running': _struct['running'], 'error': _struct['error'],
            'laeuft_seit_s': int(time.time() - _struct['started']) if _struct['running'] else 0}
    if data is None:
        return jsonify({**base, 'status': 'building' if _struct['running'] else 'missing'})
    fams = [{'family': f['family'], 'projects': f['projects'], 'templates': len(f['templates'])}
            for f in data['families']]
    fam = request.args.get('family') or (fams[0]['family'] if fams else '')
    sel = next((f for f in data['families'] if f['family'] == fam), None)
    # WBS-Obergruppe je CK zum Vergleich (Katalog der Familie — gilt für die Hauptvorlage)
    ogm = _og_map(fam) if sel else {}
    return jsonify({**base, 'status': 'ok', 'ts': data['ts'], 'stats': data['stats'], 'families': fams,
                    'family': fam, 'templates': sel['templates'] if sel else [], 'og': ogm})


@app.route('/api/modules')
def api_modules():
    try:
        data = module_catalog(force=request.args.get('force') == '1')
        return jsonify({k: v for k, v in data.items() if k != 'remap'})
    except Exception as e:
        return jsonify({'error': str(e)})


HUB_HTML = """<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title>SAP Claim Analyzer – MTU Hub Auswertungen</title>
<script src="/static/chart.umd.min.js"></script>
<!--SAP_BASE-->
<style>
  *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
  
  body { font-family: var(--font); background: var(--bg); color: var(--text); font-size: 13px; }






  #conn { display: flex; align-items: center; gap: 7px; font-size: 11px; color: var(--muted); }
  #dot { width: 8px; height: 8px; border-radius: 50%; background: var(--ink-3); }
  #dot.ok { background: var(--green); } #dot.err { background: var(--red); }

  .page { max-width: 1600px; margin: 0 auto; padding: 18px 20px 60px; }
  .head { display: flex; align-items: baseline; gap: 14px; margin-bottom: 4px; flex-wrap: wrap; }
  h2 { font-size: 20px; font-weight: 500; letter-spacing: -.01em; }
  .subtitle { color: var(--muted); margin-bottom: 16px; }
  .toolbar { display: flex; gap: 8px; align-items: center; margin-bottom: 18px; flex-wrap: wrap; }
  .toolbar input[type=text] { padding: 6px 10px; font-size: 13px; border: 1px solid var(--border);
                              border-radius: var(--r-s); min-width: 200px; }
  button { background: var(--acc); color: var(--on-acc); border: 1px solid var(--acc); padding: 7px 14px;
           border-radius: var(--r-s); font-size: 13px; font-weight: 500; cursor: pointer; white-space: nowrap;
           transition: background .15s, border-color .15s; }
  button:hover { background: #3a3835; border-color: #3a3835; }
  button.sec { background: var(--surface); color: var(--ink); border: 1px solid var(--line-2); }
  button.sec:hover { background: var(--hover); border-color: var(--ink-3); }
  button.danger { background: var(--surface); color: var(--crit); border-color: rgba(184,50,42,.4); }
  button.danger:hover { background: var(--crit-bg); }
  button:disabled { opacity: .45; cursor: not-allowed; }
  label.chk { font-size: 13px; color: var(--muted); display: flex; align-items: center; gap: 5px;
              cursor: pointer; }

  #grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
  @media (max-width: 1100px) { #grid { grid-template-columns: 1fr; } }
  .card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--r-m);
          display: flex; flex-direction: column; overflow: hidden; }
  .card.full { grid-column: 1 / -1; }
  .card.drag { opacity: .4; }
  .card-head { padding: 10px 14px; border-bottom: 1px solid var(--border);
               display: flex; align-items: flex-start; gap: 10px; }
  .card-head .t { flex: 1; min-width: 0; }
  .card-head h3 { font-size: 13px; font-weight: 700; }
  .card-head p { font-size: 11px; color: var(--muted); margin-top: 2px; }
  .card-head .acts { display: flex; gap: 4px; flex-shrink: 0; }
  .card-head .acts button { padding: 3px 8px; font-size: 11px; }
  .grip { cursor: grab; color: var(--ink-3); font-size: 15px; padding-top: 1px; user-select: none; }
  .card-body { padding: 12px 14px; min-height: 240px; display: flex; flex-direction: column;
               justify-content: center; }
  .card-body.top { justify-content: flex-start; min-height: 0; }
  .card.full .card-body.top { min-height: 0; }
  .card.full .card-body { min-height: 320px; }
  .card-foot { padding: 6px 14px; border-top: 1px solid var(--border); font-size: 11px;
               color: var(--muted); display: flex; gap: 10px; align-items: center;
               flex-wrap: wrap; background: var(--hover); }
  .pill { font-size: 10px; font-weight: 700; padding: 2px 7px; border-radius: var(--r-m); }
  .pill-stale { background: var(--warn-bg); color: var(--warn); }
  .pill-cache { background: var(--info-bg); color: var(--blue); }
  .pill-err   { background: var(--crit-bg); color: var(--red); }
  .chartbox { position: relative; height: 240px; }
  .card.full .chartbox { height: 320px; }
  .facets { display: grid; gap: 9px; width: 100%;
            grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); }
  .card.full .facets { grid-template-columns: repeat(auto-fill, minmax(195px, 1fr)); }
  .facet { border: 1px solid var(--border); border-radius: var(--r-s); padding: 5px 6px 3px;
           background: var(--hover); }
  .facet h4 { font-size: 11px; font-weight: 700; margin-bottom: 3px; white-space: nowrap;
              overflow: hidden; text-overflow: ellipsis; }
  .facet .fbox { position: relative; height: 118px; }
  .card.full .facet .fbox { height: 140px; }
  .kpi { text-align: center; padding: 10px; }
  .kpi .v { font-size: 46px; font-weight: 700; line-height: 1.05; }
  .kpi .u { font-size: 15px; color: var(--muted); margin-left: 4px; font-weight: 600; }
  .kpi .l { font-size: 13px; color: var(--muted); margin-top: 6px; }
  .minitable { overflow: auto; max-height: 300px; }
  .minitable table { border-collapse: collapse; width: 100%; font-size: 11px; }
  .minitable th { background: var(--hover); color: var(--muted); padding: 4px 8px; text-align: left;
                  position: sticky; top: 0; white-space: nowrap; }
  .minitable td { padding: 3px 8px; border-bottom: 1px solid var(--hover); white-space: nowrap;
                  max-width: 260px; overflow: hidden; text-overflow: ellipsis; }
  .cerr { background: var(--crit-bg); border: 1px solid var(--red); color: var(--red); padding: 8px 12px;
          border-radius: var(--r-s); font-family: var(--mono); font-size: 11px;
          white-space: pre-wrap; max-height: 200px; overflow: auto; }
  .empty { color: var(--muted); text-align: center; padding: 40px 20px; }
  .empty h3 { font-size: 15px; color: var(--text); margin-bottom: 8px; }

  /* Editor */
  #ov { position: fixed; inset: 0; background: rgba(0,0,0,.35); display: none; z-index: 50;
        overflow-y: auto; padding: 28px 16px; }
  #ed { background: var(--surface); max-width: 940px; margin: 0 auto; border-radius: var(--r-m);
        box-shadow: 0 10px 40px rgba(0,0,0,.2); }
  #ed .eh { padding: 12px 18px; border-bottom: 1px solid var(--border); display: flex;
            justify-content: space-between; align-items: center; }
  #ed .eh b { font-size: 15px; }
  #ed .eb { padding: 16px 18px; display: flex; flex-direction: column; gap: 14px; }
  #ed .ef { padding: 12px 18px; border-top: 1px solid var(--border); display: flex; gap: 8px;
            align-items: center; }
  .row2 { display: flex; gap: 12px; flex-wrap: wrap; }
  .fld { display: flex; flex-direction: column; gap: 4px; flex: 1; min-width: 160px; }
  .fld > span { font-size: 11px; font-weight: 700; color: var(--muted);
                text-transform: uppercase; letter-spacing: .04em; }
  .fld > span.hint { text-transform: none; font-weight: 400; letter-spacing: 0; }
  .fld input, .fld select, .fld textarea {
      padding: 7px 9px; font-size: 13px; border: 1px solid var(--border);
      border-radius: var(--r-s); background: var(--surface); color: var(--text); font-family: var(--font); }
  .fld textarea { font-family: var(--mono); min-height: 130px; resize: vertical; }
  .fld input:focus, .fld select:focus, .fld textarea:focus { outline: none; border-color: var(--acc); }
  .hint { font-size: 11px; color: var(--muted); }
  #valcols { display: flex; flex-wrap: wrap; gap: 5px; }
  #valcols label { font-size: 11px; display: flex; align-items: center; gap: 4px;
                   border: 1px solid var(--border); border-radius: var(--r-m); padding: 3px 9px;
                   cursor: pointer; background: var(--bg); }
  #valcols label.on { border-color: var(--acc); color: var(--acc); background: var(--acc-soft);
                      font-weight: 600; }
  #prevbox { max-height: 220px; overflow: auto; border: 1px solid var(--border); border-radius: var(--r-s); }
  #prevbox table { border-collapse: collapse; width: 100%; font-size: 11px; }
  #prevbox th { background: var(--hover); padding: 4px 8px; text-align: left; position: sticky; top: 0;
                white-space: nowrap; }
  #prevbox th .num { color: var(--blue); font-size: 10px; }
  #prevbox td { padding: 3px 8px; border-bottom: 1px solid var(--hover); white-space: nowrap;
                max-width: 220px; overflow: hidden; text-overflow: ellipsis; }
  #edmsg { font-size: 13px; font-weight: 600; }
  .tabs { display: flex; gap: 4px; border-bottom: 1px solid var(--border); margin-bottom: 2px; }
  .tab { padding: 7px 14px; font-size: 13px; font-weight: 600; cursor: pointer;
         color: var(--muted); border-bottom: 2px solid transparent; }
  .tab:hover { color: var(--text); }
  .tab.on { color: var(--acc); border-bottom-color: var(--acc); }
  #bpane, #spane { display: none; }
  #bpane.on, #spane.on { display: flex; flex-direction: column; gap: 14px; }
  .sentence { display: flex; flex-direction: column; gap: 9px;
              background: var(--hover); border: 1px solid var(--border);
              border-radius: var(--r-m); padding: 12px 14px; }
  .sline { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
  .sline > .w { font-size: 13px; color: var(--muted); min-width: 74px; }
  .sline select, .sline input { padding: 6px 9px; font-size: 13px;
              border: 1px solid var(--border); border-radius: var(--r-s);
              background: var(--surface); color: var(--text); font-family: var(--font); }
  .sline select:focus, .sline input:focus { outline: none; border-color: var(--acc); }
  .sline .grow { flex: 1; min-width: 150px; }
  .vsel { display: flex; flex-wrap: wrap; gap: 4px; align-items: center;
          max-height: 92px; overflow-y: auto; padding: 2px 0; }
  .vchip { font-size: 11px; border: 1px solid var(--border); border-radius: var(--r-m);
           padding: 2px 9px; cursor: pointer; background: var(--surface); white-space: nowrap; }
  .vchip:hover { border-color: var(--acc); }
  .vchip.on { background: var(--acc-soft); border-color: var(--acc); color: var(--acc);
              font-weight: 600; }
  .vchip .c { color: var(--muted); font-weight: 400; margin-left: 4px; font-size: 10px; }
  .frow2 { display: flex; gap: 6px; align-items: flex-start; flex-wrap: wrap;
           padding: 6px 0; border-top: 1px dashed var(--border); }
  .frow2:first-child { border-top: none; }
  .frow2 .rm { border: none; background: none; color: var(--muted); cursor: pointer;
               font-size: 15px; font-weight: 700; padding: 3px 4px; }
  .frow2 .rm:hover { color: var(--red); }
  #genbox { font-family: var(--mono); font-size: 11px;
            background: var(--hover); border: 1px solid var(--border); border-radius: var(--r-s);
            padding: 9px 11px; white-space: pre-wrap; max-height: 190px; overflow: auto;
            color: var(--ink); }
  .warnline { font-size: 11px; color: var(--warn); background: var(--warn-bg);
              border: 1px solid var(--amber); border-radius: var(--r-s); padding: 6px 10px; }
</style>
<!--SAP_SHELL-->
</head>
<body>
<!--SAP_RAIL-->
<header class="sap-ph"><!--SAP_PH-->
<div class="ph-right">
  <div id="conn"><span id="dot"></span><span id="conntext">prüfe…</span></div>
<!--SAP_MARK-->
</div>
</header>

<div class="page">
  <div class="head">
    <span class="hint" id="count"></span>
  </div>
  <p class="subtitle">Gespeicherte SQL-Abfragen auf msql-lud-core, als Diagramm.
     Ergebnisse werden zwischengespeichert; ist die Datenbank nicht erreichbar,
     zeigt die Karte den letzten guten Stand.</p>

  <div class="toolbar">
    <button onclick="openEditor(null)">+ Neue Auswertung</button>
    <button class="sec" onclick="refreshAll(true)">Alle aktualisieren</button>
    <input type="text" id="q" placeholder="Auswertungen filtern…" oninput="render()">
    <label class="chk"><input type="checkbox" id="auto" onchange="toggleAuto()"> alle 5 min neu laden</label>
    <span class="spacer" style="flex:1"></span>
    <a href="/sql" style="font-size:13px;color:var(--blue)">→ im SQL-Explorer eine Abfrage bauen</a>
  </div>

  <div id="grid"></div>
  <div id="emptybox"></div>
</div>

<!-- Editor -->
<div id="ov" onclick="if(event.target===this) closeEditor()">
<div id="ed">
  <div class="eh"><b id="edtitle">Neue Auswertung</b>
    <button class="sec" onclick="closeEditor()">schließen</button></div>
  <div class="eb">
    <div class="row2">
      <div class="fld" style="flex:2"><span>Titel</span>
        <input type="text" id="f_title" placeholder="z. B. Baustopps je Monat"></div>
      <div class="fld"><span>Datenbank</span>
        <select id="f_db"><option value="">– wählen –</option></select></div>
    </div>
    <div class="fld"><span>Notiz</span>
      <input type="text" id="f_note" placeholder="Was zeigt die Auswertung? Woher kommen die Zahlen?"></div>

    <div class="tabs">
      <div class="tab on" id="tab-b" onclick="showPane('b')">Baukasten</div>
      <div class="tab"    id="tab-s" onclick="showPane('s')">SQL</div>
      <span style="flex:1"></span>
      <span class="hint" id="srcinfo" style="align-self:center"></span>
    </div>

    <div id="bpane" class="on">
      <div class="sentence">
        <div class="sline">
          <span class="w">Zeige</span>
          <select id="b_metric" class="grow" onchange="bChanged()"></select>
        </div>
        <div class="sline">
          <span class="w">je</span>
          <select id="b_grain" onchange="bChanged()"></select>
          <span style="font-size:13px;color:var(--muted)">von</span>
          <select id="b_group" class="grow" onchange="bGroupChanged()"></select>
        </div>
        <div class="sline">
          <span class="w">aufgeteilt</span>
          <select id="b_split" onchange="bSplitChanged()"></select>
          <div class="vsel" id="b_splitvals" style="flex:1"></div>
        </div>
        <div class="sline">
          <span class="w">je Kachel</span>
          <select id="b_facet" onchange="bFacetChanged()"></select>
          <div class="vsel" id="b_facetvals" style="flex:1"></div>
        </div>
        <div class="sline" style="align-items:flex-start">
          <span class="w">nur wo</span>
          <div style="flex:1">
            <div id="b_filters"></div>
            <button class="sec" style="margin-top:4px" onclick="bAddFilter()">+ Bedingung</button>
          </div>
        </div>
        <div class="sline">
          <span class="w">sortiert</span>
          <select id="b_sort" onchange="bChanged()">
            <option value="label">nach Beschriftung (zeitlich)</option>
            <option value="label_desc">nach Beschriftung, absteigend</option>
            <option value="wert">nach Wert, größte zuerst</option>
          </select>
          <span style="font-size:13px;color:var(--muted)">, höchstens</span>
          <input type="number" id="b_limit" min="0" step="5" value="0" style="width:80px"
                 onchange="bChanged()" oninput="bChanged()">
          <span style="font-size:13px;color:var(--muted)">Zeilen (0 = alle)</span>
        </div>
      </div>
      <div id="bwarn"></div>
      <div>
        <div class="hint" style="margin-bottom:4px">Daraus wird diese Abfrage:</div>
        <div id="genbox">—</div>
      </div>
      <div class="row2">
        <button class="sec" onclick="bApply(true)">Übernehmen &amp; Vorschau</button>
        <span id="bmsg" class="hint" style="align-self:center"></span>
      </div>
    </div>

    <div id="spane">
      <div class="fld"><span>SQL (nur SELECT / WITH)</span>
        <textarea id="f_sql" spellcheck="false" placeholder="SELECT TOP 100 …"></textarea>
        <span class="hint">Strg/Cmd+Enter führt die Vorschau aus. Erste Spalte = Beschriftung,
          numerische Spalten = Werte. Von Hand geändertes SQL wird vom Baukasten nicht
          zurückgelesen.</span></div>
      <div class="row2">
        <button class="sec" onclick="runPreview()">Vorschau ausführen</button>
        <span id="prevmsg" class="hint" style="align-self:center"></span>
      </div>
      <div id="prevbox" style="display:none"></div>
    </div>

    <div class="row2">
      <div class="fld"><span>Darstellung</span>
        <select id="f_viz" onchange="syncEditor()">
          <option value="bar">Balken (senkrecht)</option>
          <option value="hbar">Balken (waagerecht)</option>
          <option value="line">Linie</option>
          <option value="area">Fläche</option>
          <option value="pie">Kreis</option>
          <option value="doughnut">Ring</option>
          <option value="kpi">Kennzahl (eine Zahl)</option>
          <option value="table">Tabelle</option>
        </select></div>
      <div class="fld"><span>Beschriftungsspalte</span>
        <select id="f_label"><option value="">– erste Spalte –</option></select></div>
      <div class="fld"><span>Kachelspalte</span>
        <select id="f_facet"><option value="">– eine Darstellung –</option></select>
        <span class="hint">je Wert ein kleines Diagramm, gemeinsame Skala</span></div>
      <div class="fld"><span>Einheit</span>
        <input type="text" id="f_unit" placeholder="h, Stück, %"></div>
    </div>
    <div class="fld"><span>Wertespalten</span>
      <div id="valcols"><span class="hint">Erst eine Vorschau ausführen.</span></div></div>
    <div class="row2">
      <div class="fld"><span>Breite</span>
        <select id="f_width"><option value="half">halbe Seite</option>
          <option value="full">volle Breite</option></select></div>
      <div class="fld"><span>Neu laden nach (Sekunden)</span>
        <input type="number" id="f_ttl" value="900" min="0" step="60"></div>
      <div class="fld"><span>Gestapelt</span>
        <select id="f_stacked"><option value="0">nein</option><option value="1">ja</option></select></div>
    </div>
  </div>
  <div class="ef">
    <button onclick="saveChart()">Speichern</button>
    <button class="sec" onclick="closeEditor()">Abbrechen</button>
    <button class="danger" id="delbtn" onclick="deleteChart()" style="display:none">Löschen</button>
    <span id="edmsg"></span>
  </div>
</div>
</div>

<script>
const PALETTE = ['#1a5fa8','#c0392b','#27ae60','#b8860b','#7e57c2','#00897b',
                 '#ef6c00','#5d4037','#546e7a','#d81b60'];
let CHARTS = [], RESULTS = {}, CANVAS = {}, EDITING = null, PREVIEW = null, AUTOTIMER = null;
let PENDING_COLS = null;      // vom Baukasten gesetzte Spaltenzuordnung


// ── Abfrage-Baukasten ─────────────────────────────────────────────────────
// Die Auswahl wird an /api/hub/build geschickt; der Server erzeugt das SQL.
// Einbahnstraße: Baukasten schreibt SQL, SQL wird nicht zurückgelesen.
let CAT = null, VALCACHE = {}, BUILT = null;
// Die zuletzt übernommene Auswahl wird mit der Auswertung gespeichert, damit
// der Editor sie wieder aufklappen kann. BDRIFT merkt sich, dass das gespeicherte
// SQL seither von Hand geändert wurde.
let LAST_SPEC = null, LAST_SPEC_SQL = '', BDRIFT = false;

async function loadCatalog() {
  if (CAT) return CAT;
  CAT = await (await fetch('/api/hub/catalog')).json();
  const info = document.getElementById('srcinfo');
  if (CAT.error) { info.textContent = CAT.error; return CAT; }
  info.textContent = CAT.database + ' · ' + CAT.table;

  fill('b_metric', CAT.metrics.map(m => [m.key, m.label]));
  fill('b_grain',  Object.entries(CAT.grains));
  renderFieldSelects();
  loadFill();                    // Füllgrad kommt im Hintergrund nach
  document.getElementById('b_grain').value = 'monat';
  return CAT;
}

function fill(id, pairs) {
  document.getElementById(id).innerHTML =
    pairs.map(([v, l]) => `<option value="${esc(v)}">${esc(l)}</option>`).join('');
}

// Felder nach Herkunft gruppiert (Incident / SAP-Projekt / Betroffene Teile) —
// Teilefelder schalten den Teile-Modus, das soll man schon an der Liste sehen.
// Füllgrad: unter FILL_LOW gilt ein Feld als fast leer — es rutscht in seiner
// Gruppe nach hinten und wird grau, bleibt aber wählbar (Filter „ist gefüllt"
// auf ein seltenes Feld kann genau die Frage sein).
const FILL_LOW = 10;
function fieldOptions(fields, empty, markTime) {
  const groups = {};
  fields.forEach(f => { const g = f.bereich || 'Incident'; (groups[g] = groups[g] || []).push(f); });
  const low = f => f.fill !== undefined && f.fill < FILL_LOW;
  return (empty !== undefined ? `<option value="">${esc(empty)}</option>` : '')
    + Object.entries(groups).map(([g, list]) =>
        `<optgroup label="${esc(g)}">`
        + list.slice().sort((a, b) => low(a) - low(b)).map(f =>
            `<option value="${esc(f.key)}"${low(f) ? ' style="color:var(--ink-3)"' : ''}>${esc(f.label)}`
            + (markTime && f.kind === 'zeit' ? ' (Zeit)' : '')
            + (f.fill !== undefined && f.fill < 99.5
               ? ' · ' + (f.fill < 1 && f.fill > 0 ? '<1' : Math.round(f.fill)) + ' %'
                 + (low(f) ? ' — fast leer' : '') : '')
            + '</option>').join('')
        + '</optgroup>').join('');
}

function renderFieldSelects() {
  const kat = CAT.fields.filter(f => f.kind === 'kategorie');
  const set = (el, html) => { const v = el.value; el.innerHTML = html; el.value = v; };
  set(document.getElementById('b_group'), fieldOptions(CAT.fields, '— nichts, eine einzelne Zahl —', true));
  set(document.getElementById('b_split'), fieldOptions(kat, '— nicht aufteilen —'));
  set(document.getElementById('b_facet'), fieldOptions(kat, '— alles in einem Diagramm —'));
  document.querySelectorAll('#b_filters select.ff').forEach(el => set(el, fieldOptions(CAT.fields)));
}

async function loadFill() {
  try {
    const d = await (await fetch('/api/hub/fill')).json();
    if (!d.fill) return;
    CAT.fields.forEach(f => { if (d.fill[f.key] !== undefined) f.fill = d.fill[f.key]; });
    renderFieldSelects();
  } catch (e) { /* ohne Füllgrad geht es auch */ }
}

function fieldOf(key) { return (CAT && CAT.fields || []).find(f => f.key === key); }

function showPane(which) {
  document.getElementById('tab-b').classList.toggle('on', which === 'b');
  document.getElementById('tab-s').classList.toggle('on', which === 's');
  document.getElementById('bpane').classList.toggle('on', which === 'b');
  document.getElementById('spane').classList.toggle('on', which === 's');
}

async function loadValues(key) {
  if (VALCACHE[key]) return VALCACHE[key];
  const d = await (await fetch('/api/hub/values', {
    method: 'POST', headers: {'Content-Type': 'application/json'},
    body: JSON.stringify({field: key})
  })).json();
  VALCACHE[key] = d.error ? [] : (d.values || []);
  return VALCACHE[key];
}

function bGroupChanged() {
  const f = fieldOf(document.getElementById('b_group').value);
  const isTime = f && f.kind === 'zeit';
  document.getElementById('b_grain').style.display = isTime ? '' : 'none';
  document.getElementById('b_sort').value = isTime ? 'label' : 'wert';
  if (!isTime && f) document.getElementById('b_limit').value = 15;
  if (isTime) document.getElementById('b_limit').value = 0;
  bChanged();
}

async function bChips(boxId, key, pre, defOn, note) {
  const box = document.getElementById(boxId);
  if (!key) { box.innerHTML = ''; bChanged(); return; }
  box.innerHTML = '<span class="hint">lade Werte…</span>';
  const vals = await loadValues(key);
  if (!vals.length) { box.innerHTML = '<span class="hint">keine Werte gefunden</span>'; return; }
  const list = vals.slice(0, 40).map(x => ({v: String(x.v), n: x.n}));
  // Gespeicherte Werte, die es nicht mehr unter den häufigsten gibt, trotzdem zeigen.
  const chosen = (pre && pre.length) ? pre.map(String) : null;
  if (chosen) chosen.forEach(v => {
    if (!list.some(x => x.v === v)) list.unshift({v: v, n: 0});
  });
  box.innerHTML = list.map((x, i) => {
    const on = chosen ? chosen.indexOf(x.v) >= 0 : i < defOn;
    return `<span class="vchip ${on ? 'on' : ''}" onclick="this.classList.toggle('on');bChanged()"
             data-v="${esc(x.v)}">${esc(x.v)}<span class="c">${x.n}</span></span>`;
  }).join('') + '<span class="hint" style="width:100%">' + note + '</span>';
  bChanged();
}

function bSplitChanged(pre) {
  return bChips('b_splitvals', document.getElementById('b_split').value, pre, 4,
                'Angehakte Werte werden je eine Reihe.');
}

function bFacetChanged(pre) {
  return bChips('b_facetvals', document.getElementById('b_facet').value, pre, 6,
                'Angehakte Werte werden je eine Kachel — gleiche Achsen, gemeinsame Skala.');
}

function bChipValues(boxId) {
  return Array.from(document.querySelectorAll('#' + boxId + ' .vchip.on'))
              .map(e => e.dataset.v);
}
function bSplitValues() { return bChipValues('b_splitvals'); }

// Vom Server vorgeschlagener Filter (z. B. CK00 ausblenden) als Filterzeile
function bSuggest() {
  const s = BUILT && BUILT.suggest_filter;
  if (!s) return;
  Promise.resolve(bAddFilter({field: s.field, op: s.op, values: s.values})).then(() => bChanged());
}

function bAddFilter(pre) {
  const box = document.getElementById('b_filters');
  const row = document.createElement('div');
  row.className = 'frow2';
  row.innerHTML =
    `<select class="ff" onchange="bFilterFieldChanged(this)">`
    + fieldOptions(CAT.fields)
    + `</select><select class="fo" onchange="bFilterOpChanged(this)"></select>`
    + `<div class="fv" style="flex:1;min-width:170px"></div>`
    + `<button class="rm" onclick="this.closest('.frow2').remove();bChanged()">&times;</button>`;
  box.appendChild(row);
  if (pre && pre.field) row.querySelector('.ff').value = pre.field;
  return bFilterFieldChanged(row.querySelector('.ff'), pre);
}

async function bFilterFieldChanged(sel, pre) {
  const row  = sel.closest('.frow2');
  const f    = fieldOf(sel.value);
  const ops  = Object.entries(CAT.ops).filter(([k, o]) => o.kinds.indexOf(f.kind) >= 0);
  row.querySelector('.fo').innerHTML =
    ops.map(([k, o]) => `<option value="${esc(k)}">${esc(o.label)}</option>`).join('');
  if (pre && pre.op) row.querySelector('.fo').value = pre.op;
  await bFilterValues(row, pre);
}

// Wechselt der Operator, passt das Wertefeld: "enthält" will Freitext,
// "ist gefüllt" gar nichts, "ist" die angebotenen Werte.
function bFilterOpChanged(sel) { bFilterValues(sel.closest('.frow2'), null); }

async function bFilterValues(row, pre) {
  const f  = fieldOf(row.querySelector('.ff').value);
  const op = row.querySelector('.fo').value;
  const fv = row.querySelector('.fv');
  const v0 = esc((pre && pre.values && pre.values.join('; ')) || '');
  if (op === 'gefuellt' || op === 'leer') {
    fv.innerHTML = '<span class="hint">— kein Wert nötig —</span>';
  } else if (f.kind === 'kategorie' && op === 'enthaelt') {
    // Freitext: der Wert muss kein vollständiger Feldwert sein. Mehrere
    // Textteile mit ";" trennen — der Generator verodert sie.
    const dlid = 'dl_' + f.key;
    fv.innerHTML = `<input type="text" class="ftext" list="${dlid}" style="width:100%"
                    placeholder="Textteil, z. B. Test — mehrere mit ; trennen"
                    onchange="bChanged()" oninput="bChanged()" value="${v0}">
                    <datalist id="${dlid}"></datalist>
                    <span class="hint">sucht irgendwo im Feld (LIKE %…%)</span>`;
    const vals = await loadValues(f.key);        // nur als Tippvorschlag
    const dl = fv.querySelector('datalist');
    if (dl) dl.innerHTML = vals.slice(0, 60)
      .map(x => `<option value="${esc(x.v)}"></option>`).join('');
  } else if (f.kind === 'kategorie') {
    fv.innerHTML = '<span class="hint">lade Werte…</span>';
    const vals = await loadValues(f.key);
    const chosen = (pre && pre.values) || [];
    fv.innerHTML = '<div class="vsel">' + vals.slice(0, 60).map(x =>
      `<span class="vchip ${chosen.indexOf(String(x.v)) >= 0 ? 'on' : ''}"
             onclick="this.classList.toggle('on');bChanged()"
             data-v="${esc(x.v)}">${esc(x.v)}<span class="c">${x.n}</span></span>`).join('')
      + '</div>';
  } else if (f.kind === 'zeit') {
    fv.innerHTML = `<input type="date" class="fdate" onchange="bChanged()"
                    value="${esc((pre && pre.values && pre.values[0]) || '')}">`;
  } else {
    fv.innerHTML = `<input type="number" class="fnum" step="1" onchange="bChanged()"
                    oninput="bChanged()" value="${esc((pre && pre.values && pre.values[0]) || '')}">`;
  }
  bChanged();
}

function bSpec() {
  const filters = [];
  document.querySelectorAll('#b_filters .frow2').forEach(r => {
    const field = r.querySelector('.ff').value;
    const op    = r.querySelector('.fo').value;
    let values  = Array.from(r.querySelectorAll('.vchip.on')).map(e => e.dataset.v);
    const d = r.querySelector('.fdate'), n = r.querySelector('.fnum');
    const t = r.querySelector('.ftext');
    if (d && d.value) values = [d.value];
    if (n && n.value !== '') values = [n.value];
    if (t) values = t.value.split(';').map(s => s.trim()).filter(s => s !== '');
    filters.push({field, op, values});
  });
  return {
    metric: document.getElementById('b_metric').value,
    group:  document.getElementById('b_group').value,
    grain:  document.getElementById('b_grain').value,
    split:  document.getElementById('b_split').value,
    split_values: bSplitValues(),
    facet:  document.getElementById('b_facet').value,
    facet_values: bChipValues('b_facetvals'),
    sort:   document.getElementById('b_sort').value,
    limit:  parseInt(document.getElementById('b_limit').value || '0', 10),
    filters: filters,
  };
}

let _bTimer = null;
function bChanged() {
  clearTimeout(_bTimer);
  _bTimer = setTimeout(bGenerate, 180);     // Tippen nicht bei jedem Zeichen bauen
}

async function bGenerate() {
  const warn = document.getElementById('bwarn');
  const d = await (await fetch('/api/hub/build', {
    method: 'POST', headers: {'Content-Type': 'application/json'},
    body: JSON.stringify({spec: bSpec()})
  })).json();
  if (d.error) {
    document.getElementById('genbox').textContent = '—';
    warn.innerHTML = '<div class="warnline">' + esc(d.error) + '</div>';
    BUILT = null;
    return;
  }
  BUILT = d;
  document.getElementById('genbox').textContent = d.sql;
  const f = fieldOf(document.getElementById('b_group').value);
  warn.innerHTML =
    (BDRIFT ? '<div class="warnline">Das gespeicherte SQL wurde von Hand nachbearbeitet. '
            + 'Der Baukasten zeigt den Stand von davor — „Übernehmen" verwirft die Handarbeit.'
            + '</div>' : '')
    + ((f && f.hint)
       ? '<div class="warnline">' + esc(f.label) + ': ' + esc(f.hint) + '</div>' : '')
    + ((f && f.fill !== undefined && f.fill < 50)
       ? '<div class="warnline">' + esc(f.label) + ' ist nur zu ' + Math.round(f.fill)
         + ' % gefüllt — der Rest landet als Balken ohne Beschriftung (leer) im Diagramm.</div>' : '')
    + (d.warnings || []).map(w => '<div class="warnline">' + esc(w) + '</div>').join('')
    + (d.suggest_filter
       ? '<div class="warnline"><button class="sec" onclick="bSuggest()">'
         + esc(d.suggest_filter.label) + '</button></div>' : '');
  return d;
}

async function bApply(runPrev) {
  const d = BUILT || await bGenerate();
  if (!d) return;
  document.getElementById('f_sql').value   = d.sql;
  document.getElementById('f_db').value    = d.database;
  document.getElementById('f_viz').value   = d.viz;
  document.getElementById('f_unit').value  = d.unit || '';
  document.getElementById('f_stacked').value = d.stacked ? '1' : '0';
  if (!document.getElementById('f_title').value.trim())
    document.getElementById('f_title').value = d.title;
  if (d.wide) document.getElementById('f_width').value = 'full';
  PENDING_COLS = {label: d.label_col, values: d.value_cols, facet: d.facet_col || ''};
  LAST_SPEC = bSpec(); LAST_SPEC_SQL = d.sql; BDRIFT = false;
  document.getElementById('bwarn').innerHTML = '';
  document.getElementById('bmsg').textContent = 'übernommen';
  if (runPrev) { showPane('s'); await runPreview(); }
}

// ── Laden ─────────────────────────────────────────────────────────────────
async function init() {
  checkStatus();
  await loadDatabases();
  await loadCharts();
  const pending = sessionStorage.getItem('hub_new');     // Übergabe aus /sql
  if (pending) {
    sessionStorage.removeItem('hub_new');
    try { openEditor(null, JSON.parse(pending)); } catch (e) {}
  }
  if (localStorage.getItem('hub_auto') === '1') {
    document.getElementById('auto').checked = true; toggleAuto();
  }
}

async function checkStatus() {
  const dot = document.getElementById('dot'), txt = document.getElementById('conntext');
  try {
    const s = await (await fetch('/api/sql/status')).json();
    dot.className = s.ok ? 'ok' : 'err';
    txt.textContent = s.ok ? s.server + ' verbunden' : 'keine DB-Verbindung';
  } catch (e) { dot.className = 'err'; txt.textContent = 'Server nicht erreichbar'; }
}

async function loadDatabases() {
  try {
    const d = await (await fetch('/api/sql/databases', {
      method: 'POST', headers: {'Content-Type': 'application/json'}, body: '{}'
    })).json();
    const sel = document.getElementById('f_db');
    (d.databases || []).filter(x => x.accessible).forEach(x => {
      const o = document.createElement('option'); o.value = x.name; o.textContent = x.name;
      sel.appendChild(o);
    });
  } catch (e) {}
}

async function loadCharts() {
  const d = await (await fetch('/api/hub/charts')).json();
  CHARTS = d.charts || [];
  render();
  CHARTS.forEach(c => runChart(c.id, false));
}

// ── Raster ────────────────────────────────────────────────────────────────
function visible() {
  const q = document.getElementById('q').value.toLowerCase();
  return !q ? CHARTS : CHARTS.filter(c =>
    (c.title + ' ' + c.note + ' ' + c.sql).toLowerCase().includes(q));
}

function render() {
  const list = visible();
  document.getElementById('count').textContent =
    CHARTS.length ? list.length + ' von ' + CHARTS.length + ' Auswertungen' : '';
  const grid = document.getElementById('grid');
  const eb   = document.getElementById('emptybox');
  if (!CHARTS.length) {
    grid.innerHTML = '';
    eb.innerHTML = '<div class="empty"><h3>Noch keine Auswertung gespeichert</h3>'
      + 'Eine Auswertung ist eine SQL-Abfrage plus Diagrammtyp — einmal angelegt, '
      + 'bleibt sie hier stehen und lädt sich selbst nach.<br><br>'
      + '<button onclick="openEditor(null)">+ Erste Auswertung anlegen</button></div>';
    return;
  }
  eb.innerHTML = '';
  grid.innerHTML = list.map(c => cardHtml(c)).join('');
  list.forEach(c => paint(c.id));
}

function cardHtml(c) {
  return `<div class="card ${c.width === 'full' ? 'full' : ''}" id="card_${c.id}"
               draggable="false" ondragover="dragOver(event,'${c.id}')" ondrop="dropOn(event,'${c.id}')">
    <div class="card-head">
      <span class="grip" draggable="true" title="verschieben"
            ondragstart="dragStart(event,'${c.id}')" ondragend="dragEnd(event)">⠿</span>
      <div class="t">
        <h3>${esc(c.title)}</h3>
        ${c.note ? '<p>' + esc(c.note) + '</p>' : ''}
      </div>
      <div class="acts">
        <button class="sec" title="neu laden" onclick="runChart('${c.id}', true)">↻</button>
        <button class="sec" title="CSV" onclick="exportChart('${c.id}')">CSV</button>
        <button class="sec" onclick="openEditor('${c.id}')">Bearbeiten</button>
      </div>
    </div>
    <div class="card-body" id="body_${c.id}"><div class="empty">Lade…</div></div>
    <div class="card-foot" id="foot_${c.id}"></div>
  </div>`;
}

async function runChart(id, force) {
  const body = document.getElementById('body_' + id);
  if (body) body.innerHTML = '<div class="empty">Lade…</div>';
  try {
    const d = await (await fetch('/api/hub/run', {
      method: 'POST', headers: {'Content-Type': 'application/json'},
      body: JSON.stringify({id, force: !!force})
    })).json();
    RESULTS[id] = d;
  } catch (e) {
    RESULTS[id] = {error: 'Netzwerkfehler: ' + e.message};
  }
  paint(id);
}

function refreshAll(force) { visible().forEach(c => runChart(c.id, force)); checkStatus(); }

function toggleAuto() {
  const on = document.getElementById('auto').checked;
  localStorage.setItem('hub_auto', on ? '1' : '0');
  if (AUTOTIMER) { clearInterval(AUTOTIMER); AUTOTIMER = null; }
  if (on) AUTOTIMER = setInterval(() => refreshAll(false), 300000);
}

// ── Zeichnen ──────────────────────────────────────────────────────────────
function paint(id) {
  const c = CHARTS.find(x => x.id === id), res = RESULTS[id];
  const body = document.getElementById('body_' + id);
  const foot = document.getElementById('foot_' + id);
  if (!c || !body) return;
  if (CANVAS[id]) {
    (Array.isArray(CANVAS[id]) ? CANVAS[id] : [CANVAS[id]]).forEach(ch => ch.destroy());
    delete CANVAS[id];
  }
  body.classList.remove('top');
  if (!res) { body.innerHTML = '<div class="empty">–</div>'; return; }

  if (res.error && !res.columns) {
    body.innerHTML = '<div class="cerr">' + esc(res.error) + '</div>';
    if (foot) foot.innerHTML = '<span class="pill pill-err">Fehler</span>';
    return;
  }
  const cols = res.columns || [], rows = res.rows || [];
  if (!rows.length) {
    body.innerHTML = '<div class="empty">Abfrage lieferte keine Zeilen.</div>';
  } else if (c.viz === 'table') {
    body.classList.add('top');
    body.innerHTML = '<div class="minitable"><table><thead><tr>'
      + cols.map(h => '<th>' + esc(h) + '</th>').join('')
      + '</tr></thead><tbody>'
      + rows.slice(0, 200).map(r => '<tr>' + r.map(v =>
          '<td title="' + esc(v == null ? '' : v) + '">' + (v == null ? '–' : esc(v)) + '</td>'
        ).join('') + '</tr>').join('')
      + '</tbody></table></div>';
  } else if (c.viz === 'kpi') {
    const vi = c.value_cols.length ? cols.indexOf(c.value_cols[0]) : firstNumeric(cols, rows);
    const v  = vi >= 0 ? rows[0][vi] : null;
    body.innerHTML = '<div class="kpi"><div class="v">'
      + (v == null ? '–' : fmt(v))
      + (c.unit ? '<span class="u">' + esc(c.unit) + '</span>' : '')
      + '</div><div class="l">' + esc(vi >= 0 ? cols[vi] : '') + '</div></div>';
  } else if (c.facet_col && cols.indexOf(c.facet_col) >= 0) {
    body.classList.add('top');
    drawFacets(c, res, cols.indexOf(c.facet_col), body);
  } else {
    body.innerHTML = '<div class="chartbox"><canvas id="cv_' + id + '"></canvas></div>';
    drawChart(c, res);
  }

  if (foot) {
    const bits = [];
    if (res.stale) bits.push('<span class="pill pill-stale">veralteter Stand</span>');
    else if (res.cached) bits.push('<span class="pill pill-cache">aus Cache</span>');
    bits.push(rows.length + ' Zeilen');
    if (res.ms) bits.push(res.ms + ' ms');
    if (res.ts) bits.push('Stand ' + res.ts.replace('T', ' '));
    if (c.database) bits.push(esc(c.database));
    if (res.truncated) bits.push('<span class="pill pill-stale">gekappt</span>');
    if (res.stale && res.error) bits.push('<span title="' + esc(res.error)
      + '" style="color:var(--red);cursor:help">DB-Fehler ⓘ</span>');
    foot.innerHTML = bits.join(' · ');
  }
}

function firstNumeric(cols, rows) {
  for (let i = 0; i < cols.length; i++) {
    const v = rows[0][i];
    if (typeof v === 'number') return i;
  }
  return -1;
}

// Beschriftungsspalte: gewählte, sonst die erste, die nicht die Kachelspalte ist.
function labelIndex(c, cols, skip) {
  const i = c.label_col ? cols.indexOf(c.label_col) : -1;
  if (i >= 0 && i !== skip) return i;
  for (let k = 0; k < cols.length; k++) if (k !== skip) return k;
  return 0;
}

// Wertespalten: gewählte, sonst alle numerischen außer Beschriftung und Kachel.
function valueIndices(c, cols, rows, skip, li) {
  let vidx = (c.value_cols || []).map(n => cols.indexOf(n))
                                 .filter(i => i >= 0 && i !== li && i !== skip);
  if (!vidx.length) {
    vidx = cols.map((_, i) => i)
               .filter(i => i !== li && i !== skip && typeof rows[0][i] === 'number');
    if (!vidx.length) vidx = [cols.length > 1 ? 1 : 0];
  }
  return vidx;
}

// Eine Kachel je Wert der Kachelspalte — gleiche Achsen, gemeinsame Skala,
// sonst vergleicht man Formen, die nichts miteinander zu tun haben.
function drawFacets(c, res, fi, body) {
  const cols = res.columns, rows = res.rows;
  const keys = [], buckets = {};
  rows.forEach(r => {                       // Reihenfolge aus dem SQL übernehmen
    const k = r[fi] == null ? '–' : String(r[fi]);
    if (!(k in buckets)) { buckets[k] = []; keys.push(k); }
    buckets[k].push(r);
  });
  const li   = labelIndex(c, cols, fi);
  const vidx = valueIndices(c, cols, rows, fi, li);
  let max = 0;
  rows.forEach(r => {
    if (c.stacked) {
      let sum = 0;
      vidx.forEach(i => { const n = num(r[i]); if (n != null) sum += n; });
      if (sum > max) max = sum;
    } else {
      vidx.forEach(i => { const n = num(r[i]); if (n != null && n > max) max = n; });
    }
  });
  body.innerHTML = '<div class="facets">' + keys.map((k, n) =>
      '<div class="facet"><h4 title="' + esc(k) + '">' + esc(k) + '</h4>'
      + '<div class="fbox"><canvas id="cvf_' + c.id + '_' + n + '"></canvas></div></div>'
    ).join('') + '</div>';
  CANVAS[c.id] = keys.map((k, n) => buildChart('cvf_' + c.id + '_' + n, c, cols,
      buckets[k], li, vidx, {max: max, compact: true, legend: n === 0}));
}

function drawChart(c, res) {
  const li   = labelIndex(c, res.columns, -1);
  const vidx = valueIndices(c, res.columns, res.rows, -1, li);
  CANVAS[c.id] = buildChart('cv_' + c.id, c, res.columns, res.rows, li, vidx, {});
}

function buildChart(canvasId, c, cols, rows, li, vidx, o) {
  const labels = rows.map(r => r[li] == null ? '–' : String(r[li]));
  const pie    = c.viz === 'pie' || c.viz === 'doughnut';
  const datasets = vidx.map((i, k) => ({
    label: cols[i],
    data: rows.map(r => num(r[i])),
    backgroundColor: pie ? rows.map((_, ri) => PALETTE[ri % PALETTE.length])
                         : PALETTE[k % PALETTE.length],
    borderColor: PALETTE[k % PALETTE.length],
    borderWidth: pie ? 0 : (c.viz === 'line' || c.viz === 'area' ? 2 : 0),
    fill: c.viz === 'area',
    tension: .25,
    pointRadius: rows.length > 40 ? 0 : 3,
  }));
  const type = c.viz === 'hbar' ? 'bar'
             : c.viz === 'area' ? 'line'
             : c.viz === 'pie' ? 'pie'
             : c.viz === 'doughnut' ? 'doughnut' : c.viz;
  const unit = c.unit ? ' ' + c.unit : '';
  const fs   = o.compact ? 9 : 10;
  const vax  = {stacked: !!c.stacked, beginAtZero: true, ticks: {font: {size: fs}}};
  if (o.max) vax.suggestedMax = o.max;     // gemeinsame Skala über alle Kacheln
  const lax  = {stacked: !!c.stacked,
                ticks: {font: {size: fs}, maxRotation: 60, autoSkip: true}};
  const showLegend = (pie || datasets.length > 1) && (o.legend !== false);
  return new Chart(document.getElementById(canvasId).getContext('2d'), {
    type,
    data: {labels, datasets},
    options: {
      responsive: true, maintainAspectRatio: false,
      indexAxis: c.viz === 'hbar' ? 'y' : 'x',
      animation: o.compact ? false : undefined,
      plugins: {
        legend: {display: showLegend,
                 position: pie ? 'right' : 'top',
                 labels: {boxWidth: 12, font: {size: o.compact ? 10 : 11}}},
        tooltip: {callbacks: {label: it =>
          (it.dataset.label ? it.dataset.label + ': ' : '')
          + fmt(pie ? it.parsed : (c.viz === 'hbar' ? it.parsed.x : it.parsed.y)) + unit}},
      },
      scales: pie ? {} : (c.viz === 'hbar' ? {x: vax, y: lax} : {x: lax, y: vax}),
    },
  });
}

function num(v) { const n = Number(v); return isNaN(n) ? null : n; }
function fmt(v) {
  if (typeof v !== 'number') return String(v);
  return Number.isInteger(v) ? v.toLocaleString('de-DE')
                             : v.toLocaleString('de-DE', {maximumFractionDigits: 2});
}

function exportChart(id) {
  const f = document.createElement('form');
  f.method = 'POST'; f.action = '/api/hub/export-csv-form'; f.style.display = 'none';
  const i = document.createElement('input'); i.name = 'id'; i.value = id; f.appendChild(i);
  document.body.appendChild(f); f.submit(); setTimeout(() => f.remove(), 2000);
}

// ── Reihenfolge ───────────────────────────────────────────────────────────
let DRAG = null;
function dragStart(e, id) { DRAG = id; document.getElementById('card_' + id).classList.add('drag'); }
function dragEnd(e) {
  if (DRAG) { const el = document.getElementById('card_' + DRAG); if (el) el.classList.remove('drag'); }
  DRAG = null;
}
function dragOver(e, id) { if (DRAG && DRAG !== id) e.preventDefault(); }
async function dropOn(e, id) {
  e.preventDefault();
  if (!DRAG || DRAG === id) return;
  const ids = CHARTS.map(c => c.id);
  ids.splice(ids.indexOf(DRAG), 1);
  ids.splice(ids.indexOf(id), 0, DRAG);
  CHARTS = ids.map(i => CHARTS.find(c => c.id === i));
  dragEnd(e); render(); Object.keys(RESULTS).forEach(k => paint(k));
  await fetch('/api/hub/charts/reorder', {
    method: 'POST', headers: {'Content-Type': 'application/json'},
    body: JSON.stringify({ids})
  });
}

// ── Editor ────────────────────────────────────────────────────────────────
function openEditor(id, prefill) {
  EDITING = id ? CHARTS.find(c => c.id === id) : null;
  PREVIEW = null;
  const c = EDITING || Object.assign(
    {title: '', note: '', database: localStorage.getItem('mtuhub_db') || '', sql: '',
     viz: 'bar', label_col: '', value_cols: [], facet_col: '', unit: '', width: 'half',
     ttl_s: 900, stacked: false, spec: null, spec_sql: ''}, prefill || {});
  const spec  = c.spec || null;
  // Auswahl und SQL laufen auseinander, sobald jemand im SQL-Reiter nachbessert.
  const drift = !!spec && (c.sql || '').trim() !== (c.spec_sql || '').trim();
  LAST_SPEC = null; LAST_SPEC_SQL = ''; BDRIFT = drift;
  document.getElementById('edtitle').textContent = EDITING ? 'Auswertung bearbeiten' : 'Neue Auswertung';
  document.getElementById('f_title').value  = c.title || '';
  document.getElementById('f_note').value   = c.note || '';
  document.getElementById('f_db').value     = c.database || '';
  document.getElementById('f_sql').value    = c.sql || '';
  document.getElementById('f_viz').value    = c.viz || 'bar';
  document.getElementById('f_unit').value   = c.unit || '';
  document.getElementById('f_width').value  = c.width || 'half';
  document.getElementById('f_ttl').value    = c.ttl_s != null ? c.ttl_s : 900;
  document.getElementById('f_stacked').value = c.stacked ? '1' : '0';
  document.getElementById('delbtn').style.display = EDITING ? '' : 'none';
  document.getElementById('edmsg').textContent = '';
  document.getElementById('prevmsg').textContent = '';
  document.getElementById('prevbox').style.display = 'none';
  fillColumnPickers(c.label_col, c.value_cols || [], c.facet_col || '');
  document.getElementById('ov').style.display = 'block';
  document.getElementById('f_title').focus();

  // Mit gespeicherter Auswahl öffnet der Baukasten die Auswertung wieder so,
  // wie sie gebaut wurde. Nur von Hand geschriebenes SQL startet im SQL-Reiter.
  showPane(spec && !drift ? 'b' : (EDITING || (prefill && prefill.sql) ? 's' : 'b'));
  BUILT = null; PENDING_COLS = null;
  document.getElementById('bmsg').textContent = '';
  document.getElementById('bwarn').innerHTML = '';
  document.getElementById('b_filters').innerHTML = '';
  document.getElementById('b_splitvals').innerHTML = '';
  document.getElementById('b_facetvals').innerHTML = '';
  loadCatalog().then(cat => {
    if (cat && !cat.error) {
      const dbsel = document.getElementById('f_db');   // Quelle des Baukastens
      if (!dbsel.value && cat.database) dbsel.value = cat.database;
      if (spec) { bRestore(spec); return; }
      document.getElementById('b_split').value = '';
      document.getElementById('b_facet').value = '';
      bGroupChanged();
    } else {
      document.getElementById('bpane').innerHTML =
        '<div class="warnline">Baukasten nicht verfügbar: '
        + esc((cat && cat.error) || 'unbekannt')
        + '<br>Der SQL-Reiter funktioniert weiterhin.</div>';
    }
  });
  if (c.sql && c.database) runPreview();
}

function closeEditor() { document.getElementById('ov').style.display = 'none'; EDITING = null; }

// Gespeicherte Auswahl zurück in die Bedienelemente. Felder, die es im Katalog
// nicht mehr gibt, fallen dabei still auf die Vorgabe zurück — der Hinweis über
// dem SQL zeigt dann ohnehin, dass etwas nicht mehr passt.
async function bRestore(spec) {
  const set = (id, v) => { const el = document.getElementById(id);
                           if (el && v != null) el.value = v; };
  set('b_metric', spec.metric);
  set('b_group',  spec.group || '');
  set('b_grain',  spec.grain || 'monat');
  bGroupChanged();                       // richtet Raster, Sortierung und Limit ein
  set('b_sort',   spec.sort);            // … deshalb erst danach die gespeicherten Werte
  set('b_limit',  spec.limit != null ? spec.limit : 0);
  document.getElementById('b_filters').innerHTML = '';
  (spec.filters || []).forEach(f => bAddFilter(f));
  set('b_split', spec.split || '');
  await bSplitChanged(spec.split_values || []);
  set('b_facet', spec.facet || '');
  await bFacetChanged(spec.facet_values || []);
  await bGenerate();
}

function fillColumnPickers(label, vals, facet) {
  const cols = PREVIEW ? PREVIEW.columns : [];
  const lsel = document.getElementById('f_label');
  lsel.innerHTML = '<option value="">– erste Spalte –</option>'
    + cols.map(c => `<option value="${esc(c)}">${esc(c)}</option>`).join('');
  lsel.value = label && cols.indexOf(label) >= 0 ? label : '';
  const fsel = document.getElementById('f_facet');
  fsel.innerHTML = '<option value="">– eine Darstellung –</option>'
    + cols.map(c => `<option value="${esc(c)}">${esc(c)}</option>`).join('');
  fsel.value = facet && cols.indexOf(facet) >= 0 ? facet : '';
  const vc = document.getElementById('valcols');
  if (!cols.length) {
    vc.innerHTML = '<span class="hint">Erst eine Vorschau ausführen — '
      + 'dann lassen sich hier die Wertespalten wählen. Ohne Auswahl werden '
      + 'alle numerischen Spalten genommen.</span>';
    return;
  }
  vc.innerHTML = cols.map((c, i) => {
    const on = vals.indexOf(c) >= 0;
    return `<label class="${on ? 'on' : ''}" onclick="setTimeout(syncValcols,0)">
      <input type="checkbox" value="${esc(c)}" ${on ? 'checked' : ''}>
      ${esc(c)}${PREVIEW.numeric[i] ? '' : ' <span class="hint">(Text)</span>'}</label>`;
  }).join('');
}
function syncValcols() {
  document.querySelectorAll('#valcols label').forEach(l =>
    l.classList.toggle('on', l.querySelector('input').checked));
}
function syncEditor() {}

async function runPreview() {
  const sql = document.getElementById('f_sql').value.trim();
  const db  = document.getElementById('f_db').value;
  const msg = document.getElementById('prevmsg');
  if (!sql || !db) { msg.textContent = 'Datenbank und SQL angeben.'; return; }
  msg.textContent = 'läuft…';
  const d = await (await fetch('/api/hub/preview', {
    method: 'POST', headers: {'Content-Type': 'application/json'},
    body: JSON.stringify({sql, database: db})
  })).json();
  if (d.error) {
    msg.innerHTML = '<span style="color:var(--red)">' + esc(d.error) + '</span>';
    document.getElementById('prevbox').style.display = 'none';
    PREVIEW = null; fillColumnPickers('', [], '');
    return;
  }
  PREVIEW = d;
  msg.textContent = d.rows.length + ' Zeilen · ' + d.ms + ' ms'
    + (d.truncated ? ' · Vorschau bei 500 gekappt' : '');
  const box = document.getElementById('prevbox');
  box.style.display = 'block';
  box.innerHTML = '<table><thead><tr>' + d.columns.map((c, i) =>
      '<th>' + esc(c) + (d.numeric[i] ? ' <span class="num">123</span>' : '') + '</th>').join('')
    + '</tr></thead><tbody>' + d.rows.slice(0, 20).map(r =>
      '<tr>' + r.map(v => '<td>' + (v == null ? '–' : esc(v)) + '</td>').join('') + '</tr>'
    ).join('') + '</tbody></table>';
  let curLabel, curVals, curFacet;
  if (PENDING_COLS) {                       // frisch aus dem Baukasten
    curLabel = PENDING_COLS.label; curVals = PENDING_COLS.values;
    curFacet = PENDING_COLS.facet || ''; PENDING_COLS = null;
  } else {
    curLabel = document.getElementById('f_label').value
               || (EDITING ? EDITING.label_col : '');
    curVals  = collectValcols().length ? collectValcols()
               : (EDITING ? EDITING.value_cols || [] : []);
    curFacet = document.getElementById('f_facet').value
               || (EDITING ? EDITING.facet_col || '' : '');
  }
  fillColumnPickers(curLabel, curVals, curFacet);
}

function collectValcols() {
  return Array.from(document.querySelectorAll('#valcols input:checked')).map(i => i.value);
}

async function saveChart() {
  const msg = document.getElementById('edmsg');
  const payload = {
    id:        EDITING ? EDITING.id : undefined,
    title:     document.getElementById('f_title').value.trim() || 'Ohne Titel',
    note:      document.getElementById('f_note').value.trim(),
    database:  document.getElementById('f_db').value,
    sql:       document.getElementById('f_sql').value.trim(),
    viz:       document.getElementById('f_viz').value,
    label_col: document.getElementById('f_label').value,
    value_cols: collectValcols(),
    facet_col: document.getElementById('f_facet').value,
    spec:      LAST_SPEC || (EDITING && EDITING.spec) || null,
    spec_sql:  LAST_SPEC ? LAST_SPEC_SQL : ((EDITING && EDITING.spec_sql) || ''),
    unit:      document.getElementById('f_unit').value.trim(),
    width:     document.getElementById('f_width').value,
    ttl_s:     parseInt(document.getElementById('f_ttl').value || '900', 10),
    stacked:   document.getElementById('f_stacked').value === '1',
  };
  if (!payload.sql)      { msg.innerHTML = '<span style="color:var(--red)">SQL fehlt.</span>'; return; }
  if (!payload.database) { msg.innerHTML = '<span style="color:var(--red)">Datenbank fehlt.</span>'; return; }
  msg.textContent = 'Speichern…';
  const r = await fetch('/api/hub/charts', {
    method: 'POST', headers: {'Content-Type': 'application/json'},
    body: JSON.stringify(payload)
  });
  const d = await r.json();
  if (d.error) { msg.innerHTML = '<span style="color:var(--red)">' + esc(d.error) + '</span>'; return; }
  closeEditor();
  await loadCharts();
  runChart(d.chart.id, true);
}

async function deleteChart() {
  if (!EDITING) return;
  if (!confirm('Auswertung „' + EDITING.title + '" wirklich löschen?')) return;
  await fetch('/api/hub/charts/delete', {
    method: 'POST', headers: {'Content-Type': 'application/json'},
    body: JSON.stringify({id: EDITING.id})
  });
  closeEditor();
  loadCharts();
}

function esc(s) {
  return String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;')
    .replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}

document.addEventListener('keydown', e => {
  const open = document.getElementById('ov').style.display === 'block';
  if (open && (e.ctrlKey || e.metaKey) && e.key === 'Enter') { e.preventDefault(); runPreview(); }
  if (open && e.key === 'Escape') closeEditor();
});

init();
</script>
</body>
</html>
"""


@app.route('/hub')
def hub_page():
    return HUB_HTML


MODULE_HTML = """<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title>SAP Claim Analyzer – Modul-Katalog</title>
<!--SAP_BASE-->
<style>
  *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
  
  body { font-family: var(--font); background: var(--bg); color: var(--text); font-size: 13px; }





  h2 { font-size: 20px; font-weight: 500; letter-spacing: -.01em; margin-bottom: 4px; }
  .subtitle { color: var(--muted); margin-bottom: 14px; max-width: 900px; line-height: 1.45; }
  .toolbar { display: flex; gap: 8px; align-items: center; margin-bottom: 12px; flex-wrap: wrap; }
  .toolbar input:not([type=checkbox]) { padding: 6px 10px; font-size: 13px; border: 1px solid var(--border);
                   border-radius: var(--r-s); min-width: 240px; }
  button { background: var(--surface); color: var(--ink); border: 1px solid var(--line-2);
           padding: 6px 12px; border-radius: var(--r-s); font-size: 13px; cursor: pointer; }
  button:hover { background: var(--hover); border-color: var(--ink-3); }
  button:disabled { opacity: .45; cursor: wait; }
  label.chk { font-size: 13px; color: var(--muted); display: flex; align-items: center; gap: 5px; }
  #info { font-size: 11px; color: var(--muted); }
  .warn { background: var(--warn-bg); border: 1px solid var(--warn-line); color: var(--warn); padding: 8px 12px;
          border-radius: var(--r-s); margin-bottom: 12px; font-size: 13px; }
  #fams { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 14px; }
  .fam { background: var(--surface); border: 1px solid var(--border); border-radius: var(--r-pill);
         padding: 4px 11px; font-size: 13px; cursor: pointer; }
  .fam.on { background: var(--text); color: var(--surface); border-color: var(--text); }
  .fam small { opacity: .65; margin-left: 4px; }
  table { width: 100%; border-collapse: collapse; background: var(--surface);
          border: 1px solid var(--border); border-radius: var(--r-m); overflow: hidden; }
  th { text-align: left; font-size: 11px; text-transform: uppercase; letter-spacing: .04em;
       color: var(--muted); padding: 8px 10px; border-bottom: 1px solid var(--border); background: var(--hover); }
  td { padding: 7px 10px; border-bottom: 1px solid var(--hover); vertical-align: top; }
  td.num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
  th.num { text-align: right; }
  td.ck { font-weight: 700; white-space: nowrap; }
  tr.zero td { color: var(--muted); }
  .bar { display: inline-block; height: 7px; background: var(--blue); border-radius: var(--r-s);
         vertical-align: middle; margin-left: 6px; opacity: .75; }
  .var { font-size: 11px; color: var(--muted); }
  .types { font-size: 11px; color: var(--muted); max-width: 320px; }
  .orders { font-size: 11px; color: var(--muted); }
  tr.oghead td { background: var(--hover); border-top: 2px solid var(--ink-3); font-size: 13px; padding-top: 7px; }
  tr.oghead span { color: var(--muted); }
  .badge.kopf { background: var(--info-bg); color: var(--info); }
  .badge.tset { background: var(--surface); color: var(--info); border: 1px dashed var(--info); }
  .warnog { color: var(--warn); }
  .oghint { font-size: 13px; color: var(--muted); margin: 0 0 8px; max-width: 900px; }
  .empty { padding: 40px; text-align: center; color: var(--muted); }
  tr.eng td { background: var(--warn-bg); }
  .badge { display: inline-block; font-size: 11px; background: var(--warn-line); color: var(--warn);
           border-radius: var(--r-s); padding: 1px 6px; margin-left: 6px; font-weight: 600; }
  #groups { margin: 0 0 16px; }
  #groups h3 { font-size: 13px; margin-bottom: 4px; }
  #groups p { font-size: 11px; color: var(--muted); margin-bottom: 6px; max-width: 900px; }
  #groups table { max-width: 720px; }
  .gbar { display: inline-block; height: 7px; background: var(--amber); border-radius: var(--r-s);
          vertical-align: middle; margin-left: 6px; opacity: .8; }
  /* Kontextspalte „Module": klebt, zeigt immer die gewählte Familie + Obergruppen-Verzeichnis */
  #mod-fam { font-size: 28px; font-weight: 700; letter-spacing: -.02em; line-height: 1.05; }
  #mod-stats { color: var(--ink-2); font-size: 13px; line-height: 1.5; }
  #mod-stats b { color: var(--ink); font-weight: 600; }
  #fam-sel { width: 100%; padding: 6px 9px; font-size: 13px; border: 1px solid var(--line-2); border-radius: var(--r-s);
             background: var(--surface); }
  #og-index { display: flex; flex-direction: column; gap: 1px; margin-top: 6px; max-height: calc(100vh - 340px);
              overflow-y: auto; border-top: 1px solid var(--line); padding-top: 6px; }
  #og-index a { display: grid; grid-template-columns: 44px minmax(0,1fr) 36px; gap: 6px; align-items: center;
                padding: 3px 4px; border-radius: var(--r-s); color: var(--ink-2); text-decoration: none; cursor: pointer;
                font-size: 11px; }
  #og-index a:hover, #og-index a.on { background: var(--acc-soft); color: var(--ink); }
  #og-index a b { font-weight: 600; color: var(--ink); font-variant-numeric: tabular-nums; }
  #og-index a span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
  #og-index a i { display: block; height: 5px; background: var(--info); opacity: .75; border-radius: 1px; }
  #og-index a em { font-style: normal; text-align: right; font-variant-numeric: tabular-nums; }
  .ogi-row { display: contents; }
  details.hint { font-size: 11px; color: var(--ink-3); }
  details.hint summary { cursor: pointer; }
  details.hint p { margin-top: 6px; color: var(--ink-2); font-size: 11px; line-height: 1.5; }
  tr.oghead td { scroll-margin-top: 16px; }
  tr.hl td { background: var(--acc-soft) !important; }
  a.teile { font-size: 11px; color: var(--ink-3); margin-left: 8px; white-space: nowrap; text-decoration: none; }
  a.teile:hover { color: var(--ink); }
  .tplsel { display: flex; flex-direction: column; gap: 4px; margin-top: 8px; }
  .tplchip { border: 1px solid var(--line-2); border-radius: var(--r-s); padding: 3px 8px; cursor: pointer; font-size: 12px;
             color: var(--ink-2); background: var(--surface); }
  .tplchip.on { border-color: var(--ink); color: var(--ink); font-weight: 600; }
  .tplchip small { display: block; font-weight: 400; color: var(--ink-3); font-size: 11px; }
</style>
<!--SAP_SHELL-->
</head>
<body>
<!--SAP_RAIL-->
<header class="sap-ph"><!--SAP_PH-->
<div class="ph-right">
<!--SAP_MARK-->
</div>
</header>
<div class="sap-page">
<section class="sap-sec">
 <div class="sap-sec-head">
  <span class="eyebrow">04.1 Katalog</span>
  <h2>Modul-Katalog</h2>
  <p>Welche Module hat eine Engine-Familie, wo landen ihre Incidents, und wo wird nicht modulgenau gebucht?</p>
 </div>
 <div class="sap-sec-body">
  <p class="subtitle">Alle CK-Module je Engine-Familie, gebaut aus der SAP-PSP-Struktur
    (x_mtuae_ins_wbs) — nicht nur die Module, an denen Incidents hängen. Namensvarianten
    einzelner Projekte sind auf den häufigsten Namen abgebildet. <b>Projekte</b> zählt, wie oft
    das Modul in einer Projektvorlage steht; weil jede Vorlage alle Module enthält, heißt das
    <i>nicht</i>, dass das Modul bearbeitet wurde. Incidents: alle Zyklen.</p>
  <div id="stale"></div>
  <div class="toolbar">
    <input id="q" placeholder="Modul, CK-Nummer oder Engine-Typ filtern…" oninput="render()">
    <label class="chk"><input type="checkbox" id="onlyinc" onchange="render()"> nur Module mit Incidents</label>
    <label class="chk"><input type="checkbox" id="showvar" checked onchange="render()"> Varianten zeigen</label>
    <button id="rebuild" onclick="load(true)">Neu aufbauen</button>
    <a class="standalone" href="/hub/module/vorschlag" style="font-size:13px">Modulvorschlag für Bauteile →</a>
    <a class="standalone" href="/hub/module/graph" style="font-size:13px">Hierarchie-Graph →</a>
    <span id="info"></span>
  </div>
  <div id="fams"></div>
 </div>
</section>
<section class="sap-sec" id="sec-groups" hidden>
 <div class="sap-sec-head">
  <span class="eyebrow">Buchungspraxis <span id="grp-fam"></span></span>
  <h2>Wo wird nicht modulgenau gebucht?</h2>
  <p>Anteil der Incidents, die auf CK00 (Engine gesamt) statt auf ein Modul gebucht sind, je Meldegruppe.
    Am Prüfstand ist das die Regel, weil dort kein Modul bekannt ist; bei Gruppen, die über die
    CS-Order/Laufkarte melden, sollte das Modul meist mitkommen. Gruppen ab 5 Incidents.</p>
 </div>
 <div class="sap-sec-body"><div id="groups"></div></div>
</section>
<section class="sap-sec">
 <div class="sap-sec-head">
  <span class="eyebrow">Module nach Obergruppe</span>
  <div id="mod-fam">–</div>
  <p id="mod-stats"></p>
  <select id="fam-sel" onchange="pickFam(this.value)" title="Familie wechseln, ohne hochzuscrollen"></select>
  <div id="og-index" role="navigation" aria-label="Obergruppen"></div>   <!-- kein <nav>: html.embed nav wird ausgeblendet -->
  <details class="hint"><summary>Was ist eine Obergruppe?</summary>
    <p>Obergruppe = mittleres Segment des WBS-Codes (Projekt.<b>OG</b>.CK, z. B. L.20000.<b>20</b>.80) — so gliedert
    SAP die Module. Einen Namen hat die Obergruppe in LUD_Core nicht; genannt wird nur ihr Kopfknoten (CK = OG).
    „Teilesatz?" ist eine Vermutung aus CK-Nummer und Name. Typen und Vorlagen stehen oben; in der Zeile nur,
    wenn ein Modul davon abweicht.</p></details>
 </div>
 <div class="sap-sec-body">
  <div id="out"><div class="empty">Lade Katalog — beim ersten Mal liest das alle WBS-Elemente, das dauert etwas…</div></div>
 </div>
</section>
</div>
<script>
let CAT = null, FAM = null;
function esc(s) { return String(s == null ? '' : s).replace(/[&<>"]/g,
  c => ({'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;'}[c])); }
function fmt(n) { return (n || 0).toLocaleString('de-DE'); }

async function load(force) {
  const btn = document.getElementById('rebuild');
  btn.disabled = true;
  if (force) document.getElementById('out').innerHTML = '<div class="empty">Baue Katalog neu auf…</div>';
  try {
    const d = await (await fetch('/api/modules' + (force ? '?force=1' : ''))).json();
    if (d.error && !d.modules) {
      document.getElementById('out').innerHTML = '<div class="warn">' + esc(d.error) + '</div>';
      return;
    }
    CAT = d;
    document.getElementById('stale').innerHTML = d.stale
      ? '<div class="warn">Datenbank nicht erreichbar — gezeigt wird der Stand vom ' + esc(d.ts)
        + '. (' + esc(d.error || '') + ')</div>' : '';
    const st = d.stats || {};
    document.getElementById('info').textContent =
      'Stand ' + (d.ts || '').replace('T', ' ') + ' · ' + fmt(st.module) + ' Module aus '
      + fmt(st.ck_elemente) + ' CK-Elementen · ' + fmt(st.umbenannt) + ' Namensvarianten vereinheitlicht'
      + (st.incidents_ohne_zuordnung ? ' · ' + fmt(st.incidents_ohne_zuordnung)
         + ' Incidents ohne Projekt/Familie' : '');
    const u = new URLSearchParams(location.search);          // Querverweis: ?family=…&ck=…
    if (!FAM && u.get('family') && d.families.some(f => f.family === u.get('family'))) FAM = u.get('family');
    if (!FAM || !d.families.some(f => f.family === FAM)) FAM = d.families.length ? d.families[0].family : null;
    render();
    if (u.get('ck') && !window._ckShown) { window._ckShown = true; showCk(u.get('ck')); }
  } catch (e) {
    document.getElementById('out').innerHTML = '<div class="warn">Netzwerkfehler: ' + esc(e.message) + '</div>';
  } finally {
    btn.disabled = false;
  }
}

let TPL = '';               // Vorlage: '' = Hauptvorlage, sonst Wurzel-Material (z. B. M0000)
function pickFam(f) { FAM = f; TPL = ''; render(); }
function pickTpl(t) { TPL = t; render(); }

// Obergruppe benennen — EINE Stelle für Tabelle und Verzeichnis. Kommen echte OG-Namen
// (z. B. aus Abfrage H, abfragen_modulstruktur_HIJ.sql) in /api/modules, hier einhängen.
function ogLabel(og, g) {
  if (!og) return 'ohne Obergruppe';
  if (g && g.name) return g.name;
  return g && g.head ? g.head.name.replace(/^[^:]*:[ ]*/, '') : 'kein Kopfknoten';
}

function jumpOg(og) {
  const r = document.getElementById('og-row-' + (og || 'x'));
  if (r) r.scrollIntoView({behavior: 'smooth', block: 'start'});
}

function showCk(ck) {
  const r = document.getElementById('ck-row-' + ck);
  if (!r) return;
  r.classList.add('hl');
  r.scrollIntoView({block: 'center'});
}

function render() {
  if (!CAT) return;
  const pct = (a, b) => b ? Math.round(100 * a / b) : 0;
  document.getElementById('fams').innerHTML = CAT.families.map(f =>
    `<span class="fam ${f.family === FAM ? 'on' : ''}" onclick='pickFam(${JSON.stringify(f.family)})'
       title="${esc(f.types.join(', '))}">${esc(f.family)}<small>${f.modules} Module · ${fmt(f.incidents)} Inc.`
    + (f.incidents ? ` · ${pct(f.ck00 || 0, f.incidents)} % CK00` : '') + '</small></span>'
  ).join('');
  const q = document.getElementById('q').value.trim().toLowerCase();
  // CK00 je Meldegruppe — wer bucht auf die ganze Engine? Am Prüfstand ist das
  // systembedingt (kein Modul bekannt), anderswo ein Hinweis auf fehlende Zuordnung.
  const fam = CAT.families.find(f => f.family === FAM);
  const grp = (!q && fam && fam.groups) ? fam.groups.filter(g => g.total >= 5).slice(0, 12) : [];
  document.getElementById('sec-groups').hidden = !grp.length;
  document.getElementById('grp-fam').textContent = grp.length ? '— ' + FAM : '';
  document.getElementById('groups').innerHTML = grp.length
    ? '<table><thead><tr><th>Meldegruppe</th><th class="num">CK00</th><th class="num">alle</th>'
      + '<th class="num">Anteil CK00</th></tr></thead><tbody>'
      + grp.map(g => `<tr><td>${esc(g.group)}</td><td class="num">${fmt(g.ck00)}</td>`
          + `<td class="num">${fmt(g.total)}</td><td class="num">${pct(g.ck00, g.total)} %`
          + `<span class="gbar" style="width:${Math.round(0.6 * pct(g.ck00, g.total))}px"></span></td></tr>`).join('')
      + '</tbody></table>'
    : '';
  const onlyInc = document.getElementById('onlyinc').checked;
  const showVar = document.getElementById('showvar').checked;
  let rows = CAT.modules.filter(m => m.family === FAM && (m.template || '') === TPL);
  if (q) rows = CAT.modules.filter(m =>
    (m.name + ' ' + m.ck + ' ' + m.family + ' ' + m.types.join(' ')).toLowerCase().includes(q));
  if (onlyInc) rows = rows.filter(m => m.incidents > 0);
  if (!rows.length) {
    document.getElementById('out').innerHTML = '<div class="empty">Keine Module für diese Auswahl.</div>';
    document.getElementById('og-index').innerHTML = '';
    return;
  }
  // CK00 nach oben und abgesetzt; die Balken skalieren nur über echte Module,
  // sonst drückt der große Engine-Topf alle Modulbalken zusammen.
  rows = rows.filter(m => m.engine_level).concat(rows.filter(m => !m.engine_level));
  // Innerhalb der Familie nach Obergruppe (WBS-Mittelsegment) gliedern, Kopf zuerst
  const ogNum = og => og ? parseInt(og, 10) : 999;
  if (!q) rows = rows.slice().sort((a, b) => (b.engine_level - a.engine_level)
      || (ogNum(a.og) - ogNum(b.og)) || ((b.kind === 'kopf') - (a.kind === 'kopf'))
      || (parseInt(a.ck.slice(2), 10) - parseInt(b.ck.slice(2), 10)));
  const ogs = {};
  ((CAT.obergruppen || {})[TPL ? FAM + '|' + TPL : FAM] || []).forEach(g => { ogs[g.og] = g; });
  const maxInc = Math.max(1, ...rows.filter(m => !m.engine_level).map(m => m.incidents));
  const ncol = q ? 5 : 4;
  // Was für alle Module der Familie gleich ist, steht einmal in der Kontextspalte
  const famTypes = {}, famMaxPrj = {};
  CAT.families.forEach(f => { famTypes[f.family] = (f.types || []).slice().sort().join('|'); });
  CAT.modules.forEach(m => { if ((m.template || '') === TPL || q)
    famMaxPrj[m.family] = Math.max(famMaxPrj[m.family] || 0, m.projects || 0); });
  const selFam = CAT.families.find(f => f.family === FAM) || {};
  // Vergleichsbasis „nur …": alle Typen der gewählten Vorlage, nicht der ganzen Familie
  const tplTypes = [...new Set(rows.flatMap(m => m.types || []))].sort().join('|');
  document.getElementById('mod-fam').textContent = q ? 'Suche' : (FAM || '–');
  document.getElementById('mod-stats').innerHTML = q
    ? `„${esc(q)}" über alle Familien · <b>${fmt(rows.length)}</b> Module`
    : `<b>${fmt(selFam.modules)}</b> Module · <b>${fmt(selFam.incidents)}</b> Incidents`
      + (selFam.incidents ? ` · <b>${pct(selFam.ck00 || 0, selFam.incidents)} %</b> auf CK00` : '')
      + `<br>${fmt((selFam.types || []).length)} Engine-Typen · in ${fmt(famMaxPrj[FAM])} Projektvorlagen`
      + ((selFam.templates || []).length > 1
         ? '<div class="tplsel" title="WBS-Vorlagen dieser Familie — erkannt an der Wurzel des Einbaubaums (04.4). '
           + 'Dieselbe CK-Nummer kann in zwei Vorlagen etwas anderes bedeuten.">'
           + selFam.templates.map(t => `<span class="tplchip${(t.tpl || '') === TPL ? ' on' : ''}" onclick='pickTpl(${JSON.stringify(t.tpl || '')})'>`
             + `${t.tpl ? 'Vorlage ' + esc(t.label) : 'Hauptvorlage ' + esc(t.label)}<small>${fmt(t.modules)} Module · ${fmt(t.incidents)} Inc.</small></span>`).join('')
           + '</div>' : '')
      + `<br><span title="${esc((selFam.types || []).join(', '))}" style="color:var(--ink-3);font-size:11px">`
      + esc((selFam.types || []).slice(0, 4).join(', ')) + ((selFam.types || []).length > 4 ? ' …' : '') + '</span>';
  document.getElementById('fam-sel').innerHTML = CAT.families.map(f =>
    `<option value="${esc(f.family)}" ${f.family === FAM ? 'selected' : ''}>${esc(f.family)} · ${f.modules} Module</option>`).join('');
  document.getElementById('fam-sel').style.display = q ? 'none' : '';
  const ogList = Object.values(ogs).sort((x, y) => (parseInt(x.og, 10) || 999) - (parseInt(y.og, 10) || 999));
  const maxOgInc = Math.max(1, ...ogList.map(g => g.incidents || 0));
  document.getElementById('og-index').innerHTML = q || !ogList.length ? ''
    : ogList.map(g => `<a onclick="jumpOg('${esc(g.og)}')" title="${esc(ogLabel(g.og, g))} · ${fmt(g.incidents || 0)} Incidents">`
        + `<b>OG ${esc(g.og)}</b><span>${esc(ogLabel(g.og, g))}</span><em>${fmt(g.incidents || 0)}</em>`
        + `<i style="grid-column:2/4;width:${Math.max(2, Math.round(100 * (g.incidents || 0) / maxOgInc))}%"></i></a>`).join('');
  let lastOg = null;
  function ogHead(m) {
    if (q || m.engine_level || m.og === lastOg) return '';
    lastOg = m.og;
    const g = ogs[m.og] || {};
    const head = g.head ? `Kopf ${esc(g.head.ck)}: ${esc(g.head.name.replace(/^[^:]*:[ ]*/, ''))}` : 'kein Kopfknoten';
    return `<tr class="oghead" id="og-row-${esc(m.og || 'x')}"><td colspan="${ncol}"><b>${m.og ? 'OG ' + esc(m.og) : 'ohne Obergruppe'}</b>`
      + ` <span>· ${m.og ? head + ' · ' : ''}${(g.cks || []).length || ''}${(g.cks || []).length ? ' Module · ' : ''}`
      + `${fmt(g.incidents || 0)} Incidents</span></td></tr>`;
  }
  document.getElementById('out').innerHTML =
    '<table><thead><tr>' + (q ? '<th>Familie</th>' : '')
    + '<th>CK</th><th>Modul</th><th class="num">Incidents</th>'
    + '<th>Aufträge nach Wartungsart</th></tr></thead><tbody>'
    + rows.map(m => ogHead(m) + `<tr id="ck-row-${esc(m.ck)}" class="${m.engine_level ? 'eng' : (m.incidents ? '' : 'zero')}">`
        + (q ? `<td>${esc(m.family)}</td>` : '')
        + `<td class="ck"><a href="/hub/module/graph?family=${encodeURIComponent(m.family)}&ck=${encodeURIComponent(m.ck)}" title="in der Hierarchie zeigen" style="color:inherit">${esc(m.ck)}</a></td>`
        + `<td>${esc(m.name.replace(/^[^:]*:[ ]*/, ''))}`
        + (m.engine_level ? '<span class="badge">Engine gesamt — nicht modulgenau</span>' : '')
        + (m.kind === 'kopf' ? '<span class="badge kopf" title="CK-Nummer = Obergruppe">Kopf</span>' : '')
        + (m.kind === 'teilesatz?' ? '<span class="badge tset" title="Heuristik: CK ≥ 80 und Blades/Vanes/Segm. im Namen — steht nicht in LUD_Core">Teilesatz?</span>' : '')
        + (q && m.og ? `<span class="badge">OG ${esc(m.og)}</span>` : '')
        + (q && m.template ? `<span class="badge tset" title="Nebenvorlage — CK-Nummern bedeuten hier ggf. anderes">Vorlage ${esc(m.template)}</span>` : '')
        + (m.og_other && m.og_other.length
           ? `<div class="var warnog">in ${Math.round(100 * m.og_share)} % unter OG ${esc(m.og)}, sonst: `
             + m.og_other.map(o => (o.og === '–' ? 'ohne OG' : 'OG ' + esc(o.og)) + ' (' + fmt(o.projects) + ')').join(' · ') + '</div>' : '')
        + (showVar && m.variants.length
           ? '<div class="var">auch: ' + m.variants.map(v => esc(v.name) + ' (' + fmt(v.projects) + ')').join(' · ') + '</div>' : '')
        // Abweichungen von der Familie (sonst stehen Typen/Vorlagen einmal links)
        + ((m.types || []).slice().sort().join('|') !== (q ? famTypes[m.family] : tplTypes)
           ? `<div class="var">nur ${esc(m.types.join(', '))}</div>` : '')
        + (m.projects < famMaxPrj[m.family]
           ? `<div class="var">in ${fmt(m.projects)} von ${fmt(famMaxPrj[m.family])} Projektvorlagen</div>` : '')
        + (m.engine_level ? '' : `<a class="teile" href="/hub/module/vorschlag?family=${encodeURIComponent(m.family)}&ck=${encodeURIComponent(m.ck)}"`
           + ` title="Häufigste Teile dieses Moduls im Modulvorschlag">Teile →</a>`)
        + '</td>'
        + `<td class="num">${fmt(m.incidents)}` + (m.engine_level ? ''
           : `<span class="bar" style="width:${Math.round(60 * m.incidents / maxInc)}px"></span>`) + '</td>'
        + `<td class="orders">${(m.orders || []).slice(0, 4).map(o => esc(o.type.trim()) + ' ' + fmt(o.n)).join(' · ') || '–'}</td>`
        + '</tr>').join('')
    + '</tbody></table>';
}
load(false);
</script>
</body>
</html>
"""


# ── Modul-Katalog: eine Seite, drei Reiter ────────────────────────────────────
# Die drei Seiten bleiben eigenständig und laufen je in einem iframe (?embed=1,
# dann ohne eigene Navigation). So kommen sich ihre Skripte nicht in die Quere,
# und jeder Reiter behält seinen Zustand beim Umschalten. Querverweise zwischen
# den Seiten (/hub/module/…) schalten per postMessage den Reiter um.
MODULE_TABS = {'katalog': '/hub/module/katalog', 'vorschlag': '/hub/module/vorschlag',
               'graph': '/hub/module/graph', 'struktur': '/hub/module/struktur'}

MODSHELL_HTML = """<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title>SAP Claim Analyzer – Modul-Katalog</title>
<!--SAP_BASE-->
<style>
  *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
  html, body { height: 100%; }
  body { font-family: var(--font); background: var(--paper); color: var(--ink); font-size: 13px;
         display: flex; flex-direction: column; }





  .tabs { display: flex; gap: 28px; padding: 0 clamp(16px,3vw,36px); background: var(--paper);
          border-bottom: 1px solid var(--line-2); flex-shrink: 0; counter-reset: tab; }
  .tab { padding: 12px 0 10px; font-size: 13px; color: var(--ink-2); cursor: pointer; border: 0;
         border-bottom: 2px solid transparent; margin-bottom: -1px; background: none; font-family: var(--font); }
  .tab::before { counter-increment: tab; content: "04." counter(tab); font-size: 10px; letter-spacing: .12em;
                 color: var(--ink-3); margin-right: 8px; font-variant-numeric: tabular-nums; }
  .tab:hover { color: var(--ink); }
  .tab.on { color: var(--ink); font-weight: 600; border-bottom-color: var(--ink); }
  .tab.on::before { color: var(--ink); }
  .tab small { color: var(--ink-3); font-weight: 400; margin-left: 8px; }
  #frames { flex: 1; min-height: 0; position: relative; }
  #frames iframe { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; display: none; background: var(--paper); }
  #frames iframe.on { display: block; }
</style>
<!--SAP_SHELL-->
</head>
<body>
<!--SAP_RAIL-->
<header class="sap-ph"><!--SAP_PH-->
<div class="ph-right">
<!--SAP_MARK-->
</div>
</header>
<div class="tabs">
  <button class="tab" data-tab="katalog" onclick="openTab('katalog')">Katalog<small>Module je Familie</small></button>
  <button class="tab" data-tab="vorschlag" onclick="openTab('vorschlag')">Modulvorschlag<small>Bauteil → Modul</small></button>
  <button class="tab" data-tab="graph" onclick="openTab('graph')">Hierarchie<small>Modul → Baugruppe → Teil</small></button>
  <button class="tab" data-tab="struktur" onclick="openTab('struktur')">Einbaustruktur<small>was steckt worin (ECMS)</small></button>
</div>
<div id="frames">
  <iframe data-tab="katalog" title="Katalog"></iframe>
  <iframe data-tab="vorschlag" title="Modulvorschlag"></iframe>
  <iframe data-tab="graph" title="Hierarchie" allow="fullscreen" allowfullscreen></iframe>
  <iframe data-tab="struktur" title="Einbaustruktur"></iframe>
</div>
<script>
const TABS = __TABS__;
const QUERY = {};           // letzte Parameter je Reiter (für die Adresszeile)
let CUR = null;

function frame(tab) { return document.querySelector('iframe[data-tab="' + tab + '"]'); }

function openTab(tab, params) {
  if (!TABS[tab]) tab = 'katalog';
  const f = frame(tab);
  if (params !== undefined || !f.getAttribute('src')) {
    // Neue Parameter → nur diesen Reiter neu laden; sonst bleibt sein Zustand erhalten
    QUERY[tab] = params || '';
    f.src = TABS[tab] + '?embed=1' + (QUERY[tab] ? '&' + QUERY[tab] : '');
  }
  CUR = tab;
  if (window.sapPh) window.sapPh.lock(tab === 'graph');   // Hierarchie = Werkzeug: Kopf schmal
  document.querySelectorAll('.tab').forEach(b => b.classList.toggle('on', b.dataset.tab === tab));
  document.querySelectorAll('#frames iframe').forEach(x => x.classList.toggle('on', x.dataset.tab === tab));
  syncUrl();
}

function syncUrl() {
  const q = 'tab=' + CUR + (QUERY[CUR] ? '&' + QUERY[CUR] : '');
  history.replaceState(null, '', '/hub/module?' + q);
}

// Nachrichten aus den Reitern: Querverweis (modnav) oder eigener Zustand (modurl)
window.addEventListener('message', ev => {
  const m = ev.data || {};
  if (ev.origin !== location.origin) return;
  if (m.type === 'modnav') {
    const u = new URL(m.href, location.origin);
    const tab = Object.keys(TABS).find(k => TABS[k] === u.pathname)
             || (u.pathname === '/hub/module' ? (u.searchParams.get('tab') || 'katalog') : null);
    if (!tab) { location.href = m.href; return; }
    u.searchParams.delete('tab'); u.searchParams.delete('embed');
    openTab(tab, u.searchParams.toString());
  } else if (m.type === 'modurl' && TABS[m.tab]) {
    QUERY[m.tab] = m.query || '';
    if (m.tab === CUR) syncUrl();
  }
});

const start = new URLSearchParams(location.search);
const first = start.get('tab') || 'katalog';
start.delete('tab');
openTab(first, start.toString());
</script>
</body>
</html>
""".replace('__TABS__', json.dumps(MODULE_TABS))

# Gemeinsamer Kopf für eingebettete Reiter: eigene Navigation aus, Querverweise
# auf /hub/module/… an die Reiter-Seite melden statt selbst zu navigieren.
MODULE_EMBED_HEAD = r"""
<style>html.embed nav, html.embed .standalone { display: none !important; }</style>
<script>
(function () {
  const embedded = new URLSearchParams(location.search).has('embed') && window.parent !== window;
  if (!embedded) return;
  document.documentElement.classList.add('embed');
  document.addEventListener('click', function (ev) {
    const a = ev.target.closest && ev.target.closest('a[href^="/hub/module"]');
    if (!a) return;
    ev.preventDefault();
    window.parent.postMessage({type: 'modnav', href: a.getAttribute('href')}, location.origin);
  }, true);
  window.modReportUrl = function (tab, query) {
    window.parent.postMessage({type: 'modurl', tab: tab, query: query}, location.origin);
  };
})();
</script>
"""


def _module_page(html: str, tab: str):
    """Eingebettet ausliefern — oder, direkt aufgerufen, auf den passenden Reiter umleiten."""
    if 'embed' not in request.args:
        from flask import redirect
        q = request.query_string.decode('utf-8')
        return redirect('/hub/module?tab=' + tab + ('&' + q if q else ''))
    return html.replace('</head>', MODULE_EMBED_HEAD + '</head>', 1)


@app.route('/hub/module')
def module_shell():
    return MODSHELL_HTML


@app.route('/hub/module/katalog')
def module_page():
    return _module_page(MODULE_HTML, 'katalog')


MODVOR_HTML = """<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title>SAP Claim Analyzer – Modulvorschlag</title>
<!--SAP_BASE-->
<style>
  *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
  
  body { font-family: var(--font); background: var(--bg); color: var(--text); font-size: 13px; }





  h2 { font-size: 20px; font-weight: 500; letter-spacing: -.01em; margin-bottom: 4px; }
  .subtitle { color: var(--muted); margin-bottom: 14px; max-width: 900px; line-height: 1.45; }
  .toolbar { display: flex; gap: 8px; align-items: center; margin-bottom: 14px; flex-wrap: wrap; }
  input, select { padding: 7px 10px; font-size: 13px; border: 1px solid var(--border); border-radius: var(--r-s); }
  #q { flex: 1 1 420px; min-width: 240px; font-size: 15px; height: 40px; padding: 0 12px; }
  #fam { height: 40px; }
  /* Werkzeug: Suche zuerst und klebend, Vertrauen als eine Zeile, Rest darunter */
  .vs-page { max-width: 1400px; }
  .vs-bar { position: sticky; top: var(--ph-stick, 0px); z-index: 20; background: var(--paper);
            padding: 18px 0 12px; border-bottom: 1px solid var(--line-2); }
  .vs-bar .eyebrow { font-size: 10px; display: block; margin-bottom: 6px; }
  .vs-bar h2 { font-size: 20px; margin: 0 0 10px; }
  .vs-bar .toolbar { margin: 0; }
  #vs-count { font-size: 13px; color: var(--ink-2); margin-top: 8px; min-height: 18px; }
  #vs-count b { color: var(--ink); font-weight: 600; }
  .vs-ex { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; margin-top: 8px; font-size: 11px; color: var(--ink-3); }
  .vs-trust { display: flex; flex-wrap: wrap; gap: 4px 18px; align-items: baseline; padding: 12px 0; font-size: 13px;
              color: var(--ink-2); border-bottom: 1px solid var(--line); }
  .vs-trust b { color: var(--ink); font-weight: 600; font-variant-numeric: tabular-nums; }
  .vs-trust button { margin-left: auto; }
  .vs-main { padding-top: 16px; }
  #bt { margin-top: 12px; }
  .browse { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 12px; }
  .browse .bm { background: var(--surface); border: 1px solid var(--line); border-radius: var(--r-m); padding: 10px 12px; }
  .browse .bm.hl { border-color: var(--ink); box-shadow: inset 3px 0 0 var(--ink); }
  .browse h4 { font-size: 13px; margin: 0 0 6px; display: flex; gap: 8px; align-items: baseline; }
  .browse h4 b { font-family: var(--mono); }
  .browse h4 a { margin-left: auto; font-size: 11px; font-weight: 400; color: var(--ink-3); }
  .browse table td { padding: 3px 6px 3px 0; font-size: 11px; border-bottom: 1px solid var(--hover); }
  .browse table td:first-child { font-family: var(--mono); white-space: nowrap; cursor: pointer; }
  .browse table td:first-child:hover { text-decoration: underline; }
  .vs-lead { font-size: 13px; color: var(--ink-2); margin: 0 0 12px; }
  .vs-about { margin-top: 32px; border-top: 1px solid var(--line-2); padding-top: 14px; }
  .vs-about summary { cursor: pointer; font-size: 13px; color: var(--ink-2); }
  .vs-about .subtitle { margin: 10px 0 12px; }
  button { background: var(--surface); color: var(--ink); border: 1px solid var(--line-2);
           padding: 6px 12px; border-radius: var(--r-s); font-size: 13px; cursor: pointer; }
  button:hover { background: var(--hover); border-color: var(--ink-3); }
  .box { background: var(--surface); border: 1px solid var(--border); border-radius: var(--r-m);
         padding: 12px 14px; margin-bottom: 14px; }
  .box h3 { font-size: 13px; margin-bottom: 6px; }
  .kpis { display: flex; gap: 26px; flex-wrap: wrap; }
  .kpi b { font-size: 20px; display: block; }
  .kpi span { font-size: 11px; color: var(--muted); }
  .muted { color: var(--muted); font-size: 11px; }
  .warn { background: var(--warn-bg); border: 1px solid var(--warn-line); color: var(--warn); padding: 8px 12px;
          border-radius: var(--r-s); margin-bottom: 12px; font-size: 13px; }
  .hit { background: var(--surface); border: 1px solid var(--border); border-radius: var(--r-m);
         padding: 10px 14px; margin-bottom: 8px; }
  .hit .t { display: flex; gap: 10px; align-items: baseline; flex-wrap: wrap; }
  .hit .pn { font-weight: 700; font-family: var(--mono); }
  .v { font-size: 11px; border-radius: var(--r-s); padding: 1px 6px; font-weight: 600; white-space: nowrap; }
  .v.eindeutig { background: var(--ok-bg); color: var(--ok); }
  .v.mehrdeutig { background: var(--warn-bg); color: var(--warn); }
  .v.wenig { background: var(--line-2); color: var(--ink-2); }
  .v.korr { background: var(--info-bg); color: var(--info); }
  .v.og { background: var(--neutral-bg); color: var(--ink); border: 1px dashed var(--ink-2); }
  .ogline { font-size: 11px; color: var(--ink); margin: 3px 0 2px; }
  select.sm, input.sm { padding: 3px 6px; font-size: 11px; margin: 1px 3px 1px 0; }
  button.sm { padding: 3px 8px; font-size: 11px; margin: 1px 3px 1px 0; }
  td.korr { min-width: 330px; }
  .warnl { color: var(--crit); font-size: 11px; }
  .cand { display: grid; grid-template-columns: 70px 1fr 120px 230px; gap: 8px; align-items: center;
          font-size: 13px; margin-top: 4px; }
  .hit .cand:nth-child(2) { font-size: 15px; }
  .hit .cand:nth-child(2) .ck { font-size: 15px; }
  .hit .cand:nth-child(n+3) { color: var(--ink-2); font-size: 11px; }
  .cand .ck { font-weight: 700; }
  .track { background: var(--hover); height: 8px; border-radius: var(--r-s); position: relative; }
  .track i { position: absolute; left: 0; top: 0; bottom: 0; background: var(--blue); border-radius: var(--r-s); }
  table { border-collapse: collapse; width: 100%; }
  th, td { text-align: left; padding: 5px 8px; border-bottom: 1px solid var(--hover); font-size: 13px; }
  th { font-size: 11px; text-transform: uppercase; color: var(--muted); }
  td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; }
  #sheet { display: none; }
  @media print {
    .sap-page, #rail, .sap-ph { display: none !important; }
    #sheet { display: block !important; }
    body { background: var(--surface); font-size: 11px; }
    .sm { break-inside: avoid; margin-bottom: 10px; }
  }
  .sm h4 { font-size: 13px; margin: 10px 0 3px; border-bottom: 1px solid var(--ink-3); }
</style>
<!--SAP_SHELL-->
</head>
<body>
<!--SAP_RAIL-->
<header class="sap-ph"><!--SAP_PH-->
<div class="ph-right">
<!--SAP_MARK-->
</div>
</header>
<div class="sap-page vs-page">
  <div class="vs-bar">
    <span class="eyebrow">04.2 Modulvorschlag · Werkzeug</span>
    <h2>Zu welchem Modul gehört dieses Bauteil?</h2>
    <div class="toolbar">
      <select id="fam" onchange="search();famInfo();browse()"></select>
      <input id="q" placeholder="Teilenummer, Wort aus der Benennung (SHROUD) oder ATA-Code (72-23)"
             oninput="searchSoon()" autofocus>
      <button class="btn ghost sm" onclick="sheet()" title="Häufigste Teile je Modul der gewählten Familie als Aushang">Aushang drucken</button>
    </div>
    <div id="vs-count"></div>
    <div class="vs-ex" id="vs-ex">Beispiele:
      <button class="chip" onclick="exQ('SHROUD')">SHROUD</button>
      <button class="chip" onclick="exQ('BLADE')">BLADE</button>
      <button class="chip" onclick="exQ('72-23')">ATA 72-23</button>
      <span id="vs-expn"></span>
    </div>
  </div>
  <div class="vs-trust" id="vs-trust"></div>
  <div class="box" id="bt" style="display:none"></div>
  <div id="status"></div>
  <div class="vs-main">
    <div id="faminfo" class="muted" style="margin:0 0 10px"></div>
    <div class="box" id="atabox" style="display:none"></div>
    <div id="atahits"></div>
    <div id="mods"></div>
    <div id="out"></div>
    <div id="browse"></div>
  </div>
  <details class="vs-about">
    <summary>Über diese Seite und Wartung</summary>
    <p class="subtitle">Zu welchem CK-Modul gehört ein Bauteil? Grundlage sind die Einbauten
      serialisierter Teile (ECMS) und alle Incidents, die bereits auf ein Modul gebucht sind.
      <b>Ein Vorschlag, keine Automatik</b> — der Anteil zeigt, wie sicher er ist. Gedacht für
      Meldungen, bei denen das Modul nicht über die Laufkarte mitkommt (z. B. Prüfstand).
      <span class="standalone"><a href="/hub/module">← Modul-Katalog</a> · <a href="/hub/module/graph">Hierarchie-Graph</a></span></p>
    <button onclick="rebuild()" title="Liest alle ECMS-Einbauten neu — dauert einige Minuten">Neu aufbauen</button>
  </details>
</div>
<div id="sheet"></div>
<script>
let M = null, TIMER = null;
function esc(s) { return String(s == null ? '' : s).replace(/[&<>"]/g,
  c => ({'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;'}[c])); }
function fmt(n) { return (n || 0).toLocaleString('de-DE'); }
function pct(a, b) { return b ? Math.round(1000 * a / b) / 10 : 0; }

async function status() {
  const d = await (await fetch('/api/modules/matrix')).json();
  const st = document.getElementById('status');
  if (d.status !== 'ok') {
    st.innerHTML = '<div class="warn">' + (d.error ? 'Aufbau fehlgeschlagen: ' + esc(d.error)
      : 'Matrix wird aufgebaut (liest alle ECMS-Einbauten, das kann einige Minuten dauern) — läuft seit '
        + d.laeuft_seit_s + ' s. Die Seite aktualisiert sich selbst.') + '</div>';
    if (!d.error) setTimeout(status, 5000);
    return;
  }
  M = d;
  st.innerHTML = d.running ? '<div class="warn">Neuaufbau läuft im Hintergrund — gezeigt wird der Stand vom '
    + esc(d.ts.replace('T', ' ')) + '.</div>' : '';
  if (d.running) setTimeout(status, 8000);
  const sel = document.getElementById('fam'), keep = sel.value;
  sel.innerHTML = '<option value="">alle Familien</option>' + d.families.map(f =>
    `<option value="${esc(f.family)}">${esc(f.family)} (${fmt(f.teilenummern)} Teile)</option>`).join('');
  sel.value = keep;
  const u = new URLSearchParams(location.search);          // Aufruf aus dem Graphen
  if (!keep && u.get('family')) sel.value = u.get('family');
  if (u.get('q') && !document.getElementById('q').value) {
    document.getElementById('q').value = u.get('q');
    search();
  }
  if (u.get('ck')) BROWSE_CK = u.get('ck');                // Querverweis aus dem Katalog
  renderBacktest();
  famInfo();
  browse();
}

function btLine(b) {
  return `<div class="kpis">
    <div class="kpi"><b>${pct(b.treffer_eindeutig, b.eindeutig)} %</b><span>richtig, wo eindeutig (≥ ${Math.round(100 * M.rules.eindeutig_ab)} %)</span></div>
    <div class="kpi"><b>${pct(b.eindeutig, b.bewertet)} %</b><span>der Fälle bekommen einen eindeutigen Vorschlag</span></div>
    <div class="kpi"><b>${pct(b.treffer_bester, b.mit_vorschlag)} %</b><span>richtig, bester Kandidat (alle mit Vorschlag)</span></div>
    <div class="kpi"><b>${fmt(b.bewertet)}</b><span>geprüfte Teilepositionen</span></div></div>`;
}

let BT_OPEN = false;
function toggleBt() { BT_OPEN = !BT_OPEN; renderBacktest(); }

function renderBacktest() {
  const b = M.backtest, box = document.getElementById('bt'), tr = document.getElementById('vs-trust');
  if (!b || !b.gesamt.bewertet) { box.style.display = 'none'; tr.innerHTML = ''; return; }
  const g = b.gesamt;
  tr.innerHTML = '<span class="eyebrow" style="font-size:10px">Wie verlässlich?</span>'
    + `<span><b>${pct(g.treffer_eindeutig, g.eindeutig)} %</b> richtig, wo eindeutig</span>`
    + `<span><b>${pct(g.eindeutig, g.bewertet)} %</b> bekommen einen eindeutigen Vorschlag</span>`
    + `<span>geprüft an <b>${fmt(g.bewertet)}</b> Teilepositionen</span>`
    + `<button class="btn ghost sm" onclick="toggleBt()">${BT_OPEN ? 'Details ▾' : 'Details ▸'}</button>`;
  box.style.display = BT_OPEN ? '' : 'none';
  box.innerHTML = '<h3>Rückrechnung — wie oft stimmt der Vorschlag?</h3>'
    + '<p class="muted" style="margin-bottom:8px">Für jeden auf ein Modul gebuchten Incident mit Teil '
    + 'wird das Modul vorhergesagt und mit der Buchung verglichen. Der geprüfte Incident zählt dabei '
    + 'nicht für seinen eigenen Vorschlag.</p>'
    + btLine(b.gesamt)
    + '<p class="muted" style="margin-top:8px">Nur aus ECMS-Einbauten: ' + pct(b.nur_ecms.treffer_eindeutig, b.nur_ecms.eindeutig)
    + ' % richtig bei ' + pct(b.nur_ecms.eindeutig, b.nur_ecms.bewertet) + ' % eindeutig vorgeschlagenen Fällen'
    + ' · Stand ' + esc(M.ts.replace('T', ' ')) + ' · ' + fmt(M.stats.teilenummern) + ' Teilenummern</p>'
    + ataLine() + derivedLine();
}

// Was das Feld „Modul (abgeleitet)" im Baukasten daraus macht
function derivedLine() {
  const d = M.derived_stats;
  if (!d) return '';
  if (d.fehler) return '<p class="muted" style="margin-top:6px">Modul (abgeleitet): nicht berechnet — ' + esc(d.fehler) + '</p>';
  const ub = d.unit_backtest;
  const unitLine = (d.geprueft_mit_einheit || (ub && ub.bewertet))
    ? '<p class="muted" style="margin-top:6px"><b>Weg 0 — Einheit des ECMS-Teils</b> (Modul-Seriennummer, kein Lernen): '
      + fmt(d.ueber_einheit || 0) + ' Incidents ohne Modulbuchung bekommen ihr Modul direkt, '
      + fmt(d.ueber_einheit_lca || 0) + ' über den gemeinsamen Vorfahr mehrerer Einheiten im Einbaubaum'
      + (d.ueber_einheit_og ? ', ' + fmt(d.ueber_einheit_og) + ' nur die gemeinsame Obergruppe' : '')
      + (d.einheit_widerspruch ? ', ' + fmt(d.einheit_widerspruch) + ' bleiben offen (Einheiten aus verschiedenen Hauptmodulen — '
         + 'hier raten auch die schwächeren Wege nicht)' : '')
      + (ub && ub.bewertet ? ' · Gegenprobe an modulgenau gebuchten Incidents: <b>' + pct(ub.gleich, ub.bewertet)
         + ' %</b> der ' + fmt(ub.bewertet) + ' Einheiten passen zum gebuchten CK'
         + (ub.ungleich ? ' — die ' + fmt(ub.ungleich) + ' übrigen sind Kandidaten für Fehlbuchungen (Beispiele: '
            + ub.beispiele.slice(0, 4).map(b => esc(b[0]) + ' gebucht ' + esc(b[1]) + ', Teil in ' + esc(b[2])).join('; ') + ')' : '') : '') + '</p>'
    : '';
  return unitLine + '<p class="muted" style="margin-top:6px"><b>Im Baukasten „Modul (abgeleitet)":</b> von den Incidents ohne '
    + 'Modulbuchung bekommen <b>' + fmt(d.ueber_teil) + '</b> ein Modul über ihr Teil (' + fmt(d.geprueft_mit_teil)
    + ' mit Teil geprüft' + (d.teile_widerspruechlich ? '; ' + fmt(d.teile_widerspruechlich) + ' haben Teile aus verschiedenen Modulen — davon bekommen <b>'
        + fmt(d.ueber_teil_ata || 0) + '</b> ihr Modul, weil der ATA-Code eins davon trifft, und <b>' + fmt(d.ueber_teil_og || 0)
        + '</b> nur die gemeinsame Obergruppe; Teile aus verschiedenen Obergruppen bleiben offen' : '')
    + ') und <b>' + fmt(d.ueber_ata) + '</b> über den ATA-Code ihres Auftrags (' + fmt(d.geprueft_mit_ata)
    + ' mit Modul-ATA geprüft)' + (d.ueber_ata_og ? '; <b>' + fmt(d.ueber_ata_og) + '</b> weitere bekommen über den ATA-Code '
    + 'nur ihre Obergruppe („OG … (Modul offen)")' : '') + '. Der Rest bleibt „nicht bestimmbar".</p>';
}

// Quelle 3: ATA-Code aus dem Auftragstext → Modul
function ataLine() {
  const a = M.backtest.ata, r = M.ata_rec, o = M.ata_og_backtest;
  if (!a || !a.bewertet) return M.stats.ata_fehler
    ? '<p class="muted" style="margin-top:6px">Quelle 3 (ATA) nicht verfügbar: ' + esc(M.stats.ata_fehler) + '</p>' : '';
  return '<p class="muted" style="margin-top:6px"><b>Quelle 3 — ATA-Code aus dem Auftragstext:</b> '
    + pct(a.treffer_eindeutig, a.eindeutig) + ' % richtig, wo eindeutig · ' + pct(a.eindeutig, a.bewertet)
    + ' % der ' + fmt(a.bewertet) + ' Modul-Aufträge eindeutig'
    + (M.stats.ata_pseudo ? ' (' + fmt(M.stats.ata_pseudo) + ' Marshalling-Aufträge nicht mitgelernt — '
        + '„G57-72-09-00F001 = MARSHALLING OF 09X" trägt an der ATA-Stelle die CK-Nummer)' : '')
    + (o && o.bewertet ? ' · <b>Rückfallebene Obergruppe:</b> bei ' + fmt(o.bewertet) + ' Aufträgen ohne eindeutiges Modul '
        + 'trifft die Obergruppe in ' + pct(o.eindeutig, o.bewertet) + ' % eindeutig zu, davon ' + pct(o.treffer, o.eindeutig) + ' % richtig' : '')
    + (r && r.ck00_mit_ata ? ' · von ' + fmt(r.ck00_mit_ata) + ' CK00-Incidents mit ATA-Auftrag bekämen <b>'
        + fmt(r.eindeutig) + '</b> ein eindeutiges Modul, <b>' + fmt(r.nur_obergruppe || 0) + '</b> nur die Obergruppe ('
        + fmt(r.mehrdeutig) + ' mehrdeutig, '
        + fmt(r.nur_engine_ata) + ' nur Engine-ATA 72-00 wie Test Run/Build up'
        + (r.davon_korrigiert ? '; ' + fmt(r.davon_korrigiert) + ' davon dank Korrektur' : '') + ')' : '')
    + ' · <a href="#" onclick="ataReview();return false">ATA-Zuordnung der Familie prüfen &amp; korrigieren</a></p>';
}

function ataClose() { document.getElementById('atabox').style.display = 'none'; }

let ATAROWS = [], ATAMODS = [], ATAFAM = '';

async function ataReview() {
  const fam = document.getElementById('fam').value;
  if (!fam) { alert('Erst oben eine Familie wählen.'); return; }
  const d = await (await fetch('/api/modules/ata?family=' + encodeURIComponent(fam))).json();
  ATAROWS = d.rows || []; ATAMODS = d.modules || []; ATAFAM = fam;
  const ATAOGS = d.obergruppen || [];
  const box = document.getElementById('atabox');
  box.style.display = '';
  const opts = '<option value="">— Modul wählen —</option>' + ATAMODS.map(m =>
    `<option value="${esc(m.ck)}">${esc(m.ck)} ${esc(m.name.replace(/^[^:]*:[ ]*/, ''))}</option>`).join('')
    + (ATAOGS.length ? '<optgroup label="nur Obergruppe (Modul offen)">' + ATAOGS.map(g =>
        `<option value="OG:${esc(g.og)}">OG ${esc(g.og)}${g.head ? ' · ' + esc(g.head.replace(/^Kopf [^:]*:[ ]*/, 'Kopf ')) : ''}</option>`).join('')
      + '</optgroup>' : '');
  const vc = v => v === 'eindeutig' ? 'eindeutig' : v === 'mehrdeutig' ? 'mehrdeutig' : v === 'nur Obergruppe' ? 'og'
    : String(v).startsWith('korrigiert') ? 'korr' : 'wenig';
  const row = (r, i) => {
    const o = r.override;
    let korr;
    if (o) {
      const conflict = r.source === 'korrigiert' && r.learned_best && r.learned_best !== r.ck && r.n
        ? `<div class="warnl">Aufträge zeigen meist ${esc(r.learned_best)}</div>` : '';
      korr = (o.ignore ? '<b>ignoriert</b> — wird nie zur Ableitung benutzt'
              : o.og ? '<b>→ OG ' + esc(o.og) + '</b> <span class="muted">(Modul offen)</span>'
                     : '<b>→ ' + esc(r.ck) + '</b>')
        + (o.note ? `<div class="muted">„${esc(o.note)}"</div>` : '')
        + `<div class="muted">${esc((o.ts || '').replace('T', ' '))}</div>` + conflict
        + `<button class="sm" onclick="ataReset(${i})">zurücksetzen</button>`;
    } else {
      korr = `<select id="ovs_${i}" class="sm">${opts}</select>`
        + `<input id="ovn_${i}" class="sm" placeholder="Notiz (optional)">`
        + `<button class="sm" onclick="ataSet(${i})">setzen</button>`
        + `<button class="sm" onclick="ataIgnore(${i})">ignorieren</button>`;
    }
    return `<tr><td><b>${esc(r.ata)}</b></td><td>` + (r.candidates.map(c =>
        `${esc(c.ck)} ${esc(c.name.replace(/^[^:]*:[ ]*/, ''))} <span class="muted">${c.og ? 'OG ' + esc(c.og) + ' · ' : ''}${c.n ? c.share + ' %' : 'Korrektur'}</span>`).join('<br>') || '<span class="muted">—</span>')
      + `</td><td>` + ((r.og_candidates || []).map(c => (c.og === '–' ? 'ohne OG' : '<b>OG ' + esc(c.og) + '</b>')
          + ` <span class="muted">${c.share} %</span>`).join('<br>') || '<span class="muted">—</span>')
      + `</td><td class="num">${fmt(r.n)}</td><td><span class="v ${vc(r.verdict)}">${esc(r.verdict)}</span>`
      + (r.og && !r.ck ? `<div class="muted">→ OG ${esc(r.og)}</div>` : '') + `</td>`
      + `<td class="num">${fmt(r.ck00_incidents)}</td><td class="korr">${korr}</td></tr>`;
  };
  box.innerHTML = '<h3>Zuordnung ATA-Abschnitt → Modul · ' + esc(fam)
    + ' <a href="#" style="font-weight:400;font-size:11px;margin-left:8px" onclick="ataClose();return false">schließen</a></h3>'
    + '<p class="muted" style="margin-bottom:8px">Gelernt aus allen Aufträgen, die an einem echten Modul hängen. '
    + '<b>Korrektur</b> überschreibt das Gelernte sofort (Suche, CK00-Zeile, Baukasten „Modul (abgeleitet)"), '
    + 'die gelernten Anteile bleiben daneben sichtbar. „Ignorieren" = dieser Code wird nie zur Ableitung benutzt. '
    + '„CK00-Incidents" = Meldungen auf der ganzen Engine, deren Auftrag diesen Code trägt.</p>'
    + '<p class="muted" style="margin-bottom:8px"><b>Rückfallebene:</b> Ist kein Modul eindeutig (≥ 80 %), '
    + 'aber eine Obergruppe (mittleres WBS-Segment), gilt „nur Obergruppe" — z. B. 72-21 bei CFM: Fan &amp; Booster, '
    + 'Fan Blades, Booster Blades liegen alle unter OG 20. Im Baukasten erscheint das als „OG 20 (Modul offen)".</p>'
    + '<table><thead><tr><th>ATA</th><th>Module laut Aufträgen</th><th>Obergruppen</th><th class="num">Aufträge</th>'
    + '<th>Urteil</th><th class="num">CK00-Inc.</th><th>Korrektur</th></tr></thead><tbody>'
    + ATAROWS.map(row).join('')
    + `<tr><td><input id="ova_ata" class="sm" placeholder="72-51" style="width:64px"></td>`
    + `<td colspan="5" class="muted">ATA-Abschnitt ergänzen, den die Aufträge nicht kennen</td>`
    + `<td class="korr"><select id="ova_ck" class="sm">${opts}</select><input id="ova_note" class="sm" placeholder="Notiz (optional)">`
    + `<button class="sm" onclick="ataAdd()">hinzufügen</button></td></tr>`
    + '</tbody></table>';
}

async function ataSave(body) {
  const r = await fetch('/api/modules/ata/override', {method: 'POST',
    headers: {'Content-Type': 'application/json'}, body: JSON.stringify({family: ATAFAM, ...body})});
  const d = await r.json();
  if (d.error) { alert(d.error); return; }
  await ataReview();
  status();                                   // Kennzahlen (CK00-Zeile, abgeleitet) neu
  if (document.getElementById('q').value.trim()) search();
}
function ovTarget(v) { return v.startsWith('OG:') ? {og: v.slice(3)} : {ck: v}; }
function ataSet(i) {
  const ck = document.getElementById('ovs_' + i).value;
  if (!ck) { alert('Erst ein Modul oder eine Obergruppe wählen.'); return; }
  ataSave({ata: ATAROWS[i].ata, ...ovTarget(ck), note: document.getElementById('ovn_' + i).value});
}
function ataIgnore(i) { ataSave({ata: ATAROWS[i].ata, ignore: true, note: document.getElementById('ovn_' + i).value}); }
function ataReset(i) { ataSave({ata: ATAROWS[i].ata, reset: true}); }
function ataAdd() {
  const ata = document.getElementById('ova_ata').value.trim(), ck = document.getElementById('ova_ck').value;
  if (!ata || !ck) { alert('ATA-Abschnitt und Modul angeben.'); return; }
  ataSave({ata: ata, ...ovTarget(ck), note: document.getElementById('ova_note').value});
}

function famInfo() {
  const fam = document.getElementById('fam').value, el = document.getElementById('faminfo');
  const f = M && M.families.find(x => x.family === fam);
  if (!f) { el.textContent = ''; return; }
  const r = f.rueckrechnung;
  el.textContent = f.family + ': ' + fmt(f.teilenummern) + ' Teilenummern, davon ' + fmt(f.eindeutig)
    + ' eindeutig einem Modul zugeordnet' + (r.bewertet ? ' · Rückrechnung: ' + pct(r.treffer_eindeutig, r.eindeutig)
    + ' % richtig bei ' + pct(r.eindeutig, r.bewertet) + ' % eindeutigen Vorschlägen (' + fmt(r.bewertet) + ' Fälle)' : '');
}

function searchSoon() { clearTimeout(TIMER); TIMER = setTimeout(search, 250); }

async function search() {
  const q = document.getElementById('q').value.trim(), fam = document.getElementById('fam').value;
  const out = document.getElementById('out'), mods = document.getElementById('mods');
  const atah = document.getElementById('atahits');
  const cnt = document.getElementById('vs-count');
  if (!M || q.length < 2) { out.innerHTML = ''; mods.innerHTML = ''; atah.innerHTML = ''; cnt.innerHTML = ''; browse(); return; }
  document.getElementById('browse').innerHTML = '';
  const d = await (await fetch('/api/modules/suggest?family=' + encodeURIComponent(fam)
                               + '&q=' + encodeURIComponent(q))).json();
  if (d.error) { out.innerHTML = '<div class="warn">' + esc(d.error) + '</div>'; return; }
  mods.innerHTML = (d.modules || []).length
    ? '<div class="box"><h3>Module mit „' + esc(q) + '" im Namen</h3>' + d.modules.map(m =>
        `<span style="margin-right:14px"><b>${esc(m.ck)}</b> ${esc(m.name.replace(/^[^:]*:[ ]*/, ''))}`
        + (fam ? '' : ` <span class="muted">${esc(m.family)}</span>`) + '</span>').join('') + '</div>' : '';
  // Quelle 3: ATA-Code im Suchtext und MPL-Katalogteile (Modul über IPC/ATA)
  // Rückfallebene: Modul offen, Obergruppe (WBS-Mittelsegment) eindeutig
  const ogLine = a => a.og && !a.ck
    ? `<div class="ogline">→ <b>Obergruppe ${esc(a.og)}</b>${a.og_head ? ' · ' + esc(a.og_head) : ''} — `
      + `das Modul bleibt offen, die Obergruppe trägt ${(a.og_candidates || []).filter(c => c.og === a.og).map(c => c.share + ' %').join('') || 'laut Korrektur'} der Aufträge</div>`
    : '';
  const cand = c => `<div class="cand"><span class="ck">${esc(c.ck)}</span>`
      + `<span>${esc(c.name.replace(/^[^:]*:[ ]*/, ''))}</span>`
      + `<span class="track"><i style="width:${c.share}%"></i></span>`
      + `<span class="muted">${c.share} % · ${fmt(c.n)} Aufträge</span></div>`;
  const vcls = v => v === 'eindeutig' ? 'eindeutig' : v === 'mehrdeutig' ? 'mehrdeutig' : v === 'nur Obergruppe' ? 'og'
    : String(v).startsWith('korrigiert') ? 'korr' : 'wenig';
  atah.innerHTML = (d.ata || []).map(a =>
      `<div class="hit" style="border-left:3px solid var(--info)"><div class="t"><span class="pn">ATA ${esc(a.ata)}</span>`
      + `<span class="v ${vcls(a.verdict)}">${esc(a.verdict)}</span><span class="muted">${esc(a.family)} · aus ${fmt(a.n)} Modul-Aufträgen`
      + (a.ck00_incidents ? ' · ' + fmt(a.ck00_incidents) + ' CK00-Incidents mit diesem ATA' : '')
      + (a.override && a.override.note ? ' · Korrektur: „' + esc(a.override.note) + '"' : '') + '</span></div>'
      + ogLine(a) + a.candidates.map(cand).join('') + '</div>').join('')
    + ((d.ipc || []).length ? '<h3 style="font-size:13px;margin:12px 0 6px">Aus dem MPL-Katalog — Modul über das ATA-Kapitel der IPC-Nummer</h3>'
      + d.ipc.map(h => `<div class="hit"><div class="t"><span class="pn">${esc(h.part)}</span><span>${esc(h.bez)}</span>`
          + `<span class="v ${vcls(h.verdict)}">${esc(h.verdict)}</span><span class="muted">${esc(h.family)} · IPC ${esc(h.csn)} → ATA ${esc(h.ata)}</span></div>`
          + h.candidates.map(cand).join('') + '</div>').join('') : '');
  const nEin = d.results.filter(h => h.verdict === 'eindeutig').length;
  cnt.innerHTML = `<b>${fmt(d.results.length)}${d.more ? '+' : ''}</b> Treffer für „${esc(q)}" · ${fam ? esc(fam) : 'alle Familien'}`
    + (d.results.length ? ` · <b>${fmt(nEin)}</b> eindeutig` : '')
    + ((d.ata || []).length ? ` · ${fmt(d.ata.length)} ATA-Treffer` : '');
  if (!d.results.length) {
    out.innerHTML = (d.ata || []).length || (d.ipc || []).length ? ''
      : '<p class="muted">Keine Teilenummer gefunden. Kürzer suchen oder oben „alle Familien" wählen.</p>';
    return;
  }
  out.innerHTML = d.results.map(h => {
    const cls = h.verdict === 'eindeutig' ? 'eindeutig' : h.verdict === 'mehrdeutig' ? 'mehrdeutig' : 'wenig';
    return `<div class="hit"><div class="t"><span class="pn">${esc(h.part)}</span><span>${esc(h.bez)}</span>`
      + `<span class="v ${cls}">${esc(h.verdict)}</span><span class="muted">${esc(h.family)} · ${fmt(h.n)} Belege</span>`
      + (h.candidates.some(c => c.ecms) ? ` <a class="muted" href="/hub/module/graph?family=${encodeURIComponent(h.family)}&q=${encodeURIComponent(h.part)}">im Graph</a>` : '')
      + (h.candidates.length ? ` <a class="muted" href="/hub/module/katalog?family=${encodeURIComponent(h.family)}&ck=${encodeURIComponent(h.candidates[0].ck)}">im Katalog</a>` : '')
      + '</div>'
      + h.candidates.map(c => `<div class="cand"><span class="ck">${esc(c.ck)}</span>`
          + `<span>${esc(c.name.replace(/^[^:]*:[ ]*/, ''))}</span>`
          + `<span class="track"><i style="width:${c.share}%"></i></span>`
          + `<span class="muted">${c.share} % · ${fmt(c.ecms)} Einbauten · ${fmt(c.inc)} Incidents</span></div>`).join('')
      + '</div>';
  }).join('') + (d.more ? `<p class="muted">… ${d.more} weitere — Suche genauer fassen.</p>` : '');
}

function exQ(v) { const q = document.getElementById('q'); q.value = v; q.focus(); search(); }

// Leerer Zustand: häufigste eindeutig zuordenbare Teile je Modul der Familie
// (dieselben Daten wie der Aushang, /api/modules/sheet). ?ck=… hebt ein Modul hervor.
let BROWSE_CK = '', BROWSE_CACHE = {};
async function browse() {
  const el = document.getElementById('browse');
  const q = document.getElementById('q').value.trim(), fam = document.getElementById('fam').value;
  if (!M || q.length >= 2) { el.innerHTML = ''; return; }
  if (!fam) {
    el.innerHTML = '<p class="vs-lead">Familie wählen, dann zeigt die Seite hier die häufigsten eindeutig '
      + 'zuordenbaren Teile je Modul — oder oben direkt eine Teilenummer eingeben.</p>';
    return;
  }
  let d = BROWSE_CACHE[fam];
  if (!d) {
    el.innerHTML = '<p class="vs-lead">Lade häufigste Teile je Modul …</p>';
    d = BROWSE_CACHE[fam] = await (await fetch('/api/modules/sheet?family=' + encodeURIComponent(fam))).json();
  }
  if (document.getElementById('q').value.trim().length >= 2) return;   // inzwischen gesucht
  if (d.error) { el.innerHTML = '<div class="warn">' + esc(d.error) + '</div>'; return; }
  const mods = BROWSE_CK ? d.modules.filter(m => m.ck === BROWSE_CK) : d.modules;
  const first = (d.modules[0] || {}).parts || [];
  document.getElementById('vs-expn').innerHTML = first.length
    ? `<button class="chip" onclick="exQ('${esc(first[0].part)}')">${esc(first[0].part)}</button>` : '';
  el.innerHTML = '<p class="vs-lead">' + (BROWSE_CK
      ? `Häufigste Teile von <b>${esc(BROWSE_CK)}</b> · ${esc(fam)} · <a href="#" onclick="BROWSE_CK='';browse();return false">alle Module zeigen</a>`
      : `Häufigste eindeutig zuordenbare Teile je Modul · ${esc(fam)} · Klick auf eine Teilenummer sucht sie`) + '</p>'
    + '<div class="browse">' + (mods.length ? mods.map(m => `<div class="bm${m.ck === BROWSE_CK ? ' hl' : ''}">`
      + `<h4><b>${esc(m.ck)}</b>${esc(m.name.replace(/^[^:]*:[ ]*/, ''))}`
      + `<a href="/hub/module/katalog?family=${encodeURIComponent(fam)}&ck=${encodeURIComponent(m.ck)}">im Katalog</a></h4><table>`
      + m.parts.slice(0, 6).map(p => `<tr><td onclick="exQ('${esc(p.part)}')">${esc(p.part)}</td><td>${esc(p.bez)}</td>`
        + `<td class="num">${p.share} %</td></tr>`).join('')
      + (m.weitere || m.parts.length > 6 ? `<tr><td colspan="3" class="muted">+ ${(m.weitere || 0) + Math.max(0, m.parts.length - 6)} weitere</td></tr>` : '')
      + '</table></div>').join('') : '<p class="muted">Für dieses Modul sind keine eindeutig zuordenbaren Teile bekannt.</p>')
    + '</div>';
}

async function sheet() {
  const fam = document.getElementById('fam').value;
  if (!fam) { alert('Für den Aushang erst eine Familie wählen.'); return; }
  const d = await (await fetch('/api/modules/sheet?family=' + encodeURIComponent(fam))).json();
  if (d.error) { alert(d.error); return; }
  document.getElementById('sheet').innerHTML =
    `<h2>Modulvorschlag ${esc(fam)}</h2><p class="muted">Häufigste eindeutig zuordenbare Teile je Modul · Stand `
    + esc(d.ts.replace('T', ' ')) + '</p>'
    + d.modules.map(m => `<div class="sm"><h4>${esc(m.ck)} · ${esc(m.name.replace(/^[^:]*:[ ]*/, ''))}</h4><table>`
        + m.parts.map(p => `<tr><td style="width:150px;font-family:monospace">${esc(p.part)}</td><td>${esc(p.bez)}</td>`
          + `<td class="num" style="width:60px">${p.share} %</td></tr>`).join('')
        + (m.weitere ? `<tr><td colspan="3" class="muted">+ ${m.weitere} weitere</td></tr>` : '')
        + '</table></div>').join('');
  window.print();
}

async function rebuild() {
  await fetch('/api/modules/matrix?force=1');
  setTimeout(status, 800);
}

status();
</script>
</body>
</html>
"""


@app.route('/hub/module/vorschlag')
def modvor_page():
    return _module_page(MODVOR_HTML, 'vorschlag')


MODGRAPH_HTML = """<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title>SAP Claim Analyzer – Modul-Hierarchie</title>
<script src="/static/vis-network.min.js"></script>
<!--SAP_BASE-->
<style>
  *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
  
  html, body { height: 100%; }
  body { font-family: var(--font); background: var(--bg); color: var(--text); font-size: 13px;
         display: flex; flex-direction: column; }





  .bar { display: flex; gap: 8px; align-items: center; padding: 8px 16px; flex-wrap: wrap;
         background: var(--surface); border-bottom: 1px solid var(--border); flex-shrink: 0; }
  .bar h2 { font-size: 15px; margin-right: 8px; }
  select, input { padding: 6px 9px; font-size: 13px; border: 1px solid var(--border); border-radius: var(--r-s); }
  #q { min-width: 230px; }
  .btn { background: var(--surface); border: 1px solid var(--border); color: var(--text); border-radius: var(--r-pill);
         padding: 4px 11px; font-size: 13px; cursor: pointer; }
  .btn:hover { background: var(--bg); }
  .btn.on { background: var(--ink); border-color: var(--ink); color: var(--surface); font-weight: 600; }
  .lbl { font-size: 11px; color: var(--muted); text-transform: uppercase; letter-spacing: .6px; }
  .legend { display: flex; gap: 10px; font-size: 11px; color: var(--muted); margin-left: auto; flex-wrap: wrap; }
  .legend i { display: inline-block; width: 10px; height: 10px; border-radius: var(--r-s); margin-right: 4px; vertical-align: -1px; }
  #net { flex: 1; min-height: 0; position: relative; }
  #canvas { position: absolute; inset: 0; }
  #sv { width: 170px; }
  #svwrap { position: relative; display: inline-block; }
  #sug { display: none; position: absolute; top: calc(100% + 3px); left: 0; z-index: 30; width: 480px;
         max-height: 380px; overflow-y: auto; background: var(--surface); border: 1px solid var(--border);
         border-radius: var(--r-m); box-shadow: 0 10px 30px rgba(0,0,0,.16); font-size: 13px; }
  #sug .sg { display: flex; gap: 8px; align-items: baseline; padding: 6px 10px; cursor: pointer;
             border-bottom: 1px solid var(--hover); }
  #sug .sg:last-child { border-bottom: 0; }
  #sug .sg.on, #sug .sg:hover { background: var(--hover); }
  #sug .k { flex-shrink: 0; width: 50px; font-size: 10px; text-transform: uppercase; letter-spacing: .5px;
            color: var(--muted); }
  #sug .v { font-weight: 700; white-space: nowrap; }
  #sug .v b { background: var(--warn-bg); font-weight: 700; }
  #sug .m { color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
  #sug .x { color: var(--ink-3); font-style: italic; }
  #sug .hd { padding: 7px 10px; color: var(--muted); }
  #scopebar { display: none; gap: 8px; align-items: center; flex-wrap: wrap; padding: 6px 16px;
              background: var(--hover); border-bottom: 1px solid var(--border); font-size: 13px; flex-shrink: 0; }
  #scopebar b { font-size: 13px; }
  #scopebar .muted { color: var(--muted); }
  .chip { border: 1px solid var(--line-2); background: var(--surface); border-radius: var(--r-pill); padding: 3px 10px; font-size: 11px;
          cursor: pointer; }
  .chip.off { opacity: .45; text-decoration: line-through; }
  .chip small { color: var(--muted); }
  .msg { padding: 28px; color: var(--ink); line-height: 1.7; max-width: 760px; }
  .msg b { color: var(--ink); }
  #stats { position: absolute; bottom: 12px; right: 16px; background: var(--surface); border: 1px solid var(--border);
           border-radius: var(--r-pill); padding: 4px 12px; font-size: 11px; color: var(--muted); pointer-events: none; }
  #pop { display: none; position: fixed; z-index: 20; min-width: 260px; max-width: 420px; background: var(--surface);
         border: 1px solid var(--border); border-radius: var(--r-m); box-shadow: 0 10px 30px rgba(0,0,0,.18); font-size: 13px; }
  #pop .h { padding: 9px 12px; border-bottom: 1px solid var(--border); font-weight: 700; display: flex;
            justify-content: space-between; gap: 10px; }
  #pop .h span { cursor: pointer; color: var(--muted); font-weight: 400; }
  #pop table { border-collapse: collapse; width: 100%; }
  #pop td { padding: 4px 12px; vertical-align: top; border-bottom: 1px solid var(--hover); }
  #pop td:first-child { color: var(--muted); white-space: nowrap; width: 1%; }
  #pop .f { padding: 8px 12px; display: flex; gap: 6px; flex-wrap: wrap; }
  #pop a { font-size: 13px; }
  .legend i { vertical-align: -1px; }
  /* Ansicht: Bedienfeld, Übersichtskarte, lesbare Schilder beim Herauszoomen */
  #viewctl { position: absolute; top: 12px; right: 16px; z-index: 15; display: flex; flex-direction: column; gap: 1px;
             background: var(--line); border: 1px solid var(--line-2); border-radius: var(--r-m); overflow: hidden;
             box-shadow: 0 6px 20px rgba(27,27,27,.10); }
  #viewctl button { all: unset; box-sizing: border-box; cursor: pointer; display: flex; align-items: center;
                    justify-content: center; width: 40px; height: 32px; background: var(--surface); color: var(--ink);
                    font: 500 15px/1 var(--font); white-space: nowrap; }
  #zoomval { font-size: 10px !important; height: 24px !important; color: var(--ink-2) !important; }
  #viewctl button:hover { background: var(--hover); }
  #viewctl button:focus-visible { outline: 2px solid var(--ink); outline-offset: -2px; }
  #viewctl button.on { background: var(--ink); color: var(--paper); }
  #viewctl button .g { width: 14px; text-align: center; font-size: 13px; }
  #viewctl kbd { margin-left: auto; padding-left: 10px; font: 10px var(--mono); color: var(--ink-3); }
  #viewctl button.on kbd { color: rgba(240,237,230,.6); }
  #viewctl .zrow { display: flex; gap: 1px; background: var(--line); }
  #viewctl .zrow button { flex: 1; justify-content: center; padding: 0 8px; }
  #zoomval { font-variant-numeric: tabular-nums; }
  #mini { position: absolute; left: 16px; bottom: 12px; z-index: 15; background: rgba(251,250,246,.95);
          border: 1px solid var(--line-2); border-radius: var(--r-m); box-shadow: 0 6px 20px rgba(27,27,27,.10);
          padding: 6px; cursor: pointer; }
  #mini canvas { display: block; }
  #mini .t { font-size: 10px; letter-spacing: .14em; text-transform: uppercase; color: var(--ink-3); margin: 0 0 4px 2px; }
  #ovl { position: absolute; inset: 0; pointer-events: none; z-index: 5; overflow: hidden; }
  #ovl .oc { position: absolute; transform: translate(-50%, -50%); padding: 4px 8px; border-radius: var(--r-s);
             font: 12px/1.25 var(--font); text-align: center; white-space: nowrap; overflow: hidden;
             text-overflow: ellipsis; box-shadow: 0 1px 4px rgba(0,0,0,.22); }
  #ovl .oc b { font-weight: 700; }
  #ovl .oc small { display: block; font-size: 11px; opacity: .85; overflow: hidden; text-overflow: ellipsis; }
  /* Schild deckt den Diagramm-Kasten dahinter immer ganz ab (min-Maße aus drawOverlay) → nur ein Rechteck */
  #ovl .oc { box-sizing: border-box; display: flex; flex-direction: column; justify-content: center; }
  #ovl .oc > span { display: block; overflow: hidden; text-overflow: ellipsis; }
  #ovl .oc.tight { padding: 3px 5px; font-size: 11px; }
  #ovl .oc.tight > span { text-overflow: clip; }
  #ovl .oc.tight small { font-size: 10px; }
  #ovl .ol { position: absolute; font: 500 11px/14px var(--font); white-space: nowrap; overflow: hidden;
             text-overflow: ellipsis; }
  #ovl .ol i { font-style: normal; opacity: .8; margin-left: 4px; }
  html:fullscreen, html:fullscreen body { background: var(--paper); }
  /* Stufe 4: Werkzeugleiste in Gruppen */
  .bar { gap: 6px 0; padding: 8px 16px; }
  .bar .grp { display: flex; align-items: center; gap: 6px; padding: 0 14px; border-left: 1px solid var(--line); }
  .bar .grp:first-child { padding-left: 0; border-left: 0; }
  .bar .grp .lbl { font-size: 10px; letter-spacing: .14em; }
  .bar .seg3 { display: inline-flex; }
  .bar .seg3 .btn { border-radius: 0; margin-left: -1px; }
  .bar .seg3 .btn:first-child { border-radius: var(--r-s) 0 0 var(--r-s); margin-left: 0; }
  .bar .seg3 .btn:last-child { border-radius: 0 var(--r-s) var(--r-s) 0; }
  /* Stufe 3: Bühne = Baum + Seitenleiste */
  #stage { flex: 1; min-height: 0; display: flex; }
  #net { min-width: 0; }
  #side { width: 320px; flex-shrink: 0; border-left: 1px solid var(--line-2); background: var(--surface);
          overflow-y: auto; transition: width .25s var(--ease); }
  #side.closed { width: 0; border-left: 0; overflow: hidden; }
  #side .sd-sec { padding: 14px 16px; border-bottom: 1px solid var(--line); }
  #side .eyebrow { font-size: 10px; display: block; margin-bottom: 4px; }
  #side h3 { font-size: 17px; font-weight: 500; margin: 0 0 2px; letter-spacing: -.01em; }
  #side p { color: var(--ink-2); font-size: 12px; line-height: 1.45; margin: 0; }
  #side p b { color: var(--ink); }
  #side .big { font-size: 28px; font-weight: 500; letter-spacing: -.02em; line-height: 1.1; margin: 6px 0 2px;
               font-variant-numeric: tabular-nums; }
  #side .big small { font-size: 12px; color: var(--ink-2); font-weight: 400; margin-left: 4px; }
  .rk { display: grid; grid-template-columns: minmax(0,1fr) auto; gap: 1px 8px; padding: 5px 6px; margin: 0 -6px;
        border-radius: var(--r-s); cursor: pointer; text-decoration: none; color: var(--ink); font-size: 12px; }
  .rk:hover { background: var(--hover); }
  .rk .n { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
  .rk .v { font-variant-numeric: tabular-nums; color: var(--ink-2); }
  .rk .v b { color: var(--ink); font-weight: 600; }
  .rk i { grid-column: 1 / -1; display: block; height: 4px; border-radius: 2px; background: var(--ink); opacity: .75; }
  .rk i.up { background: var(--crit); } .rk i.dn { background: var(--ok); }
  .rk .v .up { color: var(--crit); } .rk .v .dn { color: var(--ok); }
  #side .legend { margin: 6px 0 0; display: flex; flex-wrap: wrap; gap: 6px 12px; font-size: 11px; color: var(--ink-2); }
  #side .legend i { width: 12px; height: 10px; box-sizing: border-box; }
  #sd-detail table { width: 100%; border-collapse: collapse; margin-top: 8px; }
  #sd-detail td { padding: 5px 0; border-bottom: 1px solid var(--line); vertical-align: top; font-size: 12px; }
  #sd-detail td:first-child { color: var(--ink-2); width: 42%; padding-right: 8px; }
  #sd-detail .f { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 12px; }
  #sd-detail .f a { font-size: 12px; }
  #inc-btn { margin-top: 10px; }
  #inc-list .inc-note { font-size: 11px; color: var(--ink-2); margin: 10px 0 4px; line-height: 1.5; }
  #inc-list .inc-row { padding: 7px 0; border-bottom: 1px solid var(--line); font-size: 12px; }
  #inc-list .inc-h { display: flex; gap: 8px; align-items: baseline; }
  #inc-list .inc-h a { color: var(--ink); text-decoration: none; border-bottom: 1px solid var(--line-2); }
  #inc-list .inc-h a:hover { border-bottom-color: var(--ink); }
  #inc-list .inc-h span:last-child { margin-left: auto; color: var(--ink-3); font-size: 11px; }
  #inc-list .inc-c { font-size: 10px; font-weight: 600; padding: 1px 6px; border-radius: var(--r-pill);
                     background: var(--neutral-bg); color: var(--ink-2); }
  #inc-list .inc-c.bs { background: var(--crit-bg); color: var(--crit); }
  #inc-list .inc-c.kr { background: var(--warn-bg); color: var(--warn); }
  #inc-list .inc-t { margin-top: 3px; line-height: 1.4; }
  #inc-list .inc-m { margin-top: 2px; color: var(--ink-3); font-size: 11px; }
  #crumb { position: absolute; left: 16px; top: 12px; z-index: 14; max-width: calc(100% - 120px); display: none;
           padding: 5px 10px; border-radius: var(--r-pill); background: rgba(251,250,246,.95); border: 1px solid var(--line-2);
           font-size: 12px; color: var(--ink-2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
           box-shadow: 0 2px 10px rgba(27,27,27,.08); pointer-events: none; }
  #crumb b { color: var(--ink); font-weight: 600; }
  #ovl .dim { opacity: .25; }
  /* Eine Zeile: Familie · Jahr · Suche · Tiefe · Farbe · ⋯ */
  .bar { flex-wrap: nowrap; gap: 0; padding: 8px 16px; min-height: 52px; }
  .bar .grp.grow { flex: 1 1 auto; min-width: 240px; }
  #omniwrap { position: relative; flex: 1 1 auto; }
  #omni { width: 100%; box-sizing: border-box; height: 34px; padding: 0 12px 0 32px; font-size: 13px;
          background: var(--surface); border: 1px solid var(--line-2); border-radius: var(--r-s); }
  #omni:focus { outline: none; border-color: var(--ink); box-shadow: 0 0 0 3px var(--acc-soft); }
  #omniwrap::before { content: "⌕"; position: absolute; left: 11px; top: 50%; transform: translateY(-52%);
                      color: var(--ink-3); font-size: 15px; pointer-events: none; }
  #omsug { display: none; position: absolute; top: calc(100% + 4px); left: 0; right: 0; z-index: 40; max-height: 420px;
           overflow-y: auto; background: var(--surface); border: 1px solid var(--line-2); border-radius: var(--r-m);
           box-shadow: 0 12px 32px rgba(27,27,27,.16); font-size: 13px; }
  #omsug .hd { padding: 8px 12px 4px; font-size: 10px; letter-spacing: .14em; text-transform: uppercase; color: var(--ink-3); }
  #omsug .it { display: grid; grid-template-columns: 64px minmax(0,auto) minmax(0,1fr); gap: 10px; align-items: baseline;
               padding: 6px 12px; cursor: pointer; }
  #omsug .it.on, #omsug .it:hover { background: var(--hover); }
  #omsug .it .k { font-size: 10px; letter-spacing: .1em; text-transform: uppercase; color: var(--ink-3); }
  #omsug .it .v { font-weight: 600; white-space: nowrap; }
  #omsug .it .v b { background: var(--warn-bg); }
  #omsug .it .m { color: var(--ink-2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
  #omsug .none { padding: 10px 12px; color: var(--ink-2); }
  #omsug .foot { padding: 6px 12px; border-top: 1px solid var(--line); font-size: 11px; color: var(--ink-3); }
  .seg3 .btn.on .c { opacity: .7; font-weight: 400; margin-left: 4px; }
  #morewrap { position: relative; }
  #moremenu { display: none; position: absolute; right: 0; top: calc(100% + 4px); z-index: 40; min-width: 240px;
              background: var(--surface); border: 1px solid var(--line-2); border-radius: var(--r-m);
              box-shadow: 0 12px 32px rgba(27,27,27,.16); padding: 6px; }
  #moremenu.open { display: block; }
  #moremenu > * { display: flex; align-items: center; gap: 8px; width: 100%; box-sizing: border-box; padding: 7px 10px;
                  border-radius: var(--r-s); font-size: 13px; color: var(--ink); text-decoration: none; cursor: pointer;
                  background: none; border: 0; text-align: left; }
  #moremenu > *:hover { background: var(--hover); }
  #moremenu hr { border: 0; border-top: 1px solid var(--line); margin: 4px 0; padding: 0; }
  /* Aktiv-Zeile: was gerade eingegrenzt ist, einzeln aufhebbar */
  #scopebar { gap: 8px; padding: 6px 16px; background: var(--paper); border-bottom: 1px solid var(--line); }
  #scopebar .eyebrow { font-size: 10px; margin-right: 2px; }
  .achip { display: inline-flex; align-items: center; gap: 6px; height: 26px; padding: 0 4px 0 10px; border-radius: var(--r-pill);
           background: var(--surface); border: 1px solid var(--line-2); font-size: 12px; white-space: nowrap; }
  .achip .k { font-size: 10px; letter-spacing: .1em; text-transform: uppercase; color: var(--ink-3); }
  .achip button { all: unset; cursor: pointer; width: 20px; height: 20px; border-radius: 50%; text-align: center;
                  line-height: 20px; color: var(--ink-2); }
  .achip button:hover { background: var(--ink); color: var(--paper); }
  #legacy-ctl { display: none !important; }
</style>
<!--SAP_SHELL-->
</head>
<body>
<!--SAP_RAIL-->
<header class="sap-ph"><!--SAP_PH-->
<div class="ph-right">
<!--SAP_MARK-->
</div>
</header>
<div class="bar">
  <div class="grp">
    <select id="fam" title="Engine-Familie" onchange="S.focus='';S.q='';S.sk='';S.sv='';S.only=[];document.getElementById('q').value='';document.getElementById('sv').value='';load()"></select>
    <select id="yr" onchange="S.year=this.value;load()" title="Jahr des Meldedatums — nur in der Familiensicht"><option value="">alle Jahre</option></select>
  </div>
  <div class="grp grow">
    <span id="omniwrap"><input id="omni" autocomplete="off" placeholder="Engine (Projekt, ESN), Modul oder Teilenummer suchen …"
      oninput="omniInput()" onfocus="omniInput()" onkeydown="omniKey(event)" onblur="setTimeout(omniHide, 150)"><div id="omsug"></div></span>
  </div>
  <div class="grp"><span class="lbl">Tiefe</span>
    <span class="seg3"><button class="btn" id="d1" onclick="setDepth(1)">Module</button><button class="btn" id="d2" onclick="setDepth(2)">Baugruppen</button><button class="btn" id="d3" onclick="setDepth(3)">Teile</button></span>
  </div>
  <div class="grp"><span class="lbl">Farbe</span>
    <span class="seg3"><button class="btn" id="col-typ" onclick="setColor(false)" title="Farbe nach Knotentyp">Typ</button><button class="btn" id="col-inc" onclick="setColor(true)" title="Farbe nach Anzahl Incidents (Heatmap) — Anordnung bleibt gleich">Incidents</button></span>
  </div>
  <div class="grp"><span id="morewrap">
    <button class="btn" onclick="toggleMore(event)" title="Weitere Optionen" aria-haspopup="true">⋯</button>
    <div id="moremenu" role="menu">
      <label title="Einbauten, die unter einem anderen Modul gebucht sind als die Mehrheit"><input type="checkbox" id="strays" onchange="S.strays=this.checked;load()"> Ausreißer zeigen</label>
      <button onclick="resetAll()">Alles zurücksetzen</button>
      <button onclick="rebuildTree()" title="Liest alle ECMS-Einbauten neu — dauert einige Minuten">Baum neu aufbauen</button>
      <hr>
      <a href="/hub/module">Modul-Katalog</a>
      <a href="/hub/module/vorschlag">Modulvorschlag</a>
    </div></span>
  </div>
  <!-- Bisherige Bedienelemente: unsichtbar, gleiche IDs — setScope()/search()/load() lesen sie weiter -->
  <div id="legacy-ctl" aria-hidden="true">
    <select id="sk"><option value="project">Projekt</option><option value="esn">ESN</option></select>
    <span id="svwrap"><input id="sv" oninput="sugInput()" onkeydown="sugKey(event)" onblur="setTimeout(sugHide, 150)"><div id="sug"></div></span>
    <select id="ck" onchange="S.focus=this.value;S.q='';document.getElementById('q').value='';load()"></select>
    <input id="q" onkeydown="if(event.key==='Enter')search()">
    <button class="btn" id="heatbtn" onclick="toggleHeat()">Heatmap Incidents</button>
  </div>
</div>
<div id="scopebar"></div>
<div id="stage">
<div id="net"><div id="canvas"></div><div id="ovl"></div><div id="crumb"></div><div id="stats"></div>
  <div id="viewctl" role="toolbar" aria-label="Ansicht">
    <button onclick="viewFit()" title="Einpassen — ganzer Baum (F)">⤢</button>
    <button onclick="viewWidth()" title="Breite einpassen — alle Obergruppen nebeneinander (W)">↔</button>
    <button onclick="zoomBy(1.25)" title="Vergrößern (+)">+</button>
    <button id="zoomval" onclick="viewScale(1)" title="Auf 100 % (0)">100 %</button>
    <button onclick="zoomBy(0.8)" title="Verkleinern (−)">−</button>
    <button id="minibtn" class="on" onclick="toggleMini()" title="Übersichtskarte ein/aus (M)">▣</button>
    <button id="fsbtn" onclick="toggleFs()" title="Vollbild — nur der Baum (V, Esc beendet)">⛶</button>
    <button id="sidebtn" class="on" onclick="toggleSide()" title="Aussagen &amp; Details ein/aus (A)">◧</button>
    <button onclick="savePng()" title="Bild speichern — aktuelle Ansicht als PNG (B)">⤓</button>
  </div>
  <div id="mini" style="display:none" title="Übersicht — klicken oder ziehen verschiebt den Ausschnitt">
    <div class="t">Übersicht</div><canvas id="minicv"></canvas></div>
</div>
<aside id="side" aria-label="Aussagen und Details">
  <div id="sd-detail" class="sd-sec" hidden></div>
  <div id="sd-main">
    <div class="sd-sec"><span class="eyebrow">Aussagen</span><h3 id="sd-title">–</h3><p id="sd-sub"></p>
      <div class="big" id="sd-total"></div></div>
    <div class="sd-sec" id="sd-top"></div>
    <div class="sd-sec" id="sd-og"></div>
    <div class="sd-sec" id="sd-trend"></div>
    <div class="sd-sec"><span class="eyebrow">Legende</span>
      <div class="legend" id="heatlegend" style="display:none"></div>
      <div class="legend" id="typelegend">
        <span><i style="background:#1b1b1b"></i>Familie</span>
        <span title="Mittelsegment des WBS-Codes (Projekt.OG.CK)"><i style="background:#3a3835"></i>Obergruppe (WBS)</span>
        <span><i style="background:#fbfaf6;border:1.5px solid #1b1b1b"></i>Modul</span>
        <span><i style="background:#fbfaf6;border:3px solid #1b1b1b"></i>Kopf (CK = OG)</span>
        <span title="Heuristik: CK ≥ 80 + Blades/Vanes/Segm. im Namen — steht nicht in LUD_Core"><i style="background:#fbfaf6;border:1.5px dashed #1b1b1b"></i>Teilesatz?</span>
        <span><i style="background:#f5ead0;border:1px solid #946300"></i>CK00 Engine</span>
        <span><i style="background:#fbfaf6;border:1px solid #b5afa5"></i>Baugruppe</span>
        <span><i style="background:#f0ede6;border:1px solid #cfc9bf"></i>Teil</span>
        <span><i style="background:#ebe7f5;border:1px solid #8b7cc7"></i>Normteil / mehrdeutig</span>
        <span><i style="background:#ecebe8;border:1px dashed #9e9e9e"></i>Ausreißer</span>
        <span><i style="background:#f1e4f7;border:2px solid #6a1b9a"></i>Suchtreffer</span>
        <span>Zahl im Kästchen = Incidents. Farbe nach Incidents: „Heatmap“.</span>
      </div></div>
  </div>
</aside>
</div>
<div id="pop"></div>
<script>
const NL = String.fromCharCode(10);
const S = {depth: 2, focus: '', q: '', force: false, strays: false, heat: false,
           year: '', sk: '', sv: '', only: []};
// Heatmap: warmer Verlauf (Gelb → Orange → Dunkelrot, YlOrRd), hell → dunkel =
// wenige → viele Incidents; 0 = neutral grau. Warm statt Blau, weil Incidents ein
// Problemmaß sind und Blau im Normalmodus schon „Modul“ heißt.
// Skala je Ebene (Module unter sich, Baugruppen unter sich …), linear vom
// kleinsten zum größten Wert der GANZEN Familie — im Modul-Fokus hat ein Modul
// dieselbe Farbe wie in der Übersicht. CK00 bleibt ungefärbt: kein Modul, und
// sein großer Topf würde alle Module blass machen.
const HEAT = ['#fff2b2', '#ffe38a', '#fed36a', '#feba4f', '#fda044', '#fc843a', '#f76530',
              '#ec4527', '#d92a22', '#c0151f', '#a3081f', '#86031f', '#67001f'];
// Im Heatmap-Modus kollidiert das Suchtreffer-Rot mit der Skala → violetter Rahmen;
// CK00 neutral grau gestrichelt statt Beige (sähe sonst wie die unterste Gelbstufe aus).
const HEAT_TARGET = '#6a1b9a';
const HEAT_CK00 = {background: '#f4f4f4', border: '#8a8a8a'};
const HEAT_ZERO = {background: '#ecebe8', border: '#cfcdc8'};
const HEAT_LEVELS = {section: 'Obergruppen', ck: 'Module', asm: 'Baugruppen', part: 'Teile'};
// Projekt/ESN: wenige Incidents je Knoten → feste Stufen statt 13er-Verlauf
// (ein Projekt hätte sonst mit 1 Incident schon die dunkelste Farbe).
const HEAT_STEPS = [[1, 1, 3], [2, 3, 6], [4, 7, 9], [8, Infinity, 12]];   // [von, bis, HEAT-Index]
// Projekt/ESN: im Familienbaum, aber in dieser Engine nicht verbaut (ECMS). Eigene
// blasse Farbe statt Transparenz — sonst scheinen die Linien durch den Kasten.
const NOT_INST = {background: '#ffffff', border: '#dcdcd8'};
function notInstalled(n) { return n.inst === 0 && !n.inc; }
function isScope() { return !!(DATA && DATA.scope && (DATA.scope.kind === 'project' || DATA.scope.kind === 'esn')); }
function stepIndex(v) { const s = HEAT_STEPS.find(([a, b]) => v >= a && v <= b); return s ? s[2] : 0; }
function isEngineLevel(n) { return n.group === 'ck00' || n.ck === 'CK00'; }
function toggleHeat() {
  S.heat = !S.heat;
  VIEW.keep = NET ? {scale: NET.getScale(), position: NET.getViewPosition()} : null;   // Ausschnitt behalten
  if (DATA) draw(DATA);
  writeUrl();
}
function setScope() {
  S.sk = document.getElementById('sk').value; S.sv = document.getElementById('sv').value.trim();
  S.only = []; S.focus = ''; S.q = ''; document.getElementById('q').value = '';
  if (S.sv) S.heat = true;              // Bereich ohne Farbe sagt wenig — Heatmap gleich an
  load();
}
function clearScope() { S.sk = ''; S.sv = ''; S.only = []; document.getElementById('sv').value = ''; load(); }
function toggleOnly(num) {
  const all = DATA.scope.projects.map(p => p.number);
  let on = S.only.length ? S.only.slice() : all.slice();
  on = on.includes(num) ? on.filter(x => x !== num) : on.concat([num]);
  if (!on.length) return;               // mindestens ein Projekt bleibt an
  S.only = on.length === all.length ? [] : on;
  load();
}
function scopeBar(d) {
  // Aktiv-Zeile: alles, was die Ansicht eingrenzt, als Chip mit × (Jahr, Engine, Modul, Teil)
  const bar = document.getElementById('scopebar'), sc = d.scope || {};
  const chip = (k, v, off) => `<span class="achip"><span class="k">${k}</span>${v}<button onclick="${off}" title="aufheben">✕</button></span>`;
  let h = '';
  if (sc.kind === 'project' || sc.kind === 'esn') {
    const sel = new Set(sc.selected || []), P = sc.projects || [];
    const yr = p => p.first ? p.first.slice(0, 4) + (p.last && p.last.slice(0, 4) !== p.first.slice(0, 4) ? '–' + p.last.slice(2, 4) : '') : 'ohne Incidents';
    const p0 = P[0] || {};
    h += chip(sc.kind === 'esn' ? 'ESN' : 'Projekt', '<b>' + esc(sc.kind === 'esn' ? (p0.esn || sc.value) : p0.number) + '</b>'
      + ' <span class="muted">' + esc(p0.engine || '') + (p0.customer ? ' · ' + esc(p0.customer) : '') + '</span>', 'clearScope()');
    if (sc.kind === 'esn')
      h += P.map(p => `<span class="chip${sel.has(p.number) ? '' : ' off'}" onclick="toggleOnly('${esc(p.number)}')" title="${esc(p.name || '')} — klicken zum Ab-/Zuschalten">`
        + `${esc(p.number)} <small>${yr(p)} · ${fmt(p.inc)} Inc.</small></span>`).join('');
    else h += ' <span class="muted">' + (p0.esn ? 'ESN ' + esc(p0.esn) + ' · ' : '') + yr(p0) + ' · ' + fmt(p0.inc) + ' Incidents' + (p0.state ? ' · ' + esc(p0.state) : '') + '</span>';
    if (sc.ohne_modul) h += ' <span class="muted" title="auf Projekt- oder Sonder-WBS gebucht, nicht auf ein CK-Element">· ' + fmt(sc.ohne_modul) + ' ohne Modul</span>';
    if ((sc.mixed_families || []).length) h += ' <span style="color:var(--crit)">· Projekte aus mehreren Familien: ' + sc.mixed_families.map(esc).join(', ') + '</span>';
    h += ' <span class="muted">· Baum = ganze Familie ' + esc(d.family) + ', blass = in dieser Engine nicht verbaut</span>';
  } else if (S.year) h += chip('Jahr', esc(S.year), "S.year='';load()");
  if (d.focus) {
    const c = (d.cks || []).find(x => x.ck === d.focus);
    h += chip('Modul', '<b>' + esc(d.focus) + '</b>' + (c ? ' ' + esc(nice(c.name)) : ''), "S.focus='';load()");
  }
  if (d.q) h += chip('Teil', '<b>' + esc(d.q) + '</b> <span class="muted">' + (d.hits || []).length + ' Fundstelle(n)</span>',
                     "S.q='';document.getElementById('q').value='';load()");
  if (!h) { bar.style.display = 'none'; return; }
  bar.innerHTML = '<span class="eyebrow">Aktiv</span>' + h
    + '<button class="btn ghost sm" style="margin-left:auto" onclick="resetAll()">Alles zurücksetzen</button>';
  bar.style.display = 'flex';
}
function resetAll() {
  S.sk = ''; S.sv = ''; S.only = []; S.focus = ''; S.q = ''; S.year = '';
  document.getElementById('sv').value = ''; document.getElementById('q').value = ''; document.getElementById('omni').value = '';
  document.getElementById('moremenu').classList.remove('open');
  load();
}
function setColor(inc) { if (S.heat !== inc) toggleHeat(); }
function toggleMore(e) { e.stopPropagation(); document.getElementById('moremenu').classList.toggle('open'); }
document.addEventListener('click', e => {
  const m = document.getElementById('moremenu');
  if (m && m.classList.contains('open') && !m.contains(e.target)) m.classList.remove('open');
});
async function rebuildTree() {
  document.getElementById('moremenu').classList.remove('open');
  await fetch('/api/modules/matrix?force=1');
  setTimeout(load, 800);
}

// ── Eine Suche für Engine, Modul und Teil ────────────────────────────────
// Module aus den geladenen Daten, Engines (Projekt/ESN) über /api/modules/graph/suggest,
// Teil = Pfadsuche search(). Die Auswahl ruft dieselben Funktionen wie die alten Felder.
let OMNI = [], OMNII = -1, OMNIT = null;
function omniHide() { document.getElementById('omsug').style.display = 'none'; OMNII = -1; }
function omniInput() { clearTimeout(OMNIT); OMNIT = setTimeout(omniFetch, 160); }
async function omniFetch() {
  const box = document.getElementById('omsug'), q = document.getElementById('omni').value.trim();
  if (q.length < 2) { omniHide(); return; }
  const ql = q.toLowerCase().replace(/\s+/g, '');
  const mods = ((DATA && DATA.cks) || []).filter(c => (c.ck + ' ' + c.name).toLowerCase().replace(/\s+/g, '').includes(ql)
      || nice(c.name).toLowerCase().includes(q.toLowerCase())).slice(0, 6)
    .map(c => ({kind: 'modul', value: c.ck, label: nice(c.name), meta: ''}));
  const partItem = {kind: 'teil', value: q, label: 'im Baum suchen', meta: 'Pfad nach oben zeigen'};
  const draw0 = eng => {
    const items = [...mods, ...eng, partItem];
    OMNI = items;
    if (OMNII < 0 || OMNII >= items.length) OMNII = 0;
    const grp = {modul: 'Module', projekt: 'Engines', esn: 'Engines', teil: 'Teil'};
    let last = '', h = '';
    items.forEach((it, i) => {
      const g = grp[it.kind];
      if (g !== last) { h += `<div class="hd">${g}</div>`; last = g; }
      h += `<div class="it${i === OMNII ? ' on' : ''}" data-i="${i}" onmousedown="event.preventDefault();omniPick(${i})">`
        + `<span class="k">${it.kind === 'esn' ? 'ESN' : it.kind === 'projekt' ? 'Projekt' : it.kind === 'modul' ? 'Modul' : 'Teil'}</span>`
        + `<span class="v">${sugMark(it.value, q)}</span><span class="m">${esc(it.label)}${it.meta ? ' · ' + esc(it.meta) : ''}</span></div>`;
    });
    box.innerHTML = h + '<div class="foot">↑ ↓ wählen · Enter übernehmen · Esc schließen</div>';
    box.style.display = 'block';
  };
  draw0([]);                                               // Module + Teil sofort, Engines kommen nach
  try {
    const r = await (await fetch('/api/modules/graph/suggest?kind=project&q=' + encodeURIComponent(q))).json();
    if (q !== document.getElementById('omni').value.trim() || document.activeElement.id !== 'omni') return;
    const eng = (r.items || []).slice(0, 6).map(it => ({kind: it.kind === 'esn' ? 'esn' : 'projekt', value: it.value,
      label: [it.engine, it.customer].filter(Boolean).join(' · '),
      meta: fmt(it.inc) + ' Inc.' + (it.kind === 'esn' ? ' · ' + it.visits.length + ' Visit(s)' : (it.esn ? ' · ESN ' + it.esn : ''))
            + (it.tree ? '' : ' · kein Baum')}));
    // Treffer, der genau passt, nach vorn (Projekt/ESN vor „Teil suchen")
    if (eng.length && !mods.length) OMNII = 0;
    draw0(eng);
  } catch (e) { /* ohne Server-Vorschläge bleiben Module + Teil */ }
}
function omniKey(e) {
  const box = document.getElementById('omsug'), open = box.style.display === 'block' && OMNI.length;
  if (e.key === 'ArrowDown' && open) OMNII = Math.min(OMNI.length - 1, OMNII + 1);
  else if (e.key === 'ArrowUp' && open) OMNII = Math.max(0, OMNII - 1);
  else if (e.key === 'Enter') { e.preventDefault(); if (open) omniPick(OMNII); return; }
  else if (e.key === 'Escape') { omniHide(); e.target.blur(); return; }
  else return;
  e.preventDefault();
  box.querySelectorAll('.it').forEach(el => el.classList.toggle('on', +el.dataset.i === OMNII));
  const on = box.querySelector('.it.on'); if (on) on.scrollIntoView({block: 'nearest'});
}
function omniPick(i) {
  const it = OMNI[i];
  if (!it) return;
  omniHide();
  const inp = document.getElementById('omni');
  inp.value = ''; inp.blur();
  if (it.kind === 'modul') { S.focus = it.value; S.q = ''; document.getElementById('q').value = ''; load(); }
  else if (it.kind === 'projekt' || it.kind === 'esn') {
    document.getElementById('sk').value = it.kind === 'esn' ? 'esn' : 'project';
    document.getElementById('sv').value = it.value;
    setScope();
  } else { document.getElementById('q').value = it.value; search(); }
}
// ── Live-Vorschläge beim Tippen: Projekte UND ESNs, gewählte Art zuerst ──
let SUG = [], SUGI = -1, SUGT = null;
function sugInput() { clearTimeout(SUGT); SUGT = setTimeout(sugFetch, 180); }
function sugHide() { document.getElementById('sug').style.display = 'none'; SUGI = -1; }
async function sugFetch() {
  const box = document.getElementById('sug'), q = document.getElementById('sv').value.trim();
  if (q.length < 2) { sugHide(); return; }
  if (!SUG.length) { box.innerHTML = '<div class="hd">suche …</div>'; box.style.display = 'block'; }
  const r = await (await fetch('/api/modules/graph/suggest?kind=' + document.getElementById('sk').value
                               + '&q=' + encodeURIComponent(q))).json();
  if (q !== document.getElementById('sv').value.trim() || document.activeElement.id !== 'sv') return;  // veraltet
  SUG = r.items || []; SUGI = -1;
  if (r.error) { box.innerHTML = '<div class="hd">&#9651; ' + esc(r.error) + '</div>'; return; }
  if (!SUG.length) { box.innerHTML = '<div class="hd">nichts gefunden zu „' + esc(q) + '"</div>'; box.style.display = 'block'; return; }
  box.innerHTML = SUG.map((it, i) => {
    const vis = it.kind === 'esn'
      ? it.visits.length + ' Visit' + (it.visits.length === 1 ? '' : 's') + ': '
        + it.visits.map(v => v[0] + (v[1] ? ' ' + v[1] : '')).join(', ')
      : [it.esn ? 'ESN ' + it.esn : '', it.year, it.customer].filter(Boolean).join(' · ');
    return `<div class="sg" data-i="${i}" onmousedown="event.preventDefault();sugPick(${i})">`
      + `<span class="k">${it.kind === 'esn' ? 'ESN' : 'Projekt'}</span>`
      + `<span class="v">${sugMark(it.value, q)}</span>`
      + `<span class="m">${esc(it.engine || '')} · ${fmt(it.inc)} Inc. · ${esc(vis)}</span>`
      + (it.tree ? '' : `<span class="x" title="Für ${esc(it.family)} gibt es keinen ECMS-Baum">kein Baum</span>`)
      + '</div>';
  }).join('');
  box.style.display = 'block';
}
function sugMark(v, q) {
  // Treffer hervorheben — Leerzeichen und Groß/klein wie bei der Suche ignorieren
  const norm = s => s.replace(/\s+/g, '').toUpperCase(), qn = norm(q);
  let map = [], flat = '';
  for (let i = 0; i < v.length; i++) if (!/\s/.test(v[i])) { map.push(i); flat += v[i].toUpperCase(); }
  const at = flat.indexOf(qn);
  if (at < 0 || !qn) return esc(v);
  const a = map[at], b = map[at + qn.length - 1] + 1;
  return esc(v.slice(0, a)) + '<b>' + esc(v.slice(a, b)) + '</b>' + esc(v.slice(b));
}
function sugKey(e) {
  const box = document.getElementById('sug'), open = box.style.display === 'block' && SUG.length;
  if (e.key === 'ArrowDown' && open) { SUGI = Math.min(SUG.length - 1, SUGI + 1); }
  else if (e.key === 'ArrowUp' && open) { SUGI = Math.max(-1, SUGI - 1); }
  else if (e.key === 'Enter') { e.preventDefault(); if (open && SUGI >= 0) sugPick(SUGI); else { sugHide(); setScope(); } return; }
  else if (e.key === 'Escape') { sugHide(); return; }
  else return;
  e.preventDefault();
  box.querySelectorAll('.sg').forEach(el => el.classList.toggle('on', +el.dataset.i === SUGI));
  const on = box.querySelector('.sg.on'); if (on) on.scrollIntoView({block: 'nearest'});
}
function sugPick(i) {
  const it = SUG[i];
  document.getElementById('sk').value = it.kind;
  document.getElementById('sv').value = it.value;
  sugHide(); SUG = [];
  document.getElementById('sv').blur();
  setScope();
}

async function fillYears(d) {
  const ys = document.getElementById('yr'), sc = d.scope || {};
  const scoped = sc.kind === 'project' || sc.kind === 'esn';
  let years = sc.years || [];
  ys.disabled = scoped;
  ys.title = scoped ? 'Jahresfilter gilt nur für die Familiensicht' : 'Jahr des Meldedatums — nur in der Familiensicht';
  ys.innerHTML = '<option value="">' + (sc.years_building && !years.length ? 'Jahre werden gezählt…' : 'alle Jahre') + '</option>'
    + years.map(y => `<option ${y === S.year && !scoped ? 'selected' : ''}>${y}</option>`).join('');
  if (sc.years_building && !scoped) setTimeout(async () => {
    const r = await (await fetch('/api/modules/graph/years?family=' + encodeURIComponent(d.family))).json();
    fillYears({...d, scope: {...sc, years: r.years, years_building: r.running}});
  }, 4000);
}
let NET = null, DATA = null;
// Ruhige Farbsprache (Stufe 4): Den Typ zeigen Fläche und Rand, nicht bunte Farbe —
// Farbe ist für Incidents reserviert (Heatmap). Legende in der Seitenleiste spiegelt diese Werte.
const HOV = c => ({background: c, border: '#1b1b1b'});
const GROUPS = {
  family:  {color: {background: '#1b1b1b', border: '#1b1b1b', highlight: HOV('#3a3835'), hover: HOV('#3a3835')}, font: {color: '#f0ede6', size: 14}},
  section: {color: {background: '#3a3835', border: '#1b1b1b', highlight: HOV('#1b1b1b'), hover: HOV('#1b1b1b')}, font: {color: '#f0ede6', size: 12}},
  ck:      {color: {background: '#fbfaf6', border: '#1b1b1b', highlight: HOV('#e7e3da'), hover: HOV('#f5f2ec')}, font: {color: '#1b1b1b'}},
  ck00:    {color: {background: '#f5ead0', border: '#946300', highlight: {background: '#efdcae', border: '#946300'}, hover: {background: '#efdcae', border: '#946300'}}, font: {color: '#5f4000'}},
  asm:     {color: {background: '#fbfaf6', border: '#b5afa5', highlight: HOV('#e7e3da'), hover: HOV('#f5f2ec')}, font: {color: '#3a3835'}},
  part:    {color: {background: '#f0ede6', border: '#cfc9bf', highlight: HOV('#e7e3da'), hover: HOV('#e7e3da')}, font: {color: '#5f5a54'}},
  norm:    {color: {background: '#ebe7f5', border: '#8b7cc7', highlight: {background: '#ded8f0', border: '#5a45ad'}, hover: {background: '#ded8f0', border: '#5a45ad'}}, font: {color: '#3d2f80'}},
  target:  {color: {background: '#f1e4f7', border: '#6a1b9a', highlight: {background: '#e6d0f0', border: '#6a1b9a'}, hover: {background: '#e6d0f0', border: '#6a1b9a'}}, font: {color: '#3b0f57'}, borderWidth: 3},
  stray:   {color: {background: '#ecebe8', border: '#9e9e9e', highlight: {background: '#e0e0e0', border: '#616161'}, hover: {background: '#e0e0e0', border: '#616161'}}, font: {color: '#777'}, shapeProperties: {borderDashes: [4, 3]}},
};
// Großbuchstaben aus SAP („FAN STATOR/FRAME INSTALLATION") lesbar setzen; Kürzel bleiben.
const ABBR = new Set(['HPC', 'HPT', 'LPC', 'LPT', 'NMP', 'IGB', 'AGB', 'TGB', 'QEC', 'OVH', 'NGV', 'VSV', 'VBV', 'ATA',
  'ESN', 'ECU', 'IPC', 'MPL', 'ECMS', 'FADEC', 'PMA', 'CFM', 'LEAP', 'OGV', 'IGV', 'OEM', 'SLU', 'TAT', 'WBS',
  'CSN', 'BRG', 'ACT', 'CDP', 'HMU', 'TRA', 'APU', 'EGT', 'LLP', 'NDT', 'PW', 'LM', 'GE']);
function nice(str) {
  return String(str).replace(/[A-ZÄÖÜ]{3,}/g, w => ABBR.has(w) ? w : w[0] + w.slice(1).toLowerCase());
}
function esc(s) { return String(s == null ? '' : s).replace(/[&<>"]/g,
  c => ({'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;'}[c])); }
function plain(s) { return String(s).replace(/</g, '‹'); }
function fmt(n) { return (n || 0).toLocaleString('de-DE'); }

function readUrl() {
  const u = new URLSearchParams(location.search);
  S.family = u.get('family') || ''; S.q = u.get('q') || ''; S.focus = u.get('ck') || '';
  S.heat = u.get('heat') === '1';
  if (u.get('depth')) S.depth = +u.get('depth');
  document.getElementById('q').value = S.q;
  S.year = u.get('year') || ''; S.sk = u.get('scope') || ''; S.sv = u.get('sv') || '';
  S.only = (u.get('only') || '').split(',').filter(Boolean);
  S.view = u.get('v') || ''; VIEW.pending = S.view;              // Zoom,x,y aus einem geteilten Link
  if (S.sk) document.getElementById('sk').value = S.sk;
  document.getElementById('sv').value = S.sv;
}
function writeUrl() {
  const u = new URLSearchParams();
  if (S.family) u.set('family', S.family); if (S.q) u.set('q', S.q);
  if (S.focus) u.set('ck', S.focus); u.set('depth', S.depth);
  if (S.heat) u.set('heat', '1');
  if (S.view) u.set('v', S.view);
  if (S.sk && S.sv) { u.set('scope', S.sk); u.set('sv', S.sv); if (S.only.length) u.set('only', S.only.join(',')); }
  else if (S.year) u.set('year', S.year);
  history.replaceState(null, '', '?' + u.toString() + (document.documentElement.classList.contains('embed') ? '&embed=1' : ''));
  if (window.modReportUrl) window.modReportUrl('graph', u.toString());
}
function setDepth(d) { S.depth = d; S.q = ''; document.getElementById('q').value = ''; load(); }
function search() { S.q = document.getElementById('q').value.trim(); S.focus = ''; load(); }

async function load() {
  const famSel = document.getElementById('fam');
  if (famSel.value) S.family = famSel.value;
  [1, 2, 3].forEach(d => document.getElementById('d' + d).classList.toggle('on', d === S.depth && !S.q));
  hidePop();
  const canvas = document.getElementById('canvas');
  const url = '/api/modules/graph?family=' + encodeURIComponent(S.family || '') + '&depth=' + S.depth
    + '&ck=' + encodeURIComponent(S.focus) + '&q=' + encodeURIComponent(S.q) + (S.force ? '&force=1' : '')
    + (S.strays ? '&strays=1' : '')
    + (S.sk && S.sv ? '&scope=' + S.sk + '&sv=' + encodeURIComponent(S.sv) + (S.only.length ? '&only=' + encodeURIComponent(S.only.join(',')) : '')
                    : (S.year ? '&year=' + encodeURIComponent(S.year) : ''));
  S.force = false;
  if (S.sk && S.sv) canvas.innerHTML = '<div class="msg">&#8987; Lade ' + (S.sk === 'esn' ? 'ESN' : 'Projekt') + ' ' + esc(S.sv) + ' …</div>';
  const d = await (await fetch(url)).json();
  if (d.status === 'scope_missing' || d.status === 'scope_error') {
    document.getElementById('scopebar').style.display = 'none';
    const what = S.sk === 'esn' ? 'ESN' : 'Projekt';
    canvas.innerHTML = '<div class="msg">&#9651; ' + (d.status === 'scope_error' ? '<b>' + esc(d.error) + '</b>'
      : '<b>' + what + ' „' + esc(S.sv) + '" nicht gefunden.</b>'
        + ((d.candidates || []).length ? '<br>Meintest du: ' + d.candidates.map(c =>
            `<a href="#" onclick="document.getElementById('sv').value='${esc(S.sk === 'esn' ? c.esn : c.number)}';setScope();return false">`
            + `${esc(S.sk === 'esn' ? 'ESN ' + c.esn + ' (' + c.number + ')' : c.number + (c.esn ? ' · ESN ' + c.esn : ''))}</a> <span style="color:var(--ink-3)">${esc(c.engine || '')}</span>`).join(' · ')
          : '<br>Auch keine ähnlichen Einträge.'))
      + '<br><button class="btn" onclick="clearScope()">zurück zur Familie</button></div>';
    return;
  }
  if (d.status !== 'ok') {
    canvas.innerHTML = '<div class="msg">' + (d.error ? '&#9651; Aufbau fehlgeschlagen: ' + esc(d.error)
      : d.what === 'years'
      ? '&#8987; <b>Die Incidents werden je Jahr gezählt</b> — einmalig für alle Familien, danach ist jeder '
        + 'Jahreswechsel sofort da (läuft seit ' + d.laeuft_seit_s + ' s). Die Seite aktualisiert sich selbst.'
      : '&#8987; <b>Die Teile-Hierarchie wird aufgebaut</b> — sie entsteht zusammen mit dem Modulvorschlag '
        + 'aus allen ECMS-Einbauten und kann einige Minuten dauern (läuft seit ' + d.laeuft_seit_s + ' s). '
        + 'Die Seite aktualisiert sich selbst.') + '</div>';
    if (!d.error) setTimeout(load, 5000);
    return;
  }
  DATA = d; S.family = d.family; writeUrl();
  scopeBar(d); fillYears(d);
  famSel.innerHTML = d.families.map(f => `<option ${f === d.family ? 'selected' : ''}>${esc(f)}</option>`).join('');
  document.getElementById('ck').innerHTML = '<option value="">alle Module</option>'
    + d.cks.map(c => `<option value="${esc(c.ck)}" ${c.ck === d.focus ? 'selected' : ''}>${esc(c.ck)} ${esc(c.name)}</option>`).join('');
  const st = document.getElementById('stats');
  const hid = (d.hidden.baugruppen ? d.hidden.baugruppen + ' Baugruppen' : '')
    + (d.hidden.baugruppen && d.hidden.teile ? ' + ' : '') + (d.hidden.teile ? d.hidden.teile + ' Teile' : '');
  st.textContent = d.count + ' Knoten' + (d.q ? ' · ' + d.hits.length + ' Teilenummer(n) zu „' + d.q + '"' : '')
    + (hid ? ' · ' + hid + ' als Ausreißer ausgeblendet' : '')
    + (d.scope && d.scope.kind === 'year' ? ' · nur Meldejahr ' + d.scope.year : '')
    + ' · Stand ' + (d.ts || '').replace('T', ' ');
  if (d.q && !d.hits.length) {
    canvas.innerHTML = '<div class="msg">&#9651; <b>„' + esc(d.q) + '" kommt in den ECMS-Einbauten von ' + esc(d.family)
      + ' nicht vor.</b><br>Der Baum kennt nur serialisierte ECMS-Teile. Schüttware, Normteile und reine '
      + 'MPL-Katalogteile findet der <a href="/hub/module/vorschlag?family=' + encodeURIComponent(d.family)
      + '&q=' + encodeURIComponent(d.q) + '">Modulvorschlag</a> über die bisherigen Meldungen.</div>';
    return;
  }
  if (d.too_many) {
    canvas.innerHTML = '<div class="msg">&#9651; <b>' + d.count + ' Knoten</b> in Tiefe ' + d.depth
      + ' — als Bild unlesbar.<br>Oben ein Modul wählen (dann erscheinen dessen Baugruppen und Teile), '
      + 'eine kleinere Tiefe nehmen &nbsp;oder&nbsp; <button class="btn" onclick="S.force=true;load()">trotzdem zeichnen</button></div>';
    return;
  }
  draw(d);
}

// ── Layout: Organigramm statt breiter Ebenen ─────────────────────────────
// Ebenen mit wenigen Knoten stehen nebeneinander (oben → unten, geschwungene
// Linien). Die erste Ebene unter den Obergruppen, die breiter würde als MAXCOLS, hängt als Liste
// unter ihrem Elternknoten — mit Winkel-Linien über unsichtbare Stützpunkte
// (Stamm links, kurzer Ast zu jedem Kasten), wie in einem Organigramm.
const RANK = {family: 0, section: 1, ck: 2, asm: 3, part: 4};
const MAXCOLS = 10, W_SPREAD = 150, GAP = 34, LEVEL_H = 118, W_LIST = 205, INDENT = 30, ROW_H = 44;
const BOX_PAD = 20;        // vis rechnet Innenabstand + Rand zur Breite dazu

function label(n, list) {
  const parts = n.label.split(NL).map(x => plain(nice(x)));
  const inc = n.inc ? '<i>' + fmt(n.inc) + ' Inc.' + (n.rep ? ' · ↻ ' + n.rep + ' Visits' : '') + '</i>' : '';
  if (list)   // Listenzeile: kompakt, eine (höchstens zwei) Zeilen
    return '<b>' + parts[0] + '</b>' + (parts.length > 1 ? ' ' + parts.slice(1).join(' ') : '')
         + (inc ? '  ' + inc : '');
  return '<b>' + parts[0] + '</b>' + (parts.length > 1 ? NL + parts.slice(1).join(NL) : '')
       + (inc ? NL + inc : '');
}

function layout(d) {
  const byId = {}, kids = {}, par = {};
  d.nodes.forEach(n => { byId[n.id] = n; kids[n.id] = []; });
  d.edges.forEach(e => {
    if (byId[e.from] && byId[e.to] && par[e.to] === undefined && e.from !== e.to) {
      kids[e.from].push(e.to); par[e.to] = e.from;
    }
  });
  const root = d.nodes.find(n => par[n.id] === undefined);
  const cnt = {};
  d.nodes.forEach(n => { const r = RANK[n.level]; cnt[r] = (cnt[r] || 0) + 1; });
  // Obergruppen stehen immer nebeneinander: es sind selten mehr als ~15, und
  // eine Kachel mehr („ohne Obergruppe") soll nicht das ganze Bild zur Liste kippen.
  let stackFrom = 99;
  for (let r = 2; r <= 4; r++) if ((cnt[r] || 0) > MAXCOLS) { stackFrom = r; break; }

  const pos = {}, listNode = new Set(), extra = [], spine = [];
  const depthBelow = id => kids[id].length ? 1 + Math.max(...kids[id].map(depthBelow)) : 0;

  // Liste aller Nachfahren von p unterhalb von p, eingerückt je Ebene
  function outline(p, pLeft, yStart) {
    let y = yStart;
    (function rec(id, left) {
      const cs = kids[id];
      if (!cs.length) return;
      const sx = left + INDENT / 2;                         // Stamm-x
      const top = '~t|' + id;                               // Stützpunkt unter dem Elternkasten
      extra.push({id: top, x: sx, y: pos[id].y});
      let prev = top;
      cs.forEach(c => {
        pos[c] = {x: left + INDENT + (W_LIST + BOX_PAD) / 2, y: y};
        listNode.add(c);
        const pt = '~b|' + c;                               // Abzweig zu diesem Kasten
        extra.push({id: pt, x: sx, y: y});
        spine.push([prev, pt], [pt, c]);
        prev = pt;
        y += ROW_H;
        rec(c, left + INDENT);
      });
    })(p, pLeft);
    return y;
  }

  // Nebeneinander-Teil: liefert die Breite des Teilbaums
  function spread(id, left) {
    const r = RANK[byId[id].level];
    const y = r * LEVEL_H;
    if (r === stackFrom - 1 && kids[id].length) {           // hier beginnt die Liste
      pos[id] = {x: left + W_SPREAD / 2, y: y};
      outline(id, left, y + LEVEL_H * 0.62);
      return Math.max(W_SPREAD, INDENT * depthBelow(id) + W_LIST) + GAP;
    }
    if (!kids[id].length) { pos[id] = {x: left + W_SPREAD / 2, y: y}; return W_SPREAD + GAP; }
    let w = 0;
    kids[id].forEach(c => { w += spread(c, left + w); });
    const first = pos[kids[id][0]].x, last = pos[kids[id][kids[id].length - 1]].x;
    pos[id] = {x: (first + last) / 2, y: y};
    return Math.max(w, W_SPREAD + GAP);
  }
  spread(root.id, 0);
  return {byId, kids, par, pos, listNode, extra, spine, stackFrom};
}

function draw(d) {
  const canvas = document.getElementById('canvas');
  if (typeof vis === 'undefined') {
    canvas.innerHTML = '<div class="msg">&#9651; <b>vis-network konnte nicht geladen werden.</b><br>'
      + 'Die Datei <b>static/vis-network.min.js</b> fehlt neben SAP_Claim_Analyzer_V3.py '
      + '(Serverkonsole beim Start prüfen).</div>';
    return;
  }
  canvas.innerHTML = '';
  if (NET) { NET.destroy(); NET = null; }
  const L = layout(d);
  const sc = d.scale || {};
  function heatStyle(n) {
    if (notInstalled(n)) return {color: {...NOT_INST, highlight: {background: '#fafafa', border: '#999'}},
                                 font: {color: '#a8a8a4'}};
    if (!S.heat || !HEAT_LEVELS[n.level] || n.group === 'stray') return {};
    if (isEngineLevel(n) || n.section === 'Engine gesamt')
      return {color: {...HEAT_CK00, highlight: HEAT_CK00}, font: {color: '#555'}, dashes: true};
    const [lo, hi] = sc[n.level] || [0, 0];
    const t = hi > lo ? Math.max(0, Math.min(1, (n.inc - lo) / (hi - lo))) : 1;
    if (!n.inc) return {color: {...HEAT_ZERO, highlight: HEAT_ZERO}, font: {color: '#666'},
                        ...(n.group === 'target' ? {color: {...HEAT_ZERO, border: HEAT_TARGET}} : {})};
    const i = isScope() ? stepIndex(n.inc)
                        : Math.max(0, Math.min(HEAT.length - 1, Math.round(t * (HEAT.length - 1))));
    const bg = HEAT[i], bd = n.group === 'target' ? HEAT_TARGET : HEAT[Math.min(HEAT.length - 1, i + 2)];
    return {color: {background: bg, border: bd, highlight: {background: bg, border: '#1b1b1b'}},
            font: {color: i >= 7 ? '#ffffff' : '#0d1b2a'}};
  }
  document.getElementById('heatbtn').classList.toggle('on', S.heat);
  document.getElementById('col-typ').classList.toggle('on', !S.heat);
  document.getElementById('col-inc').classList.toggle('on', S.heat);
  const DL = {1: 'Module', 2: 'Baugruppen', 3: 'Teile'};
  [1, 2, 3].forEach(k => { document.getElementById('d' + k).innerHTML = DL[k]
    + (k === S.depth && !d.q ? `<span class="c">${fmt(d.count)}</span>` : ''); });
  document.getElementById('typelegend').style.display = S.heat ? 'none' : '';
  const hl = document.getElementById('heatlegend');
  hl.style.display = S.heat ? '' : 'none';
  if (S.heat) hl.innerHTML = '<span><i style="background:' + HEAT_ZERO.background + ';border:1px solid ' + HEAT_ZERO.border + '"></i>0</span>'
    + (isScope()
      ? HEAT_STEPS.map(([a, b, i]) => '<span><i style="background:' + HEAT[i] + '"></i>'
          + (b === Infinity ? '≥ ' + a : a === b ? a : a + '–' + b) + '</span>').join('')
        + '<span>Incidents (feste Stufen)</span>'
      : '<span style="display:inline-flex;align-items:center;gap:4px">wenige <i style="width:120px;height:10px;background:linear-gradient(90deg,'
        + HEAT.join(',') + ')"></i> viele Incidents</span>'
        + '<span>je Ebene, ganze Familie' + (d.scope && d.scope.kind === 'year' ? ', Meldejahr ' + esc(d.scope.year) : '') + ': '
        + Object.keys(HEAT_LEVELS)
        .filter(k => d.nodes.some(n => n.level === k) && sc[k] && sc[k][1])
        .map(k => HEAT_LEVELS[k] + ' ' + fmt(sc[k][0]) + '–' + fmt(sc[k][1])).join(' · ') + '</span>')
    + (isScope() ? '<span><i style="background:#fff;border:1px solid #c8c8c4"></i>nicht verbaut</span>'
                   + (DATA.scope.kind === 'esn' ? '<span>↻ = in mehreren Visits</span>' : '') : '')
    + '<span><i style="background:' + HEAT_CK00.background + ';border:1px dashed ' + HEAT_CK00.border + '"></i>CK00 (kein Modul, nicht gefärbt)</span>'
    + '<span><i style="background:#fff;border:2px solid ' + HEAT_TARGET + '"></i>Suchtreffer</span>';
  VIEW.L = L; VIEW.tier = ''; VIEW.lastOg = ''; VIEW.col = {}; VIEW.font = {}; VIEW.color = {};
  VIEW.path = null; VIEW.sel = null; VIEW.pathKey = '';
  document.getElementById('crumb').style.display = 'none';
  const det0 = document.getElementById('sd-detail');
  if (det0) { det0.hidden = true; document.getElementById('sd-main').hidden = false; }
  const nodes = new vis.DataSet(d.nodes.map(n => {
    const list = L.listNode.has(n.id);
    const hs = heatStyle(n);
    const gc = (GROUPS[n.group] || GROUPS.ck).color, gf = (GROUPS[n.group] || GROUPS.ck).font || {};
    VIEW.col[n.id] = {bg: (hs.color && hs.color.background) || gc.background,
                      bd: (hs.color && hs.color.border) || gc.border,
                      fc: (hs.font && hs.font.color) || gf.color || '#fff'};
    VIEW.font[n.id] = {...(list ? {align: 'left'} : {}), ...(hs.font || {})};
    VIEW.color[n.id] = hs.color;                           // vis setzt sonst beim Update die Gruppenfarbe
    return {
      id: n.id, label: label(n, list), group: n.group, x: L.pos[n.id].x, y: L.pos[n.id].y, fixed: true,
      ...(hs.color ? {color: hs.color} : {}),
      widthConstraint: list ? {minimum: W_LIST, maximum: W_LIST} : {minimum: W_SPREAD - 30, maximum: W_SPREAD},
      font: {...(list ? {align: 'left'} : {}), ...(hs.font || {})},
      // Knoten mit Incidents etwas kräftiger umrandet — der Blick soll dort hängen bleiben
      borderWidth: n.group === 'target' ? (S.heat ? 4 : 3) : n.kind === 'kopf' || n.rep ? 3 : (n.inc ? 2 : 1),

      // vermuteter Teilesatz gestrichelt wie auf der Modulstruktur-Folie (T-Boxen)
      ...(n.kind === 'teilesatz?' || hs.dashes ? {shapeProperties: {borderDashes: [5, 3]}} : {}),
    };
  }).concat(L.extra.map(p => ({id: p.id, x: p.x, y: p.y, fixed: true, shape: 'dot', size: 0.5,
                               color: {background: 'rgba(0,0,0,0)', border: 'rgba(0,0,0,0)'},
                               chosen: false, label: undefined}))));
  const edges = [];
  d.edges.forEach((e, i) => {
    if (L.listNode.has(e.to)) return;                       // Listen bekommen Winkel-Linien
    edges.push({id: 'e' + i, from: e.from, to: e.to,
                smooth: {type: 'cubicBezier', forceDirection: 'vertical', roundness: 0.45}});
  });
  L.spine.forEach(([a, b], i) => edges.push({id: 's' + i, from: a, to: b, smooth: false, chosen: false}));
  NET = new vis.Network(canvas, {nodes, edges: new vis.DataSet(edges)}, {
    layout: {hierarchical: false, improvedLayout: false},
    physics: {enabled: false},
    interaction: {hover: true, dragNodes: false},
    nodes: {shape: 'box', margin: {top: 6, bottom: 6, left: 9, right: 9},
            font: {size: 11, face: '"Helvetica Neue", Helvetica, Arial, sans-serif', multi: 'html', color: '#fff'}},
    edges: {width: 1.2, color: {color: '#9a9a96', highlight: '#1b1b1b'}, arrows: {to: {enabled: false}}},
    groups: GROUPS,
  });
  VIEW.b = boundsOf(d.nodes.map(n => n.id));
  NET.once('afterDrawing', () => {
    if (VIEW.pending) {                                    // geteilter Link: genau dieser Ausschnitt
      const [sc, x, y] = VIEW.pending.split(',').map(Number);
      VIEW.pending = '';
      if (sc > 0) { NET.moveTo({scale: sc, position: {x: x, y: y}}); return; }
    }
    if (VIEW.keep) { NET.moveTo(VIEW.keep); VIEW.keep = null; return; }
    S.view = '';
    viewStart();
  });
  NET.on('afterDrawing', afterDraw);
  NET.on('hoverNode', p => { clearTimeout(VIEW.hovT); showPath(p.node); });
  NET.on('blurNode', () => { clearTimeout(VIEW.hovT); VIEW.hovT = setTimeout(() => showPath(VIEW.sel), 90); });
  renderSide();
  NET.on('zoom', saveViewSoon);
  NET.on('dragEnd', saveViewSoon);
  NET.on('animationFinished', saveViewSoon);
  NET.on('click', p => {
    const n = p.nodes.length && L.byId[p.nodes[0]];
    if (!n) { hidePop(); return; }
    showPop(n, p.pointer.DOM);
  });
  NET.on('doubleClick', p => {
    const n = p.nodes.length && L.byId[p.nodes[0]];
    if (n && n.level === 'ck') { S.focus = n.ck; S.q = ''; document.getElementById('q').value = ''; load(); }
    else if (n && (n.level === 'section' || n.level === 'asm')) { hidePop(); focusSubtree(n.id); }   // hinein, 2. Mal zurück
  });
}

// ── Ansicht: Einpassen, Zoom, Vollbild, Übersichtskarte, semantischer Zoom ──
// Stufe 1: Ansicht beherrschen · Stufe 2: lesbar in jeder Zoomstufe.
// Semantischer Zoom: unter 75 % werden die (dann zu kleinen) Kästchentexte ausgeblendet
// und als HTML-Schilder in lesbarer Größe darübergelegt (#ovl); ganz weit draußen bleiben
// nur die Schilder der oberen Ebenen, die Kästchen zeigen dann nur noch ihre Farbe.
const ANIM = {duration: 420, easingFunction: 'easeInOutQuad'};
const Z_FULL = 0.75, Z_MIN = 0.08, Z_MAX = 2.5;
let VIEW = {L: null, b: null, tier: '', lastOg: '', col: {}, font: {}, keep: null, pending: '', saveT: null,
            mini: (() => { try { return localStorage.getItem('sap_graph_mini') !== '0'; } catch (e) { return true; } })()};

function halfW(id) { return VIEW.L.listNode.has(id) ? (W_LIST + BOX_PAD) / 2 : W_SPREAD / 2; }
function boundsOf(ids) {
  let x0 = Infinity, x1 = -Infinity, y0 = Infinity, y1 = -Infinity;
  ids.forEach(id => {
    const q = VIEW.L.pos[id];
    if (!q) return;
    const hw = halfW(id), hh = VIEW.L.listNode.has(id) ? 18 : 34;
    x0 = Math.min(x0, q.x - hw); x1 = Math.max(x1, q.x + hw);
    y0 = Math.min(y0, q.y - hh); y1 = Math.max(y1, q.y + hh);
  });
  return {x0, x1, y0, y1};
}
function canvasSize() { const c = document.getElementById('canvas'); return [c.clientWidth, c.clientHeight]; }
function fitBox(bx, mode, anim) {
  if (!NET || !bx || !isFinite(bx.x0)) return;
  const [cw, ch] = canvasSize(), pad = 48, ctl = 64;        // rechts Platz für das Bedienfeld
  const sw = (cw - pad - ctl) / Math.max(1, bx.x1 - bx.x0), sh = (ch - pad) / Math.max(1, bx.y1 - bx.y0);
  const sc = Math.max(Z_MIN, Math.min(1.2, mode === 'width' ? sw : Math.min(sw, sh)));
  const cx = (bx.x0 + bx.x1) / 2 + ctl / 2 / sc;
  const cy = mode === 'width' && (bx.y1 - bx.y0) * sc > ch - pad
    ? bx.y0 - 24 / sc + ch / 2 / sc                       // zu hoch: oben beginnen, nach unten ziehen
    : (bx.y0 + bx.y1) / 2;
  NET.moveTo({position: {x: cx, y: cy}, scale: sc, animation: anim === false ? false : ANIM});
}
function viewStart() {                                     // Start: Breite; passt alles lesbar, dann ganz
  const [cw, ch] = canvasSize(), bx = VIEW.b;
  const all = Math.min((cw - 48) / (bx.x1 - bx.x0), (ch - 48) / (bx.y1 - bx.y0));
  fitBox(bx, all >= Z_FULL ? 'all' : 'width', false);
}
function viewFit()   { VIEW.lastOg = ''; fitBox(VIEW.b, 'all'); }
function viewWidth() { VIEW.lastOg = ''; fitBox(VIEW.b, 'width'); }
function viewScale(sc) {
  if (!NET) return;
  NET.moveTo({scale: Math.max(Z_MIN, Math.min(Z_MAX, sc)), position: NET.getViewPosition(), animation: ANIM});
}
function zoomBy(f) { if (NET) viewScale(NET.getScale() * f); }
function focusSubtree(id) {
  if (VIEW.lastOg === id) { viewWidth(); return; }
  const ids = [];
  (function rec(i) { ids.push(i); (VIEW.L.kids[i] || []).forEach(rec); })(id);
  fitBox(boundsOf(ids), 'all');
  VIEW.lastOg = id;
}
function toggleMini() {
  VIEW.mini = !VIEW.mini;
  try { localStorage.setItem('sap_graph_mini', VIEW.mini ? '1' : '0'); } catch (e) {}
  drawMini();
}
function toggleFs() {
  const el = document.documentElement;
  if (document.fullscreenElement) document.exitFullscreen();
  else if (el.requestFullscreen) el.requestFullscreen().catch(() => {});
}
document.addEventListener('fullscreenchange', () => {
  document.getElementById('fsbtn').classList.toggle('on', !!document.fullscreenElement);
  setTimeout(() => { if (NET) { NET.redraw(); fitBox(VIEW.b, 'width', false); } }, 250);
});
document.addEventListener('keydown', e => {
  if (!NET || e.metaKey || e.ctrlKey || e.altKey) return;
  const tg = e.target && e.target.tagName;
  if (tg === 'INPUT' || tg === 'SELECT' || tg === 'TEXTAREA') return;
  const k = e.key;
  if (k === 'f' || k === 'F') viewFit();
  else if (k === 'w' || k === 'W') viewWidth();
  else if (k === '0') viewScale(1);
  else if (k === '+' || k === '=') zoomBy(1.25);
  else if (k === '-') zoomBy(0.8);
  else if (k === 'm' || k === 'M') toggleMini();
  else if (k === 'v' || k === 'V') toggleFs();
  else if (k === 'Escape') hidePop();
  else return;
  e.preventDefault();
});
function saveViewSoon() {
  clearTimeout(VIEW.saveT);
  VIEW.saveT = setTimeout(() => {
    if (!NET) return;
    const c = NET.getViewPosition();
    S.view = NET.getScale().toFixed(3) + ',' + Math.round(c.x) + ',' + Math.round(c.y);
    writeUrl();
  }, 350);
}

function afterDraw() {
  const sc = NET.getScale();
  document.getElementById('zoomval').textContent = Math.round(sc * 100) + ' %';
  applyTier(sc);
  drawOverlay(sc);
  drawMini();
}
function tierFor(sc) { return sc >= Z_FULL ? 'full' : (ROW_H * sc >= 14 ? 'mid' : 'far'); }
function applyTier(sc) {                                   // Kästchentexte nur bei Stufenwechsel umschalten
  const tr = tierFor(sc);
  if (tr === VIEW.tier) return;
  VIEW.tier = tr;
  refreshNodes();
}
// Einzige Stelle, die Knoten nachträglich ändert. Farbe immer mitgeben: vis setzt
// beim Update sonst die Gruppenfarbe (Heatmap ginge verloren).
function refreshNodes() {
  const hide = VIEW.tier && VIEW.tier !== 'full';
  NET.body.data.nodes.update(DATA.nodes.map(n => {
    const off = VIEW.path && !VIEW.path.has(n.id), c = VIEW.col[n.id];
    // gedimmt deckend (zum Papierton aufgehellt) statt transparent — sonst scheinen die Linien durch
    const col = off ? {background: mixHex(c.bg, '#f0ede6', .8), border: mixHex(c.bd, '#f0ede6', .7)} : VIEW.color[n.id];
    return {id: n.id, ...(col ? {color: col} : {}),
            font: {...VIEW.font[n.id], color: hide ? 'rgba(0,0,0,0)' : off ? '#b5afa5' : c.fc}};
  }));
}
function mixHex(a, b, t) {
  const pa = /^#?([0-9a-f]{6})$/i.exec(a || ''), pb = /^#?([0-9a-f]{6})$/i.exec(b || '');
  if (!pa || !pb) return a;
  const x = parseInt(pa[1], 16), y = parseInt(pb[1], 16);
  const ch = sh => Math.round(((x >> sh) & 255) * (1 - t) + ((y >> sh) & 255) * t);
  return '#' + [16, 8, 0].map(sh => ch(sh).toString(16).padStart(2, '0')).join('');
}
function drawOverlay(sc) {
  const ov = document.getElementById('ovl');
  if (VIEW.tier === 'full' || !VIEW.L) { ov.innerHTML = ''; return; }
  const [cw, ch] = canvasSize(), L = VIEW.L;
  const dom = id => NET.canvasToDOM(L.pos[id]);
  let h = '';
  // Obere Ebenen (nebeneinander): Schild mit Kennung · Incidents, Name wenn Platz ist
  const rows = {};
  DATA.nodes.forEach(n => { if (!L.listNode.has(n.id)) (rows[L.pos[n.id].y] = rows[L.pos[n.id].y] || []).push(n.id); });
  Object.values(rows).forEach(ids => {
    ids.sort((x, y) => L.pos[x].x - L.pos[y].x);
    const xs = ids.map(id => dom(id));
    ids.forEach((id, i) => {
      const q = xs[i];
      if (q.x < -240 || q.x > cw + 240 || q.y < -60 || q.y > ch + 60) return;
      const gap = Math.min(i ? q.x - xs[i - 1].x : 999, i < ids.length - 1 ? xs[i + 1].x - q.x : 999);
      const w = Math.max(52, Math.min(230, gap - 6));
      const n = L.byId[id], c = VIEW.col[id], parts = n.label.split(NL).map(nice);
      const name = parts.slice(1).join(' ');
      const tight = w < 96;                                // eng: Kennung oben, Incidents darunter
      const dm = VIEW.path && !VIEW.path.has(id) ? ' dim' : '';
      const bb = NET.getBoundingBox(id);                    // Kasten dahinter: Schild mindestens so groß (+2 px Kantenglättung)
      const mw = bb ? Math.ceil((bb.right - bb.left) * sc) + 2 : 0, mh = bb ? Math.ceil((bb.bottom - bb.top) * sc) + 2 : 0;
      h += `<div class="oc${tight ? ' tight' : ''}${dm}" data-id="${esc(id)}" style="left:${q.x}px;top:${q.y}px;max-width:${Math.max(w, mw)}px;min-width:${mw}px;min-height:${mh}px;background:${c.bg};color:${c.fc};border:1px solid ${c.bd}">`
        + `<span><b>${esc(parts[0])}</b>` + (tight ? (n.inc ? `<small>${fmt(n.inc)}</small>` : '')
                                             : (n.inc ? ' · ' + fmt(n.inc) : '') + (w >= 110 && name ? `<small>${esc(name)}</small>` : ''))
        + '</span></div>';
    });
  });
  // Listenzeilen (Module, Baugruppen): lesbar, solange die Zeile ≥ 17 px hoch ist
  if (VIEW.tier === 'mid') {
    const bw = (W_LIST + BOX_PAD) * sc, small = ROW_H * sc < 18 ? ';font-size:10px;line-height:12px' : '';
    DATA.nodes.forEach(n => {
      if (!L.listNode.has(n.id)) return;
      const q = dom(n.id);
      if (q.y < -20 || q.y > ch + 20 || q.x < -bw || q.x > cw + bw) return;
      h += `<div class="ol${VIEW.path && !VIEW.path.has(n.id) ? ' dim' : ''}" style="left:${q.x - bw / 2 + 7}px;top:${q.y - 7}px;width:${bw - 14}px;color:${VIEW.col[n.id].fc}${small}">`
        + esc(nice(n.label.split(NL).join(' '))) + (n.inc ? `<i>${fmt(n.inc)}</i>` : '') + '</div>';
    });
  }
  ov.innerHTML = h;
}
function drawMini() {
  const box = document.getElementById('mini'), cv = document.getElementById('minicv');
  document.getElementById('minibtn').classList.toggle('on', VIEW.mini);
  if (!NET || !VIEW.b || !isFinite(VIEW.b.x0)) { box.style.display = 'none'; return; }
  const bx = VIEW.b, bw = bx.x1 - bx.x0, bh = bx.y1 - bx.y0;
  const W = 200, H = Math.max(56, Math.min(150, W * bh / bw)), k = Math.min(W / bw, H / bh);
  const [cw, ch] = canvasSize(), sc = NET.getScale(), c = NET.getViewPosition();
  const vx = c.x - cw / 2 / sc, vy = c.y - ch / 2 / sc, vw = cw / sc, vh = ch / sc;
  const allIn = vx <= bx.x0 && vy <= bx.y0 && vx + vw >= bx.x1 && vy + vh >= bx.y1;
  box.style.display = VIEW.mini && !allIn ? '' : 'none';
  if (box.style.display === 'none') return;
  const dpr = window.devicePixelRatio || 1;
  if (cv.width !== Math.round(W * dpr)) { cv.width = Math.round(W * dpr); cv.style.width = W + 'px'; }
  if (cv.height !== Math.round(H * dpr)) { cv.height = Math.round(H * dpr); cv.style.height = H + 'px'; }
  const g = cv.getContext('2d');
  g.setTransform(dpr, 0, 0, dpr, 0, 0);
  g.clearRect(0, 0, W, H);
  DATA.nodes.forEach(n => {
    const q = VIEW.L.pos[n.id];
    if (!q) return;
    const hw = halfW(n.id), hh = VIEW.L.listNode.has(n.id) ? 16 : 30;
    const cc = VIEW.col[n.id];
    g.fillStyle = S.heat || !isLight(cc.bg) ? cc.bg : cc.bd;
    g.fillRect((q.x - hw - bx.x0) * k, (q.y - hh - bx.y0) * k, Math.max(1, 2 * hw * k), Math.max(1, 2 * hh * k));
  });
  g.fillStyle = 'rgba(27,27,27,.07)';
  g.strokeStyle = '#1b1b1b'; g.lineWidth = 1.5;
  const rx = (vx - bx.x0) * k, ry = (vy - bx.y0) * k;
  g.fillRect(rx, ry, vw * k, vh * k);
  g.strokeRect(rx, ry, vw * k, vh * k);
  VIEW.mk = k;
}
(function miniDrag() {
  const box = document.getElementById('mini');
  let down = false;
  function go(e) {
    if (!NET || !VIEW.mk) return;
    const r = document.getElementById('minicv').getBoundingClientRect();
    NET.moveTo({position: {x: VIEW.b.x0 + (e.clientX - r.left) / VIEW.mk, y: VIEW.b.y0 + (e.clientY - r.top) / VIEW.mk},
                scale: NET.getScale()});
  }
  box.addEventListener('mousedown', e => { down = true; go(e); e.preventDefault(); });
  window.addEventListener('mousemove', e => { if (down) go(e); });
  window.addEventListener('mouseup', () => { if (down) { down = false; saveViewSoon(); } });
})();

// ── Stufe 3: Pfad zur Familie, Seitenleiste „Aussagen", Detailbereich ──────
const LEVEL_NAME = {family: 'Familie', section: 'Obergruppe', ck: 'Modul', asm: 'Baugruppe', part: 'Teil'};
function isLight(hex) {
  const m = /^#?([0-9a-f]{6})$/i.exec(hex || '');
  if (!m) return false;
  const v = parseInt(m[1], 16);
  return (0.299 * (v >> 16) + 0.587 * ((v >> 8) & 255) + 0.114 * (v & 255)) > 205;
}
function pathOf(id) {
  const out = [];
  for (let x = id; x !== undefined && x !== null && VIEW.L.byId[x]; x = VIEW.L.par[x]) out.push(x);
  return out;
}
function showPath(id) {                                    // Weg bis zur Familie hervorheben, Rest dimmen
  if (!NET || !VIEW.L) return;
  const path = id ? pathOf(id) : [];
  const key = path.join('|');
  if (key === VIEW.pathKey) return;
  VIEW.pathKey = key;
  VIEW.path = path.length ? new Set(path) : null;
  refreshNodes();
  const cr = document.getElementById('crumb');
  cr.style.display = path.length ? 'block' : 'none';
  cr.innerHTML = path.slice().reverse().map((x, i, arr) => {
    const nd = VIEW.L.byId[x], lp = nd.label.split(NL);
    const txt = esc(nice(nd.level === 'section' || nd.level === 'family' ? lp[0] : lp.join(' ')));
    return i === arr.length - 1 ? '<b>' + txt + '</b>' : txt;
  }).join(' › ');
}
function toggleSide(force) {
  const sd = document.getElementById('side');
  const open = force === undefined ? sd.classList.contains('closed') : force;
  sd.classList.toggle('closed', !open);
  document.getElementById('sidebtn').classList.toggle('on', open);
  try { localStorage.setItem('sap_graph_side', open ? '1' : '0'); } catch (e) {}
  setTimeout(() => { if (NET) NET.redraw(); }, 280);
}
(function () {                                             // Start: offen auf breiten Bildschirmen
  let open = window.innerWidth >= 1200;
  try { const v = localStorage.getItem('sap_graph_side'); if (v !== null) open = v === '1'; } catch (e) {}
  document.getElementById('side').classList.toggle('closed', !open);
  document.getElementById('sidebtn').classList.toggle('on', open);
})();
document.addEventListener('keydown', e => {
  if (e.metaKey || e.ctrlKey || e.altKey) return;
  const tg = e.target && e.target.tagName;
  if (tg === 'INPUT' || tg === 'SELECT' || tg === 'TEXTAREA') return;
  if (e.key === '/') { document.getElementById('omni').focus(); e.preventDefault(); }
  else if (e.key === 'a' || e.key === 'A') { toggleSide(); e.preventDefault(); }
  else if (e.key === 'b' || e.key === 'B') { savePng(); e.preventDefault(); }
});
function goNode(id) {                                      // aus der Seitenleiste: hinzoomen + Detail
  const n = VIEW.L && VIEW.L.byId[id];
  if (!n) return;
  const ids = [];
  (function rec(i) { ids.push(i); (VIEW.L.kids[i] || []).forEach(rec); })(id);
  fitBox(boundsOf(ids), 'all');
  VIEW.lastOg = id;
  showPop(n);
}
function rankRows(list, total, cls) {
  const max = Math.max(1, ...list.map(x => x.v));
  return list.map(x => `<a class="rk" onclick="goNode('${esc(x.id)}')" title="hinzoomen">`
    + `<span class="n">${esc(nice(x.name))}</span><span class="v"><b>${fmt(x.v)}</b>`
    + (total ? ` · ${Math.round(100 * x.v / total)} %` : '') + (x.extra || '') + '</span>'
    + `<i class="${cls || ''}" style="width:${Math.max(2, Math.round(100 * x.v / max))}%"></i></a>`).join('');
}
function renderSide() {
  if (!DATA || !VIEW.L) return;
  const d = DATA, sc = d.scope || {};
  const nm = n => n.label.split(NL).join(' ');
  document.getElementById('sd-title').textContent = d.family + (d.focus ? ' · ' + d.focus : '');
  document.getElementById('sd-sub').innerHTML = sc.kind === 'project' ? 'Projekt ' + esc(sc.value || S.sv)
    : sc.kind === 'esn' ? 'ESN ' + esc(sc.value || S.sv) : sc.kind === 'year' ? 'Meldejahr ' + esc(sc.year) : 'alle Meldejahre';
  const cks = d.nodes.filter(n => n.level === 'ck' && !isEngineLevel(n));
  const eng = d.nodes.find(n => isEngineLevel(n));
  const modTot = cks.reduce((x, n) => x + (n.inc || 0), 0), engInc = eng ? eng.inc || 0 : 0;
  document.getElementById('sd-total').innerHTML = fmt(modTot + engInc) + '<small>Incidents</small>';
  const root = d.nodes.find(n => n.level === 'family');
  const famTot = (root && root.inc) || (modTot + engInc);
  if (d.focus) {                                           // Modul im Fokus: seine Baugruppen rangieren
    const asm = d.nodes.filter(n => n.level === 'asm' && n.inc).sort((x, y) => y.inc - x.inc).slice(0, 6);
    document.getElementById('sd-top').innerHTML = '<span class="eyebrow">Wo liegen die Incidents in ' + esc(d.focus) + '?</span>'
      + (asm.length ? rankRows(asm.map(n => ({id: n.id, name: n.label.split(NL).join(' '), v: n.inc})), modTot)
                    : '<p>Keine Baugruppe mit Incidents.</p>');
    document.getElementById('sd-og').innerHTML = famTot && modTot
      ? `<span class="eyebrow">Anteil an der Familie</span><p><b>${Math.round(100 * modTot / famTot)} %</b> aller Incidents von ${esc(d.family)} (${fmt(famTot)}) liegen in ${esc(d.focus)}.</p>` : '';
    renderTrend();
    return;
  }
  // Pareto: wenige Module tragen den Großteil
  const top = cks.filter(n => n.inc).sort((x, y) => y.inc - x.inc).slice(0, 5);
  const topSum = top.reduce((x, n) => x + n.inc, 0);
  document.getElementById('sd-top').innerHTML = top.length
    ? `<span class="eyebrow">Wo liegen die Incidents?</span>`
      + `<p style="margin-bottom:8px"><b>${top.length} von ${cks.length} Modulen</b> tragen <b>${modTot ? Math.round(100 * topSum / modTot) : 0} %</b> der modulgenau gebuchten Incidents`
      + (engInc ? ` · dazu ${fmt(engInc)} nur auf CK00 (Engine gesamt)` : '') + '.</p>'
      + rankRows(top.map(n => ({id: n.id, name: nm(n), v: n.inc})), modTot)
    : '<span class="eyebrow">Wo liegen die Incidents?</span><p>Keine Incidents in dieser Auswahl.</p>';
  const ogs = d.nodes.filter(n => n.level === 'section' && n.inc).sort((x, y) => y.inc - x.inc).slice(0, 5);
  document.getElementById('sd-og').innerHTML = ogs.length
    ? '<span class="eyebrow">Obergruppen mit den meisten Incidents</span>'
      + rankRows(ogs.map(n => ({id: n.id, name: nm(n), v: n.inc})), famTot) : '';
  renderTrend();
}
const TREND = {};
async function renderTrend() {
  const el = document.getElementById('sd-trend');
  const d = DATA, sc = d.scope || {};
  if (isScope() || d.q || d.focus) { el.innerHTML = ''; return; }
  const years = (sc.years || []).map(Number).filter(Boolean).sort((x, y) => x - y);
  let Y = S.year ? +S.year : years[years.length - 1];
  const stand = +(String(d.ts || '').slice(0, 4));
  if (!S.year && Y === stand && years.includes(Y - 1)) Y = Y - 1;     // laufendes Jahr ist unvollständig
  const P = Y - 1;
  if (!Y || !years.includes(P)) {
    el.innerHTML = '<span class="eyebrow">Veränderung zum Vorjahr</span><p>'
      + (sc.years_building ? 'Die Jahre werden noch gezählt …' : 'Für das Vorjahr gibt es keine Daten.') + '</p>';
    return;
  }
  el.innerHTML = `<span class="eyebrow">Veränderung ${Y} ggü. ${P}</span><p>Wird berechnet …</p>`;
  const key = d.family + '|' + Y;
  try {
    if (!TREND[key]) {
      const get = y => fetch('/api/modules/graph?family=' + encodeURIComponent(d.family) + '&depth=1&year=' + y).then(r => r.json());
      const [a, b] = await Promise.all([get(Y), get(P)]);
      if (a.status !== 'ok' || b.status !== 'ok') { el.innerHTML = `<span class="eyebrow">Veränderung ${Y} ggü. ${P}</span><p>Die Jahre werden noch gezählt …</p>`; return; }
      const inc = r => { const m = {}; r.nodes.forEach(n => { if (n.level === 'ck' && !isEngineLevel(n)) m[n.ck] = n.inc || 0; }); return m; };
      TREND[key] = {y: inc(a), p: inc(b)};
    }
    if (DATA !== d) return;                                // inzwischen neu geladen
    const {y, p} = TREND[key];
    const byCk = {};
    d.nodes.forEach(n => { if (n.level === 'ck') byCk[n.ck] = n; });
    const rows = Object.keys(Object.assign({}, y, p)).filter(ck => byCk[ck])
      .map(ck => ({ck, a: y[ck] || 0, b: p[ck] || 0, dlt: (y[ck] || 0) - (p[ck] || 0)}));
    const up = rows.filter(r => r.dlt > 0).sort((u, v) => v.dlt - u.dlt).slice(0, 4);
    const dn = rows.filter(r => r.dlt < 0).sort((u, v) => u.dlt - v.dlt).slice(0, 2);
    const row = (r, c) => ({id: byCk[r.ck].id, name: byCk[r.ck].label.split(NL).join(' '), v: Math.abs(r.dlt),
                            extra: ` <span class="${c}">${r.dlt > 0 ? '▲' : '▼'}</span> <span style="color:var(--ink-3)">${fmt(r.b)} → ${fmt(r.a)}</span>`});
    el.innerHTML = `<span class="eyebrow">Veränderung ${Y} ggü. ${P}</span>`
      + (up.length ? '<p style="margin:4px 0 6px">Stärkster Anstieg</p>' + rankRows(up.map(r => row(r, 'up')), 0, 'up') : '<p>Kein Modul mit mehr Incidents als im Vorjahr.</p>')
      + (dn.length ? '<p style="margin:10px 0 6px">Stärkster Rückgang</p>' + rankRows(dn.map(r => row(r, 'dn')), 0, 'dn') : '');
  } catch (e) {
    el.innerHTML = `<span class="eyebrow">Veränderung ${Y} ggü. ${P}</span><p>Nicht verfügbar: ${esc(e.message)}</p>`;
  }
}

// ── Stufe 4: Bild speichern (aktuelle Ansicht inkl. lesbarer Schilder) ─────
function savePng() {
  if (!NET) return;
  const src = NET.canvas.frame.canvas, cr = src.getBoundingClientRect();
  const dpr = src.width / Math.max(1, cr.width), head = Math.round(44 * dpr);
  const out = document.createElement('canvas');
  out.width = src.width; out.height = src.height + head;
  const g = out.getContext('2d');
  g.fillStyle = '#f0ede6'; g.fillRect(0, 0, out.width, out.height);
  g.drawImage(src, 0, head);
  const F = (w, px) => `${w} ${Math.round(px * dpr)}px "Helvetica Neue", Helvetica, Arial, sans-serif`;
  g.fillStyle = '#1b1b1b'; g.font = F(600, 14); g.textBaseline = 'alphabetic';
  g.fillText('Modul-Hierarchie ' + (DATA ? DATA.family : ''), 16 * dpr, 20 * dpr);
  g.fillStyle = '#5f5a54'; g.font = F(400, 11);
  g.fillText((document.getElementById('sd-sub').textContent || '') + ' · Tiefe ' + S.depth + (S.heat ? ' · Heatmap Incidents' : '')
    + ' · Stand ' + String(DATA && DATA.ts || '').replace('T', ' ').slice(0, 16), 16 * dpr, 36 * dpr);
  g.fillStyle = 'rgba(27,27,27,.24)'; g.fillRect(0, head - dpr, out.width, dpr);
  document.querySelectorAll('#ovl > div').forEach(el => {              // Schilder übertragen
    const r = el.getBoundingClientRect(), cs = getComputedStyle(el);
    const x = (r.left - cr.left) * dpr, y = (r.top - cr.top) * dpr + head, w = r.width * dpr, h = r.height * dpr;
    g.save();
    g.globalAlpha = +cs.opacity || 1;
    g.beginPath(); g.rect(x, y, w, h); g.clip();
    if (cs.backgroundColor && cs.backgroundColor !== 'rgba(0, 0, 0, 0)') {
      g.fillStyle = cs.backgroundColor; g.fillRect(x, y, w, h);
    }
    g.fillStyle = cs.color;
    const center = el.classList.contains('oc'), fs = parseFloat(cs.fontSize) || 11;
    const lines = el.innerText.split(String.fromCharCode(10));
    g.textAlign = center ? 'center' : 'left'; g.textBaseline = 'middle';
    lines.forEach((ln, i) => {
      g.font = F(i === 0 && center ? 700 : 500, i === 0 ? fs : fs - 1);
      const ly = y + h / 2 + (i - (lines.length - 1) / 2) * (fs + 2) * dpr;
      g.fillText(ln, center ? x + w / 2 : x, ly);
    });
    g.restore();
  });
  out.toBlob(bl => {
    const a = document.createElement('a');
    a.href = URL.createObjectURL(bl);
    a.download = 'Hierarchie_' + (DATA ? DATA.family : 'Modul') + '_' + new Date().toISOString().slice(0, 10) + '.png';
    document.body.appendChild(a); a.click(); a.remove();
    setTimeout(() => URL.revokeObjectURL(a.href), 2000);
  }, 'image/png');
}

// showPop/hidePop behalten ihren Namen (auch für andere Aufrufer) — sie steuern jetzt
// den Detailbereich der Seitenleiste statt eines schwebenden Popups.
function showPop(n, pt) {
  VIEW.sel = n.id;
  showPath(n.id);
  toggleSide(true);
  const rows = (n.info || []).map(([k, v]) =>
    `<tr><td>${esc(k)}</td><td>${esc(typeof v === 'number' ? fmt(v) : v)}</td></tr>`).join('');
  let foot = '';
  if (n.level === 'ck' && S.focus !== n.ck)
    foot += `<button class="btn sm" onclick="S.focus='${esc(n.ck)}';S.q='';document.getElementById('q').value='';load()">Baugruppen &amp; Teile aufklappen</button>`;
  if (n.level === 'ck' && !isEngineLevel(n))
    foot += `<a href="/hub/module/katalog?family=${encodeURIComponent(DATA.family)}&ck=${encodeURIComponent(n.ck)}">im Katalog</a>`
          + `<a href="/hub/module/vorschlag?family=${encodeURIComponent(DATA.family)}&ck=${encodeURIComponent(n.ck)}">Teile →</a>`;
  if (n.level === 'part')
    foot += `<a href="/hub/module/vorschlag?family=${encodeURIComponent(DATA.family)}&q=${encodeURIComponent(n.part)}">im Modulvorschlag</a>`
          + `<button class="btn sm" onclick="document.getElementById('q').value='${esc(n.part)}';search()">alle Fundstellen</button>`;
  const parts = n.label.split(NL);
  const det = document.getElementById('sd-detail');
  det.innerHTML = '<button class="btn ghost sm" onclick="hidePop()" style="margin:-4px 0 8px -8px">← Aussagen</button>'
    + `<span class="eyebrow">${LEVEL_NAME[n.level] || ''}</span><h3>${esc(nice(parts[0]))}</h3>`
    + (parts.length > 1 ? `<p>${esc(nice(parts.slice(1).join(' ')))}</p>` : '')
    + (n.inc !== undefined ? `<div class="big">${fmt(n.inc || 0)}<small>Incidents</small></div>` : '')
    + (n.inc ? '<button class="btn sm" id="inc-btn" onclick="loadIncidents()">Incidents anzeigen</button><div id="inc-list"></div>' : '')
    + `<table>${rows}</table>` + (foot ? `<div class="f">${foot}</div>` : '');
  VIEW.incNode = n;
  det.hidden = false;
  document.getElementById('sd-main').hidden = true;
}
// Incidents hinter der gewählten Kachel — erst auf Klick live aus der SQL
async function loadIncidents() {
  const n = VIEW.incNode, box = document.getElementById('inc-list'), btn = document.getElementById('inc-btn');
  if (!n || !box) return;
  const q = new URLSearchParams({family: DATA.family, level: n.level});
  const rest = n.id.slice(DATA.family.length + 1);            // Knoten-ID = Familie|…
  if (n.level === 'section') q.set('og', rest.replace(/^§/, ''));
  if (n.ck) q.set('ck', n.ck);
  if (n.level === 'asm') q.set('asm', rest.split('|').slice(1).join('|'));
  if (n.level === 'part') q.set('part', n.part);
  if (isScope()) q.set('projects', (DATA.scope.selected || []).join(','));
  else if (DATA.scope && DATA.scope.year) q.set('year', DATA.scope.year);
  btn.disabled = true; btn.textContent = 'Lade …';
  box.innerHTML = '';
  let d;
  try { d = await (await fetch('/api/modules/graph/incidents?' + q)).json(); }
  catch (e) { d = {error: e.message}; }
  if (VIEW.incNode !== n) return;                               // inzwischen andere Kachel gewählt
  btn.remove();
  if (d.error) { box.innerHTML = '<p class="inc-note">&#9651; ' + esc(d.error) + '</p>'; return; }
  const more = d.total > d.rows.length;
  box.innerHTML = '<p class="inc-note">' + fmt(d.total) + ' Incidents live'
    + (more ? ' · die ' + d.rows.length + ' wichtigsten (Baustopp zuerst, dann neueste)' : '')
    + (d.total !== n.inc ? ' · Kachel ' + fmt(n.inc) + ' (Stand Matrix)' : '') + '</p>'
    + d.rows.map(r => '<div class="inc-row"><div class="inc-h">'
      + (r.project ? '<a href="/?project=' + encodeURIComponent(r.project) + '&inc=' + encodeURIComponent(r.nr)
                     + '" target="_blank" rel="noopener" title="Engine ' + esc(r.project) + ' in 01 Engine-Liste öffnen (neuer Tab)"><b>'
                     + esc(r.nr) + '</b> ↗</a>' : '<b>' + esc(r.nr) + '</b>')
      + '<span class="inc-c ' + (r.crit === 'Baustopp' ? 'bs' : r.crit === 'Kritisch' ? 'kr' : '') + '">' + esc(r.crit || '–') + '</span>'
      + '<span>' + esc(r.date) + '</span></div>'
      + '<div class="inc-t">' + esc(r.txt || '–') + '</div>'
      + '<div class="inc-m">' + [r.state, r.project, r.esn ? 'ESN ' + r.esn : '', n.level === 'section' || n.level === 'family' ? r.ck : '',
                                 n.level === 'asm' ? r.part : ''].filter(Boolean).map(esc).join(' · ') + '</div></div>').join('');
}
function hidePop() {
  VIEW.incNode = null;
  document.getElementById('pop').style.display = 'none';
  const det = document.getElementById('sd-detail');
  if (det) { det.hidden = true; document.getElementById('sd-main').hidden = false; }
  if (VIEW.sel) { VIEW.sel = null; showPath(null); }
}

function showPopOld(n, pt) {
  const pop = document.getElementById('pop');
  const rows = (n.info || []).map(([k, v]) =>
    `<tr><td>${esc(k)}</td><td>${esc(typeof v === 'number' ? fmt(v) : v)}</td></tr>`).join('');
  let foot = '';
  if (n.level === 'ck' && S.focus !== n.ck)
    foot += `<button class="btn" onclick="S.focus='${esc(n.ck)}';S.q='';document.getElementById('q').value='';load()">Baugruppen &amp; Teile aufklappen</button>`;
  if (n.level === 'part')
    foot += `<a href="/hub/module/vorschlag?family=${encodeURIComponent(DATA.family)}&q=${encodeURIComponent(n.part)}">im Modulvorschlag</a>`
          + `<button class="btn" onclick="document.getElementById('q').value='${esc(n.part)}';search()">alle Fundstellen</button>`;
  pop.innerHTML = `<div class="h">${esc(n.label.split(NL)[0])}<span onclick="hidePop()">&#10005;</span></div>`
    + `<table>${rows}</table>` + (foot ? `<div class="f">${foot}</div>` : '');
  const r = document.getElementById('net').getBoundingClientRect();
  pop.style.display = 'block';
  pop.style.left = Math.min(r.left + pt.x + 14, window.innerWidth - pop.offsetWidth - 10) + 'px';
  pop.style.top = Math.min(r.top + pt.y + 14, window.innerHeight - pop.offsetHeight - 10) + 'px';
}

readUrl();
load();
</script>
</body>
</html>
"""


@app.route('/hub/module/graph')
def modgraph_page():
    return _module_page(MODGRAPH_HTML, 'graph')


# ══════════════════════════════════════════════════════════════════════════════
# Workscope-Klassen
# Der Workscope im ZLSGS-Export ist der beim Auftrag VERGEBENE (Kundenbetreuer);
# der tatsächliche Umfang ändert sich im Shop, steht aber nur in SAP/WRB Online.
# 88 Texte („WS3 - REP", „WS1 - FULL PR" …) sind nicht vergleichbar, und die
# Nummern sind je Engine-Typ anders belegt (WS3 = REP bei PW2, FULL LLP bei
# CF34-10) — der TEXT trägt die Bedeutung. Deshalb eine Klassen-Tabelle je
# Familie: Vorschlag aus Stichworten im Text, SLU als zweite, unabhängige Spur
# (1 SLU = 3 000 h Arbeit). SLU nur RELATIV zur eigenen Familie — ein PW2-OVH hat
# 0,19 SLU, ein CF34-10 FULL PR 0,66. Widerspruch Text ↔ SLU wird markiert, nicht
# automatisch aufgelöst. Korrekturen in workscope_classes.json, wirken sofort.
# ══════════════════════════════════════════════════════════════════════════════
WS_CLASS_PATH = os.path.join(BASE_DIR, 'workscope_classes.json')
_ws_class_lock = threading.Lock()
WS_CLASSES = [
    {'key': 'voll',    'label': 'Voll',             'hint': 'Überholung / Performance Restoration der ganzen Engine'},
    {'key': 'modular', 'label': 'Modular',          'hint': 'einzelne Module oder Sektionen (HSI, QT LPT, REP …)'},
    {'key': 'befund',  'label': 'Befund / Sonder',  'hint': 'Untersuchung, Teardown, EOL, Metal in Oil, Sonstige'},
    {'key': 'aus',     'label': 'Ausschluss',       'hint': 'keine echte Shop-Arbeit (Migrations-Dummy, leer)'},
]
_WS_CLASS_KEYS = {c['key'] for c in WS_CLASSES}
WS_SLU_MIN_N   = 3            # darunter keine SLU-Stufe, kein Widerspruch
WS_ACTIVE_DAYS = 730          # „aktiv" = in den letzten 2 Jahren eingeliefert

# Reihenfolge zählt: Ausschluss → Befund → HSI/QT (sonst „PR (HSI)" = Voll) → Voll → Modular
_WS_RULES = [
    ('aus',     re.compile(r'MIGRATION|DUMMY')),
    ('befund',  re.compile(r'\bINV\b|INVESTIG|\bTRD\b|TEAR ?DOWN|\bTAR\b|\bEOL\b|END OF LIFE|METAL IN OIL|'
                           r'SONSTIGE|INSPECTION|CLEANING|BORESCOPE|\bTD\b')),
    ('modular', re.compile(r'\bHSI\b|\bQT\b|QUICK ?TURN')),
    ('voll',    re.compile(r'\bOVH\b|OVERHAUL|\bFULL\b|\bPR\b|PERFORMANCE RESTORATION')),
    ('modular', re.compile(r'\bREP\b|REPAIR|HOT SECTION|\bHS\b|\bHPT\b|\bLPT\b|\bHPC\b|\bPT\b|BOOSTER|\bFAN\b|'
                           r'\bGBX\b|GEARBOX|\bAGB\b|\bTGB\b|\bLPC\b|MODULE|REMOVAL|\bCASE\b|\bTMF\b|'
                           r'\bCORE\b|UPGRADE|\bFOD\b|PLENUM')),
]


def _ws_text(ws: str) -> str:
    """'WS21 - QT LPT' → 'QT LPT' (Nummer ist je Typ anders belegt, der Text nicht)."""
    return re.sub(r'^\s*WS\s*\d+\s*-\s*', '', ws or '').strip()


def ws_class_guess(ws: str) -> tuple:
    """(Klasse, Begründung) aus dem Workscope-Text; ('', …) wenn kein Stichwort passt."""
    if not (ws or '').strip():
        return 'aus', 'kein Workscope vergeben'
    if (ws or '').startswith(DUMMY_WORKSCOPE_PREFIX):
        return 'aus', 'WS99 = Migrations-Dummy'
    txt = _ws_text(ws).upper()
    for cls, rx in _WS_RULES:
        m = rx.search(txt)
        if m:
            return cls, f'Stichwort „{m.group(0).strip()}"'
    return '', 'kein Stichwort erkannt — bitte einordnen'


def ws_classes_load() -> dict:
    try:
        with open(WS_CLASS_PATH, encoding='utf-8') as f:
            data = json.load(f)
            return data if isinstance(data, dict) else {}
    except Exception:
        return {}


def ws_classes_save(data: dict):
    tmp = WS_CLASS_PATH + '.tmp'
    with open(tmp, 'w', encoding='utf-8') as f:
        json.dump(data, f, indent=2, ensure_ascii=False, sort_keys=True)
    os.replace(tmp, WS_CLASS_PATH)


def workscope_class(family: str, ws: str) -> str:
    """Wirksame Klasse (Korrektur vor Vorschlag) — für Mix- und Häufungsauswertungen."""
    o = ws_classes_load().get(family, {}).get(ws or '')
    return o['cls'] if o and o.get('cls') in _WS_CLASS_KEYS else ws_class_guess(ws)[0]


def _ws_quartiles(v: list) -> tuple:
    """(P25, Median, P75) — eigener Name: _quartiles() oben rechnet anders und wird
    von Prognose/Werkstattlast benutzt."""
    v = sorted(v)
    if len(v) == 1:
        return v[0], v[0], v[0]
    q = statistics.quantiles(v, n=4, method='inclusive')
    return q[0], statistics.median(v), q[2]


def workscope_table() -> dict:
    """Je Familie × Workscope: Shop-Visits, SLU-Verteilung, SLU-Index, Klasse + Quelle."""
    engines, _ = dash_data()
    ovr = ws_classes_load()
    today = datetime.date.today()
    groups = defaultdict(lambda: {'n': 0, 'slu': [], 'types': Counter(), 'years': [], 'active': 0})
    for e in engines.values():
        fam = _engine_family(e.get('engine_type', ''))
        g = groups[(fam, (e.get('workscope') or '').strip())]
        g['n'] += 1
        if (e.get('slu') or 0) > 0:
            g['slu'].append(e['slu'])
        if e.get('engine_type'):
            g['types'][e['engine_type']] += 1
        ts = (e.get('tat_start') or '')[:10]
        if ts:
            g['years'].append(ts[:4])
            try:
                if (today - datetime.date.fromisoformat(ts)).days <= WS_ACTIVE_DAYS:
                    g['active'] += 1
            except ValueError:
                pass
    fams = defaultdict(list)
    for (fam, ws), g in groups.items():
        guess, why = ws_class_guess(ws)
        o = ovr.get(fam, {}).get(ws)
        cls = o['cls'] if o and o.get('cls') in _WS_CLASS_KEYS else guess
        row = {'workscope': ws, 'text': _ws_text(ws), 'n': g['n'], 'n_slu': len(g['slu']),
               'active': g['active'], 'types': [t for t, _ in g['types'].most_common()],
               'years': [min(g['years']), max(g['years'])] if g['years'] else None,
               'guess': guess, 'why': why, 'cls': cls,
               'source': 'geprüft' if o and o.get('cls') in _WS_CLASS_KEYS else 'Vorschlag',
               'note': (o or {}).get('note', ''), 'ts': (o or {}).get('ts', ''), '_slu': g['slu']}
        if g['slu']:
            q1, med, q3 = _ws_quartiles(g['slu'])
            row.update(slu_p25=round(q1, 3), slu_med=round(med, 3), slu_p75=round(q3, 3))
        fams[fam].append(row)

    out, tot = [], Counter()
    for fam, rows in fams.items():
        # Bezugsgröße: typische Arbeit eines Voll-Workscopes dieser Familie
        voll = [s for r in rows if r['cls'] == 'voll' for s in r['_slu']]
        if len(voll) >= WS_SLU_MIN_N:
            ref, basis = statistics.median(voll), f'Median aller Voll-Workscopes ({len(voll)} Shop-Visits)'
        else:
            cand = [r for r in rows if r['n_slu'] >= WS_SLU_MIN_N and r['cls'] != 'aus']
            best = max(cand, key=lambda r: r['slu_med'], default=None)
            ref = best['slu_med'] if best else 0
            basis = (f'kein Voll-Workscope mit SLU — größter Median ({best["workscope"]})'
                     if best else 'keine SLU-Werte')
        for r in rows:
            r.pop('_slu')
            idx = r.get('slu_med', 0) / ref if ref and r['n_slu'] >= WS_SLU_MIN_N else None
            r['slu_index'] = round(idx, 2) if idx is not None else None
            r['slu_level'] = ('' if idx is None else 'schwer' if idx >= 0.75
                              else 'mittel' if idx >= 0.35 else 'leicht')
            warn = []
            if idx is not None:
                if r['cls'] == 'voll' and idx < 0.4:
                    warn.append(f'wenig Arbeit für „Voll" (SLU-Index {idx:.2f})')
                elif r['cls'] == 'modular' and idx >= 0.85:
                    warn.append(f'Arbeit wie ein Voll-Workscope (SLU-Index {idx:.2f})')
                elif r['cls'] == 'befund' and idx >= 0.75:
                    warn.append(f'SLU wie ein schwerer Workscope (Index {idx:.2f}) — '
                                f'wird aus dem Befund oft ein großer Umfang?')
            if not r['cls']:
                warn.append('nicht eingeordnet')
            r['warn'] = warn
            r['slu_leer'] = r['n'] - r['n_slu']
            tot[r['cls'] or 'offen'] += r['n']
        rows.sort(key=lambda r: (r['cls'] == 'aus', -(r.get('slu_med') or 0), -r['n']))
        out.append({'family': fam, 'ref_slu': round(ref, 3) if ref else None, 'ref_basis': basis,
                    'n': sum(r['n'] for r in rows), 'rows': rows,
                    'offen': sum(1 for r in rows if r['source'] == 'Vorschlag'),
                    'warn': sum(1 for r in rows if r['warn'])})
    out.sort(key=lambda f: -f['n'])
    return {'classes': WS_CLASSES, 'families': out, 'shop_visits': dict(tot),
            'geprueft': sum(len(v) for v in ovr.values()), 'datei': os.path.basename(WS_CLASS_PATH)}


@app.route('/api/workscopes')
def api_workscopes():
    try:
        return jsonify(workscope_table())
    except Exception as e:
        return jsonify({'error': str(e)})


@app.route('/api/workscopes/class', methods=['POST'])
def api_workscopes_class():
    """{family, workscope, cls, note} setzen · {family, workscope, reset} zurück auf Vorschlag ·
    {family, accept: true} alle eingeordneten Vorschläge der Familie als geprüft übernehmen."""
    d   = request.get_json(silent=True) or {}
    fam = str(d.get('family') or '').strip()
    if not fam:
        return jsonify({'error': 'Familie fehlt.'}), 400
    now = datetime.datetime.now().isoformat(timespec='seconds')
    with _ws_class_lock:
        ovr = ws_classes_load()
        if d.get('accept'):
            tab = next((f for f in workscope_table()['families'] if f['family'] == fam), None)
            n = 0
            for r in (tab or {}).get('rows', []):
                if r['source'] == 'Vorschlag' and r['cls']:
                    ovr.setdefault(fam, {})[r['workscope']] = {'cls': r['cls'], 'note': '', 'ts': now}
                    n += 1
            ws_classes_save(ovr)
            return jsonify({'ok': True, 'uebernommen': n})
        ws = str(d.get('workscope') if d.get('workscope') is not None else '').strip()
        if d.get('reset'):
            ovr.get(fam, {}).pop(ws, None)
            if fam in ovr and not ovr[fam]:
                ovr.pop(fam)
        else:
            cls = str(d.get('cls') or '')
            if cls not in _WS_CLASS_KEYS:
                return jsonify({'error': 'Unbekannte Klasse.'}), 400
            old = ovr.get(fam, {}).get(ws) or {}
            ovr.setdefault(fam, {})[ws] = {'cls': cls, 'ts': now,
                                           'note': str(d.get('note', old.get('note', '')) or '').strip()[:300]}
        ws_classes_save(ovr)
    return jsonify({'ok': True})


# ══════════════════════════════════════════════════════════════════════════════
# Workscope-Mix über die Zeit + Häufungserkennung (Incidents pro SLU)
# Zwei Zeitachsen, bewusst getrennt:
#   Zulauf (Kohorte)  — ein Shop-Visit zählt einmal, im Quartal seines Eingangs.
#                       Beantwortet „was kommt rein": Klassen-Mix, Ø SLU.
#   Werkstatt (Kalender) — Incidents nach Meldedatum, SLU über die Verweildauer
#                       verteilt (die Arbeit fällt über die TAT an). Incidents pro
#                       SLU je Kalenderquartal ist damit unabhängig davon, wie viele
#                       Engines noch offen sind — eine Kohortenrate wäre für die
#                       jüngsten Quartale immer zu niedrig (Incidents kommen noch).
# Ob SLU in SAP nachgezogen wird, ist OFFEN (Nutzer prüft, 26.09.2026) — falls ja, ist sie
# bei offenen Engines ein Zwischenstand. Ohne SLU (≈ 29 %) gilt der Familien-Median der Klasse.
# ══════════════════════════════════════════════════════════════════════════════
CLUSTER_BASE_DAYS  = 365      # Grundlinie: 12 Monate vor dem Fenster (saisonfrei)
CLUSTER_MIN_OBS    = 3        # darunter nie Alarm — 1–2 Incidents sind Zufall
CLUSTER_ALARM_P    = 0.01
CLUSTER_WATCH_P    = 0.05
CLUSTER_MIN_FACTOR = 1.5
CLUSTER_MIN_ENG    = 3        # Alarm/Beobachten erst ab so vielen verschiedenen Engines —
                              # eine einzelne Problem-Engine ist keine Häufung (Incidents je
                              # Engine hängen zusammen, der Poisson-Test unterstellt Unabhängigkeit)


# ── Incident-Seite: wessen Problem ist es? ────────────────────────────────────
# Frage „was läuft bei UNS schief" braucht die Trennung: Befund am Kundenteil (Zustand
# der Hardware, exogen, meist abrechenbar) gegen Material/Lieferkette und internen Ablauf.
# Anfragen (Bauteilbestellung, Zertifizierungsanfrage, Back to Birth …) sind kein Fehler;
# Anfrage/Störung entscheidet die LUD-Core-Spalte type, mit Baustopp → 'wait'.
# Quelle, in dieser Reihenfolge: Ursache (cause) wenn eindeutig → Grund (reason) →
# Kategorie → Kurztext (Regeln unten) → Meldegruppe. Grund/Kategorie/Ursache sind im
# MTU-Hub strukturierte ServiceNow-Listen (Snapshot 24.09.2026); die CSV-Kopie hat sie
# nicht, dort bleibt nur der Text.
INC_SIDES = [
    {'key': 'hw',  'label': 'Befund am Teil',        'hint': 'Zustand der Kunden-Hardware: Maß außer Limit, Riss, Leckage, Verschleiß, Befundbeauftragung'},
    {'key': 'mat', 'label': 'Material & Lieferkette', 'hint': 'Fehlt, falsch/beschädigt angeliefert, Schüttware leer, Lieferverzug'},
    {'key': 'int', 'label': 'Interner Ablauf',        'hint': 'Beschädigt im Prozess, Doku/Tag/Zertifikat, falsch gebucht/befundet, Arbeitsplan'},
    {'key': 'wait', 'label': 'Warten auf Entscheidung/Teil', 'hint': 'LUD-Typ Anfrage mit Baustopp: Kunden-/technische Entscheidung, Gaptausch, Befundbeauftragung, Bauteilbestellung, Testleihe — echter Stillstand, aber kein Fehler'},
    {'key': 'anf', 'label': 'Anfrage (Routine)',      'hint': 'LUD-Typ Anfrage ohne Baustopp: Bestellung, Zertifizierung, Back to Birth, OV-Versand — kein Fehler'},
]
_SIDE_KEYS = {s['key'] for s in INC_SIDES}


def _sn(s) -> str:
    return re.sub(r'\s+', ' ', str(s or '').strip().lower())


# Ursache: nur die Werte, die die Seite eindeutig machen („Sonstiges", „Gelöst durch Ersteller" nicht)
_SIDE_CAUSE = [
    ('int', re.compile(r'^(falsch befundet|handhabungssch|menschlicher fehler|interner fehler|bauteil wurde mit einem werkzeug'
                       r'|bauteil ist mit einer vorrichtung|beschädigung bei der befundung|bauteil nicht korrekt repariert'
                       r'|falsche(r|s)? (buchung|arbeitsplan|materialbuchung|materialreservierung|lagerort|transfer|wbs|cs-order)'
                       r'|systemparameter falsch|nicht oder zu wenig geofflogt|zu kurz geschliffen|maskierung|bearbeitungsspuren)')),
    ('mat', re.compile(r'^(lieferverzögerung|verbrauchsspitze|bedarfsgesteuert|fehlbestand|schütter leer|nicht angeliefert'
                       r'|routingfehler durch (den lieferanten|ov|versand)|fehlt in der kit|unzureichende menge|fertigungsfehler'
                       r'|ov-teil|fehler in der wareneingangsdoku|qn im wareneingang)')),
    ('hw',  re.compile(r'^(verschleiß|konstruktionsfehler|vermessene blendtiefe|flow-wert über limit)')),
]
# Grund (und Kategorie, gleiche Liste — beide stammen aus x_mtuae_ins_reason)
_SIDE_REASON = [
    ('anf', re.compile(r'^(bauteilbestellung|zertifizierungsanfrage|bauteilhistorienprüfung|materialrücklieferung|material lieferung'
                       r'|projekt erstellung|erstellung purchase order|part needs to be sent|bauteil muss an externen|kundenanfrage'
                       r'|exportklassifizierung|klärung mangel|red label|hse|triebwerk lieferung|serviceable)')),
    ('int', re.compile(r'^(beschädigt im prozess|falsch befundet|inspektion|fehlerhaft|falsch$|ergebnis nicht zufriedenstellend'
                       r'|tag|zertifikat|cso|arbeitsblatt|arbeitsplan|dokumentation|stempel)')),
    ('mat', re.compile(r'^(fehlt|beschädigt angeliefert|falsche(s)? (bauteil|menge|liefermenge)|unvol+ständig|schüttware)')),
    ('hw',  re.compile(r'^(maß außer limit|befundbeauftragung|leckage|vibration|leistung außer limit|beschädigt / defekt'
                       r'|kundenentscheidung|technische entscheidung)')),
]
_SIDE_REASON_INT = re.compile(r'^(beschädigt im prozess|falsch befundet)')
# „Nicht konform / Abweichung" und „Fehlt": Doku-Kategorie (Tag, Zertifikat, Laufkarte …) → intern,
# sonst geht es um ein Teil → Material
_SIDE_DOC_CAT = re.compile(r'^(tag|zertifikat|cso|arbeitsblatt|arbeitsplan|dokumentation|sonstige unterlagen)')
# Kurztext — Ersatz, wenn die Felder fehlen (CSV-Kopie). Reihenfolge zählt.
_SIDE_TEXT = [
    ('anf', re.compile(r'bestell|disponier|zertifizierungsanfrage|back to birth|outside vendor|\bzum ov\b|rücklieferung', re.I)),
    ('int', re.compile(r'zu boden|runtergefallen|heruntergefallen|beim (transport|entnehmen|handling|vernieten)|versehentlich'
                       r'|fälschlich|verwechs|vergessen', re.I)),
    ('mat', re.compile(r'\bfehl(t|en|end|ende|endes|teile?)\b|fehlteil|missing|angeliefert|anliefer|angliefer|geliefert|lieferung'
                       r'|nicht (angekommen|bereitgestellt|vorhanden|verfügbar|auffindbar)|nicht .{0,25}gefunden|bereitstell'
                       r'|\bbenötig|\bleer\b|schütt|testleihe|tauschteil|neuteil|\bstatus\b|gefordert|\bkit\b|kitting|zu (viel|wenig)\b', re.I)),
    ('int', re.compile(r'zerti|certif|\bcofc?\b|\btag\b|\bbom\b|\bopns?\b|\bwrb\b|insplv|insp\.? ?level|inspection level|falsch|wrong'
                       r'|incor+ect|onlog|offlog|\bbuch|streichen|lesbar|nicht zu erkennen|nachweis|\bsb ?\d|\bmpd\b|approval|freigabe'
                       r'|\bebs\b|\bpmi\b|config|dokument|\bdoku|papier|\bsap\b|\bnis\b|subtask|\b(tsn|csn)\b'
                       r'|nicht (durchgeführt|abgemeldet|erledigt|lackiert|demontiert|verbaubar|zertifiziert)|matched pair', re.I)),
    ('hw',  re.compile(r'beschädig|defekt|fehlerhaft|\briss|gerissen|rissig|crack|verbogen|deform|\bwear\b|verschlei|verschliss'
                       r'|abgenutzt|\blimit|verschrott|scrap|korros|corros|\brost|erosion|erodiert|damage|\bburn|verbrannt|\bnicks?\b'
                       r'|genickt|\bdents?\b|kerbe|delle|undicht|leak|leck|ausgebrochen|abgebrochen|pitting|befund|serbienbef|verzogen'
                       r'|abplatz|schmelz|überhitz|\bfod\b|fremdkörper|\blose?\b|locker|unwucht|vibration|geplatzt|bruch|gebrochen'
                       r'|ausgeschlagen|festgefressen|riefen|scheuer|schwergängig|ohne funktion|ausgefallen|high metal|verschliffen'
                       r'|suspekt|(torque|torquewert|drehmoment)\w* zu (gering|niedrig|hoch)|run-on|klemm|zu (kurz|lang|dick|dünn)\b', re.I)),
]
_SIDE_GROUP = re.compile(r'incoming_goods|warehouse|kitting|logistic|purchasing|material_planning|dispatch', re.I)


def inc_side(i: dict) -> tuple:
    """(Seite, Quelle) eines Incidents. Seite ∈ hw/mat/int/wait/anf oder '' (unklar);
    Quelle ∈ 'typ' (LUD-Core-Spalte type), 'feld' (Ursache/Grund/Kategorie), 'text',
    'gruppe' oder ''.

    Die LUD-Core-Spalte type entscheidet Anfrage gegen Störung (echter Hub
    28.09.2026: nie leer). Anfragen mit Baustopp sind fast alle ein Warten auf
    Entscheidung, Tausch oder Teil — Stillstand, aber kein Fehler → 'wait'.
    Nur Störungen werden per Heuristik auf Befund/Material/Intern verteilt; sagt
    die Heuristik dort „Anfrage", bleibt die Seite unklar. Ohne type-Feld (alter
    Snapshot, CSV) entscheidet die Heuristik allein, wie früher."""
    t = _sn(i.get('type'))
    if t.startswith('anfr'):
        crit = (i.get('criticality') or '').strip().lower()
        return ('wait' if crit == 'baustopp' else 'anf'), 'typ'
    side, src = _inc_side_heur(i)
    if t.startswith(('stör', 'stoer', 'stor')) and side == 'anf':
        return '', ''
    return side, src


def _inc_side_heur(i: dict) -> tuple:
    """Seite aus Ursache/Grund/Kategorie/Text/Meldegruppe (ohne LUD-Typ)."""
    cause, reason, cat = _sn(i.get('cause')), _sn(i.get('reason')), _sn(i.get('category'))
    if _SIDE_REASON_INT.search(reason):      # der Melder sagt selbst „bei uns passiert" — schlägt die Ursache
        return 'int', 'feld'
    for side, rx in _SIDE_CAUSE:
        if cause and rx.search(cause):
            return side, 'feld'
    for val in (reason, cat):
        if not val:
            continue
        if val.startswith(('nicht konform', 'fehlt')):
            return ('int' if _SIDE_DOC_CAT.search(cat) else 'mat'), 'feld'
        for side, rx in _SIDE_REASON:
            if rx.search(val):
                return side, 'feld'
    for txt in (i.get('description') or '', i.get('long_text') or ''):
        for side, rx in _SIDE_TEXT:
            if txt and rx.search(txt):
                return side, 'text'
    if _SIDE_GROUP.search(i.get('group') or ''):
        return 'mat', 'gruppe'
    return '', ''


def _side_arg() -> str:
    s = request.args.get('side') or ''
    return s if s in _SIDE_KEYS or s in ('fehler', 'stoer') else ''


def _side_ok(x: dict, side: str) -> bool:
    """side = '' alle · 'fehler' = ohne Routine-Anfragen (Warten bleibt drin) ·
    'stoer' = nur Störungen (ohne jede Anfrage) · sonst genau diese Seite."""
    if not side:
        return True
    if side == 'fehler':
        return x['side'] != 'anf'
    if side == 'stoer':
        return x['side'] not in ('anf', 'wait')
    return x['side'] == side
_exposure_cache = {'key': None, 'data': None}


def _d(s):
    try:
        return datetime.date.fromisoformat(str(s)[:10])
    except (ValueError, TypeError):
        return None


def _quarter(d: datetime.date) -> str:
    return f'{d.year}-Q{(d.month - 1) // 3 + 1}'


def _quarter_bounds(q: str) -> tuple:
    y, n = int(q[:4]), int(q[-1])
    start = datetime.date(y, 3 * (n - 1) + 1, 1)
    end = datetime.date(y + (n == 4), 1 if n == 4 else 3 * n + 1, 1)
    return start, end


def _ck_of_wbs(wbs: str) -> str:
    """'L.20000.20.80' → 'CK80' (Projekt.OG.CK); sonst '' = nicht modulgenau gebucht."""
    parts = (wbs or '').strip().split('.')
    return 'CK' + parts[3] if len(parts) == 4 and parts[3].isdigit() else ''


def shop_exposure() -> dict:
    """SLU je Tag im Shop, je Familie und Workscope-Klasse, als Summenreihe — jede
    Fenstersumme ist danach eine Subtraktion. Plus alle Incidents mit Familie/Klasse/Modul."""
    engines, _ = dash_data()
    ovr = ws_classes_load()
    key = (id(engines), json.dumps(ovr, sort_keys=True))
    if _exposure_cache['key'] == key:
        return _exposure_cache['data']

    def cls_of(fam, ws):
        o = ovr.get(fam, {}).get(ws)
        return o['cls'] if o and o.get('cls') in _WS_CLASS_KEYS else ws_class_guess(ws)[0] or 'modular'

    visits, inc_dates = [], []
    for e in engines.values():
        fam = _engine_family(e.get('engine_type', ''))
        ws = (e.get('workscope') or '').strip()
        cls = cls_of(fam, ws)
        act = e.get('milestones_actual') or {}
        start = _d(act.get('I')) or _d(e.get('tat_start'))
        visits.append({'fam': fam, 'cls': cls, 'slu': e.get('slu') or 0.0, 'start': start,
                       'end': _d(act.get('SA')), 'e': e, 'eage': e.get('age') or {}, 'seg': None})
        for i in e.get('incidents_c3') or []:
            d = _d(i.get('created_on'))
            if d:
                inc_dates.append(d)
    # Referenzdatum = der ÄLTERE der beiden Datenstände, nicht „heute": nach dem
    # SAP-Exportdatum fehlen neu eingelieferte Engines → SLU im Shop zu klein →
    # Scheinhäufungen; nach dem Incident-Stand fehlen Incidents → Scheinruhe.
    inc_ref = max(inc_dates) if inc_dates else None
    sap_ref = _d(_data_through(engines))
    ref = min([d for d in (inc_ref, sap_ref) if d] or [datetime.date.today()])
    # typische TAT je Familie für offene Engines (Ende = Start + Median-TAT, mind. ref + 30 T)
    tat = defaultdict(list)
    slu_med = defaultdict(list)
    for v in visits:
        if v['start'] and v['end'] and v['end'] > v['start']:
            tat[v['fam']].append((v['end'] - v['start']).days)
        if v['slu'] > 0:
            slu_med[(v['fam'], v['cls'])].append(v['slu'])
    tat_med = {f: statistics.median(x) for f, x in tat.items()}
    slu_med = {k: statistics.median(x) for k, x in slu_med.items()}
    d0 = min((v['start'] for v in visits if v['start']), default=ref)
    ndays = (ref - d0).days + 400
    diff = defaultdict(lambda: [0.0] * (ndays + 1))
    incidents = []
    imputed = 0
    for v in visits:
        if v['cls'] == 'aus' or not v['start']:
            continue
        slu = v['slu']
        if slu <= 0:
            slu = slu_med.get((v['fam'], v['cls']), 0.0)
            imputed += 1
        end = v['end']
        if not end or end <= v['start']:
            end = max(v['start'] + datetime.timedelta(days=int(tat_med.get(v['fam'], 120))),
                      ref + datetime.timedelta(days=30))
        a, b = (v['start'] - d0).days, min((end - d0).days, ndays)
        if b > a and slu > 0:
            rate = slu / (b - a)
            v['seg'] = (a, b, rate)
            for k in ((v['fam'], v['cls']), (v['fam'], '*')):
                diff[k][a] += rate
                diff[k][b] -= rate
        for i in v['e'].get('incidents_c3') or []:
            d = _d(i.get('created_on'))
            if d and d <= ref:
                side, src = inc_side(i)
                incidents.append({'d': d, 'fam': v['fam'], 'cls': v['cls'], 'ck': _ck_of_wbs(i.get('wbs')),
                                  'eage': v['eage'], 'side': side, 'src': src,
                                  'bs_iv': _bs_interval(i, d, v['seg'], d0, ref),
                                  'i': i, 'project': v['e'].get('project', ''), 'esn': v['e'].get('esn', '')})
    cum = {}
    for k, arr in diff.items():
        run, tot, c = 0.0, 0.0, [0.0]
        for x in arr:
            run += x
            tot += run
            c.append(tot)
        cum[k] = c
    data = {'d0': d0, 'ref': ref, 'inc_ref': inc_ref, 'sap_ref': sap_ref,
            'cum': cum, 'visits': visits, 'incidents': incidents,
            'imputed': imputed, 'ndays': ndays}
    _exposure_cache.update(key=key, data=data)
    return data


def _bs_interval(i: dict, c: datetime.date, seg, d0: datetime.date, ref: datetime.date):
    """Baustopp als Intervall (Tage ab d0, Kommazahl), geschnitten auf die Zeit, in der die
    Engine im Shop lag (Exposure-Segment) und auf den Datenstand. Offen → bis Stand.
    Erledigt ohne Lösungsdatum → None (Dauer unbekannt). Kein Baustopp → None."""
    if not seg or (i.get('criticality') or '').strip().lower() != 'baustopp':
        return None
    s = (c - d0).days
    r = _d(i.get('resolved_at'))
    stop = (ref - d0).days + 1
    if r and r <= ref:
        h = i.get('resolution_h')
        e = s + (h / 24 if h is not None else (r - c).days)
    elif (i.get('state') or '').strip().lower() in _INC_DONE:
        return None
    else:
        e = stop
    s, e = max(s, seg[0]), min(e, seg[1], stop)
    return (s, e) if e > s else None


def _bs_merged(X, ok) -> dict:
    """Projekt → (ein Incident als Träger von Familie/Klasse/Alter, verschmolzene Intervalle).
    Überlappende Baustopps derselben Engine zählen einmal — zwei Tickets gleichzeitig
    sind ein Stillstand, nicht zwei."""
    by, one = defaultdict(list), {}
    for x in X['incidents']:
        if x.get('bs_iv') and x['cls'] != 'aus' and ok(x):
            by[x['project']].append(x['bs_iv'])
            one[x['project']] = x
    out = {}
    for p, ivs in by.items():
        ivs.sort()
        m = [list(ivs[0])]
        for s, e in ivs[1:]:
            if s <= m[-1][1]:
                m[-1][1] = max(m[-1][1], e)
            else:
                m.append([s, e])
        out[p] = (one[p], m)
    return out


def _bs_hours(M: dict, X, a: datetime.date, b: datetime.date, key=None):
    """Baustopp-Stunden im Fenster [a, b). Ohne key: Summe; mit key(x): Counter je Schlüssel."""
    lo, hi = (a - X['d0']).days, (b - X['d0']).days
    out = Counter()
    for x, ivs in M.values():
        h = 24 * sum(max(0.0, min(e, hi) - max(s, lo)) for s, e in ivs)
        if h:
            out[key(x) if key else ''] += h
    return out if key else out['']


_age_expo_cache = {}


def age_exposure(X, metric: str) -> dict:
    """Wie shop_exposure, aber je (Familie, Klasse, Altersband) und (Familie, '*', Band).
    Band '?' = ohne Alterswert — bleibt drin, damit die Summen zur Gesamtsicht passen."""
    key = (id(X['cum']), metric)
    if key in _age_expo_cache:
        return _age_expo_cache[key]
    cuts = age_bands([{'family': v['fam'], 'eage': v['eage']} for v in X['visits'] if v['cls'] != 'aus'], metric)
    diff = defaultdict(lambda: [0.0] * (X['ndays'] + 1))
    for v in X['visits']:
        if v['cls'] == 'aus' or not v['seg']:
            continue
        band = age_band_of({'family': v['fam'], 'eage': v['eage']}, metric, cuts) or '?'
        a, b, rate = v['seg']
        for k in ((v['fam'], v['cls'], band), (v['fam'], '*', band)):
            diff[k][a] += rate
            diff[k][b] -= rate
    cum = {}
    for k, arr in diff.items():
        run, tot, c = 0.0, 0.0, [0.0]
        for x in arr:
            run += x
            tot += run
            c.append(tot)
        cum[k] = c
    res = {'cuts': cuts, 'cum': cum, 'd0': X['d0'], 'metric': metric,
           'band': lambda fam, eage: age_band_of({'family': fam, 'eage': eage}, metric, cuts) or '?'}
    if len(_age_expo_cache) > 8:
        _age_expo_cache.clear()
    _age_expo_cache[key] = res
    return res


def _age_metric_arg() -> str:
    m = request.args.get('age') or ''
    return m if m in export_age_columns() else ''


def _expo(X, key, a: datetime.date, b: datetime.date) -> float:
    """SLU-Tage/Tagessumme im Fenster [a, b) → SLU-Anteil, der in dieses Fenster fällt."""
    c = X['cum'].get(key)
    if not c:
        return 0.0
    i = max(0, min(len(c) - 1, (a - X['d0']).days))
    j = max(0, min(len(c) - 1, (b - X['d0']).days))
    return c[j] - c[i]


@app.route('/api/dashboard/ws-mix-time')
def api_ws_mix_time():
    """Zulauf je Quartal nach Klasse + Ø SLU, Incidents pro SLU je Kalenderquartal,
    Zerlegung letzte 4 vs. vorherige 4 volle Quartale in Arbeit · Mix · Rate."""
    X = shop_exposure()
    fam = request.args.get('family') or ''
    nq = max(4, min(int(request.args.get('quarters') or 12), 28))
    age_m = _age_metric_arg()
    side = _side_arg()
    bsh = request.args.get('metric') == 'bsh'     # Wirkung: Baustopp-Stunden statt Anzahl
    A = age_exposure(X, age_m) if age_m else None
    ref = X['ref']
    cur_q = _quarter(ref)
    qs, q = [], cur_q
    for _ in range(nq):
        qs.append(q)
        s, _e = _quarter_bounds(q)
        q = _quarter(s - datetime.timedelta(days=1))
    qs.reverse()
    fams_all = sorted({v['fam'] for v in X['visits'] if v['cls'] != 'aus'},
                      key=lambda f: -sum(1 for v in X['visits'] if v['fam'] == f))
    sel = [f for f in fams_all if not fam or f == fam]
    classes = [c['key'] for c in WS_CLASSES if c['key'] != 'aus']
    intake = {q: {'n': Counter(), 'slu': [], 'open': 0, 'aidx': [], 'alt': 0, 'banded': 0} for q in qs}
    for v in X['visits']:
        if v['fam'] not in sel or v['cls'] == 'aus' or not v['start']:
            continue
        q = _quarter(v['start'])
        if q in intake:
            it = intake[q]
            it['n'][v['cls']] += 1
            if v['slu'] > 0:
                it['slu'].append(v['slu'])
            if not v['end']:
                it['open'] += 1
            if A:
                c = A['cuts'].get(v['fam'])
                val = v['eage'].get(age_m)
                if c and val:
                    it['aidx'].append(val / c[2])
                    it['banded'] += 1
                    it['alt'] += val > c[1]
    inc_q = defaultdict(lambda: Counter())
    side_q = defaultdict(lambda: Counter())       # Quartal → Seite → n (immer alle Seiten, für die Linien)
    cov, cov_src = Counter(), Counter()
    q_set = set(qs)
    for x in X['incidents']:
        if x['fam'] in sel and x['cls'] != 'aus':
            q = _quarter(x['d'])
            side_q[q][x['side']] += 1
            if q in q_set:
                cov[x['side']] += 1
                cov_src[x['src']] += 1
                if _side_ok(x, side) and (x['i'].get('criticality') or '').strip().lower() == 'baustopp':
                    cov_src['bs'] += 1
                    cov_src['bs_iv'] += bool(x['bs_iv'])
            if _side_ok(x, side):
                inc_q[q][x['cls']] += 1
    bs_sel = _bs_merged(X, lambda x: x['fam'] in sel and _side_ok(x, side))
    bs_side = {k: _bs_merged(X, lambda x, k=k: x['fam'] in sel and x['side'] == k) for k in _SIDE_KEYS}
    rows = []
    for q in qs:
        a, b = _quarter_bounds(q)
        b_eff = min(b, ref + datetime.timedelta(days=1))
        H = _bs_hours(bs_sel, X, a, b_eff)
        expo = {c: sum(_expo(X, (f, c), a, b_eff) for f in sel) for c in classes}
        E = sum(expo.values())
        I = sum(inc_q[q].values())
        it = intake[q]
        nvis = sum(it['n'].values())
        sq = side_q[q]
        rows.append({'q': q, 'visits': {c: it['n'][c] for c in classes}, 'n': nvis,
                     'sides': {k: sq[k] for k in list(_SIDE_KEYS) + ['']},
                     'side_rate': {k: (round(sq[k] / E, 1) if E > 0.05 else None) for k in _SIDE_KEYS},
                     'slu_mean': round(statistics.mean(it['slu']), 3) if it['slu'] else None,
                     'open_share': round(it['open'] / nvis, 2) if nvis else 0,
                     'incidents': I, 'expo': round(E, 2),
                     'rate': round(I / E, 1) if E > 0.05 else None,
                     'bs_h': round(H), 'bs_rate': round(H / E, 1) if E > 0.05 else None,
                     'bs_side_rate': {k: (round(_bs_hours(bs_side[k], X, a, b_eff) / E, 1) if E > 0.05 else None)
                                      for k in _SIDE_KEYS},
                     'age_idx': round(statistics.median(it['aidx']), 2) if it['aidx'] else None,
                     'age_alt': round(100 * it['alt'] / it['banded']) if it['banded'] else None,
                     'partial': q == cur_q})
    # Zerlegung: 4 volle Quartale gegen die 4 davor (12 gegen 12 Monate → saisonfrei)
    full = [r for r in rows if not r['partial']]
    dec = None
    if len(full) >= 8:
        p1, p0 = full[-4:], full[-8:-4]

        bands = AGE_BANDS + ['?']
        cells = [(c, bd) for c in classes for bd in bands] if A else [(c, '') for c in classes]

        def agg(part):
            a, _x = _quarter_bounds(part[0]['q'])
            _y, b = _quarter_bounds(part[-1]['q'])
            if A:
                E = {(c, bd): sum(_expo(A, (f, c, bd), a, b) for f in sel) for c, bd in cells}
            else:
                E = {(c, ''): sum(_expo(X, (f, c), a, b) for f in sel) for c in classes}
            cell_of = lambda x: (x['cls'], A['band'](x['fam'], x['eage']) if A else '')
            if bsh:
                I = _bs_hours(bs_sel, X, a, b, key=cell_of)
            else:
                I = Counter()
                for x in X['incidents']:
                    if x['fam'] in sel and x['cls'] in classes and a <= x['d'] < b and _side_ok(x, side):
                        I[cell_of(x)] += 1
            return E, I, f"{part[0]['q']}–{part[-1]['q']}"
        E0, I0, lab0 = agg(p0)
        E1, I1, lab1 = agg(p1)
        e0, e1 = sum(E0.values()), sum(E1.values())
        if e0 > 0 and e1 > 0:
            s0 = {k: E0[k] / e0 for k in cells}
            s1 = {k: E1[k] / e1 for k in cells}
            r0 = {k: I0[k] / E0[k] if E0[k] > 0 else 0 for k in cells}
            r1 = {k: I1[k] / E1[k] if E1[k] > 0 else r0[k] for k in cells}
            R0 = sum(s0[k] * r0[k] for k in cells)
            R1 = sum(s1[k] * r1[k] for k in cells)
            eb = (e0 + e1) / 2
            arbeit = (e1 - e0) * (R0 + R1) / 2
            mix_all = eb * sum((s1[k] - s0[k]) * (r0[k] + r1[k]) / 2 for k in cells)
            # Workscope-Anteil des Mix-Effekts: Klassen-Anteile × Klassen-Rate; der Rest ist Alters-Mix
            def cls_rate(s, r, c):
                sc = sum(s[k] for k in cells if k[0] == c)
                return sc, (sum(s[k] * r[k] for k in cells if k[0] == c) / sc if sc else 0)
            mix = 0.0
            for c in classes:
                sc0, rc0 = cls_rate(s0, r0, c)
                sc1, rc1 = cls_rate(s1, r1, c)
                mix += eb * (sc1 - sc0) * (rc0 + rc1) / 2
            age_mix = mix_all - mix
            total = sum(I1.values()) - sum(I0.values())
            I0c, I1c, E0c, E1c = Counter(), Counter(), Counter(), Counter()
            for k in cells:
                I0c[k[0]] += I0[k]
                I1c[k[0]] += I1[k]
                E0c[k[0]] += E0[k]
                E1c[k[0]] += E1[k]
            dec = {'von': lab0, 'bis': lab1, 'i0': round(sum(I0.values())), 'i1': round(sum(I1.values())),
                   'slu0': round(e0, 1), 'slu1': round(e1, 1),
                   'rate0': round(sum(I0.values()) / e0, 1), 'rate1': round(sum(I1.values()) / e1, 1),
                   'arbeit': round(arbeit, 1), 'mix': round(mix, 1),
                   'age_mix': round(age_mix, 1) if A else None,
                   'rate': round(total - arbeit - mix_all, 1),
                   'share0': {c: round(100 * E0c[c] / e0, 1) for c in classes},
                   'share1': {c: round(100 * E1c[c] / e1, 1) for c in classes},
                   'r0': {c: round(I0c[c] / E0c[c], 1) if E0c[c] else 0 for c in classes},
                   'r1': {c: round(I1c[c] / E1c[c], 1) if E1c[c] else 0 for c in classes}}
            if A:
                dec['age_share0'] = {bd: round(100 * sum(E0[(c, bd)] for c in classes) / e0, 1) for bd in bands}
                dec['age_share1'] = {bd: round(100 * sum(E1[(c, bd)] for c in classes) / e1, 1) for bd in bands}
    return jsonify({'ref': ref.isoformat(), 'stand': _stand(X), 'family': fam, 'families': fams_all,
                    'age_metric': age_m, 'age_metrics': [m for m in ENGINE_AGE_METRICS if m['key'] in export_age_columns()],
                    'classes': WS_CLASSES, 'side': side, 'sides': INC_SIDES, 'metric': 'bsh' if bsh else 'n',
                    'side_cov': {'n': sum(cov.values()), 'by': dict(cov), 'src': dict(cov_src)},
                    'quarters': rows, 'decomposition': dec, 'imputed_slu': X['imputed']})


def _stand(X) -> dict:
    return {'sap': X['sap_ref'].isoformat() if X['sap_ref'] else None,
            'incidents': X['inc_ref'].isoformat() if X['inc_ref'] else None}


def _poisson_sf(k: int, lam: float) -> float:
    """P(X ≥ k) für X ~ Poisson(lam)."""
    if k <= 0:
        return 1.0
    term = math.exp(-lam)
    cdf = term
    for i in range(1, k):
        term *= lam / i
        cdf += term
    return max(0.0, 1.0 - cdf)


def _module_names() -> dict:
    cat = module_catalog(build=False) or {}
    out = {}
    for m in sorted(cat.get('modules', []), key=lambda m: -m['projects']):
        if not m.get('template'):
            out.setdefault(f"{m['family']}|{m['ck']}", _short(m['name']))
    return out


@app.route('/api/dashboard/clusters')
def api_clusters():
    """Häufungen: Incidents im Fenster gegen den Erwartungswert
    (Rate je SLU der letzten 12 Monate davor × SLU im Fenster), je Familie und
    Familie × Modul. Poisson-Test, gereiht nach Überraschung."""
    X = shop_exposure()
    win = max(7, min(int(request.args.get('window') or 28), 182))
    ref = X['ref']
    w1 = ref + datetime.timedelta(days=1)
    w0 = w1 - datetime.timedelta(days=win)
    b0 = w0 - datetime.timedelta(days=CLUSTER_BASE_DAYS)
    names = _module_names()
    age_m = _age_metric_arg()
    side = _side_arg()
    A = age_exposure(X, age_m) if age_m else None
    base_band = Counter()                    # (Zelle, Band) → Grundlinien-Incidents
    obs, base = Counter(), Counter()
    eng_w = defaultdict(Counter)             # Zelle → Projekt → Incidents im Fenster
    weekly = defaultdict(Counter)            # Zelle → Wochenindex (0 = älteste von 13) → n
    wk0 = w1 - datetime.timedelta(days=7 * 13)
    for x in X['incidents']:
        if x['cls'] == 'aus' or not _side_ok(x, side):
            continue
        cells = [(x['fam'], '*'), (x['fam'], x['ck'] or '–')]
        for c in cells:
            if w0 <= x['d'] < w1:
                obs[c] += 1
                eng_w[c][x['project']] += 1
            elif b0 <= x['d'] < w0:
                base[c] += 1
                if A:
                    base_band[(c, A['band'](x['fam'], x['eage']))] += 1
            if wk0 <= x['d'] < w1:
                weekly[c][(x['d'] - wk0).days // 7] += 1
    fams = {c[0] for c in list(obs) + list(base)}
    ex_w = {f: _expo(X, (f, '*'), w0, w1) for f in fams}
    ex_b = {f: _expo(X, (f, '*'), b0, w0) for f in fams}
    ex_wk = {f: [_expo(X, (f, '*'), wk0 + datetime.timedelta(days=7 * k),
                       wk0 + datetime.timedelta(days=7 * (k + 1))) for k in range(13)] for f in fams}
    rows = []
    for c in set(obs) | set(base):
        f, ck = c
        if ex_b.get(f, 0) <= 0.05 or ex_w.get(f, 0) <= 0.01:
            continue                         # Familie kaum im Shop — keine belastbare Erwartung
        rate = max(base[c], 0.5) / ex_b[f]   # 0 Grundlinie → halber Incident, sonst wäre jedes Auftreten „unendlich"
        E = E_plain = rate * ex_w[f]
        if A:
            # je Altersband eigene Rate, zum Familienwert gezogen (Gewicht ≙ 1/6 der Grundlinien-SLU):
            # ein dünn besetztes Band soll mit zwei Zufalls-Incidents nicht die Erwartung bestimmen
            m_ = ex_b[f] / 6
            E = 0.0
            for bd in AGE_BANDS + ['?']:
                xb = _expo(A, (f, '*', bd), b0, w0)
                xw = _expo(A, (f, '*', bd), w0, w1)
                if xw > 0:
                    E += (base_band[(c, bd)] + m_ * rate) / (xb + m_) * xw
            E = max(E, 1e-6)
        O = obs[c]
        p = _poisson_sf(O, E)
        factor = O / E if E > 0 else None
        level = ('alarm' if O >= CLUSTER_MIN_OBS and p < CLUSTER_ALARM_P and factor >= CLUSTER_MIN_FACTOR
                 else 'watch' if O >= CLUSTER_MIN_OBS and p < CLUSTER_WATCH_P and factor >= CLUSTER_MIN_FACTOR
                 else '')
        n_eng = len(eng_w[c])
        top_n = max(eng_w[c].values(), default=0)
        if level and n_eng < CLUSTER_MIN_ENG:
            level = 'engine'                 # auffällig, aber an 1–2 Engines: Einzelfall, keine Häufung
        if not level and request.args.get('all') != '1':
            continue
        rows.append({'family': f, 'ck': ck, 'name': 'ganze Familie' if ck == '*'
                     else 'nicht modulgenau (ohne CK)' if ck == '–'
                     else names.get(f'{f}|{ck}', ck),
                     'obs': O, 'exp': round(E, 2), 'exp_plain': round(E_plain, 2),
                     'engines': n_eng, 'top_share': round(top_n / O, 2) if O else 0,
                     'factor': round(factor, 2) if factor else None,
                     'p': p, 'level': level, 'base': base[c], 'base_new': base[c] == 0,
                     'slu_win': round(ex_w[f], 2), 'slu_base': round(ex_b[f], 2),
                     'weeks': [weekly[c][k] for k in range(13)],
                     'weeks_exp': [round(rate * v * (E / E_plain if E_plain else 1), 2) for v in ex_wk[f]]})
    rows.sort(key=lambda r: ({'alarm': 0, 'watch': 1, 'engine': 2}.get(r['level'], 3), r['p'], -r['obs']))
    # Datenqualität: CK00-Anteil im Fenster gegen Grundlinie — eine geänderte
    # Buchungspraxis sieht sonst aus wie eine Häufung auf CK00
    dq = []
    for f in sorted(fams):
        ow, ob = obs[(f, '*')], base[(f, '*')]
        if ow >= 10 and ob >= 10:
            sw, sb = obs[(f, 'CK00')] / ow, base[(f, 'CK00')] / ob
            if abs(sw - sb) >= 0.10:
                dq.append({'family': f, 'ck00_win': round(100 * sw), 'ck00_base': round(100 * sb)})
    return jsonify({'ref': ref.isoformat(), 'stand': _stand(X), 'window': win, 'win_from': w0.isoformat(),
                    'age_metric': age_m, 'age_metrics': [m for m in ENGINE_AGE_METRICS if m['key'] in export_age_columns()],
                    'side': side, 'sides': INC_SIDES,
                    'win_to': ref.isoformat(), 'base_from': b0.isoformat(),
                    'base_to': (w0 - datetime.timedelta(days=1)).isoformat(),
                    'weeks_from': wk0.isoformat(), 'rows': rows, 'dq': dq,
                    'rules': {'min_obs': CLUSTER_MIN_OBS, 'min_eng': CLUSTER_MIN_ENG, 'alarm_p': CLUSTER_ALARM_P,
                              'watch_p': CLUSTER_WATCH_P, 'min_factor': CLUSTER_MIN_FACTOR}})


@app.route('/api/dashboard/clusters/detail')
def api_clusters_detail():
    """Die Incidents hinter einer Häufung — mit Meldegruppe/Kategorie/Ursache-Zählung,
    damit man Logistik („Fehlt") von Technik (Befund) trennen kann."""
    X = shop_exposure()
    fam, ck = request.args.get('family') or '', request.args.get('ck') or '*'
    win = max(7, min(int(request.args.get('window') or 28), 182))
    w1 = X['ref'] + datetime.timedelta(days=1)
    w0 = w1 - datetime.timedelta(days=win)
    side = _side_arg()
    sel = [x for x in X['incidents'] if x['fam'] == fam and w0 <= x['d'] < w1 and x['cls'] != 'aus'
           and (ck == '*' or (x['ck'] or '–') == ck) and _side_ok(x, side)]
    sel.sort(key=lambda x: x['d'], reverse=True)

    def top(key):
        return Counter((x['i'].get(key) or '–') for x in sel).most_common(5)
    side_lbl = {s['key']: s['label'] for s in INC_SIDES}
    per_eng = Counter(x['project'] for x in sel)
    return jsonify({
        'n': len(sel), 'engines': len(per_eng),
        'top_engines': [{'project': p_, 'n': n_, 'esn': next((x['esn'] for x in sel if x['project'] == p_), '')}
                        for p_, n_ in per_eng.most_common(3)],
        'by_side': [(side_lbl.get(k, 'nicht zuordenbar'), n_) for k, n_ in Counter(x['side'] for x in sel).most_common()],
        'by': {'Meldegruppe': top('group'), 'Grund': top('reason'), 'Kategorie': top('category'), 'Ursache': top('cause'),
               'Kritikalität': top('criticality')},
        'by_class': Counter(x['cls'] for x in sel).most_common(),
        'incidents': [{'date': x['d'].isoformat(), 'number': x['i'].get('number'), 'project': x['project'],
                       'esn': x['esn'], 'ck': x['ck'], 'desc': (x['i'].get('description') or '')[:140],
                       'crit': x['i'].get('criticality'), 'group': x['i'].get('group'),
                       'side': side_lbl.get(x['side'], ''), 'cls': x['cls']} for x in sel[:60]]})


# ══════════════════════════════════════════════════════════════════════════════
# SLU-Bilanz: Eingang − Ausgang = Veränderung des Bestands
# Frage des Nutzers: „Warum produziert die Firma weniger SLU?" — SLU ist der
# Hauptindikator. Befund am Export 11.05.2026: Eingang steigt, SLU pro Visit
# stabil, Ausgang fällt → Stau im Haus, kein Nachfrageproblem. Diese Karte macht
# das auf einen Blick sichtbar: Brücke je Periode, Wer staut (je Familie), Wo
# stehen die offenen Engines (letzter Ist-Meilenstein), Datenleichen.
# Definitionen wie die Durchsatz-Karte: Eingang = Ist-I, Ausgang = Ist-SA.
# Datenleichen (offen ohne SA, aber nicht mehr im Shop) würden den Bestand
# künstlich wachsen lassen — am Export 233 von 435 offenen seit > 2 Jahren.
# ══════════════════════════════════════════════════════════════════════════════
BAL_STALE_MIN_DAYS = 365      # „ungewöhnlich lange offen" frühestens ab einem Jahr …
BAL_STALE_TAT_X    = 2.0      # … und ab dem Doppelten der typischen TAT der Familie
BAL_STILL_DAYS     = 180      # seit so vielen Tagen kein neuer Ist-Meilenstein
_AFTER_SA = set(MILESTONE_COLS[MILESTONE_COLS.index('SA') + 1:])
_MS_PHASES = [('Eingang', 'I'), ('Zerlegung & Befund', 'ED'), ('Teile & Reparatur', 'M'),
              ('Montage', 'B3'), ('Test', 'TE'), ('Auslieferung', MILESTONE_COLS[-1])]
_MS_EXTRA_LABELS = {'ITS': 'Incoming Test Start', 'ITE': 'Incoming Test End', 'ED': 'Engine Disassembled',
                    'S2': 'S2', 'R2': 'R2', 'CE': 'CE', 'CR': 'CR', 'ND': 'ND', 'M': 'M'}


_FEHLT_RX = re.compile(r'fehl|missing', re.I)


def _inc_fehlt(i: dict) -> bool:
    """„Fehlt" aus dem Grund-Feld („Fehlt", „Fehlt (Assy Kit)"). Nur ohne Grund der Text —
    der Text allein fängt auch „WE Haken fehlt" (SAP-Buchung, kein fehlendes Teil)."""
    r = (i.get('reason') or '').strip()
    if r:
        return r.lower().startswith('fehlt')
    return bool(_FEHLT_RX.search(f"{i.get('description') or ''} {i.get('long_text') or ''}"))
_INC_DONE = {'gelöst', 'abgeschlossen', 'resolved', 'closed', 'geschlossen', 'cancelled', 'abgebrochen'}
BS_EFFECT_DAYS = 730          # Vergleich mit/ohne Baustopp: Auslieferungen der letzten 24 Monate
BS_EFFECT_MIN  = 3            # Schicht (Familie × Klasse) braucht je Gruppe so viele Engines


def _visit_incident_stats(e: dict, ref: datetime.date) -> dict:
    """Incident-Last eines Shop-Visits zum Datenstand: gesamt, Baustopps, offene,
    offene „fehlt"-Meldungen (Text), Baustopp-Stunden (gelöst: Lösungsdauer, offen: bis Stand)."""
    st = {'n': 0, 'bs': 0, 'open': 0, 'open_bs': 0, 'open_fehlt': 0, 'bs_h': 0.0}
    for i in e.get('incidents_c3') or []:
        c = _d(i.get('created_on'))
        if not c or c > ref:
            continue
        r = _d(i.get('resolved_at'))
        is_open = (not r or r > ref) and (i.get('state') or '').strip().lower() not in _INC_DONE
        bs = (i.get('criticality') or '').strip().lower() == 'baustopp'
        st['n'] += 1
        st['bs'] += bs
        if is_open:
            st['open'] += 1
            st['open_bs'] += bs
            if _inc_fehlt(i):
                st['open_fehlt'] += 1
        if bs:
            if not is_open and i.get('resolution_h') is not None:
                st['bs_h'] += i['resolution_h']
            elif is_open:
                st['bs_h'] += (ref - c).days * 24
    return st


def _ms_phase(code: str) -> str:
    i = MILESTONE_COLS.index(code) if code in MILESTONE_COLS else -1
    for name, last in _MS_PHASES:
        if i <= MILESTONE_COLS.index(last):
            return name
    return 'Auslieferung'


def slu_balance_data() -> dict:
    """Alle Shop-Visits mit Ein-/Ausgangsdatum, SLU (gebucht oder geschätzt) und
    Datenleichen-Befund — Grundlage für Bilanz, Familien-Brücke und Bestand."""
    engines, _ = dash_data()
    ref = _d(_data_through(engines)) or datetime.date.today()
    med_ws, med_fam = defaultdict(list), defaultdict(list)
    tat = defaultdict(list)
    for e in engines.values():
        fam = _engine_family(e.get('engine_type', ''))
        if (e.get('slu') or 0) > 0:
            med_ws[(fam, e.get('workscope') or '')].append(e['slu'])
            med_fam[fam].append(e['slu'])
        a = e.get('milestones_actual') or {}
        i, sa = _d(a.get('I')), _d(a.get('SA'))
        if i and sa and sa > i and sa.year >= ref.year - 3:
            tat[fam].append((sa - i).days)
    med_ws = {k: statistics.median(v) for k, v in med_ws.items()}
    med_fam = {k: statistics.median(v) for k, v in med_fam.items()}
    tat_med = {k: statistics.median(v) for k, v in tat.items()}
    rows = []
    for e in engines.values():
        ws = e.get('workscope') or ''
        fam = _engine_family(e.get('engine_type', ''))
        if ws.startswith(DUMMY_WORKSCOPE_PREFIX) or workscope_class(fam, ws) == 'aus':
            continue
        a = e.get('milestones_actual') or {}
        din, dout = _d(a.get('I')), _d(a.get('SA'))
        in_src = 'I'
        if not din and dout:                 # ausgeliefert ohne Ist-I → TAT-Start, sonst ginge die Bilanz nicht auf
            din, in_src = _d(e.get('tat_start')), 'TAT-Start'
        if not din:
            continue                         # noch nicht eingeliefert (nur geplant)
        slu, est = e.get('slu') or 0.0, False
        if slu <= 0:
            slu, est = med_ws.get((fam, ws)) or med_fam.get(fam, 0.0), True
        r = {'project': e.get('project', ''), 'esn': e.get('esn', ''), 'family': fam, 'workscope': ws,
             'cls': workscope_class(fam, ws), 'inc': _visit_incident_stats(e, ref), 'eage': e.get('age') or {},
             'end_delay': e.get('end_delay_cd'),
             'status': e.get('status', ''), 'in': din, 'out': dout if dout and dout <= ref else None,
             'slu': slu, 'est': est, 'in_src': in_src, 'stale': '', 'last_ms': '', 'last_date': None}
        if not r['out']:
            lm = _last_actual_milestone(a)
            if lm:
                r['last_ms'], r['last_date'] = lm[0], _d(lm[1])
            age = (ref - din).days
            done_after = sorted(c for c in _AFTER_SA if a.get(c))
            if done_after:
                r['stale'] = f'spätere Meilensteine ({", ".join(done_after[:3])}) gebucht, SA fehlt'
            elif (r['status'] or '').upper() == 'CLSD':
                r['stale'] = 'Projekt geschlossen (CLSD), SA fehlt'
            elif age >= max(BAL_STALE_MIN_DAYS, BAL_STALE_TAT_X * tat_med.get(fam, 150)):
                r['stale'] = (f'{age} Tage offen — mehr als das {BAL_STALE_TAT_X:g}-fache der typischen '
                              f'TAT ({int(tat_med.get(fam, 150))} T)')
            r['age'] = age
            r['still'] = (ref - r['last_date']).days if r['last_date'] else age
        rows.append(r)
    return {'ref': ref, 'rows': rows, 'tat_med': tat_med}


AGE_BAND_MIN = 9             # so viele Werte braucht eine Familie für Terzile
AGE_BANDS = ['jung', 'mittel', 'alt']


def age_bands(rows: list, metric: str) -> dict:
    """Familie → (P33, P67, Median) des Alters-Werts. Relativ je Familie: 20 000 h sind für
    eine PW100 viel, für eine LM2500 (Industrie, Dauerbetrieb) wenig."""
    vals = defaultdict(list)
    for r in rows:
        v = (r.get('eage') or {}).get(metric)
        if v:
            vals[r['family']].append(v)
    out = {}
    for f, v in vals.items():
        if len(v) >= AGE_BAND_MIN:
            q = statistics.quantiles(sorted(v), n=3)
            out[f] = (q[0], q[1], statistics.median(v))
    return out


def age_band_of(r: dict, metric: str, cuts: dict) -> str:
    v = (r.get('eage') or {}).get(metric)
    c = cuts.get(r['family'])
    if not v or not c:
        return ''
    return 'jung' if v <= c[0] else 'mittel' if v <= c[1] else 'alt'


def _baustopp_effect(rows: list, ref: datetime.date, age_metric: str = '') -> dict:
    """Kostet ein Baustopp Zeit? Ausgelieferte Engines der letzten 24 Monate, geschichtet
    nach Familie × Workscope-Klasse (schwere Workscopes haben mehr Incidents UND längere TAT —
    ungeschichtet wäre der Vergleich verzerrt). Kennzahl: TAT-Index = TAT / Median-TAT der
    Engines OHNE Baustopp derselben Schicht; 1,0 = so lange wie vergleichbare ohne Baustopp."""
    since = ref - datetime.timedelta(days=BS_EFFECT_DAYS)
    strata = defaultdict(list)
    cuts = age_bands(rows, age_metric) if age_metric else {}
    for r in rows:
        if r['out'] and r['out'] >= since and r['in_src'] == 'I' and r['out'] > r['in']:
            if age_metric:
                band = age_band_of(r, age_metric, cuts)
                if not band:
                    continue                 # ohne Alterswert nicht vergleichbar geschichtet
                strata[(r['family'], r['cls'], band)].append(r)
            else:
                strata[(r['family'], r['cls'])].append(r)
    buckets = [('0', 0, 0), ('1', 1, 1), ('2–3', 2, 3), ('4+', 4, 10 ** 6)]
    dose = {b[0]: {'idx': [], 'slu_idx': [], 'delay': []} for b in buckets}
    table, diffs = [], []
    for key, rs in strata.items():
        fam, cls = key[0], key[1] + (f' · {key[2]}' if len(key) > 2 else '')
        no = [r for r in rs if r['inc']['bs'] == 0]
        yes = [r for r in rs if r['inc']['bs'] > 0]
        if len(no) < BS_EFFECT_MIN:
            continue
        tat0 = statistics.median((r['out'] - r['in']).days for r in no)
        # SLU-Vergleich nur mit echten SAP-Werten — geschätzte SLU (Median) verwässern ihn Richtung 1,0
        slu_no = [r['slu'] for r in no if not r['est']]
        slu0 = statistics.median(slu_no) if slu_no else None
        if tat0 <= 0:
            continue
        for r in rs:
            b = next(b[0] for b in buckets if b[1] <= r['inc']['bs'] <= b[2])
            dose[b]['idx'].append((r['out'] - r['in']).days / tat0)
            if slu0 and not r['est']:
                dose[b]['slu_idx'].append(r['slu'] / slu0)
            if r['end_delay'] is not None:
                dose[b]['delay'].append(r['end_delay'])
        if len(yes) >= BS_EFFECT_MIN:
            tat1 = statistics.median((r['out'] - r['in']).days for r in yes)
            diffs.append((tat1 - tat0, len(yes)))
            table.append({'family': fam, 'cls': cls, 'n0': len(no), 'n1': len(yes),
                          'tat0': int(tat0), 'tat1': int(tat1), 'diff': int(tat1 - tat0),
                          'bs_med': statistics.median(r['inc']['bs'] for r in yes),
                          'slu0': round(slu0 or 0, 2),
                          'slu1': round(statistics.median([r['slu'] for r in yes if not r['est']] or [0]), 2)})
    table.sort(key=lambda t: -t['n1'])
    w = sum(n for _, n in diffs)
    return {'since': since.isoformat(), 'age_metric': age_metric,
            'days_per_engine': round(sum(dd * n for dd, n in diffs) / w, 1) if w else None,
            'engines_with': w,
            'dose': [{'bucket': b, 'n': len(v['idx']),
                      'tat_idx': round(statistics.median(v['idx']), 2) if v['idx'] else None,
                      'slu_idx': round(statistics.median(v['slu_idx']), 2) if v['slu_idx'] else None,
                      'delay_med': round(statistics.median(v['delay']), 1) if v['delay'] else None}
                     for b, v in dose.items()],
            'strata': table[:12]}


@app.route('/api/dashboard/engine-age')
def api_engine_age():
    """Wirkt das Engine-Alter? Je Familie × Altersband (Terzile je Familie): ausgelieferte
    Engines der letzten 36 Monate mit SLU-, TAT- und Incident-Kennzahlen; SLU- und TAT-Index
    innerhalb derselben Familie × Workscope-Klasse (sonst misst man nur den Workscope).
    Dazu: wird der Zulauf älter? (Alters-Index des Eingangs je Jahr)"""
    cols = export_age_columns()
    metrics = [dict(m, column=cols[m['key']]) for m in ENGINE_AGE_METRICS if m['key'] in cols]
    base = {'metrics': metrics, 'searched': [m['label'] for m in ENGINE_AGE_METRICS],
            'names': {m['label']: m['names'] for m in ENGINE_AGE_METRICS}}
    if not metrics:
        return jsonify({**base, 'found': False})
    metric = request.args.get('metric') or metrics[0]['key']
    if metric not in cols:
        metric = metrics[0]['key']
    B = slu_balance_data()
    rows, ref = B['rows'], B['ref']
    cuts = age_bands(rows, metric)
    cov = {m['key']: sum(1 for r in rows if (r.get('eage') or {}).get(m['key'])) for m in metrics}
    since = ref - datetime.timedelta(days=3 * 365)
    shipped = [r for r in rows if r['out'] and r['out'] >= since and r['in_src'] == 'I' and r['out'] > r['in']]
    ref_tat, ref_slu = defaultdict(list), defaultdict(list)
    for r in shipped:
        ref_tat[(r['family'], r['cls'])].append((r['out'] - r['in']).days)
        if not r['est']:                     # geschätzte SLU = Median → würde den Index auf 1,0 ziehen
            ref_slu[(r['family'], r['cls'])].append(r['slu'])
    ref_tat = {k: statistics.median(v) for k, v in ref_tat.items()}
    ref_slu = {k: statistics.median(v) for k, v in ref_slu.items()}
    cell = defaultdict(lambda: {'n': 0, 'tat_idx': [], 'slu_idx': [], 'slu': 0.0, 'inc': 0, 'bs': 0,
                                'fehlt': 0, 'voll': 0, 'val': []})
    for r in shipped:
        band = age_band_of(r, metric, cuts)
        if not band:
            continue
        for key in ((r['family'], band), ('Alle', band)):
            c = cell[key]
            k = (r['family'], r['cls'])
            c['n'] += 1
            c['val'].append(r['eage'][metric])
            if ref_tat.get(k):
                c['tat_idx'].append((r['out'] - r['in']).days / ref_tat[k])
            if ref_slu.get(k) and not r['est']:
                c['slu_idx'].append(r['slu'] / ref_slu[k])
            c['slu'] += r['slu']
            c['inc'] += r['inc']['n']
            c['bs'] += r['inc']['bs']
            c['voll'] += r['cls'] == 'voll'
    table = []
    for (fam, band), c in cell.items():
        table.append({'family': fam, 'band': band, 'n': c['n'],
                      'val_med': round(statistics.median(c['val'])),
                      'tat_idx': round(statistics.median(c['tat_idx']), 2) if c['tat_idx'] else None,
                      'slu_idx': round(statistics.median(c['slu_idx']), 2) if c['slu_idx'] else None,
                      'inc_per_slu': round(c['inc'] / c['slu'], 1) if c['slu'] > 0 else None,
                      'bs_per_visit': round(c['bs'] / c['n'], 2), 'voll_share': round(100 * c['voll'] / c['n'])})
    fam_order = ['Alle'] + [f for f, _ in Counter(r['family'] for r in shipped if r['family'] in cuts).most_common()]
    table.sort(key=lambda t: (fam_order.index(t['family']) if t['family'] in fam_order else 99, AGE_BANDS.index(t['band'])))
    # Zulauf: Alters-Index (Wert / Familien-Median) des Eingangs je Jahr
    intake = defaultdict(list)
    for r in rows:
        v = (r.get('eage') or {}).get(metric)
        if v and r['family'] in cuts and r['in_src'] == 'I':
            intake[str(r['in'].year)].append(v / cuts[r['family']][2])
    years = sorted(intake)[-8:]
    return jsonify({**base, 'found': True, 'metric': metric, 'ref': ref.isoformat(), 'since': since.isoformat(),
                    'coverage': {'visits': len(rows), **cov},
                    'cuts': {f: [round(c[0]), round(c[1])] for f, c in cuts.items()},
                    'table': table,
                    'intake': [{'year': y, 'n': len(intake[y]), 'idx': round(statistics.median(intake[y]), 2),
                                'alt_share': round(100 * sum(1 for v in intake[y] if v > 1.25) / len(intake[y]))}
                               for y in years]})


def _period(d: datetime.date, gran: str) -> str:
    return str(d.year) if gran == 'year' else _quarter(d)


@app.route('/api/dashboard/slu-balance')
def api_slu_balance():
    B = slu_balance_data()
    ref, fam = B['ref'], request.args.get('family') or ''
    gran = 'year' if request.args.get('gran') == 'year' else 'quarter'
    n_per = max(4, min(int(request.args.get('periods') or (6 if gran == 'year' else 12)), 40))
    rows_all = B['rows']
    fams = [f for f, _ in Counter(r['family'] for r in rows_all).most_common()]
    rows = [r for r in rows_all if not fam or r['family'] == fam]
    # Perioden rückwärts ab der des Datenstands
    pers, d = [], ref
    for _ in range(n_per):
        p = _period(d, gran)
        pers.append(p)
        d = (datetime.date(int(p), 1, 1) if gran == 'year' else _quarter_bounds(p)[0]) - datetime.timedelta(days=1)
    pers.reverse()

    def bounds(p):
        return ((datetime.date(int(p), 1, 1), datetime.date(int(p) + 1, 1, 1)) if gran == 'year'
                else _quarter_bounds(p))
    out = []
    for p in pers:
        a, b = bounds(p)
        b_eff = min(b, ref + datetime.timedelta(days=1))
        x = {'p': p, 'partial': b > ref + datetime.timedelta(days=1),
             'in_n': 0, 'in_slu': 0.0, 'in_est': 0.0, 'out_n': 0, 'out_slu': 0.0, 'out_est': 0.0,
             'stock_n': 0, 'stock_slu': 0.0, 'stale_n': 0, 'stale_slu': 0.0}
        for r in rows:
            if a <= r['in'] < b_eff:
                x['in_n'] += 1
                x['in_slu'] += r['slu']
                x['in_est'] += r['slu'] if r['est'] else 0
            if r['out'] and a <= r['out'] < b_eff:
                x['out_n'] += 1
                x['out_slu'] += r['slu']
                x['out_est'] += r['slu'] if r['est'] else 0
            # Bestand am Periodenende (bzw. am Datenstand)
            if r['in'] < b_eff and (not r['out'] or r['out'] >= b_eff):
                x['stock_n'] += 1
                x['stock_slu'] += r['slu']
                if r['stale']:
                    x['stale_n'] += 1
                    x['stale_slu'] += r['slu']
        for k in ('in_slu', 'in_est', 'out_slu', 'out_est', 'stock_slu', 'stale_slu'):
            x[k] = round(x[k], 2)
        x['delta'] = round(x['in_slu'] - x['out_slu'], 2)
        out.append(x)

    # Wer staut: letzte 12 Monate gegen die 12 davor, je Familie
    y1 = ref + datetime.timedelta(days=1)
    y0 = y1 - datetime.timedelta(days=365)
    yp = y0 - datetime.timedelta(days=365)
    fam_rows = defaultdict(lambda: {'in': 0.0, 'out': 0.0, 'out_prev': 0.0, 'in_prev': 0.0,
                                    'stock_n': 0, 'stock_slu': 0.0, 'stale_n': 0, 'stale_slu': 0.0,
                                    'ages': [], 'out_n': 0, 'out_n_prev': 0})
    for r in rows_all:
        f = fam_rows[r['family']]
        if y0 <= r['in'] < y1:
            f['in'] += r['slu']
        elif yp <= r['in'] < y0:
            f['in_prev'] += r['slu']
        if r['out']:
            if y0 <= r['out'] < y1:
                f['out'] += r['slu']
                f['out_n'] += 1
            elif yp <= r['out'] < y0:
                f['out_prev'] += r['slu']
                f['out_n_prev'] += 1
        else:
            if r['stale']:
                f['stale_n'] += 1
                f['stale_slu'] += r['slu']
            else:
                f['stock_n'] += 1
                f['stock_slu'] += r['slu']
                f['ages'].append(r['age'])
    who = []
    for f, v in fam_rows.items():
        if max(v['in'], v['out'], v['out_prev'], v['stock_slu']) < 0.5:
            continue
        who.append({'family': f, 'in': round(v['in'], 1), 'out': round(v['out'], 1),
                    'delta': round(v['in'] - v['out'], 1), 'in_prev': round(v['in_prev'], 1),
                    'out_prev': round(v['out_prev'], 1), 'out_change': round(v['out'] - v['out_prev'], 1),
                    'out_n': v['out_n'], 'out_n_prev': v['out_n_prev'],
                    'stock_n': v['stock_n'], 'stock_slu': round(v['stock_slu'], 1),
                    'stale_n': v['stale_n'], 'stale_slu': round(v['stale_slu'], 1),
                    'age_med': int(statistics.median(v['ages'])) if v['ages'] else None,
                    'tat_med': int(B['tat_med'].get(f, 0)) or None})
    who.sort(key=lambda x: x['out_change'])

    # Wo stehen die offenen (bereinigten) Engines?
    ms = defaultdict(lambda: {'n': 0, 'slu': 0.0, 'still': [], 'fams': Counter(), 'inc_eng': 0, 'open': 0,
                              'open_bs_eng': 0, 'open_fehlt_eng': 0, 'bs_h': [], 'with_inc': 0})
    for r in rows:
        if r['out'] or r['stale']:
            continue
        m_ = ms[r['last_ms'] or '–']
        m_['n'] += 1
        ic = r['inc']
        m_['with_inc'] += ic['n'] > 0
        m_['open'] += ic['open']
        m_['inc_eng'] += ic['open'] > 0
        m_['open_bs_eng'] += ic['open_bs'] > 0
        m_['open_fehlt_eng'] += ic['open_fehlt'] > 0
        if ic['bs']:
            m_['bs_h'].append(ic['bs_h'])
        m_['slu'] += r['slu']
        m_['still'].append(r['still'])
        m_['fams'][r['family']] += 1
    order = {c: i for i, c in enumerate(MILESTONE_COLS)}
    by_ms = [{'ms': k, 'label': MILESTONE_LABELS.get(k) or _MS_EXTRA_LABELS.get(k) or k,
              'phase': _ms_phase(k) if k != '–' else 'ohne Ist-Meilenstein',
              'n': v['n'], 'slu': round(v['slu'], 2), 'still_med': int(statistics.median(v['still'])),
              'still_long': sum(1 for s in v['still'] if s >= BAL_STILL_DAYS),
              'fams': v['fams'].most_common(3),
              'with_inc': v['with_inc'], 'open': v['open'], 'open_eng': v['inc_eng'],
              'open_bs_eng': v['open_bs_eng'], 'open_fehlt_eng': v['open_fehlt_eng'],
              'bs_h_med': round(statistics.median(v['bs_h'])) if v['bs_h'] else None}
             for k, v in ms.items()]
    by_ms.sort(key=lambda x: order.get(x['ms'], -1))
    age_m = request.args.get('age') or ''
    bs_effect = _baustopp_effect(rows, ref, age_m if age_m in {m['key'] for m in ENGINE_AGE_METRICS} else '')
    stale = sorted((r for r in rows if not r['out'] and r['stale']), key=lambda r: -r['slu'])
    reasons = Counter(r['stale'].split(' ')[0] if r['stale'].startswith('spätere') else
                      'CLSD' if 'CLSD' in r['stale'] else 'alt' for r in stale)
    return jsonify({
        'ref': ref.isoformat(), 'gran': gran, 'family': fam, 'families': fams, 'periods': out,
        'window': {'from': y0.isoformat(), 'to': ref.isoformat(), 'prev_from': yp.isoformat()},
        'who': who, 'by_ms': by_ms, 'bs_effect': bs_effect,
        'inc_coverage': {'visits': len(rows), 'with_inc': sum(1 for r in rows if r['inc']['n'])},
        'stale': {'n': len(stale), 'slu': round(sum(r['slu'] for r in stale), 1),
                  'nach_sa': reasons.get('spätere', 0), 'clsd': reasons.get('CLSD', 0), 'alt': reasons.get('alt', 0),
                  'list': [{'project': r['project'], 'esn': r['esn'], 'family': r['family'], 'workscope': r['workscope'],
                            'in': r['in'].isoformat(), 'last_ms': r['last_ms'],
                            'last_date': r['last_date'].isoformat() if r['last_date'] else '',
                            'slu': round(r['slu'], 2), 'est': r['est'], 'why': r['stale']} for r in stale[:300]]},
        'est_share': round(100 * sum(r['slu'] for r in rows if r['est']) / max(1e-9, sum(r['slu'] for r in rows)), 1),
        'rules': {'stale_min_days': BAL_STALE_MIN_DAYS, 'stale_tat_x': BAL_STALE_TAT_X, 'still_days': BAL_STILL_DAYS}})


WSCLASS_HTML = """<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title>SAP Claim Analyzer – Workscope-Klassen</title>
<!--SAP_BASE-->
<style>
  *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
  
  body { font-family: var(--font); background: var(--bg); color: var(--text); font-size: 13px; }





  .sub { color: var(--muted); line-height: 1.55; max-width: 980px; }
  .sap-page .sap-sec .sap-sec-head { top: calc(var(--ph-stick, 0px) + 72px); }   /* unter Kopf + Filterleiste */
  .sap-sec-head .w { color: var(--warn); font-weight: 600; }
  .sap-sec-ctl .btn { margin-left: 0; }
  .tblbox { background: var(--surface); border: 1px solid var(--line); border-radius: var(--r-m); overflow-x: auto; }
  .sum { display: flex; gap: 1px; flex-wrap: wrap; margin-bottom: 18px; width: fit-content; max-width: 100%;
         background: var(--line); border: 1px solid var(--line); border-radius: var(--r-m); overflow: hidden; }
  .kpi { background: var(--surface); padding: 10px 16px; flex: 1 1 auto; }
  .kpi b { font-size: 20px; font-weight: 500; letter-spacing: -.02em; display: block; font-variant-numeric: tabular-nums; }
  .kpi span { color: var(--muted); font-size: 11px; }
  .kpi i { display: inline-block; width: 9px; height: 9px; border-radius: var(--r-s); margin-right: 5px; }
  .bar { display: flex; gap: 10px; align-items: center; flex-wrap: wrap;
         position: sticky; top: var(--ph-stick, 0px); background: var(--bg); padding: 10px 0;
         border-top: 1px solid var(--line-2); border-bottom: 1px solid var(--line); z-index: 5; }
  select, input[type=text] { padding: 6px 9px; font-size: 13px; border: 1px solid var(--border); border-radius: var(--r-s); }
  label.chk { display: flex; gap: 5px; align-items: center; color: var(--ink); font-size: 13px; }
  .card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--r-m); margin-bottom: 16px; }
  .card .hd { display: flex; gap: 14px; align-items: baseline; padding: 11px 14px; border-bottom: 1px solid var(--border);
              flex-wrap: wrap; }
  .card .hd h3 { font-size: 15px; }
  .card .hd .m { color: var(--muted); font-size: 13px; }
  .card .hd .w { color: var(--warn); font-size: 13px; font-weight: 600; }
  .btn { background: var(--surface); border: 1px solid var(--border); color: var(--text); border-radius: var(--r-pill);
         padding: 4px 11px; font-size: 13px; cursor: pointer; margin-left: auto; }
  .btn:hover { background: var(--bg); }
  table { border-collapse: collapse; width: 100%; }
  th { text-align: left; font-size: 11px; text-transform: uppercase; letter-spacing: .5px; color: var(--muted);
       font-weight: 600; padding: 7px 10px; border-bottom: 1px solid var(--border); white-space: nowrap; }
  td { padding: 6px 10px; border-top: 1px solid var(--hover); vertical-align: middle; }
  tr.aus td { color: var(--ink-3); }
  td.num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
  .wsn { color: var(--muted); font-size: 11px; margin-right: 4px; }
  .wst { font-weight: 600; }
  .types { color: var(--muted); font-size: 11px; max-width: 170px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
  .small { color: var(--muted); font-size: 11px; }
  .idx { position: relative; width: 150px; height: 14px; background: var(--hover); border-radius: var(--r-s); }
  .idx .f { position: absolute; left: 0; top: 0; bottom: 0; border-radius: var(--r-s); }
  .idx .t { position: absolute; top: -3px; bottom: -3px; width: 0; border-left: 2px solid var(--ink); left: 66.7%; }
  .lvl { font-size: 11px; color: var(--muted); margin-left: 6px; }
  .seg { display: inline-flex; border: 1px solid var(--border); border-radius: var(--r-pill); overflow: hidden; }
  .seg button { border: 0; background: var(--surface); padding: 3px 9px; font-size: 11px; cursor: pointer; color: var(--ink-2);
                white-space: nowrap;
                border-right: 1px solid var(--border); }
  .seg button:last-child { border-right: 0; }
  .seg button:hover { background: var(--hover); }
  .seg button.on { color: var(--surface); font-weight: 600; }
  .seg.guess button.on { opacity: .55; }
  .src { font-size: 11px; white-space: nowrap; }
  .src.ok { color: var(--ok); font-weight: 600; }
  .src.vs { color: var(--muted); font-style: italic; }
  .warn { color: var(--warn); font-size: 11px; min-width: 240px; }
  a.lnk { color: var(--muted); font-size: 11px; cursor: pointer; text-decoration: underline; margin-left: 6px; }
  .empty { padding: 30px; color: var(--muted); }
</style>
<!--SAP_SHELL-->
</head>
<body>
<!--SAP_RAIL-->
<header class="sap-ph"><!--SAP_PH-->
<div class="ph-right">
<!--SAP_MARK-->
</div>
</header>
<div class="sap-page">
<section class="sap-sec">
 <div class="sap-sec-head">
  <span class="eyebrow">Überblick</span>
  <h2>Workscope-Klassen</h2>
  <p>Je Familie nach dem Text eingeordnet, über die SLU gegengeprüft. Widersprüche sind markiert.</p>
 </div>
 <div class="sap-sec-body">
  <p class="sub">Der Workscope im ZLSGS-Export ist der <b>beim Auftrag vergebene</b> (Kundenbetreuer) — was im Shop
    tatsächlich daraus wird, steht nur in SAP/WRB Online. Die Nummern sind je Engine-Typ anders belegt, deshalb
    wird hier <b>je Familie nach dem Text</b> eingeordnet. Zweite Spur ist die <b>SLU</b> (1 SLU = 3 000 h Arbeit),
    <b>relativ zur eigenen Familie</b>: SLU-Index 1,0 = typische Arbeit eines Voll-Workscopes dieser Familie
    (schwarzer Strich). Widersprüche zwischen Text und SLU sind markiert, nicht automatisch aufgelöst.
    Grundlage für die Mix-Auswertung und die Häufungs-Erkennung.</p>
  <div class="sum" id="sum"></div>
 </div>
</section>
  <div class="bar">
    <select id="fam" onchange="render()"><option value="">alle Familien</option></select>
    <input type="text" id="q" placeholder="Workscope-Text filtern …" oninput="render()">
    <label class="chk"><input type="checkbox" id="onlyw" onchange="render()"> nur Widersprüche / offen</label>
    <label class="chk"><input type="checkbox" id="onlyv" onchange="render()"> nur ungeprüfte</label>
    <label class="chk" title="in den letzten 2 Jahren eingeliefert"><input type="checkbox" id="onlya" onchange="render()"> nur aktive</label>
  </div>
  <div id="out"><div class="empty">Lade …</div></div>
</div>
<script>
let D = null;
const COL = {voll: 'var(--voll)', modular: 'var(--modular)', befund: 'var(--befund)', aus: 'var(--aus)'};
const LVL = {schwer: '#7a2e12', mittel: '#c8671c', leicht: '#e9b77a'};
function esc(s) { return String(s == null ? '' : s).replace(/[&<>"']/g,
  c => ({'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'}[c])); }
function fmt(n, d) { return n == null ? '–' : Number(n).toLocaleString('de-DE', {minimumFractionDigits: d || 0, maximumFractionDigits: d || 0}); }

async function load() {
  const keep = window.scrollY;
  D = await (await fetch('/api/workscopes')).json();
  if (D.error) { document.getElementById('out').innerHTML = '<div class="empty">&#9651; ' + esc(D.error) + '</div>'; return; }
  const sel = document.getElementById('fam'), cur = sel.value;
  sel.innerHTML = '<option value="">alle Familien</option>'
    + D.families.map(f => `<option ${f.family === cur ? 'selected' : ''} value="${esc(f.family)}">${esc(f.family)} (${f.n})</option>`).join('');
  const tot = Object.values(D.shop_visits).reduce((a, b) => a + b, 0) || 1;
  const rows = D.families.flatMap(f => f.rows);
  document.getElementById('sum').innerHTML = D.classes.map(c =>
      `<div class="kpi" title="${esc(c.hint)}"><b>${fmt(D.shop_visits[c.key] || 0)}</b>`
      + `<span><i style="background:${COL[c.key]}"></i>${esc(c.label)} · ${fmt(100 * (D.shop_visits[c.key] || 0) / tot)} % der Shop-Visits</span></div>`).join('')
    + (D.shop_visits.offen ? `<div class="kpi"><b>${fmt(D.shop_visits.offen)}</b><span>nicht eingeordnet</span></div>` : '')
    + `<div class="kpi"><b>${fmt(rows.filter(r => r.source === 'geprüft').length)} / ${fmt(rows.length)}</b><span>Workscopes geprüft · Datei ${esc(D.datei)}</span></div>`
    + `<div class="kpi"><b style="color:var(--warn)">${fmt(rows.filter(r => r.warn.length).length)}</b><span>Widersprüche / offen</span></div>`;
  render();
  window.scrollTo(0, keep);
}

function render() {
  const fam = document.getElementById('fam').value, q = document.getElementById('q').value.trim().toUpperCase();
  const ow = document.getElementById('onlyw').checked, ov = document.getElementById('onlyv').checked,
        oa = document.getElementById('onlya').checked;
  let html = '';
  D.families.forEach(f => {
    if (fam && f.family !== fam) return;
    const rows = f.rows.filter(r => (!q || r.workscope.toUpperCase().includes(q)) && (!ow || r.warn.length)
                                 && (!ov || r.source === 'Vorschlag') && (!oa || r.active));
    if (!rows.length) return;
    html += `<section class="sap-sec"><div class="sap-sec-head"><span class="eyebrow">Familie</span><h2>${esc(f.family)}</h2>`
      + `<p>${fmt(f.n)} Shop-Visits<br><span title="${esc(f.ref_basis)}">Bezug: ${f.ref_slu == null ? 'keine SLU' : fmt(f.ref_slu, 2) + ' SLU ≙ Index 1,0'}</span>`
      + (f.warn ? `<br><span class="w">${f.warn} Widersprüche / offen</span>` : '') + '</p>'
      + (f.offen ? `<div class="sap-sec-ctl"><button class="btn sm" onclick="acceptAll('${esc(f.family)}')" title="Alle eingeordneten Vorschläge dieser Familie als geprüft speichern">${f.offen} Vorschläge übernehmen</button></div>` : '')
      + `</div><div class="sap-sec-body"><div class="tblbox"><table><thead><tr><th>Workscope</th><th>Engine-Typen</th><th style="text-align:right">Shop-Visits</th>`
      + `<th>Zeitraum</th><th style="text-align:right">SLU Median</th><th>SLU-Index</th><th>Klasse</th><th>Quelle</th><th>Hinweis</th></tr></thead><tbody>`
      + rows.map(r => row(f, r)).join('') + '</tbody></table></div></div></section>';
  });
  document.getElementById('out').innerHTML = html || '<div class="empty">Keine Workscopes für diese Auswahl.</div>';
}

function row(f, r) {
  const num = (r.workscope.match(/^\\s*(WS\\s*\\d+)/) || [])[1] || '';
  const idx = r.slu_index;
  const bar = idx == null ? `<span class="small">${r.n_slu ? 'zu wenig SLU-Werte' : 'keine SLU'}</span>`
    : `<div style="display:flex;align-items:center"><div class="idx" title="SLU-Index ${fmt(idx, 2)}">`
      + `<div class="f" style="width:${Math.min(idx, 1.5) / 1.5 * 100}%;background:${LVL[r.slu_level]}"></div><div class="t"></div></div>`
      + `<span class="lvl">${fmt(idx, 2)} · ${r.slu_level}</span></div>`;
  const seg = `<span class="seg${r.source === 'Vorschlag' ? ' guess' : ''}">` + D.classes.map(c =>
      `<button class="${r.cls === c.key ? 'on' : ''}" style="${r.cls === c.key ? 'background:' + COL[c.key] : ''}" title="${esc(c.hint)}"`
      + ` onclick="setCls('${esc(f.family)}', ${JSON.stringify(r.workscope).replace(/"/g, '&quot;')}, '${c.key}')">${esc(c.label)}</button>`).join('') + '</span>';
  const src = r.source === 'geprüft'
    ? `<span class="src ok" title="${esc(r.ts)}${r.note ? ' — ' + esc(r.note) : ''}">&#10003; geprüft</span>`
      + `<a class="lnk" onclick="resetCls('${esc(f.family)}', ${JSON.stringify(r.workscope).replace(/"/g, '&quot;')})">zurücksetzen</a>`
    : `<span class="src vs" title="${esc(r.why)}">Vorschlag · ${esc(r.why)}</span>`;
  const note = `<a class="lnk" onclick="editNote('${esc(f.family)}', ${JSON.stringify(r.workscope).replace(/"/g, '&quot;')})" title="${esc(r.note || 'Notiz hinzufügen')}">${r.note ? '&#9998; Notiz' : '&#9998;'}</a>`;
  return `<tr class="${r.cls === 'aus' ? 'aus' : ''}">`
    + `<td><span class="wsn">${esc(num)}</span><span class="wst">${esc(r.text || '(leer)')}</span></td>`
    + `<td class="types" title="${esc(r.types.join(', '))}">${esc(r.types.join(', '))}</td>`
    + `<td class="num">${fmt(r.n)}<div class="small">${r.active ? fmt(r.active) + ' aktiv' : 'nicht aktiv'}</div></td>`
    + `<td class="small">${r.years ? r.years[0] + (r.years[1] !== r.years[0] ? '–' + r.years[1] : '') : '–'}</td>`
    + `<td class="num">${r.slu_med == null ? '–' : fmt(r.slu_med, 2)}`
    + `<div class="small">${r.slu_med == null ? '' : fmt(r.slu_p25, 2) + '–' + fmt(r.slu_p75, 2)}${r.slu_leer ? ' · ' + r.slu_leer + ' ohne' : ''}</div></td>`
    + `<td>${bar}</td><td>${seg}</td><td>${src}${note}</td>`
    + `<td class="warn">${r.warn.map(esc).join('<br>')}</td></tr>`;
}

async function post(body) {
  const r = await (await fetch('/api/workscopes/class', {method: 'POST', headers: {'Content-Type': 'application/json'},
                                                         body: JSON.stringify(body)})).json();
  if (r.error) alert(r.error);
  await load();
}
function setCls(family, workscope, cls) { post({family, workscope, cls}); }
function resetCls(family, workscope) { post({family, workscope, reset: true}); }
function editNote(family, workscope) {
  const r = D.families.find(f => f.family === family).rows.find(x => x.workscope === workscope);
  const note = prompt('Notiz zu ' + family + ' · ' + (workscope || '(leer)') + ':', r.note || '');
  if (note === null) return;
  if (!r.cls) { alert('Erst eine Klasse wählen.'); return; }
  post({family, workscope, cls: r.cls, note});
}
async function acceptAll(family) {
  if (!confirm('Alle eingeordneten Vorschläge von ' + family + ' als geprüft speichern?')) return;
  post({family, accept: true});
}
load();
</script>
</body>
</html>
"""


@app.route('/workscopes')
def workscopes_page():
    return WSCLASS_HTML


SETTINGS_HTML = """<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title>SAP Claim Analyzer – Einstellungen</title>
<!--SAP_BASE-->
<style>
  *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
  
  body { font-family: var(--font); background: var(--bg); color: var(--text); font-size: 13px; }






  .field-wrap { max-width: 760px; }
  h2 { font-size: 20px; font-weight: 500; letter-spacing: -.01em; margin-bottom: 6px; }
  .subtitle { color: var(--muted); margin-bottom: 32px; }
  .field { margin-bottom: 20px; }
  .field:last-child { margin-bottom: 0; }
  label { display: block; font-weight: 600; margin-bottom: 6px; }
  .field-desc { color: var(--muted); font-size: 13px; margin-bottom: 6px; }
  .input-row { display: flex; gap: 8px; align-items: center; }
  input[type=text] { flex: 1; padding: 8px 10px; border: 1px solid var(--border);
                     border-radius: var(--r-s); font-size: 13px; font-family: var(--mono);
                     background: var(--surface); color: var(--text); }
  input[type=text]:focus { outline: none; border-color: var(--acc); }
  .badge { font-size: 11px; font-weight: 700; padding: 3px 8px; border-radius: var(--r-m);
           white-space: nowrap; }
  .badge-ok  { background: var(--ok-bg); color: var(--green); }
  .badge-err { background: var(--crit-bg); color: var(--red); }
  .actions { display: flex; gap: 12px; align-items: center; }
  button.primary { background: var(--acc); color: var(--on-acc); border: 1px solid var(--acc); padding: 9px 20px;
                   border-radius: var(--r-s); font-size: 13px; font-weight: 500; cursor: pointer; }
  button.primary:hover { background: #3a3835; border-color: #3a3835; }
  button.secondary { background: var(--surface); color: var(--ink); border: 1px solid var(--line-2);
                     padding: 9px 18px; border-radius: var(--r-s); font-size: 13px; cursor: pointer; }
  button.secondary:hover { background: var(--hover); border-color: var(--ink-3); }
  #msg { font-size: 13px; font-weight: 600; }
  .msg-ok  { color: var(--green); }
  .msg-err { color: var(--red); }
  .divider { border: none; border-top: 1px solid var(--border); margin: 24px 0; }
</style>
<!--SAP_SHELL-->
</head>
<body>
<!--SAP_RAIL-->
<header class="sap-ph"><!--SAP_PH-->
<div class="ph-right">
<!--SAP_MARK-->
</div>
</header>

<div class="sap-page">
<section class="sap-sec">
 <div class="sap-sec-head">
  <span class="eyebrow">Quellen</span>
  <h2>SAP-Exporte</h2>
  <p>Meilensteine und P7-Claims kommen als CSV aus ZLSGS. Pfade auf dem Netzlaufwerk oder lokal.</p>
 </div>
 <div class="sap-sec-body field-wrap">
  <div class="field">
    <label>ZLSGS Dataexport (Milestones)</label>
    <p class="field-desc">ZLSGS_Dataexport.csv — Enthält ZMMT/ZMMP Meilensteine pro Projekt</p>
    <div class="input-row">
      <input type="text" id="export_path" placeholder="/pfad/zur/ZLSGS_Dataexport.csv">
      <span class="badge" id="badge_export_path">–</span>
    </div>
  </div>

  <div class="field">
    <label>ZLSGS Claimexport (P7 Claims)</label>
    <p class="field-desc">zlsgs_claimexport.csv — Enthält Claim-Benachrichtigungen (P7 Milestone Shifts)</p>
    <div class="input-row">
      <input type="text" id="claim_path" placeholder="/pfad/zur/zlsgs_claimexport.csv">
      <span class="badge" id="badge_claim_path">–</span>
    </div>
  </div>

</div>
</section>

<section class="sap-sec">
 <div class="sap-sec-head">
  <span class="eyebrow">Quellen</span>
  <h2>Shop Incidents — MTU Hub (SQL)</h2>
  <p>Störungsmeldungen kommen aus <code>msql-lud-core</code>, nicht mehr aus einem CSV-Export.
    Ist die Datenbank nicht erreichbar, startet der Analyzer mit dem zuletzt geladenen Stand.</p>
 </div>
 <div class="sap-sec-body field-wrap">

  <div class="field">
    <label>Datenbank</label>
    <p class="field-desc">Leer lassen, um den Fund aus dem SQL-Explorer zu nutzen
      („Incident-Tabelle finden" auf der Seite <a href="/sql">MTU Hub SQL</a>).</p>
    <div class="input-row">
      <input type="text" id="incident_database" placeholder="z. B. MTUHub_Prod">
      <span class="badge" id="badge_incident_database">–</span>
    </div>
  </div>

  <div class="field">
    <label>Tabelle</label>
    <p class="field-desc">Leer lassen: die Tabelle wird im Schema gesucht
      (Hilfstabellen wie <code>…_tmp</code> werden dabei übersprungen).
      Sonst Schema und Name angeben, z. B. <code>MTUhub.x_mtuae_ins_shop_incident</code>.</p>
    <div class="input-row">
      <input type="text" id="incident_table" placeholder="automatisch suchen">
      <span class="badge" id="badge_incident_table">–</span>
    </div>
  </div>

  <div class="field">
    <label>Zyklus-Filter</label>
    <p class="field-desc">Wird auf <code>cycle_of_wbs</code> angewandt. <code>Cycle 3</code>
      entspricht dem bisherigen CSV-Export; leer lädt alle Zyklen.</p>
    <div class="input-row">
      <input type="text" id="incident_cycle" placeholder="Cycle 3">
    </div>
  </div>

  <div class="field">
    <div class="input-row">
      <button class="secondary" onclick="diag()">Incident-Quelle prüfen</button>
      <span id="diagmsg" style="font-size:13px;color:var(--muted)"></span>
    </div>
    <pre id="diagbox" style="display:none;margin-top:10px;background:var(--hover);border:1px solid var(--border);border-radius:var(--r-s);padding:10px 12px;font-size:11px;line-height:1.5;overflow:auto;max-height:420px;white-space:pre-wrap"></pre>
  </div>

</div>
</section>

<section class="sap-sec">
 <div class="sap-sec-head">
  <span class="eyebrow">Speichern</span>
  <h2>Übernehmen</h2>
  <p>Änderungen werden in <code>sap_config.json</code> gespeichert und sofort aktiv.
    Danach lädt der Analyzer die Daten neu.</p>
 </div>
 <div class="sap-sec-body">
  <div class="actions">
    <button class="primary" onclick="save()">Speichern & Daten neu laden</button>
    <button class="secondary" onclick="load()">Zurücksetzen</button>
    <span id="msg"></span>
  </div>
 </div>
</section>
</div>

<script>
const PATHS  = ['export_path', 'claim_path'];
const VALUES = ['incident_database', 'incident_table', 'incident_cycle'];
const FIELDS = PATHS.concat(VALUES);

function setBadge(key, exists) {
  const el = document.getElementById('badge_' + key);
  if (!el) return;
  if (exists === null) { el.textContent = '–'; el.className = 'badge'; return; }
  el.textContent = exists ? 'Datei gefunden' : 'Nicht gefunden';
  el.className   = 'badge ' + (exists ? 'badge-ok' : 'badge-err');
}

function setIncBadge(meta, db) {
  const el = document.getElementById('badge_incident_database');
  if (!el) return;
  if (!db)            { el.textContent = 'nicht gesetzt'; el.className = 'badge badge-err'; return; }
  if (meta && meta.pending) { el.textContent = 'noch nicht geladen'; el.className = 'badge'; return; }
  if (meta && meta.ok){ el.textContent = meta.joined + ' Incidents geladen';
                        el.className = 'badge badge-ok'; return; }
  if (meta && meta.stale) { el.textContent = 'Snapshot vom ' + (meta.ts || '?').slice(0, 10);
                            el.className = 'badge badge-err'; return; }
  el.textContent = 'keine Verbindung'; el.className = 'badge badge-err';
}

async function load() {
  const res  = await fetch('/api/settings');
  const data = await res.json();
  PATHS.forEach(k => {
    document.getElementById(k).value = data[k] ? data[k].path : '';
    setBadge(k, data[k] ? data[k].exists : null);
  });
  VALUES.forEach(k => {
    document.getElementById(k).value = data[k] ? (data[k].value || '') : '';
  });
  const inc = data._incidents || {};
  if (!document.getElementById('incident_database').value && inc.database) {
    document.getElementById('incident_database').placeholder =
      'aus SQL-Explorer: ' + inc.database;
  }
  const tb = document.getElementById('badge_incident_table');
  if (inc.resolved) { tb.textContent = 'nutzt ' + inc.resolved; tb.className = 'badge badge-ok'; }
  else              { tb.textContent = 'nicht aufgelöst';       tb.className = 'badge'; }
  setIncBadge(inc.meta, inc.database);
  document.getElementById('msg').textContent = '';
}

async function diag() {
  const msg = document.getElementById('diagmsg'), box = document.getElementById('diagbox');
  msg.textContent = 'prüfe…'; box.style.display = 'none';
  try {
    const d = await (await fetch('/api/incidents/diag')).json();
    const m = d.meta || {};
    const lines = [];
    lines.push('Quelle       : ' + (m.database || '?') + ' · ' + (m.table || '?')
               + (m.cycle ? ' · Filter ' + m.cycle : ' · alle Zyklen'));
    lines.push('Status       : ' + (m.ok ? 'live geladen' : (m.stale
               ? 'VERALTETER SNAPSHOT vom ' + (m.ts || '?') : 'FEHLER')));
    if (m.error) lines.push('Fehler       : ' + m.error);
    lines.push('Zeilen       : ' + d.incidents + ' Incidents in ' + d.projekte + ' Projekten'
               + (m.unjoined ? '  (' + m.unjoined + ' ohne verwertbare WBS)' : ''));
    lines.push('ohne Engine  : ' + d.projekte_ohne_engine + ' Projekte (keine Zeile im SAP-Dataexport)');
    lines.push('Lösungszeit  : ' + d.mit_loesungszeit + ' mit Wert');
    lines.push('');
    lines.push('Kritikalität : ' + JSON.stringify(d.kritikalitaet));
    if ((d.warnung_kritikalitaet || []).length)
      lines.push('  ⚠ unbekannte Werte — der Code zählt nur Baustopp/Kritisch/Nicht kritisch: '
                 + d.warnung_kritikalitaet.join(', '));
    lines.push('Status-Feld  : ' + JSON.stringify(d.status));
    lines.push('Zyklen       : ' + JSON.stringify(d.zyklen));
    lines.push('Jahre        : ' + JSON.stringify(d.jahre));
    lines.push('Gruppen (Top 10):');
    (d.gruppen_top10 || []).forEach(([g, n]) =>
      lines.push('  ' + String(n).padStart(6) + '  ' + g));
    lines.push('');
    lines.push('Spalten-Zuordnung:');
    Object.entries(d.spalten_mapping || {}).forEach(([k, v]) =>
      lines.push('  ' + k.padEnd(13) + '→ ' + v));
    box.textContent = lines.join('\\n');
    box.style.display = 'block';
    msg.textContent = '';
    setIncBadge(m, m.database);      // Diagnose hat die Quelle gerade geladen
  } catch (e) {
    msg.textContent = 'Fehler: ' + e.message;
  }
}

async function save() {
  const msg = document.getElementById('msg');
  msg.textContent = 'Speichern…';
  msg.className   = '';
  const payload = {};
  FIELDS.forEach(k => { payload[k] = document.getElementById(k).value.trim(); });
  try {
    const res  = await fetch('/api/settings', {
      method:  'POST',
      headers: {'Content-Type': 'application/json'},
      body:    JSON.stringify(payload),
    });
    const data = await res.json();
    if (data.ok) {
      PATHS.forEach(k => {
        const s = data.settings[k];
        if (s) setBadge(k, s.exists);
      });
      const inc = data.settings._incidents || {};
      setIncBadge(inc.meta, inc.database);
      msg.textContent = 'Gespeichert. Daten werden neu geladen…';
      msg.className   = 'msg-ok';
      // trigger reload so cache is refreshed
      await fetch('/api/reload', { method: 'POST' });
      msg.textContent = 'Gespeichert und Daten neu geladen.';
    } else {
      msg.textContent = 'Fehler beim Speichern.';
      msg.className   = 'msg-err';
    }
  } catch (e) {
    msg.textContent = 'Netzwerkfehler: ' + e.message;
    msg.className   = 'msg-err';
  }
}

load();
</script>
</body>
</html>
"""


@app.route('/settings')
def settings_page():
    return SETTINGS_HTML



# ══════════════════════════════════════════════════════════════════════════════
# 07 Priorisierung — offene Incidents nach Dringlichkeit (Mockup:
# https://claude.ai/artifact/4YnAt5svtSWfTsbfe3tQx5)
# Backend liefert je offenem Incident die Rohbausteine (Kritikalität, Tage bis
# Plan-SA, Cycle der Engine, Häufung des Moduls, Alter); den Prioritätswert
# rechnet die Seite, damit die Gewichte live verändert werden können.
# Feste Regel: Baustopp steht immer oben (Entscheidung User 2026-09-27).
# ══════════════════════════════════════════════════════════════════════════════
TRIAGE_HOT_DAYS  = 14      # Häufung: Meldungen der letzten 14 Tage …
TRIAGE_BASE_DAYS = 90      # … gegen den Schnitt der 90 Tage davor
TRIAGE_HOT_MIN   = 3       # ab so vielen Meldungen in 14 Tagen
TRIAGE_HOT_RATIO = 2.5     # und so viel mehr als üblich
TRIAGE_CYCLES = ['Cycle 0 · Queue', 'Cycle 1 · Demontage & Inspektion', 'Cycle 2 · Material & Dispo',
                 'Cycle 3 · Aufbau & Test', 'Nach SA']
_TRIAGE_WBS = re.compile(r'^(.+?)\.(\d{2})\.(\d{2})$')


def _triage_cycle(actual: dict):
    """Cycle der Engine aus dem letzten Ist-Meilenstein (Grenzen wie CYCLE_DEFS: I, #, R, SA)."""
    lm = _last_actual_milestone(actual or {})
    if not lm:
        return 0
    i = MILESTONE_COLS.index(lm[0])
    if i >= MILESTONE_COLS.index('SA'):
        return 4
    if i >= MILESTONE_COLS.index('R'):
        return 3
    if i >= MILESTONE_COLS.index('#'):
        return 2
    if i >= MILESTONE_COLS.index('I'):
        return 1
    return 0


def triage_data() -> dict:
    cache = _get_cache()
    engines, aliases = cache['engines'], cache['aliases']
    today = datetime.date.today()
    through = _data_through(engines)
    cat = None
    try:
        cat = module_catalog(build=False)
    except Exception:
        cat = None
    mod_name, og_name = {}, {}
    for m in (cat or {}).get('modules', []):
        if not m.get('template'):
            mod_name[(m.get('family'), m.get('ck'))] = re.sub(r'^[^:]*:\s*', '', m.get('name') or '')
    for fam, ogs in ((cat or {}).get('obergruppen') or {}).items():
        for g in ogs:
            head = (g.get('head') or {}).get('name') or ''
            og_name[(fam, g.get('og'))] = re.sub(r'^[^:]*:\s*', '', head)

    # alle Incidents (offen + gelöst) für die Häufung, je Familie × CK
    seen, alli = set(), []
    for proj, lst in cache['incidents'].items():
        main = aliases.get(proj, proj)
        for i in lst:
            if i.get('number') in seen:
                continue
            seen.add(i.get('number'))
            alli.append((main, i))
    recent, prev = Counter(), Counter()
    recent_eng = defaultdict(set)

    def mod_of(i):
        m = _TRIAGE_WBS.match((i.get('wbs') or '').strip())
        return (m.group(2), 'CK' + m.group(3)) if m else ('', '')

    for main, i in alli:
        c = _d(i.get('created_on'))
        if not c:
            continue
        e = engines.get(main) or {}
        fam = _engine_family(e.get('engine_type', '')) if e else (i.get('engine_family') or '')
        og, ck = mod_of(i)
        if not ck:
            continue
        age = (today - c).days
        if 0 <= age < TRIAGE_HOT_DAYS:
            recent[(fam, ck)] += 1
            recent_eng[(fam, ck)].add(main)
        elif TRIAGE_HOT_DAYS <= age < TRIAGE_HOT_DAYS + TRIAGE_BASE_DAYS:
            prev[(fam, ck)] += 1
    hot = {}
    for key in set(recent) | set(prev):
        n14 = recent[key]
        base = max(0.8, prev[key] * TRIAGE_HOT_DAYS / TRIAGE_BASE_DAYS)
        hot[key] = {'n14': n14, 'base': round(base, 2), 'ratio': round(n14 / base, 2),
                    'engines': len(recent_eng[key]),
                    'hot': n14 >= TRIAGE_HOT_MIN and n14 / base >= TRIAGE_HOT_RATIO}

    rows = []
    for main, i in alli:
        r = _d(i.get('resolved_at'))
        if r or (i.get('state') or '').strip().lower() in _INC_DONE:
            continue
        c = _d(i.get('created_on'))
        e = engines.get(main)
        fam = _engine_family(e.get('engine_type', '')) if e else (i.get('engine_family') or '')
        og, ck = mod_of(i)
        cyc, sa_plan, sa_days = None, '', None
        if e:
            cyc = _triage_cycle(e.get('milestones_actual'))
            sa_plan = (e.get('milestones_zmmp') or {}).get('SA', '') or ''
            sd = _d(sa_plan)
            sa_days = (sd - today).days if sd else None
        h = hot.get((fam, ck), {})
        rows.append({
            'nr': i.get('number', ''), 'txt': i.get('description', ''), 'crit': i.get('criticality', ''),
            'state': i.get('state', ''), 'req': i.get('group', '') or '(ohne Meldegruppe)',
            'grp': i.get('assign_group', '') or '(ohne Bearbeitergruppe)',
            'created': i.get('created_on', ''), 'age': (today - c).days if c else None,
            'project': main, 'esn': (e or {}).get('esn') or i.get('esn', ''),
            'typ': (e or {}).get('engine_type') or i.get('engine_type', ''), 'fam': fam or '(ohne Familie)',
            'cust': (e or {}).get('customer') or i.get('customer', ''),
            'cyc': cyc, 'sa_plan': sa_plan, 'sa_days': sa_days, 'in_sap': bool(e),
            'og': og, 'ck': ck or '', 'mod': mod_name.get((fam, ck), '') if ck else '',
            'og_name': ('Engine gesamt' if ck == 'CK00' else og_name.get((fam, og), '')) if og else '',
            'n14': h.get('n14', 0), 'base': h.get('base', 0), 'ratio': h.get('ratio', 0), 'hot': bool(h.get('hot')),
        })
    hot_list = sorted(({'fam': k[0], 'ck': k[1], 'mod': mod_name.get(k, ''), **v}
                       for k, v in hot.items() if v['hot']), key=lambda x: -x['ratio'])[:8]
    meta = get_incident_meta()
    return {'rows': rows, 'hot': hot_list, 'cycles': TRIAGE_CYCLES, 'today': today.isoformat(),
            'data_through': through, 'incident_ts': meta.get('ts', ''), 'incident_stale': bool(meta.get('stale')),
            'incident_cycle_filter': meta.get('cycle', ''), 'demo': MTUHUB_DEMO,
            'rules': {'hot_days': TRIAGE_HOT_DAYS, 'base_days': TRIAGE_BASE_DAYS,
                      'hot_min': TRIAGE_HOT_MIN, 'hot_ratio': TRIAGE_HOT_RATIO}}


@app.route('/api/triage')
def api_triage():
    try:
        return jsonify(triage_data())
    except Exception as e:
        return jsonify({'error': str(e)})


TRIAGE_HTML = r"""<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SAP Claim Analyzer – Priorisierung</title>
<!--SAP_BASE-->
<style>
  :root { --c-sev:#b8322a; --c-time:#c8671c; --c-cyc:#3b6ea5; --c-hot:#6a3fa0; --c-age:#8a847c; }
  *, *::before, *::after { box-sizing: border-box; }
  body { margin: 0; font-size: 13px; line-height: 1.5; }
  h1, h2, h3, h4 { margin: 0; font-weight: 500; }
  p { margin: 0; }
  .mono { font-family: var(--mono); font-size: 12px; }
  .tabs { display: flex; gap: 28px; padding: 0 clamp(16px,3vw,36px); border-bottom: 1px solid var(--line-2); background: var(--paper); }
  .tab { all: unset; cursor: pointer; padding: 12px 0 10px; font-size: 13px; color: var(--ink-2); border-bottom: 2px solid transparent;
         margin-bottom: -1px; white-space: nowrap; }
  .tab i { font-style: normal; font-size: 10px; letter-spacing: .12em; color: var(--ink-3); margin-right: 8px; }
  .tab small { color: var(--ink-3); margin-left: 8px; font-size: 11px; }
  .tab:hover { color: var(--ink); }
  .tab.on { color: var(--ink); font-weight: 600; border-bottom-color: var(--ink); }
  .tab.on i { color: var(--ink); }
  .tab:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; }
  .tp-bar { display: flex; flex-wrap: wrap; gap: 8px 16px; align-items: center; padding: 14px 0 0; font-size: 12px; color: var(--ink-2); }
  .tp-bar select { height: 32px; padding: 0 10px; border: 1px solid var(--line-2); border-radius: var(--r-s); background: var(--surface); }
  .tp-note { font-size: 12px; }
  .kpis { display: grid; grid-template-columns: repeat(5,minmax(0,1fr)); gap: 1px; background: var(--line); border: 1px solid var(--line);
          border-radius: var(--r-m); overflow: hidden; margin: 16px 0 8px; }
  .kpi { background: var(--surface); padding: 14px 16px 12px; display: flex; flex-direction: column; gap: 4px; }
  .kpi .v { font-size: 28px; font-weight: 500; letter-spacing: -.02em; line-height: 1.1; font-variant-numeric: tabular-nums; }
  .kpi .v small { font-size: 13px; font-weight: 400; color: var(--ink-2); margin-left: 3px; }
  .kpi .s { font-size: 11px; color: var(--ink-3); }
  .kpi.crit .v { color: var(--crit); }
  .sheet { background: var(--surface); border: 1px solid var(--line); border-radius: var(--r-m); padding: 18px; min-width: 0; }
  .flow-wrap { overflow-x: auto; }
  .flow-inner { position: relative; min-width: 780px; }
  #flow { display: block; width: 100%; height: auto; }
  #flowfx { position: absolute; inset: 0; pointer-events: none; }
  .flow-cols { display: grid; grid-template-columns: 1fr 1fr 1fr; font-size: 10px; letter-spacing: .14em; text-transform: uppercase;
               color: var(--ink-3); margin-bottom: 6px; min-width: 780px; }
  .flow-cols span:nth-child(2) { text-align: center; } .flow-cols span:nth-child(3) { text-align: right; }
  .flow-legend, .mx-legend { display: flex; flex-wrap: wrap; gap: 6px 18px; margin-top: 10px; font-size: 11px; color: var(--ink-2); }
  .flow-legend i { display: inline-block; width: 22px; height: 6px; border-radius: 3px; vertical-align: middle; margin-right: 6px; }
  .mx-legend i { display: inline-block; width: 10px; height: 10px; border-radius: 50%; vertical-align: -1px; margin-right: 5px; }
  .fbtn { all: unset; cursor: pointer; display: inline-flex; align-items: center; height: 26px; padding: 0 10px; border-radius: var(--r-s);
          border: 1px solid var(--line-2); background: var(--surface); font-size: 11px; color: var(--ink-2); }
  .fbtn.on { background: var(--ink); border-color: var(--ink); color: var(--paper); }
  .hot-list { display: grid; grid-template-columns: repeat(auto-fit,minmax(210px,1fr)); gap: 12px; }
  .hotc { background: var(--surface); border: 1px solid var(--line); border-left: 3px solid var(--c-hot); border-radius: var(--r-s);
          padding: 12px 14px; cursor: pointer; display: flex; flex-direction: column; gap: 4px; }
  .hotc:hover { background: var(--hover); }
  .hotc b { font-size: 15px; font-weight: 600; }
  .hotc .ratio { font-size: 28px; font-weight: 500; letter-spacing: -.02em; color: var(--c-hot); line-height: 1.1; font-variant-numeric: tabular-nums; }
  .hotc .ratio small { font-size: 12px; color: var(--ink-2); font-weight: 400; margin-left: 4px; }
  .hotc p { color: var(--ink-2); font-size: 12px; }
  #matrix { display: block; width: 100%; height: auto; }
  .wl { display: grid; grid-template-columns: 260px minmax(0,1fr); gap: 28px; padding-top: 24px; }
  .side { position: sticky; top: calc(var(--ph-stick, 0px) + 20px); align-self: start; display: flex; flex-direction: column; gap: 18px; }
  .field { display: flex; flex-direction: column; gap: 6px; }
  .field label, .side .eyebrow { font-size: 10px; letter-spacing: .14em; text-transform: uppercase; color: var(--ink-3); }
  .field select { height: 34px; padding: 0 10px; border: 1px solid var(--line-2); border-radius: var(--r-s); background: var(--surface); }
  .w-row { display: grid; grid-template-columns: 12px minmax(0,1fr) 34px; gap: 8px; align-items: center; font-size: 12px; }
  .w-row i { width: 12px; height: 12px; border-radius: 2px; display: block; }
  .w-row output { text-align: right; font-variant-numeric: tabular-nums; color: var(--ink-2); }
  .w-row input { grid-column: 2 / 4; width: 100%; accent-color: var(--ink); margin: 0 0 4px; }
  .rule { font-size: 12px; color: var(--ink-2); line-height: 1.5; padding: 10px 12px; background: var(--surface); border: 1px solid var(--line);
          border-left: 3px solid var(--crit); border-radius: var(--r-s); }
  .rule b { color: var(--ink); }
  .seg { display: inline-flex; }
  .seg .btn { border-radius: 0; margin-left: -1px; }
  .seg .btn:first-child { border-radius: var(--r-s) 0 0 var(--r-s); margin-left: 0; }
  .seg .btn:last-child { border-radius: 0 var(--r-s) var(--r-s) 0; }
  .btn.on { background: var(--ink); border-color: var(--ink); color: var(--paper); }
  .wl-top { display: flex; flex-wrap: wrap; gap: 10px 16px; align-items: center; margin-bottom: 12px; }
  .wl-top h2 { font-size: 20px; letter-spacing: -.01em; margin-right: auto; }
  .fchip { display: inline-flex; align-items: center; gap: 6px; height: 26px; padding: 0 4px 0 10px; border-radius: var(--r-pill);
           background: var(--surface); border: 1px solid var(--line-2); font-size: 12px; }
  .fchip button { all: unset; cursor: pointer; width: 20px; height: 20px; border-radius: 50%; text-align: center; line-height: 20px; color: var(--ink-2); }
  .fchip button:hover { background: var(--ink); color: var(--paper); }
  .fchip .k { font-size: 10px; letter-spacing: .1em; text-transform: uppercase; color: var(--ink-3); }
  #list { display: flex; flex-direction: column; gap: 6px; }
  .trow { display: grid; grid-template-columns: 44px minmax(0,1fr) 260px; gap: 14px; align-items: center; background: var(--surface);
          border: 1px solid var(--line); border-radius: var(--r-m); padding: 10px 14px; position: relative; }
  .trow.stop { border-left: 3px solid var(--crit); }
  .trow.sel { outline: 2px solid var(--ink); outline-offset: -1px; }
  .trow .rk { font-size: 20px; font-weight: 500; letter-spacing: -.02em; color: var(--ink-3); font-variant-numeric: tabular-nums; text-align: right; }
  .trow.stop .rk { color: var(--crit); }
  .r-main { min-width: 0; display: flex; flex-direction: column; gap: 3px; }
  .r-l1 { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 10px; }
  .r-l1 .t { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 100%; }
  .r-l2 { display: flex; flex-wrap: wrap; gap: 4px 12px; font-size: 12px; color: var(--ink-2); }
  .r-l2 a { color: var(--ink-2); }
  .why { font-size: 12px; color: var(--ink-2); }
  .why b { color: var(--ink); font-weight: 600; }
  .tp { display: inline-flex; align-items: center; height: 20px; padding: 0 8px; border-radius: var(--r-pill); font-size: 11px;
        font-weight: 600; white-space: nowrap; }
  .tp.stop { background: var(--crit); color: #fff; }
  .tp.crit { background: var(--crit-bg); color: var(--crit); }
  .tp.low { background: var(--neutral-bg); color: var(--ink-2); }
  .tp.hot { background: #ece4f5; color: var(--c-hot); }
  .tp.sa { background: var(--warn-bg); color: var(--warn); }
  .tp.cyc { background: #e3eaf2; color: #2f5f8f; }
  .r-score { display: flex; flex-direction: column; gap: 4px; }
  .r-score .tot { display: flex; justify-content: space-between; font-size: 11px; color: var(--ink-3); }
  .r-score .tot b { font-size: 15px; color: var(--ink); font-variant-numeric: tabular-nums; font-weight: 600; }
  .sbar { display: flex; height: 10px; border-radius: 2px; overflow: hidden; background: var(--hover); }
  .sbar i { display: block; height: 100%; transition: width .45s var(--ease); }
  .more-note { text-align: center; color: var(--ink-3); font-size: 12px; padding: 10px; }
  .empty { padding: 32px; text-align: center; color: var(--ink-2); }
  #tip { position: fixed; z-index: 1000; pointer-events: none; background: var(--ink); color: var(--paper); font-size: 12px; line-height: 1.45;
         padding: 8px 10px; border-radius: var(--r-s); max-width: 340px; opacity: 0; transition: opacity .12s; }
  #tip.on { opacity: 1; }
  #tip span { color: rgba(240,237,230,.7); }
  @media (max-width: 1280px) { .wl { grid-template-columns: minmax(0,1fr); } .side { position: static; } }
  @media (max-width: 900px) { .kpis { grid-template-columns: repeat(2,minmax(0,1fr)); } .trow { grid-template-columns: 36px minmax(0,1fr); }
                              .r-score { grid-column: 1 / -1; } }
</style>
<!--SAP_SHELL-->
</head>
<body>
<!--SAP_RAIL-->
<header class="sap-ph"><!--SAP_PH-->
<div class="ph-right">
  <span id="stand" style="font-size:11px;color:var(--ink-2)"></span>
  <button class="btn sm" onclick="load(true)">↺ Neu laden</button>
<!--SAP_MARK-->
</div>
</header>
<nav class="tabs" role="tablist">
  <button class="tab on" id="tab-lage" role="tab" onclick="showTab('lage')"><i>06.1</i>Lage<small>für Führungskräfte</small></button>
  <button class="tab" id="tab-liste" role="tab" onclick="showTab('liste')"><i>06.2</i>Arbeitsliste<small>für Bearbeitergruppen</small></button>
</nav>
<div class="sap-page">
  <div class="tp-bar">
    <label for="famsel" class="eyebrow">Familie</label>
    <select id="famsel" onchange="FAM=this.value;renderAll()"></select>
    <span class="tp-note" id="srcnote"></span>
  </div>

  <div id="pane-lage">
    <div class="kpis" id="kpis"></div>
    <section class="sap-sec">
      <div class="sap-sec-head">
        <span class="eyebrow">Datenstrom</span>
        <h2>Woher kommt die Last, und wo im Prozess hängt sie?</h2>
        <p>Jede Linie ist ein Strom offener Incidents: von der <b>Meldegruppe</b> über die <b id="midlab">Familie</b> bis zum <b>Cycle</b>, in dem die Engine gerade steht. Je dicker, desto mehr. Die Punkte zeigen neue Meldungen der letzten 7 Tage, <b style="color:var(--c-hot)">violett</b> leuchten Ströme mit Häufung.</p>
        <p>Klick auf einen Knoten öffnet die passende Arbeitsliste. Mit einer Familie oben zeigt die Mitte deren Obergruppen.</p>
        <div class="sap-sec-ctl">
          <button class="fbtn on" data-f="all" onclick="flowFilter('all')">Alle offenen</button>
          <button class="fbtn" data-f="crit" onclick="flowFilter('crit')">Nur Baustopp + Kritisch</button>
        </div>
      </div>
      <div class="sap-sec-body"><div class="sheet">
        <div class="flow-wrap"><div class="flow-cols"><span>Meldegruppe</span><span id="midcol">Familie</span><span>Cycle der Engine</span></div>
          <div class="flow-inner"><svg id="flow" viewBox="0 0 1000 470" role="img" aria-label="Datenstrom offener Incidents"></svg><canvas id="flowfx"></canvas></div></div>
        <div class="flow-legend">
          <span><i style="background:rgba(27,27,27,.22)"></i>Strom offener Incidents (Breite = Anzahl)</span>
          <span><i style="background:var(--c-hot)"></i>mit Häufung</span>
          <span><i style="background:var(--crit);height:6px;width:6px;border-radius:50%"></i>neue Meldung (letzte 7 Tage)</span>
        </div>
      </div></div>
    </section>
    <section class="sap-sec">
      <div class="sap-sec-head">
        <span class="eyebrow">Brennpunkte</span>
        <h2>Wo häufen sich die Meldungen?</h2>
        <p id="hotrule">Module mit deutlich mehr Meldungen in den letzten 14 Tagen als üblich.</p>
      </div>
      <div class="sap-sec-body"><div class="hot-list" id="hotlist"></div></div>
    </section>
    <section class="sap-sec">
      <div class="sap-sec-head">
        <span class="eyebrow">Triage-Matrix</span>
        <h2>Was ist dringend, und was ist wichtig?</h2>
        <p>Jeder Punkt ist ein offener Incident. Links: die Engine soll bald ausgeliefert werden (überfällig ganz links). Oben: schwer oder gehäuft. Die Größe zeigt den Prioritätswert.</p>
        <p><b>Oben links ist „Sofort“.</b> Klick öffnet den Incident in der Arbeitsliste.</p>
      </div>
      <div class="sap-sec-body"><div class="sheet">
        <svg id="matrix" viewBox="0 0 1000 470" role="img" aria-label="Triage-Matrix"></svg>
        <div class="mx-legend">
          <span><i style="background:var(--crit)"></i>Baustopp</span><span><i style="background:#e08a74"></i>Kritisch</span>
          <span><i style="background:#b5afa5"></i>Nicht kritisch</span><span><i style="background:transparent;border:2px solid var(--c-hot)"></i>Häufung</span>
          <span id="mxnote"></span>
        </div>
      </div></div>
    </section>
  </div>

  <div id="pane-liste" hidden>
    <div class="wl">
      <aside class="side">
        <div class="field"><label for="grp">Bearbeitergruppe</label><select id="grp" onchange="renderList()"></select></div>
        <div class="field"><label>Umfang</label>
          <span class="seg"><button class="btn sm on" id="m-10" onclick="setMode(10)">Die nächsten 10</button><button class="btn sm" id="m-all" onclick="setMode(0)">Alle</button></span></div>
        <div class="rule"><b>Feste Regel:</b> Baustopp steht immer oben, unabhängig von den Gewichten. Darunter entscheidet der Prioritätswert.</div>
        <div>
          <span class="eyebrow">Gewichte · live verändern</span>
          <div id="weights" style="display:flex;flex-direction:column;gap:6px;margin-top:8px"></div>
          <button class="btn sm" style="margin-top:8px" onclick="resetW()">Gewichte zurücksetzen</button>
        </div>
      </aside>
      <div>
        <div class="wl-top"><h2 id="wl-title">Arbeitsliste</h2><span id="wl-chips" style="display:flex;gap:6px;flex-wrap:wrap"></span></div>
        <div id="list"><div class="empty">Lade offene Incidents …</div></div>
      </div>
    </div>
  </div>
</div>
<div id="tip"></div>

<script>
'use strict';
const $ = s => document.querySelector(s);
const fmt = n => Number(n || 0).toLocaleString('de-DE');
const esc = s => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'}[c]));
const dec = (v, n) => Number(v).toFixed(n || 1).replace('.', ',');
let D = null, ALL = [], FAM = '', FLOWF = 'all', MODE = 10, FILTER = {};
const CYC_SHORT = ['C0 Queue', 'C1 Demontage', 'C2 Material', 'C3 Aufbau & Test', 'Nach SA'];
const CYC_COL = ['#9fb9d4', '#6f93bd', '#3b6ea5', '#1f4b7a', '#8a847c'];

// ── Prioritätswert: fünf sichtbare Bausteine, Gewichte live ─────────────────
const W0 = {sev: 1, time: 1, cyc: 1, hot: 1, age: 1};
let W = {...W0};
try { const s = JSON.parse(localStorage.getItem('sap_triage_w') || 'null'); if (s) W = {...W0, ...s}; } catch (e) {}
const CW = [0.1, 0.4, 0.7, 1, 0.2];                       // Cycle 0 … 3, nach SA
const COMP = [
  {k: 'sev',  name: 'Schwere',   col: 'var(--c-sev)',  max: 40, f: x => x.crit === 'Baustopp' ? 40 : x.crit === 'Kritisch' ? 25 : 5},
  {k: 'time', name: 'Zeitdruck', col: 'var(--c-time)', max: 30, f: x => x.sa_days == null ? 0 : 30 * Math.max(0, Math.min(1, (90 - x.sa_days) / 80))},
  {k: 'cyc',  name: 'Cycle',     col: 'var(--c-cyc)',  max: 20, f: x => x.cyc == null ? 0 : 20 * CW[x.cyc]},
  {k: 'hot',  name: 'Häufung',   col: 'var(--c-hot)',  max: 15, f: x => 15 * Math.max(0, Math.min(1, (x.ratio - 1) / 3)) * (x.age != null && x.age < 14 ? 1 : .4)},
  {k: 'age',  name: 'Alter',     col: 'var(--c-age)',  max: 10, f: x => x.age == null ? 0 : 10 * Math.min(1, x.age / 21)},
];
function score(x) { const parts = COMP.map(c => c.f(x) * W[c.k]); return {parts, tot: parts.reduce((a, b) => a + b, 0)}; }
const maxTot = () => COMP.reduce((a, c) => a + c.max * W[c.k], 0) || 1;
const isStop = x => x.crit === 'Baustopp';
function ranked(list) {
  return list.map(x => ({x, s: score(x)})).sort((a, b) => isStop(b.x) - isStop(a.x) || b.s.tot - a.s.tot);
}
function saText(x) {
  if (x.sa_days == null) return x.in_sap ? 'ohne Plan-SA' : 'nicht im SAP-Export';
  return x.sa_days < 0 ? `SA überfällig ${-x.sa_days} T` : `SA in ${x.sa_days} T`;
}
function why(x, s) {
  const r = [];
  if (isStop(x)) r.push('<b>Baustopp</b>');
  COMP.map((c, i) => ({c, v: s.parts[i]})).filter(o => o.c.k !== 'sev').sort((a, b) => b.v - a.v).slice(0, 3).forEach(o => {
    if (o.v < 3) return;
    if (o.c.k === 'time') r.push(x.sa_days < 0 ? `Plan-SA <b>seit ${-x.sa_days} Tagen überfällig</b>` : `Plan-SA in <b>${x.sa_days} Tagen</b>`);
    if (o.c.k === 'cyc') r.push(`Engine in <b>${esc(D.cycles[x.cyc].split(' · ')[1] || D.cycles[x.cyc])}</b>`);
    if (o.c.k === 'hot') r.push(`<b>${x.n14}. Meldung</b> zu ${esc(x.ck)} in 14 Tagen`);
    if (o.c.k === 'age') r.push(`seit <b>${x.age} Tagen</b> offen`);
  });
  if (x.crit === 'Kritisch' && r.length < 3) r.unshift('Kritisch');
  return r.join(' · ') || 'niedrige Dringlichkeit';
}

// ── Laden ───────────────────────────────────────────────────────────────────
async function load() {
  $('#list').innerHTML = '<div class="empty">Lade offene Incidents …</div>';
  const d = await (await fetch('/api/triage')).json();
  if (d.error) { $('#kpis').innerHTML = ''; $('#list').innerHTML = '<div class="empty">&#9651; ' + esc(d.error) + '</div>';
                 $('#srcnote').textContent = 'Fehler: ' + d.error; return; }
  D = d; ALL = d.rows;
  const fams = [...new Set(ALL.map(x => x.fam))].sort();
  $('#famsel').innerHTML = '<option value="">alle Familien (' + ALL.length + ')</option>'
    + fams.map(f => `<option value="${esc(f)}">${esc(f)} (${ALL.filter(x => x.fam === f).length})</option>`).join('');
  $('#famsel').value = FAM;
  const stale = d.data_through && (new Date(d.today) - new Date(d.data_through)) / 864e5 > 14;
  $('#stand').textContent = 'Stand ' + d.today.split('-').reverse().join('.');
  $('#srcnote').innerHTML = `Incidents ${d.incident_stale ? '<b style="color:var(--crit)">Snapshot</b> ' : ''}vom ${esc((d.incident_ts || '').replace('T', ' ').slice(0, 16))}`
    + (d.incident_cycle_filter ? ` · Zyklus-Filter „${esc(d.incident_cycle_filter)}“ aus den Einstellungen` : '')
    + ` · SAP-Export bis ${esc(d.data_through || '?')}`
    + (stale ? ' · <b style="color:var(--crit)">SAP-Export älter als 14 Tage: Zeit bis SA unzuverlässig</b>' : '')
    + (d.demo ? ' · <b style="color:var(--warn)">Demo-Daten</b>' : '');
  $('#hotrule').innerHTML = `Module mit mindestens <b>${d.rules.hot_min} Meldungen in ${d.rules.hot_days} Tagen</b> und mehr als dem <b>${dec(d.rules.hot_ratio)}-fachen</b> des Üblichen (Schnitt der ${d.rules.base_days} Tage davor). Häufungen sind oft <b>eine</b> Ursache, die viele Engines trifft.`;
  renderAll();
}
function sel() { return FAM ? ALL.filter(x => x.fam === FAM) : ALL; }
function renderAll() { renderKpis(); drawFlow(); drawMatrix(); renderWeights(); renderList(); }

// ── Kennzahlen, Brennpunkte ─────────────────────────────────────────────────
function renderKpis() {
  const xs = sel(), stop = xs.filter(isStop), soon = new Set(xs.filter(x => x.sa_days != null && x.sa_days <= 14).map(x => x.project));
  const hots = D.hot.filter(h => !FAM || h.fam === FAM);
  const ages = xs.filter(x => x.age != null), age = ages.length ? ages.reduce((a, x) => a + x.age, 0) / ages.length : 0;
  $('#kpis').innerHTML =
    `<div class="kpi"><span class="eyebrow">Offene Incidents</span><span class="v">${fmt(xs.length)}</span><span class="s">an ${fmt(new Set(xs.map(x => x.project)).size)} Engines</span></div>`
    + `<div class="kpi crit"><span class="eyebrow">Baustopp</span><span class="v">${fmt(stop.length)}</span><span class="s">stehen immer oben</span></div>`
    + `<div class="kpi"><span class="eyebrow">SA ≤ 14 Tage</span><span class="v">${fmt(soon.size)}<small>Engines</small></span><span class="s">mit offenem Incident (inkl. überfällig)</span></div>`
    + `<div class="kpi"><span class="eyebrow">Häufungen aktiv</span><span class="v" style="color:var(--c-hot)">${hots.length}</span><span class="s">${hots.slice(0, 4).map(h => esc(h.ck)).join(' · ') || 'keine'}</span></div>`
    + `<div class="kpi"><span class="eyebrow">Ø offen seit</span><span class="v">${dec(age)}<small>Tage</small></span><span class="s">über ${fmt(ages.length)} Incidents</span></div>`;
  $('#hotlist').innerHTML = hots.length ? hots.map(h =>
    `<div class="hotc" tabindex="0" role="button" onclick="openList({mod:'${esc(h.ck)}',fam:'${esc(h.fam)}'})" onkeydown="if(event.key==='Enter')this.click()">`
    + `<span class="eyebrow">${esc(h.fam)}</span><b>${esc(h.ck)} ${esc(h.mod)}</b>`
    + `<span class="ratio">${dec(h.ratio)} ×<small>üblich</small></span>`
    + `<p>${h.n14} Meldungen in ${D.rules.hot_days} Tagen (sonst ≈ ${dec(h.base)}) · ${h.engines} Engines betroffen</p></div>`).join('')
    : '<p style="color:var(--ink-2)">Zurzeit keine Häufung nach dieser Regel.</p>';
}

// ── Tooltip ─────────────────────────────────────────────────────────────────
const tip = $('#tip');
function showTip(e, h) { tip.innerHTML = h; tip.classList.add('on'); tip.style.left = Math.min(e.clientX + 14, innerWidth - tip.offsetWidth - 10) + 'px'; tip.style.top = (e.clientY + 14) + 'px'; }
function hideTip() { tip.classList.remove('on'); }

// ── Datenstrom ──────────────────────────────────────────────────────────────
let PARTICLES = [];
function flowFilter(f) { FLOWF = f; document.querySelectorAll('.fbtn').forEach(b => b.classList.toggle('on', b.dataset.f === f)); drawFlow(); }
function drawFlow() {
  const svg = $('#flow'), H_ = 470, NW = 12, PAD = 8, X = [190, 494, 800];
  const inc = sel().filter(x => FLOWF === 'all' || x.crit === 'Baustopp' || x.crit === 'Kritisch');
  $('#midlab').textContent = FAM ? 'Obergruppe' : 'Familie';
  $('#midcol').textContent = FAM ? 'Obergruppe · ' + FAM : 'Familie';
  if (!inc.length) { svg.innerHTML = '<text x="500" y="235" text-anchor="middle" fill="#5f5a54" font-size="14">Keine offenen Incidents in dieser Auswahl.</text>'; PARTICLES = []; return; }
  const mid = x => FAM ? (x.og ? 'OG ' + x.og : 'ohne Modul') : x.fam;
  const midLab = k => { if (!FAM || k === 'ohne Modul') return k; const r = inc.find(x => 'OG ' + x.og === k); return k + (r && r.og_name ? ' ' + r.og_name : ''); };
  const cycKey = x => x.cyc == null ? 'n' : String(x.cyc);
  const top = (arr, n) => { const c = {}; arr.forEach(k => c[k] = (c[k] || 0) + 1); const s = Object.entries(c).sort((a, b) => b[1] - a[1]);
    const keep = new Set(s.slice(0, n).map(e => e[0])); return k => keep.has(k) ? k : 'weitere'; };
  const reqK = top(inc.map(x => x.req), 9), midK = top(inc.map(mid), 10);
  const pr = x => reqK(x.req), pm = x => midK(mid(x));
  const colOf = (f, lab) => { const c = {}; inc.forEach(x => { const k = f(x); c[k] = (c[k] || 0) + 1; });
    return Object.entries(c).sort((a, b) => (a[0] === 'weitere') - (b[0] === 'weitere') || b[1] - a[1]).map(([k, n]) => ({key: k, n, lab: lab(k)})); };
  const cols = [
    colOf(pr, k => k.replace(/-LUD$/, '')).map(n => ({...n, id: 'r:' + n.key})),
    colOf(pm, k => midLab(k)).map(n => ({...n, id: 'm:' + n.key})),
    colOf(cycKey, k => k === 'n' ? 'ohne SAP-Stand' : CYC_SHORT[+k]).sort((a, b) => (a.key === 'n') - (b.key === 'n') || a.key.localeCompare(b.key)).map(n => ({...n, id: 'c:' + n.key})),
  ];
  const tot = inc.length, k = (H_ - 20 - PAD * (Math.max(...cols.map(c => c.length)) - 1)) / tot;
  cols.forEach((col, ci) => {
    const used = col.reduce((a, n) => a + n.n * k, 0) + PAD * (col.length - 1);
    let y = 10 + (H_ - 20 - used) / 2;
    col.forEach(n => { n.x = X[ci]; n.y = y; n.h = n.n * k; n.oy = n.y; n.iy = n.y; y += n.h + PAD; });
  });
  const byId = {}; cols.flat().forEach(n => byId[n.id] = n);
  const links = [];
  const agg = (a, b, fa, fb) => { const m = {}; inc.forEach(x => { const key = fa(x) + '\u0001' + fb(x); (m[key] = m[key] || []).push(x); });
    Object.entries(m).forEach(([key, xs]) => { const [s, t] = key.split('\u0001'); links.push({s: byId[a + s], t: byId[b + t], xs}); }); };
  agg('r:', 'm:', pr, pm); agg('m:', 'c:', pm, cycKey);
  links.sort((p, q) => p.s.y - q.s.y || p.t.y - q.t.y);
  links.forEach(l => { l.w = l.xs.length * k; l.y0 = l.s.oy + l.w / 2; l.s.oy += l.w; });
  links.slice().sort((p, q) => p.t.y - q.t.y || p.s.y - q.s.y).forEach(l => { l.y1 = l.t.iy + l.w / 2; l.t.iy += l.w; });
  const recentHot = x => x.hot && x.age != null && x.age < 14;
  links.forEach(l => {
    const nh = l.xs.filter(recentHot).length;
    l.hot = nh >= 3 && nh / l.xs.length >= .5;
    l.fresh = l.xs.filter(x => x.age != null && x.age < 7).length;
    l.x0 = l.s.x + NW; l.x1 = l.t.x; l.cx = (l.x0 + l.x1) / 2;
    l.d = `M${l.x0},${l.y0} C${l.cx},${l.y0} ${l.cx},${l.y1} ${l.x1},${l.y1}`;
  });
  let h = '<defs><filter id="glow" x="-20%" y="-50%" width="140%" height="200%"><feGaussianBlur stdDeviation="3" result="b"/><feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge></filter></defs>';
  links.forEach((l, i) => { h += `<path class="lk" data-i="${i}" d="${l.d}" fill="none" stroke="${l.hot ? '#6a3fa0' : '#1b1b1b'}" stroke-opacity="${l.hot ? .5 : .13}" stroke-width="${Math.max(1.5, l.w).toFixed(1)}"${l.hot ? ' filter="url(#glow)"' : ''} style="cursor:pointer"/>`; });
  const halo = ' stroke="#fbfaf6" stroke-width="4" stroke-linejoin="round" paint-order="stroke"';
  cols.forEach((col, ci) => col.forEach(n => {
    const hot = ci === 1 && inc.some(x => pm(x) === n.key && recentHot(x));
    const fill = ci === 2 ? (n.key === 'n' ? '#cfc9bf' : CYC_COL[+n.key]) : hot ? '#6a3fa0' : ci === 0 ? '#5f5a54' : '#1b1b1b';
    h += `<rect class="nd" data-id="${esc(n.id)}" x="${n.x}" y="${n.y}" width="${NW}" height="${Math.max(2, n.h)}" rx="2" fill="${fill}" style="cursor:pointer"/>`;
    const lx = ci === 0 ? n.x - 8 : n.x + NW + 8, anc = ci === 0 ? 'end' : 'start', ty = n.y + n.h / 2;
    h += `<text x="${lx}" y="${ty - 1}" text-anchor="${anc}" font-size="12" fill="#1b1b1b" font-weight="${hot ? 700 : 500}"${halo} style="pointer-events:none">${esc(n.lab.length > 34 ? n.lab.slice(0, 33) + '…' : n.lab)}</text>`
      + `<text x="${lx}" y="${ty + 13}" text-anchor="${anc}" font-size="11" fill="#5f5a54"${halo} style="pointer-events:none">${fmt(n.n)} offen</text>`;
  }));
  svg.innerHTML = h;
  svg.querySelectorAll('.lk').forEach(p => {
    const l = links[+p.dataset.i];
    p.addEventListener('mousemove', e => showTip(e, `<b>${esc(l.s.lab)} → ${esc(l.t.lab)}</b><br>${fmt(l.xs.length)} offene Incidents · davon ${l.xs.filter(isStop).length} Baustopp<br><span>${l.fresh} neu in 7 Tagen${l.hot ? ' · Häufung' : ''}</span>`));
    p.addEventListener('mouseleave', hideTip);
  });
  svg.querySelectorAll('.nd').forEach(r => r.addEventListener('click', () => {
    const id = r.dataset.id, t = id[0], v = id.slice(2);
    if (v === 'weitere') return;
    openList(t === 'r' ? {req: v} : t === 'm' ? (FAM ? {og: v === 'ohne Modul' ? '' : v.slice(3), ogLab: v} : {fam2: v}) : {cyc: v === 'n' ? null : +v});
  }));
  PARTICLES = [];
  links.forEach(l => { for (let i = 0; i < Math.min(14, l.fresh * 2); i++) PARTICLES.push({l, t: Math.random(), v: .0022 + Math.random() * .002, o: (Math.random() - .5) * l.w * .7}); });
}
const fx = $('#flowfx'), g = fx.getContext('2d');
const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
function bez(l, t) { const u = 1 - t;
  return [u*u*u*l.x0 + 3*u*u*t*l.cx + 3*u*t*t*l.cx + t*t*t*l.x1, u*u*u*l.y0 + 3*u*u*t*l.y0 + 3*u*t*t*l.y1 + t*t*t*l.y1]; }
function tick() {
  const r = $('#flow').getBoundingClientRect(), dpr = devicePixelRatio || 1;
  if (r.width && (fx.width !== Math.round(r.width * dpr) || fx.height !== Math.round(r.height * dpr))) {
    fx.width = Math.round(r.width * dpr); fx.height = Math.round(r.height * dpr); fx.style.width = r.width + 'px'; fx.style.height = r.height + 'px'; }
  const s = fx.width / 1000;
  g.clearRect(0, 0, fx.width, fx.height);
  PARTICLES.forEach(p => {
    if (!reduce) p.t = (p.t + p.v) % 1;
    const [x, y] = bez(p.l, p.t);
    g.beginPath(); g.arc(x * s, (y + p.o * Math.sin(p.t * Math.PI)) * s, (p.l.hot ? 3.2 : 2.4) * s, 0, 6.283);
    g.fillStyle = p.l.hot ? 'rgba(106,63,160,.95)' : 'rgba(184,50,42,.85)'; g.fill();
  });
}
let RUN = false;
function loop() { tick(); if (!reduce && !$('#pane-lage').hidden) requestAnimationFrame(loop); else RUN = false; }
function startLoop() { if (RUN) return; RUN = true; requestAnimationFrame(loop); }

// ── Triage-Matrix ───────────────────────────────────────────────────────────
function drawMatrix() {
  const svg = $('#matrix'), L = 70, R = 970, T = 24, B = 420, XMIN = -30, XMAX = 140, YMAX = 58;
  const X = d => L + (R - L) * (Math.max(XMIN, Math.min(d, XMAX)) - XMIN) / (XMAX - XMIN), Y = v => B - (B - T) * Math.min(v, YMAX) / YMAX;
  const xs = sel().filter(x => x.sa_days != null);
  $('#mxnote').textContent = sel().length - xs.length ? `· ${sel().length - xs.length} ohne Plan-SA nicht gezeigt` : '';
  const qx = X(30), qy = Y(26);
  let h = `<rect x="${L}" y="${T}" width="${qx - L}" height="${qy - T}" fill="#f5e0db" opacity=".55"/>`
    + `<rect x="${L}" y="${T}" width="${X(0) - L}" height="${B - T}" fill="rgba(184,50,42,.05)"/>`
    + `<line x1="${qx}" y1="${T}" x2="${qx}" y2="${B}" stroke="rgba(27,27,27,.24)" stroke-dasharray="4 4"/>`
    + `<line x1="${L}" y1="${qy}" x2="${R}" y2="${qy}" stroke="rgba(27,27,27,.24)" stroke-dasharray="4 4"/>`
    + `<line x1="${X(0)}" y1="${T}" x2="${X(0)}" y2="${B}" stroke="rgba(184,50,42,.35)"/>`;
  [[L + 8, T + 20, 'SOFORT', '#b8322a'], [qx + 10, T + 20, 'BEOBACHTEN', '#5f5a54'], [L + 8, B - 10, 'EINPLANEN', '#5f5a54'], [qx + 10, B - 10, 'SPÄTER', '#8a847c']]
    .forEach(([x, y, t, c]) => { h += `<text x="${x}" y="${y}" font-size="11" letter-spacing="1.5" fill="${c}" font-weight="700">${t}</text>`; });
  h += `<text x="${(L + X(0)) / 2}" y="${B + 20}" text-anchor="middle" font-size="11" fill="#b8322a">überfällig</text>`;
  for (let d = 0; d <= XMAX; d += 20) h += `<line x1="${X(d)}" y1="${B}" x2="${X(d)}" y2="${B + 5}" stroke="#8a847c"/><text x="${X(d)}" y="${B + 20}" text-anchor="middle" font-size="11" fill="#5f5a54">${d}</text>`;
  h += `<line x1="${L}" y1="${B}" x2="${R}" y2="${B}" stroke="#8a847c"/><text x="${(L + R) / 2}" y="${B + 42}" text-anchor="middle" font-size="12" fill="#5f5a54">Tage bis Plan-SA der Engine →</text>`;
  [0, 15, 30, 45].forEach(v => { h += `<text x="${L - 10}" y="${Y(v) + 4}" text-anchor="end" font-size="11" fill="#5f5a54">${v}</text><line x1="${L}" y1="${Y(v)}" x2="${R}" y2="${Y(v)}" stroke="rgba(27,27,27,.07)"/>`; });
  h += `<text transform="translate(18,${(T + B) / 2}) rotate(-90)" text-anchor="middle" font-size="12" fill="#5f5a54">Schwere + Häufung →</text>`;
  const mt = maxTot();
  let jit = 1;
  const rj = () => { jit = (jit * 9301 + 49297) % 233280; return jit / 233280 - .5; };
  const pts = ranked(xs).map(({x, s}) => ({x, s, px: X(x.sa_days + rj() * 3), py: Y(COMP[0].f(x) + COMP[3].f(x) + rj() * 2.5), r: 4 + 9 * s.tot / mt}))
    .sort((a, b) => b.r - a.r);
  pts.forEach((p, i) => {
    const c = isStop(p.x) ? '#b8322a' : p.x.crit === 'Kritisch' ? '#e08a74' : '#b5afa5', hot = p.x.hot && p.x.age < 14;
    h += `<circle class="pt" data-i="${i}" cx="${p.px.toFixed(1)}" cy="${p.py.toFixed(1)}" r="${p.r.toFixed(1)}" fill="${c}" fill-opacity=".85" stroke="${hot ? '#6a3fa0' : '#fbfaf6'}" stroke-width="${hot ? 2.5 : 1}" style="cursor:pointer"/>`;
  });
  svg.innerHTML = h;
  svg.querySelectorAll('.pt').forEach(c => {
    const p = pts[+c.dataset.i];
    c.addEventListener('mousemove', e => showTip(e, `<b>${esc(p.x.nr)} · ${esc(p.x.crit || 'ohne Kritikalität')}</b><br>${esc(p.x.txt)}<br><span>${esc(p.x.project)} · ${esc(p.x.typ)} · ${p.x.cyc != null ? CYC_SHORT[p.x.cyc] : '–'} · ${saText(p.x)} · Wert ${Math.round(p.s.tot)}</span>`));
    c.addEventListener('mouseleave', hideTip);
    c.addEventListener('click', () => { hideTip(); openList({nr: p.x.nr}); });
  });
}

// ── Arbeitsliste ────────────────────────────────────────────────────────────
function setMode(n) { MODE = n; $('#m-10').classList.toggle('on', n === 10); $('#m-all').classList.toggle('on', !n); renderList(); }
function resetW() { W = {...W0}; saveW(); renderWeights(); renderList(); drawMatrix(); }
function saveW() { try { localStorage.setItem('sap_triage_w', JSON.stringify(W)); } catch (e) {} }
function renderWeights() {
  $('#weights').innerHTML = COMP.map(c => `<div class="w-row"><i style="background:${c.col}"></i><label for="w-${c.k}">${c.name}</label>`
    + `<output id="o-${c.k}">${dec(W[c.k])}×</output><input type="range" id="w-${c.k}" min="0" max="2" step="0.1" value="${W[c.k]}" oninput="setW('${c.k}',this.value)"></div>`).join('');
}
function setW(k, v) { W[k] = +v; $('#o-' + k).textContent = dec(+v) + '×'; saveW(); renderList(); }
function openList(f) { FILTER = f; if (f.nr) MODE = 0; setMode(MODE); showTab('liste'); }
function filterLabel() {
  const f = FILTER;
  if (f.req) return ['Meldegruppe', f.req];
  if (f.fam2) return ['Familie', f.fam2];
  if (f.og !== undefined) return ['Obergruppe', f.ogLab || f.og];
  if ('cyc' in f) return ['Cycle', f.cyc == null ? 'ohne SAP-Stand' : D.cycles[f.cyc]];
  if (f.mod) return ['Häufung', f.fam + ' · ' + f.mod];
  if (f.nr) return ['Incident', f.nr];
  return null;
}
function renderList() {
  if (!D) return;
  const s0 = $('#grp'), keep = s0.value, base = sel();
  const grps = [...new Set(base.map(x => x.grp))].sort((a, b) => base.filter(x => x.grp === b).length - base.filter(x => x.grp === a).length);
  s0.innerHTML = '<option value="">Alle Gruppen (' + base.length + ')</option>'
    + grps.map(gr => `<option value="${esc(gr)}">${esc(gr)} (${base.filter(x => x.grp === gr).length})</option>`).join('');
  s0.value = grps.includes(keep) ? keep : '';
  const gsel = s0.value, f = FILTER;
  let xs = base.filter(x => !gsel || x.grp === gsel);
  if (f.req) xs = xs.filter(x => x.req === f.req);
  if (f.fam2) xs = xs.filter(x => x.fam === f.fam2);
  if (f.og !== undefined) xs = xs.filter(x => x.og === f.og);
  if ('cyc' in f) xs = xs.filter(x => x.cyc === f.cyc);
  if (f.mod) xs = xs.filter(x => x.ck === f.mod && x.fam === f.fam);
  let rk = ranked(xs);
  const all = rk.length, CAP = 300;                        // „Alle" bleibt flüssig, auch bei Tausenden
  rk = rk.slice(0, MODE || CAP);
  $('#wl-title').textContent = (gsel || 'Alle Gruppen') + ' · ' + (MODE ? 'die nächsten ' + Math.min(MODE, all) : all + ' offen');
  const fl = filterLabel();
  $('#wl-chips').innerHTML = fl ? `<span class="fchip"><span class="k">${fl[0]}</span>${esc(fl[1])}<button onclick="clearF()" title="Filter aufheben">✕</button></span>` : '';
  const list = $('#list'), before = {};
  list.querySelectorAll('.trow').forEach(r => before[r.dataset.nr] = r.getBoundingClientRect().top);
  const mx = maxTot();
  list.innerHTML = rk.map(({x, s}, i) => {
    const bar = s.parts.map((v, j) => `<i style="width:${(100 * v / mx).toFixed(1)}%;background:${COMP[j].col}" title="${COMP[j].name}: ${Math.round(v)}"></i>`).join('');
    const pill = isStop(x) ? '<span class="tp stop">Baustopp</span>' : x.crit === 'Kritisch' ? '<span class="tp crit">Kritisch</span>' : `<span class="tp low">${esc(x.crit || 'ohne Kritikalität')}</span>`;
    const urgent = x.sa_days != null && x.sa_days <= 14;
    const graph = x.ck ? ` · <a href="/hub/module?tab=graph&family=${encodeURIComponent(x.fam)}&ck=${encodeURIComponent(x.ck)}" title="in der Hierarchie zeigen">${esc(x.ck)}${x.mod ? ' ' + esc(x.mod) : ''}</a>` : ' · ohne Modul';
    return `<div class="trow${isStop(x) ? ' stop' : ''}${f.nr === x.nr ? ' sel' : ''}" data-nr="${esc(x.nr)}">`
      + `<div class="rk">${i + 1}</div>`
      + `<div class="r-main"><div class="r-l1">${pill}<span class="mono">${esc(x.nr)}</span><span class="t">${esc(x.txt)}</span></div>`
      + `<div class="r-l2"><span>${esc(x.project)}${x.esn ? ' · ESN ' + esc(x.esn) : ''} · ${esc(x.typ)}${x.cust ? ' · ' + esc(x.cust) : ''}${graph}</span>`
      + (x.cyc != null ? `<span class="tp cyc">${CYC_SHORT[x.cyc]}</span>` : '')
      + (urgent ? `<span class="tp sa">${saText(x)}</span>` : `<span>${saText(x)}</span>`)
      + (x.hot && x.age < 14 ? '<span class="tp hot">Häufung</span>' : '') + `<span>${esc(x.grp)}</span></div>`
      + `<div class="why">Warum hier: ${why(x, s)}</div></div>`
      + `<div class="r-score"><div class="tot"><span>Prioritätswert</span><b>${Math.round(s.tot)}</b></div><div class="sbar">${bar}</div></div></div>`;
  }).join('') + (MODE && all > MODE ? `<div class="more-note">${all - MODE} weitere · „Alle“ zeigt die ganze Liste</div>` : '')
    + (!MODE && all > CAP ? `<div class="more-note">die ersten ${CAP} von ${fmt(all)} · Bearbeitergruppe oder Filter wählen, um einzugrenzen</div>` : '')
    + (!all ? '<div class="empty">Keine offenen Incidents für diese Auswahl.</div>' : '');
  if (!reduce) list.querySelectorAll('.trow').forEach(r => {
    const old = before[r.dataset.nr]; if (old === undefined) return;
    const dy = old - r.getBoundingClientRect().top; if (!dy) return;
    r.style.transition = 'none'; r.style.transform = `translateY(${dy}px)`;
    requestAnimationFrame(() => { r.style.transition = 'transform .45s cubic-bezier(.4,0,.2,1)'; r.style.transform = ''; });
  });
  if (f.nr) { const r = list.querySelector('.trow.sel'); if (r) r.scrollIntoView({block: 'center'}); }
}
function clearF() { FILTER = {}; renderList(); }
function showTab(t) {
  $('#pane-lage').hidden = t !== 'lage'; $('#pane-liste').hidden = t !== 'liste';
  $('#tab-lage').classList.toggle('on', t === 'lage'); $('#tab-liste').classList.toggle('on', t === 'liste');
  try { localStorage.setItem('sap_triage_tab', t); } catch (e) {}
  if (t === 'lage') startLoop();
  if (location.hash !== '#' + t) history.replaceState(null, '', '#' + t);
}
let start = location.hash === '#liste' ? 'liste' : location.hash === '#lage' ? 'lage' : 'lage';
if (!location.hash) { try { start = localStorage.getItem('sap_triage_tab') || 'lage'; } catch (e) {} }
showTab(start);
addEventListener('resize', () => requestAnimationFrame(tick));
load();
</script>
</body>
</html>
"""


@app.route('/priorisierung')
def triage_page():
    return TRIAGE_HTML


# UI-Sprache: gemeinsamen Rahmen in alle Seiten einsetzen (siehe SAP_BASE_CSS)
# ══════════════════════════════════════════════════════════════════════════════
# 08 Datencheck — Erstlauf-Prüfung mit Ampeln
# Fasst /api/incidents/diag und die übrigen Quellen zu Prüfpunkten zusammen, damit
# nach dem ersten Lauf auf echten Daten (oder nach einem Umzug) niemand JSON lesen
# muss. Grün = passt, Gelb = ansehen/fachlich klären, Rot = Zahlen sind falsch.
# Schwellen sind bewusst grob; sie sollen Ausreißer finden, nicht Qualität messen.
# ══════════════════════════════════════════════════════════════════════════════
CHECK_OPEN_STATES = {'neu', 'new', 'in bearbeitung', 'in progress', 'angehalten', 'on hold'}
CHECK_REQUIRED_COLS = ('number', 'wbs', 'created', 'criticality')   # ohne sie keine Auswertung
CHECK_EXPORT_MAX_AGE_D = 7         # SAP-CSV älter → gelb
CHECK_INC_MAX_AGE_H = 24           # Incident-Stand älter → gelb
CHECK_MODULE_SHARE = (0.10, 0.40)  # erwarteter Anteil Modul-Visits (bisher ~23 %)
CHECK_BAUSTOPP_WARN = 0.50         # mehr Baustopp als das → fachlich prüfen


def _pct(a, b) -> float:
    return (100.0 * a / b) if b else 0.0


def _chk(title, status, value='', detail='', hint=''):
    return {'title': title, 'status': status, 'value': value, 'detail': detail, 'hint': hint}


def _check_cycle_counts(meta: dict):
    """Incidents je Zyklus OHNE Filter — zeigt, was der Zyklus-Filter weglässt."""
    db, table = meta.get('database'), meta.get('table')
    if not db or not table or '.' not in table:
        return None
    cols = _incident_columns(db, table)
    if 'cycle' not in cols:
        return None
    c = cols['cycle']
    _, rows, _ = _sql_query(f'SELECT [{c}] AS c, COUNT(*) AS n FROM {table} GROUP BY [{c}]',
                            database=db, timeout=60, cap=0)
    return {(r[0] or '').strip() or '–': int(r[1]) for r in rows}


def erstlauf_checks() -> dict:
    groups = []
    cfg = load_config()
    try:
        with open(CONFIG_PATH, encoding='utf-8') as f:
            stored_cfg = json.loads(f.read().strip() or '{}')
    except Exception:
        stored_cfg = {}

    # ── 1 Quellen ────────────────────────────────────────────────────────────
    q = []
    q.append(_chk('Demo-Modus aus', 'crit' if MTUHUB_DEMO else 'ok',
                  'DEMO aktiv' if MTUHUB_DEMO else 'echte Daten',
                  'Incidents, Module und Teile kommen aus .demo/ (Fake-Daten).' if MTUHUB_DEMO else '',
                  'MTUHUB_DEMO aus der Umgebung bzw. "mtuhub_demo" aus sap_config.json entfernen, neu starten.'
                  if MTUHUB_DEMO else ''))
    missing = _check_static_assets(quiet=True)
    q.append(_chk('Bibliotheken in static/', 'crit' if missing else 'ok',
                  (f'{len(missing)} fehlen' if missing else 'vollständig'),
                  ', '.join(missing),
                  'Dateien neben das Skript in static/ kopieren — ohne sie bleiben Diagramme bzw. '
                  'die Hierarchie leer.' if missing else ''))
    engines, claims = get_data()
    for key, label, n in (('export_path', 'SAP Dataexport', len(engines)),
                          ('claim_path', 'SAP Claimexport', len(claims))):
        path = cfg.get(key) or ''
        if not os.path.isfile(path):
            q.append(_chk(label, 'crit', 'Datei fehlt', path, 'Pfad unter 07 Einstellungen prüfen.'))
            continue
        age_d = (time.time() - os.path.getmtime(path)) / 86400
        st = 'crit' if not n else 'warn' if age_d > CHECK_EXPORT_MAX_AGE_D else 'ok'
        q.append(_chk(label, st, f'{n:,} Projekte'.replace(',', '.') if key == 'export_path'
                      else f'{n:,} Projekte mit Claims'.replace(',', '.'),
                      f'{path} · Stand vor {age_d:.1f} Tagen',
                      'Datei ist leer oder nicht lesbar.' if not n else
                      f'Älter als {CHECK_EXPORT_MAX_AGE_D} Tage — läuft der Export noch?' if st == 'warn' else ''))

    d = incident_diag()
    meta = d['meta']
    total = d['incidents']
    if meta.get('stale'):
        st, val = 'crit', 'alter Snapshot'
    elif not meta.get('ok'):
        st, val = 'crit', 'nicht geladen'
    else:
        st, val = 'ok', 'live aus SQL'
    q.append(_chk('Incident-Quelle', st, val,
                  f"{meta.get('database', '')} · {meta.get('table', '')}"
                  + (f" · Fehler: {meta['error']}" if meta.get('error') else ''),
                  'Die Seiten zeigen den zuletzt gespeicherten Stand, nicht die Datenbank.'
                  if meta.get('stale') else ''))
    age_h = None
    try:
        age_h = (datetime.datetime.now()
                 - datetime.datetime.fromisoformat(meta.get('ts'))).total_seconds() / 3600
    except Exception:
        pass
    q.append(_chk('Incident-Stand aktuell',
                  'warn' if age_h is None or age_h > CHECK_INC_MAX_AGE_H else 'ok',
                  meta.get('ts') or '–',
                  f'vor {age_h:.1f} h geladen' if age_h is not None else 'kein Zeitstempel',
                  'Über /api/reload bzw. Speichern in 07 Einstellungen neu laden.'
                  if age_h is None or age_h > CHECK_INC_MAX_AGE_H else ''))
    q.append(_chk('Abfrage vollständig', 'crit' if meta.get('truncated') or not total else 'ok',
                  'abgeschnitten' if meta.get('truncated') else f'{total:,} Zeilen'.replace(',', '.'),
                  '', 'Zeilenlimit greift — Zahlen sind zu klein.' if meta.get('truncated') else
                  'Keine Incidents — Quelle oder Zyklus-Filter prüfen.' if not total else ''))
    groups.append({'name': 'Quellen', 'q': 'Kommen die Daten von dort, wo sie herkommen sollen?',
                   'checks': q})

    # ── 2 Zuordnung ──────────────────────────────────────────────────────────
    z = []
    cols = d.get('spalten_mapping') or {}
    miss_req = [c for c in CHECK_REQUIRED_COLS if c not in cols]
    miss_opt = [c for c in ('state', 'resolved', 'closed', 'cycle', 'group', 'assign_group',
                            'category', 'reason', 'cause') if c not in cols]
    z.append(_chk('Spalten gefunden',
                  'crit' if miss_req or '_fehler' in cols else 'warn' if miss_opt else 'ok',
                  f'{len(cols)} zugeordnet',
                  ('fehlt Pflicht: ' + ', '.join(miss_req) + ' · ' if miss_req else '')
                  + ('fehlt optional: ' + ', '.join(miss_opt) if miss_opt else '')
                  + (cols.get('_fehler', '') if '_fehler' in cols else ''),
                  'Spaltennamen in der Hub-Tabelle weichen ab — INCIDENT_COLUMNS im Code ergänzen.'
                  if miss_req or miss_opt else ''))
    z.append(_chk('Status- und Projekttabelle', 'ok' if meta.get('state_source') and meta.get('project_source')
                  else 'warn',
                  'beide verknüpft' if meta.get('state_source') and meta.get('project_source') else 'unvollständig',
                  f"Status: {meta.get('state_source') or '–'} · Projekt: {meta.get('project_source') or '–'}",
                  '' if meta.get('project_source') else
                  'Ohne x_mtuae_ins_project fehlen Engine-Typ und Kunde an Incidents ohne SAP-Export.'))
    rows = meta.get('rows') or 0
    unj = meta.get('unjoined') or 0
    p = _pct(unj, rows)
    z.append(_chk('WBS → Projekt', 'ok' if p < 1 else 'warn' if p < 5 else 'crit',
                  f'{p:.1f} % ohne Projekt', f'{unj} von {rows} Zeilen ohne verwertbare WBS',
                  '_wbs_base() passt nicht zur WBS-Form in der Datenbank.' if p >= 1 else ''))
    pr = d['projekt']
    p = _pct(pr['mit_engine_typ'], total)
    z.append(_chk('Engine-Typ am Incident', 'ok' if p >= 99 else 'warn' if p >= 90 else 'crit',
                  f'{p:.1f} %', f"{pr['mit_engine_typ']} von {total}", ''))
    both = pr['familie_gleich'] + pr['familie_abweichend']
    p = _pct(pr['familie_abweichend'], both)
    z.append(_chk('Familie SAP ⇄ Hub gleich', 'ok' if p < 0.5 else 'warn' if p < 3 else 'crit',
                  f"{pr['familie_abweichend']} abweichend",
                  ' · '.join(f'{k} ({n})' for k, n in pr['abweichungen_top10']) or
                  f"{pr['familie_gleich']} gleich",
                  'engine_families.json um die abweichenden Typen ergänzen.' if pr['familie_abweichend'] else ''))
    no_sap = pr['ohne_sap_export_aber_mit_projekt']
    p = _pct(no_sap, total)
    z.append(_chk('Incidents mit SAP-Export', 'ok' if p < 3 else 'warn' if p < 10 else 'crit',
                  f'{p:.1f} % ohne Export',
                  f"{no_sap} Incidents in {d['projekte_ohne_engine']} Projekten fehlen im ZLSGS-Export",
                  'Diese Incidents fehlen überall, wo SLU/TAT/Delay gebraucht wird.' if p >= 3 else ''))
    groups.append({'name': 'Zuordnung', 'q': 'Finden Incidents, Projekte und Familien zueinander?',
                   'checks': z})

    # ── 3 Werte ──────────────────────────────────────────────────────────────
    w = []
    unk = d.get('warnung_kritikalitaet') or []
    w.append(_chk('Kritikalität bekannt', 'crit' if unk else 'ok',
                  f'{len(unk)} unbekannte Werte' if unk else 'nur Baustopp / Kritisch / Nicht kritisch',
                  ', '.join(unk) or ' · '.join(f'{k} {v}' for k, v in d['kritikalitaet'].items()),
                  'Diese Werte fallen aus Baustopp-Zählung und Priorisierung.' if unk else ''))
    status = d.get('status') or {}
    unk_st = {k: v for k, v in status.items()
              if k != '–' and k.lower() not in _INC_DONE and k.lower() not in CHECK_OPEN_STATES}
    w.append(_chk('Status-Werte bekannt', 'warn' if unk_st else 'ok',
                  f'{len(unk_st)} unbekannt' if unk_st else f'{len(status)} Ausprägungen',
                  ' · '.join(f'{k} {v}' for k, v in (unk_st or status).items()),
                  'Unbekannte Status zählen als offen — in _INC_DONE bzw. CHECK_OPEN_STATES eintragen.'
                  if unk_st else ''))
    empty_st = status.get('–', 0)
    w.append(_chk('Status gefüllt', 'ok' if _pct(empty_st, total) < 0.5 else 'warn',
                  f'{empty_st} leer', 'weder task.state noch stage gesetzt — zählen als offen', ''))

    incidents = get_incidents()
    open_res = done_nores = done = 0
    no_date = 0
    for lst in incidents.values():
        for i in lst:
            stt = (i.get('state') or '').strip().lower()
            is_done = stt in _INC_DONE
            if not i.get('created_on'):
                no_date += 1
            if is_done:
                done += 1
                if i.get('resolution_h') is None:
                    done_nores += 1
            elif i.get('resolved_at'):
                open_res += 1
    p = _pct(done_nores, done)
    w.append(_chk('Lösezeit an erledigten', 'ok' if p < 2 else 'warn',
                  f'{p:.1f} % ohne', f'{done_nores} von {done} erledigten ohne Lösedatum',
                  'resolved_at/closed_at wird nicht gefüllt oder nicht erkannt.' if p >= 2 else ''))
    w.append(_chk('Offen mit Lösedatum', 'ok' if _pct(open_res, total) < 1 else 'warn',
                  f'{open_res} Incidents',
                  'offen laut Status, aber resolved_at gesetzt — meist wiedereröffnet; '
                  'Lösungszeit ist dann die der ersten Lösung', ''))
    groups.append({'name': 'Werte', 'q': 'Kennt der Code alle Textwerte, die er wörtlich vergleicht?',
                   'checks': w})

    # ── 4 Plausibilität ──────────────────────────────────────────────────────
    pl = []
    cyc = (cfg.get('incident_cycle') or '').strip()
    explicit = 'incident_cycle' in stored_cfg
    try:
        per_cycle = _check_cycle_counts(meta) if cyc else None
    except Exception as e:
        per_cycle = {'_fehler': str(e)}
    if not cyc:
        pl.append(_chk('Zyklus-Filter', 'ok', 'aus — alle Zyklen', '', ''))
    else:
        if per_cycle and '_fehler' not in per_cycle:
            allc = sum(per_cycle.values())
            dist = ' · '.join(f'{k} {v}' for k, v in sorted(per_cycle.items()))
            detail = f'lädt {per_cycle.get(cyc, 0):,} von {allc:,} Incidents — {dist}'.replace(',', '.')
        else:
            detail = 'Verteilung ohne Filter nicht abrufbar' + (
                f": {per_cycle['_fehler']}" if per_cycle else '')
        pl.append(_chk('Zyklus-Filter', 'info' if explicit else 'warn', f'„{cyc}"',
                       detail + ('' if explicit else ' · Wert kommt aus dem Code-Default, nicht aus sap_config.json'),
                       'Bewusst so? Dann in 07 Einstellungen einmal speichern, damit er explizit gesetzt ist; '
                       'leer lädt alle Zyklen.' if not explicit else ''))
    years = {int(y): n for y, n in d['jahre'].items()}
    now_y = datetime.date.today().year
    odd = [y for y in years if y < 2015 or y > now_y]
    gaps = [y for y in range(min(years), max(years)) if y not in years] if years else []
    pl.append(_chk('Jahre plausibel', 'warn' if odd or gaps or _pct(no_date, total) >= 1 else 'ok',
                   (f'{min(years)}–{max(years)}' if years else '–'),
                   ' · '.join(f'{y}: {n}' for y, n in sorted(years.items()))
                   + (f' · ohne Datum: {no_date}' if no_date else ''),
                   ('Ausreißer-Jahre ' + ', '.join(map(str, odd)) + ' — Null-/Platzhalterdaten? ' if odd else '')
                   + ('Lücke ' + ', '.join(map(str, gaps)) if gaps else '')))
    bs = d['kritikalitaet'].get('Baustopp', 0)
    p = _pct(bs, total)
    pl.append(_chk('Anteil Baustopp', 'warn' if p > CHECK_BAUSTOPP_WARN * 100 else 'ok',
                   f'{p:.0f} %', f'{bs} von {total}',
                   'Sehr hoch — ist Baustopp der Default beim Anlegen? Dann trennt die Priorisierung kaum.'
                   if p > CHECK_BAUSTOPP_WARN * 100 else ''))
    n_mod = sum(1 for e in engines.values() if e.get('visit_kind') == VISIT_MODULE)
    p = _pct(n_mod, len(engines))
    lo, hi = CHECK_MODULE_SHARE
    pl.append(_chk('Anteil Modul-Visits', 'ok' if lo * 100 <= p <= hi * 100 else 'warn',
                   f'{p:.0f} %', f'{n_mod} von {len(engines)} Shop Visits (bisher ~23 %)',
                   'Erkennung (Modul-SN 30X…, Workscope „- M -") greift auf diesen Daten anders.'
                   if not lo * 100 <= p <= hi * 100 else ''))
    groups.append({'name': 'Plausibilität', 'q': 'Passen die Größenordnungen zum Fachwissen?',
                   'checks': pl})

    # ── 5 Modul-Hierarchie ───────────────────────────────────────────────────
    mh = []
    m = module_matrix()
    if _matrix['running']:
        mh.append(_chk('Modul-Matrix', 'info', 'wird gebaut',
                       f"seit {int(time.time() - _matrix['started'])} s", 'Seite später neu prüfen.'))
    elif _matrix['error']:
        mh.append(_chk('Modul-Matrix', 'crit', 'Fehler', _matrix['error'], ''))
    elif m is None:
        mh.append(_chk('Modul-Matrix', 'info', 'noch nicht gebaut', '',
                       '04 Modul-Katalog öffnen — der Bau startet dann im Hintergrund.'))
    else:
        fams = sorted((m.get('tree') or {}))
        old = m.get('version') != MATRIX_VERSION
        mh.append(_chk('Modul-Matrix', 'warn' if old or not fams else 'ok',
                       f'{len(fams)} Familien mit Baum', ', '.join(fams),
                       'Veralteter Cache — wird beim nächsten Aufruf neu gebaut.' if old else
                       'Kein ECMS-Baum — Hierarchie 04.3 bleibt leer.' if not fams else ''))
    groups.append({'name': 'Modul-Hierarchie', 'q': 'Steht das Gerüst für 04.1–04.4?', 'checks': mh})

    if not total:
        # ohne Incidents sagen Anteile nichts — nicht grün „0 von 0" zeigen
        for g in groups[1:4]:
            for c in g['checks']:
                if c['title'] not in ('Spalten gefunden', 'Anteil Modul-Visits', 'Zyklus-Filter'):
                    c.update(status='info', value='keine Incidents', hint='')
    cnt = Counter(c['status'] for g in groups for c in g['checks'])
    return {'groups': groups, 'summary': dict(cnt), 'ts': datetime.datetime.now().isoformat(timespec='seconds'),
            'demo': MTUHUB_DEMO}


@app.route('/api/check')
def api_check():
    try:
        return jsonify(erstlauf_checks())
    except Exception as e:
        return jsonify({'error': f'{type(e).__name__}: {e}'})


CHECK_HTML = r"""<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title>SAP Claim Analyzer – Datencheck</title>
<!--SAP_BASE-->
<style>
  *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
  body { font-family: var(--font); background: var(--bg); color: var(--text); font-size: 13px; }
  .ck-sum { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; }
  .ck-pill { display: inline-flex; align-items: center; gap: 7px; padding: 5px 12px; border-radius: var(--r-pill);
             border: 1px solid var(--line); background: var(--surface); font-weight: 600; font-size: 12px; }
  .ck-verdict { font-size: var(--fs-m); font-weight: 500; margin-right: 8px; }
  .ck-list { display: flex; flex-direction: column; border: 1px solid var(--line); border-radius: var(--r-m);
             background: var(--surface); overflow: hidden; }
  .ck-row { display: grid; grid-template-columns: 22px minmax(160px, 230px) minmax(110px, 190px) minmax(0, 1fr);
            gap: 4px 14px; padding: 11px 14px; border-top: 1px solid var(--line); align-items: baseline; }
  .ck-row:first-child { border-top: 0; }
  .ck-title { font-weight: 600; }
  .ck-val { font-family: var(--mono); font-size: 12px; }
  .ck-detail { color: var(--ink-2); line-height: 1.5; overflow-wrap: anywhere; }
  .ck-hint { grid-column: 2 / -1; font-size: 12px; line-height: 1.5; }
  .ck-row.warn .ck-hint { color: var(--warn); }
  .ck-row.crit .ck-hint { color: var(--crit); }
  .ck-row.crit { background: var(--crit-bg); }
  .lamp { width: 12px; height: 12px; border-radius: 50%; display: inline-block; flex: none;
          transform: translateY(1px); }
  .lamp.ok   { background: var(--ok); }
  .lamp.warn { background: #d9a400; }
  .lamp.crit { background: var(--crit); }
  .lamp.info { background: var(--info-2); }
  .lamp.todo { background: transparent; border: 1.5px solid var(--ink-3); }
  .ck-man label { display: contents; cursor: pointer; }
  .ck-man input { margin-top: 2px; }
  #ck-err { color: var(--crit); font-weight: 600; }
  @media (max-width: 760px) {
    .ck-row { grid-template-columns: 22px minmax(0, 1fr); }
    .ck-val, .ck-detail { grid-column: 2; }
  }
</style>
<!--SAP_SHELL-->
</head>
<body>
<!--SAP_RAIL-->
<header class="sap-ph"><!--SAP_PH-->
<div class="ph-right">
<!--SAP_MARK-->
</div>
</header>

<div class="sap-page">
<section class="sap-sec">
 <div class="sap-sec-head">
  <span class="eyebrow">Ergebnis</span>
  <h2>Kann man den Zahlen trauen?</h2>
  <p>Rot = Zahlen sind falsch oder unvollständig. Gelb = ansehen oder fachlich klären. Grün = passt.
     Blau = Hinweis ohne Bewertung.</p>
  <div class="sap-sec-ctl">
   <button class="btn" onclick="load()">Neu prüfen</button>
   <button class="btn" onclick="copyReport(this)">Als Text kopieren</button>
  </div>
 </div>
 <div class="sap-sec-body">
  <div class="ck-sum" id="ck-sum">Prüfe …</div>
  <div id="ck-err"></div>
 </div>
</section>
<div id="ck-groups"></div>

<section class="sap-sec">
 <div class="sap-sec-head">
  <span class="eyebrow">Von Hand</span>
  <h2>Stichproben</h2>
  <p>Was kein Code prüfen kann. Abhaken merkt sich dieser Browser.</p>
 </div>
 <div class="sap-sec-body"><div class="ck-list" id="ck-man"></div></div>
</section>
</div>

<script>
const LABEL = {ok: 'passt', warn: 'ansehen', crit: 'falsch', info: 'Hinweis'};
const MANUAL = [
  ['esn', '2–3 bekannte ESNs', 'Anzahl Incidents in 01 Engine-Liste gegen ServiceNow bzw. eigenes Wissen.'],
  ['baustopp', 'Baustopp-Einstufung', '5 Baustopp-Incidents öffnen: steht die Arbeit dort wirklich?'],
  ['offen', 'Offene Incidents', '06 Priorisierung: sind die offenen die, die du als offen kennst?'],
  ['graph', 'Hierarchie 04.3', 'Eine bekannte ESN als Bereich: passen die verbauten Teile?'],
  ['modul', 'Modul-Visits', 'Schalter „nur Module": sind das wirklich Modul-Aufträge?'],
];
let DATA = null;
const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;'}[c]));
function manState() { try { return JSON.parse(localStorage.getItem('sap_check_manual') || '{}'); } catch (e) { return {}; } }
function manSet(k, v) { const s = manState(); s[k] = v; try { localStorage.setItem('sap_check_manual', JSON.stringify(s)); } catch (e) {} renderManual(); }

function row(c) {
  return '<div class="ck-row ' + c.status + '"><span class="lamp ' + c.status + '" title="' + LABEL[c.status] + '"></span>'
    + '<span class="ck-title">' + esc(c.title) + '</span><span class="ck-val">' + esc(c.value) + '</span>'
    + '<span class="ck-detail">' + esc(c.detail) + '</span>'
    + (c.hint ? '<span class="ck-hint">' + esc(c.hint) + '</span>' : '') + '</div>';
}

function render(d) {
  const s = d.summary || {};
  const verdict = s.crit ? 'Nicht vertrauen — erst die roten Punkte lösen'
    : s.warn ? 'Technisch sauber — gelbe Punkte fachlich klären' : 'Alles grün';
  document.getElementById('ck-sum').innerHTML = '<span class="ck-verdict">' + verdict + '</span>'
    + ['crit', 'warn', 'ok', 'info'].filter(k => s[k]).map(k =>
      '<span class="ck-pill"><span class="lamp ' + k + '"></span>' + s[k] + ' ' + LABEL[k] + '</span>').join('')
    + '<span class="ck-pill" style="font-weight:400;color:var(--ink-2)">geprüft ' + esc(d.ts.replace('T', ' ')) + '</span>';
  document.getElementById('ck-groups').innerHTML = d.groups.map((g, gi) => {
    const worst = ['crit', 'warn', 'info', 'ok'].find(k => g.checks.some(c => c.status === k)) || 'ok';
    return '<section class="sap-sec"><div class="sap-sec-head"><span class="eyebrow">' + String(gi + 1).padStart(2, '0')
      + '</span><h2><span class="lamp ' + worst + '"></span> ' + esc(g.name) + '</h2><p>' + esc(g.q) + '</p></div>'
      + '<div class="sap-sec-body"><div class="ck-list">' + g.checks.map(row).join('') + '</div></div></section>';
  }).join('');
}

function renderManual() {
  const st = manState();
  document.getElementById('ck-man').innerHTML = MANUAL.map(([k, t, h]) =>
    '<div class="ck-row ck-man"><label><input type="checkbox" ' + (st[k] ? 'checked' : '')
    + ' onchange="manSet(\'' + k + '\', this.checked)"><span class="ck-title">' + esc(t) + '</span>'
    + '<span class="ck-val">' + (st[k] ? 'geprüft' : 'offen') + '</span>'
    + '<span class="ck-detail">' + esc(h) + '</span></label></div>').join('');
}

async function load() {
  document.getElementById('ck-sum').textContent = 'Prüfe …';
  document.getElementById('ck-err').textContent = '';
  try {
    const d = await (await fetch('/api/check')).json();
    if (d.error) throw new Error(d.error);
    DATA = d; render(d);
  } catch (e) {
    document.getElementById('ck-sum').textContent = '';
    document.getElementById('ck-err').textContent = 'Prüfung fehlgeschlagen: ' + e.message;
  }
}

function copyReport(b) {
  if (!DATA) return;
  const mark = {ok: '[OK]  ', warn: '[GELB]', crit: '[ROT] ', info: '[INFO]'};
  const lines = ['SAP Claim Analyzer – Datencheck ' + DATA.ts, ''];
  DATA.groups.forEach(g => {
    lines.push('== ' + g.name);
    g.checks.forEach(c => {
      lines.push(mark[c.status] + ' ' + c.title + ': ' + c.value + (c.detail ? ' — ' + c.detail : ''));
      if (c.hint) lines.push('        → ' + c.hint);
    });
    lines.push('');
  });
  navigator.clipboard.writeText(lines.join('\n')).then(
    () => { const t = b.textContent; b.textContent = 'Kopiert'; setTimeout(() => b.textContent = t, 1200); });
}

renderManual();
load();
</script>
</body>
</html>
"""


@app.route('/check')
def check_page():
    return CHECK_HTML


HTML = sap_shell(HTML, 'engines')
DASHBOARD_HTML = sap_shell(DASHBOARD_HTML, 'dashboard')
SQL_HTML = sap_shell(SQL_HTML, 'sql')
HUB_HTML = sap_shell(HUB_HTML, 'hub')
MODULE_HTML = sap_shell(MODULE_HTML, 'module')
MODSHELL_HTML = sap_shell(MODSHELL_HTML, 'module')
MODVOR_HTML = sap_shell(MODVOR_HTML, 'module')
MODGRAPH_HTML = sap_shell(MODGRAPH_HTML, 'module')
STRUCT_HTML = sap_shell(STRUCT_HTML, 'module')
WSCLASS_HTML = sap_shell(WSCLASS_HTML, 'ws')
SETTINGS_HTML = sap_shell(SETTINGS_HTML, 'settings')
TRIAGE_HTML = sap_shell(TRIAGE_HTML, 'triage')
CHECK_HTML = sap_shell(CHECK_HTML, 'check')



def _check_static_assets(quiet: bool = False):
    """Warnt beim Start, wenn eine Seite /static/<datei> einbindet, die im
    static/-Ordner fehlt — sonst merkt man es erst am 404 in der Browser-Konsole.
    Die Liste wird aus den HTML-Vorlagen gelesen, muss also nicht gepflegt werden."""
    refs = set()
    for v in list(globals().values()):
        if isinstance(v, str) and '/static/' in v:
            refs.update(re.findall(r'/static/([\w./-]+\.(?:js|css))', v))
    missing = sorted(f for f in refs if not os.path.isfile(os.path.join(STATIC_DIR, f)))
    if missing and not quiet:
        print('*** WARNUNG: fehlende Dateien in ' + STATIC_DIR + ':')
        for f in missing:
            print('***   ' + f)
    return missing


if __name__ == '__main__':
    if MTUHUB_DEMO:
        print('*** DEMO-MODUS: Incidents/Module/Teile aus .demo/ (Fake-Daten), NICHT aus der MTU-Hub-SQL ***')
    _check_static_assets()
    print('Loading data ...')
    _get_cache()          # parse before the first request, not on the first click
    print('SAP Claim Analyzer → http://localhost:5015')
    print('Dashboard          → http://localhost:5015/dashboard')
    app.run(port=5099, debug=False)