M3: chat widget, owner dashboard, super-admin panel

- Embeddable vanilla-JS chat widget (one script tag + tenant public key):
  WebSocket, resumable sessions (24h localStorage token), honeypot + per-IP
  and per-message rate limits, Bosnian UI (§7)
- Session auth (bcrypt + signed cookies), owner accounts + super-admins,
  admin impersonation ('otvori kao salon', §11)
- Owner dashboard (§10, Bosnian): requests with same actions as email,
  call/chat history with transcripts, usage bar; settings for profile,
  working hours (manual + LLM paste-to-parse), services CRUD + 'Zalijepi
  cjenovnik' LLM import with review step, scheduling (Google OAuth free/busy
  read-only + calendar mappings + buffers, partner status), telephony
  (forwarding MMI codes per operator, ring group, worker SIP creds + QR),
  agent voice/price-mode/notes + widget snippet, notifications/SMS templates
- Super-admin panel (§11): tenant CRUD incl. plan/minutes/paid-until,
  partner API config (encrypted secrets), number/SIM registry + assignment,
  connectivity test (live availability + dry-run push), versioned prompt
  template editor with publish/rollback, playground, usage overview, health
- Fixes: WAL + busy_timeout for sqlite tests, no awaits in WS disconnect
  path (deadlock), template publish autoflush bug
- 76 tests green (incl. widget WS e2e booking flow); dashboard, widget and
  admin verified live in a browser against Postgres

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-11 10:51:57 +02:00
parent 0d0838e6e2
commit 646917c322
46 changed files with 4402 additions and 8 deletions

View File

@@ -0,0 +1,85 @@
{% extends "base.html" %}
{% block title %}Usluge — {{ owner.tenant.name }}{% endblock %}
{% block nav %}{% include "dashboard/_nav.html" %}{% endblock %}
{% block content %}
<h1>Usluge i cjenovnik</h1>
<div class="card">
<table>
<tr><th>Usluga</th><th>Trajanje</th><th>Cijena (KM)</th><th>Kućna posjeta</th><th>Napomena za asistenta</th><th></th></tr>
{% for s in services %}
<tr>
<form method="post" action="/dash/services/{{ s.id }}">
<td><input type="text" name="name" value="{{ s.name }}"></td>
<td style="width:90px"><input type="number" name="duration_min" value="{{ s.duration_min }}" min="5" step="5"></td>
<td style="width:150px;white-space:nowrap">
<input type="number" name="price_min" value="{{ s.price_min if s.price_min is not none }}" step="0.5" style="width:65px;display:inline-block">
<input type="number" name="price_max" value="{{ s.price_max if s.price_max is not none }}" step="0.5" style="width:65px;display:inline-block">
</td>
<td style="width:60px;text-align:center"><input type="checkbox" name="home_visit" {{ 'checked' if s.home_visit }}></td>
<td><input type="text" name="agent_note" value="{{ s.agent_note }}"></td>
<td style="white-space:nowrap">
<button class="small">Sačuvaj</button>
</form>
<form method="post" action="/dash/services/{{ s.id }}/delete" style="display:inline"
onsubmit="return confirm('Obrisati uslugu {{ s.name }}?')">
<button class="small red">Obriši</button>
</form>
</td>
</tr>
{% endfor %}
<tr>
<form method="post" action="/dash/services">
<td><input type="text" name="name" placeholder="Nova usluga…" required></td>
<td><input type="number" name="duration_min" value="30" min="5" step="5"></td>
<td style="white-space:nowrap">
<input type="number" name="price_min" step="0.5" style="width:65px;display:inline-block">
<input type="number" name="price_max" step="0.5" style="width:65px;display:inline-block">
</td>
<td style="text-align:center"><input type="checkbox" name="home_visit"></td>
<td><input type="text" name="agent_note"></td>
<td><button class="small green">Dodaj</button></td>
</form>
</tr>
</table>
</div>
<div class="card">
<h2>Zalijepi cjenovnik</h2>
<p class="muted">Zalijepite cijeli cjenovnik kako god ga imate (sa web stranice, iz Worda,
sa Facebooka…). Gogo će ga pretvoriti u tabelu — vi pregledate, ispravite trajanja i potvrdite.</p>
<form method="post" action="/dash/services/parse">
<textarea name="text" style="min-height:140px" placeholder="npr.
Šišanje i feniranje ......... 25-35 KM
Farbanje ..................... od 60 KM
Manikir ...................... 20 KM"></textarea>
<button class="ghost" style="margin-top:8px">Pročitaj cjenovnik</button>
</form>
</div>
{% if parsed %}
<div class="card">
<h2>Provjeri i potvrdi</h2>
<p class="muted">Ovako je Gogo pročitao vaš cjenovnik. Ispravite šta treba pa potvrdite —
usluge će biti <b>dodane</b> postojećim.</p>
<form method="post" action="/dash/services/import">
<table>
<tr><th></th><th>Usluga</th><th>Trajanje (min)</th><th>Cijena od</th><th>Cijena do</th><th>Kućna</th><th>Napomena</th></tr>
{% for p in parsed %}
<tr>
<td><input type="checkbox" name="row_{{ loop.index0 }}" checked></td>
<td><input type="text" name="name_{{ loop.index0 }}" value="{{ p.name }}"></td>
<td><input type="number" name="duration_{{ loop.index0 }}" value="{{ p.duration_min }}" min="5" step="5"></td>
<td><input type="number" name="pmin_{{ loop.index0 }}" value="{{ p.price_min if p.price_min is not none }}" step="0.5"></td>
<td><input type="number" name="pmax_{{ loop.index0 }}" value="{{ p.price_max if p.price_max is not none }}" step="0.5"></td>
<td style="text-align:center"><input type="checkbox" name="home_{{ loop.index0 }}" {{ 'checked' if p.home_visit }}></td>
<td><input type="text" name="note_{{ loop.index0 }}" value="{{ p.note or '' }}"></td>
</tr>
{% endfor %}
</table>
<input type="hidden" name="count" value="{{ parsed | length }}">
<button class="green" style="margin-top:12px">Dodaj označene usluge</button>
</form>
</div>
{% endif %}
{% endblock %}