voip click2calll

This commit is contained in:
javad committed 2026-09-26 12:51:12 +03:30
1 parent cb92abbb41
commit 38b1cf06df
18 files changed
+543 -26

No files matched your search

+4 -4
View File
@@ -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):
@@ -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="شماره موبایل"),
),
]
@@ -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),
]
+13 -1
View File
@@ -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", "ورود"
+13 -1
View File
@@ -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")
@@ -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"},
),
]
+32 -9
View File
@@ -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")
+57
View File
@@ -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, "خطا در ارتباط با سرویس تماس.")
+1 -1
View File
@@ -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/<uuid:public_id>/edit/",views.staff_contact_edit,name="contact_edit"),path("contact-groups/",views.staff_groups,name="groups"),path("contact-groups/<int:group_id>/",views.staff_groups,name="group_edit"),path("magic-links/",views.staff_magic_audit,name="magic_audit"),path("magic-links/<int:link_id>/revoke/",views.staff_magic_revoke,name="magic_revoke"),path("sms-templates/",views.staff_sms_templates,name="sms_templates"),path("sms-templates/<int:template_id>/",views.staff_sms_templates,name="sms_template_edit"),path("dispatches/",views.staff_dispatch_create,name="dispatch_create"),path("dispatches/<uuid:public_id>/",views.staff_dispatch_detail,name="dispatch_detail"),path("dispatches/<uuid:public_id>/retry/",views.staff_dispatch_retry,name="dispatch_retry"),path("dispatches/<uuid:public_id>/resolve/",views.staff_dispatch_resolve,name="dispatch_resolve"),path("experts/<uuid:public_id>/avatar/",views.expert_avatar,name="expert_avatar"),path("tickets/",views.staff_list,name="tickets"),path("tickets/new/",views.staff_create,name="create"),path("tickets/<uuid:public_id>/messages/older/",views.staff_messages,name="messages"),path("tickets/<uuid:public_id>/messages/poll/",views.staff_poll,name="poll"),path("tickets/<uuid:public_id>/sms/<int:job_id>/retry/",views.staff_sms_retry,name="sms_retry"),path("tickets/<uuid:public_id>/",views.staff_detail,name="detail"),path("tickets/<uuid:public_id>/claim/",views.staff_claim,name="claim"),path("tickets/<uuid:public_id>/reply/",views.staff_reply,name="reply"),path("tickets/<uuid:public_id>/note/",lambda r,public_id:views.staff_reply(r,public_id,note=True),name="note"),path("tickets/<uuid:public_id>/transfer/",views.staff_transfer,name="transfer"),path("tickets/<uuid:public_id>/share/",views.staff_share,name="share"),path("tickets/<uuid:public_id>/revoke/<uuid:user_id>/",views.staff_revoke,name="revoke"),path("tickets/<uuid:public_id>/status/",views.staff_status,name="status"),path("tickets/<uuid:public_id>/priority/",views.staff_priority,name="priority"),path("tickets/<uuid:public_id>/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/<uuid:public_id>/edit/",views.staff_contact_edit,name="contact_edit"),path("contacts/<uuid:public_id>/call/",views.staff_contact_call,name="contact_call"),path("contact-groups/",views.staff_groups,name="groups"),path("contact-groups/<int:group_id>/",views.staff_groups,name="group_edit"),path("magic-links/",views.staff_magic_audit,name="magic_audit"),path("magic-links/<int:link_id>/revoke/",views.staff_magic_revoke,name="magic_revoke"),path("sms-templates/",views.staff_sms_templates,name="sms_templates"),path("sms-templates/<int:template_id>/",views.staff_sms_templates,name="sms_template_edit"),path("dispatches/",views.staff_dispatch_create,name="dispatch_create"),path("dispatches/<uuid:public_id>/",views.staff_dispatch_detail,name="dispatch_detail"),path("dispatches/<uuid:public_id>/retry/",views.staff_dispatch_retry,name="dispatch_retry"),path("dispatches/<uuid:public_id>/resolve/",views.staff_dispatch_resolve,name="dispatch_resolve"),path("experts/<uuid:public_id>/avatar/",views.expert_avatar,name="expert_avatar"),path("tickets/",views.staff_list,name="tickets"),path("tickets/new/",views.staff_create,name="create"),path("tickets/<uuid:public_id>/messages/older/",views.staff_messages,name="messages"),path("tickets/<uuid:public_id>/messages/poll/",views.staff_poll,name="poll"),path("tickets/<uuid:public_id>/sms/<int:job_id>/retry/",views.staff_sms_retry,name="sms_retry"),path("tickets/<uuid:public_id>/",views.staff_detail,name="detail"),path("tickets/<uuid:public_id>/claim/",views.staff_claim,name="claim"),path("tickets/<uuid:public_id>/reply/",views.staff_reply,name="reply"),path("tickets/<uuid:public_id>/note/",lambda r,public_id:views.staff_reply(r,public_id,note=True),name="note"),path("tickets/<uuid:public_id>/transfer/",views.staff_transfer,name="transfer"),path("tickets/<uuid:public_id>/share/",views.staff_share,name="share"),path("tickets/<uuid:public_id>/revoke/<uuid:user_id>/",views.staff_revoke,name="revoke"),path("tickets/<uuid:public_id>/status/",views.staff_status,name="status"),path("tickets/<uuid:public_id>/priority/",views.staff_priority,name="priority"),path("tickets/<uuid:public_id>/delete/",views.staff_delete,name="delete")]
+36 -3
View File
@@ -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
+5
View File
@@ -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}
+119
View File
@@ -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);
})();
+17
View File
@@ -0,0 +1,17 @@
{% if voip_call_methods and customer.mobile %}
<div class="voip-call" data-voip-call data-call-url="{% url 'staff_tickets:contact_call' customer.public_id %}">
<button type="button" class="voip-call-trigger" data-voip-call-trigger aria-expanded="false" aria-haspopup="menu">
<span class="voip-call-trigger-icon" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M6.6 10.8c1.7 3.3 3.3 4.9 6.6 6.6l2.2-2.2c.3-.3.7-.4 1.1-.3 1.2.4 2.5.6 3.8.6.6 0 1 .4 1 1V20c0 .6-.4 1-1 1C10.8 21 3 13.2 3 3.7c0-.6.4-1 1-1h3.5c.6 0 1 .4 1 1 0 1.3.2 2.6.6 3.8.1.4 0 .8-.3 1.1l-2.2 2.2Z"/></svg></span>
<span class="voip-call-trigger-copy"><b>تماس</b><small>ارتباط با مخاطب</small></span>
<span class="voip-call-chevron" aria-hidden="true">⌄</span>
</button>
<div class="voip-call-menu" data-voip-call-menu role="menu" aria-label="انتخاب روش تماس" hidden>
<header><span aria-hidden="true">☎</span><div><b>شروع تماس</b><small>روش ارتباط کارشناس را انتخاب کنید</small></div></header>
<div class="voip-call-options">
{% if 'ip_phone' in voip_call_methods %}<button type="button" role="menuitem" data-voip-method="ip_phone"><span class="voip-method-icon" aria-hidden="true">⌁</span><span><b>تلفن داخلی</b><small>تماس از داخلی ثبت‌شده کارشناس</small></span><span class="voip-method-arrow" aria-hidden="true">←</span></button>{% endif %}
{% if 'mobile' in voip_call_methods %}<button type="button" role="menuitem" data-voip-method="mobile"><span class="voip-method-icon is-mobile" aria-hidden="true">▯</span><span><b>موبایل کارشناس</b><small>اتصال تماس به شماره VOIP کارشناس</small></span><span class="voip-method-arrow" aria-hidden="true">←</span></button>{% endif %}
</div>
</div>
<small class="voip-call-status" data-voip-call-status role="status" aria-live="polite"></small>
</div>
{% endif %}
+3 -2
View File
@@ -1,7 +1,8 @@
{% extends 'base.html' %}
{% extends 'base.html' %}{% load static %}
{% block title %}{{ page_title }}{% endblock %}
{% block content %}<div class="operations-page operations-contacts">
<section class="operations-hero"><div><p class="eyebrow">مرکز یکپارچه ارتباطات</p><h1>مخاطبان و مشتریان</h1><p>پرونده ارتباطی مشتریان، تأمین‌کنندگان و باربری‌ها همراه با گروه‌ها و وضعیت تیکت‌ها در یک نما.</p></div><div class="operations-hero-actions"><a class="button button-secondary" href="{% url 'staff_tickets:groups' %}">مدیریت گروه‌ها</a>{% if can_add_contact %}<a class="button" href="{% url 'staff_tickets:contact_create' %}">+ مخاطب جدید</a>{% endif %}</div></section>
<section class="operations-kpis" aria-label="آمار مخاطبان"><article><span class="kpi-icon">◎</span><div><b>{{ metrics.total }}</b><small>نتیجه قابل مشاهده</small></div></article><article><span class="kpi-icon is-blue">♙</span><div><b>{{ metrics.customers }}</b><small>مشتری</small></div></article><article><span class="kpi-icon is-amber">▣</span><div><b>{{ metrics.suppliers }}</b><small>تأمین‌کننده</small></div></article><article><span class="kpi-icon is-violet">⇄</span><div><b>{{ metrics.carriers }}</b><small>باربری</small></div></article><article><span class="kpi-icon is-coral">!</span><div><b>{{ metrics.needs_action }}</b><small>تیکت نیازمند اقدام</small></div></article></section>
<section class="operations-panel"><form method="get" class="contact-filter-bar"><label class="contact-search"><span>⌕</span><input type="search" name="q" value="{{ request.GET.q }}" placeholder="جست‌وجو با نام، سازمان یا شماره همراه"></label><label><span>نوع مخاطب</span><select name="stakeholder_type"><option value="">همه انواع</option>{% for value,label in stakeholder_types %}<option value="{{ value }}" {% if request.GET.stakeholder_type == value %}selected{% endif %}>{{ label }}</option>{% endfor %}</select></label><label><span>گروه</span><select name="group"><option value="">همه گروه‌ها</option>{% for group in groups %}<option value="{{ group.pk }}" {% if request.GET.group == group.pk|stringformat:'s' %}selected{% endif %}>{{ group.name }} ({{ group.contact_count }})</option>{% endfor %}</select></label><label><span>استان</span><select name="province"><option value="">همه استان‌ها</option>{% for province in provinces %}<option value="{{ province }}" {% if request.GET.province == province %}selected{% endif %}>{{ province }}</option>{% endfor %}</select></label><button type="submit">اعمال فیلتر</button>{% if request.GET %}<a class="filter-reset" href="{% url 'staff_tickets:contacts' %}">پاک‌کردن</a>{% endif %}</form>
<div class="contact-result-meta"><div><b>{{ contacts|length }} مخاطب</b><small>مرتب‌شده بر اساس تیکت‌های فعال و آخرین ثبت</small></div><a href="{% url 'staff_tickets:groups' %}">{{ groups|length }} گروه قابل دسترس ←</a></div><div class="contact-card-grid">{% for contact in contacts %}{% with profile=contact.customer_profile %}<article class="contact-card"><header><span class="contact-avatar">{{ profile.first_name|default:contact.local_mobile|slice:':1' }}</span><div class="contact-identity"><h2>{{ profile|default:contact.local_mobile }}</h2><p>{{ profile.organization_name|default:profile.get_stakeholder_type_display }}</p></div><span class="contact-type type-{{ profile.stakeholder_type|lower }}">{{ profile.get_stakeholder_type_display }}</span></header><div class="contact-meta"><span dir="ltr">☎ {{ contact.local_mobile }}</span><span>⌖ {% if profile.city or profile.province %}{{ profile.city }}{% if profile.city and profile.province %}، {% endif %}{{ profile.province }}{% else %}موقعیت ثبت نشده{% endif %}</span></div><div class="contact-groups">{% for group in profile.visible_groups %}<span>{{ group.name }}</span>{% empty %}<small>بدون گروه قابل مشاهده</small>{% endfor %}</div><div class="contact-ticket-stats"><span><b>{{ contact.ticket_count }}</b>کل تیکت</span><span><b>{{ contact.active_count }}</b>فعال</span><span class="needs-action"><b>{{ contact.waiting_staff_count }}</b>نیازمند اقدام</span><span><b>{{ contact.resolved_count }}</b>مختومه</span></div><footer><a href="{% url 'staff_tickets:contact_edit' contact.public_id %}">ویرایش پرونده</a>{% if staff_operations_access.create_ticket %}<a class="primary-action" href="{% url 'staff_tickets:create' %}?customer={{ contact.public_id }}">+ ثبت تیکت</a>{% endif %}</footer></article>{% endwith %}{% empty %}<div class="empty-state contact-empty"><span>⌕</span><h2>مخاطبی پیدا نشد</h2><p>فیلترها را تغییر دهید یا مخاطب جدیدی ثبت کنید.</p>{% if can_add_contact %}<a class="button" href="{% url 'staff_tickets:contact_create' %}">ثبت مخاطب جدید</a>{% endif %}</div>{% endfor %}</div></section></div>{% endblock %}
<div class="contact-result-meta"><div><b>{{ contacts|length }} مخاطب</b><small>مرتب‌شده بر اساس تیکت‌های فعال و آخرین ثبت</small></div><a href="{% url 'staff_tickets:groups' %}">{{ groups|length }} گروه قابل دسترس ←</a></div><div class="contact-card-grid">{% for contact in contacts %}{% with profile=contact.customer_profile %}<article class="contact-card"><header><span class="contact-avatar">{{ profile.first_name|default:contact.local_mobile|slice:':1' }}</span><div class="contact-identity"><h2>{{ profile|default:contact.local_mobile }}</h2><p>{{ profile.organization_name|default:profile.get_stakeholder_type_display }}</p></div><span class="contact-type type-{{ profile.stakeholder_type|lower }}">{{ profile.get_stakeholder_type_display }}</span></header><div class="contact-meta"><span dir="ltr">☎ {{ contact.local_mobile }}</span><span>⌖ {% if profile.city or profile.province %}{{ profile.city }}{% if profile.city and profile.province %}، {% endif %}{{ profile.province }}{% else %}موقعیت ثبت نشده{% endif %}</span></div><div class="contact-groups">{% for group in profile.visible_groups %}<span>{{ group.name }}</span>{% empty %}<small>بدون گروه قابل مشاهده</small>{% endfor %}</div><div class="contact-ticket-stats"><span><b>{{ contact.ticket_count }}</b>کل تیکت</span><span><b>{{ contact.active_count }}</b>فعال</span><span class="needs-action"><b>{{ contact.waiting_staff_count }}</b>نیازمند اقدام</span><span><b>{{ contact.resolved_count }}</b>مختومه</span></div><footer><a href="{% url 'staff_tickets:contact_edit' contact.public_id %}">ویرایش پرونده</a>{% include 'tickets/_voip_call_button.html' with customer=contact %}{% if staff_operations_access.create_ticket %}<a class="primary-action" href="{% url 'staff_tickets:create' %}?customer={{ contact.public_id }}">+ ثبت تیکت</a>{% endif %}</footer></article>{% endwith %}{% empty %}<div class="empty-state contact-empty"><span>⌕</span><h2>مخاطبی پیدا نشد</h2><p>فیلترها را تغییر دهید یا مخاطب جدیدی ثبت کنید.</p>{% if can_add_contact %}<a class="button" href="{% url 'staff_tickets:contact_create' %}">ثبت مخاطب جدید</a>{% endif %}</div>{% endfor %}</div></section></div>{% endblock %}
{% block extra_scripts %}<script defer src="{% static 'js/voip-call.js' %}?v={{ static_asset_version }}"></script>{% endblock %}
+2 -2
View File
@@ -4,7 +4,7 @@
<div class="ticket-workspace staff-ticket-workspace" data-conversation-workspace data-poll-url="{% url 'staff_tickets:poll' ticket.public_id %}" data-poll-version="{{ poll_version }}" data-poll-cursor="{{ poll_cursor }}" data-draft-key="ticket-{{ ticket.public_id }}-staff" data-reply-result="{{ request.GET.reply_result|default:'' }}">
<header class="ticket-workspace-head">
<div class="ticket-title"><a class="workspace-back" href="{% url 'staff_tickets:list' %}" aria-label="بازگشت">→</a><div><span class="ticket-number" dir="ltr">{{ ticket.ticket_number }}</span><h1>{{ ticket.subject }}</h1></div></div>
<div class="ticket-head-actions"><span class="badge" data-ticket-status>{{ ticket.get_status_display }}</span>{% if can_claim %}<form method="post" action="{% url 'staff_tickets:claim' ticket.public_id %}">{% csrf_token %}<button type="submit">اختصاص به من</button></form>{% endif %}<button type="button" class="icon-button" data-toggle-panel="ticket-actions" aria-label="عملیات تیکت">⚙</button><button type="button" class="icon-button" data-toggle-panel="customer-info" aria-label="اطلاعات مشتری">◉</button></div>
<div class="ticket-head-actions"><span class="badge" data-ticket-status>{{ ticket.get_status_display }}</span>{% include 'tickets/_voip_call_button.html' with customer=ticket.customer %}{% if can_claim %}<form method="post" action="{% url 'staff_tickets:claim' ticket.public_id %}">{% csrf_token %}<button type="submit">اختصاص به من</button></form>{% endif %}<button type="button" class="icon-button" data-toggle-panel="ticket-actions" aria-label="عملیات تیکت">⚙</button><button type="button" class="icon-button" data-toggle-panel="customer-info" aria-label="اطلاعات مشتری">◉</button></div>
</header>
{% if ticket.group_dispatch %}
<section class="ticket-dispatch-context" aria-label="اطلاعات ارسال گروهی">
@@ -42,5 +42,5 @@
</div>
</div>
{% include 'tickets/_image_viewer.html' %}
<script src="{% static 'vendor/compressor.min.js' %}?v={{ static_asset_version }}"></script><script src="{% static 'js/attachment-uploader.js' %}?v={{ static_asset_version }}"></script><script src="{% static 'js/image-viewer.js' %}?v={{ static_asset_version }}"></script><script src="{% static 'js/conversation.js' %}?v={{ static_asset_version }}"></script>
<script src="{% static 'vendor/compressor.min.js' %}?v={{ static_asset_version }}"></script><script src="{% static 'js/attachment-uploader.js' %}?v={{ static_asset_version }}"></script><script src="{% static 'js/image-viewer.js' %}?v={{ static_asset_version }}"></script><script src="{% static 'js/conversation.js' %}?v={{ static_asset_version }}"></script><script src="{% static 'js/voip-call.js' %}?v={{ static_asset_version }}"></script>
{% endblock %}
+2 -2
View File
@@ -1,7 +1,7 @@
{% extends 'base.html' %}{% load static %}
{% block title %}{{ page_title }}{% endblock %}
{% block content %}<div class="operations-page operations-contact-form" data-location-picker data-locations-url="{% static 'data/iran-locations.json' %}">
<section class="operations-hero"><div><p class="eyebrow">پرونده ارتباطی</p><h1>{{ form_title }}</h1><p>{{ form_help }}</p></div><div class="operations-hero-actions"><a class="button button-secondary" href="{% url 'staff_tickets:contacts' %}">بازگشت به مخاطبان</a></div></section>
<section class="operations-hero"><div><p class="eyebrow">پرونده ارتباطی</p><h1>{{ form_title }}</h1><p>{{ form_help }}</p></div><div class="operations-hero-actions">{% if call_customer %}{% include 'tickets/_voip_call_button.html' with customer=call_customer %}{% endif %}<a class="button button-secondary" href="{% url 'staff_tickets:contacts' %}">بازگشت به مخاطبان</a></div></section>
<form method="post" class="contact-editor" data-disable-on-submit>{% csrf_token %}
{% if form.non_field_errors %}<div class="create-form-errors" role="alert">{{ form.non_field_errors }}</div>{% endif %}
<section class="editor-section"><header><span>۱</span><div><h2>هویت و راه ارتباطی</h2><p>اطلاعات پایه مخاطب برای شناسایی و ارسال پیامک</p></div></header><div class="editor-grid"><label><span>{{ form.mobile.label }} <em>*</em></span>{{ form.mobile }}{{ form.mobile.errors }}<small>شماره معتبر به شکل 09xxxxxxxxx</small></label><label><span>{{ form.stakeholder_type.label }} <em>*</em></span>{{ form.stakeholder_type }}{{ form.stakeholder_type.errors }}</label><label><span>{{ form.first_name.label }} <em>*</em></span>{{ form.first_name }}{{ form.first_name.errors }}</label><label><span>{{ form.last_name.label }}</span>{{ form.last_name }}{{ form.last_name.errors }}</label><label class="editor-wide"><span>{{ form.organization_name.label }}</span>{{ form.organization_name }}{{ form.organization_name.errors }}</label></div></section>
@@ -9,4 +9,4 @@
<section class="editor-section"><header><span>۳</span><div><h2>گروه‌بندی و دسترسی</h2><p>با یک کلیک عضویت مخاطب را در گروه‌های قابل مدیریت اضافه یا حذف کنید</p></div></header><fieldset class="group-selector" data-contact-group-selector><legend>{{ form.groups.label }} <em>*</em></legend><div class="group-selector-tools"><label><span class="sr-only">جست‌وجوی گروه</span><input type="search" placeholder="جست‌وجوی نام گروه یا واحد…" autocomplete="off" data-group-search></label><span class="group-selection-count" data-group-selection-count aria-live="polite">۰ گروه انتخاب شده</span><button type="button" data-clear-groups>پاک‌کردن انتخاب‌ها</button></div><div class="group-choice-list">{{ form.groups }}</div>{{ form.groups.errors }}<small>{{ form.groups.help_text }}</small><p class="group-empty" data-group-empty hidden>گروهی با این عبارت پیدا نشد.</p></fieldset></section>
<footer class="editor-actions"><button type="submit">ذخیره مخاطب</button><a href="{% url 'staff_tickets:contacts' %}">انصراف</a><span>اطلاعات ورود یا رمز عبور برای مخاطب ساخته نمی‌شود.</span></footer>
</form></div>{% endblock %}
{% block extra_scripts %}<script defer src="{% static 'js/location-picker.js' %}?v={{ static_asset_version }}"></script><script defer src="{% static 'js/contact-group-selector.js' %}?v={{ static_asset_version }}"></script>{% endblock %}
{% block extra_scripts %}<script defer src="{% static 'js/location-picker.js' %}?v={{ static_asset_version }}"></script><script defer src="{% static 'js/contact-group-selector.js' %}?v={{ static_asset_version }}"></script><script defer src="{% static 'js/voip-call.js' %}?v={{ static_asset_version }}"></script>{% endblock %}
+23 -1
View File
@@ -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")
+134
View File
@@ -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