From 38b1cf06dfcec8dd6f60de1ad84a2b1c230e156f Mon Sep 17 00:00:00 2001 From: javad Date: Sat, 26 Sep 2026 12:51:12 +0330 Subject: [PATCH] voip click2calll --- apps/accounts/admin.py | 8 +- ..._voip_call_user_voip_extension_and_more.py | 23 +++ .../migrations/0007_user_voip_mobile.py | 42 ++++++ apps/accounts/models.py | 14 +- apps/core/admin.py | 14 +- .../core/migrations/0007_voipconfiguration.py | 17 +++ apps/core/models.py | 41 ++++-- apps/integrations/voip.py | 57 ++++++++ apps/tickets/staff_urls.py | 2 +- apps/tickets/views.py | 39 ++++- static/css/operations.css | 5 + static/js/voip-call.js | 119 ++++++++++++++++ templates/tickets/_voip_call_button.html | 17 +++ templates/tickets/staff_contacts.html | 5 +- templates/tickets/staff_detail.html | 4 +- templates/tickets/staff_entity_form.html | 4 +- tests/e2e/test_staff_operations_ui.py | 24 +++- tests/test_voip_calls.py | 134 ++++++++++++++++++ 18 files changed, 543 insertions(+), 26 deletions(-) create mode 100644 apps/accounts/migrations/0006_user_voip_call_user_voip_extension_and_more.py create mode 100644 apps/accounts/migrations/0007_user_voip_mobile.py create mode 100644 apps/core/migrations/0007_voipconfiguration.py create mode 100644 apps/integrations/voip.py create mode 100644 static/js/voip-call.js create mode 100644 templates/tickets/_voip_call_button.html create mode 100644 tests/test_voip_calls.py diff --git a/apps/accounts/admin.py b/apps/accounts/admin.py index 2e29982..ca9b534 100644 --- a/apps/accounts/admin.py +++ b/apps/accounts/admin.py @@ -58,10 +58,10 @@ class StaffProfileInline(admin.StackedInline): @admin.register(User) class CustomUserAdmin(UserAdmin): - fieldsets = UserAdmin.fieldsets + (("سامانه تیکت", {"fields": ("mobile", "account_type")}),) - add_fieldsets = UserAdmin.add_fieldsets + (("سامانه تیکت", {"fields": ("mobile", "account_type")}),) - list_display = ("username", "mobile", "account_type", "is_active", "is_staff") - list_filter = UserAdmin.list_filter + ("account_type",) + fieldsets = UserAdmin.fieldsets + (("سامانه تیکت", {"fields": ("mobile", "account_type", "voip_extension", "voip_mobile", "voip_call")}),) + add_fieldsets = UserAdmin.add_fieldsets + (("سامانه تیکت", {"fields": ("mobile", "account_type", "voip_extension", "voip_mobile", "voip_call")}),) + list_display = ("username", "mobile", "voip_extension", "voip_mobile", "voip_call", "account_type", "is_active", "is_staff") + list_filter = UserAdmin.list_filter + ("account_type", "voip_call") inlines = (StaffProfileInline,) def save_model(self, request, obj, form, change): diff --git a/apps/accounts/migrations/0006_user_voip_call_user_voip_extension_and_more.py b/apps/accounts/migrations/0006_user_voip_call_user_voip_extension_and_more.py new file mode 100644 index 0000000..37fd899 --- /dev/null +++ b/apps/accounts/migrations/0006_user_voip_call_user_voip_extension_and_more.py @@ -0,0 +1,23 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [("accounts", "0005_magiclinkaccesslog_magic_opened_idx_and_more")] + + operations = [ + migrations.AddField( + model_name="user", + name="voip_call", + field=models.BooleanField(default=True, verbose_name="امکان تماس VOIP"), + ), + migrations.AddField( + model_name="user", + name="voip_extension", + field=models.CharField(blank=True, max_length=20, verbose_name="شماره داخلی کارشناس"), + ), + migrations.AlterField( + model_name="user", + name="mobile", + field=models.CharField(blank=True, max_length=16, null=True, unique=True, verbose_name="شماره موبایل"), + ), + ] diff --git a/apps/accounts/migrations/0007_user_voip_mobile.py b/apps/accounts/migrations/0007_user_voip_mobile.py new file mode 100644 index 0000000..728f347 --- /dev/null +++ b/apps/accounts/migrations/0007_user_voip_mobile.py @@ -0,0 +1,42 @@ +from django.db import migrations, models + + +def copy_staff_mobile_to_voip(apps, schema_editor): + User = apps.get_model("accounts", "User") + User.objects.filter(account_type="STAFF", voip_mobile="", mobile__isnull=False).exclude(mobile="").update( + voip_mobile=models.F("mobile") + ) + + +def clear_copied_voip_mobile(apps, schema_editor): + User = apps.get_model("accounts", "User") + User.objects.filter(account_type="STAFF", voip_mobile=models.F("mobile")).update(voip_mobile="") + + +class Migration(migrations.Migration): + dependencies = [("accounts", "0006_user_voip_call_user_voip_extension_and_more")] + + operations = [ + migrations.AddField( + model_name="user", + name="voip_mobile", + field=models.CharField( + blank=True, + help_text="مستقل از شماره حساب مشتری است و می‌تواند تکراری باشد.", + max_length=16, + verbose_name="شماره موبایل کارشناس برای VOIP", + ), + ), + migrations.AlterField( + model_name="user", + name="mobile", + field=models.CharField( + blank=True, + max_length=16, + null=True, + unique=True, + verbose_name="شماره موبایل حساب", + ), + ), + migrations.RunPython(copy_staff_mobile_to_voip, clear_copied_voip_mobile), + ] diff --git a/apps/accounts/models.py b/apps/accounts/models.py index 994305f..d2a4866 100644 --- a/apps/accounts/models.py +++ b/apps/accounts/models.py @@ -19,14 +19,26 @@ class User(PublicIdModel, AbstractUser): class AccountType(models.TextChoices): CUSTOMER = "CUSTOMER", "مشتری" STAFF = "STAFF", "کارشناس" - mobile = models.CharField(max_length=16, unique=True, null=True, blank=True) + mobile = models.CharField("شماره موبایل حساب", max_length=16, unique=True, null=True, blank=True) account_type = models.CharField(max_length=10, choices=AccountType.choices, default=AccountType.CUSTOMER) + voip_extension = models.CharField("شماره داخلی کارشناس", max_length=20, blank=True) + voip_mobile = models.CharField( + "شماره موبایل کارشناس برای VOIP", + max_length=16, + blank=True, + help_text="مستقل از شماره حساب مشتری است و می‌تواند تکراری باشد.", + ) + voip_call = models.BooleanField("امکان تماس VOIP", default=True) def save(self, *args, **kwargs): if self.mobile: self.mobile = normalize_mobile(self.mobile) + if self.voip_mobile: self.voip_mobile = normalize_mobile(self.voip_mobile) super().save(*args, **kwargs) @property def local_mobile(self): return f"0{self.mobile[3:]}" if self.mobile and self.mobile.startswith("+98") else (self.mobile or "") + @property + def voip_local_mobile(self): + return f"0{self.voip_mobile[3:]}" if self.voip_mobile.startswith("+98") else self.voip_mobile class OTPChallenge(models.Model): class Purpose(models.TextChoices): LOGIN = "LOGIN", "ورود" diff --git a/apps/core/admin.py b/apps/core/admin.py index 4be4e23..179d487 100644 --- a/apps/core/admin.py +++ b/apps/core/admin.py @@ -1,6 +1,6 @@ from django.contrib import admin -from .models import PageTitle, ProductionSeedState, SiteBranding +from .models import PageTitle, ProductionSeedState, SiteBranding, VoipConfiguration @admin.register(SiteBranding) @@ -29,6 +29,18 @@ class SiteBrandingAdmin(admin.ModelAdmin): return False +@admin.register(VoipConfiguration) +class VoipConfigurationAdmin(admin.ModelAdmin): + fields = ("voip_call_url", "updated_at") + readonly_fields = ("updated_at",) + + def has_add_permission(self, request): + return not VoipConfiguration.objects.exists() + + def has_delete_permission(self, request, obj=None): + return False + + @admin.register(PageTitle) class PageTitleAdmin(admin.ModelAdmin): list_display = ("route_name", "title", "is_active", "updated_at") diff --git a/apps/core/migrations/0007_voipconfiguration.py b/apps/core/migrations/0007_voipconfiguration.py new file mode 100644 index 0000000..bbd901b --- /dev/null +++ b/apps/core/migrations/0007_voipconfiguration.py @@ -0,0 +1,17 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [("core", "0006_productionseedstate")] + + operations = [ + migrations.CreateModel( + name="VoipConfiguration", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("voip_call_url", models.URLField(blank=True, help_text="نشانی کامل endpoint تماس، مانند http://127.0.0.1:8000/v1/click2call", verbose_name="VOIP_CALL_URL")), + ("updated_at", models.DateTimeField(auto_now=True)), + ], + options={"verbose_name": "تنظیمات تماس VOIP", "verbose_name_plural": "تنظیمات تماس VOIP"}, + ), + ] diff --git a/apps/core/models.py b/apps/core/models.py index ca88386..8fc5a78 100644 --- a/apps/core/models.py +++ b/apps/core/models.py @@ -31,7 +31,20 @@ class PublicIdModel(models.Model): abstract = True -class SiteBranding(models.Model): +class SingletonConfiguration(models.Model): + class Meta: + abstract = True + + def save(self, *args, **kwargs): + self.pk = 1 + return super().save(*args, **kwargs) + + @classmethod + def load(cls): + return cls.objects.filter(pk=1).first() or cls() + + +class SiteBranding(SingletonConfiguration): """Database-backed copy shared by customer, authentication and staff pages.""" site_title = models.CharField(max_length=160, default="گفت‌وگو با واحد فروش") header_title = models.CharField(max_length=160, default="گفت‌وگو با واحد فروش") @@ -99,18 +112,28 @@ class SiteBranding(models.Model): verbose_name = "تنظیمات عنوان و برند" verbose_name_plural = "تنظیمات عنوان و برند" - def save(self, *args, **kwargs): - self.pk = 1 - return super().save(*args, **kwargs) - - @classmethod - def load(cls): - return cls.objects.filter(pk=1).first() or cls() - def __str__(self): return self.site_title +class VoipConfiguration(SingletonConfiguration): + """Singleton configuration for the backend click-to-call service.""" + + voip_call_url = models.URLField( + "VOIP_CALL_URL", + blank=True, + help_text="نشانی کامل endpoint تماس، مانند http://127.0.0.1:8000/v1/click2call", + ) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = "تنظیمات تماس VOIP" + verbose_name_plural = "تنظیمات تماس VOIP" + + def __str__(self): + return "تنظیمات تماس VOIP" + + class PageTitle(models.Model): """Editable browser title for a named Django route.""" route_name = models.CharField(max_length=160, unique=True, help_text="مانند accounts:login") diff --git a/apps/integrations/voip.py b/apps/integrations/voip.py new file mode 100644 index 0000000..fc6d24e --- /dev/null +++ b/apps/integrations/voip.py @@ -0,0 +1,57 @@ +import logging +from dataclasses import dataclass +from uuid import uuid4 + +import requests +from django.conf import settings + +from apps.core.models import VoipConfiguration + + +logger = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class VoipCallResult: + success: bool + error: str = "" + + +def place_voip_call(*, agent, customer, method): + configuration = VoipConfiguration.load() + if not configuration.voip_call_url: + return VoipCallResult(False, "VOIP_CALL_URL تنظیم نشده است.") + + payload = { + "method": method, + "customer_number": customer.local_mobile, + "metadata": { + "agent_id": str(agent.public_id), + "customer_id": str(customer.public_id), + }, + } + if method == "ip_phone" and agent.voip_extension: + payload["agent_extension"] = agent.voip_extension + key_prefix = "internal" + elif method == "mobile" and agent.voip_local_mobile: + payload["agent_mobile"] = agent.voip_local_mobile + key_prefix = "mobile" + else: + return VoipCallResult(False, "روش تماس انتخاب‌شده در دسترس نیست.") + + try: + response = requests.post( + configuration.voip_call_url, + json=payload, + headers={"Idempotency-Key": f"{key_prefix}-{uuid4().hex}"}, + timeout=settings.INTEGRATION_HTTP_TIMEOUT_SECONDS, + ) + response.raise_for_status() + if response.content: + body = response.json() + if isinstance(body, dict) and body.get("success") is False: + return VoipCallResult(False, "سرویس تماس درخواست را نپذیرفت.") + return VoipCallResult(True) + except (requests.RequestException, ValueError): + logger.exception("VOIP click-to-call failed") + return VoipCallResult(False, "خطا در ارتباط با سرویس تماس.") diff --git a/apps/tickets/staff_urls.py b/apps/tickets/staff_urls.py index 20cf5ed..db5975f 100644 --- a/apps/tickets/staff_urls.py +++ b/apps/tickets/staff_urls.py @@ -1,4 +1,4 @@ from django.urls import path from . import views app_name="staff_tickets" -urlpatterns=[path("",views.staff_list,name="list"),path("customers/",views.staff_customers,name="customers"),path("customers/search/",views.staff_customer_search,name="customer_search"),path("contacts/",views.staff_contacts,name="contacts"),path("contacts/new/",views.staff_contact_create,name="contact_create"),path("contacts//edit/",views.staff_contact_edit,name="contact_edit"),path("contact-groups/",views.staff_groups,name="groups"),path("contact-groups//",views.staff_groups,name="group_edit"),path("magic-links/",views.staff_magic_audit,name="magic_audit"),path("magic-links//revoke/",views.staff_magic_revoke,name="magic_revoke"),path("sms-templates/",views.staff_sms_templates,name="sms_templates"),path("sms-templates//",views.staff_sms_templates,name="sms_template_edit"),path("dispatches/",views.staff_dispatch_create,name="dispatch_create"),path("dispatches//",views.staff_dispatch_detail,name="dispatch_detail"),path("dispatches//retry/",views.staff_dispatch_retry,name="dispatch_retry"),path("dispatches//resolve/",views.staff_dispatch_resolve,name="dispatch_resolve"),path("experts//avatar/",views.expert_avatar,name="expert_avatar"),path("tickets/",views.staff_list,name="tickets"),path("tickets/new/",views.staff_create,name="create"),path("tickets//messages/older/",views.staff_messages,name="messages"),path("tickets//messages/poll/",views.staff_poll,name="poll"),path("tickets//sms//retry/",views.staff_sms_retry,name="sms_retry"),path("tickets//",views.staff_detail,name="detail"),path("tickets//claim/",views.staff_claim,name="claim"),path("tickets//reply/",views.staff_reply,name="reply"),path("tickets//note/",lambda r,public_id:views.staff_reply(r,public_id,note=True),name="note"),path("tickets//transfer/",views.staff_transfer,name="transfer"),path("tickets//share/",views.staff_share,name="share"),path("tickets//revoke//",views.staff_revoke,name="revoke"),path("tickets//status/",views.staff_status,name="status"),path("tickets//priority/",views.staff_priority,name="priority"),path("tickets//delete/",views.staff_delete,name="delete")] +urlpatterns=[path("",views.staff_list,name="list"),path("customers/",views.staff_customers,name="customers"),path("customers/search/",views.staff_customer_search,name="customer_search"),path("contacts/",views.staff_contacts,name="contacts"),path("contacts/new/",views.staff_contact_create,name="contact_create"),path("contacts//edit/",views.staff_contact_edit,name="contact_edit"),path("contacts//call/",views.staff_contact_call,name="contact_call"),path("contact-groups/",views.staff_groups,name="groups"),path("contact-groups//",views.staff_groups,name="group_edit"),path("magic-links/",views.staff_magic_audit,name="magic_audit"),path("magic-links//revoke/",views.staff_magic_revoke,name="magic_revoke"),path("sms-templates/",views.staff_sms_templates,name="sms_templates"),path("sms-templates//",views.staff_sms_templates,name="sms_template_edit"),path("dispatches/",views.staff_dispatch_create,name="dispatch_create"),path("dispatches//",views.staff_dispatch_detail,name="dispatch_detail"),path("dispatches//retry/",views.staff_dispatch_retry,name="dispatch_retry"),path("dispatches//resolve/",views.staff_dispatch_resolve,name="dispatch_resolve"),path("experts//avatar/",views.expert_avatar,name="expert_avatar"),path("tickets/",views.staff_list,name="tickets"),path("tickets/new/",views.staff_create,name="create"),path("tickets//messages/older/",views.staff_messages,name="messages"),path("tickets//messages/poll/",views.staff_poll,name="poll"),path("tickets//sms//retry/",views.staff_sms_retry,name="sms_retry"),path("tickets//",views.staff_detail,name="detail"),path("tickets//claim/",views.staff_claim,name="claim"),path("tickets//reply/",views.staff_reply,name="reply"),path("tickets//note/",lambda r,public_id:views.staff_reply(r,public_id,note=True),name="note"),path("tickets//transfer/",views.staff_transfer,name="transfer"),path("tickets//share/",views.staff_share,name="share"),path("tickets//revoke//",views.staff_revoke,name="revoke"),path("tickets//status/",views.staff_status,name="status"),path("tickets//priority/",views.staff_priority,name="priority"),path("tickets//delete/",views.staff_delete,name="delete")] diff --git a/apps/tickets/views.py b/apps/tickets/views.py index f5e49a2..6281464 100644 --- a/apps/tickets/views.py +++ b/apps/tickets/views.py @@ -26,6 +26,7 @@ from apps.messaging.models import Message,MessageReadReceipt from apps.notifications.models import NotificationJob, SMSTemplate from apps.core.iran_locations import location_data from apps.core.persian_text import normalize_persian_text +from apps.integrations.voip import place_voip_call from apps.staff.models import OrganizationalUnit, StaffProfile from .forms import ContactGroupForm,GroupDispatchForm,ReplyForm,ShareForm,StaffContactForm,StaffCreateTicketForm,StaffReplyForm,TicketForm,TransferForm,UnitSMSTemplateForm from .models import GroupDispatch,Ticket,TicketCategory,TicketReadState,TicketTag @@ -450,7 +451,7 @@ def staff_detail(request,public_id): group_dispatch = ticket.group_dispatch can_resolve_dispatch = bool(group_dispatch and DispatchPolicy.can_view(request.user, group_dispatch) and (request.user.has_perm("tickets.change_ticket") or TicketPolicy.has_unit_permission(request.user, group_dispatch.unit, "tickets.change_ticket"))) group_open_count = group_dispatch.tickets.exclude(status__in=(Ticket.Status.RESOLVED, Ticket.Status.CLOSED)).count() if group_dispatch else 0 - return render(request,"tickets/staff_detail.html",{"ticket":ticket,"conversation":conversation,"has_older":has_older,"older_cursor":older_cursor,"form":StaffReplyForm(),"transfer_form":TransferForm(),"share_form":ShareForm(),"can_claim":TicketPolicy.can_claim(request.user,ticket),"can_transfer":TicketPolicy.can_transfer(request.user,ticket),"can_share":TicketPolicy.can_share(request.user,ticket),"can_delete":TicketPolicy.can_delete(request.user,ticket),"can_resolve_dispatch":can_resolve_dispatch,"group_open_count":group_open_count,"statuses":Ticket.Status.choices,"priorities":Ticket.Priority.choices,"grants":ticket.access_grants.filter(revoked_at__isnull=True).select_related("user__staff_profile"),"customer_history":history,"sms_jobs":sms_jobs,"viewer_user":request.user,"poll_version":conversation_version(ticket,latest),"poll_cursor":latest.public_id if latest else ""}) + return render(request,"tickets/staff_detail.html",{"ticket":ticket,"conversation":conversation,"has_older":has_older,"older_cursor":older_cursor,"form":StaffReplyForm(),"transfer_form":TransferForm(),"share_form":ShareForm(),"can_claim":TicketPolicy.can_claim(request.user,ticket),"can_transfer":TicketPolicy.can_transfer(request.user,ticket),"can_share":TicketPolicy.can_share(request.user,ticket),"can_delete":TicketPolicy.can_delete(request.user,ticket),"can_resolve_dispatch":can_resolve_dispatch,"group_open_count":group_open_count,"statuses":Ticket.Status.choices,"priorities":Ticket.Priority.choices,"grants":ticket.access_grants.filter(revoked_at__isnull=True).select_related("user__staff_profile"),"customer_history":history,"sms_jobs":sms_jobs,"viewer_user":request.user,"voip_call_methods":_voip_call_methods(request.user),"poll_version":conversation_version(ticket,latest),"poll_cursor":latest.public_id if latest else ""}) @staff_required @require_POST def staff_sms_retry(request,public_id,job_id): @@ -750,6 +751,38 @@ def _contact_queryset(user): ).distinct() +def _voip_call_methods(user): + if not user.voip_call: + return () + methods = [] + if user.voip_extension: + methods.append("ip_phone") + if user.voip_mobile: + methods.append("mobile") + return tuple(methods) + + +@staff_required +@require_POST +def staff_contact_call(request, public_id): + profile = get_object_or_404(_contact_queryset(request.user), user__public_id=public_id) + if not request.user.voip_call: + raise PermissionDenied + try: + data = json.loads(request.body or "{}") + except json.JSONDecodeError: + return JsonResponse({"success": False, "message": "درخواست تماس معتبر نیست."}, status=400) + method = data.get("method") + if method not in _voip_call_methods(request.user): + return JsonResponse({"success": False, "message": "روش تماس در دسترس نیست."}, status=400) + if not profile.user.mobile: + return JsonResponse({"success": False, "message": "شماره مخاطب ثبت نشده است."}, status=400) + result = place_voip_call(agent=request.user, customer=profile.user, method=method) + if not result.success: + return JsonResponse({"success": False, "message": "برقراری تماس ناموفق بود."}, status=502) + return JsonResponse({"success": True, "message": "درخواست تماس ارسال شد."}) + + def _has_any_unit_permission(user, permission): return user.is_superuser or any( TicketPolicy.has_unit_permission(user, membership.unit, permission) @@ -782,7 +815,7 @@ def staff_contacts(request): "carriers": base_profiles.filter(stakeholder_type=CustomerProfile.StakeholderType.CARRIER).count(), "needs_action": sum(item.waiting_staff_count for item in contacts), } - context = {"contacts": contacts, "metrics": metrics, "groups": visible_groups.annotate(contact_count=Count("contacts", distinct=True)), "stakeholder_types": CustomerProfile.StakeholderType.choices, "provinces": [row["province"] for row in location_data()], "can_add_contact": _has_any_unit_permission(request.user, "customers.add_customerprofile"), "active_staff_section": "contacts", "active_tab_title": "دفترچه مخاطبان"} + context = {"contacts": contacts, "metrics": metrics, "groups": visible_groups.annotate(contact_count=Count("contacts", distinct=True)), "stakeholder_types": CustomerProfile.StakeholderType.choices, "provinces": [row["province"] for row in location_data()], "can_add_contact": _has_any_unit_permission(request.user, "customers.add_customerprofile"), "voip_call_methods": _voip_call_methods(request.user), "active_staff_section": "contacts", "active_tab_title": "دفترچه مخاطبان"} return render(request, "tickets/staff_contacts.html", context) @@ -830,7 +863,7 @@ def staff_contact_edit(request, public_id): profile.groups.add(*form.cleaned_data["groups"]) messages.success(request, "اطلاعات مخاطب بروزرسانی شد.") return redirect("staff_tickets:contacts") - return render(request, "tickets/staff_entity_form.html", {"form": form, "active_staff_section": "contacts", "active_tab_title": "ویرایش مخاطب", "form_title": str(profile), "form_help": "عضویت همزمان مخاطب در چند گروه قابل مدیریت است."}) + return render(request, "tickets/staff_entity_form.html", {"form": form, "call_customer": profile.user, "voip_call_methods": _voip_call_methods(request.user), "active_staff_section": "contacts", "active_tab_title": "ویرایش مخاطب", "form_title": str(profile), "form_help": "عضویت همزمان مخاطب در چند گروه قابل مدیریت است."}) @staff_required diff --git a/static/css/operations.css b/static/css/operations.css index f99c220..fde4b23 100644 --- a/static/css/operations.css +++ b/static/css/operations.css @@ -39,6 +39,11 @@ .contact-card-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:.8rem;padding:1rem;background:#f7fafc}.contact-card{display:grid;gap:.75rem;min-width:0;border:1px solid #dce7ef;border-radius:1rem;background:#fff;padding:1rem;transition:.18s ease;box-shadow:0 10px 28px -26px #08376b}.contact-card:hover{transform:translateY(-2px);border-color:#9dcbe2;box-shadow:0 18px 36px -29px #08376b}.contact-card header{display:flex;align-items:center;gap:.7rem}.contact-avatar{display:grid;width:2.65rem;height:2.65rem;flex:none;place-items:center;border-radius:.8rem;background:linear-gradient(145deg,#dff4ff,#dff7ef);color:#084d73;font-size:1rem;font-weight:900}.contact-identity{min-width:0;flex:1}.contact-identity h2{overflow:hidden;margin:0;color:#153f60;font-size:.88rem;text-overflow:ellipsis;white-space:nowrap}.contact-identity p{overflow:hidden;margin:.15rem 0 0;color:#718698;font-size:.63rem;text-overflow:ellipsis;white-space:nowrap}.contact-type{flex:none;border-radius:999px;background:#e7f4fb;padding:.2rem .48rem;color:#146b98;font-size:.57rem;font-weight:900}.contact-type.type-supplier{background:#fff3d8;color:#966000}.contact-type.type-carrier{background:#f0eaff;color:#6841b8}.contact-meta{display:flex;flex-wrap:wrap;gap:.4rem .9rem;color:#5f7689;font-size:.64rem}.contact-groups{display:flex;min-height:1.6rem;flex-wrap:wrap;gap:.3rem}.contact-groups span{border-radius:999px;background:#edf5f8;padding:.18rem .48rem;color:#49677c;font-size:.58rem;font-weight:800}.contact-groups small{color:#93a2ae;font-size:.6rem}.contact-ticket-stats{display:grid;grid-template-columns:repeat(4,1fr);gap:.35rem}.contact-ticket-stats span{display:grid;gap:.1rem;border-radius:.6rem;background:#f3f6f8;padding:.45rem;text-align:center;color:#718394;font-size:.55rem}.contact-ticket-stats b{color:#173f60;font-size:.82rem}.contact-ticket-stats .needs-action{background:#fff5df;color:#916000}.contact-ticket-stats .needs-action b{color:#c37700}.contact-card footer{display:flex;align-items:center;justify-content:space-between;border-top:1px solid #edf2f5;padding-top:.7rem}.contact-card footer a{color:#557187;font-size:.64rem;font-weight:900}.contact-card footer .primary-action{border-radius:.6rem;background:#e8f6ff;padding:.4rem .65rem;color:#0875b5}.contact-empty{grid-column:1/-1}.contact-empty>span{display:grid;width:3rem;height:3rem;margin:0 auto .5rem;place-items:center;border-radius:1rem;background:#eaf5fb;color:#1679aa;font-size:1.3rem} .operations-split,.template-layout,.dispatch-layout{display:grid;grid-template-columns:minmax(0,1fr) 20rem;align-items:start;gap:1rem}.panel-heading{display:flex;align-items:center;justify-content:space-between;padding:1rem 1.1rem;border-bottom:1px solid #e5edf3;background:#f8fbfd}.panel-heading small{color:#74889a;font-size:.6rem}.panel-heading h2{margin:.15rem 0 0;color:#173f60;font-size:.95rem}.panel-heading>span{border-radius:999px;background:#e7f4fb;padding:.3rem .6rem;color:#126c9d;font-size:.6rem;font-weight:900}.group-card-list,.template-card-list{display:grid;gap:.55rem;padding:.8rem}.group-card,.template-card{display:grid;grid-template-columns:auto minmax(0,1fr) auto auto;align-items:center;gap:.75rem;border:1px solid #e0e9ef;border-radius:.8rem;padding:.75rem;transition:.15s}.group-card:hover,.template-card:hover{border-color:#acd2e5;background:#fafdff}.group-symbol,.template-icon{display:grid;width:2.35rem;height:2.35rem;place-items:center;border-radius:.7rem;background:#e8f5fb;color:#0870a6;font-weight:900}.group-card h3,.template-card h3{margin:0;color:#173f60;font-size:.78rem}.group-card p,.template-card p{margin:.12rem 0;color:#667d90;font-size:.62rem}.group-card small,.template-card small{color:#8a9aa7;font-size:.56rem}.group-card>a,.template-card>a{color:#0875b5;font-size:.62rem;font-weight:900}.operations-form-side{overflow:hidden;border:1px solid #d8e4ec;border-radius:1rem;background:#fff;box-shadow:0 18px 40px -36px #08376b;position:sticky;top:5.6rem}.operations-form-side>header{display:flex;align-items:center;gap:.65rem;padding:1rem;background:linear-gradient(135deg,#0a4d78,#087b91);color:#fff}.operations-form-side>header>span{display:grid;width:2rem;height:2rem;place-items:center;border-radius:.6rem;background:rgba(255,255,255,.16);font-weight:900}.operations-form-side header small{font-size:.56rem;color:rgba(255,255,255,.65)}.operations-form-side header h2{margin:.12rem 0 0;font-size:.85rem}.operations-form{display:grid;gap:.75rem;padding:1rem}.operations-form label{display:grid;gap:.35rem}.operations-form label>span{color:#405e75;font-size:.65rem;font-weight:900}.operations-form input,.operations-form select,.operations-form textarea{width:100%;min-height:2.65rem;border:1px solid #c8d8e4;border-radius:.7rem;background:#fbfdff;padding:.6rem .7rem;color:#143e60}.operations-form textarea{min-height:6rem;resize:vertical}.operations-form button{min-height:2.75rem;border:0;border-radius:.7rem;background:linear-gradient(135deg,#0879b7,#075b8d);color:#fff;font-size:.7rem;font-weight:900}.operations-form>a{text-align:center;color:#6e8293;font-size:.62rem;font-weight:800}.operations-form .errorlist,.editor-section .errorlist,.dispatch-composer .errorlist{margin:0;padding:0;color:#b42318;font-size:.6rem;list-style:none} .contact-editor,.dispatch-composer{display:grid;gap:.85rem}.contact-editor{max-width:58rem;width:100%;margin-inline:auto}.editor-section,.dispatch-composer>section{overflow:hidden;border:1px solid #dbe6ee;border-radius:1rem;background:#fff;box-shadow:0 15px 38px -35px #08376b}.editor-section>header,.dispatch-composer>section>header{display:flex;align-items:center;gap:.7rem;border-bottom:1px solid #e8eef3;padding:.85rem 1rem;background:#f9fcfe}.editor-section>header>span,.dispatch-composer>section>header>span{display:grid;width:2rem;height:2rem;flex:none;place-items:center;border-radius:.65rem;background:linear-gradient(135deg,#0a699f,#098d9c);color:#fff;font-size:.7rem;font-weight:900}.editor-section h2,.dispatch-composer h2{margin:0;color:#173f60;font-size:.82rem}.editor-section header p,.dispatch-composer header p{margin:.15rem 0 0;color:#73889a;font-size:.6rem}.editor-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:.8rem;padding:1rem}.editor-grid label,.group-selector,.message-editor{display:grid;align-content:start;gap:.35rem;color:#3f5f77;font-size:.65rem;font-weight:900}.editor-grid input,.editor-grid select,.group-selector select,.message-editor textarea{width:100%;min-height:2.75rem;border:1px solid #c8d8e4;border-radius:.72rem;background:#fbfdff;padding:.62rem .72rem;color:#123f61}.editor-grid input:focus,.editor-grid select:focus,.group-selector select:focus,.message-editor textarea:focus{border-color:#1689bd;outline:3px solid rgba(22,137,189,.11);background:#fff}.editor-grid small,.group-selector small{color:#7a8e9e;font-size:.56rem;font-weight:500}.editor-grid em,.group-selector em{color:#d05c3b;font-style:normal}.editor-wide{grid-column:1/-1}.group-selector,.message-editor{margin:1rem}.group-selector select{min-height:9rem}.location-status{margin:-.35rem 1rem .85rem;border-radius:.6rem;background:#edf7fc;padding:.55rem .7rem;color:#39708e;font-size:.58rem}.location-status.is-error{background:#fff0ed;color:#b04432}.editor-actions{display:flex;align-items:center;gap:.7rem;border:1px solid #d9e5ed;border-radius:1rem;background:#fff;padding:.8rem}.editor-actions button{min-height:2.75rem;border:0;border-radius:.7rem;background:linear-gradient(135deg,#0879b7,#075b8d);padding:.65rem 1.4rem;color:#fff;font-weight:900}.editor-actions a{color:#657d90;font-size:.65rem;font-weight:900}.editor-actions span{margin-right:auto;color:#8798a5;font-size:.58rem} +.voip-call{position:relative;display:inline-flex;align-items:center;gap:.4rem}.voip-call-trigger{min-height:2.35rem;border:1px solid #0b8b74!important;border-radius:.65rem!important;background:#e8f8f3!important;padding:.45rem .8rem!important;color:#08735f!important;font-size:.68rem;font-weight:900}.voip-call-menu{position:absolute;z-index:80;top:calc(100% + .35rem);right:0;min-width:10.5rem;border:1px solid #d4e5e1;border-radius:.75rem;background:#fff;padding:.35rem;box-shadow:0 16px 38px -20px #08376b}.voip-call-menu button{display:block;width:100%;border:0!important;border-radius:.5rem!important;background:#fff!important;padding:.6rem .7rem!important;text-align:right;color:#164e63!important;font-size:.65rem;font-weight:800}.voip-call-menu button:hover{background:#edf9f6!important}.voip-call-status{max-width:11rem;color:#647b88;font-size:.57rem}.voip-call-status:empty{display:none}.voip-call-status.is-error{color:#b42318}.voip-call-status.is-success{color:#08735f}.contact-card footer .voip-call-status{position:absolute;right:1rem;bottom:.15rem}.ticket-head-actions .voip-call-status{position:absolute;top:100%;right:0;border-radius:.4rem;background:#fff;padding:.2rem .4rem;white-space:nowrap}.ticket-head-actions .voip-call{position:relative} +.voip-call{isolation:isolate}.voip-call-trigger{display:flex!important;align-items:center;gap:.55rem;min-height:2.65rem!important;border:1px solid rgba(7,132,112,.28)!important;border-radius:.8rem!important;background:linear-gradient(135deg,#edfbf7,#dcf5ed)!important;padding:.38rem .55rem .38rem .72rem!important;color:#076a59!important;box-shadow:0 8px 22px -17px rgba(7,106,89,.8)!important;transition:transform .16s ease,border-color .16s ease,box-shadow .16s ease!important}.voip-call-trigger:hover{transform:translateY(-1px);border-color:rgba(7,132,112,.52)!important;box-shadow:0 12px 24px -16px rgba(7,106,89,.9)!important}.voip-call-trigger:focus-visible{outline:3px solid rgba(18,170,142,.2);outline-offset:2px}.voip-call-trigger:disabled{cursor:wait;opacity:.72}.voip-call-trigger-icon{display:grid;width:1.85rem;height:1.85rem;flex:none;place-items:center;border-radius:.58rem;background:linear-gradient(145deg,#0d987f,#08715f);color:#fff;box-shadow:0 6px 14px -8px #075c4e}.voip-call-trigger-icon svg{width:1rem;height:1rem;fill:currentColor}.voip-call-trigger-copy{display:grid;text-align:right;line-height:1.2}.voip-call-trigger-copy b{font-size:.68rem}.voip-call-trigger-copy small{margin-top:.16rem;color:#438478;font-size:.5rem;font-weight:700}.voip-call-chevron{margin-right:.1rem;color:#4b897e;font-size:.8rem;transition:transform .16s}.voip-call-trigger.is-open .voip-call-chevron{transform:rotate(180deg)}.voip-call-trigger.is-success .voip-call-trigger-icon{background:linear-gradient(145deg,#16a36f,#087d55)}.voip-call-trigger.is-loading .voip-call-trigger-icon svg{animation:voip-pulse .9s ease-in-out infinite alternate}@keyframes voip-pulse{to{opacity:.42;transform:scale(.82)}} +body>.voip-call-menu{position:fixed;z-index:2147483000;top:0;right:auto;left:0;width:min(20rem,calc(100vw - 1.5rem));min-width:18rem;overflow:hidden;border:1px solid rgba(22,77,99,.13);border-radius:1rem;background:rgba(255,255,255,.98);padding:0;direction:rtl;box-shadow:0 24px 65px -24px rgba(8,47,85,.48),0 7px 18px -12px rgba(8,47,85,.25);backdrop-filter:blur(14px);transform-origin:top right;animation:voip-menu-in .16s ease-out}body>.voip-call-menu[hidden]{display:none!important}@keyframes voip-menu-in{from{opacity:0;transform:translateY(-5px) scale(.98)}to{opacity:1;transform:translateY(0) scale(1)}}body>.voip-call-menu>header{display:flex;align-items:center;gap:.7rem;border-bottom:1px solid #e7eff2;background:linear-gradient(135deg,#f1fbf8,#f7fbfd);padding:.85rem .9rem}body>.voip-call-menu>header>span{display:grid;width:2.2rem;height:2.2rem;flex:none;place-items:center;border-radius:.7rem;background:linear-gradient(145deg,#0c927b,#096c78);color:#fff;font-size:.85rem;box-shadow:0 8px 18px -11px #075f51}body>.voip-call-menu header b,body>.voip-call-menu header small{display:block}body>.voip-call-menu header b{color:#173f56;font-size:.72rem}body>.voip-call-menu header small{margin-top:.15rem;color:#718795;font-size:.55rem}.voip-call-options{display:grid;gap:.35rem;padding:.48rem}.voip-call-menu .voip-call-options button{display:grid!important;grid-template-columns:auto minmax(0,1fr) auto;align-items:center;gap:.65rem;width:100%;min-height:3.8rem;border:1px solid transparent!important;border-radius:.75rem!important;background:#fff!important;padding:.55rem .6rem!important;text-align:right;color:#173f56!important;box-shadow:none!important;transition:background .14s,border-color .14s,transform .14s!important}.voip-call-menu .voip-call-options button:hover,.voip-call-menu .voip-call-options button:focus-visible{transform:translateX(-2px);border-color:#cbe8df!important;background:#f1fbf8!important;outline:none}.voip-call-menu .voip-call-options button>span:nth-child(2){min-width:0}.voip-call-menu .voip-call-options b,.voip-call-menu .voip-call-options small{display:block}.voip-call-menu .voip-call-options b{font-size:.68rem}.voip-call-menu .voip-call-options small{margin-top:.18rem;overflow:hidden;color:#718795;font-size:.53rem;font-weight:600;text-overflow:ellipsis;white-space:nowrap}.voip-method-icon{display:grid;width:2.35rem;height:2.35rem;place-items:center;border-radius:.68rem;background:#e4f7f1;color:#08715f;font-size:1rem;font-weight:900}.voip-method-icon.is-mobile{background:#e9f3fb;color:#176b99}.voip-method-arrow{color:#7da19a;font-size:.85rem} +body>.voip-call-status{position:fixed!important;z-index:2147483001;right:auto!important;bottom:1.25rem!important;left:50%!important;top:auto!important;display:flex;align-items:center;gap:.55rem;width:max-content;max-width:min(26rem,calc(100vw - 1.5rem));min-height:2.8rem;border:1px solid rgba(21,64,82,.12);border-radius:.85rem;background:#fff;padding:.55rem .85rem!important;color:#23485b!important;font-size:.64rem;font-weight:850;white-space:normal;box-shadow:0 18px 46px -20px rgba(8,47,85,.65);opacity:0;pointer-events:none;transform:translate(-50%,1rem);transition:opacity .18s,transform .18s}body>.voip-call-status:before{display:grid;width:1.55rem;height:1.55rem;flex:none;place-items:center;border-radius:.48rem;background:#e7f4f8;color:#176c88;content:'…'}body>.voip-call-status.is-visible{opacity:1;transform:translate(-50%,0)}body>.voip-call-status.is-loading:before{content:'↻';animation:voip-spin .9s linear infinite}body>.voip-call-status.is-success{border-color:#bce5d7!important;background:#f6fdfa!important;color:#08715f!important}body>.voip-call-status.is-success:before{background:#dff6ed;color:#08715f;content:'✓'}body>.voip-call-status.is-error{border-color:#f0c5be!important;background:#fff9f8!important;color:#a33a2b!important}body>.voip-call-status.is-error:before{background:#ffebe8;color:#b34231;content:'!'}@keyframes voip-spin{to{transform:rotate(360deg)}}.contact-card footer .voip-call-trigger{min-height:2.25rem!important;padding:.28rem .42rem!important}.contact-card footer .voip-call-trigger-icon{width:1.65rem;height:1.65rem}.contact-card footer .voip-call-trigger-copy small,.contact-card footer .voip-call-chevron{display:none} +@media(max-width:560px){body>.voip-call-menu{top:auto!important;right:.65rem!important;bottom:.65rem!important;left:.65rem!important;width:auto;min-width:0;border-radius:1.05rem;transform-origin:bottom center;animation:voip-sheet-in .2s ease-out}@keyframes voip-sheet-in{from{opacity:0;transform:translateY(1rem)}to{opacity:1;transform:translateY(0)}}body>.voip-call-status{bottom:5.25rem!important}.operations-hero-actions .voip-call-trigger{width:100%;justify-content:center}.ticket-head-actions .voip-call-trigger-copy small{display:none}} .group-selector{border:0}.group-selector legend{color:#3f5f77;font-size:.7rem;font-weight:900}.group-selector-tools{display:grid;grid-template-columns:minmax(12rem,1fr) auto auto;align-items:center;gap:.55rem;margin:.4rem 0 .65rem}.group-selector-tools label{display:block}.group-selector-tools input{width:100%;min-height:2.65rem;border:1px solid #c8d8e4;border-radius:.72rem;background:#fbfdff;padding:.58rem .75rem}.group-selection-count{border-radius:999px;background:#e7f4fb;padding:.42rem .7rem;color:#126c9d;white-space:nowrap}.group-selector-tools button{min-height:2.55rem;border:1px solid #d8e4ec;border-radius:.65rem;background:#fff;padding:.45rem .7rem;color:#64798c;font-size:.6rem;font-weight:900}.group-selector-tools button:disabled{cursor:not-allowed;opacity:.45}.group-choice-list>div{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:.55rem}.group-choice-list>div>div{border:1px solid #dbe5ec;border-radius:.75rem;background:#fbfdff;transition:.15s}.group-choice-list label{display:flex;align-items:center;gap:.65rem;min-height:3.15rem;padding:.65rem .75rem;cursor:pointer;color:#35566e;font-size:.65rem}.group-choice-list input{width:1.1rem;height:1.1rem;flex:none;accent-color:#0879a7}.group-choice-list>div>div.is-selected{border-color:#58aeca;background:#eaf8fb;box-shadow:0 0 0 2px rgba(22,137,189,.08)}.group-choice-list>div>div[hidden]{display:none}.group-empty{margin:.5rem 0;border-radius:.65rem;background:#f5f8fa;padding:.75rem;color:#758999;font-size:.62rem}.staff-attachment-picker{display:grid;gap:.8rem}.attachment-picker-copy{display:flex;align-items:center;gap:.7rem}.staff-attachment-picker .media-picker-actions{margin:0}.staff-attachment-picker .file-preview:empty{display:none} .dispatch-composer>footer{display:flex;align-items:center;justify-content:space-between;gap:1rem;border:1px solid #d7e4ec;border-radius:1rem;background:#fff;padding:.9rem 1rem}.dispatch-composer footer b,.dispatch-composer footer small{display:block}.dispatch-composer footer b{color:#173f60;font-size:.68rem}.dispatch-composer footer small{margin-top:.18rem;color:#73889a;font-size:.56rem}.dispatch-composer footer button{min-height:2.8rem;border:0;border-radius:.72rem;background:linear-gradient(135deg,#0879b7,#087c86);padding:.65rem 1rem;color:#fff;font-size:.68rem;font-weight:900}.message-editor textarea{min-height:11rem;resize:vertical;line-height:1.9}.dispatch-history{overflow:hidden;border:1px solid #d8e4ec;border-radius:1rem;background:#fff;position:sticky;top:5.6rem}.dispatch-history>header{display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid #e5edf3;padding:1rem}.dispatch-history header small{color:#7b8d9d;font-size:.56rem}.dispatch-history header h2{margin:.12rem 0 0;color:#173f60;font-size:.88rem}.dispatch-history header>span{display:grid;width:2rem;height:2rem;place-items:center;border-radius:.65rem;background:#e8f4fb;color:#0873a9;font-size:.65rem;font-weight:900}.dispatch-history>div{display:grid;max-height:39rem;overflow:auto}.dispatch-history-card{display:grid;grid-template-columns:auto minmax(0,1fr) auto;align-items:center;gap:.55rem;border-bottom:1px solid #edf2f5;padding:.75rem;color:inherit}.dispatch-history-card:hover{background:#f7fbfd}.dispatch-state{width:.5rem;height:.5rem;border-radius:50%;background:#8da0ae}.dispatch-state.state-sent{background:#12a36d}.dispatch-state.state-queued{background:#e19b18}.dispatch-state.state-partial{background:#dc5c42}.dispatch-history-card b{display:block;overflow:hidden;color:#24485f;font-size:.68rem;text-overflow:ellipsis;white-space:nowrap}.dispatch-history-card small{display:block;margin-top:.15rem;color:#718698;font-size:.56rem}.dispatch-history-card p{display:flex;gap:.6rem;margin:.35rem 0 0;color:#8798a5;font-size:.52rem}.dispatch-history-card em{color:#8aa0b0;font-style:normal} .template-card-list{max-height:44rem;overflow:auto}.variable-box{border:1px dashed #b9cedb;border-radius:.7rem;background:#f7fbfd;padding:.7rem}.variable-box>b{display:block;margin-bottom:.45rem;color:#3e6077;font-size:.62rem}.variable-box>div{display:flex;flex-wrap:wrap;gap:.3rem}.variable-box code{border-radius:.35rem;background:#e7f3f9;padding:.18rem .35rem;color:#075e8d;font-size:.53rem} diff --git a/static/js/voip-call.js b/static/js/voip-call.js new file mode 100644 index 0000000..5b339a4 --- /dev/null +++ b/static/js/voip-call.js @@ -0,0 +1,119 @@ +(()=>{ + const roots=[...document.querySelectorAll('[data-voip-call]')]; + if(!roots.length)return; + + const csrfToken=()=>{ + const item=document.cookie.split(';').map(value=>value.trim()).find(value=>value.startsWith('csrftoken=')); + return item?decodeURIComponent(item.slice('csrftoken='.length)):''; + }; + const menuFor=root=>root._voipMenu; + const statusFor=root=>root._voipStatus; + + const positionMenu=root=>{ + const menu=menuFor(root); + const trigger=root.querySelector('[data-voip-call-trigger]'); + if(!menu||menu.hidden)return; + if(window.matchMedia('(max-width:560px)').matches){ + menu.style.removeProperty('top'); + menu.style.removeProperty('left'); + return; + } + const rect=trigger.getBoundingClientRect(); + const gap=10; + const edge=12; + const width=menu.offsetWidth; + const height=menu.offsetHeight; + const top=rect.bottom+gap+height<=window.innerHeight-edge?rect.bottom+gap:Math.max(edge,rect.top-height-gap); + const left=Math.min(Math.max(edge,rect.right-width),window.innerWidth-width-edge); + menu.style.top=`${Math.round(top)}px`; + menu.style.left=`${Math.round(left)}px`; + }; + + const setOpen=(root,open,{restoreFocus=false}={})=>{ + const menu=menuFor(root); + const trigger=root.querySelector('[data-voip-call-trigger]'); + if(!menu||!trigger)return; + menu.hidden=!open; + menu.classList.toggle('is-open',open); + trigger.classList.toggle('is-open',open); + trigger.setAttribute('aria-expanded',String(open)); + if(open){ + positionMenu(root); + requestAnimationFrame(()=>menu.querySelector('[data-voip-method]')?.focus()); + }else{ + menu.style.removeProperty('top'); + menu.style.removeProperty('left'); + if(restoreFocus)trigger.focus(); + } + }; + + const closeOthers=current=>roots.forEach(root=>{if(root!==current)setOpen(root,false);}); + + const notify=(root,message,state)=>{ + const status=statusFor(root); + clearTimeout(status._hideTimer); + status.textContent=message; + status.className=`voip-call-status is-visible is-${state}`; + if(state!=='loading')status._hideTimer=setTimeout(()=>status.classList.remove('is-visible'),4200); + }; + + const call=async(root,method)=>{ + const trigger=root.querySelector('[data-voip-call-trigger]'); + const buttons=[trigger,...menuFor(root).querySelectorAll('button')]; + setOpen(root,false); + buttons.forEach(button=>button.disabled=true); + trigger.classList.add('is-loading'); + notify(root,'در حال ارسال درخواست تماس…','loading'); + try{ + const response=await fetch(root.dataset.callUrl,{method:'POST',credentials:'same-origin',headers:{'Content-Type':'application/json','X-CSRFToken':csrfToken(),'Accept':'application/json'},body:JSON.stringify({method})}); + const data=await response.json().catch(()=>({})); + if(!response.ok||!data.success)throw new Error(); + notify(root,data.message||'درخواست تماس با موفقیت ارسال شد.','success'); + trigger.classList.add('is-success'); + setTimeout(()=>trigger.classList.remove('is-success'),1800); + }catch(error){ + notify(root,'برقراری تماس ناموفق بود.','error'); + }finally{ + trigger.classList.remove('is-loading'); + buttons.forEach(button=>button.disabled=false); + } + }; + + roots.forEach((root,index)=>{ + const menu=root.querySelector('[data-voip-call-menu]'); + const status=root.querySelector('[data-voip-call-status]'); + const trigger=root.querySelector('[data-voip-call-trigger]'); + menu.id=`voip-call-menu-${index+1}`; + trigger.setAttribute('aria-controls',menu.id); + root._voipMenu=menu; + root._voipStatus=status; + menu._voipRoot=root; + document.body.append(menu,status); + }); + + document.addEventListener('click',event=>{ + const methodButton=event.target.closest('[data-voip-method]'); + if(methodButton){ + const root=methodButton.closest('[data-voip-call-menu]')._voipRoot; + call(root,methodButton.dataset.voipMethod); + return; + } + const trigger=event.target.closest('[data-voip-call-trigger]'); + if(trigger){ + const root=trigger.closest('[data-voip-call]'); + const methods=menuFor(root).querySelectorAll('[data-voip-method]'); + if(methods.length===1){call(root,methods[0].dataset.voipMethod);return;} + const opening=menuFor(root).hidden; + closeOthers(root); + setOpen(root,opening); + return; + } + if(!event.target.closest('[data-voip-call-menu]'))roots.forEach(root=>setOpen(root,false)); + }); + + document.addEventListener('keydown',event=>{ + if(event.key==='Escape')roots.forEach(root=>{if(!menuFor(root).hidden)setOpen(root,false,{restoreFocus:true});}); + }); + window.addEventListener('resize',()=>roots.forEach(positionMenu)); + window.addEventListener('scroll',()=>roots.forEach(positionMenu),true); +})(); diff --git a/templates/tickets/_voip_call_button.html b/templates/tickets/_voip_call_button.html new file mode 100644 index 0000000..e4a2af3 --- /dev/null +++ b/templates/tickets/_voip_call_button.html @@ -0,0 +1,17 @@ +{% if voip_call_methods and customer.mobile %} +
+ + + +
+{% endif %} diff --git a/templates/tickets/staff_contacts.html b/templates/tickets/staff_contacts.html index c773a82..1bc5810 100644 --- a/templates/tickets/staff_contacts.html +++ b/templates/tickets/staff_contacts.html @@ -1,7 +1,8 @@ -{% extends 'base.html' %} +{% extends 'base.html' %}{% load static %} {% block title %}{{ page_title }}{% endblock %} {% block content %}

