Add Schadenprotokoll SPA with Python Flask API

New tool for processing damage protocols:
- Tab 1: Generate pre-filled PDF from Schadenlaufzettel.docx
- Tab 2: Analyze filled PDF forms and extract dropdown selections
- Tab 3: Generate Vorbericht.docx from filled PDF data

Backend: Flask API with pypdf and python-docx
Frontend: Vanilla JS SPA with drag-drop file upload

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-21 11:45:28 +00:00
parent da7104bc97
commit c176c7ce39
8 changed files with 626 additions and 0 deletions

1
.gitignore vendored
View File

@@ -17,3 +17,4 @@ kostenschaetzung/
schadendokumentation/
zeitwert/
.claude/
__pycache__/

109
schadenprotokoll/api/app.py Normal file
View File

@@ -0,0 +1,109 @@
#!/usr/bin/env python3
"""
Schadenprotokoll API - Flask Backend
Endpunkte: /generate, /analyze, /vorbericht
"""
from flask import Flask, request, jsonify, send_file
from flask_cors import CORS
import tempfile
import os
from processors import parse_laufzettel, fill_pdf, analyze_pdf, generate_vorbericht
app = Flask(__name__)
CORS(app)
TEMPLATE_PDF = "/opt/stacks/spa-hosting/html/schadenprotokoll/templates/protokoll.pdf"
TEMPLATE_DOCX = "/opt/stacks/spa-hosting/html/schadenprotokoll/templates/vorbericht.docx"
@app.route("/health", methods=["GET"])
def health():
return jsonify({"status": "ok"})
@app.route("/generate", methods=["POST"])
def generate():
"""Laufzettel.docx -> vorausgefuelltes Protokoll.pdf"""
if "file" not in request.files:
return jsonify({"error": "Keine Datei hochgeladen"}), 400
file = request.files["file"]
if not file.filename.endswith(".docx"):
return jsonify({"error": "Nur .docx Dateien erlaubt"}), 400
with tempfile.NamedTemporaryFile(suffix=".docx", delete=False) as tmp_in:
file.save(tmp_in.name)
try:
data = parse_laufzettel(tmp_in.name)
with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as tmp_out:
fill_pdf(TEMPLATE_PDF, tmp_out.name, data)
os.unlink(tmp_in.name)
return send_file(
tmp_out.name,
mimetype="application/pdf",
as_attachment=True,
download_name="Schadenprotokoll_vorbefuellt.pdf"
)
except Exception as e:
os.unlink(tmp_in.name)
return jsonify({"error": str(e)}), 500
@app.route("/analyze", methods=["POST"])
def analyze():
"""Ausgefuelltes PDF -> JSON mit allen Feldern"""
if "file" not in request.files:
return jsonify({"error": "Keine Datei hochgeladen"}), 400
file = request.files["file"]
if not file.filename.endswith(".pdf"):
return jsonify({"error": "Nur .pdf Dateien erlaubt"}), 400
with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as tmp:
file.save(tmp.name)
try:
result = analyze_pdf(tmp.name)
os.unlink(tmp.name)
return jsonify({"success": True, "data": result})
except Exception as e:
os.unlink(tmp.name)
return jsonify({"error": str(e)}), 500
@app.route("/vorbericht", methods=["POST"])
def vorbericht():
"""Ausgefuelltes PDF -> Vorbericht.docx"""
if "file" not in request.files:
return jsonify({"error": "Keine Datei hochgeladen"}), 400
file = request.files["file"]
if not file.filename.endswith(".pdf"):
return jsonify({"error": "Nur .pdf Dateien erlaubt"}), 400
with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as tmp_in:
file.save(tmp_in.name)
try:
pdf_data = analyze_pdf(tmp_in.name)
with tempfile.NamedTemporaryFile(suffix=".docx", delete=False) as tmp_out:
generate_vorbericht(pdf_data, TEMPLATE_DOCX, tmp_out.name)
os.unlink(tmp_in.name)
return send_file(
tmp_out.name,
mimetype="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
as_attachment=True,
download_name="Vorbericht.docx"
)
except Exception as e:
os.unlink(tmp_in.name)
return jsonify({"error": str(e)}), 500
if __name__ == "__main__":
app.run(host="0.0.0.0", port=5050, debug=False)

