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 <noreply@anthropic.com>
This commit is contained in:
@@ -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")
|
||||
|
||||
+6
-1
@@ -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:
|
||||
|
||||
+31
-4
@@ -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/<int:ticket_id>")
|
||||
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/<int:ticket_id>/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/<int:ticket_id>/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/<int:ticket_id>/article/<int:article_id>/att/<int:att_id>")
|
||||
def attachment(ticket_id, article_id, att_id):
|
||||
meta = conversation.find_attachment(article_id, att_id)
|
||||
|
||||
@@ -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; }
|
||||
|
||||
+27
-7
@@ -46,22 +46,30 @@
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-head"><h2>Status-Filter</h2></div>
|
||||
{% set arch = '1' if show_archived else none %}
|
||||
<div class="chips">
|
||||
<a class="chip {{ 'on' if not active_state }}" href="{{ url_for('index', sort=sort, dir=dir) }}">alle</a>
|
||||
<a class="chip {{ 'on' if not active_state }}" href="{{ url_for('index', sort=sort, dir=dir, archived=arch) }}">alle</a>
|
||||
<a class="chip {{ 'on' if active_state == 'not_closed' }}"
|
||||
href="{{ url_for('index', state='not_closed', sort=sort, dir=dir) }}">nicht geschlossen</a>
|
||||
href="{{ url_for('index', state='not_closed', sort=sort, dir=dir, archived=arch) }}">nicht geschlossen</a>
|
||||
{% for s in states %}
|
||||
<a class="chip {{ 'on' if active_state == s.state }}"
|
||||
href="{{ url_for('index', state=s.state, sort=sort, dir=dir) }}">{{ s.state }} ({{ s.c }})</a>
|
||||
href="{{ url_for('index', state=s.state, sort=sort, dir=dir, archived=arch) }}">{{ s.state }} ({{ s.c }})</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="chips" style="margin-top:.5rem">
|
||||
{% if show_archived %}
|
||||
<a class="chip on" href="{{ url_for('index', state=active_state or none, sort=sort, dir=dir) }}">erledigte ausblenden</a>
|
||||
{% else %}
|
||||
<a class="chip" href="{{ url_for('index', state=active_state or none, sort=sort, dir=dir, archived='1') }}">erledigte einblenden{% if archived_count %} ({{ archived_count }}){% endif %}</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{% macro th(col, label) %}
|
||||
{% set nextdir = 'asc' if (sort == col and dir == 'desc') else 'desc' %}
|
||||
<th class="{{ 'sorted' if sort == col }}">
|
||||
<a href="{{ url_for('index', state=active_state or none, sort=col, dir=nextdir) }}">
|
||||
<a href="{{ url_for('index', state=active_state or none, sort=col, dir=nextdir, archived=request.args.get('archived')) }}">
|
||||
{{ label }}{% if sort == col %} <span class="arrow">{{ '▼' if dir == 'desc' else '▲' }}</span>{% endif %}
|
||||
</a>
|
||||
</th>
|
||||
@@ -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') }}
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for t in tickets %}
|
||||
{% set n = unseen_map.get(t.id) %}
|
||||
<tr class="{{ 'row-unseen' if n }}">
|
||||
<tr class="{{ 'row-unseen' if n }}{{ ' row-archived' if t.archived }}">
|
||||
<td>
|
||||
{% if n %}<span class="badge" title="{{ n }} unbestätigte Änderung(en)">{{ n }}</span>{% endif %}
|
||||
<a href="{{ url_for('ticket_detail', ticket_id=t.id) }}">{{ t.number }}</a>
|
||||
</td>
|
||||
<td class="title">{{ t.title }}</td>
|
||||
<td class="title">{{ t.title }}{% if t.archived %} <span class="tag">erledigt</span>{% endif %}</td>
|
||||
<td><span class="state state-{{ t.state }}">{{ t.state }}</span></td>
|
||||
<td>{{ t.priority }}</td>
|
||||
<td>{{ t.owner if t.owner and t.owner != '-' else '—' }}</td>
|
||||
<td>{{ t.article_count }}</td>
|
||||
<td><time>{{ t.zammad_updated_at|dt }}</time></td>
|
||||
<td class="rowact">
|
||||
{% if t.archived %}
|
||||
<form method="post" action="{{ url_for('unarchive_ticket', ticket_id=t.id) }}">
|
||||
<button type="submit" title="Wieder einblenden">↩</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<form method="post" action="{{ url_for('archive_ticket', ticket_id=t.id) }}">
|
||||
<button type="submit" title="Als wirklich erledigt ablegen (ausblenden)">✓</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="7" class="muted">Keine Tickets.</td></tr>
|
||||
<tr><td colspan="8" class="muted">Keine Tickets.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
+13
-2
@@ -7,8 +7,19 @@
|
||||
<section class="panel">
|
||||
<div class="panel-head">
|
||||
<h2>#{{ ticket.number }} – {{ ticket.title }}</h2>
|
||||
<a class="btn-sm" href="{{ zammad_url }}/#ticket/zoom/{{ ticket.id }}"
|
||||
target="_blank" rel="noopener">in Zammad öffnen ↗</a>
|
||||
<div class="head-tools">
|
||||
{% if ticket.archived %}
|
||||
<form method="post" action="{{ url_for('unarchive_ticket', ticket_id=ticket.id) }}">
|
||||
<button class="btn-ghost" type="submit">↩ Wieder einblenden</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<form method="post" action="{{ url_for('archive_ticket', ticket_id=ticket.id) }}">
|
||||
<button class="btn-ghost" type="submit" title="Als wirklich erledigt ablegen und ausblenden">✓ Wirklich erledigt</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
<a class="btn-sm" href="{{ zammad_url }}/#ticket/zoom/{{ ticket.id }}"
|
||||
target="_blank" rel="noopener">in Zammad öffnen ↗</a>
|
||||
</div>
|
||||
</div>
|
||||
<dl class="meta">
|
||||
<div><dt>Status</dt><dd><span class="state state-{{ ticket.state }}">{{ ticket.state }}</span></dd></div>
|
||||
|
||||
Reference in New Issue
Block a user