مرکز یکپارچه ارتباطات

مخاطبان و مشتریان

پرونده ارتباطی مشتریان، تأمین‌کنندگان و باربری‌ها همراه با گروه‌ها و وضعیت تیکت‌ها در یک نما.

مدیریت گروه‌ها{% if can_add_contact %}+ مخاطب جدید{% endif %}
◎
{{ metrics.total }}نتیجه قابل مشاهده
♙
{{ metrics.customers }}مشتری
▣
{{ metrics.suppliers }}تأمین‌کننده
⇄
{{ metrics.carriers }}باربری
!
{{ metrics.needs_action }}تیکت نیازمند اقدام
{% if request.GET %}پاک‌کردن{% endif %}
-
{{ contacts|length }} مخاطبمرتب‌شده بر اساس تیکت‌های فعال و آخرین ثبت
{{ groups|length }} گروه قابل دسترس ←
{% for contact in contacts %}{% with profile=contact.customer_profile %}
{{ profile.first_name|default:contact.local_mobile|slice:':1' }}

{{ profile|default:contact.local_mobile }}

{{ profile.organization_name|default:profile.get_stakeholder_type_display }}

{{ profile.get_stakeholder_type_display }}
☎ {{ contact.local_mobile }}⌖ {% if profile.city or profile.province %}{{ profile.city }}{% if profile.city and profile.province %}، {% endif %}{{ profile.province }}{% else %}موقعیت ثبت نشده{% endif %}
{% for group in profile.visible_groups %}{{ group.name }}{% empty %}بدون گروه قابل مشاهده{% endfor %}
{{ contact.ticket_count }}کل تیکت{{ contact.active_count }}فعال{{ contact.waiting_staff_count }}نیازمند اقدام{{ contact.resolved_count }}مختومه
{% endwith %}{% empty %}
⌕

