modified: Dockerfile
new file: pdf_export.py modified: requirements.txt modified: routes/journal_routes.py modified: static/css/style.css modified: templates/base.html modified: templates/day.html new file: templates/edit_future.html new file: templates/edit_note.html modified: templates/future.html modified: templates/monat.html new file: templates/report.html modified: templates/week.html
This commit is contained in:
@@ -4,6 +4,11 @@ FROM python:3.11-slim
|
||||
# Arbeitsverzeichnis erstellen
|
||||
WORKDIR /app
|
||||
|
||||
# System-Dependencies: DejaVu-Schriftarten für PDF-Export (Umlaute)
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
fonts-dejavu-core \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Kopiere die requirements-Datei und installiere die Abhängigkeiten
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
159
pdf_export.py
Normal file
159
pdf_export.py
Normal file
@@ -0,0 +1,159 @@
|
||||
"""PDF-Export für Wochen-/Monatsberichte (ReportLab).
|
||||
|
||||
Unterstützt Umlaute über die DejaVu-Schriftarten, die im Docker-Image
|
||||
(fonts-dejavu-core) enthalten sind. Falls keine TTF gefunden wird,
|
||||
fällt die Generierung auf Helvetica zurück (dann evtl. ohne Umlaute).
|
||||
"""
|
||||
import io
|
||||
import os
|
||||
|
||||
from reportlab.lib import colors
|
||||
from reportlab.lib.enums import TA_LEFT
|
||||
from reportlab.lib.pagesizes import A4
|
||||
from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
|
||||
from reportlab.lib.units import mm
|
||||
from reportlab.platypus import (
|
||||
SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle,
|
||||
)
|
||||
|
||||
_DEJAVU_PATHS = [
|
||||
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
|
||||
"/usr/share/fonts/dejavu/DejaVuSans.ttf",
|
||||
"/usr/share/fonts/truetype/DejaVuSans.ttf",
|
||||
]
|
||||
_DEJAVU_BOLD_PATHS = [
|
||||
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
|
||||
"/usr/share/fonts/dejavu/DejaVuSans-Bold.ttf",
|
||||
]
|
||||
|
||||
|
||||
def _register_fonts():
|
||||
from reportlab.pdfbase import pdfmetrics
|
||||
from reportlab.pdfbase.ttfonts import TTFont
|
||||
|
||||
regular = next((p for p in _DEJAVU_PATHS if os.path.exists(p)), None)
|
||||
bold = next((p for p in _DEJAVU_BOLD_PATHS if os.path.exists(p)), None)
|
||||
|
||||
if regular:
|
||||
pdfmetrics.registerFont(TTFont("DejaVu", regular))
|
||||
if bold:
|
||||
pdfmetrics.registerFont(TTFont("DejaVu-Bold", bold))
|
||||
return "DejaVu", "DejaVu-Bold"
|
||||
return "Helvetica", "Helvetica-Bold"
|
||||
|
||||
|
||||
def _build_styles(font_name: str, font_bold: str):
|
||||
styles = getSampleStyleSheet()
|
||||
base = ParagraphStyle(
|
||||
"ReportBase",
|
||||
fontName=font_name,
|
||||
fontSize=9,
|
||||
leading=12,
|
||||
textColor=colors.HexColor("#2b2b2b"),
|
||||
)
|
||||
return {
|
||||
"title": ParagraphStyle(
|
||||
"ReportTitle", parent=styles["Title"], fontName=font_bold,
|
||||
fontSize=18, leading=22, spaceAfter=4, textColor=colors.HexColor("#2b2b2b"),
|
||||
),
|
||||
"subtitle": ParagraphStyle(
|
||||
"ReportSub", parent=styles["Normal"], fontName=font_name,
|
||||
fontSize=10, leading=14, textColor=colors.HexColor("#8a867e"),
|
||||
),
|
||||
"h2": ParagraphStyle(
|
||||
"ReportH2", parent=styles["Heading2"], fontName=font_bold,
|
||||
fontSize=12, leading=16, spaceBefore=10, spaceAfter=4,
|
||||
textColor=colors.HexColor("#a8871d"),
|
||||
),
|
||||
"body": base,
|
||||
"cell": ParagraphStyle(
|
||||
"Cell", parent=base, fontSize=8.5, leading=11,
|
||||
alignment=TA_LEFT,
|
||||
),
|
||||
"cellheader": ParagraphStyle(
|
||||
"CellHeader", parent=base, fontName=font_bold,
|
||||
fontSize=8.5, leading=11, textColor=colors.white,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def build_pdf(title: str, subtitle: str, rows: list[dict],
|
||||
summary: dict | None = None) -> bytes:
|
||||
"""Erzeugt ein PDF mit einer Übersichtstabelle der Einträge.
|
||||
|
||||
rows: Liste von dicts mit Schluesseln
|
||||
date_str, weekday, bullet, bullet_label, note_text, priority,
|
||||
status, refs
|
||||
summary: optionales dict mit open/done/total/Zeitraum.
|
||||
"""
|
||||
font_name, font_bold = _register_fonts()
|
||||
st = _build_styles(font_name, font_bold)
|
||||
|
||||
buffer = io.BytesIO()
|
||||
|
||||
doc = SimpleDocTemplate(
|
||||
buffer, pagesize=A4,
|
||||
leftMargin=15 * mm, rightMargin=15 * mm,
|
||||
topMargin=18 * mm, bottomMargin=18 * mm,
|
||||
title=title,
|
||||
)
|
||||
|
||||
story = []
|
||||
story.append(Paragraph(title, st["title"]))
|
||||
story.append(Paragraph(subtitle, st["subtitle"]))
|
||||
story.append(Spacer(1, 4))
|
||||
|
||||
# Zusammenfassung
|
||||
if summary:
|
||||
story.append(Paragraph("Zusammenfassung", st["h2"]))
|
||||
sum_rows = [
|
||||
["Offen", "Erledigt", "Gesamt"],
|
||||
[str(summary.get("open", 0)), str(summary.get("done", 0)),
|
||||
str(summary.get("total", 0))],
|
||||
]
|
||||
sum_table = Table(sum_rows, colWidths=[40 * mm, 40 * mm, 40 * mm])
|
||||
sum_table.setStyle(TableStyle([
|
||||
("FONTNAME", (0, 0), (-1, 0), font_bold),
|
||||
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#a8871d")),
|
||||
("TEXTCOLOR", (0, 0), (-1, 0), colors.white),
|
||||
("FONTNAME", (0, 1), (-1, -1), font_name),
|
||||
("GRID", (0, 0), (-1, -1), 0.4, colors.HexColor("#e5ded2")),
|
||||
("ALIGN", (0, 0), (-1, -1), "CENTER"),
|
||||
("TOPPADDING", (0, 0), (-1, -1), 4),
|
||||
("BOTTOMPADDING", (0, 0), (-1, -1), 4),
|
||||
]))
|
||||
story.append(sum_table)
|
||||
story.append(Spacer(1, 6))
|
||||
|
||||
# Einträge
|
||||
story.append(Paragraph("Einträge", st["h2"]))
|
||||
header = ["Datum", "Wochentag", "Bullet", "Notiz", "Priorität", "Status", "Verweise"]
|
||||
table_data = [[Paragraph(h, st["cellheader"]) for h in header]]
|
||||
|
||||
for r in rows:
|
||||
table_data.append([
|
||||
Paragraph(r.get("date_str", ""), st["cell"]),
|
||||
Paragraph(r.get("weekday", ""), st["cell"]),
|
||||
Paragraph(f"{r.get('bullet', '')} {r.get('bullet_label', '')}", st["cell"]),
|
||||
Paragraph(r.get("note_text", ""), st["cell"]),
|
||||
Paragraph(r.get("priority", ""), st["cell"]),
|
||||
Paragraph(r.get("status", ""), st["cell"]),
|
||||
Paragraph(r.get("refs", ""), st["cell"]),
|
||||
])
|
||||
|
||||
col_widths = [18 * mm, 18 * mm, 20 * mm, 60 * mm, 16 * mm, 16 * mm, 30 * mm]
|
||||
table = Table(table_data, colWidths=col_widths, repeatRows=1)
|
||||
table.setStyle(TableStyle([
|
||||
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#a8871d")),
|
||||
("GRID", (0, 0), (-1, -1), 0.3, colors.HexColor("#e5ded2")),
|
||||
("VALIGN", (0, 0), (-1, -1), "TOP"),
|
||||
("ROWBACKGROUNDS", (0, 1), (-1, -1),
|
||||
[colors.white, colors.HexColor("#faf7ef")]),
|
||||
("TOPPADDING", (0, 0), (-1, -1), 3),
|
||||
("BOTTOMPADDING", (0, 0), (-1, -1), 3),
|
||||
]))
|
||||
story.append(table)
|
||||
|
||||
doc.build(story)
|
||||
buffer.seek(0)
|
||||
return buffer.getvalue()
|
||||
@@ -3,3 +3,4 @@ PyMySQL==1.1.1
|
||||
Werkzeug==3.0.3
|
||||
gunicorn==22.0.0
|
||||
python-dotenv==1.0.1
|
||||
reportlab==4.2.2
|
||||
|
||||
@@ -111,6 +111,122 @@ def bullet_label(bullet_type: str) -> str:
|
||||
return BULLET_LABELS.get(bullet_type, "Notiz")
|
||||
|
||||
|
||||
PRIORITY_LABELS = {0: "Niedrig", 1: "Mittel", 2: "Hoch"}
|
||||
STATUS_LABELS = {"open": "Offen", "done": "Erledigt", "moved": "Verschoben"}
|
||||
|
||||
|
||||
def _report_filters() -> dict:
|
||||
"""Liest die Report-Filter aus request.args (GET-Formular/Export-Links)."""
|
||||
args = request.args
|
||||
range_type = args.get("range", "week")
|
||||
|
||||
if range_type == "month":
|
||||
month_param = args.get("month", "")
|
||||
try:
|
||||
y, mo = (int(x) for x in month_param.split("-"))
|
||||
start = date(y, mo, 1)
|
||||
except (ValueError, TypeError):
|
||||
start = date.today().replace(day=1)
|
||||
end = date(start.year, start.month,
|
||||
calendar.monthrange(start.year, start.month)[1])
|
||||
label = start.strftime("%B %Y")
|
||||
elif range_type == "custom":
|
||||
start = parse_date(args.get("from", ""))
|
||||
end = parse_date(args.get("to", ""))
|
||||
if not start or not end or end < start:
|
||||
start = monday_of_week(date.today())
|
||||
end = start + timedelta(days=6)
|
||||
label = f"{start.strftime('%d.%m.%Y')} – {end.strftime('%d.%m.%Y')}"
|
||||
range_type = "custom"
|
||||
else: # week
|
||||
week_param = args.get("week", "")
|
||||
try:
|
||||
iso_year, iso_week = week_param.split("-W")
|
||||
start = date.fromisocalendar(int(iso_year), int(iso_week), 1)
|
||||
except (ValueError, TypeError):
|
||||
start = monday_of_week(date.today())
|
||||
end = start + timedelta(days=6)
|
||||
label = f"Woche {start.strftime('%d.%m.%Y')} – {end.strftime('%d.%m.%Y')}"
|
||||
|
||||
# Filter (leere Auswahl = alle zulassen)
|
||||
weekdays = [int(x) for x in args.getlist("wd")]
|
||||
bullets = [b for b in args.getlist("bullet") if b in BULLET_LABELS]
|
||||
statuses = [s for s in args.getlist("status") if s in STATUS_LABELS]
|
||||
priorities = [int(p) for p in args.getlist("priority") if p.isdigit()]
|
||||
|
||||
if not weekdays:
|
||||
weekdays = list(range(7))
|
||||
if not bullets:
|
||||
bullets = list(BULLET_LABELS.keys())
|
||||
if not statuses:
|
||||
statuses = ["open", "done"]
|
||||
if not priorities:
|
||||
priorities = [0, 1, 2]
|
||||
|
||||
return {
|
||||
"range_type": range_type,
|
||||
"start": start,
|
||||
"end": end,
|
||||
"label": label,
|
||||
"weekdays": weekdays,
|
||||
"bullets": bullets,
|
||||
"statuses": statuses,
|
||||
"priorities": priorities,
|
||||
}
|
||||
|
||||
|
||||
def _run_report(uid: int, f: dict) -> tuple[list[dict], dict]:
|
||||
"""Führt den Report aus und liefert (rows, summary)."""
|
||||
with get_connection() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT * FROM notes WHERE user_id = %s "
|
||||
"AND note_date BETWEEN %s AND %s "
|
||||
"ORDER BY note_date ASC, id ASC",
|
||||
(uid, f["start"], f["end"]),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
|
||||
filtered = []
|
||||
for n in rows:
|
||||
if n["note_date"].weekday() not in f["weekdays"]:
|
||||
continue
|
||||
if n["bullet_type"] not in f["bullets"]:
|
||||
continue
|
||||
if n["status"] not in f["statuses"]:
|
||||
continue
|
||||
if n["priority"] not in f["priorities"]:
|
||||
continue
|
||||
filtered.append(n)
|
||||
|
||||
rows_out = []
|
||||
for n in filtered:
|
||||
refs = ", ".join(
|
||||
f"{ref_type_label(r.get('type', ''))} {r.get('value', '')}"
|
||||
for r in parse_refs(n.get("refs"))
|
||||
)
|
||||
rows_out.append({
|
||||
"id": n["id"],
|
||||
"date_str": n["note_date"].strftime("%d.%m.%Y"),
|
||||
"iso": n["note_date"].strftime("%Y-%m-%d"),
|
||||
"weekday": WEEKDAYS[n["note_date"].weekday()],
|
||||
"bullet": n["bullet_type"],
|
||||
"bullet_label": bullet_label(n["bullet_type"]),
|
||||
"note_text": n["note_text"],
|
||||
"priority": PRIORITY_LABELS.get(n.get("priority", 0), str(n.get("priority", ""))),
|
||||
"status": STATUS_LABELS.get(n.get("status"), n.get("status", "")),
|
||||
"status_code": n.get("status"),
|
||||
"refs": refs,
|
||||
})
|
||||
|
||||
summary = {
|
||||
"total": len(rows_out),
|
||||
"open": sum(1 for r in rows_out if r["status_code"] == "open"),
|
||||
"done": sum(1 for r in rows_out if r["status_code"] == "done"),
|
||||
}
|
||||
return rows_out, summary
|
||||
|
||||
|
||||
@journal_bp.route("/")
|
||||
@login_required
|
||||
def index():
|
||||
@@ -409,6 +525,233 @@ def export_month():
|
||||
return _csv_response("Monatsübersicht", filename, rows)
|
||||
|
||||
|
||||
@journal_bp.route("/report")
|
||||
@login_required
|
||||
def report():
|
||||
"""Report-Generator: dynamischer Wochen-/Monatsbericht mit Filtern."""
|
||||
filters = _report_filters()
|
||||
rows, summary = _run_report(_user_id(), filters)
|
||||
today = date.today()
|
||||
|
||||
# Für Export-Links: aktuelle Filter als Query-String aufbauen
|
||||
def _qs(fmt):
|
||||
params = [
|
||||
("range", filters["range_type"]),
|
||||
("format", fmt),
|
||||
]
|
||||
for wd in filters["weekdays"]:
|
||||
params.append(("wd", str(wd)))
|
||||
for b in filters["bullets"]:
|
||||
params.append(("bullet", b))
|
||||
for s in filters["statuses"]:
|
||||
params.append(("status", s))
|
||||
for p in filters["priorities"]:
|
||||
params.append(("priority", str(p)))
|
||||
if filters["range_type"] == "month":
|
||||
params.append(("month", filters["start"].strftime("%Y-%m")))
|
||||
elif filters["range_type"] == "custom":
|
||||
params.append(("from", filters["start"].strftime("%Y-%m-%d")))
|
||||
params.append(("to", filters["end"].strftime("%Y-%m-%d")))
|
||||
else:
|
||||
params.append(("week", filters["start"].strftime("%G-W%V")))
|
||||
return url_for("journal.report_export", **dict(params))
|
||||
|
||||
export_csv_url = _qs("csv")
|
||||
export_pdf_url = _qs("pdf")
|
||||
|
||||
current_week = monday_of_week(today).strftime("%G-W%V")
|
||||
current_month = today.strftime("%Y-%m")
|
||||
|
||||
filters["week_value"] = current_week
|
||||
filters["from_value"] = filters["start"].strftime("%Y-%m-%d") if filters["range_type"] == "custom" else ""
|
||||
filters["to_value"] = filters["end"].strftime("%Y-%m-%d") if filters["range_type"] == "custom" else ""
|
||||
|
||||
return render_template(
|
||||
"report.html",
|
||||
f=filters,
|
||||
rows=rows,
|
||||
summary=summary,
|
||||
weekdays=WEEKDAYS,
|
||||
bullet_labels=BULLET_LABELS,
|
||||
current_week=current_week,
|
||||
current_month=current_month,
|
||||
today_str=today.strftime("%Y-%m-%d"),
|
||||
export_csv_url=export_csv_url,
|
||||
export_pdf_url=export_pdf_url,
|
||||
)
|
||||
|
||||
|
||||
@journal_bp.route("/report/export")
|
||||
@login_required
|
||||
def report_export():
|
||||
"""Export des Report-Generators als CSV oder PDF (format=pdf|csv)."""
|
||||
filters = _report_filters()
|
||||
rows, summary = _run_report(_user_id(), filters)
|
||||
fmt = request.args.get("format", "csv")
|
||||
|
||||
safe_name = filters["label"].replace(" ", "_").replace("–", "-")
|
||||
filename = f"report_{safe_name}"
|
||||
|
||||
if fmt == "pdf":
|
||||
from pdf_export import build_pdf
|
||||
pdf_bytes = build_pdf(
|
||||
title="Bullet-Journal-Report",
|
||||
subtitle=filters["label"],
|
||||
rows=rows,
|
||||
summary=summary,
|
||||
)
|
||||
return Response(
|
||||
pdf_bytes,
|
||||
mimetype="application/pdf",
|
||||
headers={
|
||||
"Content-Disposition": f"attachment; filename={filename}.pdf"
|
||||
},
|
||||
)
|
||||
|
||||
# CSV
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output)
|
||||
writer.writerow([
|
||||
"Datum", "Wochentag", "Bullet", "Typ", "Notiz", "Priorität",
|
||||
"Status", "Verweise",
|
||||
])
|
||||
for r in rows:
|
||||
writer.writerow([
|
||||
r["date_str"], r["weekday"], r["bullet"], r["bullet_label"],
|
||||
r["note_text"], r["priority"], r["status"], r["refs"],
|
||||
])
|
||||
output.seek(0)
|
||||
return Response(
|
||||
"\ufeff" + output.getvalue(),
|
||||
mimetype="text/csv; charset=utf-8",
|
||||
headers={"Content-Disposition": f"attachment; filename={filename}.csv"},
|
||||
)
|
||||
|
||||
|
||||
@journal_bp.route("/note/<int:note_id>/edit", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def edit_note(note_id: int):
|
||||
"""Bearbeitet eine Notiz (Text, Bullet, Priorität, Verweise)."""
|
||||
with get_connection() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT * FROM notes WHERE id = %s AND user_id = %s",
|
||||
(note_id, _user_id()),
|
||||
)
|
||||
note = cur.fetchone()
|
||||
|
||||
if not note:
|
||||
flash("Notiz nicht gefunden.", "danger")
|
||||
return redirect(url_for("journal.index"))
|
||||
|
||||
if request.method == "POST":
|
||||
bullet = request.form.get("bullet", "-")
|
||||
note_text = request.form.get("note_text", "").strip()
|
||||
try:
|
||||
priority = int(request.form.get("priority", "0"))
|
||||
except ValueError:
|
||||
priority = 0
|
||||
ref_type = request.form.get("ref_type", "").strip()
|
||||
ref_value = request.form.get("ref_value", "").strip()
|
||||
refs = None
|
||||
if ref_value:
|
||||
refs = json.dumps([{"type": ref_type or "link", "value": ref_value}])
|
||||
|
||||
if bullet not in (".", "x", "-", "?"):
|
||||
bullet = "-"
|
||||
if not note_text:
|
||||
flash("Notiz darf nicht leer sein.", "danger")
|
||||
else:
|
||||
with get_connection() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"UPDATE notes SET bullet_type = %s, note_text = %s, "
|
||||
"priority = %s, refs = %s WHERE id = %s AND user_id = %s",
|
||||
(bullet, note_text, priority, refs, note_id, _user_id()),
|
||||
)
|
||||
flash("Notiz aktualisiert.", "success")
|
||||
return redirect(url_for("journal.day", date_str=note["note_date"].strftime("%Y-%m-%d")))
|
||||
|
||||
refs = parse_refs(note.get("refs"))
|
||||
current_ref = refs[0] if refs else {}
|
||||
|
||||
return render_template(
|
||||
"edit_note.html",
|
||||
note=note,
|
||||
bullet_labels=BULLET_LABELS,
|
||||
current_ref=current_ref,
|
||||
)
|
||||
|
||||
|
||||
@journal_bp.route("/note/<int:note_id>/delete", methods=["POST"])
|
||||
@login_required
|
||||
def delete_note(note_id: int):
|
||||
with get_connection() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT note_date FROM notes WHERE id = %s AND user_id = %s",
|
||||
(note_id, _user_id()),
|
||||
)
|
||||
note = cur.fetchone()
|
||||
if not note:
|
||||
flash("Notiz nicht gefunden.", "danger")
|
||||
return redirect(url_for("journal.index"))
|
||||
cur.execute(
|
||||
"DELETE FROM notes WHERE id = %s AND user_id = %s",
|
||||
(note_id, _user_id()),
|
||||
)
|
||||
flash("Notiz gelöscht.", "success")
|
||||
return redirect(request.referrer or url_for("journal.index"))
|
||||
|
||||
|
||||
@journal_bp.route("/future/<int:entry_id>/edit", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def edit_future(entry_id: int):
|
||||
with get_connection() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT * FROM future_log WHERE id = %s AND user_id = %s",
|
||||
(entry_id, _user_id()),
|
||||
)
|
||||
entry = cur.fetchone()
|
||||
|
||||
if not entry:
|
||||
flash("Eintrag nicht gefunden.", "danger")
|
||||
return redirect(url_for("journal.future"))
|
||||
|
||||
if request.method == "POST":
|
||||
note_text = request.form.get("note_text", "").strip()
|
||||
month_str = request.form.get("month", "")
|
||||
month = parse_date(month_str)
|
||||
if note_text and month:
|
||||
with get_connection() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"UPDATE future_log SET note_text = %s, month_date = %s "
|
||||
"WHERE id = %s AND user_id = %s",
|
||||
(note_text, month, entry_id, _user_id()),
|
||||
)
|
||||
flash("Eintrag aktualisiert.", "success")
|
||||
else:
|
||||
flash("Bitte Text und Monat angeben.", "danger")
|
||||
return redirect(url_for("journal.future"))
|
||||
|
||||
return render_template("edit_future.html", entry=entry)
|
||||
|
||||
|
||||
@journal_bp.route("/future/<int:entry_id>/delete", methods=["POST"])
|
||||
@login_required
|
||||
def delete_future(entry_id: int):
|
||||
with get_connection() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"DELETE FROM future_log WHERE id = %s AND user_id = %s",
|
||||
(entry_id, _user_id()),
|
||||
)
|
||||
flash("Eintrag aus dem Future Log gelöscht.", "success")
|
||||
return redirect(url_for("journal.future"))
|
||||
|
||||
|
||||
@journal_bp.route("/tag/<date_str>", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def day(date_str: str):
|
||||
|
||||
@@ -257,3 +257,26 @@ input:focus, select:focus { outline: 2px solid var(--accent); }
|
||||
.card { box-shadow: none; border: 1px solid #ccc; break-inside: avoid; }
|
||||
.month-cell { min-height: 70px; }
|
||||
}
|
||||
|
||||
/* --- Report-Generator --- */
|
||||
.report-form { display: flex; flex-direction: column; gap: .9rem; }
|
||||
.report-block {
|
||||
display: flex; flex-wrap: wrap; gap: .5rem; align-items: center;
|
||||
padding: .6rem; background: #fdfaf2; border: 1px solid var(--line); border-radius: 8px;
|
||||
}
|
||||
.report-block-title {
|
||||
font-weight: 600; font-size: .8rem; color: var(--accent-dark);
|
||||
flex-basis: 100%; margin-bottom: .1rem;
|
||||
}
|
||||
.report-block label { flex-direction: row; align-items: center; gap: .3rem; font-size: .85rem; }
|
||||
.report-block label.grow { flex: 1 1 180px; }
|
||||
.report-actions { display: flex; gap: .5rem; }
|
||||
.chk { display: inline-flex; }
|
||||
.report-exports { display: flex; gap: .5rem; }
|
||||
|
||||
/* Textarea */
|
||||
textarea {
|
||||
width: 100%; padding: .5rem .6rem; border: 1px solid var(--line);
|
||||
border-radius: 6px; font-size: .95rem; font-family: inherit;
|
||||
}
|
||||
textarea:focus { outline: 2px solid var(--accent); }
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
<a href="{{ url_for('journal.index') }}">Dashboard</a>
|
||||
<a href="{{ url_for('journal.week') }}">Wochenübersicht</a>
|
||||
<a href="{{ url_for('journal.month') }}">Monatsübersicht</a>
|
||||
<a href="{{ url_for('journal.report') }}">Report</a>
|
||||
<a href="{{ url_for('journal.future') }}">Future Log</a>
|
||||
<a href="{{ url_for('journal.day', date_str=today_str) }}" class="nav-today">Heute</a>
|
||||
{% if session.get('is_admin') %}
|
||||
|
||||
@@ -106,6 +106,11 @@
|
||||
<button type="submit" class="btn btn-small btn-future" name="target" value="future">→ Future Log</button>
|
||||
</form>
|
||||
</span>
|
||||
<a href="{{ url_for('journal.edit_note', note_id=n.id) }}" class="btn btn-small">Bearbeiten</a>
|
||||
<form method="post" action="{{ url_for('journal.delete_note', note_id=n.id) }}" class="inline"
|
||||
onsubmit="return confirm('Diese Notiz wirklich löschen?');">
|
||||
<button class="btn btn-small btn-danger">Löschen</button>
|
||||
</form>
|
||||
</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
26
templates/edit_future.html
Normal file
26
templates/edit_future.html
Normal file
@@ -0,0 +1,26 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Future-Log-Eintrag bearbeiten – Bullet Journal{% endblock %}
|
||||
{% block content %}
|
||||
<div class="page-head">
|
||||
<div>
|
||||
<h1>Future-Log-Eintrag bearbeiten</h1>
|
||||
</div>
|
||||
<a href="{{ url_for('journal.future') }}" class="btn btn-small">← Zurück zum Future Log</a>
|
||||
</div>
|
||||
|
||||
<section class="card">
|
||||
<form method="post" action="{{ url_for('journal.edit_future', entry_id=entry.id) }}" class="stack">
|
||||
<label>
|
||||
Monat
|
||||
<input type="month" name="month" value="{{ entry.month_date.strftime('%Y-%m') }}" required>
|
||||
</label>
|
||||
<label>
|
||||
Notiz
|
||||
<textarea name="note_text" rows="3" required>{{ entry.note_text }}</textarea>
|
||||
</label>
|
||||
<div class="report-actions">
|
||||
<button type="submit" class="btn btn-primary">Speichern</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
{% endblock %}
|
||||
54
templates/edit_note.html
Normal file
54
templates/edit_note.html
Normal file
@@ -0,0 +1,54 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Notiz bearbeiten – Bullet Journal{% endblock %}
|
||||
{% block content %}
|
||||
<div class="page-head">
|
||||
<div>
|
||||
<h1>Notiz bearbeiten</h1>
|
||||
<p class="muted">{{ note.note_date.strftime('%d.%m.%Y') }}</p>
|
||||
</div>
|
||||
<a href="{{ url_for('journal.day', date_str=note.note_date.strftime('%Y-%m-%d')) }}" class="btn btn-small">← Zurück zum Tag</a>
|
||||
</div>
|
||||
|
||||
<section class="card">
|
||||
<form method="post" action="{{ url_for('journal.edit_note', note_id=note.id) }}" class="stack">
|
||||
<label>
|
||||
Bullet
|
||||
<select name="bullet">
|
||||
<option value="." {{ 'selected' if note.bullet_type == '.' else '' }}>• Aufgabe</option>
|
||||
<option value="x" {{ 'selected' if note.bullet_type == 'x' else '' }}>✕ Termin</option>
|
||||
<option value="-" {{ 'selected' if note.bullet_type == '-' else '' }}>– Notiz</option>
|
||||
<option value="?" {{ 'selected' if note.bullet_type == '?' else '' }}>? Frage</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Notiz
|
||||
<textarea name="note_text" rows="3" required>{{ note.note_text }}</textarea>
|
||||
</label>
|
||||
<label>
|
||||
Priorität
|
||||
<select name="priority">
|
||||
<option value="0" {{ 'selected' if note.priority == 0 else '' }}>Niedrig</option>
|
||||
<option value="1" {{ 'selected' if note.priority == 1 else '' }}>Mittel</option>
|
||||
<option value="2" {{ 'selected' if note.priority == 2 else '' }}>Hoch</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Verweis-Typ
|
||||
<select name="ref_type">
|
||||
<option value="" {{ 'selected' if not current_ref else '' }}>– kein –</option>
|
||||
<option value="email" {{ 'selected' if current_ref.get('type') == 'email' else '' }}>📧 E-Mail</option>
|
||||
<option value="link" {{ 'selected' if current_ref.get('type') == 'link' else '' }}>🔗 Link/URL</option>
|
||||
<option value="phone" {{ 'selected' if current_ref.get('type') == 'phone' else '' }}>📞 Telefon</option>
|
||||
<option value="mention" {{ 'selected' if current_ref.get('type') == 'mention' else '' }}>@ Erwähnung</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Verweis
|
||||
<input type="text" name="ref_value" value="{{ current_ref.get('value', '') }}">
|
||||
</label>
|
||||
<div class="report-actions">
|
||||
<button type="submit" class="btn btn-primary">Speichern</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -46,6 +46,13 @@
|
||||
{% endif %}
|
||||
<span class="note-date">{{ e.month_date.strftime('%m/%Y') }}</span>
|
||||
</div>
|
||||
<span class="mini-actions">
|
||||
<a href="{{ url_for('journal.edit_future', entry_id=e.id) }}" class="btn btn-small">Bearbeiten</a>
|
||||
<form method="post" action="{{ url_for('journal.delete_future', entry_id=e.id) }}" class="inline"
|
||||
onsubmit="return confirm('Eintrag wirklich löschen?');">
|
||||
<button class="btn btn-small btn-danger">Löschen</button>
|
||||
</form>
|
||||
</span>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
||||
@@ -70,6 +70,11 @@
|
||||
{{ 'Erledigt' if n.status != 'done' else 'Öffnen' }}
|
||||
</button>
|
||||
</form>
|
||||
<a href="{{ url_for('journal.edit_note', note_id=n.id) }}" class="btn btn-small">Bearbeiten</a>
|
||||
<form method="post" action="{{ url_for('journal.delete_note', note_id=n.id) }}" class="inline"
|
||||
onsubmit="return confirm('Diese Notiz wirklich löschen?');">
|
||||
<button class="btn btn-small btn-danger">Löschen</button>
|
||||
</form>
|
||||
</span>
|
||||
</li>
|
||||
{% endif %}
|
||||
|
||||
117
templates/report.html
Normal file
117
templates/report.html
Normal file
@@ -0,0 +1,117 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Report-Generator – Bullet Journal{% endblock %}
|
||||
{% block content %}
|
||||
<div class="page-head">
|
||||
<div>
|
||||
<h1>Report-Generator</h1>
|
||||
<p class="muted">Erstelle dynamische Wochen-/Monatsberichte und exportiere sie als CSV oder PDF.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="card">
|
||||
<h2>Filter</h2>
|
||||
<form method="get" action="{{ url_for('journal.report') }}" class="report-form">
|
||||
<div class="report-block">
|
||||
<span class="report-block-title">Zeitraum</span>
|
||||
<label>
|
||||
<input type="radio" name="range" value="week"
|
||||
{{ 'checked' if f.range_type == 'week' or f.range_type == '' else '' }}> Woche
|
||||
</label>
|
||||
<label class="grow"><input type="week" name="week" value="{{ f.week_value or current_week }}"></label>
|
||||
<label>
|
||||
<input type="radio" name="range" value="month"
|
||||
{{ 'checked' if f.range_type == 'month' else '' }}> Monat
|
||||
</label>
|
||||
<label class="grow"><input type="month" name="month" value="{{ current_month }}"></label>
|
||||
<label>
|
||||
<input type="radio" name="range" value="custom"
|
||||
{{ 'checked' if f.range_type == 'custom' else '' }}> Zeitraum
|
||||
</label>
|
||||
<label>Von <input type="date" name="from" value="{{ f.from_value or '' }}"></label>
|
||||
<label>Bis <input type="date" name="to" value="{{ f.to_value or '' }}"></label>
|
||||
</div>
|
||||
|
||||
<div class="report-block">
|
||||
<span class="report-block-title">Wochentage</span>
|
||||
{% for i in range(7) %}
|
||||
<label class="chk">
|
||||
<input type="checkbox" name="wd" value="{{ i }}"
|
||||
{{ 'checked' if i in f.weekdays else '' }}> {{ weekdays[i][:2] }}
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="report-block">
|
||||
<span class="report-block-title">Bullet-Typen</span>
|
||||
{% for b, label in bullet_labels.items() %}
|
||||
<label class="chk">
|
||||
<input type="checkbox" name="bullet" value="{{ b }}"
|
||||
{{ 'checked' if b in f.bullets else '' }}> {{ b }} {{ label }}
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="report-block">
|
||||
<span class="report-block-title">Status</span>
|
||||
<label class="chk"><input type="checkbox" name="status" value="open"
|
||||
{{ 'checked' if 'open' in f.statuses else '' }}> Offen</label>
|
||||
<label class="chk"><input type="checkbox" name="status" value="done"
|
||||
{{ 'checked' if 'done' in f.statuses else '' }}> Erledigt</label>
|
||||
<label class="chk"><input type="checkbox" name="status" value="moved"
|
||||
{{ 'checked' if 'moved' in f.statuses else '' }}> Verschoben</label>
|
||||
</div>
|
||||
|
||||
<div class="report-block">
|
||||
<span class="report-block-title">Priorität</span>
|
||||
<label class="chk"><input type="checkbox" name="priority" value="0"
|
||||
{{ 'checked' if 0 in f.priorities else '' }}> Niedrig</label>
|
||||
<label class="chk"><input type="checkbox" name="priority" value="1"
|
||||
{{ 'checked' if 1 in f.priorities else '' }}> Mittel</label>
|
||||
<label class="chk"><input type="checkbox" name="priority" value="2"
|
||||
{{ 'checked' if 2 in f.priorities else '' }}> Hoch</label>
|
||||
</div>
|
||||
|
||||
<div class="report-actions">
|
||||
<button type="submit" class="btn btn-primary">Vorschau anzeigen</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="day-head">
|
||||
<h2>Vorschau: {{ f.label }}</h2>
|
||||
<div class="report-exports">
|
||||
<a class="btn btn-small" href="{{ export_csv_url }}">⬇ CSV exportieren</a>
|
||||
<a class="btn btn-small" href="{{ export_pdf_url }}">⬇ PDF exportieren</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="day-head-stats">
|
||||
<span class="pill open">{{ summary.open }} offen</span>
|
||||
<span class="pill done">{{ summary.done }} erledigt</span>
|
||||
<span class="pill">{{ summary.total }} gesamt</span>
|
||||
</div>
|
||||
|
||||
{% if rows %}
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr><th>Datum</th><th>Wochentag</th><th>Bullet</th><th>Notiz</th><th>Priorität</th><th>Status</th><th>Verweise</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for r in rows %}
|
||||
<tr>
|
||||
<td>{{ r.date_str }}</td>
|
||||
<td>{{ r.weekday }}</td>
|
||||
<td>{{ r.bullet }} {{ r.bullet_label }}</td>
|
||||
<td>{{ r.note_text|linkify }}</td>
|
||||
<td>{{ r.priority }}</td>
|
||||
<td>{{ r.status }}</td>
|
||||
<td>{% if r.refs %}<span class="refs">{{ r.refs }}</span>{% endif %}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="muted">Keine Einträge für die gewählten Filter.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -57,6 +57,11 @@
|
||||
<input type="date" name="target_date" class="move-date" title="Verschiebe-Button nutzt dieses Datum">
|
||||
<button type="submit" class="btn btn-small" name="target" value="date">Verschieben</button>
|
||||
</form>
|
||||
<a href="{{ url_for('journal.edit_note', note_id=n.id) }}" class="btn btn-small">Bearbeiten</a>
|
||||
<form method="post" action="{{ url_for('journal.delete_note', note_id=n.id) }}" class="inline"
|
||||
onsubmit="return confirm('Diese Notiz wirklich löschen?');">
|
||||
<button class="btn btn-small btn-danger">Löschen</button>
|
||||
</form>
|
||||
</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
Reference in New Issue
Block a user