View File

@@ -0,0 +1,136 @@
"""
Schadenprotokoll Processors - PDF/DOCX Verarbeitung
"""
from docx import Document
from docx.oxml.ns import qn
from pypdf import PdfReader, PdfWriter
import zipfile
import xml.etree.ElementTree as ET
def parse_laufzettel(docx_path: str) -> dict:
"""Extrahiert Schadensdaten aus dem Laufzettel.docx"""
data = {}
with zipfile.ZipFile(docx_path, "r") as z:
xml_content = z.read("word/document.xml")
root = ET.fromstring(xml_content)
ns = {"w": "http://schemas.openxmlformats.org/wordprocessingml/2006/main"}
for sdt in root.findall(".//w:sdt", ns):
alias = sdt.find(".//w:alias", ns)
if alias is not None:
alias_val = alias.get(qn("w:val"))
texts = sdt.findall(".//w:t", ns)
content = " ".join([t.text for t in texts if t.text]).strip()
field_map = {
"Schadennummer": "Schadennummer",
"Versicherungsnehmer": "Versicherungsnehmer:",
"Versicherer": "Versicherer:",
"Datum": "Datum:",
"Schadenort": "Schadenort:",
"Schadenart": "Schadenart:",
"e-Mail": "e-Mail VN:",
}
if alias_val in field_map:
data[field_map[alias_val]] = content
elif alias_val == "Straße":
data["Adresse:"] = content
elif alias_val == "Ort":
if "Adresse:" in data:
data["Adresse:"] += ", " + content
else:
data["Adresse:"] = content
return data
def fill_pdf(template_path: str, output_path: str, data: dict) -> str:
"""Befuellt das PDF-Template mit den Schadensdaten"""
reader = PdfReader(template_path)
writer = PdfWriter()
for page in reader.pages:
writer.add_page(page)
if "/Annots" in page:
writer.update_page_form_field_values(
writer.pages[-1], data, auto_regenerate=False
)
with open(output_path, "wb") as f:
writer.write(f)
return output_path
def analyze_pdf(pdf_path: str) -> dict:
"""Liest alle Formularfelder aus dem ausgefuellten PDF"""
reader = PdfReader(pdf_path)
result = {"textfields": {}, "dropdowns": {}, "checkboxes": {}}
for page_num, page in enumerate(reader.pages):
if "/Annots" not in page:
continue
for annot in page["/Annots"]:
obj = annot.get_object()
field_type = obj.get("/FT", "")
field_name = obj.get("/T", f"Feld_{page_num}")
if field_type == "/Tx":
value = obj.get("/V", "")
result["textfields"][field_name] = str(value) if value else ""
elif field_type == "/Ch":
value = obj.get("/V", "")
options = []
if "/Opt" in obj:
for opt in obj["/Opt"]:
if isinstance(opt, list):
options.append(str(opt[1]) if len(opt) > 1 else str(opt[0]))
else:
options.append(str(opt))
result["dropdowns"][field_name] = {
"selected": str(value) if value else "",
"options": options
}
elif field_type == "/Btn":
value = obj.get("/V", "/Off")
result["checkboxes"][field_name] = value != "/Off"
return result
def generate_vorbericht(pdf_data: dict, template_path: str, output_path: str) -> str:
"""Generiert Vorbericht.docx aus PDF-Daten"""
doc = Document(template_path)
tf = pdf_data.get("textfields", {})
replacements = {
"Schadennummer": tf.get("Schadennummer", "xx"),
"Versicherer": tf.get("Versicherer:", "xx"),
"Versicherungsnehmer": tf.get("Versicherungsnehmer:", "xx"),
"Adresse": tf.get("Adresse:", "xx"),
}
# Dropdown-Antworten fuer Sachverhalt sammeln
dropdowns = pdf_data.get("dropdowns", {})
sachverhalt = []
for key in sorted(dropdowns.keys()):
val = dropdowns[key].get("selected", "")
if val and val not in ["Was ist beschädigt?", "Schadenursache?", ""]:
sachverhalt.append(val)
for para in doc.paragraphs:
text = para.text
for key, value in replacements.items():
if key.lower() in text.lower() and "xx" in text:
para.text = text.replace("xx", value, 1)
break
doc.save(output_path)
return output_path