مخاطبی پیدا نشد

فیلترها را تغییر دهید یا مخاطب جدیدی ثبت کنید.

{% if can_add_contact %}ثبت مخاطب جدید{% endif %}
{% endfor %}
{% endblock %} +
{{ contacts|length }} مخاطبمرتب‌شده بر اساس تیکت‌های فعال و آخرین ثبت
{{ groups|length }} گروه قابل دسترس ←
{% for contact in contacts %}{% with profile=contact.customer_profile %}
{{ profile.first_name|default:contact.local_mobile|slice:':1' }}

{{ profile|default:contact.local_mobile }}

{{ profile.organization_name|default:profile.get_stakeholder_type_display }}

{{ profile.get_stakeholder_type_display }}
☎ {{ contact.local_mobile }}⌖ {% if profile.city or profile.province %}{{ profile.city }}{% if profile.city and profile.province %}، {% endif %}{{ profile.province }}{% else %}موقعیت ثبت نشده{% endif %}
{% for group in profile.visible_groups %}{{ group.name }}{% empty %}بدون گروه قابل مشاهده{% endfor %}
{{ contact.ticket_count }}کل تیکت{{ contact.active_count }}فعال{{ contact.waiting_staff_count }}نیازمند اقدام{{ contact.resolved_count }}مختومه
{% endwith %}{% empty %}
⌕

