"""Owner dashboard (§10) — Bosnian UI, server-rendered. Settings: profile/hours (with paste-to-parse), services CRUD + 'Zalijepi cjenovnik', scheduling per provider, telephony (forwarding codes, ring group, worker QR), agent (voice/price mode/notes/widget snippet), notifications. Review: booking requests (same actions as email), call/chat history, usage. """ from __future__ import annotations import json import uuid from fastapi import APIRouter, Depends, Form, Request from fastapi.responses import FileResponse, HTMLResponse, Response from sqlalchemy import desc, select from sqlalchemy.ext.asyncio import AsyncSession from gogo.agent.prompt import compose_greeting from gogo.auth import ( OwnerContext, clear_session, current_owner, read_session, set_session, verify_password, ) from gogo.config import get_settings from gogo.db import get_session from gogo.domain import BookingStatus, Slot from gogo.i18n import fmt_slot from gogo.metering import usage_summary from gogo.models import ( BookingRequest, CalendarConnection, CalendarMapping, Call, ChatMessage, ChatSession, PhoneNumber, Service, Tenant, User, Worker, ) from gogo.proposals import engine as proposal_engine from gogo.sms.templates import DEFAULT_TEMPLATES from gogo.telephony import sip from gogo.telephony.forwarding import OPERATORS, forwarding_codes from gogo.web import ( DAY_LABELS, OUTCOME_LABELS, STATUS_LABELS, TTS_VOICES, hours_to_text, redirect, templates, text_to_intervals, ) router = APIRouter() def ctx(request: Request, owner: OwnerContext, **extra) -> dict: return { "request": request, "owner": owner, "t": owner.tenant, "status_labels": STATUS_LABELS, "outcome_labels": OUTCOME_LABELS, **extra, } # -- auth --------------------------------------------------------------------- @router.get("/login", response_class=HTMLResponse) async def login_page(request: Request): return templates.TemplateResponse(request, "dashboard/login.html", {"request": request}) @router.post("/login") async def login( request: Request, email: str = Form(...), password: str = Form(...), session: AsyncSession = Depends(get_session), ): user = ( await session.execute(select(User).where(User.email == email.strip().lower())) ).scalar_one_or_none() if user is None or not user.active or not verify_password(password, user.password_hash): return redirect("/login", err="Pogrešan email ili lozinka.") resp = redirect("/dash") set_session(resp, {"kind": "owner", "user_id": str(user.id)}) return resp @router.get("/logout") async def logout(request: Request): data = read_session(request) or {} resp = redirect("/admin" if data.get("kind") == "admin" else "/login") if data.get("kind") == "admin": data.pop("impersonate_tenant", None) set_session(resp, data) else: clear_session(resp) return resp # -- review: requests --------------------------------------------------------- async def _request_row(session: AsyncSession, tenant: Tenant, req: BookingRequest) -> dict: service_name = req.service_name_raw or "—" if req.service_id: svc = ( await session.execute(select(Service).where(Service.id == req.service_id)) ).scalar_one_or_none() if svc: service_name = svc.name slots = [ fmt_slot(Slot.model_validate(s).start, tenant.timezone) for s in (req.slots or []) ] return {"req": req, "service_name": service_name, "slots": slots} @router.get("/dash", response_class=HTMLResponse) async def requests_list( request: Request, owner: OwnerContext = Depends(current_owner), session: AsyncSession = Depends(get_session), ): reqs = ( ( await session.execute( select(BookingRequest) .where(BookingRequest.tenant_id == owner.tenant.id) .order_by(desc(BookingRequest.created_at)) .limit(100) ) ) .scalars() .all() ) rows = [await _request_row(session, owner.tenant, r) for r in reqs] usage = await usage_summary(session, owner.tenant) await session.commit() return templates.TemplateResponse( request, "dashboard/requests.html", ctx(request, owner, rows=rows, usage=usage, active="requests"), ) async def _load_request( session: AsyncSession, owner: OwnerContext, request_id: str ) -> BookingRequest | None: try: rid = uuid.UUID(request_id) except ValueError: return None return ( await session.execute( select(BookingRequest).where( BookingRequest.id == rid, BookingRequest.tenant_id == owner.tenant.id ) ) ).scalar_one_or_none() @router.get("/dash/requests/{request_id}", response_class=HTMLResponse) async def request_detail( request_id: str, request: Request, owner: OwnerContext = Depends(current_owner), session: AsyncSession = Depends(get_session), ): req = await _load_request(session, owner, request_id) if req is None: return redirect("/dash", err="Zahtjev nije pronađen.") row = await _request_row(session, owner.tenant, req) from gogo.sms.templates import render_sms transcript = await _transcript_for_request(session, req) confirmed_label = "" if req.confirmed_slot: confirmed_label = fmt_slot( Slot.model_validate(req.confirmed_slot).start, owner.tenant.timezone ) return templates.TemplateResponse( request, "dashboard/request_detail.html", ctx( request, owner, req=req, service_name=row["service_name"], slots=row["slots"], rejection_sms=render_sms(owner.tenant, "rejection"), transcript=transcript, confirmed_label=confirmed_label, active="requests", ), ) async def _transcript_for_request(session: AsyncSession, req: BookingRequest) -> list[dict]: if req.call_id: call = ( await session.execute(select(Call).where(Call.id == req.call_id)) ).scalar_one_or_none() if call and call.transcript: return call.transcript if req.chat_session_id: msgs = ( ( await session.execute( select(ChatMessage) .where(ChatMessage.session_id == req.chat_session_id) .order_by(ChatMessage.at) ) ) .scalars() .all() ) return [{"role": m.role, "text": m.content} for m in msgs] return [] @router.post("/dash/requests/{request_id}/resolve") async def request_resolve( request_id: str, owner: OwnerContext = Depends(current_owner), session: AsyncSession = Depends(get_session), ): req = await _load_request(session, owner, request_id) if req is None or req.status != BookingStatus.pending.value: return redirect("/dash", err="Zahtjev je već obrađen.") await proposal_engine.resolve_request(session, owner.tenant, req) await session.commit() return redirect("/dash", msg="Zahtjev označen kao riješen — klijentu nije poslan SMS.") @router.post("/dash/requests/{request_id}/confirm/{slot_index}") async def request_confirm( request_id: str, slot_index: int, owner: OwnerContext = Depends(current_owner), session: AsyncSession = Depends(get_session), ): req = await _load_request(session, owner, request_id) if req is None or req.status != BookingStatus.pending.value: return redirect("/dash", err="Zahtjev je već obrađen.") slots = [Slot.model_validate(s) for s in (req.slots or [])] if slot_index >= len(slots): return redirect(f"/dash/requests/{request_id}", err="Termin ne postoji.") ok = await proposal_engine.confirm_request(session, owner.tenant, req, slots[slot_index]) await session.commit() if not ok: return redirect( f"/dash/requests/{request_id}", err="Termin je u međuvremenu zauzet u kalendaru — provjerite pa pokušajte ponovo.", ) return redirect(f"/dash/requests/{request_id}", msg="Termin potvrđen, SMS poslan klijentu.") @router.post("/dash/requests/{request_id}/reject") async def request_reject( request_id: str, sms_body: str = Form(""), owner: OwnerContext = Depends(current_owner), session: AsyncSession = Depends(get_session), ): req = await _load_request(session, owner, request_id) if req is None or req.status != BookingStatus.pending.value: return redirect("/dash", err="Zahtjev je već obrađen.") await proposal_engine.reject_request( session, owner.tenant, req, custom_sms=sms_body.strip() or None ) await session.commit() return redirect("/dash", msg="Zahtjev odbijen, klijent je obaviješten SMS-om.") # transcript short-link used in proposal emails: /t/{request_id} @router.get("/t/{request_id}") async def transcript_link( request_id: str, owner: OwnerContext = Depends(current_owner), ): return redirect(f"/dash/requests/{request_id}") # -- review: calls / chats / usage --------------------------------------------- @router.get("/dash/calls", response_class=HTMLResponse) async def calls_list( request: Request, owner: OwnerContext = Depends(current_owner), session: AsyncSession = Depends(get_session), ): calls = ( ( await session.execute( select(Call) .where(Call.tenant_id == owner.tenant.id) .order_by(desc(Call.started_at)) .limit(200) ) ) .scalars() .all() ) return templates.TemplateResponse( request, "dashboard/calls.html", ctx(request, owner, calls=calls, active="calls") ) @router.get("/dash/calls/{call_id}", response_class=HTMLResponse) async def call_detail( call_id: str, request: Request, owner: OwnerContext = Depends(current_owner), session: AsyncSession = Depends(get_session), ): call = ( await session.execute( select(Call).where( Call.id == uuid.UUID(call_id), Call.tenant_id == owner.tenant.id ) ) ).scalar_one_or_none() if call is None: return redirect("/dash/calls", err="Poziv nije pronađen.") return templates.TemplateResponse( request, "dashboard/call_detail.html", ctx(request, owner, call=call, active="calls") ) @router.get("/dash/calls/{call_id}/audio") async def call_audio( call_id: str, owner: OwnerContext = Depends(current_owner), session: AsyncSession = Depends(get_session), ): call = ( await session.execute( select(Call).where( Call.id == uuid.UUID(call_id), Call.tenant_id == owner.tenant.id ) ) ).scalar_one_or_none() if call is None or not call.recording_path: return Response(status_code=404) return FileResponse(call.recording_path, media_type="audio/wav") @router.get("/dash/chats", response_class=HTMLResponse) async def chats_list( request: Request, owner: OwnerContext = Depends(current_owner), session: AsyncSession = Depends(get_session), ): chats = ( ( await session.execute( select(ChatSession) .where(ChatSession.tenant_id == owner.tenant.id) .order_by(desc(ChatSession.started_at)) .limit(200) ) ) .scalars() .all() ) rows = [] for chat in chats: first_user = ( await session.execute( select(ChatMessage) .where(ChatMessage.session_id == chat.id, ChatMessage.role == "user") .order_by(ChatMessage.at) .limit(1) ) ).scalar_one_or_none() preview = (first_user.content[:80] + "…") if first_user and len(first_user.content) > 80 \ else (first_user.content if first_user else "(bez poruka)") rows.append({"chat": chat, "preview": preview}) return templates.TemplateResponse( request, "dashboard/chats.html", ctx(request, owner, sessions=rows, active="chats") ) @router.get("/dash/chats/{chat_id}", response_class=HTMLResponse) async def chat_detail( chat_id: str, request: Request, owner: OwnerContext = Depends(current_owner), session: AsyncSession = Depends(get_session), ): chat = ( await session.execute( select(ChatSession).where( ChatSession.id == uuid.UUID(chat_id), ChatSession.tenant_id == owner.tenant.id, ) ) ).scalar_one_or_none() if chat is None: return redirect("/dash/chats", err="Razgovor nije pronađen.") messages = ( ( await session.execute( select(ChatMessage) .where(ChatMessage.session_id == chat.id) .order_by(ChatMessage.at) ) ) .scalars() .all() ) return templates.TemplateResponse( request, "dashboard/chat_detail.html", ctx(request, owner, chat=chat, messages=messages, active="chats"), ) @router.get("/dash/usage", response_class=HTMLResponse) async def usage_page( request: Request, owner: OwnerContext = Depends(current_owner), session: AsyncSession = Depends(get_session), ): usage = await usage_summary(session, owner.tenant) await session.commit() return templates.TemplateResponse( request, "dashboard/usage.html", ctx(request, owner, usage=usage, active="usage") ) # -- settings: profile + hours -------------------------------------------------- @router.get("/dash/settings", response_class=HTMLResponse) async def settings_page( request: Request, owner: OwnerContext = Depends(current_owner), ): return templates.TemplateResponse( request, "dashboard/settings.html", ctx( request, owner, hours_text=hours_to_text(owner.tenant.working_hours), day_labels=DAY_LABELS, active="settings", ), ) @router.post("/dash/settings") async def settings_save( request: Request, owner: OwnerContext = Depends(current_owner), session: AsyncSession = Depends(get_session), ): form = await request.form() t = await session.merge(owner.tenant) t.name = str(form.get("name", t.name)).strip() or t.name t.address = str(form.get("address", "")).strip() t.city = str(form.get("city", "")).strip() t.phone = str(form.get("phone", "")).strip() t.website = str(form.get("website", "")).strip() hours = {} try: for key, _label in DAY_LABELS: hours[key] = text_to_intervals(str(form.get(f"wh_{key}", ""))) except ValueError as e: await session.commit() return redirect("/dash/settings", err=str(e)) t.working_hours = hours await session.commit() return redirect("/dash/settings", msg="Sačuvano.") @router.post("/dash/settings/parse-hours") async def settings_parse_hours( text: str = Form(...), owner: OwnerContext = Depends(current_owner), session: AsyncSession = Depends(get_session), ): from gogo.dashboard.parse import parse_working_hours hours = await parse_working_hours(text) if hours is None: return redirect( "/dash/settings", err="Nisam uspio pročitati radno vrijeme — unesite ga ručno." ) t = await session.merge(owner.tenant) t.working_hours = hours await session.commit() return redirect("/dash/settings", msg="Radno vrijeme pročitano — provjerite i po potrebi ispravite.") # -- settings: services ---------------------------------------------------------- @router.get("/dash/services", response_class=HTMLResponse) async def services_page( request: Request, owner: OwnerContext = Depends(current_owner), session: AsyncSession = Depends(get_session), ): services = ( ( await session.execute( select(Service) .where(Service.tenant_id == owner.tenant.id, Service.active.is_(True)) .order_by(Service.name) ) ) .scalars() .all() ) return templates.TemplateResponse( request, "dashboard/services.html", ctx(request, owner, services=services, parsed=None, active="services"), ) def _num_or_none(v) -> float | None: try: s = str(v).strip() return float(s) if s else None except ValueError: return None @router.post("/dash/services") async def service_add( request: Request, owner: OwnerContext = Depends(current_owner), session: AsyncSession = Depends(get_session), ): form = await request.form() session.add( Service( tenant_id=owner.tenant.id, name=str(form.get("name", "")).strip(), duration_min=int(form.get("duration_min") or 30), price_min=_num_or_none(form.get("price_min")), price_max=_num_or_none(form.get("price_max")), home_visit=bool(form.get("home_visit")), agent_note=str(form.get("agent_note", "")).strip(), ) ) await session.commit() return redirect("/dash/services", msg="Usluga dodana.") @router.post("/dash/services/{service_id:uuid}") async def service_update( service_id: uuid.UUID, request: Request, owner: OwnerContext = Depends(current_owner), session: AsyncSession = Depends(get_session), ): form = await request.form() svc = ( await session.execute( select(Service).where( Service.id == service_id, Service.tenant_id == owner.tenant.id ) ) ).scalar_one_or_none() if svc is None: return redirect("/dash/services", err="Usluga nije pronađena.") svc.name = str(form.get("name", svc.name)).strip() or svc.name svc.duration_min = int(form.get("duration_min") or svc.duration_min) svc.price_min = _num_or_none(form.get("price_min")) svc.price_max = _num_or_none(form.get("price_max")) svc.home_visit = bool(form.get("home_visit")) svc.agent_note = str(form.get("agent_note", "")).strip() await session.commit() return redirect("/dash/services", msg="Sačuvano.") @router.post("/dash/services/{service_id}/delete") async def service_delete( service_id: str, owner: OwnerContext = Depends(current_owner), session: AsyncSession = Depends(get_session), ): svc = ( await session.execute( select(Service).where( Service.id == uuid.UUID(service_id), Service.tenant_id == owner.tenant.id ) ) ).scalar_one_or_none() if svc: svc.active = False # soft delete: history references remain valid await session.commit() return redirect("/dash/services", msg="Usluga uklonjena.") @router.post("/dash/services/parse", response_class=HTMLResponse) async def services_parse( request: Request, text: str = Form(...), owner: OwnerContext = Depends(current_owner), session: AsyncSession = Depends(get_session), ): from gogo.dashboard.parse import parse_price_list parsed = await parse_price_list(text) if not parsed: return redirect( "/dash/services", err="Nisam uspio pročitati cjenovnik — dodajte usluge ručno." ) services = ( ( await session.execute( select(Service) .where(Service.tenant_id == owner.tenant.id, Service.active.is_(True)) .order_by(Service.name) ) ) .scalars() .all() ) return templates.TemplateResponse( request, "dashboard/services.html", ctx(request, owner, services=services, parsed=parsed, active="services"), ) @router.post("/dash/services/import") async def services_import( request: Request, owner: OwnerContext = Depends(current_owner), session: AsyncSession = Depends(get_session), ): form = await request.form() count = int(form.get("count") or 0) added = 0 for i in range(count): if not form.get(f"row_{i}"): continue name = str(form.get(f"name_{i}", "")).strip() if not name: continue session.add( Service( tenant_id=owner.tenant.id, name=name, duration_min=int(form.get(f"duration_{i}") or 30), price_min=_num_or_none(form.get(f"pmin_{i}")), price_max=_num_or_none(form.get(f"pmax_{i}")), home_visit=bool(form.get(f"home_{i}")), agent_note=str(form.get(f"note_{i}", "")).strip(), ) ) added += 1 await session.commit() return redirect("/dash/services", msg=f"Dodano {added} usluga.") # -- settings: scheduling --------------------------------------------------------- @router.get("/dash/scheduling", response_class=HTMLResponse) async def scheduling_page( request: Request, owner: OwnerContext = Depends(current_owner), session: AsyncSession = Depends(get_session), ): t = owner.tenant connection = ( await session.execute( select(CalendarConnection).where(CalendarConnection.tenant_id == t.id) ) ).scalar_one_or_none() mappings_rows = ( (await session.execute(select(CalendarMapping).where(CalendarMapping.tenant_id == t.id))) .scalars() .all() ) mappings = {} for m in mappings_rows: mappings["default" if m.service_id is None else str(m.service_id)] = m.google_calendar_id services = ( ( await session.execute( select(Service) .where(Service.tenant_id == t.id, Service.active.is_(True)) .order_by(Service.name) ) ) .scalars() .all() ) s = get_settings() return templates.TemplateResponse( request, "dashboard/scheduling.html", ctx( request, owner, connection=connection, mappings=mappings, services=services, google_configured=bool(s.google_client_id and s.google_client_secret), partner_ok=None, last_sync=None, active="scheduling", ), ) @router.post("/dash/scheduling/mappings") async def scheduling_mappings_save( request: Request, owner: OwnerContext = Depends(current_owner), session: AsyncSession = Depends(get_session), ): form = await request.form() t = owner.tenant existing = ( (await session.execute(select(CalendarMapping).where(CalendarMapping.tenant_id == t.id))) .scalars() .all() ) for m in existing: await session.delete(m) default = str(form.get("default", "")).strip() if default: session.add( CalendarMapping(tenant_id=t.id, service_id=None, google_calendar_id=default) ) for key, value in form.items(): if key.startswith("svc_") and str(value).strip(): session.add( CalendarMapping( tenant_id=t.id, service_id=uuid.UUID(key.removeprefix("svc_")), google_calendar_id=str(value).strip(), ) ) await session.commit() return redirect("/dash/scheduling", msg="Sačuvano.") @router.post("/dash/scheduling/buffers") async def scheduling_buffers_save( min_notice_hours: int = Form(...), max_days_ahead: int = Form(...), owner: OwnerContext = Depends(current_owner), session: AsyncSession = Depends(get_session), ): t = await session.merge(owner.tenant) t.min_notice_hours = max(0, min(72, min_notice_hours)) t.max_days_ahead = max(1, min(60, max_days_ahead)) await session.commit() return redirect("/dash/scheduling", msg="Sačuvano.") # Google OAuth (free/busy read-only scope, §8.1) GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth" GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token" FREEBUSY_SCOPE = "https://www.googleapis.com/auth/calendar.freebusy" @router.get("/dash/scheduling/google/start") async def google_oauth_start(owner: OwnerContext = Depends(current_owner)): from urllib.parse import urlencode s = get_settings() params = { "client_id": s.google_client_id, "redirect_uri": f"{s.base_url}/oauth/google/callback", "response_type": "code", "scope": f"openid email {FREEBUSY_SCOPE}", "access_type": "offline", "prompt": "consent", "state": str(owner.tenant.id), } return redirect(f"{GOOGLE_AUTH_URL}?{urlencode(params)}") @router.get("/oauth/google/callback") async def google_oauth_callback( code: str = "", state: str = "", owner: OwnerContext = Depends(current_owner), session: AsyncSession = Depends(get_session), ): import httpx from gogo.crypto import encrypt if not code or state != str(owner.tenant.id): return redirect("/dash/scheduling", err="Google prijava nije uspjela.") s = get_settings() async with httpx.AsyncClient(timeout=15) as client: resp = await client.post( GOOGLE_TOKEN_URL, data={ "client_id": s.google_client_id, "client_secret": s.google_client_secret, "code": code, "grant_type": "authorization_code", "redirect_uri": f"{s.base_url}/oauth/google/callback", }, ) if resp.status_code != 200: return redirect("/dash/scheduling", err="Google prijava nije uspjela (token).") tokens = resp.json() email = "" id_token = tokens.get("id_token", "") if id_token.count(".") == 2: # decode JWT payload without verification (email is cosmetic) import base64 try: payload = id_token.split(".")[1] payload += "=" * (-len(payload) % 4) email = json.loads(base64.urlsafe_b64decode(payload)).get("email", "") except Exception: # noqa: BLE001 email = "" from datetime import UTC, datetime, timedelta token_data = { "access_token": tokens.get("access_token", ""), "refresh_token": tokens.get("refresh_token", ""), "expiry": ( datetime.now(UTC) + timedelta(seconds=tokens.get("expires_in", 3600)) ).isoformat(), } existing = ( await session.execute( select(CalendarConnection).where(CalendarConnection.tenant_id == owner.tenant.id) ) ).scalar_one_or_none() if existing: existing.google_account = email or existing.google_account existing.token_data_encrypted = encrypt(json.dumps(token_data)) existing.status = "connected" else: session.add( CalendarConnection( tenant_id=owner.tenant.id, google_account=email or "(nepoznat nalog)", scopes=[FREEBUSY_SCOPE], token_data_encrypted=encrypt(json.dumps(token_data)), ) ) await session.commit() return redirect("/dash/scheduling", msg="Google kalendar povezan.") @router.post("/dash/scheduling/google/disconnect") async def google_disconnect( owner: OwnerContext = Depends(current_owner), session: AsyncSession = Depends(get_session), ): existing = ( await session.execute( select(CalendarConnection).where(CalendarConnection.tenant_id == owner.tenant.id) ) ).scalar_one_or_none() if existing: await session.delete(existing) await session.commit() return redirect("/dash/scheduling", msg="Veza s Google kalendarom prekinuta.") # -- settings: telephony ----------------------------------------------------------- @router.get("/dash/telephony", response_class=HTMLResponse) async def telephony_page( request: Request, owner: OwnerContext = Depends(current_owner), session: AsyncSession = Depends(get_session), ): t = owner.tenant number = ( await session.execute(select(PhoneNumber).where(PhoneNumber.tenant_id == t.id)) ).scalar_one_or_none() workers = ( ( await session.execute( select(Worker) .where(Worker.tenant_id == t.id, Worker.active.is_(True)) .order_by(Worker.name) ) ) .scalars() .all() ) forwarding = ( [forwarding_codes(op, number.msisdn, t.ring_timeout_s) for op in OPERATORS] if number else [] ) return templates.TemplateResponse( request, "dashboard/telephony.html", ctx(request, owner, number=number, workers=workers, forwarding=forwarding, active="telephony"), ) @router.post("/dash/telephony") async def telephony_save( request: Request, owner: OwnerContext = Depends(current_owner), session: AsyncSession = Depends(get_session), ): form = await request.form() t = await session.merge(owner.tenant) t.ring_first_enabled = bool(form.get("ring_first_enabled")) t.ring_timeout_s = max(5, min(60, int(form.get("ring_timeout_s") or 12))) strategy = str(form.get("ring_strategy", "ring_all")) t.ring_strategy = strategy if strategy in ("ring_all", "sequential") else "ring_all" await session.commit() return redirect("/dash/telephony", msg="Sačuvano.") @router.post("/dash/workers") async def worker_add( name: str = Form(...), owner: OwnerContext = Depends(current_owner), session: AsyncSession = Depends(get_session), ): worker = Worker( tenant_id=owner.tenant.id, name=name.strip(), sip_username=sip.make_sip_username(owner.tenant.slug, name), sip_password=sip.make_sip_password(), ) session.add(worker) await session.commit() return redirect(f"/dash/workers/{worker.id}/setup", msg=f"{worker.name} dodan/a.") async def _load_worker(session, owner: OwnerContext, worker_id: str) -> Worker | None: try: wid = uuid.UUID(worker_id) except ValueError: return None return ( await session.execute( select(Worker).where(Worker.id == wid, Worker.tenant_id == owner.tenant.id) ) ).scalar_one_or_none() @router.get("/dash/workers/{worker_id}/setup", response_class=HTMLResponse) async def worker_setup( worker_id: str, request: Request, owner: OwnerContext = Depends(current_owner), session: AsyncSession = Depends(get_session), ): worker = await _load_worker(session, owner, worker_id) if worker is None: return redirect("/dash/telephony", err="Radnik nije pronađen.") return templates.TemplateResponse( request, "dashboard/worker_setup.html", ctx(request, owner, worker=worker, domain=sip.sip_domain(), active="telephony"), ) @router.get("/dash/workers/{worker_id}/qr.png") async def worker_qr( worker_id: str, owner: OwnerContext = Depends(current_owner), session: AsyncSession = Depends(get_session), ): worker = await _load_worker(session, owner, worker_id) if worker is None: return Response(status_code=404) png = sip.qr_png(sip.provisioning_text(worker.sip_username, worker.sip_password)) return Response(png, media_type="image/png") @router.post("/dash/workers/{worker_id}/delete") async def worker_delete( worker_id: str, owner: OwnerContext = Depends(current_owner), session: AsyncSession = Depends(get_session), ): worker = await _load_worker(session, owner, worker_id) if worker: worker.active = False await session.commit() return redirect("/dash/telephony", msg="Uklonjeno.") # -- settings: agent + notifications ------------------------------------------------- @router.get("/dash/agent", response_class=HTMLResponse) async def agent_page( request: Request, owner: OwnerContext = Depends(current_owner), ): return templates.TemplateResponse( request, "dashboard/agent.html", ctx( request, owner, voices=TTS_VOICES, base_url=get_settings().base_url, greeting=compose_greeting(owner.tenant, "voice"), active="agent", ), ) @router.post("/dash/agent") async def agent_save( request: Request, owner: OwnerContext = Depends(current_owner), session: AsyncSession = Depends(get_session), ): form = await request.form() t = await session.merge(owner.tenant) voice = str(form.get("tts_voice", "")) if voice in {v["id"] for v in TTS_VOICES}: t.tts_voice = voice mode = str(form.get("price_mode", "exact")) t.price_mode = mode if mode in ("exact", "range", "on_request") else "exact" t.agent_notes = str(form.get("agent_notes", "")).strip() await session.commit() return redirect("/dash/agent", msg="Sačuvano.") @router.get("/dash/notifications", response_class=HTMLResponse) async def notifications_page( request: Request, owner: OwnerContext = Depends(current_owner), ): t = owner.tenant sms_rows = [ ("confirmation", "Potvrda termina", DEFAULT_TEMPLATES["confirmation"]), ("rejection", "Odbijanje zahtjeva", DEFAULT_TEMPLATES["rejection"]), ("request_received", "Zahtjev zaprimljen", DEFAULT_TEMPLATES["request_received"]), ("missed_call", "Propušten poziv", DEFAULT_TEMPLATES["missed_call"]), ("expiry", "Istek zahtjeva", DEFAULT_TEMPLATES["expiry"]), ] return templates.TemplateResponse( request, "dashboard/notifications.html", ctx( request, owner, emails_text="\n".join(t.notify_emails or []), overrides=t.sms_templates or {}, sms_rows=sms_rows, active="notifications", ), ) @router.post("/dash/notifications") async def notifications_save( request: Request, owner: OwnerContext = Depends(current_owner), session: AsyncSession = Depends(get_session), ): form = await request.form() t = await session.merge(owner.tenant) emails = [ e.strip() for e in str(form.get("notify_emails", "")).replace(",", "\n").splitlines() if e.strip() ] t.notify_emails = emails t.sms_request_received = bool(form.get("sms_request_received")) overrides = {} for key in DEFAULT_TEMPLATES: val = str(form.get(f"sms_{key}", "")).strip() if val and val != DEFAULT_TEMPLATES[key]: overrides[key] = val t.sms_templates = overrides await session.commit() return redirect("/dash/notifications", msg="Sačuvano.") # -- widget demo page ------------------------------------------------------------ @router.get("/widget-demo/{key}", response_class=HTMLResponse) async def widget_demo(key: str, request: Request): return templates.TemplateResponse(request, "widget_demo.html", {"request": request, "key": key})