174
schadenprotokoll/app.js Normal file
View File

@@ -0,0 +1,174 @@
// Tab Navigation
document.querySelectorAll(".tab").forEach(tab => {
tab.addEventListener("click", () => {
document.querySelectorAll(".tab").forEach(t => t.classList.remove("active"));
document.querySelectorAll(".tab-content").forEach(c => c.classList.remove("active"));
tab.classList.add("active");
document.getElementById(tab.dataset.tab).classList.add("active");
});
});
// Upload Zone Setup
function setupUploadZone(zoneId, inputId, infoId, btnId) {
const zone = document.getElementById(zoneId);
const input = document.getElementById(inputId);
const info = document.getElementById(infoId);
const btn = document.getElementById(btnId);
zone.addEventListener("click", () => input.click());
zone.addEventListener("dragover", (e) => { e.preventDefault(); zone.classList.add("dragover"); });
zone.addEventListener("dragleave", () => zone.classList.remove("dragover"));
zone.addEventListener("drop", (e) => {
e.preventDefault();
zone.classList.remove("dragover");
if (e.dataTransfer.files.length) {
input.files = e.dataTransfer.files;
updateFileInfo(input, info, btn);
}
});
input.addEventListener("change", () => updateFileInfo(input, info, btn));
}
function updateFileInfo(input, info, btn) {
if (input.files.length) {
const file = input.files[0];
info.innerHTML = `<strong>✓ ${file.name}</strong> (${(file.size / 1024).toFixed(1)} KB)`;
info.classList.add("visible");
btn.disabled = false;
}
}
function showStatus(id, type, message) {
const status = document.getElementById(id);
status.className = `status visible ${type}`;
status.textContent = message;
}
// Setup all upload zones
setupUploadZone("laufzettel-zone", "laufzettel-input", "laufzettel-info", "generate-btn");
setupUploadZone("pdf-zone", "pdf-input", "pdf-info", "analyze-btn");
setupUploadZone("vorbericht-zone", "vorbericht-input", "vorbericht-info", "vorbericht-btn");
// API Base URL
const API_BASE = "/schadenprotokoll/api";
// Generate Button
document.getElementById("generate-btn").addEventListener("click", async () => {
showStatus("generate-status", "loading", "⏳ Verarbeite Laufzettel...");
const file = document.getElementById("laufzettel-input").files[0];
const formData = new FormData();
formData.append("file", file);
try {
const response = await fetch(`${API_BASE}/generate`, {
method: "POST",
body: formData
});
if (\!response.ok) {
const err = await response.json();
throw new Error(err.error || "Unbekannter Fehler");
}
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "Schadenprotokoll_vorbefuellt.pdf";
a.click();
URL.revokeObjectURL(url);
showStatus("generate-status", "success", "✓ PDF erfolgreich generiert und heruntergeladen");
} catch (err) {
showStatus("generate-status", "error", `❌ Fehler: ${err.message}`);
}
});
// Analyze Button
document.getElementById("analyze-btn").addEventListener("click", async () => {
showStatus("analyze-status", "loading", "⏳ Analysiere PDF...");
const file = document.getElementById("pdf-input").files[0];
const formData = new FormData();
formData.append("file", file);
try {
const response = await fetch(`${API_BASE}/analyze`, {
method: "POST",
body: formData
});
if (\!response.ok) {
const err = await response.json();
throw new Error(err.error || "Unbekannter Fehler");
}
const result = await response.json();
displayAnalysisResult(result.data);
showStatus("analyze-status", "success", "✓ Analyse abgeschlossen");
} catch (err) {
showStatus("analyze-status", "error", `❌ Fehler: ${err.message}`);
}
});
function displayAnalysisResult(data) {
const container = document.getElementById("analyze-data");
let html = "";
// Textfelder
if (Object.keys(data.textfields).length > 0) {
html += `<div class="field-group"><h5>Textfelder</h5><div class="field-list">`;
for (const [key, value] of Object.entries(data.textfields)) {
if (value) html += `<div class="dropdown-item"><strong>${key}:</strong> ${value}</div>`;
}
html += `</div></div>`;
}
// Dropdowns
if (Object.keys(data.dropdowns).length > 0) {
html += `<div class="field-group"><h5>Dropdown-Auswahlen (Sachverhalt)</h5><div class="field-list">`;
for (const [key, val] of Object.entries(data.dropdowns)) {
if (val.selected) {
html += `<div class="dropdown-item"><strong>${key}:</strong> ${val.selected}</div>`;
}
}
html += `</div></div>`;
}
container.innerHTML = html || "<p>Keine ausgefüllten Felder gefunden.</p>";
document.getElementById("analyze-result").classList.add("visible");
}
// Vorbericht Button
document.getElementById("vorbericht-btn").addEventListener("click", async () => {
showStatus("vorbericht-status", "loading", "⏳ Erstelle Vorbericht...");
const file = document.getElementById("vorbericht-input").files[0];
const formData = new FormData();
formData.append("file", file);
try {
const response = await fetch(`${API_BASE}/vorbericht`, {
method: "POST",
body: formData
});
if (\!response.ok) {
const err = await response.json();
throw new Error(err.error || "Unbekannter Fehler");
}
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "Vorbericht.docx";
a.click();
URL.revokeObjectURL(url);
showStatus("vorbericht-status", "success", "✓ Vorbericht erfolgreich erstellt und heruntergeladen");
} catch (err) {
showStatus("vorbericht-status", "error", `❌ Fehler: ${err.message}`);
}
});