مخاطبی پیدا نشد

فیلترها را تغییر دهید یا مخاطب جدیدی ثبت کنید.

{% if can_add_contact %}ثبت مخاطب جدید{% endif %}
{% endfor %}
{% endblock %} +{% block extra_scripts %}{% endblock %} diff --git a/templates/tickets/staff_detail.html b/templates/tickets/staff_detail.html index 78a2036..05cfd7e 100644 --- a/templates/tickets/staff_detail.html +++ b/templates/tickets/staff_detail.html @@ -4,7 +4,7 @@
→
{{ ticket.ticket_number }}

{{ ticket.subject }}

-
{{ ticket.get_status_display }}{% if can_claim %}
{% csrf_token %}
{% endif %}
+
{{ ticket.get_status_display }}{% include 'tickets/_voip_call_button.html' with customer=ticket.customer %}{% if can_claim %}
{% csrf_token %}
{% endif %}
{% if ticket.group_dispatch %}
@@ -42,5 +42,5 @@
{% include 'tickets/_image_viewer.html' %} - + {% endblock %} diff --git a/templates/tickets/staff_entity_form.html b/templates/tickets/staff_entity_form.html index 14fd6a6..b126d5f 100644 --- a/templates/tickets/staff_entity_form.html +++ b/templates/tickets/staff_entity_form.html @@ -1,7 +1,7 @@ {% extends 'base.html' %}{% load static %} {% block title %}{{ page_title }}{% endblock %} {% block content %}
-

