- 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>
110 lines
5.5 KiB
HTML
110 lines
5.5 KiB
HTML
{% extends "base.html" %}
|
|
{% block title %}{{ t.name if t else 'Novi salon' }} — Gogo Admin{% endblock %}
|
|
{% block nav %}{% include "admin/_nav.html" %}{% endblock %}
|
|
{% block content %}
|
|
<p><a href="/admin">← Svi saloni</a></p>
|
|
<h1>{{ t.name if t else 'Novi salon' }}</h1>
|
|
|
|
<form method="post" action="{{ '/admin/tenants/' + (t.id|string) if t else '/admin/tenants/new' }}">
|
|
<div class="grid2">
|
|
<div class="card">
|
|
<h2>Osnovno</h2>
|
|
<label>Naziv</label><input type="text" name="name" value="{{ t.name if t }}" required>
|
|
<label>Slug <small>(kratki id, bez razmaka)</small></label>
|
|
<input type="text" name="slug" value="{{ t.slug if t }}" {{ 'readonly' if t }} required>
|
|
<label>Grad</label><input type="text" name="city" value="{{ t.city if t }}">
|
|
<label>Status</label>
|
|
<select name="status">
|
|
<option value="active" {{ 'selected' if t and t.status=='active' }}>aktivan</option>
|
|
<option value="disabled" {{ 'selected' if t and t.status=='disabled' }}>isključen</option>
|
|
</select>
|
|
{% if not t %}
|
|
<label>Email vlasnika <small>(kreira se nalog)</small></label>
|
|
<input type="email" name="owner_email" required>
|
|
<label>Lozinka vlasnika</label>
|
|
<input type="text" name="owner_password" required>
|
|
{% endif %}
|
|
</div>
|
|
<div class="card">
|
|
<h2>Plan i naplata (§12)</h2>
|
|
<label>Plan</label>
|
|
<input type="text" name="plan" value="{{ t.plan if t else 'gogo_start' }}">
|
|
<label>Uključene minute mjesečno</label>
|
|
<input type="number" name="included_minutes" value="{{ t.included_minutes if t else 300 }}">
|
|
<label>Plaćeno do <small>(YYYY-MM-DD, prazno = nije plaćeno)</small></label>
|
|
<input type="text" name="paid_until" value="{{ t.paid_until.strftime('%Y-%m-%d') if t and t.paid_until }}">
|
|
<label><input type="checkbox" name="hard_cutoff" {{ 'checked' if t and t.hard_cutoff }} style="width:auto">
|
|
Tvrdi prekid na 100% minuta (zadano: isključeno — asistent uvijek odgovara)</label>
|
|
<label>LLM model <small>(prazno = globalno zadani)</small></label>
|
|
<input type="text" name="llm_model" value="{{ t.llm_model if t }}">
|
|
<label>TTL prijedloga (sati)</label>
|
|
<input type="number" name="proposal_ttl_hours" value="{{ t.proposal_ttl_hours if t else 24 }}">
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h2>Provajder termina (§8)</h2>
|
|
<label>Tip</label>
|
|
<select name="scheduling_provider">
|
|
<option value="google_calendar" {{ 'selected' if t and t.scheduling_provider=='google_calendar' }}>google_calendar</option>
|
|
<option value="partner_api" {{ 'selected' if t and t.scheduling_provider=='partner_api' }}>partner_api</option>
|
|
<option value="mock" {{ 'selected' if t and t.scheduling_provider=='mock' }}>mock (demo/test)</option>
|
|
</select>
|
|
<div class="grid2">
|
|
<div>
|
|
<label>Partner: base URL</label>
|
|
<input type="url" name="partner_base_url" value="{{ pconfig.get('base_url','') }}" placeholder="https://booking.salon.ba/gogo/v1">
|
|
<label>Partner: API ključ <small>(prazno = ne mijenjaj)</small></label>
|
|
<input type="text" name="partner_api_key" placeholder="{{ '•••• postavljen' if pconfig.get('api_key_encrypted') else '' }}">
|
|
<label>Partner: webhook secret <small>(prazno = ne mijenjaj)</small></label>
|
|
<input type="text" name="partner_webhook_secret" placeholder="{{ '•••• postavljen' if pconfig.get('webhook_secret_encrypted') else '' }}">
|
|
</div>
|
|
<div>
|
|
<label><input type="checkbox" name="catalog_sync" {{ 'checked' if pconfig.get('catalog_sync') }} style="width:auto"> Sinhronizuj usluge iz partnera (GET /services)</label>
|
|
<label><input type="checkbox" name="email_to_owner" {{ 'checked' if pconfig.get('email_to_owner') }} style="width:auto"> Šalji i email vlasniku (uz push u partnera)</label>
|
|
<label><input type="checkbox" name="polling_fallback" {{ 'checked' if pconfig.get('polling_fallback') }} style="width:auto"> Polling fallback (partner nema webhook)</label>
|
|
{% if t %}
|
|
<label>Webhook URL za partnera</label>
|
|
<code style="display:block;padding:8px">{{ base_url }}/webhooks/partner/{{ t.id }}</code>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h2>Broj / SIM</h2>
|
|
<label>Dodijeljeni broj</label>
|
|
<select name="phone_number_id">
|
|
<option value="">— bez broja —</option>
|
|
{% for n in numbers %}
|
|
<option value="{{ n.id }}" {{ 'selected' if n.tenant_id == (t.id if t) }}>
|
|
{{ n.msisdn }} (port {{ n.gateway_port if n.gateway_port is not none else '?' }}, {{ n.operator or '?' }})
|
|
{%- if n.tenant_id and (not t or n.tenant_id != t.id) %} — ZAUZET{% endif %}
|
|
</option>
|
|
{% endfor %}
|
|
</select>
|
|
<p class="muted">Nove brojeve dodajete pod <a href="/admin/numbers">Brojevi/SIM</a>.</p>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h2>Prompt override <small style="font-weight:400">(samo za neobične salone — inače prazno)</small></h2>
|
|
<textarea name="prompt_override" style="min-height:120px;font-family:monospace"
|
|
placeholder="Prazno = koristi globalni šablon">{{ override_body }}</textarea>
|
|
</div>
|
|
|
|
<button>Sačuvaj</button>
|
|
</form>
|
|
|
|
{% if t and t.scheduling_provider == 'partner_api' %}
|
|
<div class="card" style="margin-top:20px">
|
|
<h2>Test veze s partnerom (§8.3)</h2>
|
|
<p class="muted">Pokreće živi <code>GET /availability</code> i dry-run
|
|
<code>POST /booking-requests</code>, prikazuje sirove odgovore.</p>
|
|
<form method="post" action="/admin/tenants/{{ t.id }}/test-connection">
|
|
<button class="ghost">Pokreni test</button>
|
|
</form>
|
|
{% if test_result %}<pre>{{ test_result }}</pre>{% endif %}
|
|
</div>
|
|
{% endif %}
|
|
{% endblock %}
|