Files
tesm-license/srv/tesm-license/templates/customers.html
T
alientimandClaude Sonnet 5 7e39320c87 Ticket-System fuer Kunden-Self-Service + Fingerprint-Auto-Supersede (v1.1.0)
Konzept: Kunde kauft eine Lizenz -> Admin legt einen Kunden an (oder nutzt
einen bestehenden) und weist ihm ein TICKET fuer eine Lizenz mit festen
Eckdaten (Typ/Module/Laufzeit) zu -- das Ticket ist die dauerhafte,
dem Kunden gehoerende Berechtigung, unabhaengig von der konkreten
signierten Lizenzdatei darunter. Admin ODER Kunde (per Self-Service-Link,
kein Login noetig) koennen aus einem offenen Ticket jederzeit eine Lizenz
erstellen, widerrufen und erneut erstellen -- ein Kunde kann mehrere
Tickets haben (z.B. mehrere Systeme).

Neu:
- license_tickets-Tabelle (id = unerratbares Token, customer_id, Typ/
  Module/Laufzeit, status 'open'/'blocked'), licenses.ticket_id-Spalte.
  Rueckwirkende Migration erzeugt fuer jede bereits VOR diesem Feature
  direkt ausgestellte Lizenz automatisch ein passendes Ticket (sonst
  waeren z.B. POETESTs bereits aktivierte Produktivlizenz oder die
  Demo-Lizenzen im neuen, rein ticket-basierten Dashboard verschwunden) --
  gegen eine echte Kopie der Live-Datenbank getestet.
- /tickets/new (Ticket anlegen, admin), /tickets/<id> (Details, Lizenz
  aus Ticket erstellen -- reguraer laut Ticket-Typ ODER als Trial mit
  vollem Modulumfang unabhaengig vom Ticket-Typ --, Ticket sperren/
  entsperren).
- /self-service/<ticket_id>: oeffentlich erreichbar (kein Login), Zugriff
  ueber Besitz des unerratbaren Ticket-Tokens (192 Bit Zufall) als 'Magic
  Link' -- Kunde kann eigene Lizenz herunterladen, widerrufen und (mit den
  Ticket-Eckdaten) neu erstellen. Link wird auf der Ticket-Seite angezeigt
  und in die 'Lizenz per E-Mail senden'-Nachricht aufgenommen.
- /licenses/<id>/delete (admin): raeumt widerrufene oder nie aktivierte
  Lizenzen auf, OHNE das zugehoerige Ticket anzutasten -- der Kunde (oder
  Admin) kann sich ueber das Ticket jederzeit eine neue mit denselben
  Eckdaten erzeugen.
- Bugfix: wird fuer einen Fingerprint eine neue Lizenz aktiviert (z.B.
  Trial durch Enterprise ersetzt), werden jetzt automatisch alle ANDEREN
  fuer denselben Fingerprint noch 'active' vermerkten Lizenzen auf
  'deactivated' gesetzt -- vorher blieben beide faelschlich gleichzeitig
  aktiv in der Uebersicht stehen. Bewusst nach Fingerprint gefiltert (ein
  Kunde darf mehrere Systeme mit je eigener Lizenz betreiben).
- Dashboard zeigt jetzt Tickets statt roher Lizenzzeilen (inkl. aktueller
  Lizenz je Ticket), Kunden-Seite zeigt Ticket-Anzahl statt Lizenz-Anzahl
  mit Filter-Link aufs Dashboard.

SCHEMA_VERSION 1 -> 2 (neue Tabelle + Spalte, ueber install.sh-
Sicherheitsnetz mit Backup+Health-Check abgesichert).

Verifiziert: Migration gegen eine echte Kopie der Live-Datenbank
(POETESTs aktive Produktivlizenz + 3 Demo-Lizenzen) durchlaufen lassen --
alle 8 bestehenden Lizenzen haben danach ein Ticket, keine verwaist.
Anschliessend alle neuen Seiten (Dashboard, Ticket-Anlage, Ticket-Detail,
Self-Service) per Playwright gegen diese migrierte Kopie auf einem
isolierten Scratch-Port durchgeklickt, keine Fehler.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 11:41:51 +02:00

162 lines
6.7 KiB
HTML