پرونده ارتباطی

{{ form_title }}

{{ form_help }}

+

پرونده ارتباطی

{{ form_title }}

{{ form_help }}

{% if call_customer %}{% include 'tickets/_voip_call_button.html' with customer=call_customer %}{% endif %}بازگشت به مخاطبان
{% csrf_token %} {% if form.non_field_errors %}{% endif %}
۱

هویت و راه ارتباطی

اطلاعات پایه مخاطب برای شناسایی و ارسال پیامک

@@ -9,4 +9,4 @@
۳

گروه‌بندی و دسترسی

با یک کلیک عضویت مخاطب را در گروه‌های قابل مدیریت اضافه یا حذف کنید

{{ form.groups.label }} *
۰ گروه انتخاب شده
{{ form.groups }}
{{ form.groups.errors }}{{ form.groups.help_text }}
انصرافاطلاعات ورود یا رمز عبور برای مخاطب ساخته نمی‌شود.
{% endblock %} -{% block extra_scripts %}{% endblock %} +{% block extra_scripts %}{% endblock %} diff --git a/tests/e2e/test_staff_operations_ui.py b/tests/e2e/test_staff_operations_ui.py index 204ed26..462c886 100644 --- a/tests/e2e/test_staff_operations_ui.py +++ b/tests/e2e/test_staff_operations_ui.py @@ -45,7 +45,13 @@ def test_staff_operations_pages_are_authorized_and_visually_styled(live_server, part = Q(content_type__app_label=app_label, codename__in=codenames) query = part if query is None else query | part unit.permissions.add(*Permission.objects.filter(query)) - staff = User.objects.create_user(username="e2e-operations", password=PASSWORD, account_type=User.AccountType.STAFF) + staff = User.objects.create_user( + username="e2e-operations", + password=PASSWORD, + account_type=User.AccountType.STAFF, + voip_extension="300", + voip_mobile="09384999655", + ) StaffProfile.objects.create(user=staff, display_name="کارشناس عملیات", primary_unit=unit) customer = User.objects.create_user(username="e2e-carrier", mobile="09120009121", account_type=User.AccountType.CUSTOMER) profile = CustomerProfile.objects.create(user=customer, first_name="باربری", stakeholder_type=CustomerProfile.StakeholderType.CARRIER) @@ -76,6 +82,22 @@ def test_staff_operations_pages_are_authorized_and_visually_styled(live_server, assert page.evaluate("getComputedStyle(document.querySelector('.operations-page')).display") == "grid" assert page.evaluate("document.documentElement.scrollWidth <= document.documentElement.clientWidth + 1") + page.goto(f"{live_server.url}{reverse('staff_tickets:contacts')}", wait_until="domcontentloaded") + call_trigger = page.locator("[data-voip-call-trigger]").first + call_trigger.click() + call_menu = page.locator("body > .voip-call-menu:not([hidden])") + expect(call_menu).to_be_visible() + expect(call_menu.get_by_role("menuitem")).to_have_count(2) + assert call_menu.evaluate("menu => menu.parentElement === document.body") + assert int(call_menu.evaluate("menu => getComputedStyle(menu).zIndex")) > 100000 + menu_box = call_menu.bounding_box() + assert menu_box is not None + assert menu_box["x"] >= 0 and menu_box["y"] >= 0 + assert menu_box["x"] + menu_box["width"] <= page.viewport_size["width"] + assert menu_box["y"] + menu_box["height"] <= page.viewport_size["height"] + page.keyboard.press("Escape") + expect(call_menu).to_be_hidden() + page.goto(f"{live_server.url}{reverse('staff_tickets:dispatch_create')}", wait_until="domcontentloaded") expect(page).to_have_title("ردیابی E2E") expect(page.locator(".staff-brand-copy b")).to_have_text("مرکز عملیات E2E") diff --git a/tests/test_voip_calls.py b/tests/test_voip_calls.py new file mode 100644 index 0000000..5e15500 --- /dev/null +++ b/tests/test_voip_calls.py @@ -0,0 +1,134 @@ +import json +from unittest.mock import Mock, patch + +import pytest +import requests +from django.urls import reverse + +from apps.accounts.models import User +from apps.core.models import VoipConfiguration +from apps.customers.models import ContactGroup, CustomerProfile +from apps.staff.models import OrganizationalUnit, StaffProfile +from apps.tickets.models import TicketCategory +from apps.tickets.services import create_ticket + + +@pytest.fixture +def voip_data(db): + unit = OrganizationalUnit.objects.create(name="فروش تماس", slug="voip-sales") + group = ContactGroup.objects.create(name="مخاطبان تماس", unit=unit) + existing_customer = User.objects.create_user( + username="existing-voip-mobile-customer", + mobile="09384999655", + account_type=User.AccountType.CUSTOMER, + ) + agent = User.objects.create_superuser( + username="voip-agent", + password="safe-password", + mobile=None, + account_type=User.AccountType.STAFF, + voip_extension="300", + voip_mobile="09384999655", + ) + StaffProfile.objects.create(user=agent, primary_unit=unit, display_name="کارشناس تماس") + customer = User.objects.create_user( + username="voip-customer", + mobile="09139591708", + account_type=User.AccountType.CUSTOMER, + ) + profile = CustomerProfile.objects.create(user=customer, first_name="مخاطب", last_name="آزمایشی") + profile.groups.add(group) + category = TicketCategory.objects.create(unit=unit, title="تماس فروش", slug="voip-call") + ticket = create_ticket(customer=customer, category=category, subject="پیگیری تماس", body="شرح تماس") + VoipConfiguration.objects.create(voip_call_url="http://127.0.0.1:9000/v1/click2call") + assert agent.voip_mobile == existing_customer.mobile + return agent, customer, ticket + + +@pytest.mark.django_db +@pytest.mark.parametrize( + "case", + ( + {"method": "ip_phone", "agent_field": "agent_extension", "agent_value": "300", "key_prefix": "internal-"}, + {"method": "mobile", "agent_field": "agent_mobile", "agent_value": "09384999655", "key_prefix": "mobile-"}, + ), +) +def test_backend_sends_voip_call(client, voip_data, case): + agent, customer, _ = voip_data + method = case["method"] + client.force_login(agent) + response_mock = Mock(content=b"{}") + response_mock.json.return_value = {} + with patch("apps.integrations.voip.requests.post", return_value=response_mock) as post: + response = client.post( + reverse("staff_tickets:contact_call", args=[customer.public_id]), + data=json.dumps({"method": method}), + content_type="application/json", + ) + + assert response.status_code == 200 + call = post.call_args + assert call.args == ("http://127.0.0.1:9000/v1/click2call",) + assert call.kwargs["json"] == { + "method": method, + case["agent_field"]: case["agent_value"], + "customer_number": "09139591708", + "metadata": {"agent_id": str(agent.public_id), "customer_id": str(customer.public_id)}, + } + assert call.kwargs["headers"]["Idempotency-Key"].startswith(case["key_prefix"]) + + +@pytest.mark.django_db +def test_call_pages_hide_provider_url(client, voip_data): + agent, customer, ticket = voip_data + client.force_login(agent) + for route in ( + reverse("staff_tickets:contacts"), + reverse("staff_tickets:contact_edit", args=[customer.public_id]), + reverse("staff_tickets:detail", args=[ticket.public_id]), + ): + content = client.get(route).content.decode() + assert 'data-voip-method="ip_phone"' in content + assert 'data-voip-method="mobile"' in content + assert reverse("staff_tickets:contact_call", args=[customer.public_id]) in content + assert "http://127.0.0.1:9000/v1/click2call" not in content + assert "voip-call.js" in content + + +@pytest.mark.django_db +def test_call_method_availability(client, voip_data): + agent, _, ticket = voip_data + agent.voip_mobile = "" + agent.save(update_fields=["voip_mobile"]) + client.force_login(agent) + content = client.get(reverse("staff_tickets:detail", args=[ticket.public_id])).content.decode() + assert 'data-voip-method="ip_phone"' in content + assert 'data-voip-method="mobile"' not in content + + agent.voip_call = False + agent.save(update_fields=["voip_call"]) + content = client.get(reverse("staff_tickets:detail", args=[ticket.public_id])).content.decode() + assert "data-voip-call" not in content + + +@pytest.mark.django_db +def test_voip_error_is_minimal(client, voip_data): + agent, customer, _ = voip_data + client.force_login(agent) + with patch("apps.integrations.voip.requests.post", side_effect=requests.ConnectionError("provider detail")): + response = client.post( + reverse("staff_tickets:contact_call", args=[customer.public_id]), + data=json.dumps({"method": "mobile"}), + content_type="application/json", + ) + assert response.status_code == 502 + assert response.json() == {"success": False, "message": "برقراری تماس ناموفق بود."} + + agent.voip_call = False + agent.save(update_fields=["voip_call"]) + response = client.post( + reverse("staff_tickets:contact_call", args=[customer.public_id]), + data=json.dumps({"method": "mobile"}), + content_type="application/json", + ) + assert response.status_code == 403