View File

@@ -0,0 +1,99 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Schadenprotokoll Tool | ass-ass</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<div class="container">
<header>
<h1>Schadenprotokoll Tool</h1>
<p>Automatisierte Verarbeitung von Schadenprotokollen</p>
</header>
<div class="tabs">
<button class="tab active" data-tab="generate">1. Generieren</button>
<button class="tab" data-tab="analyze">2. Auswerten</button>
<button class="tab" data-tab="vorbericht">3. Vorbericht</button>
</div>
<!-- Tab 1: Generieren -->
<div id="generate" class="tab-content active">
<div class="info-box">
<h4>Schadenlaufzettel → Protokoll PDF</h4>
<p>Lädt Daten aus dem Schadenlaufzettel.docx und füllt das Schadenprotokoll-PDF vor.</p>
</div>
<div class="upload-zone" id="laufzettel-zone">
<input type="file" id="laufzettel-input" accept=".docx">
<h3>📄 Schadenlaufzettel.docx hochladen</h3>
<p>Datei hierher ziehen oder klicken zum Auswählen</p>
</div>
<div class="file-info" id="laufzettel-info"></div>
<div class="actions">
<button class="btn btn-primary" id="generate-btn" disabled>
⚙️ PDF generieren
</button>
</div>
<div class="status" id="generate-status"></div>
</div>
<!-- Tab 2: Auswerten -->
<div id="analyze" class="tab-content">
<div class="info-box">
<h4>Ausgefülltes PDF analysieren</h4>
<p>Liest alle Formularfelder und Dropdown-Auswahlen aus dem Schadenprotokoll.</p>
</div>
<div class="upload-zone" id="pdf-zone">
<input type="file" id="pdf-input" accept=".pdf">
<h3>📋 Schadenprotokoll.pdf hochladen</h3>
<p>Das ausgefüllte PDF-Formular</p>
</div>
<div class="file-info" id="pdf-info"></div>
<div class="actions">
<button class="btn btn-primary" id="analyze-btn" disabled>
🔍 Analysieren
</button>
</div>
<div class="status" id="analyze-status"></div>
<div class="result" id="analyze-result">
<h4>Analyseergebnis:</h4>
<div id="analyze-data"></div>
</div>
</div>
<!-- Tab 3: Vorbericht -->
<div id="vorbericht" class="tab-content">
<div class="info-box">
<h4>Protokoll → Vorbericht Word</h4>
<p>Generiert einen strukturierten Vorbericht aus dem ausgefüllten Schadenprotokoll.</p>
</div>
<div class="upload-zone" id="vorbericht-zone">
<input type="file" id="vorbericht-input" accept=".pdf">
<h3>📋 Ausgefülltes Schadenprotokoll.pdf</h3>
<p>Das vollständig ausgefüllte PDF-Formular</p>
</div>
<div class="file-info" id="vorbericht-info"></div>
<div class="actions">
<button class="btn btn-primary" id="vorbericht-btn" disabled>
📝 Vorbericht erstellen
</button>
</div>
<div class="status" id="vorbericht-status"></div>
</div>
<footer>
<p>Schadenprotokoll Tool v1.0 | ass-ass GmbH</p>
</footer>
</div>
<script src="app.js"></script>
</body>
</html>

