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>
162 lines
6.7 KiB
HTML
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>×</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>×</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 %}
|