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,19 @@
<header>
<div class="logo">Gogo <span>Telefon</span></div>
<nav>
<a href="/dash" class="{{ 'active' if active=='requests' }}">Zahtjevi</a>
<a href="/dash/calls" class="{{ 'active' if active=='calls' }}">Pozivi</a>
<a href="/dash/chats" class="{{ 'active' if active=='chats' }}">Chat</a>
<a href="/dash/usage" class="{{ 'active' if active=='usage' }}">Potrošnja</a>
<a href="/dash/settings" class="{{ 'active' if active=='settings' }}">Salon</a>
<a href="/dash/services" class="{{ 'active' if active=='services' }}">Usluge</a>
<a href="/dash/scheduling" class="{{ 'active' if active=='scheduling' }}">Kalendar</a>
<a href="/dash/telephony" class="{{ 'active' if active=='telephony' }}">Telefonija</a>
<a href="/dash/agent" class="{{ 'active' if active=='agent' }}">Asistent</a>
<a href="/dash/notifications" class="{{ 'active' if active=='notifications' }}">Obavještenja</a>
</nav>
<div class="right">
{{ owner.tenant.name }}{% if owner.impersonated_by %} <b>(admin pregled)</b>{% endif %}
· <a href="/logout">Odjava</a>
</div>
</header>

View File

@@ -0,0 +1,50 @@
{% extends "base.html" %}
{% block title %}Asistent — {{ owner.tenant.name }}{% endblock %}
{% block nav %}{% include "dashboard/_nav.html" %}{% endblock %}
{% block content %}
<h1>Virtuelni asistent</h1>
<form method="post" action="/dash/agent">
<div class="grid2">
<div class="card">
<h2>Glas i cijene</h2>
<label>Glas asistenta</label>
<select name="tts_voice">
{% for v in voices %}
<option value="{{ v.id }}" {{ 'selected' if t.tts_voice==v.id }}>{{ v.label }}</option>
{% endfor %}
</select>
<label>Kako asistent odgovara na pitanja o cijenama</label>
<select name="price_mode">
<option value="exact" {{ 'selected' if t.price_mode=='exact' }}>Kaže tačnu cijenu</option>
<option value="range" {{ 'selected' if t.price_mode=='range' }}>Kaže raspon (od–do)</option>
<option value="on_request" {{ 'selected' if t.price_mode=='on_request' }}>„Cijene na upit"</option>
</select>
</div>
<div class="card">
<h2>Napomene za asistenta</h2>
<p class="muted">Činjenice o salonu koje asistent smije reći klijentima
(npr. „parking iza zgrade", „ne primamo djecu ispod 7 godina").</p>
<textarea name="agent_notes" style="min-height:120px">{{ t.agent_notes }}</textarea>
</div>
</div>
<button>Sačuvaj</button>
</form>
<div class="card" style="margin-top:20px">
<h2>Chat widget za vašu web stranicu</h2>
<p class="muted">Zalijepite ovaj red koda pred kraj <code>&lt;body&gt;</code> taga vaše
stranice (ili ga pošaljite onome ko vam održava web):</p>
<pre>&lt;script src="{{ base_url }}/widget.js" data-gogo-key="{{ t.widget_public_key }}" async&gt;&lt;/script&gt;</pre>
<p class="muted">Isprobajte razgovor na <a href="/widget-demo/{{ t.widget_public_key }}" target="_blank">demo stranici</a>.</p>
</div>
<div class="card">
<h2>Pozdravna poruka</h2>
<p class="muted">Pozdrav i ponašanje asistenta održava Gogo tim centralno — uvijek uključuje
obavještenje o snimanju razgovora. Trenutni pozdrav:</p>
<blockquote style="border-left:3px solid var(--brand);margin:8px 0;padding:4px 14px;color:var(--muted)">
{{ greeting }}
</blockquote>
</div>
{% endblock %}

View File

