From 528d66b2b088b7bde94a02903e79c7e3a1c545ce Mon Sep 17 00:00:00 2001 From: Leopold Strobl Date: Wed, 1 Jul 2026 17:34:09 +0200 Subject: [PATCH] Add "truly done" ticket archiving with auto-unhide - Let the user mark closed tickets as really done and hide them, so tickets closed prematurely by an agent stay visible until confirmed - User-side "archived" flag stored in the DB (independent of Zammad), added via a schema migration on existing databases - Hide archived tickets from the overview by default with an "erledigte einblenden" toggle; archive/unarchive from the ticket detail page and via a per-row quick action - Poller automatically un-hides an archived ticket when new activity is detected, so nothing is missed Co-Authored-By: Claude Opus 4.8 --- app/db.py | 11 ++++++++++- app/poller.py | 7 ++++++- app/web.py | 35 +++++++++++++++++++++++++++++++---- static/style.css | 7 +++++++ templates/index.html | 34 +++++++++++++++++++++++++++------- templates/ticket.html | 15 +++++++++++++-- 6 files changed, 94 insertions(+), 15 deletions(-) diff --git a/app/db.py b/app/db.py index e9d270c..d80bfa4 100644 --- a/app/db.py +++ b/app/db.py @@ -31,7 +31,8 @@ CREATE TABLE IF NOT EXISTS tickets ( last_close_at TEXT, raw_json TEXT, first_seen_at TEXT, - last_synced_at TEXT + last_synced_at TEXT, + archived INTEGER NOT NULL DEFAULT 0 ); CREATE TABLE IF NOT EXISTS changes ( @@ -103,3 +104,11 @@ def get_conn(): def init_db() -> None: with get_conn() as conn: conn.executescript(SCHEMA) + _migrate(conn) + + +def _migrate(conn) -> None: + """Nachträgliche Schema-Anpassungen für bestehende Datenbanken.""" + cols = {r["name"] for r in conn.execute("PRAGMA table_info(tickets)")} + if "archived" not in cols: + conn.execute("ALTER TABLE tickets ADD COLUMN archived INTEGER NOT NULL DEFAULT 0") diff --git a/app/poller.py b/app/poller.py index 2072251..267e8cb 100644 --- a/app/poller.py +++ b/app/poller.py @@ -196,8 +196,13 @@ def poll_once(client: ZammadClient) -> int: except ZammadError: pass else: - all_changes.extend(_detect_changes(conn, client, t, existing)) + ch = _detect_changes(conn, client, t, existing) _upsert(conn, row, is_new=False) + if ch: + # Neue Aktivität -> ggf. abgelegtes Ticket wieder einblenden + conn.execute( + "UPDATE tickets SET archived=0 WHERE id=?", (t["id"],)) + all_changes.extend(ch) # Backfill NACH der Haupttransaktion (eigene, kurze Verbindungen) _backfill_articles(client) except ZammadError as e: diff --git a/app/web.py b/app/web.py index 438727a..6d75dd0 100644 --- a/app/web.py +++ b/app/web.py @@ -145,15 +145,20 @@ def create_app() -> Flask: if sort not in SORT_COLS: sort = "updated" direction = "asc" if request.args.get("dir") == "asc" else "desc" + show_archived = request.args.get("archived") == "1" with db.get_conn() as conn: params = [] - where = "" + conds = [] if state == "not_closed": - where = "WHERE state NOT IN ('closed','merged','removed')" + conds.append("state NOT IN ('closed','merged','removed')") elif state: - where = "WHERE state = ?" + conds.append("state = ?") params.append(state) + # abgelegte ("wirklich erledigte") Tickets standardmäßig ausblenden + if not show_archived: + conds.append("archived = 0") + where = ("WHERE " + " AND ".join(conds)) if conds else "" # Tickets mit unbestätigten (ungelesenen) Änderungen unseen_map = { @@ -189,10 +194,14 @@ def create_app() -> Flask: last_run = conn.execute( "SELECT * FROM sync_runs ORDER BY id DESC LIMIT 1" ).fetchone() + archived_count = conn.execute( + "SELECT COUNT(*) c FROM tickets WHERE archived=1" + ).fetchone()["c"] return render_template("index.html", tickets=tickets, states=states, stats=stats, recent=recent, last_run=last_run, active_state=state, sort=sort, dir=direction, - unseen_map=unseen_map) + unseen_map=unseen_map, show_archived=show_archived, + archived_count=archived_count) @app.route("/ticket/") def ticket_detail(ticket_id): @@ -236,6 +245,24 @@ def create_app() -> Flask: flash("✗ Status konnte nicht gesetzt werden: " + str(e)) return redirect(url_for("ticket_detail", ticket_id=ticket_id)) + @app.route("/ticket//archive", methods=["POST"]) + def archive_ticket(ticket_id): + with db.get_conn() as conn: + conn.execute("UPDATE tickets SET archived=1 WHERE id=?", (ticket_id,)) + flash("Ticket als wirklich erledigt abgelegt (ausgeblendet).") + # von der Detailseite zurück zur Übersicht, sonst zur Referrer-Seite + ref = request.referrer or "" + if f"/ticket/{ticket_id}" in ref: + return redirect(url_for("index")) + return redirect(ref or url_for("index")) + + @app.route("/ticket//unarchive", methods=["POST"]) + def unarchive_ticket(ticket_id): + with db.get_conn() as conn: + conn.execute("UPDATE tickets SET archived=0 WHERE id=?", (ticket_id,)) + flash("Ticket wieder eingeblendet.") + return redirect(request.referrer or url_for("ticket_detail", ticket_id=ticket_id)) + @app.route("/ticket//article//att/") def attachment(ticket_id, article_id, att_id): meta = conversation.find_attachment(article_id, att_id) diff --git a/static/style.css b/static/style.css index 2369bc4..efd6997 100644 --- a/static/style.css +++ b/static/style.css @@ -129,6 +129,13 @@ table.tickets tr.row-unseen td:first-child { box-shadow: inset 3px 0 0 var(--acc background: var(--accent); color: #fff; border-radius: 999px; font-size: .72rem; font-weight: 700; text-align: center; line-height: 18px; } +table.tickets tr.row-archived { opacity: .55; } +table.tickets td.rowact { text-align: right; width: 34px; } +td.rowact button { + border: 1px solid var(--line); background: #f7f8fa; border-radius: 6px; + width: 26px; height: 26px; line-height: 1; cursor: pointer; font-size: .85rem; padding: 0; +} +td.rowact button:hover { background: var(--brand); color: #fff; border-color: var(--brand); } .state { display: inline-block; padding: .1rem .5rem; border-radius: 6px; font-size: .78rem; background: #eef2f5; } .state-closed { background: #e6eaed; color: #66727c; } diff --git a/templates/index.html b/templates/index.html index 8498db6..e6e5aef 100644 --- a/templates/index.html +++ b/templates/index.html @@ -46,22 +46,30 @@

Status-Filter

+ {% set arch = '1' if show_archived else none %}
- alle + alle nicht geschlossen + href="{{ url_for('index', state='not_closed', sort=sort, dir=dir, archived=arch) }}">nicht geschlossen {% for s in states %} {{ s.state }} ({{ s.c }}) + href="{{ url_for('index', state=s.state, sort=sort, dir=dir, archived=arch) }}">{{ s.state }} ({{ s.c }}) {% endfor %}
+
{% macro th(col, label) %} {% set nextdir = 'asc' if (sort == col and dir == 'desc') else 'desc' %} - + {{ label }}{% if sort == col %} {{ '▼' if dir == 'desc' else '▲' }}{% endif %} @@ -77,25 +85,37 @@ {{ th('number', '#') }}{{ th('title', 'Titel') }}{{ th('state', 'Status') }} {{ th('priority', 'Priorität') }}{{ th('owner', 'Bearbeiter') }} {{ th('articles', 'Artikel') }}{{ th('updated', 'zuletzt geändert') }} + {% for t in tickets %} {% set n = unseen_map.get(t.id) %} - + {% if n %}{{ n }}{% endif %} {{ t.number }} - {{ t.title }} + {{ t.title }}{% if t.archived %} erledigt{% endif %} {{ t.state }} {{ t.priority }} {{ t.owner if t.owner and t.owner != '-' else '—' }} {{ t.article_count }} + + {% if t.archived %} +
+ +
+ {% else %} +
+ +
+ {% endif %} + {% else %} - Keine Tickets. + Keine Tickets. {% endfor %} diff --git a/templates/ticket.html b/templates/ticket.html index da0f7c8..9148d4b 100644 --- a/templates/ticket.html +++ b/templates/ticket.html @@ -7,8 +7,19 @@

#{{ ticket.number }} – {{ ticket.title }}

- in Zammad öffnen ↗ +
+ {% if ticket.archived %} +
+ +
+ {% else %} +
+ +
+ {% endif %} + in Zammad öffnen ↗ +
Status
{{ ticket.state }}