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:
19
gogo/templates/dashboard/_nav.html
Normal file
19
gogo/templates/dashboard/_nav.html
Normal 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>
|
||||
50
gogo/templates/dashboard/agent.html
Normal file
50
gogo/templates/dashboard/agent.html
Normal 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><body></code> taga vaše
|
||||
stranice (ili ga pošaljite onome ko vam održava web):</p>
|
||||
<pre><script src="{{ base_url }}/widget.js" data-gogo-key="{{ t.widget_public_key }}" async></script></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 %}
|
||||
27
gogo/templates/dashboard/call_detail.html
Normal file
27
gogo/templates/dashboard/call_detail.html
Normal 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 %}
|
||||
22
gogo/templates/dashboard/calls.html
Normal file
22
gogo/templates/dashboard/calls.html
Normal 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 %}
|
||||
14
gogo/templates/dashboard/chat_detail.html
Normal file
14
gogo/templates/dashboard/chat_detail.html
Normal 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 %}
|
||||
22
gogo/templates/dashboard/chats.html
Normal file
22
gogo/templates/dashboard/chats.html
Normal 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 %}
|
||||
19
gogo/templates/dashboard/login.html
Normal file
19
gogo/templates/dashboard/login.html
Normal 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 %}
|
||||
28
gogo/templates/dashboard/notifications.html
Normal file
28
gogo/templates/dashboard/notifications.html
Normal 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 %}
|
||||
59
gogo/templates/dashboard/request_detail.html
Normal file
59
gogo/templates/dashboard/request_detail.html
Normal 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 %}
|
||||
37
gogo/templates/dashboard/requests.html
Normal file
37
gogo/templates/dashboard/requests.html
Normal 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 %}
|
||||
86
gogo/templates/dashboard/scheduling.html
Normal file
86
gogo/templates/dashboard/scheduling.html
Normal 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 %}
|
||||
85
gogo/templates/dashboard/services.html
Normal file
85
gogo/templates/dashboard/services.html
Normal 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 %}
|
||||
43
gogo/templates/dashboard/settings.html
Normal file
43
gogo/templates/dashboard/settings.html
Normal 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 %}
|
||||
80
gogo/templates/dashboard/telephony.html
Normal file
80
gogo/templates/dashboard/telephony.html
Normal 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 %}
|
||||
29
gogo/templates/dashboard/usage.html
Normal file
29
gogo/templates/dashboard/usage.html
Normal 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 %}
|
||||
25
gogo/templates/dashboard/worker_setup.html
Normal file
25
gogo/templates/dashboard/worker_setup.html
Normal 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 %}
|
||||
Reference in New Issue
Block a user