@@ -0,0 +1,27 @@
{% extends "base.html" %}
{% block title %}Poziv — Gogo Telefon{% endblock %}
{% block nav %}{% include "dashboard/_nav.html" %}{% endblock %}
{% block content %}
<p><a href="/dash/calls">← Svi pozivi</a></p>
<h1>Poziv {{ call.started_at.strftime('%d.%m.%Y. %H:%M') }}
<span class="badge {{ call.outcome }}">{{ outcome_labels.get(call.outcome, call.outcome) }}</span></h1>
<div class="card">
<p><b>Broj:</b> {{ call.caller_msisdn or '—' }} ·
<b>Trajanje:</b> {{ call.duration_s }}s ·
<b>Naplaćene minute asistenta:</b> {{ (call.agent_seconds_charged / 60) | round(1) }} min</p>
{% if call.recording_path %}
<audio controls src="/dash/calls/{{ call.id }}/audio" style="width:100%"></audio>
{% else %}<p class="muted">Snimka nije dostupna (obrisana nakon isteka perioda čuvanja ili poziv nije snimljen).</p>{% endif %}
</div>
{% if call.transcript %}
<div class="card">
<h2>Transkript</h2>
<div class="transcript">
{% for t in call.transcript %}
<div class="t {{ t.role }}">{{ t.text }}</div>
{% if t.tool_calls %}<div class="tools">⚙ {% for tc in t.tool_calls %}{{ tc.name }} {% endfor %}</div>{% endif %}
{% endfor %}
</div>
</div>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,22 @@
{% extends "base.html" %}
{% block title %}Pozivi — {{ owner.tenant.name }}{% endblock %}
{% block nav %}{% include "dashboard/_nav.html" %}{% endblock %}
{% block content %}
<h1>Historija poziva</h1>
<div class="card">
<table>
<tr><th>Vrijeme</th><th>Broj</th><th>Trajanje</th><th>Ishod</th><th></th></tr>
{% for c in calls %}
<tr>
<td class="muted">{{ c.started_at.strftime('%d.%m. %H:%M') }}</td>
<td>{{ c.caller_msisdn or '—' }}</td>
<td>{{ c.duration_s // 60 }}:{{ '%02d' % (c.duration_s % 60) }}</td>
<td><span class="badge {{ c.outcome }}">{{ outcome_labels.get(c.outcome, c.outcome) }}</span></td>
<td><a class="btn small ghost" href="/dash/calls/{{ c.id }}">Detalji</a></td>
</tr>
{% else %}
<tr><td colspan="5" class="muted">Još nema zabilježenih poziva.</td></tr>
{% endfor %}
</table>
</div>
{% endblock %}

View File

@@ -0,0 +1,14 @@
{% extends "base.html" %}
{% block title %}Chat razgovor — Gogo Telefon{% endblock %}
{% block nav %}{% include "dashboard/_nav.html" %}{% endblock %}
{% block content %}
<p><a href="/dash/chats">← Svi razgovori</a></p>
<h1>Chat {{ chat.started_at.strftime('%d.%m.%Y. %H:%M') }}</h1>
<div class="card">
<div class="transcript">
{% for m in messages %}
<div class="t {{ m.role }}">{{ m.content }}</div>
{% endfor %}
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,22 @@
{% extends "base.html" %}
{% block title %}Chat — {{ owner.tenant.name }}{% endblock %}
{% block nav %}{% include "dashboard/_nav.html" %}{% endblock %}
{% block content %}
<h1>Chat razgovori</h1>
<div class="card">
<table>
<tr><th>Vrijeme</th><th>Poruka</th><th>Ishod</th><th></th></tr>
{% for row in sessions %}
<tr>
<td class="muted">{{ row.chat.started_at.strftime('%d.%m. %H:%M') }}</td>
<td class="muted">{{ row.preview }}</td>
<td>{% if row.chat.outcome %}<span class="badge {{ row.chat.outcome }}">{{ outcome_labels.get(row.chat.outcome, row.chat.outcome) }}</span>{% endif %}</td>
<td><a class="btn small ghost" href="/dash/chats/{{ row.chat.id }}">Detalji</a></td>
</tr>
{% else %}
<tr><td colspan="4" class="muted">Još nema chat razgovora. Postavite widget na vašu web
stranicu — kod se nalazi pod <a href="/dash/agent">Asistent</a>.</td></tr>
{% endfor %}
</table>
</div>
{% endblock %}

View File

@@ -0,0 +1,19 @@
{% extends "base.html" %}
{% block title %}Prijava — Gogo Telefon{% endblock %}
{% block content %}
<div style="max-width:380px;margin:60px auto">
<div class="card">
<h1 style="margin-top:0">Gogo <span style="color:var(--brand)">Telefon</span></h1>
<p class="muted">Prijava za vlasnike salona</p>
<form method="post" action="/login">
<label>Email</label>
<input type="email" name="email" required autofocus>
<label>Lozinka</label>
<input type="password" name="password" required>
<div style="margin-top:16px"><button style="width:100%">Prijavi se</button></div>
</form>
<p class="muted" style="margin-top:16px">Nemate nalog? Gogo Telefon nalozi se otvaraju
uz pomoć našeg tima — javite nam se.</p>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,28 @@
{% extends "base.html" %}
{% block title %}Obavještenja — {{ owner.tenant.name }}{% endblock %}
{% block nav %}{% include "dashboard/_nav.html" %}{% endblock %}
{% block content %}
<h1>Obavještenja</h1>
<form method="post" action="/dash/notifications">
<div class="card">
<h2>Email za zahtjeve</h2>
<p class="muted">Na ove adrese stižu novi zahtjevi za termine (jedna po redu):</p>
<textarea name="notify_emails" placeholder="vlasnica@salon.ba">{{ emails_text }}</textarea>
<label style="margin-top:16px"><input type="checkbox" name="sms_request_received"
{{ 'checked' if t.sms_request_received }} style="width:auto">
Pošalji klijentu SMS „primili smo vaš zahtjev" odmah po zaprimanju</label>
</div>
<div class="card">
<h2>SMS poruke klijentima</h2>
<p class="muted">Možete prilagoditi tekst poruka. Dostupne oznake:
<code>{salon}</code>, <code>{usluga}</code>, <code>{dan}</code>, <code>{datum}</code>,
<code>{vrijeme}</code>, <code>{link}</code>. Ostavite prazno za standardni tekst.</p>
{% for key, label, default in sms_rows %}
<label>{{ label }}</label>
<textarea name="sms_{{ key }}" placeholder="{{ default }}">{{ overrides.get(key, '') }}</textarea>
{% endfor %}
</div>
<button>Sačuvaj</button>
</form>
{% endblock %}

View File

@@ -0,0 +1,59 @@
{% extends "base.html" %}
{% block title %}Zahtjev — {{ req.client_name }}{% endblock %}
{% block nav %}{% include "dashboard/_nav.html" %}{% endblock %}
{% block content %}
<p><a href="/dash">← Svi zahtjevi</a></p>
<h1>Zahtjev — {{ req.client_name }}
<span class="badge {{ req.status }}">{{ status_labels[req.status] }}</span></h1>
<div class="grid2">
<div class="card">
<h2>Podaci</h2>
<table>
<tr><td class="muted">Klijent</td><td><b>{{ req.client_name }}</b></td></tr>
<tr><td class="muted">Telefon</td><td>{{ req.client_phone }}</td></tr>
<tr><td class="muted">Usluga</td><td>{{ service_name }}</td></tr>
<tr><td class="muted">Izvor</td><td>{{ 'telefonski poziv' if req.source=='voice' else 'web chat' }}</td></tr>
{% if req.home_visit %}<tr><td class="muted">Kućna posjeta</td><td>DA — {{ req.address or 'adresa nije navedena' }}</td></tr>{% endif %}
{% if req.time_preference_text %}<tr><td class="muted">Željeno vrijeme</td><td>{{ req.time_preference_text }}</td></tr>{% endif %}
<tr><td class="muted">Zaprimljen</td><td>{{ req.created_at.strftime('%d.%m.%Y. %H:%M') }}</td></tr>
{% if req.resolved_at %}<tr><td class="muted">Zatvoren</td><td>{{ req.resolved_at.strftime('%d.%m.%Y. %H:%M') }}</td></tr>{% endif %}
</table>
{% if req.summary %}<p><b>Sažetak:</b> {{ req.summary }}</p>{% endif %}
</div>
<div class="card">
<h2>Akcije</h2>
{% if req.status == 'pending' %}
<form method="post" action="/dash/requests/{{ req.id }}/resolve">
<button class="green" style="width:100%">✓ Riješeno — kontaktirao/la sam klijenta</button>
</form>
<p class="muted">Klijentu se ne šalje SMS — dogovorili ste se direktno.</p>
{% for s in slots %}
<form method="post" action="/dash/requests/{{ req.id }}/confirm/{{ loop.index0 }}" style="margin-top:8px">
<button style="width:100%">Potvrdi {{ s }} + pošalji SMS</button>
</form>
{% endfor %}
<form method="post" action="/dash/requests/{{ req.id }}/reject" style="margin-top:8px">
<textarea name="sms_body" title="SMS koji će klijent dobiti">{{ rejection_sms }}</textarea>
<button class="red" style="width:100%;margin-top:8px">Odbij zahtjev i pošalji SMS</button>
</form>
{% else %}
<p class="muted">Zahtjev je zatvoren.
{% if req.confirmed_slot %}Potvrđen termin: {{ confirmed_label }}.{% endif %}</p>
{% endif %}
</div>
</div>
{% if transcript %}
<div class="card">
<h2>Razgovor</h2>
<div class="transcript">
{% for t in transcript %}
<div class="t {{ t.role }}">{{ t.text }}</div>
{% if t.tool_calls %}<div class="tools">⚙ {% for tc in t.tool_calls %}{{ tc.name }} {% endfor %}</div>{% endif %}
{% endfor %}
</div>
</div>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,37 @@
{% extends "base.html" %}
{% block title %}Zahtjevi — {{ owner.tenant.name }}{% endblock %}
{% block nav %}{% include "dashboard/_nav.html" %}{% endblock %}
{% block content %}
<h1>Zahtjevi za termine</h1>
{% if usage.over_80 %}
<div class="banner">Iskorišteno je {{ usage.pct }}% uključenih minuta asistenta ovaj mjesec
({{ usage.used_minutes }}/{{ usage.included_minutes }} min).</div>
{% endif %}
<div class="card">
<table>
<tr><th>Datum</th><th>Klijent</th><th>Usluga</th><th>Termini</th><th>Status</th><th></th></tr>
{% for r in rows %}
<tr>
<td class="muted">{{ r.req.created_at.strftime('%d.%m. %H:%M') }}<br>
<span class="badge {{ r.req.source }}">{{ 'poziv' if r.req.source=='voice' else 'chat' }}</span></td>
<td><b>{{ r.req.client_name }}</b><br><span class="muted">{{ r.req.client_phone }}</span></td>
<td>{{ r.service_name }}{% if r.req.home_visit %}<br><span class="muted">🏠 {{ r.req.address or 'kućna posjeta' }}</span>{% endif %}</td>
<td>{% for s in r.slots %}{{ s }}<br>{% endfor %}
{% if r.req.time_preference_text and not r.slots %}<span class="muted">{{ r.req.time_preference_text }}</span>{% endif %}</td>
<td><span class="badge {{ r.req.status }}">{{ status_labels[r.req.status] }}</span></td>
<td style="white-space:nowrap">
<a class="btn small ghost" href="/dash/requests/{{ r.req.id }}">Detalji</a>
{% if r.req.status == 'pending' %}
<form method="post" action="/dash/requests/{{ r.req.id }}/resolve" style="display:inline">
<button class="small green" title="Kontaktirao/la sam klijenta — zatvori bez SMS-a">Riješeno</button>
</form>
{% endif %}
</td>
</tr>
{% else %}
<tr><td colspan="6" class="muted">Još nema zahtjeva. Kad asistent primi zahtjev za termin,
pojaviće se ovdje i stići će vam email.</td></tr>
{% endfor %}
</table>
</div>
{% endblock %}

View File

@@ -0,0 +1,86 @@
{% extends "base.html" %}
{% block title %}Kalendar — {{ owner.tenant.name }}{% endblock %}
{% block nav %}{% include "dashboard/_nav.html" %}{% endblock %}
{% block content %}
<h1>Kalendar i termini</h1>
{% if t.scheduling_provider == 'partner_api' %}
<div class="card">
<h2>Vaš booking softver</h2>
<p>Gogo je povezan direktno s vašim booking softverom — slobodni termini se čitaju iz njega,
a zahtjevi stižu u njega. Tu ih vaše osoblje potvrđuje kao i inače.</p>
<table>
<tr><td class="muted">Status veze</td>
<td>{% if partner_ok is none %}<span class="badge pending">nije testirano</span>
{% elif partner_ok %}<span class="badge confirmed">povezano</span>
{% else %}<span class="badge rejected">greška veze</span>{% endif %}</td></tr>
<tr><td class="muted">Zadnja sinhronizacija usluga</td><td>{{ last_sync or '—' }}</td></tr>
</table>
<p class="muted">Podešavanje veze vrši Gogo tim — javite nam se ako nešto ne radi.</p>
</div>
{% elif t.scheduling_provider == 'google_calendar' %}
<div class="card">
<h2>Google kalendar</h2>
{% if connection %}
<p>Povezan nalog: <b>{{ connection.google_account }}</b>
<span class="badge confirmed">aktivno</span></p>
<p class="muted">Gogo čita samo zauzetost (slobodno/zauzeto) — nikad sadržaj vaših
događaja i nikad ne upisuje u kalendar.</p>
<form method="post" action="/dash/scheduling/google/disconnect"
onsubmit="return confirm('Prekinuti vezu s Google kalendarom?')">
<button class="red small">Prekini vezu</button>
</form>
{% else %}
<p class="muted">Povežite Google nalog da bi asistent nudio termine samo kad ste zaista
slobodni. Gogo traži <b>samo</b> pristup zauzetosti (free/busy) — ne vidi sadržaj vaših
događaja i ne može ništa upisivati.</p>
{% if google_configured %}
<a class="btn" href="/dash/scheduling/google/start">Poveži Google kalendar</a>
{% else %}
<p class="banner">Google prijava još nije podešena na ovom serveru — javite se Gogo timu.</p>
{% endif %}
{% endif %}
</div>
<div class="card">
<h2>Kalendari po uslugama</h2>
<p class="muted">Ako vodite više kalendara (npr. po radnicama ili vrstama usluga), ovdje
odredite koji kalendar vrijedi za koju uslugu. ID kalendara nađete u Google Calendar
postavkama („Integrate calendar" → Calendar ID); glavni kalendar je <code>primary</code>.</p>
<form method="post" action="/dash/scheduling/mappings">
<table>
<tr><th>Usluga</th><th>Google Calendar ID</th></tr>
<tr><td><i>Zadani kalendar (sve usluge)</i></td>
<td><input type="text" name="default" value="{{ mappings.get('default','primary') }}"></td></tr>
{% for s in services %}
<tr><td>{{ s.name }}</td>
<td><input type="text" name="svc_{{ s.id }}" value="{{ mappings.get(s.id|string, '') }}"
placeholder="(zadani)"></td></tr>
{% endfor %}
</table>
<button style="margin-top:12px">Sačuvaj</button>
</form>
</div>
<div class="card">
<h2>Pravila nuđenja termina</h2>
<form method="post" action="/dash/scheduling/buffers">
<div class="grid2">
<div>
<label>Najranije nuđenje <small>(sati od trenutka poziva)</small></label>
<input type="number" name="min_notice_hours" value="{{ t.min_notice_hours }}" min="0" max="72">
</div>
<div>
<label>Najdalje nuđenje <small>(dana unaprijed)</small></label>
<input type="number" name="max_days_ahead" value="{{ t.max_days_ahead }}" min="1" max="60">
</div>
</div>
<button style="margin-top:12px">Sačuvaj</button>
</form>
</div>
{% else %}
<div class="card"><p class="muted">Demo režim (mock kalendar) — termini se računaju samo iz
radnog vremena.</p></div>
{% endif %}
{% endblock %}

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 %}

View File

@@ -0,0 +1,43 @@
{% extends "base.html" %}
{% block title %}Salon — {{ owner.tenant.name }}{% endblock %}
{% block nav %}{% include "dashboard/_nav.html" %}{% endblock %}
{% block content %}
<h1>Profil salona</h1>
<form method="post" action="/dash/settings">
<div class="grid2">
<div class="card">
<h2>Osnovni podaci</h2>
<label>Naziv salona</label>
<input type="text" name="name" value="{{ t.name }}" required>
<label>Adresa</label>
<input type="text" name="address" value="{{ t.address }}">
<label>Grad</label>
<input type="text" name="city" value="{{ t.city }}">
<label>Telefon salona <small>(vaš postojeći broj)</small></label>
<input type="text" name="phone" value="{{ t.phone }}">
<label>Web stranica</label>
<input type="text" name="website" value="{{ t.website }}">
</div>
<div class="card">
<h2>Radno vrijeme</h2>
<p class="muted">Format: <code>09:00-13:00, 14:00-18:00</code> (pauza = dva intervala).
Prazno = neradni dan.</p>
{% for key, label in day_labels %}
<label>{{ label }}</label>
<input type="text" name="wh_{{ key }}" value="{{ hours_text[key] }}" placeholder="npr. 09:00-18:00">
{% endfor %}
</div>
</div>
<button>Sačuvaj</button>
</form>
<div class="card" style="margin-top:20px">
<h2>Zalijepi radno vrijeme</h2>
<p class="muted">Zalijepite radno vrijeme kako god ga imate zapisano (sa web stranice,
Facebooka…) — Gogo će ga pročitati, a vi samo provjerite i sačuvate.</p>
<form method="post" action="/dash/settings/parse-hours">
<textarea name="text" placeholder="npr. pon-pet 9-18 (pauza 13-14), subota 9-14, nedjelja ne radimo"></textarea>
<button class="ghost" style="margin-top:8px">Pročitaj radno vrijeme</button>
</form>
</div>
{% endblock %}

View File

@@ -0,0 +1,80 @@
{% extends "base.html" %}
{% block title %}Telefonija — {{ owner.tenant.name }}{% endblock %}
{% block nav %}{% include "dashboard/_nav.html" %}{% endblock %}
{% block content %}
<h1>Telefonija</h1>
<div class="card">
<h2>Vaš Gogo broj</h2>
{% if number %}
<p style="font-size:22px;margin:4px 0"><b>{{ number.msisdn }}</b></p>
<p class="muted">Na ovaj broj se prosljeđuju pozivi s vašeg postojećeg broja kad se niko
ne javi. Vaši klijenti i dalje zovu vaš stari broj — ništa se ne mijenja.</p>
<h2 style="margin-top:20px">Uključivanje prosljeđivanja</h2>
<p class="muted">Ukucajte ove kodove na telefonu na kojem je SIM vašeg salonskog broja
(poziv se pokreće tipkom za zvanje). Odaberite svog operatera:</p>
{% for f in forwarding %}
<details {{ 'open' if loop.first }}>
<summary style="cursor:pointer;font-weight:600;padding:6px 0">{{ f.operator_label }}</summary>
<table>
<tr><td class="muted">Kad se ne javite ({{ t.ring_timeout_s }}s)</td><td><code>{{ f.activate_no_answer }}</code></td></tr>
<tr><td class="muted">Kad je zauzeto</td><td><code>{{ f.activate_busy }}</code></td></tr>
<tr><td class="muted">Kad ste nedostupni</td><td><code>{{ f.activate_unreachable }}</code></td></tr>
<tr><td class="muted">Isključi sva prosljeđivanja</td><td><code>{{ f.deactivate_all }}</code></td></tr>
<tr><td class="muted">Provjera statusa</td><td><code>{{ f.check_status }}</code></td></tr>
</table>
</details>
{% endfor %}
{% else %}
<p class="banner">Broj vam još nije dodijeljen — Gogo tim će to obaviti pri uključenju.</p>
{% endif %}
</div>
<div class="card">
<h2>Zvonjenje osoblju prije asistenta</h2>
<form method="post" action="/dash/telephony">
<label><input type="checkbox" name="ring_first_enabled" {{ 'checked' if t.ring_first_enabled }}
style="width:auto"> Prvo zvoni osoblju, asistent se javlja tek ako se niko ne javi</label>
<div class="grid2">
<div>
<label>Koliko dugo zvoni osoblju <small>(sekundi)</small></label>
<input type="number" name="ring_timeout_s" value="{{ t.ring_timeout_s }}" min="5" max="60">
</div>
<div>
<label>Način zvonjenja</label>
<select name="ring_strategy">
<option value="ring_all" {{ 'selected' if t.ring_strategy=='ring_all' }}>Svi odjednom</option>
<option value="sequential" {{ 'selected' if t.ring_strategy=='sequential' }}>Jedan po jedan</option>
</select>
</div>
</div>
<button style="margin-top:12px">Sačuvaj</button>
</form>
</div>
<div class="card">
<h2>Osoblje (aplikacija za javljanje)</h2>
<p class="muted">Svaka radnica instalira besplatnu aplikaciju (preporučujemo
<b>Linphone</b>, može i Zoiper/Groundwire) i skenira svoj QR kod ili prepiše podatke.
Kad zazvoni salon, zazvoni i aplikacija.</p>
<table>
<tr><th>Ime</th><th>SIP korisničko ime</th><th>Podešavanje</th><th></th></tr>
{% for w in workers %}
<tr>
<td>{{ w.name }}</td>
<td><code>{{ w.sip_username }}</code></td>
<td><a class="btn small ghost" href="/dash/workers/{{ w.id }}/setup">QR + podaci</a></td>
<td><form method="post" action="/dash/workers/{{ w.id }}/delete"
onsubmit="return confirm('Ukloniti {{ w.name }} iz zvonjenja?')">
<button class="small red">Ukloni</button></form></td>
</tr>
{% else %}
<tr><td colspan="4" class="muted">Još nema dodanog osoblja.</td></tr>
{% endfor %}
</table>
<form method="post" action="/dash/workers" style="margin-top:12px;display:flex;gap:8px">
<input type="text" name="name" placeholder="Ime radnice/radnika" required style="max-width:260px">
<button class="green">Dodaj</button>
</form>
</div>
{% endblock %}

View File

@@ -0,0 +1,29 @@
{% extends "base.html" %}
{% block title %}Potrošnja — {{ owner.tenant.name }}{% endblock %}
{% block nav %}{% include "dashboard/_nav.html" %}{% endblock %}
{% block content %}
<h1>Potrošnja — {{ usage.month }}</h1>
<div class="card">
<h2>Minute virtualnog asistenta</h2>
<div class="bar"><div class="{{ 'over' if usage.over_100 else ('warn' if usage.over_80 else '') }}"
style="width:{{ usage.pct }}%"></div></div>
<p><b>{{ usage.used_minutes }}</b> od <b>{{ usage.included_minutes }}</b> uključenih minuta
({{ usage.pct }}%)</p>
{% if usage.over_100 %}
<div class="banner">Prekoračili ste uključene minute. Asistent i dalje odgovara na pozive —
javiće vam se Gogo tim oko proširenja paketa.</div>
{% endif %}
<p class="muted">Vrijeme kad se javi vaše osoblje ne troši minute; chat se ne naplaćuje po
minutama.</p>
</div>
<div class="grid3">
<div class="card"><h2>Pozivi asistenta</h2><p style="font-size:28px;margin:0"><b>{{ usage.calls }}</b></p></div>
<div class="card"><h2>Poslani SMS</h2><p style="font-size:28px;margin:0"><b>{{ usage.sms_sent }}</b></p></div>
<div class="card"><h2>Chat razgovori</h2><p style="font-size:28px;margin:0"><b>{{ usage.chat_sessions }}</b></p></div>
</div>
{% if owner.tenant.paid_until %}
<div class="card"><h2>Pretplata</h2>
<p>Plaćeno do: <b>{{ owner.tenant.paid_until.strftime('%d.%m.%Y.') }}</b> —
paket „Gogo Start" ({{ owner.tenant.included_minutes }} min/mj).</p></div>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,25 @@
{% extends "base.html" %}
{% block title %}Podešavanje aplikacije — {{ worker.name }}{% endblock %}
{% block nav %}{% include "dashboard/_nav.html" %}{% endblock %}
{% block content %}
<p><a href="/dash/telephony">← Telefonija</a></p>
<h1>Aplikacija za: {{ worker.name }}</h1>
<div class="grid2">
<div class="card" style="text-align:center">
<h2>Skeniraj QR kod</h2>
<img src="/dash/workers/{{ worker.id }}/qr.png" alt="QR za podešavanje" style="max-width:260px">
</div>
<div class="card">
<h2>Ili prepiši ručno</h2>
<table>
<tr><td class="muted">Server / domena</td><td><code>{{ domain }}</code></td></tr>
<tr><td class="muted">Korisničko ime</td><td><code>{{ worker.sip_username }}</code></td></tr>
<tr><td class="muted">Lozinka</td><td><code>{{ worker.sip_password }}</code></td></tr>
<tr><td class="muted">Transport</td><td><code>UDP, port 5060</code></td></tr>
</table>
<p class="muted" style="margin-top:16px">Koraci u Linphone aplikaciji: Assistant →
„Use SIP account" → unesite podatke → Save. Provjerite da aplikacija ima dozvolu za
obavještenja da bi zvonila.</p>
</div>
</div>
{% endblock %}