107
schadenprotokoll/styles.css Normal file
View File

@@ -0,0 +1,107 @@
* { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--primary: #2563eb;
--primary-dark: #1d4ed8;
--success: #16a34a;
--warning: #ca8a04;
--error: #dc2626;
--bg: #f8fafc;
--card: #ffffff;
--border: #e2e8f0;
--text: #1e293b;
--text-muted: #64748b;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: var(--bg);
color: var(--text);
line-height: 1.6;
min-height: 100vh;
}
.container { max-width: 900px; margin: 0 auto; padding: 2rem; }
header { text-align: center; margin-bottom: 2rem; }
header h1 { font-size: 1.75rem; font-weight: 600; margin-bottom: 0.5rem; }
header p { color: var(--text-muted); }
.tabs {
display: flex; gap: 0.5rem; margin-bottom: 1.5rem;
border-bottom: 2px solid var(--border); padding-bottom: 0.5rem;
}
.tab {
padding: 0.75rem 1.5rem; border: none; background: none;
font-size: 1rem; cursor: pointer; border-radius: 0.5rem 0.5rem 0 0;
color: var(--text-muted); transition: all 0.2s;
}
.tab:hover { background: var(--border); }
.tab.active { background: var(--primary); color: white; }
.tab-content {
display: none; background: var(--card); border-radius: 0.75rem;
padding: 2rem; box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
.tab-content.active { display: block; }
.upload-zone {
border: 2px dashed var(--border); border-radius: 0.75rem;
padding: 3rem; text-align: center; transition: all 0.2s; cursor: pointer;
}
.upload-zone:hover, .upload-zone.dragover {
border-color: var(--primary); background: #eff6ff;
}
.upload-zone input { display: none; }
.upload-zone h3 { margin-bottom: 0.5rem; }
.upload-zone p { color: var(--text-muted); font-size: 0.875rem; }
.file-info {
margin-top: 1rem; padding: 1rem; background: #f0fdf4;
border-radius: 0.5rem; display: none;
}
.file-info.visible { display: block; }
.btn {
display: inline-flex; align-items: center; gap: 0.5rem;
padding: 0.75rem 1.5rem; border: none; border-radius: 0.5rem;
font-size: 1rem; cursor: pointer; transition: all 0.2s;
}
.btn-primary { background: var(--primary); color: white; }
.btn-primary:hover { background: var(--primary-dark); }
.btn-primary:disabled { background: var(--border); cursor: not-allowed; }
.actions { margin-top: 1.5rem; display: flex; gap: 1rem; }
.status {
padding: 1rem; border-radius: 0.5rem; margin-top: 1rem; display: none;
}
.status.visible { display: block; }
.status.success { background: #f0fdf4; color: var(--success); }
.status.error { background: #fef2f2; color: var(--error); }
.status.loading { background: #eff6ff; color: var(--primary); }
.info-box {
background: #eff6ff; border-left: 4px solid var(--primary);
padding: 1rem; margin-bottom: 1.5rem; border-radius: 0 0.5rem 0.5rem 0;
}
.info-box h4 { margin-bottom: 0.25rem; }
.info-box p { color: var(--text-muted); font-size: 0.875rem; }
.result { margin-top: 1.5rem; padding: 1rem; background: var(--bg); border-radius: 0.5rem; display: none; }
.result.visible { display: block; }
.result h4 { margin-bottom: 0.75rem; }
.dropdown-item {
padding: 0.75rem; background: white; border: 1px solid var(--border);
border-radius: 0.5rem; margin-bottom: 0.5rem;
}
.dropdown-item strong { color: var(--primary); }
.field-group { margin-bottom: 1rem; }
.field-group h5 { margin-bottom: 0.5rem; color: var(--text-muted); }
.field-list { display: grid; gap: 0.5rem; }
footer { text-align: center; margin-top: 3rem; color: var(--text-muted); font-size: 0.875rem; }

Binary file not shown.

Binary file not shown.