{% extends "base.html" %}
{% set active_page = "customers" %}
{% block page_title %}Kunden{% endblock %}
{% block page_sub %}<div class="topbar-sub">{{ customers|length }} Kunde{{ 'n' if customers|length != 1 else '' }}</div>{% endblock %}
{% block content %}
<div class="section-head">
<div>
<h2 style="font-size:16px;">Kunden</h2>
</div>
{% if can_create %}
<button type="button" class="btn btn-primary" data-open-modal="addCustomerModal">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>
Neuer Kunde
</button>
{% endif %}
</div>
<div class="table-wrap">
<div class="table-toolbar">
<div class="search-input">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/></svg>
<input type="text" id="customerSearch" placeholder="Kunden durchsuchen…" oninput="filterCustomersTable()">
</div>
</div>
<div style="overflow-x:auto;">
<table class="data-table" id="customersTable" data-sortable>
<thead><tr>
<th data-sort-key="name">Kunde</th>
<th data-sort-key="email">Kontakt</th>
<th data-sort-key="tickets">Tickets</th>
<th style="width:1%;">Aktionen</th>
</tr></thead>
{% for c in customers %}
<tbody data-sort-name="{{ c.name|lower }}" data-sort-email="{{ (c.contact_email or '')|lower }}" data-sort-tickets="{{ c.ticket_count }}">
<tr>
<td class="cell-name">{{ c.name }}</td>
<td class="text-dim">{{ c.contact_email or '—' }}{% if c.contact_phone %} · {{ c.contact_phone }}{% endif %}</td>
<td class="text-dim"><a href="{{ url_for('index', customer_id=c.id) }}">{{ c.ticket_count }}</a></td>
<td>
<div class="row-actions">
{% if can_edit %}
<button class="icon-btn" title="Bearbeiten" data-open-modal="editCustomerModal{{ loop.index }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
</button>
{% if c.ticket_count == 0 %}
<form method="post" data-confirm="Kunde „{{ c.name }}“ wirklich löschen?">
<input type="hidden" name="delete_customer" value="{{ c.id }}">
<button type="submit" class="icon-btn" style="color:var(--danger);" title="Löschen">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2m3 0l-1 14a2 2 0 01-2 2H7a2 2 0 01-2-2L4 6"/></svg>
</button>
</form>
{% endif %}
{% endif %}
{% if current_user.has_permission('licenses.create') %}
<a class="icon-btn" title="Neues Ticket" href="{{ url_for('ticket_new') }}?customer_id={{ c.id }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 7h-9m-4 0H3m2 0a2 2 0 100-4 2 2 0 000 4zm4 4h.01"/></svg>
</a>
{% endif %}
</div>
</td>
</tr>
</tbody>
{% else %}
<tbody data-sort-pinned>
<tr class="empty-row"><td colspan="4">Noch keine Kunden angelegt.</td></tr>
</tbody>
{% endfor %}
</table>
</div>
</div>
{% for c in customers %}
<div class="modal-overlay" id="editCustomerModal{{ loop.index }}">
<div class="modal" style="max-width:480px;">
<form method="post">
<input type="hidden" name="edit_customer" value="{{ c.id }}">
<div class="modal-header">
<h3>Kunde bearbeiten</h3>
<button type="button" class="modal-close" data-close-modal>&times;</button>
</div>
<div class="modal-body">
<div class="field">
<label>Name</label>
<input type="text" name="name" value="{{ c.name }}" required {% if not can_edit %}disabled{% endif %}>
</div>
<div class="field">
<label>E-Mail</label>
<input type="email" name="contact_email" value="{{ c.contact_email or '' }}" {% if not can_edit %}disabled{% endif %}>
</div>
<div class="field">
<label>Telefon</label>
<input type="text" name="contact_phone" value="{{ c.contact_phone or '' }}" {% if not can_edit %}disabled{% endif %}>
</div>
<div class="field">
<label>Notizen</label>
<textarea name="notes" rows="3" {% if not can_edit %}disabled{% endif %}>{{ c.notes or '' }}</textarea>
</div>
</div>
{% if can_edit %}
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-close-modal>Abbrechen</button>
<button type="submit" class="btn btn-primary">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>
Speichern
</button>
</div>
{% endif %}
</form>
</div>
</div>
{% endfor %}
<div class="modal-overlay" id="addCustomerModal">
<div class="modal" style="max-width:480px;">
<form method="post">
<input type="hidden" name="new_customer" value="1">
<div class="modal-header">
<h3>Neuer Kunde</h3>
<button type="button" class="modal-close" data-close-modal>&times;</button>
</div>
<div class="modal-body">
<div class="field">
<label>Name</label>
<input type="text" name="name" required placeholder="z.B. Musterfirma GmbH">
</div>
<div class="field">
<label>E-Mail</label>
<input type="email" name="contact_email">
</div>
<div class="field">
<label>Telefon</label>
<input type="text" name="contact_phone">
</div>
<div class="field">
<label>Notizen</label>
<textarea name="notes" rows="3"></textarea>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-close-modal>Abbrechen</button>
<button type="submit" class="btn btn-primary">Anlegen</button>
</div>
</form>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
function filterCustomersTable() {
const q = document.getElementById("customerSearch").value.trim().toLowerCase();
document.querySelectorAll("#customersTable tbody").forEach(tbody => {
if (tbody.hasAttribute("data-sort-pinned")) return;
tbody.style.display = tbody.innerText.toLowerCase().includes(q) ? "" : "none";
});
}
</script>
{% endblock %}