fix sms and ticket user frindly
This commit is contained in:
1 parent
1ee83374fd
commit
b8b3aba855
30 files changed
+2220
-119
No files matched your search
+5
-1
@@ -29,7 +29,7 @@ SMS_PROVIDER_CLASS=apps.integrations.sms.sms0098.ZeroZeroNinetyEightSMSProvider
|
||||
SMS0098_USERNAME=
|
||||
SMS0098_PASSWORD=
|
||||
SMS0098_FROM=
|
||||
SMS0098_API_URL=http://www.0098sms.com/sendsmslink.aspx
|
||||
SMS0098_WSDL_URL=https://webservice.0098sms.com/service.asmx?WSDL
|
||||
BEHIN_SMS_USERNAME=
|
||||
BEHIN_SMS_PASSWORD=
|
||||
BEHIN_SMS_SPECIAL_NUMBER=
|
||||
@@ -41,6 +41,10 @@ BALE_API_BASE=https://tapi.bale.ai
|
||||
BALE_BOT_TOKEN=
|
||||
INTEGRATION_HTTP_TIMEOUT_SECONDS=10
|
||||
MAX_NOTIFICATION_ATTEMPTS=5
|
||||
SMS_MAX_RETRIES=3
|
||||
SMS_RETRY_DELAY_SECONDS=180
|
||||
SMS_DELIVERY_CHECK_DELAY_SECONDS=180
|
||||
SMS_DELIVERY_MAX_CHECKS=15
|
||||
SECURE_SSL_REDIRECT=true
|
||||
SECURE_HSTS_SECONDS=31536000
|
||||
SECURE_HSTS_INCLUDE_SUBDOMAINS=true
|
||||
|
||||
@@ -7,6 +7,14 @@ class SMSResult:
|
||||
success: bool
|
||||
provider_id: str = ""
|
||||
error: str = ""
|
||||
provider_code: str = ""
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SMSDeliveryResult:
|
||||
status: str
|
||||
provider_code: str
|
||||
description: str
|
||||
is_final: bool = True
|
||||
def webotp_message(code):
|
||||
host=settings.WEBOTP_HOST or urlparse(settings.PUBLIC_BASE_URL).hostname
|
||||
if not host: raise ValueError("WEBOTP_HOST یا PUBLIC_BASE_URL معتبر تنظیم نشده است.")
|
||||
@@ -16,3 +24,7 @@ class SMSProvider(ABC):
|
||||
def send_otp(self,mobile,code): return self.send_message(mobile,webotp_message(code))
|
||||
@abstractmethod
|
||||
def send_message(self,mobile,message): raise NotImplementedError
|
||||
|
||||
def get_delivery_status(self, provider_id):
|
||||
"""Return None when a provider does not support delivery tracking."""
|
||||
return None
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Backward-compatible public facade for the SMS integration."""
|
||||
from .base import SMSProvider,SMSResult,webotp_message
|
||||
from .base import SMSDeliveryResult,SMSProvider,SMSResult,webotp_message
|
||||
from .behin import BehinSMSProvider
|
||||
from .console import ConsoleSMSProvider
|
||||
from .factory import get_sms_provider,reset_sms_provider
|
||||
from .http import HTTPSMSProvider
|
||||
__all__=["SMSProvider","SMSResult","webotp_message","BehinSMSProvider","ConsoleSMSProvider","HTTPSMSProvider","get_sms_provider","reset_sms_provider"]
|
||||
__all__=["SMSProvider","SMSResult","SMSDeliveryResult","webotp_message","BehinSMSProvider","ConsoleSMSProvider","HTTPSMSProvider","get_sms_provider","reset_sms_provider"]
|
||||
@@ -1,36 +1,172 @@
|
||||
import logging
|
||||
import re
|
||||
|
||||
import requests
|
||||
import zeep
|
||||
from django.conf import settings
|
||||
from zeep.transports import Transport
|
||||
|
||||
from apps.accounts.models import normalize_mobile
|
||||
from .base import SMSProvider, SMSResult
|
||||
from .base import SMSDeliveryResult, SMSProvider, SMSResult
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Source: the vendor's "کدهای خطا وب سرویس" table. Keeping the mapping here
|
||||
# makes worker logs and the staff UI report the same exact reason.
|
||||
SEND_RESULT_MESSAGES = {
|
||||
"2": "ارسال با موفقیت انجام شد.",
|
||||
"-3": "نام کاربری و کلمه عبور با هم تطابق ندارند؛ لطفاً با پشتیبانی 0098SMS تماس بگیرید.",
|
||||
"10": "نام کاربری یا کلمه عبور اشتباه است؛ لطفاً با پشتیبانی 0098SMS تماس بگیرید.",
|
||||
"11": "در متن پیامک کاراکتر غیرمجاز وجود دارد.",
|
||||
"-17": "متن پیامک خالی است.",
|
||||
"-18": "خطای شارژ رخ داده است؛ لطفاً با پشتیبانی 0098SMS تماس بگیرید.",
|
||||
"-19": "شارژ پنل برای ارسال کافی نیست؛ لطفاً پنل را شارژ کنید.",
|
||||
"-22": "شماره موبایل گیرنده صحیح نیست.",
|
||||
"66": "نام کاربری و کلمه عبور با هم تطابق ندارند؛ لطفاً با پشتیبانی 0098SMS تماس بگیرید.",
|
||||
"1111": "در متن پیامک کاراکتر غیرمجاز وجود دارد.",
|
||||
"hang": "حساب کاربری مسدود است؛ لطفاً با پشتیبانی 0098SMS تماس بگیرید.",
|
||||
"no doc": "مرحله دوم ثبتنام انجام نشده است؛ لطفاً ثبتنام را کامل کنید.",
|
||||
"-23": "شماره پنل مجوز ارسال از لینک را ندارد.",
|
||||
"-24": "در شماره پنل خطا وجود دارد؛ لطفاً با پشتیبانی 0098SMS تماس بگیرید.",
|
||||
"-25": "اعتبار شماره پنل به پایان رسیده است و باید تمدید شود.",
|
||||
"-26": "تنظیمات کد OTP انجام نشده است.",
|
||||
"-27": "فرمت کد OTP صحیح نیست.",
|
||||
"-28": "تنظیمات کد OTP توسط ادمین تأیید نشده است.",
|
||||
"wrong timestamp": "مقدار timestamp ارسالشده صحیح نیست.",
|
||||
"wrong time": "زمان ارسال خارج از بازه مجاز است.",
|
||||
"-29": "فرمت کد OTP صحیح نیست.",
|
||||
"-31": "شناسه پیامک (smsid) یافت نشد.",
|
||||
"-32": "شناسه پیامک (smsid) مطابقت ندارد.",
|
||||
"13": "ارسال از IP غیرمجاز انجام شده است.",
|
||||
}
|
||||
|
||||
DELIVERY_RESULTS = {
|
||||
"4": ("BLOCKED", "مخاطب دریافت پیامکهای تبلیغاتی را مسدود کرده است.", True),
|
||||
"5": ("DELIVERED", "پیامک به گوشی گیرنده رسیده است.", True),
|
||||
# 0098SMS can initially return 6 or 8 and publish the definitive result
|
||||
# much later. Keep polling these states instead of freezing a stale result.
|
||||
"6": ("UNDELIVERED", "هنوز تحویل پیامک به گوشی تأیید نشده است.", False),
|
||||
"7": ("EXPIRED", "مهلت دریافت پیامک منقضی شده است.", True),
|
||||
"8": ("UNKNOWN", "وضعیت تحویل هنوز توسط اپراتور مشخص نشده است.", False),
|
||||
"9": ("ERROR", "اطلاعات مالک شماره پیامک ثبت یا تأیید نشده است.", True),
|
||||
"12": ("ERROR", "هنوز اجازه ارسال با این شماره پنل داده نشده است.", True),
|
||||
"13": ("ERROR", "ارسال از IP غیرمجاز انجام شده است.", True),
|
||||
}
|
||||
|
||||
|
||||
def _scalar(value):
|
||||
if isinstance(value, (list, tuple)) and value:
|
||||
value = value[0]
|
||||
return str(value if value is not None else "").strip()
|
||||
|
||||
|
||||
class ZeroZeroNinetyEightSMSProvider(SMSProvider):
|
||||
"""Adapter for the 0098SMS HTTP gateway."""
|
||||
"""SOAP adapter for 0098SMS (0098vip-compatible web service)."""
|
||||
|
||||
def __init__(self):
|
||||
self.username = settings.SMS0098_USERNAME
|
||||
self.password = settings.SMS0098_PASSWORD
|
||||
self.sender = settings.SMS0098_FROM
|
||||
self.api_url = settings.SMS0098_API_URL
|
||||
self.wsdl_url = settings.SMS0098_WSDL_URL
|
||||
self._client = None
|
||||
|
||||
def _configuration_error(self):
|
||||
missing = [name for name, value in (
|
||||
("SMS0098_USERNAME", self.username),
|
||||
("SMS0098_PASSWORD", self.password),
|
||||
("SMS0098_FROM", self.sender),
|
||||
) if not value]
|
||||
return f"تنظیمات 0098SMS ناقص است: {', '.join(missing)}" if missing else ""
|
||||
|
||||
@property
|
||||
def client(self):
|
||||
if self._client is None:
|
||||
timeout = settings.INTEGRATION_HTTP_TIMEOUT_SECONDS
|
||||
self._client = zeep.Client(
|
||||
wsdl=self.wsdl_url,
|
||||
transport=Transport(timeout=timeout, operation_timeout=timeout),
|
||||
)
|
||||
return self._client
|
||||
|
||||
def _safe_exception(self, exc):
|
||||
message = str(exc)
|
||||
for secret in (self.username, self.password):
|
||||
if secret:
|
||||
message = message.replace(secret, "***")
|
||||
return message[:500]
|
||||
|
||||
def _call(self, operation, parameters):
|
||||
method = getattr(self.client.service, operation)
|
||||
# The live 0098sms.com WSDL exposes separate scalar arguments. This
|
||||
# deliberately follows the WSDL signature instead of the outdated
|
||||
# dict-shaped invocation printed in the vendor PDF sample.
|
||||
return method(**parameters)
|
||||
|
||||
def _parse_send_result(self, response):
|
||||
raw = _scalar(response)
|
||||
normalized = raw.casefold()
|
||||
if re.fullmatch(r"\d{9,}", raw):
|
||||
return SMSResult(True, provider_id=raw, provider_code=raw)
|
||||
# 2 is the SOAP success code. 0 is retained only for compatibility
|
||||
# with the vendor's older link endpoint.
|
||||
if raw in {"0", "2"}:
|
||||
return SMSResult(True, provider_code=raw)
|
||||
reason = SEND_RESULT_MESSAGES.get(normalized)
|
||||
if reason:
|
||||
return SMSResult(False, error=f"0098SMS [کد {raw}]: {reason}", provider_code=raw)
|
||||
shown = raw[:120] or "پاسخ خالی"
|
||||
return SMSResult(False, error=f"0098SMS پاسخ ناشناخته برگرداند: {shown}", provider_code=raw[:50])
|
||||
|
||||
def send_message(self, mobile, message):
|
||||
missing = [name for name, value in (("SMS0098_USERNAME", self.username), ("SMS0098_PASSWORD", self.password), ("SMS0098_FROM", self.sender)) if not value]
|
||||
if missing:
|
||||
return SMSResult(False, error=f"تنظیمات 0098SMS ناقص است: {', '.join(missing)}")
|
||||
error = self._configuration_error()
|
||||
if error:
|
||||
return SMSResult(False, error=error, provider_code="CONFIG")
|
||||
try:
|
||||
recipient = int(normalize_mobile(str(mobile)).removeprefix("+98"))
|
||||
response = requests.get(self.api_url, params={"USERNAME": self.username, "PASSWORD": self.password, "FROM": self.sender, "TO": recipient, "TEXT": str(message)}, timeout=settings.INTEGRATION_HTTP_TIMEOUT_SECONDS)
|
||||
response.raise_for_status()
|
||||
# This legacy endpoint may append an HTML page containing the
|
||||
# original query string. Persist only its first scalar result so
|
||||
# credentials can never leak into notification logs.
|
||||
provider_id = response.text.strip().splitlines()[0][:100]
|
||||
return SMSResult(True, provider_id=provider_id)
|
||||
except (ValueError, requests.RequestException) as exc:
|
||||
logger.exception("0098SMS delivery failed")
|
||||
return SMSResult(False, error=str(exc)[:1000])
|
||||
recipient = normalize_mobile(str(mobile)).removeprefix("+98")
|
||||
if not recipient.startswith("0"):
|
||||
recipient = f"0{recipient}"
|
||||
response = self._call("SendSMSWithID", {
|
||||
"username": self.username,
|
||||
"password": self.password,
|
||||
"mobileno": recipient,
|
||||
"pnlno": str(self.sender),
|
||||
"text": str(message),
|
||||
})
|
||||
return self._parse_send_result(response)
|
||||
except Exception as exc:
|
||||
logger.exception("0098SMS send failed")
|
||||
return SMSResult(
|
||||
False,
|
||||
error=f"خطا در ارتباط با وبسرویس 0098SMS: {self._safe_exception(exc)}",
|
||||
provider_code="CONNECTION",
|
||||
)
|
||||
|
||||
def get_delivery_status(self, provider_id):
|
||||
error = self._configuration_error()
|
||||
if error:
|
||||
return SMSDeliveryResult("ERROR", "CONFIG", error)
|
||||
if not re.fullmatch(r"\d{9,}", str(provider_id or "")):
|
||||
return SMSDeliveryResult("ERROR", "INVALID_ID", "شناسه رهگیری 0098SMS معتبر نیست.")
|
||||
try:
|
||||
response = self._call("smsdeliveryState", {
|
||||
"username": self.username,
|
||||
"password": self.password,
|
||||
"smsid": str(provider_id),
|
||||
})
|
||||
code = _scalar(response)
|
||||
mapped = DELIVERY_RESULTS.get(code)
|
||||
if mapped:
|
||||
status, description, is_final = mapped
|
||||
return SMSDeliveryResult(status, code, f"0098SMS [کد {code}]: {description}", is_final=is_final)
|
||||
reason = SEND_RESULT_MESSAGES.get(code.casefold())
|
||||
if reason:
|
||||
return SMSDeliveryResult("ERROR", code, f"0098SMS [کد {code}]: {reason}")
|
||||
return SMSDeliveryResult("UNKNOWN", code[:50], f"0098SMS وضعیت دلیوری ناشناخته برگرداند: {code[:120] or 'پاسخ خالی'}")
|
||||
except Exception as exc:
|
||||
logger.exception("0098SMS delivery status lookup failed")
|
||||
return SMSDeliveryResult(
|
||||
"CHECK_FAILED",
|
||||
"CONNECTION",
|
||||
f"خطا در استعلام دلیوری 0098SMS: {self._safe_exception(exc)}",
|
||||
is_final=False,
|
||||
)
|
||||
@@ -1,7 +1,7 @@
|
||||
from django.contrib import admin
|
||||
from .models import NotificationJob,NotificationPolicy,SMSTemplate
|
||||
@admin.register(NotificationJob)
|
||||
class NotificationJobAdmin(admin.ModelAdmin): list_display=("channel","event_type","status","attempts","provider_message_id","created_at"); list_filter=("channel","status"); readonly_fields=("provider_message_id","dedupe_key","created_at","sent_at")
|
||||
class NotificationJobAdmin(admin.ModelAdmin): list_display=("channel","event_type","status","attempts","provider_status_code","delivery_status","provider_message_id","created_at"); list_filter=("channel","status","delivery_status"); readonly_fields=("provider_message_id","provider_status_code","delivery_status_code","delivery_description","delivery_checked_at","dedupe_key","created_at","sent_at")
|
||||
@admin.register(SMSTemplate)
|
||||
class SMSTemplateAdmin(admin.ModelAdmin):
|
||||
list_display=("event_type","unit","title","is_active","updated_at")
|
||||
|
||||
@@ -6,6 +6,7 @@ import jdatetime
|
||||
from django.conf import settings
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db import transaction
|
||||
from django.db.models import Q
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.accounts.models import TicketMagicLink
|
||||
@@ -130,6 +131,103 @@ def _prepare_customer_reply_bale(job):
|
||||
locked.save(update_fields=["status","next_attempt_at","last_error"]); return False
|
||||
return True
|
||||
|
||||
|
||||
def _record_failure(job, error, provider_code=""):
|
||||
"""Persist a provider failure using the channel-specific retry policy."""
|
||||
job.attempts += 1
|
||||
job.last_error = str(error)[:1000]
|
||||
job.provider_status_code = str(provider_code or "")[:50]
|
||||
job.provider_message_id = ""
|
||||
if job.channel == NotificationJob.Channel.SMS:
|
||||
if job.attempts < 1 + settings.SMS_MAX_RETRIES:
|
||||
job.status = NotificationJob.Status.RETRYING
|
||||
job.next_attempt_at = timezone.now() + timedelta(seconds=settings.SMS_RETRY_DELAY_SECONDS)
|
||||
else:
|
||||
job.status = NotificationJob.Status.FAILED
|
||||
else:
|
||||
job.status = NotificationJob.Status.FAILED
|
||||
job.next_attempt_at = timezone.now() + timedelta(seconds=min(3600, 2**job.attempts * 10))
|
||||
|
||||
|
||||
def _record_success(job, result):
|
||||
job.attempts += 1
|
||||
job.status = NotificationJob.Status.SENT
|
||||
job.sent_at = timezone.now()
|
||||
job.last_error = ""
|
||||
job.provider_message_id = getattr(result, "provider_id", "") if result is not None else ""
|
||||
job.provider_status_code = getattr(result, "provider_code", "") if result is not None else ""
|
||||
if job.channel == NotificationJob.Channel.SMS and job.provider_message_id:
|
||||
job.delivery_status = NotificationJob.DeliveryStatus.PENDING
|
||||
job.delivery_next_check_at = timezone.now() + timedelta(seconds=settings.SMS_DELIVERY_CHECK_DELAY_SECONDS)
|
||||
|
||||
|
||||
def _process_delivery_checks(options):
|
||||
if options.get("channel") not in (None, NotificationJob.Channel.SMS):
|
||||
return
|
||||
now = timezone.now()
|
||||
with transaction.atomic():
|
||||
pollable_statuses = [
|
||||
NotificationJob.DeliveryStatus.PENDING,
|
||||
NotificationJob.DeliveryStatus.CHECK_FAILED,
|
||||
NotificationJob.DeliveryStatus.UNKNOWN,
|
||||
NotificationJob.DeliveryStatus.UNDELIVERED,
|
||||
]
|
||||
# Resume provisional results created by older worker versions, but do
|
||||
# not revive messages beyond the configured 45-minute monitoring span.
|
||||
monitor_cutoff = now - timedelta(seconds=settings.SMS_DELIVERY_CHECK_DELAY_SECONDS * settings.SMS_DELIVERY_MAX_CHECKS)
|
||||
NotificationJob.objects.filter(
|
||||
channel=NotificationJob.Channel.SMS,
|
||||
status=NotificationJob.Status.SENT,
|
||||
delivery_status__in=[NotificationJob.DeliveryStatus.UNKNOWN, NotificationJob.DeliveryStatus.UNDELIVERED],
|
||||
delivery_next_check_at__isnull=True,
|
||||
delivery_check_attempts__lt=settings.SMS_DELIVERY_MAX_CHECKS,
|
||||
sent_at__gte=monitor_cutoff,
|
||||
).update(delivery_next_check_at=now)
|
||||
queryset = NotificationJob.objects.select_for_update(skip_locked=True).filter(
|
||||
channel=NotificationJob.Channel.SMS,
|
||||
status=NotificationJob.Status.SENT,
|
||||
delivery_status__in=pollable_statuses,
|
||||
delivery_next_check_at__lte=now,
|
||||
)
|
||||
if options.get("job_id"):
|
||||
queryset = queryset.filter(pk=options["job_id"])
|
||||
elif options.get("min_id"):
|
||||
queryset = queryset.filter(pk__gte=options["min_id"])
|
||||
jobs = list(queryset.order_by("delivery_next_check_at")[:20])
|
||||
# Lease each status check so parallel workers cannot query it twice.
|
||||
NotificationJob.objects.filter(pk__in=[job.pk for job in jobs]).update(
|
||||
delivery_next_check_at=now + timedelta(minutes=10),
|
||||
)
|
||||
provider = get_sms_provider()
|
||||
for job in jobs:
|
||||
result = provider.get_delivery_status(job.provider_message_id)
|
||||
if result is None:
|
||||
job.delivery_status = ""
|
||||
job.delivery_next_check_at = None
|
||||
job.delivery_description = "این ارائهدهنده از استعلام دلیوری پشتیبانی نمیکند."
|
||||
else:
|
||||
job.delivery_check_attempts += 1
|
||||
job.delivery_checked_at = timezone.now()
|
||||
job.delivery_status_code = result.provider_code[:50]
|
||||
job.delivery_description = result.description[:500]
|
||||
if result.is_final:
|
||||
job.delivery_status = result.status
|
||||
job.delivery_next_check_at = None
|
||||
elif job.delivery_check_attempts < settings.SMS_DELIVERY_MAX_CHECKS:
|
||||
job.delivery_status = result.status
|
||||
job.delivery_next_check_at = timezone.now() + timedelta(seconds=settings.SMS_DELIVERY_CHECK_DELAY_SECONDS)
|
||||
else:
|
||||
job.delivery_status = result.status if result.status in NotificationJob.DeliveryStatus.values else NotificationJob.DeliveryStatus.ERROR
|
||||
job.delivery_next_check_at = None
|
||||
job.delivery_description = f"{result.description} نتیجه پس از {job.delivery_check_attempts} نوبت استعلام در بازه پایش نهایی شد."[:500]
|
||||
job.save(update_fields=[
|
||||
"delivery_status", "delivery_status_code", "delivery_description",
|
||||
"delivery_checked_at", "delivery_check_attempts", "delivery_next_check_at",
|
||||
])
|
||||
if job.ticket_id and job.ticket.group_dispatch_id:
|
||||
from apps.tickets.services import refresh_group_dispatch_status
|
||||
refresh_group_dispatch_status(job.ticket.group_dispatch_id)
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Process notification outbox"
|
||||
def add_arguments(self, parser):
|
||||
@@ -140,9 +238,15 @@ class Command(BaseCommand):
|
||||
def handle(self, *args, **options):
|
||||
while True:
|
||||
stale = timezone.now() - timedelta(minutes=10)
|
||||
NotificationJob.objects.filter(status="PROCESSING", next_attempt_at__lt=stale).update(status="FAILED", next_attempt_at=timezone.now(), last_error="Recovered stale processing job")
|
||||
NotificationJob.objects.filter(status="PROCESSING", next_attempt_at__lt=stale, channel=NotificationJob.Channel.SMS).update(status=NotificationJob.Status.RETRYING, next_attempt_at=timezone.now(), last_error="بازیابی تلاش ارسال متوقفشده")
|
||||
NotificationJob.objects.filter(status="PROCESSING", next_attempt_at__lt=stale, channel=NotificationJob.Channel.BALE).update(status=NotificationJob.Status.FAILED, next_attempt_at=timezone.now(), last_error="Recovered stale processing job")
|
||||
with transaction.atomic():
|
||||
queryset = NotificationJob.objects.select_for_update(skip_locked=True).filter(status__in=["PENDING", "FAILED"], attempts__lt=settings.MAX_NOTIFICATION_ATTEMPTS, next_attempt_at__lte=timezone.now())
|
||||
sms_limit = 1 + settings.SMS_MAX_RETRIES
|
||||
queryset = NotificationJob.objects.select_for_update(skip_locked=True).filter(
|
||||
Q(channel=NotificationJob.Channel.SMS, attempts__lt=sms_limit, status__in=[NotificationJob.Status.PENDING, NotificationJob.Status.RETRYING])
|
||||
| Q(channel=NotificationJob.Channel.BALE, attempts__lt=settings.MAX_NOTIFICATION_ATTEMPTS, status__in=[NotificationJob.Status.PENDING, NotificationJob.Status.FAILED]),
|
||||
next_attempt_at__lte=timezone.now(),
|
||||
)
|
||||
if options.get("channel"):
|
||||
queryset = queryset.filter(channel=options["channel"])
|
||||
if options.get("job_id"):
|
||||
@@ -160,15 +264,22 @@ class Command(BaseCommand):
|
||||
continue
|
||||
if job.channel == NotificationJob.Channel.SMS: result, issued_link = _send_sms(job)
|
||||
else: result = _send_bale(job)
|
||||
if result is not None and not result.success: raise RuntimeError(result.error or "Provider rejected notification")
|
||||
job.attempts += 1; job.status = "SENT"; job.sent_at = timezone.now(); job.last_error = ""; job.provider_message_id = getattr(result,"provider_id","") if result is not None else ""
|
||||
if result is not None and not result.success:
|
||||
_record_failure(job, result.error or "ارائهدهنده پیام را نپذیرفت.", getattr(result, "provider_code", ""))
|
||||
if issued_link:
|
||||
issued_link.revoked_at = timezone.now()
|
||||
issued_link.save(update_fields=["revoked_at"])
|
||||
else:
|
||||
_record_success(job, result)
|
||||
except Exception as exc:
|
||||
if issued_link:
|
||||
issued_link.revoked_at = timezone.now(); issued_link.save(update_fields=["revoked_at"])
|
||||
job.attempts += 1; job.last_error = str(exc)[:1000]; job.provider_message_id = ""; job.status = "FAILED"; job.next_attempt_at = timezone.now() + timedelta(seconds=min(3600, 2**job.attempts * 10)); logger.exception("notification failed id=%s", job.pk)
|
||||
_record_failure(job, exc)
|
||||
logger.exception("notification failed id=%s", job.pk)
|
||||
job.save()
|
||||
if job.ticket_id and job.ticket.group_dispatch_id:
|
||||
from apps.tickets.services import refresh_group_dispatch_status
|
||||
refresh_group_dispatch_status(job.ticket.group_dispatch_id)
|
||||
_process_delivery_checks(options)
|
||||
if options["once"] or options.get("job_id"): break
|
||||
time.sleep(5)
|
||||
@@ -0,0 +1,32 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [("notifications", "0010_use_actual_unit_in_staff_ticket_sms")]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="notificationjob",
|
||||
name="status",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("PENDING", "در صف"),
|
||||
("PROCESSING", "در حال ارسال"),
|
||||
("RETRYING", "در انتظار تلاش مجدد"),
|
||||
("SENT", "ارسالشده"),
|
||||
("FAILED", "ناموفق"),
|
||||
("CANCELLED", "لغوشده"),
|
||||
],
|
||||
db_index=True,
|
||||
default="PENDING",
|
||||
max_length=15,
|
||||
),
|
||||
),
|
||||
migrations.AddField(model_name="notificationjob", name="provider_status_code", field=models.CharField(blank=True, max_length=50)),
|
||||
migrations.AddField(model_name="notificationjob", name="delivery_status", field=models.CharField(blank=True, choices=[("PENDING", "در انتظار استعلام"), ("DELIVERED", "رسیده به گوشی"), ("UNDELIVERED", "نرسیده به گوشی"), ("EXPIRED", "منقضیشده"), ("BLOCKED", "مسدود توسط مخاطب"), ("UNKNOWN", "نامشخص"), ("ERROR", "خطای دلیوری"), ("CHECK_FAILED", "خطای موقت استعلام")], db_index=True, max_length=20)),
|
||||
migrations.AddField(model_name="notificationjob", name="delivery_status_code", field=models.CharField(blank=True, max_length=50)),
|
||||
migrations.AddField(model_name="notificationjob", name="delivery_description", field=models.CharField(blank=True, max_length=500)),
|
||||
migrations.AddField(model_name="notificationjob", name="delivery_checked_at", field=models.DateTimeField(blank=True, null=True)),
|
||||
migrations.AddField(model_name="notificationjob", name="delivery_check_attempts", field=models.PositiveSmallIntegerField(default=0)),
|
||||
migrations.AddField(model_name="notificationjob", name="delivery_next_check_at", field=models.DateTimeField(blank=True, db_index=True, null=True)),
|
||||
]
|
||||
@@ -44,7 +44,23 @@ class NotificationPolicy(models.Model):
|
||||
|
||||
class NotificationJob(models.Model):
|
||||
class Channel(models.TextChoices): SMS="SMS","پیامک"; BALE="BALE","بله"
|
||||
class Status(models.TextChoices): PENDING="PENDING","در صف"; PROCESSING="PROCESSING","در حال ارسال"; SENT="SENT","ارسالشده"; FAILED="FAILED","ناموفق"; CANCELLED="CANCELLED","لغوشده"
|
||||
class Status(models.TextChoices): PENDING="PENDING","در صف"; PROCESSING="PROCESSING","در حال ارسال"; RETRYING="RETRYING","در انتظار تلاش مجدد"; SENT="SENT","ارسالشده"; FAILED="FAILED","ناموفق"; CANCELLED="CANCELLED","لغوشده"
|
||||
class DeliveryStatus(models.TextChoices):
|
||||
PENDING="PENDING","در انتظار استعلام"
|
||||
DELIVERED="DELIVERED","رسیده به گوشی"
|
||||
UNDELIVERED="UNDELIVERED","نرسیده به گوشی"
|
||||
EXPIRED="EXPIRED","منقضیشده"
|
||||
BLOCKED="BLOCKED","مسدود توسط مخاطب"
|
||||
UNKNOWN="UNKNOWN","نامشخص"
|
||||
ERROR="ERROR","خطای دلیوری"
|
||||
CHECK_FAILED="CHECK_FAILED","خطای موقت استعلام"
|
||||
channel=models.CharField(max_length=10,choices=Channel.choices); event_type=models.CharField(max_length=50); customer=models.ForeignKey(settings.AUTH_USER_MODEL,on_delete=models.CASCADE,null=True,blank=True,related_name="customer_notifications"); staff_user=models.ForeignKey(settings.AUTH_USER_MODEL,on_delete=models.CASCADE,null=True,blank=True,related_name="staff_notifications"); ticket=models.ForeignKey("tickets.Ticket",on_delete=models.CASCADE,null=True,blank=True); payload=models.JSONField(default=dict); status=models.CharField(max_length=15,choices=Status.choices,default=Status.PENDING,db_index=True); attempts=models.PositiveSmallIntegerField(default=0); next_attempt_at=models.DateTimeField(db_index=True); last_error=models.TextField(blank=True); provider_message_id=models.CharField(max_length=500,blank=True); dedupe_key=models.CharField(max_length=200,unique=True); created_at=models.DateTimeField(auto_now_add=True); sent_at=models.DateTimeField(null=True,blank=True)
|
||||
provider_status_code=models.CharField(max_length=50,blank=True)
|
||||
delivery_status=models.CharField(max_length=20,choices=DeliveryStatus.choices,blank=True,db_index=True)
|
||||
delivery_status_code=models.CharField(max_length=50,blank=True)
|
||||
delivery_description=models.CharField(max_length=500,blank=True)
|
||||
delivery_checked_at=models.DateTimeField(null=True,blank=True)
|
||||
delivery_check_attempts=models.PositiveSmallIntegerField(default=0)
|
||||
delivery_next_check_at=models.DateTimeField(null=True,blank=True,db_index=True)
|
||||
class Meta:
|
||||
indexes=[models.Index(fields=["status","next_attempt_at"],name="notif_due_idx"),models.Index(fields=["ticket","channel","status"],name="notif_ticket_chan_idx")]
|
||||
@@ -1,7 +1,7 @@
|
||||
from datetime import timedelta
|
||||
from django.core.exceptions import PermissionDenied,ValidationError
|
||||
from django.db import transaction
|
||||
from django.db.models import Count
|
||||
from django.db.models import Count,Q
|
||||
from django.utils import timezone
|
||||
from apps.attachments.services import save_attachments
|
||||
from apps.core.persian_text import normalize_persian_text
|
||||
@@ -100,11 +100,12 @@ def refresh_group_dispatch_status(dispatch_id):
|
||||
"""Persist delivery state independently of whether a report page is opened."""
|
||||
dispatch=GroupDispatch.objects.filter(pk=dispatch_id).first()
|
||||
if not dispatch: return None
|
||||
counts={row["status"]:row["count"] for row in NotificationJob.objects.filter(ticket__group_dispatch_id=dispatch_id,channel=NotificationJob.Channel.SMS,event_type="STAFF_TICKET_CREATED").values("status").annotate(count=Count("id"))}
|
||||
jobs=NotificationJob.objects.filter(ticket__group_dispatch_id=dispatch_id,channel=NotificationJob.Channel.SMS,event_type="STAFF_TICKET_CREATED")
|
||||
counts={row["status"]:row["count"] for row in jobs.values("status").annotate(count=Count("id"))}
|
||||
sent=counts.get(NotificationJob.Status.SENT,0)
|
||||
failed=counts.get(NotificationJob.Status.FAILED,0)
|
||||
if dispatch.recipient_count and sent>=dispatch.recipient_count: status=GroupDispatch.Status.SENT
|
||||
elif failed: status=GroupDispatch.Status.PARTIAL
|
||||
failed=jobs.filter(Q(status=NotificationJob.Status.FAILED) | Q(status=NotificationJob.Status.SENT,delivery_status__in=[NotificationJob.DeliveryStatus.UNDELIVERED,NotificationJob.DeliveryStatus.EXPIRED,NotificationJob.DeliveryStatus.BLOCKED,NotificationJob.DeliveryStatus.UNKNOWN,NotificationJob.DeliveryStatus.ERROR],delivery_next_check_at__isnull=True)).count()
|
||||
if failed: status=GroupDispatch.Status.PARTIAL
|
||||
elif dispatch.recipient_count and sent>=dispatch.recipient_count: status=GroupDispatch.Status.SENT
|
||||
else: status=GroupDispatch.Status.QUEUED
|
||||
if dispatch.status!=status:
|
||||
GroupDispatch.objects.filter(pk=dispatch_id).update(status=status,updated_at=timezone.now())
|
||||
|
||||
+62
-8
@@ -395,8 +395,9 @@ def staff_detail(request,public_id):
|
||||
mark_messages_read(conversation,request.user)
|
||||
last=conversation[-1] if conversation else None; TicketReadState.objects.update_or_create(ticket=ticket,user=request.user,defaults={"last_read_at":timezone.now(),"last_read_message":last})
|
||||
history=Ticket.objects.filter(customer=ticket.customer).exclude(pk=ticket.pk).select_related("owner__staff_profile","selected_expert__staff_profile").order_by("-last_activity_at")[:5]
|
||||
sms_jobs=ticket.notificationjob_set.filter(channel=NotificationJob.Channel.SMS).exclude(event_type="UNIT_SUPERVISOR_NEW_TICKET").order_by("-created_at")
|
||||
latest=conversation[-1] if conversation else None
|
||||
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),"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,"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),"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 ""})
|
||||
@staff_required
|
||||
def staff_messages(request,public_id):
|
||||
ticket=get_object_or_404(Ticket,public_id=public_id)
|
||||
@@ -559,19 +560,59 @@ def staff_dispatch_detail(request, public_id):
|
||||
if not DispatchPolicy.can_view(request.user, dispatch):
|
||||
raise PermissionDenied
|
||||
tickets = list(dispatch.tickets.select_related("customer__customer_profile").prefetch_related("magic_links", "ticket_messages", "notificationjob_set").order_by("customer_id"))
|
||||
delivery_failures = {
|
||||
NotificationJob.DeliveryStatus.UNDELIVERED,
|
||||
NotificationJob.DeliveryStatus.EXPIRED,
|
||||
NotificationJob.DeliveryStatus.BLOCKED,
|
||||
NotificationJob.DeliveryStatus.UNKNOWN,
|
||||
NotificationJob.DeliveryStatus.ERROR,
|
||||
}
|
||||
now = timezone.now()
|
||||
for ticket in tickets:
|
||||
ticket.sms_job = next((job for job in ticket.notificationjob_set.all() if job.channel == NotificationJob.Channel.SMS), None)
|
||||
ticket.sms_job = next((job for job in ticket.notificationjob_set.all() if job.channel == NotificationJob.Channel.SMS and job.event_type == SMSTemplate.Event.STAFF_TICKET_CREATED), None)
|
||||
ticket.sms_retryable = bool(ticket.sms_job and (ticket.sms_job.status == NotificationJob.Status.FAILED or (ticket.sms_job.delivery_status in delivery_failures and not ticket.sms_job.delivery_next_check_at)))
|
||||
ticket.latest_customer_response = next((message for message in reversed(list(ticket.ticket_messages.all())) if message.author_id == ticket.customer_id), None)
|
||||
sms_jobs = NotificationJob.objects.filter(ticket__group_dispatch=dispatch, channel=NotificationJob.Channel.SMS)
|
||||
links = list(ticket.magic_links.all())
|
||||
ticket.magic_total_views = sum(link.view_count for link in links)
|
||||
ticket.magic_link_count = len(links)
|
||||
ticket.magic_active_count = sum(not link.revoked_at and link.expires_at > now for link in links)
|
||||
ticket.magic_last_used_at = max((link.used_at for link in links if link.used_at), default=None)
|
||||
job = ticket.sms_job
|
||||
if not job:
|
||||
ticket.sms_state_title, ticket.sms_state_hint, ticket.sms_state_tone = "در صف ثبت نشده", "برای این مخاطب job پیامک پیدا نشد.", "neutral"
|
||||
elif job.status == NotificationJob.Status.FAILED:
|
||||
ticket.sms_state_title, ticket.sms_state_hint, ticket.sms_state_tone = "ارسال ناموفق", f"ارسال خودکار پس از {1 + settings.SMS_MAX_RETRIES} تلاش متوقف شده است.", "danger"
|
||||
elif job.status == NotificationJob.Status.RETRYING:
|
||||
ticket.sms_state_title, ticket.sms_state_hint, ticket.sms_state_tone = "در انتظار تلاش مجدد", "worker پیامک را در موعد بعدی دوباره ارسال میکند.", "warning"
|
||||
elif job.status in (NotificationJob.Status.PENDING, NotificationJob.Status.PROCESSING):
|
||||
ticket.sms_state_title, ticket.sms_state_hint, ticket.sms_state_tone = "در صف ارسال", "پیامک هنوز به درگاه تحویل نشده است.", "info"
|
||||
elif job.delivery_status == NotificationJob.DeliveryStatus.DELIVERED:
|
||||
ticket.sms_state_title, ticket.sms_state_hint, ticket.sms_state_tone = "تحویل به گوشی تأیید شد", "اپراتور دریافت پیامک توسط مخاطب را تأیید کرده است.", "success"
|
||||
elif job.delivery_status in (NotificationJob.DeliveryStatus.PENDING, NotificationJob.DeliveryStatus.UNKNOWN, NotificationJob.DeliveryStatus.UNDELIVERED, NotificationJob.DeliveryStatus.CHECK_FAILED) and job.delivery_next_check_at:
|
||||
ticket.sms_state_title, ticket.sms_state_hint, ticket.sms_state_tone = "در حال پیگیری تحویل", "نتیجه فعلی قطعی نیست و worker استعلام را ادامه میدهد.", "warning"
|
||||
elif job.delivery_status == NotificationJob.DeliveryStatus.BLOCKED:
|
||||
ticket.sms_state_title, ticket.sms_state_hint, ticket.sms_state_tone = "دریافت پیامک مسدود است", "شماره مخاطب دریافت پیامک تبلیغاتی را بسته است.", "danger"
|
||||
elif job.delivery_status == NotificationJob.DeliveryStatus.EXPIRED:
|
||||
ticket.sms_state_title, ticket.sms_state_hint, ticket.sms_state_tone = "مهلت تحویل پایان یافت", "اپراتور پیامک را در بازه مجاز تحویل نداده است.", "danger"
|
||||
elif job.delivery_status in (NotificationJob.DeliveryStatus.ERROR, NotificationJob.DeliveryStatus.UNKNOWN, NotificationJob.DeliveryStatus.UNDELIVERED):
|
||||
ticket.sms_state_title, ticket.sms_state_hint, ticket.sms_state_tone = "تحویل تأیید نشد", "بازه پیگیری پایان یافته یا درگاه خطای قطعی برگردانده است.", "danger"
|
||||
else:
|
||||
ticket.sms_state_title, ticket.sms_state_hint, ticket.sms_state_tone = "پذیرفتهشده توسط درگاه", "شناسه ارسال ثبت شده و در انتظار نتیجه دلیوری است.", "info"
|
||||
sms_jobs = NotificationJob.objects.filter(ticket__group_dispatch=dispatch, channel=NotificationJob.Channel.SMS, event_type=SMSTemplate.Event.STAFF_TICKET_CREATED)
|
||||
sms_counts = {row["status"]: row["count"] for row in sms_jobs.values("status").annotate(count=Count("id"))}
|
||||
sent = sms_counts.get(NotificationJob.Status.SENT, 0)
|
||||
failed = sms_counts.get(NotificationJob.Status.FAILED, 0)
|
||||
failed = sms_jobs.filter(Q(status=NotificationJob.Status.FAILED) | Q(status=NotificationJob.Status.SENT, delivery_status__in=delivery_failures, delivery_next_check_at__isnull=True)).count()
|
||||
delivered = sms_jobs.filter(delivery_status=NotificationJob.DeliveryStatus.DELIVERED).count()
|
||||
retrying = sms_counts.get(NotificationJob.Status.RETRYING, 0)
|
||||
tracking = sms_jobs.filter(delivery_next_check_at__isnull=False).count()
|
||||
opened = TicketMagicLink.objects.filter(ticket__group_dispatch=dispatch, view_count__gt=0).values("ticket_id").distinct().count()
|
||||
total_views = TicketMagicLink.objects.filter(ticket__group_dispatch=dispatch).aggregate(total=Sum("view_count"))["total"] or 0
|
||||
replied = dispatch.tickets.filter(last_customer_message_at__isnull=False).count()
|
||||
expected = dispatch.recipient_count
|
||||
refresh_group_dispatch_status(dispatch.pk); dispatch.refresh_from_db(fields=["status"])
|
||||
metrics = {"sent": sent, "failed": failed, "pending": max(expected - sent - failed, 0), "opened": opened, "replied": replied}
|
||||
return render(request, "tickets/staff_dispatch_detail.html", {"dispatch": dispatch, "tickets": tickets, "metrics": metrics, "active_staff_section": "dispatches"})
|
||||
pending = sum(sms_counts.get(status, 0) for status in (NotificationJob.Status.PENDING, NotificationJob.Status.PROCESSING, NotificationJob.Status.RETRYING))
|
||||
delivery_percent = round((delivered / dispatch.recipient_count) * 100) if dispatch.recipient_count else 0
|
||||
metrics = {"sent": sent, "delivered": delivered, "failed": failed, "retrying": retrying, "tracking": tracking, "pending": pending, "opened": opened, "not_opened": max(dispatch.recipient_count-opened,0), "total_views": total_views, "replied": replied, "delivery_percent": delivery_percent}
|
||||
return render(request, "tickets/staff_dispatch_detail.html", {"dispatch": dispatch, "tickets": tickets, "metrics": metrics, "sms_max_attempts": 1 + settings.SMS_MAX_RETRIES, "active_staff_section": "dispatches"})
|
||||
|
||||
|
||||
@staff_required
|
||||
@@ -579,7 +620,20 @@ def staff_dispatch_detail(request, public_id):
|
||||
def staff_dispatch_retry(request, public_id):
|
||||
dispatch=get_object_or_404(GroupDispatch,public_id=public_id)
|
||||
if not DispatchPolicy.can_view(request.user,dispatch) or not DispatchPolicy.can_create(request.user,dispatch.unit,dispatch.operation_type): raise PermissionDenied
|
||||
count=NotificationJob.objects.filter(ticket__group_dispatch=dispatch,channel=NotificationJob.Channel.SMS,status=NotificationJob.Status.FAILED).update(status=NotificationJob.Status.PENDING,attempts=0,next_attempt_at=timezone.now(),last_error="")
|
||||
retryable_delivery = [NotificationJob.DeliveryStatus.UNDELIVERED, NotificationJob.DeliveryStatus.EXPIRED, NotificationJob.DeliveryStatus.BLOCKED, NotificationJob.DeliveryStatus.UNKNOWN, NotificationJob.DeliveryStatus.ERROR]
|
||||
jobs=NotificationJob.objects.filter(ticket__group_dispatch=dispatch,channel=NotificationJob.Channel.SMS,event_type=SMSTemplate.Event.STAFF_TICKET_CREATED).filter(Q(status=NotificationJob.Status.FAILED) | Q(status=NotificationJob.Status.SENT,delivery_status__in=retryable_delivery,delivery_next_check_at__isnull=True))
|
||||
job_id=request.POST.get("job_id","").strip()
|
||||
if job_id:
|
||||
if not job_id.isdigit():
|
||||
messages.error(request,"شناسه پیامک برای تلاش مجدد معتبر نیست.")
|
||||
return redirect("staff_tickets:dispatch_detail",public_id=public_id)
|
||||
jobs=jobs.filter(pk=int(job_id))
|
||||
count=jobs.update(
|
||||
status=NotificationJob.Status.PENDING, attempts=0, next_attempt_at=timezone.now(),
|
||||
last_error="", provider_message_id="", provider_status_code="", sent_at=None,
|
||||
delivery_status="", delivery_status_code="", delivery_description="",
|
||||
delivery_checked_at=None, delivery_check_attempts=0, delivery_next_check_at=None,
|
||||
)
|
||||
refresh_group_dispatch_status(dispatch.pk)
|
||||
messages.success(request,f"{count} ارسال ناموفق دوباره در صف قرار گرفت." if count else "ارسال ناموفقی برای تلاش مجدد وجود ندارد.")
|
||||
return redirect("staff_tickets:dispatch_detail",public_id=public_id)
|
||||
|
||||
@@ -96,6 +96,10 @@ DEV_OTP_MOBILE = ""
|
||||
DEV_OTP_CODE = ""
|
||||
MAGIC_LINK_TTL_DAYS = int(os.getenv("MAGIC_LINK_TTL_DAYS", "7"))
|
||||
MAX_NOTIFICATION_ATTEMPTS = int(os.getenv("MAX_NOTIFICATION_ATTEMPTS", "5"))
|
||||
SMS_MAX_RETRIES = int(os.getenv("SMS_MAX_RETRIES", "3"))
|
||||
SMS_RETRY_DELAY_SECONDS = int(os.getenv("SMS_RETRY_DELAY_SECONDS", "180"))
|
||||
SMS_DELIVERY_CHECK_DELAY_SECONDS = int(os.getenv("SMS_DELIVERY_CHECK_DELAY_SECONDS", "180"))
|
||||
SMS_DELIVERY_MAX_CHECKS = int(os.getenv("SMS_DELIVERY_MAX_CHECKS", "15"))
|
||||
PUBLIC_BASE_URL = os.getenv("PUBLIC_BASE_URL", "http://127.0.0.1:8000").rstrip("/")
|
||||
COMPANY_NAME = os.getenv("COMPANY_NAME", "لوله گستر گلپایگان")
|
||||
WEBOTP_HOST = os.getenv("WEBOTP_HOST", "")
|
||||
@@ -110,7 +114,7 @@ BEHIN_SMS_WSDL_URL = os.getenv("BEHIN_SMS_WSDL_URL", "http://panel.behinsms.com/
|
||||
SMS0098_USERNAME = os.getenv("SMS0098_USERNAME", "")
|
||||
SMS0098_PASSWORD = os.getenv("SMS0098_PASSWORD", "")
|
||||
SMS0098_FROM = os.getenv("SMS0098_FROM", "")
|
||||
SMS0098_API_URL = os.getenv("SMS0098_API_URL", "http://www.0098sms.com/sendsmslink.aspx")
|
||||
SMS0098_WSDL_URL = os.getenv("SMS0098_WSDL_URL", "https://webservice.0098sms.com/service.asmx?WSDL")
|
||||
SMS_TEST_ALLOWED_MOBILES = ("+989202202129", "+989384999655")
|
||||
BALE_API_BASE = os.getenv("BALE_API_BASE", "https://tapi.bale.ai")
|
||||
BALE_BOT_TOKEN = os.getenv("BALE_BOT_TOKEN", "")
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
<!doctype html>
|
||||
<html lang="fa" dir="rtl">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>نمونه رابط مرحلهای ثبت درخواست</title>
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="prototype-note">
|
||||
<span>نمونه نمایشی مستقل</span>
|
||||
<p>این صفحه به Django و اطلاعات واقعی متصل نیست.</p>
|
||||
</header>
|
||||
|
||||
<main class="shell">
|
||||
<nav class="topbar" aria-label="مسیر صفحه">
|
||||
<button class="back-button" type="button">→ بازگشت به درخواستها</button>
|
||||
<div class="brand"><span>لولهگستر</span><small>سامانه ارتباط با مشتریان</small></div>
|
||||
</nav>
|
||||
|
||||
<section class="intro">
|
||||
<div>
|
||||
<span class="eyebrow">ثبت درخواست جدید</span>
|
||||
<h1>چطور میتوانیم کمک کنیم؟</h1>
|
||||
<p>قدمبهقدم پیش میرویم؛ در هر مرحله فقط اطلاعات ضروری همان بخش را میبینید.</p>
|
||||
</div>
|
||||
<div class="save-state"><i></i> اطلاعات شما در این صفحه حفظ میشود</div>
|
||||
</section>
|
||||
|
||||
<ol class="progress" aria-label="مراحل ثبت درخواست">
|
||||
<li class="active" data-progress="1"><span>۱</span><div><b>موضوع</b><small>مسیر درخواست</small></div></li>
|
||||
<li data-progress="2"><span>۲</span><div><b>موقعیت</b><small>اختیاری</small></div></li>
|
||||
<li data-progress="3"><span>۳</span><div><b>کارشناس</b><small>انتخاب مسئول</small></div></li>
|
||||
<li data-progress="4"><span>۴</span><div><b>جزئیات</b><small>متن و فایل</small></div></li>
|
||||
<li data-progress="5"><span>۵</span><div><b>بازبینی</b><small>تأیید نهایی</small></div></li>
|
||||
</ol>
|
||||
|
||||
<form class="wizard" id="ticket-wizard" novalidate>
|
||||
<section class="step active" data-step="1" aria-labelledby="step-1-title">
|
||||
<header class="step-head"><span class="step-symbol">⌕</span><div><small>مرحله ۱ از ۵</small><h2 id="step-1-title">موضوع درخواستتان چیست؟</h2><p>با چند کلمه شروع کنید تا مسیر مناسب را پیشنهاد دهیم، یا خودتان انتخاب کنید.</p></div></header>
|
||||
<label class="search-box"><span class="sr-only">جستوجوی موضوع درخواست</span><i>⌕</i><input id="intent" type="search" placeholder="مثلاً فیش واریزی، موجودی کالا یا ارسال بار..."><kbd>جستوجو</kbd></label>
|
||||
<div class="suggestions" id="suggestions" aria-live="polite" hidden>
|
||||
<button type="button" data-suggest-unit="حسابداری" data-suggest-category="فیش واریزی"><b>ارسال فیش یا رسید پرداخت</b><small>حسابداری · فیش واریزی</small><span>انتخاب ←</span></button>
|
||||
<button type="button" data-suggest-unit="بارگیری انبار" data-suggest-category="هماهنگی بار"><b>هماهنگی ارسال یا بارگیری</b><small>بارگیری انبار · هماهنگی بار</small><span>انتخاب ←</span></button>
|
||||
<p class="suggestion-empty" id="suggestion-empty" hidden>پیشنهاد دقیقی پیدا نشد؛ از انتخاب دستی استفاده کنید.</p>
|
||||
</div>
|
||||
<button class="manual-toggle" type="button" id="manual-toggle" aria-expanded="false">انتخاب دستی واحد و دستهبندی <span>⌄</span></button>
|
||||
<div class="manual-fields" id="manual-fields" hidden>
|
||||
<label><span>واحد سازمانی <em>*</em></span><select id="unit" required><option value="">انتخاب واحد</option><option>فروش</option><option>حسابداری</option><option>بارگیری انبار</option></select></label>
|
||||
<label><span>دستهبندی <em>*</em></span><select id="category" required disabled><option value="">ابتدا واحد را انتخاب کنید</option></select></label>
|
||||
</div>
|
||||
<label class="full-field"><span>موضوع دقیق درخواست <em>*</em></span><input id="subject" maxlength="200" required placeholder="یک عنوان کوتاه و روشن بنویسید"><small>مثال: پیگیری فیش واریزی سفارش ۲۴۸</small></label>
|
||||
<p class="validation" data-error="1" role="alert"></p>
|
||||
<footer class="step-actions"><span>حدود یک دقیقه تا پایان</span><button class="primary" type="button" data-next>ادامه <b>←</b></button></footer>
|
||||
</section>
|
||||
|
||||
<section class="step" data-step="2" aria-labelledby="step-2-title" hidden>
|
||||
<header class="step-head"><span class="step-symbol">⌖</span><div><small>مرحله ۲ از ۵ · اختیاری</small><h2 id="step-2-title">این درخواست مربوط به کجاست؟</h2><p>اگر موقعیت در پاسخ بهتر کارشناسان مؤثر است آن را انتخاب کنید؛ در غیر این صورت عبور کنید.</p></div></header>
|
||||
<div class="two-columns">
|
||||
<label><span>استان</span><select id="province"><option value="">انتخاب استان</option><option>تهران</option><option>اصفهان</option><option>مرکزی</option></select></label>
|
||||
<label><span>شهر</span><select id="city" disabled><option value="">ابتدا استان را انتخاب کنید</option></select></label>
|
||||
</div>
|
||||
<div class="soft-tip"><span>i</span><p><b>نیازی به انتخاب ندارید</b>این بخش کاملاً اختیاری است و میتوانید مستقیماً ادامه دهید.</p></div>
|
||||
<footer class="step-actions"><button class="secondary" type="button" data-prev>مرحله قبل</button><button class="primary" type="button" data-next>ادامه <b>←</b></button></footer>
|
||||
</section>
|
||||
|
||||
<section class="step" data-step="3" aria-labelledby="step-3-title" hidden>
|
||||
<header class="step-head"><span class="step-symbol">◎</span><div><small>مرحله ۳ از ۵ · اختیاری</small><h2 id="step-3-title">چه کسی درخواست را بررسی کند؟</h2><p>صف عمومی پیشنهاد ماست؛ درخواست به اولین کارشناس آزاد همان واحد میرسد.</p></div></header>
|
||||
<div class="expert-list">
|
||||
<label class="expert selected"><input type="radio" name="expert" value="صف عمومی" checked><span class="avatar queue">⌁</span><div><b>صف عمومی واحد</b><small>سریعترین انتخاب · اولین کارشناس آزاد</small></div><i>✓</i></label>
|
||||
<label class="expert"><input type="radio" name="expert" value="مریم احمدی"><span class="avatar">م</span><div><b>مریم احمدی</b><small>کارشناس فروش · پاسخگویی فعال</small></div><i>✓</i></label>
|
||||
<label class="expert"><input type="radio" name="expert" value="علی رضایی"><span class="avatar amber">ع</span><div><b>علی رضایی</b><small>کارشناس ارشد · پاسخگویی فعال</small></div><i>✓</i></label>
|
||||
</div>
|
||||
<footer class="step-actions"><button class="secondary" type="button" data-prev>مرحله قبل</button><button class="primary" type="button" data-next>ادامه <b>←</b></button></footer>
|
||||
</section>
|
||||
|
||||
<section class="step" data-step="4" aria-labelledby="step-4-title" hidden>
|
||||
<header class="step-head"><span class="step-symbol">≡</span><div><small>مرحله ۴ از ۵</small><h2 id="step-4-title">جزئیات را برای کارشناس بنویسید</h2><p>هرچه واضحتر توضیح دهید، پاسخ دقیقتری دریافت میکنید. متن یا حداقل یک پیوست لازم است.</p></div></header>
|
||||
<label class="full-field"><span>شرح درخواست</span><textarea id="body" rows="6" placeholder="شماره سفارش، محصول موردنظر و توضیح مسئله را بنویسید..."></textarea><small><span id="body-count">۰</span> از ۲۰۰۰ نویسه</small></label>
|
||||
<div class="attachment-zone">
|
||||
<div class="attachment-head"><span>+</span><div><b>عکس و فایلهای مرتبط</b><small>حداکثر ۱۰ فایل؛ تصاویر بزرگ قبل از ارسال بهینه میشوند.</small></div></div>
|
||||
<div class="attachment-actions"><label for="gallery">▣ انتخاب عکس از گالری</label><label for="documents">+ انتخاب فایل</label></div>
|
||||
<input id="gallery" type="file" accept="image/*" multiple hidden><input id="documents" type="file" multiple hidden>
|
||||
<div class="file-preview" id="file-preview" aria-live="polite"></div>
|
||||
</div>
|
||||
<section class="voice-recorder" id="voice-recorder" aria-labelledby="voice-title">
|
||||
<header class="voice-head">
|
||||
<span class="voice-icon" aria-hidden="true">◉</span>
|
||||
<div><b id="voice-title">پیام صوتی</b><small id="voice-status" aria-live="polite">برای توضیح سریعتر، صدای خود را ضبط کنید.</small></div>
|
||||
<span class="voice-timer" id="voice-timer">۰۰:۰۰</span>
|
||||
</header>
|
||||
<div class="voice-idle" id="voice-idle">
|
||||
<button class="voice-start" type="button" id="record"><span>●</span> شروع ضبط صدا</button>
|
||||
<small>پس از اجازهدادن به میکروفن، ضبط آغاز میشود.</small>
|
||||
</div>
|
||||
<div class="voice-live" id="voice-live" hidden>
|
||||
<div class="voice-wave" aria-hidden="true"><i></i><i></i><i></i><i></i><i></i><i></i><i></i></div>
|
||||
<div class="voice-controls">
|
||||
<button type="button" id="pause">مکث</button>
|
||||
<button class="voice-stop" type="button" id="stop">■ توقف و ذخیره</button>
|
||||
<button class="voice-cancel" type="button" id="cancel-recording">لغو</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="voice-preview" id="voice-preview" hidden>
|
||||
<div class="voice-ready"><span>✓</span><p><b>پیام صوتی آماده است</b><small id="voice-meta">قابل پخش و بررسی پیش از ارسال</small></p></div>
|
||||
<audio id="audio" controls preload="metadata"></audio>
|
||||
<div class="voice-preview-actions"><button type="button" id="rerecord">ضبط مجدد</button><button type="button" id="delete-voice">حذف پیام صوتی</button></div>
|
||||
</div>
|
||||
<p class="voice-note"><span>ⓘ</span> با رفتن به مرحله بعد، ضبط فعال بهصورت خودکار متوقف و ذخیره میشود.</p>
|
||||
<p class="voice-error" id="voice-error" role="alert" hidden></p>
|
||||
</section>
|
||||
<p class="validation" data-error="4" role="alert"></p>
|
||||
<footer class="step-actions"><button class="secondary" type="button" data-prev>مرحله قبل</button><button class="primary" type="button" data-next>مشاهده پیشنمایش <b>←</b></button></footer>
|
||||
</section>
|
||||
|
||||
<section class="step review-step" data-step="5" aria-labelledby="step-5-title" hidden>
|
||||
<header class="step-head"><span class="step-symbol success">✓</span><div><small>مرحله ۵ از ۵</small><h2 id="step-5-title">همهچیز آماده ارسال است</h2><p>اطلاعات را یک بار مرور کنید؛ هر بخش را میتوانید جداگانه ویرایش کنید.</p></div></header>
|
||||
<div class="review-callout"><span>✓</span><div><b>یک مرور کوتاه پیش از ارسال</b><small>اگر موردی درست نیست، همان ردیف را ویرایش کنید؛ اطلاعات مراحل دیگر حفظ میشود.</small></div></div>
|
||||
<div class="review-list">
|
||||
<section class="review-row">
|
||||
<span class="review-icon">۱</span><div class="review-content"><small>موضوع و مسیر رسیدگی</small><h3 id="review-subject">—</h3><p><span id="review-unit">—</span><i>←</i><span id="review-category">—</span></p></div><button type="button" data-edit="1"><span>✎</span> ویرایش</button>
|
||||
</section>
|
||||
<section class="review-row">
|
||||
<span class="review-icon">۲</span><div class="review-content"><small>موقعیت</small><h3 id="review-location">ثبت نشده</h3><p>اختیاری</p></div><button type="button" data-edit="2"><span>✎</span> ویرایش</button>
|
||||
</section>
|
||||
<section class="review-row">
|
||||
<span class="review-icon">۳</span><div class="review-content"><small>نحوه ارجاع</small><h3 id="review-expert">صف عمومی</h3><p>در همان واحد سازمانی</p></div><button type="button" data-edit="3"><span>✎</span> ویرایش</button>
|
||||
</section>
|
||||
<section class="review-row review-details">
|
||||
<span class="review-icon">۴</span><div class="review-content"><small>شرح و پیوستها</small><p class="review-body" id="review-body">—</p><div class="review-files" id="review-files"></div><audio id="review-audio" class="review-audio" controls hidden></audio></div><button type="button" data-edit="4"><span>✎</span> ویرایش</button>
|
||||
</section>
|
||||
</div>
|
||||
<footer class="step-actions final-actions"><button class="secondary" type="button" data-prev>مرحله قبل</button><button class="primary send" type="submit">ثبت و ارسال نهایی <b>←</b></button></footer>
|
||||
</section>
|
||||
|
||||
<section class="success-page" id="success-page" hidden>
|
||||
<span>✓</span><h2>درخواست با موفقیت آماده شد</h2><p>این فقط نمونه نمایشی است و اطلاعاتی به سرور ارسال نشد.</p><button type="button" id="restart">مشاهده دوباره طرح</button>
|
||||
</section>
|
||||
</form>
|
||||
</main>
|
||||
<nav class="mobile-dock" aria-label="دسترسی سریع مشتری">
|
||||
<button type="button"><span>◎</span>درخواستها</button>
|
||||
<button class="active" type="button"><span>+</span>درخواست جدید</button>
|
||||
<button type="button"><span>○</span>پروفایل</button>
|
||||
</nav>
|
||||
<script src="prototype.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+379
@@ -0,0 +1,379 @@
|
||||
const form = document.querySelector('#ticket-wizard');
|
||||
const steps = [...document.querySelectorAll('[data-step]')];
|
||||
const progress = [...document.querySelectorAll('[data-progress]')];
|
||||
const unit = document.querySelector('#unit');
|
||||
const category = document.querySelector('#category');
|
||||
const subject = document.querySelector('#subject');
|
||||
const body = document.querySelector('#body');
|
||||
const province = document.querySelector('#province');
|
||||
const city = document.querySelector('#city');
|
||||
const files = [];
|
||||
let current = 1;
|
||||
|
||||
const categories = {
|
||||
'فروش': ['مشاوره خرید', 'موجودی و قیمت', 'پیگیری سفارش'],
|
||||
'حسابداری': ['فیش واریزی', 'صورتحساب', 'پیگیری پرداخت'],
|
||||
'بارگیری انبار': ['هماهنگی بار', 'زمان ارسال', 'پیگیری تحویل'],
|
||||
};
|
||||
const cities = {
|
||||
'تهران': ['تهران', 'شهریار', 'اسلامشهر'],
|
||||
'اصفهان': ['اصفهان', 'کاشان', 'گلپایگان'],
|
||||
'مرکزی': ['اراک', 'ساوه', 'محلات'],
|
||||
};
|
||||
|
||||
const recorderUi = {
|
||||
root: document.querySelector('#voice-recorder'), idle: document.querySelector('#voice-idle'),
|
||||
live: document.querySelector('#voice-live'), preview: document.querySelector('#voice-preview'),
|
||||
record: document.querySelector('#record'), pause: document.querySelector('#pause'),
|
||||
stop: document.querySelector('#stop'), cancel: document.querySelector('#cancel-recording'),
|
||||
rerecord: document.querySelector('#rerecord'), remove: document.querySelector('#delete-voice'),
|
||||
audio: document.querySelector('#audio'), reviewAudio: document.querySelector('#review-audio'),
|
||||
timer: document.querySelector('#voice-timer'), status: document.querySelector('#voice-status'),
|
||||
meta: document.querySelector('#voice-meta'), error: document.querySelector('#voice-error'),
|
||||
};
|
||||
|
||||
let recorder = null;
|
||||
let microphoneStream = null;
|
||||
let audioChunks = [];
|
||||
let voiceBlob = null;
|
||||
let voiceUrl = '';
|
||||
let recordingStartedAt = 0;
|
||||
let pausedStartedAt = 0;
|
||||
let totalPausedMs = 0;
|
||||
let timerId = null;
|
||||
let stopResolver = null;
|
||||
let discardOnStop = false;
|
||||
|
||||
function toPersianDigits(value) {
|
||||
return String(value).replace(/\d/g, digit => '۰۱۲۳۴۵۶۷۸۹'[digit]);
|
||||
}
|
||||
|
||||
function formatDuration(milliseconds) {
|
||||
const seconds = Math.max(0, Math.floor(milliseconds / 1000));
|
||||
return toPersianDigits(`${String(Math.floor(seconds / 60)).padStart(2, '0')}:${String(seconds % 60).padStart(2, '0')}`);
|
||||
}
|
||||
|
||||
function elapsedRecordingTime() {
|
||||
if (!recordingStartedAt) return 0;
|
||||
const activePause = recorder?.state === 'paused' ? Date.now() - pausedStartedAt : 0;
|
||||
return Date.now() - recordingStartedAt - totalPausedMs - activePause;
|
||||
}
|
||||
|
||||
function setSelect(select, items, placeholder) {
|
||||
select.replaceChildren(new Option(placeholder, ''), ...items.map(item => new Option(item, item)));
|
||||
select.disabled = !items.length;
|
||||
}
|
||||
|
||||
function showStep(number) {
|
||||
current = number;
|
||||
steps.forEach(step => {
|
||||
const active = Number(step.dataset.step) === number;
|
||||
step.hidden = !active;
|
||||
step.classList.toggle('active', active);
|
||||
});
|
||||
progress.forEach(item => {
|
||||
const itemNumber = Number(item.dataset.progress);
|
||||
item.classList.toggle('active', itemNumber === number);
|
||||
item.classList.toggle('completed', itemNumber < number);
|
||||
item.querySelector('span').textContent = itemNumber < number ? '✓' : toPersianDigits(itemNumber);
|
||||
});
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}
|
||||
|
||||
function showValidationError(number, message, invalidFields = []) {
|
||||
const step = document.querySelector(`[data-step="${number}"]`);
|
||||
const error = document.querySelector(`[data-error="${number}"]`);
|
||||
step.querySelectorAll('[aria-invalid="true"]').forEach(field => field.removeAttribute('aria-invalid'));
|
||||
invalidFields.forEach(field => field?.setAttribute('aria-invalid', 'true'));
|
||||
error.textContent = message;
|
||||
error.tabIndex = -1;
|
||||
error.classList.add('visible');
|
||||
requestAnimationFrame(() => {
|
||||
error.focus({ preventScroll: true });
|
||||
error.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'nearest' });
|
||||
});
|
||||
}
|
||||
|
||||
function clearValidationError(number) {
|
||||
const step = document.querySelector(`[data-step="${number}"]`);
|
||||
const error = document.querySelector(`[data-error="${number}"]`);
|
||||
step.querySelectorAll('[aria-invalid="true"]').forEach(field => field.removeAttribute('aria-invalid'));
|
||||
if (error) {
|
||||
error.textContent = '';
|
||||
error.classList.remove('visible');
|
||||
error.removeAttribute('tabindex');
|
||||
}
|
||||
}
|
||||
|
||||
function validateStep(number) {
|
||||
const error = document.querySelector(`[data-error="${number}"]`);
|
||||
if (number === 1) {
|
||||
const missing = [];
|
||||
const invalidFields = [];
|
||||
if (!unit.value) { missing.push('واحد'); invalidFields.push(unit); }
|
||||
if (!category.value) { missing.push('دستهبندی'); invalidFields.push(category); }
|
||||
if (subject.value.trim().length < 5) { missing.push('موضوع حداقل پنجحرفی'); invalidFields.push(subject); }
|
||||
if (missing.length) {
|
||||
showValidationError(number, `برای ادامه، ${missing.join('، ')} را تکمیل کنید.`, invalidFields);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (number === 4 && !body.value.trim() && !files.length && !voiceBlob) {
|
||||
showValidationError(number, 'برای ادامه، توضیح بنویسید یا یک فایل/پیام صوتی اضافه کنید.', [body]);
|
||||
return false;
|
||||
}
|
||||
if (error) clearValidationError(number);
|
||||
return true;
|
||||
}
|
||||
|
||||
function stopMicrophoneTracks() {
|
||||
microphoneStream?.getTracks().forEach(track => track.stop());
|
||||
microphoneStream = null;
|
||||
}
|
||||
|
||||
function clearTimer() { clearInterval(timerId); timerId = null; }
|
||||
|
||||
function updateRecorderView(mode, statusText) {
|
||||
recorderUi.idle.hidden = mode !== 'idle';
|
||||
recorderUi.live.hidden = mode !== 'live';
|
||||
recorderUi.preview.hidden = mode !== 'preview';
|
||||
recorderUi.root.dataset.state = mode;
|
||||
if (statusText) recorderUi.status.textContent = statusText;
|
||||
}
|
||||
|
||||
function clearVoice() {
|
||||
clearTimer();
|
||||
stopMicrophoneTracks();
|
||||
if (voiceUrl) URL.revokeObjectURL(voiceUrl);
|
||||
voiceBlob = null;
|
||||
voiceUrl = '';
|
||||
recorderUi.audio.removeAttribute('src');
|
||||
recorderUi.reviewAudio.removeAttribute('src');
|
||||
recorderUi.reviewAudio.hidden = true;
|
||||
recorderUi.timer.textContent = '۰۰:۰۰';
|
||||
recorderUi.pause.textContent = 'مکث';
|
||||
recorderUi.error.hidden = true;
|
||||
updateRecorderView('idle', 'برای توضیح سریعتر، صدای خود را ضبط کنید.');
|
||||
}
|
||||
|
||||
function completeRecording(blob, duration, automatic) {
|
||||
if (!blob.size) {
|
||||
clearVoice();
|
||||
recorderUi.error.textContent = 'صدایی ثبت نشد؛ لطفاً دوباره تلاش کنید.';
|
||||
recorderUi.error.hidden = false;
|
||||
return;
|
||||
}
|
||||
if (voiceUrl) URL.revokeObjectURL(voiceUrl);
|
||||
voiceBlob = blob;
|
||||
voiceUrl = URL.createObjectURL(blob);
|
||||
recorderUi.audio.src = voiceUrl;
|
||||
recorderUi.reviewAudio.src = voiceUrl;
|
||||
recorderUi.timer.textContent = formatDuration(duration);
|
||||
recorderUi.meta.textContent = `${formatDuration(duration)} · آماده پخش و ارسال`;
|
||||
updateRecorderView('preview', automatic ? 'ضبط هنگام ادامهدادن متوقف و ذخیره شد.' : 'ضبط با موفقیت ذخیره شد؛ آن را بررسی کنید.');
|
||||
}
|
||||
|
||||
async function startRecording() {
|
||||
recorderUi.error.hidden = true;
|
||||
if (!window.isSecureContext || !navigator.mediaDevices?.getUserMedia || !window.MediaRecorder) {
|
||||
recorderUi.error.textContent = 'مرورگر یا اتصال فعلی امکان ضبط امن صدا را ندارد.';
|
||||
recorderUi.error.hidden = false;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (voiceBlob) clearVoice();
|
||||
microphoneStream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true } });
|
||||
const mimeType = ['audio/webm;codecs=opus', 'audio/mp4', 'audio/ogg;codecs=opus', 'audio/webm']
|
||||
.find(type => MediaRecorder.isTypeSupported?.(type));
|
||||
recorder = new MediaRecorder(microphoneStream, mimeType ? { mimeType } : undefined);
|
||||
audioChunks = [];
|
||||
totalPausedMs = 0;
|
||||
discardOnStop = false;
|
||||
recorder.ondataavailable = event => { if (event.data.size) audioChunks.push(event.data); };
|
||||
recorder.onerror = () => {
|
||||
recorderUi.error.textContent = 'ضبط صدا متوقف شد. دوباره تلاش کنید.';
|
||||
recorderUi.error.hidden = false;
|
||||
clearVoice();
|
||||
};
|
||||
recorder.onstop = () => {
|
||||
const duration = elapsedRecordingTime();
|
||||
clearTimer();
|
||||
stopMicrophoneTracks();
|
||||
if (discardOnStop) clearVoice();
|
||||
else completeRecording(new Blob(audioChunks, { type: recorder.mimeType?.split(';')[0] || 'audio/webm' }), duration, recorderUi.root.dataset.autoStop === 'true');
|
||||
recorderUi.root.dataset.autoStop = 'false';
|
||||
recorder = null;
|
||||
stopResolver?.();
|
||||
stopResolver = null;
|
||||
};
|
||||
recorder.start(500);
|
||||
recordingStartedAt = Date.now();
|
||||
recorderUi.timer.textContent = '۰۰:۰۰';
|
||||
updateRecorderView('live', 'در حال ضبط؛ واضح و نزدیک به میکروفن صحبت کنید.');
|
||||
timerId = setInterval(() => { recorderUi.timer.textContent = formatDuration(elapsedRecordingTime()); }, 250);
|
||||
} catch (error) {
|
||||
stopMicrophoneTracks();
|
||||
recorderUi.error.textContent = error.name === 'NotAllowedError'
|
||||
? 'دسترسی میکروفن رد شد؛ آن را از تنظیمات مرورگر فعال کنید.'
|
||||
: 'میکروفن در دسترس نیست یا برنامه دیگری از آن استفاده میکند.';
|
||||
recorderUi.error.hidden = false;
|
||||
}
|
||||
}
|
||||
|
||||
function stopRecording({ discard = false, automatic = false } = {}) {
|
||||
if (!recorder || recorder.state === 'inactive') return Promise.resolve();
|
||||
discardOnStop = discard;
|
||||
recorderUi.root.dataset.autoStop = String(automatic);
|
||||
if (recorder.state === 'paused') {
|
||||
totalPausedMs += Date.now() - pausedStartedAt;
|
||||
recorder.resume();
|
||||
}
|
||||
return new Promise(resolve => { stopResolver = resolve; recorder.stop(); });
|
||||
}
|
||||
|
||||
function togglePause() {
|
||||
if (!recorder) return;
|
||||
if (recorder.state === 'recording') {
|
||||
recorder.pause();
|
||||
pausedStartedAt = Date.now();
|
||||
recorderUi.pause.textContent = 'ادامه ضبط';
|
||||
recorderUi.status.textContent = 'ضبط موقتاً متوقف شده است.';
|
||||
recorderUi.root.dataset.state = 'paused';
|
||||
} else if (recorder.state === 'paused') {
|
||||
totalPausedMs += Date.now() - pausedStartedAt;
|
||||
recorder.resume();
|
||||
recorderUi.pause.textContent = 'مکث';
|
||||
recorderUi.status.textContent = 'در حال ضبط؛ واضح و نزدیک به میکروفن صحبت کنید.';
|
||||
recorderUi.root.dataset.state = 'live';
|
||||
}
|
||||
}
|
||||
|
||||
function buildReview() {
|
||||
document.querySelector('#review-subject').textContent = subject.value;
|
||||
document.querySelector('#review-unit').textContent = unit.value;
|
||||
document.querySelector('#review-category').textContent = category.value;
|
||||
document.querySelector('#review-location').textContent = province.value ? [province.value, city.value].filter(Boolean).join('، ') : 'ثبت نشده';
|
||||
document.querySelector('#review-expert').textContent = document.querySelector('input[name="expert"]:checked').value;
|
||||
document.querySelector('#review-body').textContent = body.value.trim() || 'شرح متنی ثبت نشده است.';
|
||||
const list = document.querySelector('#review-files');
|
||||
list.replaceChildren();
|
||||
files.forEach(file => {
|
||||
const chip = document.createElement('span');
|
||||
chip.textContent = file.name;
|
||||
list.appendChild(chip);
|
||||
});
|
||||
if (!files.length && !voiceBlob) {
|
||||
const chip = document.createElement('span');
|
||||
chip.textContent = 'بدون پیوست';
|
||||
list.appendChild(chip);
|
||||
}
|
||||
recorderUi.reviewAudio.hidden = !voiceBlob;
|
||||
}
|
||||
|
||||
document.querySelectorAll('[data-next]').forEach(button => button.addEventListener('click', async () => {
|
||||
button.disabled = true;
|
||||
if (current === 4 && recorder && recorder.state !== 'inactive') await stopRecording({ automatic: true });
|
||||
button.disabled = false;
|
||||
if (!validateStep(current)) return;
|
||||
if (current === 4) buildReview();
|
||||
showStep(Math.min(5, current + 1));
|
||||
}));
|
||||
document.querySelectorAll('[data-prev]').forEach(button => button.addEventListener('click', async () => {
|
||||
if (current === 4 && recorder && recorder.state !== 'inactive') await stopRecording({ automatic: true });
|
||||
showStep(Math.max(1, current - 1));
|
||||
}));
|
||||
document.querySelectorAll('[data-edit]').forEach(button => button.addEventListener('click', () => showStep(Number(button.dataset.edit))));
|
||||
document.querySelector('#manual-toggle').addEventListener('click', event => {
|
||||
const target = document.querySelector('#manual-fields');
|
||||
target.hidden = !target.hidden;
|
||||
event.currentTarget.setAttribute('aria-expanded', String(!target.hidden));
|
||||
event.currentTarget.querySelector('span').textContent = target.hidden ? '⌄' : '⌃';
|
||||
});
|
||||
unit.addEventListener('change', () => setSelect(category, categories[unit.value] || [], 'انتخاب دستهبندی'));
|
||||
province.addEventListener('change', () => setSelect(city, cities[province.value] || [], 'انتخاب شهر'));
|
||||
document.querySelectorAll('[data-suggest-unit]').forEach(button => button.addEventListener('click', () => {
|
||||
unit.value = button.dataset.suggestUnit;
|
||||
setSelect(category, categories[unit.value] || [], 'انتخاب دستهبندی');
|
||||
category.value = button.dataset.suggestCategory;
|
||||
document.querySelector('#manual-fields').hidden = false;
|
||||
document.querySelector('#manual-toggle').setAttribute('aria-expanded', 'true');
|
||||
document.querySelectorAll('[data-suggest-unit]').forEach(item => item.classList.toggle('chosen', item === button));
|
||||
}));
|
||||
document.querySelector('#intent').addEventListener('input', event => {
|
||||
const query = event.target.value.trim();
|
||||
const suggestions = document.querySelector('#suggestions');
|
||||
const suggestionButtons = [...suggestions.querySelectorAll('[data-suggest-unit]')];
|
||||
suggestions.hidden = !query;
|
||||
if (!query) {
|
||||
suggestionButtons.forEach(button => { button.hidden = false; });
|
||||
document.querySelector('#suggestion-empty').hidden = true;
|
||||
return;
|
||||
}
|
||||
const normalizedQuery = query.toLocaleLowerCase('fa');
|
||||
let visibleCount = 0;
|
||||
suggestionButtons.forEach(button => {
|
||||
const matches = button.textContent.toLocaleLowerCase('fa').includes(normalizedQuery);
|
||||
button.hidden = !matches;
|
||||
if (matches) visibleCount += 1;
|
||||
});
|
||||
document.querySelector('#suggestion-empty').hidden = visibleCount > 0;
|
||||
});
|
||||
document.querySelectorAll('.expert input').forEach(input => input.addEventListener('change', () => {
|
||||
document.querySelectorAll('.expert').forEach(card => card.classList.toggle('selected', card.querySelector('input').checked));
|
||||
}));
|
||||
body.addEventListener('input', () => { document.querySelector('#body-count').textContent = toPersianDigits(body.value.length); });
|
||||
|
||||
function renderFiles() {
|
||||
const preview = document.querySelector('#file-preview');
|
||||
preview.replaceChildren();
|
||||
files.forEach((file, index) => {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'file-card';
|
||||
if (file.type.startsWith('image/')) {
|
||||
const image = document.createElement('img');
|
||||
image.src = URL.createObjectURL(file);
|
||||
image.onload = () => URL.revokeObjectURL(image.src);
|
||||
image.alt = `پیشنمایش ${file.name}`;
|
||||
card.appendChild(image);
|
||||
} else {
|
||||
const icon = document.createElement('div');
|
||||
icon.className = 'file-icon';
|
||||
icon.textContent = (file.name.split('.').pop() || 'FILE').toUpperCase();
|
||||
card.appendChild(icon);
|
||||
}
|
||||
const label = document.createElement('small');
|
||||
label.textContent = file.name;
|
||||
card.appendChild(label);
|
||||
const remove = document.createElement('button');
|
||||
remove.type = 'button';
|
||||
remove.textContent = '×';
|
||||
remove.setAttribute('aria-label', `حذف ${file.name}`);
|
||||
remove.addEventListener('click', () => { files.splice(index, 1); renderFiles(); });
|
||||
card.appendChild(remove);
|
||||
preview.appendChild(card);
|
||||
});
|
||||
}
|
||||
|
||||
document.querySelectorAll('#gallery,#documents').forEach(input => input.addEventListener('change', () => {
|
||||
files.push(...input.files);
|
||||
input.value = '';
|
||||
renderFiles();
|
||||
}));
|
||||
recorderUi.record.addEventListener('click', startRecording);
|
||||
recorderUi.pause.addEventListener('click', togglePause);
|
||||
recorderUi.stop.addEventListener('click', () => stopRecording());
|
||||
recorderUi.cancel.addEventListener('click', () => stopRecording({ discard: true }));
|
||||
recorderUi.remove.addEventListener('click', clearVoice);
|
||||
recorderUi.rerecord.addEventListener('click', startRecording);
|
||||
window.addEventListener('pagehide', () => { stopMicrophoneTracks(); clearTimer(); });
|
||||
form.addEventListener('submit', event => {
|
||||
event.preventDefault();
|
||||
steps.forEach(step => { step.hidden = true; });
|
||||
document.querySelector('#success-page').hidden = false;
|
||||
progress.forEach(item => item.classList.add('completed'));
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
});
|
||||
document.querySelector('#restart').addEventListener('click', () => {
|
||||
document.querySelector('#success-page').hidden = true;
|
||||
showStep(1);
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
@font-face{font-family:Vazirmatn;src:url("../../static/fonts/vazirmatn-arabic-wght-normal.woff2") format("woff2-variations");font-weight:100 900;font-display:swap}@font-face{font-family:Vazirmatn;src:url("../../static/fonts/vazirmatn-latin-wght-normal.woff2") format("woff2-variations");font-weight:100 900;font-display:swap}
|
||||
:root{font-family:Vazirmatn,ui-sans-serif,system-ui,sans-serif;color:#08376b;background:#eee;--ink:#08376b;--ink-soft:#0b4c8c;--sea:#0693e3;--sea-dark:#1977ce;--coral:#fa9f00;--cream:#eee;--soft:#eaeaea;--paper:#fff;--mint:#e5f4ff;--line:rgba(8,55,107,.1);--muted:rgba(8,55,107,.58);--shadow-panel:0 18px 55px -35px rgba(8,55,107,.35);--shadow-lift:0 22px 55px -28px rgba(6,147,227,.42)}
|
||||
*{box-sizing:border-box}html{scroll-behavior:smooth}body{margin:0;min-height:100vh;background:var(--cream);color:var(--ink);-webkit-font-smoothing:antialiased}button,input,textarea,select{font:inherit}button,input,textarea,select,a{outline:0}button:focus-visible,input:focus-visible,textarea:focus-visible,select:focus-visible,a:focus-visible{box-shadow:0 0 0 4px rgba(6,147,227,.2)}button{cursor:pointer}button:disabled{pointer-events:none;opacity:.5}[hidden]{display:none!important}.sr-only{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0)}
|
||||
.prototype-note{display:grid;gap:.05rem;padding:.55rem .8rem;background:#fff3d9;color:#805300;text-align:center;font-size:.68rem}.prototype-note span{font-weight:900}.prototype-note p{margin:0}.shell{width:100%;padding:0 .75rem 6.5rem}.topbar{display:flex;min-height:3.75rem;align-items:center;justify-content:space-between}.back-button,.manual-toggle{border:0;background:transparent;color:var(--sea-dark);font-size:.72rem;font-weight:850}.brand{display:none}.intro{margin:.8rem 0 1.1rem}.eyebrow{color:var(--sea);font-size:.68rem;font-weight:900;letter-spacing:.03em}.intro h1{margin:.3rem 0;color:var(--ink);font-size:1.45rem;line-height:1.45;letter-spacing:-.025em}.intro p{margin:0;color:var(--muted);font-size:.76rem;line-height:1.9}.save-state{display:none}
|
||||
.progress{position:relative;display:grid;grid-template-columns:repeat(5,1fr);margin:0 0 .75rem;padding:0;list-style:none}.progress:before{position:absolute;right:10%;left:10%;top:1.05rem;height:2px;background:rgba(8,55,107,.1);content:""}.progress li{position:relative;display:grid;justify-items:center;color:rgba(8,55,107,.35)}.progress li>span{z-index:1;display:grid;width:2.1rem;height:2.1rem;place-items:center;border:2px solid #d5dfe4;border-radius:.7rem;background:var(--cream);font-size:.68rem;font-weight:900}.progress li>div{display:none}.progress li.active>span{border-color:var(--sea);background:var(--sea);color:#fff;box-shadow:0 8px 20px -12px var(--sea)}.progress li.completed>span{border-color:#27a47a;background:#e5f7f0;color:#16815e}
|
||||
.wizard{border:1px solid var(--line);border-radius:1.1rem;background:var(--paper);box-shadow:var(--shadow-panel)}.step{padding:1rem}.step-head{display:flex;align-items:flex-start;gap:.65rem;border-bottom:1px solid rgba(8,55,107,.07);padding-bottom:1rem}.step-symbol{display:grid;width:2.4rem;height:2.4rem;flex:none;place-items:center;border-radius:.72rem;background:var(--mint);color:var(--sea-dark);font-size:1.05rem;font-weight:900}.step-symbol.success{background:#e5f8ef;color:#16815e}.step-head small{color:rgba(8,55,107,.42);font-size:.6rem;font-weight:700}.step-head h2{margin:.1rem 0;color:var(--ink);font-size:1rem;line-height:1.55}.step-head p{margin:0;color:var(--muted);font-size:.7rem;line-height:1.85}
|
||||
.search-box{display:flex;align-items:center;margin:1rem 0 .6rem;border:1px solid rgba(8,55,107,.14);border-radius:1rem;background:#fff;padding:0 .7rem;box-shadow:0 1px 3px rgba(8,55,107,.06)}.search-box:focus-within{border-color:var(--sea);box-shadow:0 0 0 4px rgba(6,147,227,.1)}.search-box i{color:var(--sea);font-style:normal}.search-box input{width:100%;min-width:0;min-height:3rem;border:0;background:transparent;padding:.65rem;font-size:.76rem;color:var(--ink)}.search-box kbd{display:none}.suggestions{display:grid;gap:.4rem}.suggestions button{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:.12rem .5rem;width:100%;border:1px solid rgba(8,55,107,.09);border-radius:.8rem;background:#fff;padding:.7rem;text-align:right;color:var(--ink)}.suggestions button:active,.suggestions button.chosen{border-color:rgba(6,147,227,.45);background:var(--mint)}.suggestions b{font-size:.7rem}.suggestions small{color:var(--muted);font-size:.6rem}.suggestions span{grid-column:2;grid-row:1/3;align-self:center;color:var(--sea-dark);font-size:.6rem;font-weight:900}.manual-toggle{display:flex;width:100%;justify-content:space-between;margin-top:.8rem;border-top:1px dashed rgba(8,55,107,.12);padding:.85rem .1rem .3rem}.manual-fields,.two-columns{display:grid;gap:.7rem;margin-top:.55rem}.manual-fields label,.two-columns label,.full-field{display:grid;gap:.35rem;color:rgba(8,55,107,.78);font-size:.7rem;font-weight:850}.manual-fields em,.full-field em{color:var(--coral);font-style:normal}.manual-fields select,.two-columns select,.full-field input,.full-field textarea{width:100%;min-height:3rem;border:1px solid rgba(8,55,107,.12);border-radius:1rem;background:#fff;padding:.7rem .8rem;color:var(--ink);font-size:.76rem;box-shadow:0 1px 3px rgba(8,55,107,.05)}.manual-fields select:focus,.two-columns select:focus,.full-field input:focus,.full-field textarea:focus{border-color:var(--sea);box-shadow:0 0 0 4px rgba(6,147,227,.1)}.full-field{margin-top:.85rem}.full-field textarea{min-height:9rem;resize:vertical;line-height:1.9}.full-field small{display:flex;justify-content:flex-end;color:rgba(8,55,107,.45);font-size:.6rem;font-weight:500}.validation{min-height:1rem;margin:.45rem 0 0;color:#a32929;font-size:.64rem;font-weight:700}
|
||||
.step-actions{position:sticky;z-index:10;bottom:4.65rem;display:grid;grid-template-columns:1fr 1.35fr;gap:.55rem;margin:1rem -1rem -1rem;border-top:1px solid rgba(8,55,107,.07);background:rgba(255,255,255,.96);padding:.7rem 1rem;backdrop-filter:blur(12px)}.step-actions>span{display:none}.primary,.secondary,.voice-box button,.success-page button{display:inline-flex;min-height:3rem;align-items:center;justify-content:center;gap:.45rem;border-radius:1rem;padding:.65rem .9rem;font-size:.72rem;font-weight:900}.primary{border:0;background:var(--sea);color:#fff;box-shadow:0 10px 30px -14px var(--sea)}.primary:active{background:var(--sea-dark)}.secondary{border:1px solid var(--line);background:#fff;color:rgba(8,55,107,.7);box-shadow:none}.step-actions .primary:only-of-type{grid-column:2}.soft-tip{display:flex;align-items:center;gap:.65rem;margin:1rem 0;border-radius:.85rem;background:var(--mint);padding:.75rem;color:rgba(8,55,107,.65)}.soft-tip>span{display:grid;width:1.75rem;height:1.75rem;flex:none;place-items:center;border-radius:.55rem;background:#fff;color:var(--sea-dark);font-weight:900}.soft-tip p{margin:0;font-size:.65rem}.soft-tip b{display:block;color:var(--ink)}
|
||||
.expert-list{display:grid;gap:.55rem;margin:1rem 0}.expert{position:relative;display:grid;grid-template-columns:auto minmax(0,1fr) auto;align-items:center;gap:.7rem;border:1px solid var(--line);border-radius:1rem;background:#fff;padding:.72rem;text-align:right}.expert.selected{border:2px solid var(--sea);background:var(--mint)}.expert input{position:absolute;opacity:0}.expert>i{display:none;width:1.3rem;height:1.3rem;place-items:center;border-radius:50%;background:var(--sea);color:#fff;font-size:.58rem;font-style:normal}.expert.selected>i{display:grid}.avatar{display:grid;width:2.7rem;height:2.7rem;place-items:center;border-radius:.85rem;background:#e4eef8;color:#285b87;font-weight:950}.avatar.queue{background:#e4f6ef;color:#16815e}.avatar.amber{background:#fff1d7;color:#9a6200}.expert b,.expert small{display:block}.expert b{font-size:.72rem}.expert small{margin-top:.12rem;color:var(--muted);font-size:.59rem}
|
||||
.attachment-zone{display:grid;gap:.7rem;margin-top:.85rem;border:2px dashed rgba(8,55,107,.14);border-radius:1rem;background:rgba(229,244,255,.45);padding:.85rem}.attachment-head,.voice-box,.voice-box>div{display:flex;align-items:center;gap:.65rem}.attachment-head>span{display:grid;width:2.35rem;height:2.35rem;flex:none;place-items:center;border-radius:.72rem;background:var(--mint);color:var(--sea-dark);font-size:1.05rem;font-weight:900}.attachment-head b,.attachment-head small,.voice-box b,.voice-box small{display:block}.attachment-head b,.voice-box b{font-size:.7rem}.attachment-head small,.voice-box small{margin-top:.1rem;color:var(--muted);font-size:.58rem}.attachment-actions{display:grid;gap:.5rem}.attachment-actions label{display:grid;min-height:3rem;place-items:center;border:1px solid rgba(6,147,227,.38);border-radius:.9rem;background:#fff;color:#0b5f9b;font-size:.7rem;font-weight:900;cursor:pointer}.attachment-actions label:first-child{border-color:var(--sea);background:var(--mint)}.file-preview{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:.45rem}.file-card{position:relative;overflow:hidden;border:1px solid var(--line);border-radius:.8rem;background:#fff;padding:.4rem}.file-card img,.file-card .file-icon{width:100%;height:76px;border-radius:.6rem}.file-card img{object-fit:cover}.file-card .file-icon{display:grid;place-items:center;background:var(--soft);color:rgba(8,55,107,.62);font-size:.66rem;font-weight:900}.file-card small{display:block;overflow:hidden;margin-top:.3rem;color:rgba(8,55,107,.7);font-size:.54rem;text-overflow:ellipsis;white-space:nowrap}.file-card button{position:absolute;top:.18rem;left:.18rem;display:grid;width:1.6rem;height:1.6rem;place-items:center;border:0;border-radius:.5rem;background:var(--coral);color:var(--ink);font-weight:900}.voice-box{justify-content:space-between;margin-top:.7rem;border:1px solid rgba(6,147,227,.14);border-radius:1rem;background:rgba(229,244,255,.55);padding:.75rem}.voice-box>div>span{color:var(--coral)}.voice-box button{min-height:2.5rem;border:1px solid var(--line);background:#fff;color:var(--ink);font-size:.65rem}
|
||||
.review-grid{display:grid;gap:.55rem;margin:1rem 0}.review-grid article{border:1px solid var(--line);border-radius:1rem;background:#fff;padding:.8rem;box-shadow:0 8px 24px -24px rgba(8,55,107,.7)}.review-grid header{display:flex;align-items:center;justify-content:space-between}.review-grid header>b{color:var(--muted);font-size:.63rem}.review-grid header button{border:0;background:transparent;color:var(--sea-dark);font-size:.62rem;font-weight:900}.review-grid h3{margin:.45rem 0 .15rem;color:var(--ink);font-size:.8rem}.review-grid p{display:flex;align-items:center;gap:.35rem;margin:0;color:var(--muted);font-size:.62rem;line-height:1.8}.review-grid p i{font-style:normal}.review-body{color:rgba(8,55,107,.75)!important;white-space:pre-wrap}.review-files{display:flex;flex-wrap:wrap;gap:.3rem;margin-top:.55rem}.review-files span{border-radius:999px;background:var(--mint);padding:.28rem .5rem;color:var(--sea-dark);font-size:.58rem}.confirm{display:flex;align-items:flex-start;gap:.65rem;border:1px solid rgba(39,164,122,.25);border-radius:1rem;background:#f3fbf8;padding:.75rem}.confirm input{width:1.1rem;height:1.1rem;accent-color:#16815e}.confirm b,.confirm small{display:block}.confirm b{color:#245d4a;font-size:.68rem}.confirm small{margin-top:.12rem;color:#66877b;font-size:.58rem}.success-page{padding:3rem 1rem;text-align:center}.success-page>span{display:grid;width:3.7rem;height:3.7rem;margin:auto;place-items:center;border-radius:1.1rem;background:#e1f7ed;color:#16815e;font-size:1.7rem;font-weight:900}.success-page h2{margin:.9rem 0 .25rem;font-size:1.1rem}.success-page p{color:var(--muted);font-size:.7rem}.success-page button{margin-top:.8rem;border:0;background:var(--ink);color:#fff}.mobile-dock{position:fixed;z-index:50;right:.75rem;bottom:.75rem;left:.75rem;display:grid;grid-template-columns:repeat(3,1fr);border-radius:1.35rem;background:rgba(8,55,107,.97);padding:.35rem;box-shadow:0 20px 50px -18px rgba(8,55,107,.75);backdrop-filter:blur(14px)}.mobile-dock button{display:flex;min-height:3rem;flex-direction:column;align-items:center;justify-content:center;gap:.05rem;border:0;border-radius:1rem;background:transparent;color:rgba(255,255,255,.6);font-size:.58rem;font-weight:800}.mobile-dock button span{font-size:.9rem}.mobile-dock button.active{background:var(--sea);color:#fff}
|
||||
@media(min-width:640px){.shell{width:min(100% - 2.5rem,72rem);padding-bottom:3rem}.topbar{min-height:4.5rem}.brand{display:grid;text-align:left}.brand span{font-size:.9rem;font-weight:900}.brand small{color:var(--muted);font-size:.58rem}.intro{display:flex;align-items:flex-end;justify-content:space-between;gap:2rem;margin:1.1rem 0 1.35rem}.intro h1{font-size:1.9rem}.intro p{font-size:.8rem}.save-state{display:flex;align-items:center;gap:.45rem;color:var(--muted);font-size:.65rem}.save-state i{width:.48rem;height:.48rem;border-radius:50%;background:#19a774;box-shadow:0 0 0 .23rem rgba(25,167,116,.1)}.progress{margin-bottom:1rem}.progress:before{top:1.12rem}.progress li{display:flex;justify-content:center;gap:.5rem}.progress li>span{width:2.3rem;height:2.3rem}.progress li>div{display:block}.progress li b,.progress li small{display:block}.progress li b{font-size:.68rem}.progress li small{margin-top:.08rem;font-size:.55rem}.wizard{border-radius:1.6rem}.step{padding:1.35rem 1.5rem}.step-head{gap:.8rem}.step-symbol{width:2.7rem;height:2.7rem}.step-head h2{font-size:1.1rem}.step-head p{font-size:.74rem}.search-box{margin-top:1.15rem}.search-box kbd{display:inline;border:1px solid var(--line);border-radius:.4rem;background:var(--cream);padding:.2rem .4rem;color:var(--muted);font-size:.56rem}.manual-fields,.two-columns{grid-template-columns:1fr 1fr}.expert-list{grid-template-columns:repeat(3,1fr)}.expert{display:grid;grid-template-columns:1fr;justify-items:center;padding:.9rem;text-align:center}.expert>i{position:absolute;top:.5rem;left:.5rem}.avatar{width:3.15rem;height:3.15rem}.expert b{margin-top:.5rem}.attachment-actions{grid-template-columns:1fr 1fr}.file-preview{grid-template-columns:repeat(3,minmax(0,1fr))}.review-grid{grid-template-columns:1fr 1fr}.review-grid article.wide{grid-column:1/-1}.step-actions{position:static;display:flex;justify-content:flex-end;margin:1.15rem -1.5rem -1.35rem;padding:.8rem 1.5rem}.step-actions>span{display:block;margin-left:auto;color:rgba(8,55,107,.4);font-size:.6rem}.step-actions .primary:only-of-type{margin-right:auto}.primary,.secondary{min-width:8.5rem}.final-actions{justify-content:space-between}.mobile-dock{display:none}}
|
||||
@media(min-width:1024px){.shell{width:min(100% - 4rem,65rem)}.intro h1{font-size:2.15rem}.wizard{margin-inline:auto}.step{padding:1.6rem 1.8rem}.step-actions{margin:1.3rem -1.8rem -1.6rem;padding:.85rem 1.8rem}.step-head h2{font-size:1.2rem}.suggestions{grid-template-columns:1fr 1fr}.review-grid{gap:.7rem}.file-preview{grid-template-columns:repeat(4,minmax(0,1fr))}}
|
||||
@media(prefers-reduced-motion:reduce){html{scroll-behavior:auto}*{transition:none!important}}
|
||||
|
||||
/* Voice recorder: mobile-first states with playback and safe removal. */
|
||||
.voice-recorder{margin-top:.8rem;overflow:hidden;border:1px solid rgba(6,147,227,.2);border-radius:1.05rem;background:linear-gradient(145deg,#f7fcff,#edf7ff);box-shadow:0 12px 32px -28px rgba(8,55,107,.75)}
|
||||
.voice-head{display:grid;grid-template-columns:auto minmax(0,1fr) auto;align-items:center;gap:.65rem;padding:.8rem}
|
||||
.voice-icon{display:grid;width:2.35rem;height:2.35rem;place-items:center;border-radius:.75rem;background:#fff;color:var(--sea);font-size:1rem;box-shadow:0 8px 20px -16px rgba(8,55,107,.8)}
|
||||
.voice-head b,.voice-head small,.voice-ready b,.voice-ready small{display:block}.voice-head b{font-size:.72rem}.voice-head small{margin-top:.12rem;color:var(--muted);font-size:.58rem;line-height:1.7}
|
||||
.voice-timer{direction:ltr;border:1px solid rgba(6,147,227,.16);border-radius:999px;background:#fff;padding:.28rem .5rem;color:var(--ink);font-size:.64rem;font-weight:900;font-variant-numeric:tabular-nums}
|
||||
.voice-idle,.voice-live,.voice-preview{border-top:1px solid rgba(8,55,107,.07);padding:.75rem .8rem}
|
||||
.voice-idle{display:grid;gap:.35rem}.voice-idle>small{color:var(--muted);font-size:.56rem;text-align:center}
|
||||
.voice-start{display:flex;min-height:3rem;align-items:center;justify-content:center;gap:.45rem;border:0;border-radius:.9rem;background:var(--sea);color:#fff;font-size:.7rem;font-weight:900;box-shadow:0 12px 28px -18px var(--sea)}.voice-start span{color:#fff;font-size:.85rem}
|
||||
.voice-wave{display:flex;height:2.8rem;align-items:center;justify-content:center;gap:.24rem}.voice-wave i{width:.22rem;height:.75rem;border-radius:999px;background:var(--sea);animation:voice-wave .9s ease-in-out infinite}.voice-wave i:nth-child(2),.voice-wave i:nth-child(6){height:1.35rem;animation-delay:.12s}.voice-wave i:nth-child(3),.voice-wave i:nth-child(5){height:2rem;animation-delay:.24s}.voice-wave i:nth-child(4){height:2.5rem;animation-delay:.36s}.voice-recorder[data-state="paused"] .voice-wave i{animation-play-state:paused;opacity:.4}
|
||||
@keyframes voice-wave{0%,100%{transform:scaleY(.55)}50%{transform:scaleY(1)}}
|
||||
.voice-controls{display:grid;grid-template-columns:1fr 1.5fr;gap:.45rem}.voice-controls button,.voice-preview-actions button{min-height:2.65rem;border:1px solid var(--line);border-radius:.8rem;background:#fff;color:var(--ink);font-size:.63rem;font-weight:850}.voice-controls .voice-stop{border-color:var(--sea);background:var(--sea);color:#fff}.voice-controls .voice-cancel{grid-column:1/-1;min-height:2rem;border:0;background:transparent;color:#9c3042}
|
||||
.voice-preview{display:grid;gap:.65rem;background:#fff}.voice-ready{display:flex;align-items:center;gap:.55rem}.voice-ready>span{display:grid;width:1.8rem;height:1.8rem;flex:none;place-items:center;border-radius:.55rem;background:#e5f8ef;color:#16815e;font-size:.65rem;font-weight:900}.voice-ready b{font-size:.68rem}.voice-ready small{color:var(--muted);font-size:.56rem}.voice-preview audio,.review-audio{width:100%;height:2.6rem;accent-color:var(--sea)}.voice-preview-actions{display:grid;grid-template-columns:1fr 1fr;gap:.45rem}.voice-preview-actions #delete-voice{border-color:rgba(169,39,60,.18);color:#a9273c}
|
||||
.voice-note{display:flex;align-items:flex-start;gap:.35rem;margin:0;border-top:1px dashed rgba(8,55,107,.1);padding:.6rem .8rem;color:var(--muted);font-size:.55rem;line-height:1.7}.voice-note span{color:var(--sea-dark)}.voice-error{margin:0;padding:.55rem .8rem;background:#fff0f1;color:#9c3042;font-size:.6rem;font-weight:800}
|
||||
|
||||
/* Review: one calm scan path instead of a dashboard of competing cards. */
|
||||
.review-callout{display:flex;align-items:center;gap:.65rem;margin:.85rem 0 .65rem;border-radius:.9rem;background:#f0faf6;padding:.7rem}.review-callout>span{display:grid;width:2rem;height:2rem;flex:none;place-items:center;border-radius:.65rem;background:#fff;color:#16815e;font-weight:900}.review-callout b,.review-callout small{display:block}.review-callout b{font-size:.68rem}.review-callout small{margin-top:.1rem;color:#66877b;font-size:.56rem;line-height:1.65}
|
||||
.review-list{overflow:hidden;border:1px solid var(--line);border-radius:1rem;background:#fff}.review-row{display:grid;grid-template-columns:auto minmax(0,1fr) auto;align-items:start;gap:.6rem;padding:.75rem}.review-row+.review-row{border-top:1px solid rgba(8,55,107,.07)}.review-icon{display:grid;width:1.8rem;height:1.8rem;place-items:center;border-radius:.55rem;background:var(--mint);color:var(--sea-dark);font-size:.58rem;font-weight:900}.review-content{min-width:0}.review-content>small{color:var(--muted);font-size:.55rem;font-weight:800}.review-content h3{overflow-wrap:anywhere;margin:.16rem 0 .08rem;color:var(--ink);font-size:.73rem;line-height:1.6}.review-content p{display:flex;flex-wrap:wrap;align-items:center;gap:.3rem;margin:0;color:var(--muted);font-size:.58rem;line-height:1.75}.review-content p i{font-style:normal}.review-row>button{display:flex;min-height:2rem;align-items:center;gap:.25rem;border:0;border-radius:.65rem;background:var(--mint);padding:.35rem .5rem;color:var(--sea-dark);font-size:.56rem;font-weight:900}.review-row>button span{font-size:.7rem}.review-details .review-content{grid-column:2/-1}.review-details>button{grid-column:3;grid-row:1}.review-body{display:block!important;margin-top:.2rem!important;color:rgba(8,55,107,.78)!important;white-space:pre-wrap}.review-files{display:flex;flex-wrap:wrap;gap:.3rem;margin-top:.45rem}.review-files span{border-radius:999px;background:var(--mint);padding:.28rem .5rem;color:var(--sea-dark);font-size:.56rem}.review-audio{margin-top:.55rem}
|
||||
|
||||
@media(min-width:640px){.voice-recorder{margin-top:1rem}.voice-head{padding:.9rem 1rem}.voice-idle,.voice-live,.voice-preview{padding:.85rem 1rem}.voice-idle{grid-template-columns:auto 1fr;align-items:center}.voice-idle>small{text-align:right}.voice-start{min-width:11rem}.voice-live{grid-template-columns:1fr auto;align-items:center}.voice-controls{grid-template-columns:auto auto auto}.voice-controls .voice-cancel{grid-column:auto;min-height:2.65rem}.voice-controls button{min-width:6.5rem}.voice-note{padding:.65rem 1rem}.review-callout{padding:.8rem 1rem}.review-row{gap:.8rem;padding:.9rem 1rem}.review-row>button{padding:.4rem .65rem}.review-content h3{font-size:.78rem}.review-details .review-content{grid-column:2}.review-details>button{grid-column:3}.review-audio{max-width:28rem}}
|
||||
|
||||
@media(prefers-reduced-motion:reduce){.voice-wave i{animation:none}}
|
||||
|
||||
/* Readability pass: comfortable mobile text, 16px form controls, and clear hierarchy. */
|
||||
body{font-size:16px;line-height:1.75}.prototype-note{font-size:.76rem;line-height:1.65}.back-button,.manual-toggle{font-size:.82rem}.eyebrow{font-size:.76rem}.intro p{font-size:.86rem;line-height:1.85}.step-head small{font-size:.7rem}.step-head h2{font-size:1.08rem;line-height:1.6}.step-head p{font-size:.82rem;line-height:1.85}.search-box input,.manual-fields select,.two-columns select,.full-field input,.full-field textarea{font-size:1rem}.suggestions b{font-size:.84rem}.suggestions small{font-size:.73rem}.suggestions span{font-size:.72rem}.suggestion-empty{grid-column:1/-1;margin:0;border:1px dashed rgba(8,55,107,.14);border-radius:.8rem;background:#fafcfd;padding:.75rem;color:var(--muted);font-size:.78rem;line-height:1.7;text-align:center}.manual-fields label,.two-columns label,.full-field{font-size:.82rem}.full-field small{font-size:.7rem}.validation{font-size:.74rem;line-height:1.7}.primary,.secondary,.success-page button{font-size:.82rem}.soft-tip p{font-size:.76rem;line-height:1.75}.expert b{font-size:.82rem}.expert small{font-size:.7rem;line-height:1.65}.attachment-head b{font-size:.82rem}.attachment-head small{font-size:.7rem;line-height:1.65}.attachment-actions label{font-size:.8rem}.file-card small{font-size:.68rem}.voice-head b{font-size:.84rem}.voice-head small{font-size:.72rem}.voice-timer{font-size:.75rem}.voice-idle>small{font-size:.69rem}.voice-start{font-size:.82rem}.voice-controls button,.voice-preview-actions button{font-size:.75rem}.voice-ready b{font-size:.8rem}.voice-ready small{font-size:.7rem}.voice-note{font-size:.68rem}.voice-error{font-size:.72rem}.review-callout b{font-size:.82rem}.review-callout small{font-size:.7rem}.review-content>small{font-size:.7rem}.review-content h3{font-size:.9rem;line-height:1.7}.review-content p{font-size:.72rem;line-height:1.8}.review-row>button{font-size:.68rem}.review-files span{font-size:.68rem}.confirm b{font-size:.8rem}.confirm small{font-size:.7rem;line-height:1.7}.success-page p{font-size:.8rem}.mobile-dock button{font-size:.68rem}
|
||||
#review-subject{font-size:1rem;font-weight:900;line-height:1.8}.review-body{font-size:.88rem!important;font-weight:550;line-height:2!important}
|
||||
|
||||
@media(min-width:640px){.brand span{font-size:1rem}.brand small{font-size:.7rem}.intro p{font-size:.9rem}.save-state{font-size:.76rem}.progress li b{font-size:.78rem}.progress li small{font-size:.67rem}.step-head h2{font-size:1.2rem}.step-head p{font-size:.86rem}.review-content h3{font-size:.96rem}#review-subject{font-size:1.08rem}.review-body{font-size:.94rem!important}.review-row>button{font-size:.72rem}}
|
||||
|
||||
/* The decisive action stays visible throughout the review step. */
|
||||
.review-step{padding-bottom:6.75rem}.final-actions{position:fixed;z-index:40;right:.75rem;bottom:4.85rem;left:.75rem;display:grid;grid-template-columns:minmax(0,.78fr) minmax(0,1.4fr);gap:.5rem;margin:0;border:1px solid rgba(8,55,107,.12);border-radius:1.1rem;background:rgba(255,255,255,.96);padding:.55rem;box-shadow:0 20px 50px -20px rgba(8,55,107,.55);backdrop-filter:blur(14px)}.final-actions .primary,.final-actions .secondary{min-width:0}.final-actions .send{box-shadow:0 12px 28px -14px var(--sea)}
|
||||
@media(min-width:640px){.review-step{padding-bottom:6rem}.final-actions{right:auto;bottom:1rem;left:50%;width:min(calc(100% - 2rem),42rem);transform:translateX(-50%);padding:.6rem}.final-actions .primary,.final-actions .secondary{min-height:3.15rem}}
|
||||
|
||||
/* Validation feedback is a visible destination, not a quiet line below the form. */
|
||||
.validation:empty{display:none}.validation.visible{position:relative;min-height:0;margin:.8rem 0 0;border:1px solid rgba(169,39,60,.2);border-radius:.85rem;background:#fff1f3;padding:.72rem 2.2rem .72rem .75rem;color:#92263a;font-size:.78rem;font-weight:850;line-height:1.8;box-shadow:0 10px 28px -24px rgba(146,38,58,.75)}.validation.visible:before{position:absolute;top:.75rem;right:.75rem;display:grid;width:1.15rem;height:1.15rem;place-items:center;border-radius:50%;background:#a9273c;color:#fff;font-size:.66rem;content:"!"}.validation.visible:focus{box-shadow:0 0 0 4px rgba(169,39,60,.13)}[aria-invalid="true"]{border-color:#b8324a!important;background:#fffafb!important;box-shadow:0 0 0 3px rgba(184,50,74,.1)!important}
|
||||
@@ -0,0 +1,163 @@
|
||||
import base64
|
||||
|
||||
import pytest
|
||||
from playwright.sync_api import expect
|
||||
|
||||
|
||||
pytest_plugins = ["tests.e2e.conftest"]
|
||||
pytestmark = pytest.mark.e2e
|
||||
|
||||
|
||||
def test_validation_message_is_focused_and_scrolled_into_view(browser_page):
|
||||
with browser_page() as page:
|
||||
page.set_viewport_size({"width": 390, "height": 620})
|
||||
page.goto(
|
||||
"http://127.0.0.1:4173/design-prototypes/ticket-new-wizard/",
|
||||
wait_until="domcontentloaded",
|
||||
)
|
||||
page.locator('[data-step="1"] [data-next]').click()
|
||||
error = page.locator('[data-error="1"]')
|
||||
expect(error).to_be_visible()
|
||||
expect(error).to_contain_text("واحد، دستهبندی، موضوع")
|
||||
expect(error).to_be_in_viewport()
|
||||
expect(page.locator("#unit")).to_have_attribute("aria-invalid", "true")
|
||||
expect(page.locator("#category")).to_have_attribute("aria-invalid", "true")
|
||||
expect(page.locator("#subject")).to_have_attribute("aria-invalid", "true")
|
||||
assert page.evaluate("document.activeElement === document.querySelector('[data-error=\"1\"]')")
|
||||
|
||||
|
||||
def test_ticket_wizard_progressive_flow_preview_edit_and_attachment_removal(browser_page):
|
||||
tiny_png=base64.b64decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=")
|
||||
with browser_page() as page:
|
||||
page.set_viewport_size({"width":390,"height":844})
|
||||
page.goto("http://127.0.0.1:4173/design-prototypes/ticket-new-wizard/",wait_until="domcontentloaded")
|
||||
expect(page.locator('[data-step="1"]')).to_be_visible()
|
||||
expect(page.locator('[data-step="2"]')).to_be_hidden()
|
||||
expect(page.locator(".mobile-dock")).to_be_visible()
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= document.documentElement.clientWidth + 1")
|
||||
|
||||
expect(page.locator("#suggestions")).to_be_hidden()
|
||||
page.locator("#intent").fill("فیش")
|
||||
expect(page.locator("#suggestions")).to_be_visible()
|
||||
expect(page.get_by_role("button",name="ارسال فیش یا رسید پرداخت")).to_be_visible()
|
||||
expect(page.get_by_role("button",name="هماهنگی ارسال یا بارگیری")).to_be_hidden()
|
||||
assert page.locator("#intent").evaluate("element => parseFloat(getComputedStyle(element).fontSize)") >= 16
|
||||
page.get_by_role("button",name="ارسال فیش یا رسید پرداخت").click()
|
||||
expect(page.locator("#unit")).to_have_value("حسابداری")
|
||||
expect(page.locator("#category")).to_have_value("فیش واریزی")
|
||||
expect(page.locator("#subject")).to_have_value("")
|
||||
page.locator("#subject").fill("پیگیری فیش واریزی سفارش")
|
||||
page.locator("#unit").select_option(label="فروش")
|
||||
expect(page.locator("#subject")).to_have_value("پیگیری فیش واریزی سفارش")
|
||||
page.locator("#unit").select_option(label="حسابداری")
|
||||
page.locator("#category").select_option(label="فیش واریزی")
|
||||
page.locator('[data-step="1"] [data-next]').click()
|
||||
expect(page.locator('[data-step="2"]')).to_be_visible()
|
||||
|
||||
page.locator('[data-step="2"] [data-next]').click()
|
||||
expect(page.locator('[data-step="3"]')).to_be_visible()
|
||||
page.locator('[data-step="3"] [data-next]').click()
|
||||
expect(page.locator('[data-step="4"]')).to_be_visible()
|
||||
|
||||
page.locator("#body").fill("فیش واریزی سفارش را برای بررسی ارسال کردهام.")
|
||||
page.locator("#gallery").set_input_files({"name":"receipt.png","mimeType":"image/png","buffer":tiny_png})
|
||||
page.locator("#documents").set_input_files({"name":"invoice.pdf","mimeType":"application/pdf","buffer":b"%PDF-1.4\n%%EOF"})
|
||||
expect(page.locator("#file-preview .file-card")).to_have_count(2)
|
||||
page.get_by_role("button",name="حذف receipt.png").click()
|
||||
expect(page.locator("#file-preview .file-card")).to_have_count(1)
|
||||
|
||||
page.get_by_role("button",name="مشاهده پیشنمایش").click()
|
||||
expect(page.locator('[data-step="5"]')).to_be_visible()
|
||||
expect(page.locator("#review-subject")).to_have_text("پیگیری فیش واریزی سفارش")
|
||||
assert page.locator("#review-subject").evaluate("element => parseFloat(getComputedStyle(element).fontSize)") >= 16
|
||||
assert page.locator("#review-body").evaluate("element => parseFloat(getComputedStyle(element).fontSize)") >= 14
|
||||
expect(page.locator("#review-files")).to_have_text("invoice.pdf")
|
||||
page.locator('[data-edit="4"]').click()
|
||||
expect(page.locator('[data-step="4"]')).to_be_visible()
|
||||
page.get_by_role("button",name="مشاهده پیشنمایش").click()
|
||||
send_button = page.get_by_role("button",name="ثبت و ارسال نهایی")
|
||||
expect(send_button).to_be_visible()
|
||||
expect(send_button).to_be_enabled()
|
||||
send_box = send_button.bounding_box()
|
||||
assert send_box and send_box["y"] + send_box["height"] <= page.viewport_size["height"]
|
||||
send_button.click()
|
||||
expect(page.locator("#success-page")).to_be_visible()
|
||||
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= document.documentElement.clientWidth + 1")
|
||||
|
||||
page.set_viewport_size({"width":1280,"height":900})
|
||||
expect(page.locator(".mobile-dock")).to_be_hidden()
|
||||
expect(page.locator(".progress li > div").first).to_be_visible()
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= document.documentElement.clientWidth + 1")
|
||||
|
||||
|
||||
def test_voice_recording_auto_stops_on_next_and_can_be_previewed_and_removed(browser_page):
|
||||
with browser_page() as page:
|
||||
page.add_init_script(
|
||||
"""
|
||||
window.__microphoneTrackStops = 0;
|
||||
Object.defineProperty(navigator, 'mediaDevices', {
|
||||
configurable: true,
|
||||
value: {
|
||||
getUserMedia: async () => ({
|
||||
getTracks: () => [{ stop: () => { window.__microphoneTrackStops += 1; } }],
|
||||
}),
|
||||
},
|
||||
});
|
||||
class FakeMediaRecorder {
|
||||
constructor(stream, options = {}) {
|
||||
this.stream = stream;
|
||||
this.mimeType = options.mimeType || 'audio/webm';
|
||||
this.state = 'inactive';
|
||||
}
|
||||
start() { this.state = 'recording'; }
|
||||
pause() { this.state = 'paused'; }
|
||||
resume() { this.state = 'recording'; }
|
||||
stop() {
|
||||
this.state = 'inactive';
|
||||
this.ondataavailable?.({ data: new Blob(['recorded voice'], { type: this.mimeType }) });
|
||||
setTimeout(() => this.onstop?.(), 0);
|
||||
}
|
||||
static isTypeSupported() { return true; }
|
||||
}
|
||||
Object.defineProperty(window, 'MediaRecorder', { configurable: true, value: FakeMediaRecorder });
|
||||
"""
|
||||
)
|
||||
page.set_viewport_size({"width": 390, "height": 844})
|
||||
page.goto(
|
||||
"http://127.0.0.1:4173/design-prototypes/ticket-new-wizard/",
|
||||
wait_until="domcontentloaded",
|
||||
)
|
||||
|
||||
page.locator("#intent").fill("فیش")
|
||||
page.get_by_role("button", name="ارسال فیش یا رسید پرداخت").click()
|
||||
expect(page.locator("#subject")).to_have_value("")
|
||||
page.locator("#subject").fill("موضوع دستی پیام صوتی")
|
||||
page.locator('[data-step="1"] [data-next]').click()
|
||||
page.locator('[data-step="2"] [data-next]').click()
|
||||
page.locator('[data-step="3"] [data-next]').click()
|
||||
expect(page.locator('[data-step="4"]')).to_be_visible()
|
||||
|
||||
page.get_by_role("button", name="شروع ضبط صدا").click()
|
||||
expect(page.locator("#voice-live")).to_be_visible()
|
||||
page.get_by_role("button", name="مکث").click()
|
||||
expect(page.get_by_role("button", name="ادامه ضبط")).to_be_visible()
|
||||
page.get_by_role("button", name="ادامه ضبط").click()
|
||||
|
||||
page.get_by_role("button", name="مشاهده پیشنمایش").click()
|
||||
expect(page.locator('[data-step="5"]')).to_be_visible()
|
||||
expect(page.locator("#review-audio")).to_be_visible()
|
||||
assert page.evaluate("window.__microphoneTrackStops") == 1
|
||||
|
||||
page.locator('[data-edit="4"]').click()
|
||||
expect(page.locator("#voice-preview")).to_be_visible()
|
||||
assert page.locator("#audio").get_attribute("src").startswith("blob:")
|
||||
page.get_by_role("button", name="حذف پیام صوتی").click()
|
||||
expect(page.locator("#voice-idle")).to_be_visible()
|
||||
expect(page.locator("#voice-preview")).to_be_hidden()
|
||||
page.get_by_role("button", name="مشاهده پیشنمایش").click()
|
||||
error = page.locator('[data-error="4"]')
|
||||
expect(error).to_contain_text("توضیح بنویسید")
|
||||
expect(error).to_be_in_viewport()
|
||||
expect(page.locator("#body")).to_have_attribute("aria-invalid", "true")
|
||||
assert page.evaluate("document.activeElement === document.querySelector('[data-error=\"4\"]')")
|
||||
+5
-1
@@ -33,7 +33,7 @@ x-django-service: &django-service
|
||||
SMS0098_USERNAME: ${SMS0098_USERNAME:-}
|
||||
SMS0098_PASSWORD: ${SMS0098_PASSWORD:-}
|
||||
SMS0098_FROM: ${SMS0098_FROM:-}
|
||||
SMS0098_API_URL: ${SMS0098_API_URL:-http://www.0098sms.com/sendsmslink.aspx}
|
||||
SMS0098_WSDL_URL: ${SMS0098_WSDL_URL:-https://webservice.0098sms.com/service.asmx?WSDL}
|
||||
BEHIN_SMS_USERNAME: ${BEHIN_SMS_USERNAME:-}
|
||||
BEHIN_SMS_PASSWORD: ${BEHIN_SMS_PASSWORD:-}
|
||||
BEHIN_SMS_SPECIAL_NUMBER: ${BEHIN_SMS_SPECIAL_NUMBER:-}
|
||||
@@ -45,6 +45,10 @@ x-django-service: &django-service
|
||||
BALE_BOT_TOKEN: ${BALE_BOT_TOKEN:-}
|
||||
INTEGRATION_HTTP_TIMEOUT_SECONDS: ${INTEGRATION_HTTP_TIMEOUT_SECONDS:-10}
|
||||
MAX_NOTIFICATION_ATTEMPTS: ${MAX_NOTIFICATION_ATTEMPTS:-5}
|
||||
SMS_MAX_RETRIES: ${SMS_MAX_RETRIES:-3}
|
||||
SMS_RETRY_DELAY_SECONDS: ${SMS_RETRY_DELAY_SECONDS:-180}
|
||||
SMS_DELIVERY_CHECK_DELAY_SECONDS: ${SMS_DELIVERY_CHECK_DELAY_SECONDS:-180}
|
||||
SMS_DELIVERY_MAX_CHECKS: ${SMS_DELIVERY_MAX_CHECKS:-15}
|
||||
SECURE_SSL_REDIRECT: ${SECURE_SSL_REDIRECT:-true}
|
||||
SECURE_HSTS_SECONDS: ${SECURE_HSTS_SECONDS:-31536000}
|
||||
SECURE_HSTS_INCLUDE_SUBDOMAINS: ${SECURE_HSTS_INCLUDE_SUBDOMAINS:-true}
|
||||
|
||||
@@ -80,7 +80,7 @@ SMS_PROVIDER_CLASS=apps.integrations.sms.sms0098.ZeroZeroNinetyEightSMSProvider
|
||||
SMS0098_USERNAME=<username>
|
||||
SMS0098_PASSWORD=<password>
|
||||
SMS0098_FROM=<sender-number>
|
||||
SMS0098_API_URL=http://www.0098sms.com/sendsmslink.aspx
|
||||
SMS0098_WSDL_URL=https://webservice.0098sms.com/service.asmx?WSDL
|
||||
```
|
||||
|
||||
### Behin SMS
|
||||
@@ -109,11 +109,15 @@ BALE_API_BASE=https://tapi.bale.ai
|
||||
BALE_BOT_TOKEN=<bot-token>
|
||||
INTEGRATION_HTTP_TIMEOUT_SECONDS=10
|
||||
MAX_NOTIFICATION_ATTEMPTS=5
|
||||
SMS_MAX_RETRIES=3
|
||||
SMS_RETRY_DELAY_SECONDS=180
|
||||
SMS_DELIVERY_CHECK_DELAY_SECONDS=180
|
||||
SMS_DELIVERY_MAX_CHECKS=15
|
||||
```
|
||||
|
||||
در production نبودن `BALE_BOT_TOKEN` خطای قابلمشاهده ایجاد میکند و job را بیصدا SENT علامت نمیزند. شناسه گروه دیگر یک env سراسری نیست: برای هر واحد از مسیر `x5admin → واحدهای سازمانی`، فیلد «شناسه گروه بله» را جداگانه وارد کنید. خالی بودن این فیلد یعنی اعلانهای گروهی تیکت جدید و پاسخ مشتری برای همان واحد ارسال نمیشود؛ سامانه هرگز به گروه واحد دیگر fallback نمیکند. سه شماره اختیاری سرپرست نیز در همین صفحه تنظیم میشوند و فقط هنگام ایجاد تیکت جدید، پیامک کوتاه دریافت میکنند.
|
||||
|
||||
worker هر پنج ثانیه outbox دیتابیس را بررسی میکند، با قفل `skip_locked` از پردازش همزمان جلوگیری میکند و خطاها را با backoff تا سقف `MAX_NOTIFICATION_ATTEMPTS` دوباره امتحان میکند.
|
||||
worker هر پنج ثانیه outbox دیتابیس را بررسی میکند و با قفل `skip_locked` از پردازش همزمان جلوگیری میکند. هر پیامک پس از شکست اولیه، دقیقاً `SMS_MAX_RETRIES=3` بار با فاصله ثابت `SMS_RETRY_DELAY_SECONDS=180` ثانیه دوباره امتحان میشود. `MAX_NOTIFICATION_ATTEMPTS` همچنان سقف تلاش کانالهای دیگر مانند بله است. پیامک دارای شناسه رهگیری پس از `SMS_DELIVERY_CHECK_DELAY_SECONDS` استعلام میشود؛ پاسخهای موقت 0098SMS (کدهای 6 و 8) هر سه دقیقه تا 15 نوبت، یعنی 45 دقیقه، دوباره بررسی میشوند.
|
||||
|
||||
سیاست تأخیر پیامک پاسخ کارشناس و اعلان پاسخ مشتری در Bale از مدل `NotificationPolicy` در پنل مدیریت دیتابیس کنترل میشود؛ متغیر قدیمی `STAFF_REPLY_SMS_THROTTLE_SECONDS` مرجع اصلی این رفتار نیست.
|
||||
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
.ticket-wizard-page{width:min(100%,65rem);margin-inline:auto;padding:0 0 6.5rem;color:#08376b}
|
||||
.ticket-wizard-topbar{display:flex;min-height:3rem;align-items:center;justify-content:space-between;gap:1rem}.ticket-wizard-back{color:#1977ce;font-size:.82rem;font-weight:850}.ticket-wizard-save{display:none;align-items:center;gap:.45rem;color:rgba(8,55,107,.62);font-size:.76rem}.ticket-wizard-save i{width:.48rem;height:.48rem;border-radius:50%;background:#19a774;box-shadow:0 0 0 .23rem rgba(25,167,116,.1)}
|
||||
.ticket-wizard-intro{margin:.65rem 0 1.1rem}.ticket-wizard-intro .page-kicker{margin:0;color:#0693e3;font-size:.76rem;font-weight:900}.ticket-wizard-intro h1{margin:.25rem 0;font-size:1.5rem;line-height:1.5}.ticket-wizard-intro>p:last-child{margin:0;color:rgba(8,55,107,.62);font-size:.86rem;line-height:1.9}
|
||||
.ticket-wizard-progress{position:relative;display:grid;grid-template-columns:repeat(5,1fr);margin:0 0 .8rem;padding:0;list-style:none}.ticket-wizard-progress:before{position:absolute;top:1.05rem;right:10%;left:10%;height:2px;background:rgba(8,55,107,.1);content:""}.ticket-wizard-progress li{position:relative;display:grid;justify-items:center;color:rgba(8,55,107,.35)}.ticket-wizard-progress li>span{z-index:1;display:grid;width:2.1rem;height:2.1rem;place-items:center;border:2px solid #d5dfe4;border-radius:.7rem;background:#eee;font-size:.72rem;font-weight:900}.ticket-wizard-progress li>div{display:none}.ticket-wizard-progress li.is-active>span{border-color:#0693e3;background:#0693e3;color:#fff;box-shadow:0 8px 20px -12px #0693e3}.ticket-wizard-progress li.is-complete>span{border-color:#27a47a;background:#e5f7f0;color:#16815e}
|
||||
.ticket-wizard-card{overflow:visible;border:1px solid rgba(8,55,107,.1);border-radius:1.15rem;background:#fff;box-shadow:0 18px 55px -35px rgba(8,55,107,.35)}.ticket-wizard-step{padding:1rem}.ticket-wizard-card[data-wizard-enhanced] .ticket-wizard-step:not(.is-active){display:none}.ticket-wizard-step-head{display:flex;align-items:flex-start;gap:.7rem;border-bottom:1px solid rgba(8,55,107,.07);padding-bottom:1rem}.ticket-wizard-symbol{display:grid;width:2.45rem;height:2.45rem;flex:none;place-items:center;border-radius:.75rem;background:#e5f4ff;color:#1977ce;font-size:1.05rem;font-weight:900}.ticket-wizard-symbol.is-success{background:#e5f8ef;color:#16815e}.ticket-wizard-step-head small{color:rgba(8,55,107,.48);font-size:.7rem;font-weight:750}.ticket-wizard-step-head h2{margin:.08rem 0;color:#08376b;font-size:1.08rem;line-height:1.6}.ticket-wizard-step-head p{margin:0;color:rgba(8,55,107,.62);font-size:.82rem;line-height:1.85}
|
||||
.ticket-intent-search{display:flex;align-items:center;margin:1rem 0 .65rem;border:1px solid rgba(8,55,107,.14);border-radius:1rem;background:#fff;padding:0 .75rem;box-shadow:0 1px 3px rgba(8,55,107,.06)}.ticket-intent-search:focus-within{border-color:#0693e3;box-shadow:0 0 0 4px rgba(6,147,227,.1)}.ticket-intent-search i{color:#0693e3;font-style:normal}.ticket-intent-search input{min-height:3.2rem;border:0!important;background:transparent!important;padding:.7rem!important;font-size:1rem!important;box-shadow:none!important}.ticket-intent-search kbd{display:none}.ticket-intent-results:empty{display:none}.ticket-intent-results{margin-bottom:.65rem}.ticket-intent-results .intent-result{font-size:.84rem}.ticket-intent-results .intent-result span{font-size:.75rem}.ticket-intent-results .intent-result small,.ticket-intent-results .intent-message{font-size:.73rem;line-height:1.7}
|
||||
.ticket-manual-routing{margin:.75rem 0!important;border-color:rgba(8,55,107,.1)!important;border-radius:1rem!important}.ticket-manual-routing summary{background:#fafcfd!important;padding:.8rem!important;font-size:.8rem;font-weight:850}.ticket-manual-routing summary small{font-size:.7rem!important}.ticket-manual-body{padding:.8rem!important}.ticket-routing-grid,.ticket-two-columns{display:grid;gap:.7rem}.ticket-wizard-page .field{margin-bottom:.8rem}.ticket-wizard-page label{font-size:.82rem}.ticket-wizard-page input:not([type=checkbox]):not([type=radio]),.ticket-wizard-page textarea,.ticket-wizard-page select{min-height:3.1rem;font-size:1rem}.ticket-subject-field{margin-top:.85rem!important}.ticket-subject-field label em{color:#fa9f00;font-style:normal}.ticket-subject-field>small,.ticket-body-field>small{display:flex;justify-content:flex-end;margin-top:.35rem;color:rgba(8,55,107,.5);font-size:.7rem;font-weight:500}.ticket-body-field textarea{min-height:9rem;line-height:1.9}.ticket-wizard-page .errorlist{margin:.35rem 0 0;padding:.5rem .65rem;border-radius:.7rem;background:#fff1f3;color:#92263a;font-size:.74rem;font-weight:750;list-style:none}
|
||||
.ticket-location-fields{margin-top:1rem}.ticket-location-fields .location-hint{margin:.2rem 0;color:rgba(8,55,107,.55);font-size:.72rem}.ticket-soft-tip{display:flex;align-items:center;gap:.65rem;margin:.8rem 0;border-radius:.85rem;background:#e5f4ff;padding:.75rem;color:rgba(8,55,107,.68)}.ticket-soft-tip>span{display:grid;width:1.75rem;height:1.75rem;flex:none;place-items:center;border-radius:.55rem;background:#fff;color:#1977ce;font-weight:900}.ticket-soft-tip p{margin:0;font-size:.76rem;line-height:1.75}.ticket-soft-tip b{color:#08376b}
|
||||
.ticket-expert-picker{display:grid!important;grid-template-columns:1fr!important;gap:.55rem!important;margin:1rem 0!important}.ticket-expert-picker .expert-card{display:grid!important;min-height:0!important;grid-template-columns:auto minmax(0,1fr) auto;align-items:center;gap:.7rem;border-radius:1rem!important;padding:.72rem!important;text-align:right!important}.ticket-expert-picker .expert-card:has(input:checked){border-color:#0693e3;background:#e5f4ff}.ticket-expert-picker .expert-card img,.ticket-expert-picker .expert-avatar{width:2.8rem!important;height:2.8rem!important;margin:0!important;border-radius:.85rem!important}.ticket-expert-picker .expert-card strong,.ticket-expert-picker .expert-card small{display:block}.ticket-expert-picker .expert-card strong{font-size:.82rem}.ticket-expert-picker .expert-card small{margin-top:.1rem;font-size:.7rem;line-height:1.6}.ticket-expert-picker .expert-card:after{display:none;place-items:center;width:1.3rem;height:1.3rem;border-radius:50%;background:#0693e3;color:#fff;font-size:.6rem;font-weight:900;content:"✓"}.ticket-expert-picker .expert-card:has(input:checked):after{display:grid}
|
||||
.ticket-attachment-zone{display:grid;gap:.7rem;margin-top:.8rem!important;border-color:rgba(8,55,107,.14)!important;border-radius:1rem!important;background:rgba(229,244,255,.45)!important;padding:.85rem!important;text-align:right!important}.ticket-attachment-head{display:flex;align-items:center;gap:.65rem}.ticket-attachment-head>span{display:grid;width:2.35rem;height:2.35rem;flex:none;place-items:center;border-radius:.72rem;background:#fff;color:#1977ce;font-size:1.05rem;font-weight:900}.ticket-attachment-head b,.ticket-attachment-head small{display:block}.ticket-attachment-head b{font-size:.82rem}.ticket-attachment-head small{margin-top:.1rem;color:rgba(8,55,107,.58);font-size:.7rem;line-height:1.65}.ticket-attachment-actions{display:grid!important;gap:.5rem!important}.ticket-attachment-actions label{display:grid;min-height:3rem;place-items:center;border:1px solid rgba(6,147,227,.38);border-radius:.9rem;background:#fff;color:#0b5f9b;font-size:.8rem;font-weight:900}.ticket-attachment-actions .gallery-button{background:#e5f4ff}.ticket-attachment-zone .compression-note{font-size:.7rem;line-height:1.65}.ticket-attachment-zone .file-chip{font-size:.72rem}.ticket-attachment-zone .file-chip button{min-width:1.7rem;min-height:1.7rem}.ticket-attachment-zone .canonical-files{display:none}
|
||||
.ticket-voice-recorder{margin-top:.8rem;overflow:hidden;border:1px solid rgba(6,147,227,.2);border-radius:1.05rem;background:linear-gradient(145deg,#f7fcff,#edf7ff);box-shadow:0 12px 32px -28px rgba(8,55,107,.75)}.ticket-voice-head{display:grid;grid-template-columns:auto minmax(0,1fr) auto;align-items:center;gap:.65rem;padding:.8rem}.ticket-voice-icon{display:grid;width:2.35rem;height:2.35rem;place-items:center;border-radius:.75rem;background:#fff;color:#0693e3;font-size:1rem}.ticket-voice-head b,.ticket-voice-head small,.ticket-voice-ready b,.ticket-voice-ready small{display:block}.ticket-voice-head b{font-size:.84rem}.ticket-voice-head small{margin-top:.12rem;color:rgba(8,55,107,.58);font-size:.72rem;line-height:1.7}.ticket-voice-timer{direction:ltr;border:1px solid rgba(6,147,227,.16);border-radius:999px;background:#fff;padding:.28rem .5rem;font-size:.75rem;font-weight:900;font-variant-numeric:tabular-nums}.ticket-voice-idle,.ticket-voice-live,.ticket-voice-preview{border-top:1px solid rgba(8,55,107,.07);padding:.75rem .8rem}.ticket-voice-idle{display:grid;gap:.35rem}.ticket-voice-idle>small{color:rgba(8,55,107,.58);font-size:.69rem;text-align:center}.ticket-voice-start{display:flex;min-height:3rem;align-items:center;justify-content:center;gap:.45rem;border:0;border-radius:.9rem;background:#0693e3;color:#fff;font-size:.82rem;font-weight:900}.ticket-voice-wave{display:flex;height:2.8rem;align-items:center;justify-content:center;gap:.24rem}.ticket-voice-wave i{width:.22rem;height:.75rem;border-radius:999px;background:#0693e3;animation:ticket-voice-wave .9s ease-in-out infinite}.ticket-voice-wave i:nth-child(2),.ticket-voice-wave i:nth-child(6){height:1.35rem;animation-delay:.12s}.ticket-voice-wave i:nth-child(3),.ticket-voice-wave i:nth-child(5){height:2rem;animation-delay:.24s}.ticket-voice-wave i:nth-child(4){height:2.5rem;animation-delay:.36s}.ticket-voice-recorder[data-state="paused"] .ticket-voice-wave i{animation-play-state:paused;opacity:.4}@keyframes ticket-voice-wave{0%,100%{transform:scaleY(.55)}50%{transform:scaleY(1)}}.ticket-voice-controls,.ticket-voice-preview-actions{display:grid;grid-template-columns:1fr 1.5fr;gap:.45rem}.ticket-voice-controls button,.ticket-voice-preview-actions button{min-height:2.65rem;border:1px solid rgba(8,55,107,.1);border-radius:.8rem;background:#fff;color:#08376b;font-size:.75rem;font-weight:850}.ticket-voice-controls .ticket-voice-stop{border-color:#0693e3;background:#0693e3;color:#fff}.ticket-voice-controls .ticket-voice-cancel{grid-column:1/-1;min-height:2rem;border:0;background:transparent;color:#9c3042}.ticket-voice-preview{display:grid;gap:.65rem;background:#fff}.ticket-voice-ready{display:flex;align-items:center;gap:.55rem}.ticket-voice-ready>span{display:grid;width:1.8rem;height:1.8rem;flex:none;place-items:center;border-radius:.55rem;background:#e5f8ef;color:#16815e;font-size:.65rem;font-weight:900}.ticket-voice-ready b{font-size:.8rem}.ticket-voice-ready small{color:rgba(8,55,107,.58);font-size:.7rem}.ticket-voice-preview audio,.ticket-review-audio{width:100%;height:2.6rem;accent-color:#0693e3}.ticket-voice-preview-actions{grid-template-columns:1fr 1fr}.ticket-voice-preview-actions [data-voice-delete]{border-color:rgba(169,39,60,.18);color:#a9273c}.ticket-voice-note{display:flex;gap:.35rem;margin:0;border-top:1px dashed rgba(8,55,107,.1);padding:.6rem .8rem;color:rgba(8,55,107,.58);font-size:.68rem;line-height:1.7}.ticket-voice-note span{color:#1977ce}.ticket-voice-error{margin:0;padding:.55rem .8rem;background:#fff0f1;color:#9c3042;font-size:.72rem;font-weight:800}
|
||||
.ticket-server-errors:empty{display:none}.ticket-wizard-validation:empty{display:none}.ticket-wizard-validation.is-visible{position:relative;margin:.8rem 0 0;border:1px solid rgba(169,39,60,.2);border-radius:.85rem;background:#fff1f3;padding:.72rem 2.2rem .72rem .75rem;color:#92263a;font-size:.78rem;font-weight:850;line-height:1.8}.ticket-wizard-validation.is-visible:before{position:absolute;top:.75rem;right:.75rem;display:grid;width:1.15rem;height:1.15rem;place-items:center;border-radius:50%;background:#a9273c;color:#fff;font-size:.66rem;content:"!"}.ticket-wizard-validation.is-visible:focus{box-shadow:0 0 0 4px rgba(169,39,60,.13)}.ticket-wizard-page [aria-invalid="true"]{border-color:#b8324a!important;background:#fffafb!important;box-shadow:0 0 0 3px rgba(184,50,74,.1)!important}
|
||||
.ticket-wizard-actions{position:sticky;z-index:10;bottom:4.8rem;display:flex;justify-content:flex-end;gap:.55rem;margin:1rem -1rem -1rem;border-top:1px solid rgba(8,55,107,.07);background:rgba(255,255,255,.97);padding:.7rem 1rem;backdrop-filter:blur(12px)}.ticket-wizard-actions>span{display:none}.ticket-wizard-actions button{min-height:3rem;border-radius:1rem;padding:.65rem .9rem;font-size:.82rem;font-weight:900}.ticket-wizard-actions .primary{border:0;background:#0693e3;color:#fff}.ticket-wizard-actions .secondary{border:1px solid rgba(8,55,107,.1);background:#fff;color:rgba(8,55,107,.72)}.ticket-wizard-card:not([data-wizard-enhanced]) [data-wizard-next],.ticket-wizard-card:not([data-wizard-enhanced]) [data-wizard-prev]{display:none}
|
||||
.ticket-review-callout{display:flex;align-items:center;gap:.65rem;margin:.85rem 0 .65rem;border-radius:.9rem;background:#f0faf6;padding:.7rem}.ticket-review-callout>span{display:grid;width:2rem;height:2rem;flex:none;place-items:center;border-radius:.65rem;background:#fff;color:#16815e;font-weight:900}.ticket-review-callout b,.ticket-review-callout small{display:block}.ticket-review-callout b{font-size:.82rem}.ticket-review-callout small{margin-top:.1rem;color:#66877b;font-size:.7rem;line-height:1.65}.ticket-review-list{overflow:hidden;border:1px solid rgba(8,55,107,.1);border-radius:1rem;background:#fff}.ticket-review-row{display:grid;grid-template-columns:auto minmax(0,1fr) auto;align-items:start;gap:.6rem;padding:.75rem}.ticket-review-row+.ticket-review-row{border-top:1px solid rgba(8,55,107,.07)}.ticket-review-icon{display:grid;width:1.8rem;height:1.8rem;place-items:center;border-radius:.55rem;background:#e5f4ff;color:#1977ce;font-size:.65rem;font-weight:900}.ticket-review-content{min-width:0}.ticket-review-content>small{color:rgba(8,55,107,.58);font-size:.7rem;font-weight:800}.ticket-review-content h3{overflow-wrap:anywhere;margin:.16rem 0 .08rem;color:#08376b;font-size:.9rem;line-height:1.7}.ticket-review-content [data-review-subject]{font-size:1rem;font-weight:900;line-height:1.8}.ticket-review-content p{display:flex;flex-wrap:wrap;align-items:center;gap:.3rem;margin:0;color:rgba(8,55,107,.62);font-size:.72rem;line-height:1.8}.ticket-review-content p i{font-style:normal}.ticket-review-row>button{display:flex;min-height:2rem;align-items:center;gap:.25rem;border:0;border-radius:.65rem;background:#e5f4ff;padding:.35rem .5rem;color:#1977ce;font-size:.68rem;font-weight:900}.ticket-review-details .ticket-review-content{grid-column:2/-1}.ticket-review-details>button{grid-column:3;grid-row:1}.ticket-review-body{display:block!important;margin-top:.2rem!important;color:rgba(8,55,107,.82)!important;font-size:.88rem!important;font-weight:550;line-height:2!important;white-space:pre-wrap}.ticket-review-files{display:flex;flex-wrap:wrap;gap:.3rem;margin-top:.45rem}.ticket-review-files span{border-radius:999px;background:#e5f4ff;padding:.28rem .5rem;color:#1977ce;font-size:.68rem}.ticket-review-audio{margin-top:.55rem}.ticket-review-step{padding-bottom:6.75rem}.ticket-wizard-card[data-wizard-enhanced] .ticket-final-actions{position:fixed;z-index:45;right:.75rem;bottom:5rem;left:.75rem;display:grid;grid-template-columns:minmax(0,.78fr) minmax(0,1.4fr);gap:.5rem;margin:0;border:1px solid rgba(8,55,107,.12);border-radius:1.1rem;background:rgba(255,255,255,.96);padding:.55rem;box-shadow:0 20px 50px -20px rgba(8,55,107,.55);backdrop-filter:blur(14px)}
|
||||
@media(min-width:640px){.ticket-wizard-page{padding-bottom:3rem}.ticket-wizard-topbar{min-height:3.8rem}.ticket-wizard-save{display:flex}.ticket-wizard-intro{margin:.8rem 0 1.35rem}.ticket-wizard-intro h1{font-size:2rem}.ticket-wizard-intro>p:last-child{font-size:.9rem}.ticket-wizard-progress{margin-bottom:1rem}.ticket-wizard-progress:before{top:1.12rem}.ticket-wizard-progress li{display:flex;justify-content:center;gap:.5rem}.ticket-wizard-progress li>span{width:2.3rem;height:2.3rem}.ticket-wizard-progress li>div{display:block}.ticket-wizard-progress li b,.ticket-wizard-progress li small{display:block}.ticket-wizard-progress li b{font-size:.78rem}.ticket-wizard-progress li small{font-size:.67rem}.ticket-wizard-card{border-radius:1.6rem}.ticket-wizard-step{padding:1.35rem 1.5rem}.ticket-wizard-step-head h2{font-size:1.2rem}.ticket-intent-search kbd{display:inline;border:1px solid rgba(8,55,107,.1);border-radius:.4rem;background:#eee;padding:.2rem .4rem;color:rgba(8,55,107,.58);font-size:.68rem}.ticket-routing-grid,.ticket-two-columns{grid-template-columns:1fr 1fr}.ticket-manual-body{padding:1rem!important}.ticket-expert-picker{grid-template-columns:repeat(3,1fr)!important}.ticket-expert-picker .expert-card{display:grid!important;grid-template-columns:1fr;justify-items:center;padding:.9rem!important;text-align:center!important}.ticket-expert-picker .expert-card img,.ticket-expert-picker .expert-avatar{width:3.3rem!important;height:3.3rem!important}.ticket-expert-picker .expert-card:after{position:absolute;top:.5rem;left:.5rem}.ticket-attachment-actions{grid-template-columns:1fr 1fr}.ticket-voice-idle{grid-template-columns:auto 1fr;align-items:center}.ticket-voice-idle>small{text-align:right}.ticket-voice-start{min-width:11rem}.ticket-voice-live{display:grid;grid-template-columns:1fr auto;align-items:center}.ticket-voice-controls{grid-template-columns:auto auto auto}.ticket-voice-controls .ticket-voice-cancel{grid-column:auto;min-height:2.65rem}.ticket-wizard-actions{position:static;margin:1.15rem -1.5rem -1.35rem;padding:.8rem 1.5rem}.ticket-wizard-actions>span{display:block;margin-left:auto;color:rgba(8,55,107,.45);font-size:.72rem}.ticket-wizard-actions button{min-width:8.5rem}.ticket-review-row{gap:.8rem;padding:.9rem 1rem}.ticket-review-details .ticket-review-content{grid-column:2}.ticket-review-content h3{font-size:.96rem}.ticket-review-content [data-review-subject]{font-size:1.08rem}.ticket-review-body{font-size:.94rem!important}.ticket-review-step{padding-bottom:6rem}.ticket-wizard-card[data-wizard-enhanced] .ticket-final-actions{position:fixed;right:auto;bottom:1rem;left:50%;width:min(calc(100% - 2rem),42rem);transform:translateX(-50%);padding:.6rem}}
|
||||
@media(prefers-reduced-motion:reduce){.ticket-voice-wave i{animation:none}}
|
||||
@@ -1,2 +1,19 @@
|
||||
.dispatch-metrics{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:.7rem}.dispatch-metrics article{display:grid;gap:.25rem;border:1px solid #dce7ef;border-radius:1rem;background:#fff;padding:.9rem 1rem;box-shadow:0 12px 30px -28px #08376b}.dispatch-metrics small{color:#718698;font-size:.62rem}.dispatch-metrics b{color:#123e62;font-size:1.25rem}.dispatch-filter{display:grid;gap:.65rem;border-bottom:1px solid #e3ebf1;background:#f8fbfd;padding:.8rem}.dispatch-filter>div{display:grid;grid-template-columns:1fr 1fr;gap:.5rem}.dispatch-filter label{display:grid;gap:.25rem;color:#536d81;font-size:.58rem;font-weight:900}.dispatch-filter input,.dispatch-filter select{width:100%;min-height:2.35rem;border:1px solid #cbd9e5;border-radius:.6rem;background:#fff;padding:.45rem .55rem;color:#173f60;font-size:.62rem}.dispatch-filter footer{display:flex;align-items:center;gap:.5rem}.dispatch-filter button{min-height:2.35rem;flex:1;border:0;border-radius:.6rem;background:#0875aa;color:#fff;font-size:.62rem;font-weight:900}.dispatch-filter footer a{color:#647b8d;font-size:.58rem;font-weight:900}.dispatch-results{display:grid;max-height:34rem;overflow:auto}.operations-pagination{display:flex;align-items:center;justify-content:space-between;border-top:1px solid #e5edf3;padding:.7rem .8rem;color:#667e91;font-size:.6rem}.operations-pagination a{border-radius:.55rem;background:#e8f4fb;padding:.4rem .65rem;color:#0873a9;font-weight:900}
|
||||
@media(max-width:700px){.dispatch-metrics{display:flex;overflow-x:auto}.dispatch-metrics article{min-width:8.5rem}}
|
||||
.dispatch-detail-head{align-items:flex-start}.dispatch-head-actions{display:flex;flex-wrap:wrap;align-items:center;gap:.55rem}.dispatch-head-actions form{margin:0}.button-danger-soft{background:#fff1f2!important;color:#a51d35!important;border:1px solid #fecdd3!important}.dispatch-original-message{display:grid;gap:1rem;border:1px solid #d8e6ef;border-radius:1rem;background:linear-gradient(145deg,#fff,#f5fbff);padding:1.1rem 1.2rem;box-shadow:0 16px 40px -36px #164f75}.dispatch-original-message header{display:flex;align-items:flex-start;justify-content:space-between;gap:1rem}.dispatch-original-message h2{margin:.18rem 0 0;color:#173f60;font-size:1rem}.dispatch-original-message header>span{color:#73889a;font-size:.62rem;direction:ltr}.dispatch-original-message>p{margin:0;border-right:3px solid #1490bb;padding:.7rem .9rem;color:#294c66;line-height:2;white-space:normal}.dispatch-detail-metrics{grid-template-columns:repeat(6,minmax(0,1fr))}.dispatch-detail-metrics small{display:block;margin-top:.25rem;color:#788c9c;font-size:.55rem}.dispatch-sms-state{min-width:15rem}.delivery-badge{display:inline-flex;border-radius:99rem;background:#e8f0f5;padding:.3rem .58rem;color:#435f73;font-size:.58rem}.delivery-delivered{background:#dcfce7;color:#166534}.delivery-undelivered,.delivery-expired,.delivery-blocked,.delivery-error,.delivery-failed{background:#fee2e2;color:#991b1b}.delivery-pending,.delivery-processing,.delivery-retrying{background:#fef3c7;color:#92400e}.delivery-unknown,.delivery-check_failed{background:#f1f5f9;color:#475569}.delivery-detail{max-width:24rem;margin:.45rem 0 0;color:#4b6477;font-size:.58rem;line-height:1.7}.sms-error-detail{max-width:24rem;margin-top:.45rem;border:1px solid #fecaca;border-radius:.55rem;background:#fff7f7;padding:.4rem .5rem;color:#8d1e2d;font-size:.57rem}.sms-error-detail summary{cursor:pointer;font-weight:900}.sms-error-detail p{margin:.35rem 0 0;line-height:1.7}.inline-retry-form{margin-top:.5rem}.inline-retry-form button{border:1px solid #f1b7bf;border-radius:.5rem;background:#fff;color:#a51d35;padding:.38rem .55rem;font:inherit;font-size:.56rem;font-weight:900;cursor:pointer}
|
||||
@media(max-width:1100px){.dispatch-detail-metrics{grid-template-columns:repeat(3,minmax(0,1fr))}}
|
||||
@media(max-width:700px){.dispatch-detail-head,.dispatch-original-message header{display:grid}.dispatch-head-actions{width:100%}.dispatch-head-actions>*{flex:1}.dispatch-detail-metrics{display:flex;overflow-x:auto}.dispatch-detail-metrics article{min-width:9rem}}
|
||||
|
||||
/* Dispatch report: human-readable status first, technical gateway data second. */
|
||||
.dispatch-report-hero{display:flex;align-items:flex-start;justify-content:space-between;gap:1.25rem;border:1px solid #d8e6ef;border-radius:1.2rem;background:linear-gradient(135deg,#f8fcff 0%,#fff 55%,#eff9fc 100%);padding:1.35rem;box-shadow:0 20px 50px -42px #0c4269}.dispatch-report-title{display:grid;gap:.7rem}.dispatch-title-line{display:flex;align-items:center;gap:.55rem}.dispatch-report-title h1{margin:0;color:#123b5b;font-size:1.45rem}.report-status{display:inline-flex;border-radius:99rem;background:#e7f0f5;padding:.34rem .7rem;color:#426177;font-size:.58rem;font-weight:900}.report-status.status-sent{background:#dcfce7;color:#166534}.report-status.status-partial{background:#fff0e1;color:#9a4f06}.dispatch-meta-list{display:flex;flex-wrap:wrap;gap:.5rem}.dispatch-meta-list span{display:flex;align-items:center;gap:.35rem;border:1px solid #deebf2;border-radius:.65rem;background:#fff;padding:.42rem .6rem;color:#557084;font-size:.59rem}.dispatch-meta-list b{color:#204c6d}.dispatch-original-message blockquote{margin:0;border:0;border-right:3px solid #1490bb;border-radius:.35rem;background:rgba(255,255,255,.72);padding:.8rem 1rem;color:#294c66;line-height:2}.dispatch-original-message footer{display:flex;flex-wrap:wrap;justify-content:space-between;gap:.5rem;border-top:1px dashed #d9e7ef;padding-top:.75rem;color:#6d8292;font-size:.57rem}.delivery-overview{display:grid;grid-template-columns:auto minmax(12rem,1fr) minmax(14rem,.7fr);align-items:center;gap:1rem;border:1px solid #d8e7ee;border-radius:1rem;background:#fff;padding:1rem 1.15rem}.delivery-progress-copy{min-width:12rem}.delivery-progress-copy strong{display:block;margin:.1rem 0;color:#0878a8;font-size:1.8rem}.delivery-progress-copy p{margin:0;color:#688092;font-size:.58rem}.delivery-progress-track{height:.75rem;overflow:hidden;border-radius:99rem;background:#e9f1f5;box-shadow:inset 0 1px 2px #cfdde6}.delivery-progress-track span{display:block;height:100%;border-radius:inherit;background:linear-gradient(90deg,#13a36f,#42c895);transition:width .3s ease}.delivery-overview-note{display:grid;gap:.2rem;border-right:1px solid #e1eaf0;padding-right:1rem}.delivery-overview-note b{color:#294f69;font-size:.66rem}.delivery-overview-note span{color:#728797;font-size:.56rem;line-height:1.7}.dispatch-kpi-grid{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:.7rem}.dispatch-kpi{display:flex;align-items:center;gap:.7rem;border:1px solid #dce8ef;border-radius:.9rem;background:#fff;padding:.85rem;box-shadow:0 14px 34px -32px #123f61}.kpi-icon{display:grid;width:2rem;height:2rem;flex:0 0 auto;place-items:center;border-radius:.65rem;background:#eaf2f7;color:#44657a;font-weight:900}.dispatch-kpi>div{min-width:0}.dispatch-kpi small,.dispatch-kpi p{display:block;margin:0;color:#778b99;font-size:.53rem}.dispatch-kpi strong{display:block;margin:.08rem 0;color:#183f5c;font-size:1.22rem}.kpi-success .kpi-icon{background:#dcfce7;color:#16734c}.kpi-info .kpi-icon{background:#e0f2fe;color:#0878a8}.kpi-danger .kpi-icon{background:#fee2e2;color:#a51d35}.kpi-view .kpi-icon{background:#ede9fe;color:#6d3fc0}.kpi-reply .kpi-icon{background:#fef3c7;color:#96600d}.dispatch-recipient-report{overflow:hidden}.dispatch-detail-table{border-collapse:separate;border-spacing:0;width:100%}.dispatch-detail-table thead th{position:sticky;top:0;z-index:2;background:#f5f9fc;color:#526c80;font-size:.56rem}.dispatch-detail-table tbody tr{transition:background .15s ease}.dispatch-detail-table tbody tr:hover{background:#f9fcfe}.dispatch-detail-table td{vertical-align:top;padding:.9rem .7rem;border-bottom:1px solid #e8eff3}.recipient-cell{display:flex;align-items:center;gap:.55rem;min-width:9rem}.recipient-avatar{display:grid;width:2rem;height:2rem;flex:0 0 auto;place-items:center;border-radius:.7rem;background:linear-gradient(145deg,#dff4fb,#bfe8f5);color:#0875a3;font-weight:900}.recipient-cell div{display:grid;gap:.17rem}.recipient-cell strong{color:#1e4866;font-size:.63rem}.recipient-cell small{color:#738896;font-size:.55rem}.ticket-reference{display:grid;gap:.22rem;text-decoration:none}.ticket-reference strong{color:#176993;font-size:.6rem}.ticket-reference span{color:#7b8d99;font-size:.52rem}.dispatch-sms-state{min-width:18rem}.sms-state-card{display:flex;align-items:flex-start;gap:.55rem;border:1px solid #dfe9ef;border-radius:.75rem;background:#f8fbfd;padding:.65rem}.sms-state-icon{display:grid;width:1.65rem;height:1.65rem;flex:0 0 auto;place-items:center;border-radius:.55rem;background:#e5eef4;color:#3f6075;font-weight:900}.sms-state-card>div{display:grid;gap:.18rem}.sms-state-card strong{color:#254c68;font-size:.61rem}.sms-state-card p,.sms-state-card small{margin:0;color:#718694;font-size:.53rem;line-height:1.65}.tone-success{border-color:#bcebd3;background:#f2fcf7}.tone-success .sms-state-icon{background:#d6f7e6;color:#17734d}.tone-warning{border-color:#f2d7a0;background:#fffaf0}.tone-warning .sms-state-icon{background:#feefc9;color:#93600c}.tone-danger{border-color:#f3c7cd;backgroundLine truncated
|
||||
@media(max-width:1100px){.dispatch-kpi-grid{grid-template-columns:repeat(3,minmax(0,1fr))}.delivery-overview{grid-template-columns:auto 1fr}.delivery-overview-note{grid-column:1/-1;border-top:1px solid #e5edf2;border-right:0;padding: .7rem 0 0}}
|
||||
@media(max-width:760px){.dispatch-report-hero{display:grid}.dispatch-report-title h1{font-size:1.15rem}.dispatch-head-actions{display:grid}.dispatch-kpi-grid{display:flex;overflow-x:auto;padding-bottom:.25rem}.dispatch-kpi{min-width:10.5rem}.delivery-overview{grid-template-columns:1fr}.delivery-progress-copy{min-width:0}.dispatch-detail-table thead{display:none}.dispatch-detail-table,.dispatch-detail-table tbody,.dispatch-detail-table tr,.dispatch-detail-table td{display:block;width:100%}.dispatch-detail-table tr{border-bottom:8px solid #edf3f6}.dispatch-detail-table td{display:grid;grid-template-columns:6.5rem 1fr;gap:.6rem;border-bottom:1px solid #edf2f5}.dispatch-detail-table td:before{content:attr(data-label);color:#8799a5;font-size:.52rem;font-weight:900}.dispatch-sms-state{min-width:0}.dispatch-detail-table td>*{min-width:0}}
|
||||
|
||||
/* Readable report typography and a compact delivery summary. */
|
||||
.operations-dispatch-detail{font-size:1rem}.operations-dispatch-detail .eyebrow,.operations-dispatch-detail .panel-index{font-size:.72rem}.operations-dispatch-detail .report-status{font-size:.72rem}.operations-dispatch-detail .dispatch-meta-list span{gap:.45rem;font-size:.76rem}.operations-dispatch-detail .dispatch-original-message header>span,.operations-dispatch-detail .dispatch-original-message footer{font-size:.72rem}.operations-dispatch-detail .dispatch-original-message blockquote{font-size:.86rem}.operations-dispatch-detail .delivery-overview{display:grid;grid-template-columns:minmax(0,1fr) minmax(12rem,.42fr);gap:.7rem 1rem;padding:.75rem 1rem}.operations-dispatch-detail .delivery-progress-copy{display:flex;align-items:center;justify-content:space-between;gap:1rem;min-width:0}.operations-dispatch-detail .delivery-progress-copy>div{display:flex;align-items:center;gap:.55rem}.operations-dispatch-detail .delivery-progress-copy strong{display:inline;margin:0;font-size:1rem}.operations-dispatch-detail .delivery-progress-copy p{font-size:.74rem}.operations-dispatch-detail .delivery-progress-track{grid-column:1;height:.42rem}.operations-dispatch-detail .delivery-overview-note{grid-column:2;grid-row:1/3;align-content:center;border-right:1px solid #e1eaf0;padding-right:1rem}.operations-dispatch-detail .delivery-overview-note b{font-size:.78rem}.operations-dispatch-detail .delivery-overview-note span{font-size:.7rem}.operations-dispatch-detail .dispatch-kpi small,.operations-dispatch-detail .dispatch-kpi p{font-size:.7rem;line-height:1.65}.operations-dispatch-detail .dispatch-detail-table thead th{font-size:.72rem}.operations-dispatch-detail .recipient-cell strong,.operations-dispatch-detail .ticket-reference strong,.operations-dispatch-detail .sms-state-card strong{font-size:.78rem}.operations-dispatch-detail .recipient-cell small,.operations-dispatch-detail .ticket-reference span,.operations-dispatch-detail .sms-state-card p,.operations-dispatch-detail .sms-state-card small{font-size:.7rem}.operations-dispatch-detail .sms-technical-details,.operations-dispatch-detail .sms-error-panel,.operations-dispatch-detail .ticket-state-pill,.operations-dispatch-detail .engagement-state strong,.operations-dispatch-detail .recipient-reply p{font-size:.72rem}.operations-dispatch-detail .engagement-state small,.operations-dispatch-detail .recipient-reply small,.operations-dispatch-detail .no-reply-state{font-size:.7rem}
|
||||
@media(max-width:1100px){.operations-dispatch-detail .delivery-overview{grid-template-columns:1fr}.operations-dispatch-detail .delivery-progress-track{grid-column:1}.operations-dispatch-detail .delivery-overview-note{grid-column:1;grid-row:auto;border-top:1px solid #e5edf2;border-right:0;padding:.65rem 0 0}}
|
||||
@media(max-width:760px){.operations-dispatch-detail .delivery-progress-copy{align-items:flex-start;flex-direction:column;gap:.35rem}.operations-dispatch-detail .dispatch-detail-table td:before{font-size:.66rem}}
|
||||
|
||||
/* Shared attachments for isolated group dispatches. */
|
||||
.dispatch-attachment-picker{display:grid;gap:.8rem;margin:0 1rem 1rem;border:1px dashed #8fb7d1;border-radius:.9rem;background:linear-gradient(145deg,#f8fcff,#f2f9fd);padding:1rem}.dispatch-attachment-picker .attachment-picker-copy{display:flex;align-items:center;gap:.75rem}.dispatch-attachment-picker .attachment-icon{display:grid;width:2.45rem;height:2.45rem;flex:none;place-items:center;border-radius:.72rem;background:#dff2ff;color:#1977ce;font-size:1.2rem;font-weight:900}.dispatch-attachment-picker b,.dispatch-attachment-picker small{display:block}.dispatch-attachment-picker b{color:#173f60;font-size:.78rem}.dispatch-attachment-picker .attachment-picker-copy small{margin-top:.15rem;color:#647d91;font-size:.65rem;line-height:1.7}.dispatch-attachment-picker .media-picker-actions{margin:0}.dispatch-attachment-picker .file-preview{margin-top:0}.dispatch-attachment-picker .file-preview:empty{display:none}.dispatch-attachment-picker .file-chip{box-shadow:0 10px 25px -22px #08376b}.dispatch-attachment-picker .compression-note{color:#688095;font-size:.62rem;line-height:1.7}
|
||||
@media(max-width:700px){.dispatch-attachment-picker{margin:.8rem;padding:.8rem}.dispatch-attachment-picker .media-picker-actions{display:grid;grid-template-columns:1fr}.dispatch-attachment-picker .media-picker-button{justify-content:center}}
|
||||
@@ -0,0 +1,320 @@
|
||||
(() => {
|
||||
const form = document.querySelector('[data-ticket-wizard]');
|
||||
if (!form) return;
|
||||
|
||||
const steps = [...form.querySelectorAll('[data-wizard-step]')];
|
||||
const progress = [...document.querySelectorAll('[data-wizard-progress-item]')];
|
||||
const unit = form.querySelector('[data-ticket-unit]');
|
||||
const category = form.querySelector('[data-ticket-category]');
|
||||
const subject = form.querySelector('[data-ticket-subject]');
|
||||
const province = form.querySelector('[data-province-input]');
|
||||
const city = form.querySelector('[data-city-input]');
|
||||
const body = form.querySelector('textarea[name="body"]');
|
||||
const canonicalFiles = form.querySelector('input[name="files"]');
|
||||
const voiceRoot = form.querySelector('[data-ticket-voice-recorder]');
|
||||
let currentStep = 1;
|
||||
|
||||
const toPersianDigits = value => String(value).replace(/\d/g, digit => '۰۱۲۳۴۵۶۷۸۹'[digit]);
|
||||
|
||||
function updateProgress(number) {
|
||||
progress.forEach(item => {
|
||||
const itemNumber = Number(item.dataset.wizardProgressItem);
|
||||
item.classList.toggle('is-active', itemNumber === number);
|
||||
item.classList.toggle('is-complete', itemNumber < number);
|
||||
item.querySelector('span').textContent = itemNumber < number ? '✓' : toPersianDigits(itemNumber);
|
||||
});
|
||||
}
|
||||
|
||||
function showStep(number, { scroll = true } = {}) {
|
||||
currentStep = number;
|
||||
steps.forEach(step => {
|
||||
const active = Number(step.dataset.wizardStep) === number;
|
||||
step.hidden = !active;
|
||||
step.classList.toggle('is-active', active);
|
||||
});
|
||||
updateProgress(number);
|
||||
if (scroll) window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}
|
||||
|
||||
function clearValidation(number) {
|
||||
const step = form.querySelector(`[data-wizard-step="${number}"]`);
|
||||
const error = form.querySelector(`[data-wizard-error="${number}"]`);
|
||||
step?.querySelectorAll('[aria-invalid="true"]').forEach(field => field.removeAttribute('aria-invalid'));
|
||||
if (error) {
|
||||
error.textContent = '';
|
||||
error.classList.remove('is-visible');
|
||||
error.removeAttribute('tabindex');
|
||||
}
|
||||
}
|
||||
|
||||
function showValidation(number, problem) {
|
||||
const step = form.querySelector(`[data-wizard-step="${number}"]`);
|
||||
const error = form.querySelector(`[data-wizard-error="${number}"]`);
|
||||
if (number === 1 && problem.fields.some(field => field === unit || field === category)) {
|
||||
const manualRouting = form.querySelector('.ticket-manual-routing');
|
||||
if (manualRouting) manualRouting.open = true;
|
||||
}
|
||||
step?.querySelectorAll('[aria-invalid="true"]').forEach(field => field.removeAttribute('aria-invalid'));
|
||||
problem.fields.forEach(field => field?.setAttribute('aria-invalid', 'true'));
|
||||
error.textContent = problem.message;
|
||||
error.tabIndex = -1;
|
||||
error.classList.add('is-visible');
|
||||
requestAnimationFrame(() => {
|
||||
error.focus({ preventScroll: true });
|
||||
error.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'nearest' });
|
||||
});
|
||||
}
|
||||
|
||||
function problemForStep(number) {
|
||||
if (number === 1) {
|
||||
const missing = [];
|
||||
const fields = [];
|
||||
if (!unit?.value) { missing.push('واحد سازمانی'); fields.push(unit); }
|
||||
if (!category?.value) { missing.push('دستهبندی'); fields.push(category); }
|
||||
if ((subject?.value.trim().length || 0) < 5) { missing.push('موضوع دقیق حداقل پنجحرفی'); fields.push(subject); }
|
||||
if (missing.length) return { message: `برای ادامه، ${missing.join('، ')} را تکمیل کنید.`, fields };
|
||||
}
|
||||
if (number === 2) {
|
||||
if (city?.value && !province?.value) return { message: 'برای شهر انتخابشده، استان را نیز مشخص کنید.', fields: [province, city] };
|
||||
}
|
||||
if (number === 4 && !body?.value.trim() && !(canonicalFiles?.files.length)) {
|
||||
return { message: 'برای ادامه، شرح درخواست بنویسید یا یک فایل/پیام صوتی اضافه کنید.', fields: [body] };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function validateStep(number) {
|
||||
const problem = problemForStep(number);
|
||||
if (problem) {
|
||||
showValidation(number, problem);
|
||||
return false;
|
||||
}
|
||||
clearValidation(number);
|
||||
return true;
|
||||
}
|
||||
|
||||
function selectedText(select, fallback) {
|
||||
return select?.selectedOptions?.[0]?.textContent?.trim() || fallback;
|
||||
}
|
||||
|
||||
function buildReview() {
|
||||
form.querySelector('[data-review-subject]').textContent = subject.value.trim();
|
||||
form.querySelector('[data-review-unit]').textContent = selectedText(unit, '—');
|
||||
form.querySelector('[data-review-category]').textContent = selectedText(category, '—');
|
||||
form.querySelector('[data-review-location]').textContent = province?.value
|
||||
? [province.value, city?.value].filter(Boolean).join('، ')
|
||||
: 'ثبت نشده';
|
||||
const selectedExpert = form.querySelector('input[name="selected_expert"]:checked');
|
||||
form.querySelector('[data-review-expert]').textContent = selectedExpert?.closest('label')?.querySelector('strong')?.textContent?.trim() || 'صف عمومی واحد';
|
||||
form.querySelector('[data-review-body]').textContent = body.value.trim() || 'شرح متنی ثبت نشده است.';
|
||||
const fileList = form.querySelector('[data-review-files]');
|
||||
fileList.replaceChildren();
|
||||
const visibleFiles = [...(canonicalFiles?.files || [])].filter(file => !file.type.startsWith('audio/'));
|
||||
visibleFiles.forEach(file => {
|
||||
const chip = document.createElement('span');
|
||||
chip.textContent = file.name;
|
||||
fileList.append(chip);
|
||||
});
|
||||
const reviewAudio = form.querySelector('[data-review-audio]');
|
||||
const voiceApi = voiceRoot?.ticketVoiceRecorder;
|
||||
reviewAudio.hidden = !voiceApi?.hasVoice();
|
||||
if (voiceApi?.url()) reviewAudio.src = voiceApi.url();
|
||||
if (!visibleFiles.length && !voiceApi?.hasVoice()) {
|
||||
const chip = document.createElement('span');
|
||||
chip.textContent = 'بدون پیوست';
|
||||
fileList.append(chip);
|
||||
}
|
||||
}
|
||||
|
||||
async function leaveCurrentStep(targetStep) {
|
||||
const button = form.querySelector(`[data-wizard-step="${currentStep}"] [data-wizard-next]`);
|
||||
if (button) button.disabled = true;
|
||||
if (currentStep === 4) await voiceRoot?.ticketVoiceRecorder?.stopAndSave({ automatic: true });
|
||||
if (button) button.disabled = false;
|
||||
if (!validateStep(currentStep)) return;
|
||||
if (currentStep === 4) buildReview();
|
||||
showStep(targetStep);
|
||||
}
|
||||
|
||||
form.querySelectorAll('[data-wizard-next]').forEach(button => button.addEventListener('click', () => leaveCurrentStep(Math.min(5, currentStep + 1))));
|
||||
form.querySelectorAll('[data-wizard-prev]').forEach(button => button.addEventListener('click', async () => {
|
||||
if (currentStep === 4) await voiceRoot?.ticketVoiceRecorder?.stopAndSave({ automatic: true });
|
||||
showStep(Math.max(1, currentStep - 1));
|
||||
}));
|
||||
form.querySelectorAll('[data-wizard-edit]').forEach(button => button.addEventListener('click', () => showStep(Number(button.dataset.wizardEdit))));
|
||||
|
||||
form.addEventListener('input', event => {
|
||||
event.target.removeAttribute?.('aria-invalid');
|
||||
if (event.target === body) form.querySelector('[data-body-count]').textContent = toPersianDigits(body.value.length);
|
||||
});
|
||||
form.addEventListener('change', event => event.target.removeAttribute?.('aria-invalid'));
|
||||
|
||||
form.addEventListener('submit', event => {
|
||||
const invalidStep = [1, 2, 3, 4].find(number => problemForStep(number));
|
||||
if (!invalidStep) return;
|
||||
event.preventDefault();
|
||||
form.querySelectorAll('button[type="submit"]').forEach(button => { button.disabled = false; });
|
||||
showStep(invalidStep);
|
||||
requestAnimationFrame(() => validateStep(invalidStep));
|
||||
});
|
||||
|
||||
function setupVoiceRecorder(root) {
|
||||
if (!root) return;
|
||||
const ui = {
|
||||
idle: root.querySelector('[data-voice-idle]'), live: root.querySelector('[data-voice-live]'),
|
||||
preview: root.querySelector('[data-voice-preview]'), record: root.querySelector('[data-voice-record]'),
|
||||
pause: root.querySelector('[data-voice-pause]'), stop: root.querySelector('[data-voice-stop]'),
|
||||
cancel: root.querySelector('[data-voice-cancel]'), rerecord: root.querySelector('[data-voice-rerecord]'),
|
||||
remove: root.querySelector('[data-voice-delete]'), audio: root.querySelector('[data-voice-audio]'),
|
||||
timer: root.querySelector('[data-voice-timer]'), status: root.querySelector('[data-voice-status]'),
|
||||
meta: root.querySelector('[data-voice-meta]'), error: root.querySelector('[data-voice-error]'),
|
||||
};
|
||||
let recorder = null;
|
||||
let stream = null;
|
||||
let chunks = [];
|
||||
let voiceFile = null;
|
||||
let voiceUrl = '';
|
||||
let startedAt = 0;
|
||||
let pausedAt = 0;
|
||||
let pausedMs = 0;
|
||||
let timer = null;
|
||||
let stopResolver = null;
|
||||
let discardOnStop = false;
|
||||
let automaticStop = false;
|
||||
|
||||
const elapsed = () => Math.max(0, Date.now() - startedAt - pausedMs - (recorder?.state === 'paused' ? Date.now() - pausedAt : 0));
|
||||
const formatDuration = milliseconds => {
|
||||
const seconds = Math.floor(milliseconds / 1000);
|
||||
return toPersianDigits(`${String(Math.floor(seconds / 60)).padStart(2, '0')}:${String(seconds % 60).padStart(2, '0')}`);
|
||||
};
|
||||
const stopTracks = () => { stream?.getTracks().forEach(track => track.stop()); stream = null; };
|
||||
const clearTimer = () => { clearInterval(timer); timer = null; };
|
||||
const showState = (state, status) => {
|
||||
ui.idle.hidden = state !== 'idle';
|
||||
ui.live.hidden = state !== 'live' && state !== 'paused';
|
||||
ui.preview.hidden = state !== 'preview';
|
||||
root.dataset.state = state;
|
||||
if (status) ui.status.textContent = status;
|
||||
};
|
||||
const removeVoice = ({ notify = true } = {}) => {
|
||||
if (notify && voiceFile) form.dispatchEvent(new CustomEvent('voice-removed', { detail: voiceFile }));
|
||||
voiceFile = null;
|
||||
if (voiceUrl) URL.revokeObjectURL(voiceUrl);
|
||||
voiceUrl = '';
|
||||
ui.audio.removeAttribute('src');
|
||||
ui.timer.textContent = '۰۰:۰۰';
|
||||
ui.pause.textContent = 'مکث';
|
||||
ui.error.hidden = true;
|
||||
showState('idle', 'برای توضیح سریعتر، صدای خود را ضبط کنید.');
|
||||
};
|
||||
const complete = (blob, duration) => {
|
||||
if (!blob.size) {
|
||||
removeVoice();
|
||||
ui.error.textContent = 'صدایی ثبت نشد؛ لطفاً دوباره تلاش کنید.';
|
||||
ui.error.hidden = false;
|
||||
return;
|
||||
}
|
||||
if (voiceUrl) URL.revokeObjectURL(voiceUrl);
|
||||
voiceUrl = URL.createObjectURL(blob);
|
||||
ui.audio.src = voiceUrl;
|
||||
const type = blob.type || 'audio/webm';
|
||||
const extension = type.includes('ogg') ? 'ogg' : type.includes('mp4') ? 'm4a' : type.includes('wav') ? 'wav' : 'webm';
|
||||
voiceFile = new File([blob], `voice-${Date.now()}.${extension}`, { type });
|
||||
form.dispatchEvent(new CustomEvent('voice-added', { detail: voiceFile }));
|
||||
ui.timer.textContent = formatDuration(duration);
|
||||
ui.meta.textContent = `${formatDuration(duration)} · آماده پخش و ارسال`;
|
||||
showState('preview', automaticStop ? 'ضبط هنگام ادامهدادن متوقف و ذخیره شد.' : 'ضبط ذخیره شد؛ پیش از ارسال آن را بررسی کنید.');
|
||||
};
|
||||
const start = async () => {
|
||||
ui.error.hidden = true;
|
||||
if (!window.isSecureContext || !navigator.mediaDevices?.getUserMedia || !window.MediaRecorder) {
|
||||
ui.error.textContent = 'مرورگر یا اتصال فعلی امکان ضبط امن صدا را ندارد.';
|
||||
ui.error.hidden = false;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (voiceFile) removeVoice();
|
||||
stream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true } });
|
||||
const mimeType = ['audio/webm;codecs=opus', 'audio/mp4', 'audio/ogg;codecs=opus', 'audio/webm', 'audio/wav'].find(type => MediaRecorder.isTypeSupported?.(type));
|
||||
recorder = new MediaRecorder(stream, mimeType ? { mimeType } : undefined);
|
||||
chunks = [];
|
||||
pausedMs = 0;
|
||||
discardOnStop = false;
|
||||
automaticStop = false;
|
||||
recorder.ondataavailable = event => { if (event.data.size) chunks.push(event.data); };
|
||||
recorder.onerror = () => {
|
||||
stopTracks();
|
||||
clearTimer();
|
||||
ui.error.textContent = 'ضبط صدا متوقف شد. دوباره تلاش کنید.';
|
||||
ui.error.hidden = false;
|
||||
showState('idle');
|
||||
};
|
||||
recorder.onstop = () => {
|
||||
const duration = elapsed();
|
||||
clearTimer();
|
||||
stopTracks();
|
||||
if (discardOnStop) removeVoice();
|
||||
else complete(new Blob(chunks, { type: recorder.mimeType?.split(';')[0] || 'audio/webm' }), duration);
|
||||
recorder = null;
|
||||
stopResolver?.();
|
||||
stopResolver = null;
|
||||
};
|
||||
recorder.start(500);
|
||||
startedAt = Date.now();
|
||||
ui.timer.textContent = '۰۰:۰۰';
|
||||
showState('live', 'در حال ضبط؛ واضح و نزدیک به میکروفن صحبت کنید.');
|
||||
timer = setInterval(() => { ui.timer.textContent = formatDuration(elapsed()); }, 250);
|
||||
} catch (error) {
|
||||
stopTracks();
|
||||
ui.error.textContent = error.name === 'NotAllowedError'
|
||||
? 'دسترسی میکروفن رد شد؛ آن را از تنظیمات مرورگر فعال کنید.'
|
||||
: 'میکروفن در دسترس نیست یا برنامه دیگری از آن استفاده میکند.';
|
||||
ui.error.hidden = false;
|
||||
}
|
||||
};
|
||||
const stopAndSave = ({ discard = false, automatic = false } = {}) => {
|
||||
if (!recorder || recorder.state === 'inactive') return Promise.resolve();
|
||||
discardOnStop = discard;
|
||||
automaticStop = automatic;
|
||||
if (recorder.state === 'paused') {
|
||||
pausedMs += Date.now() - pausedAt;
|
||||
recorder.resume();
|
||||
}
|
||||
return new Promise(resolve => { stopResolver = resolve; recorder.stop(); });
|
||||
};
|
||||
const togglePause = () => {
|
||||
if (recorder?.state === 'recording') {
|
||||
recorder.pause();
|
||||
pausedAt = Date.now();
|
||||
ui.pause.textContent = 'ادامه ضبط';
|
||||
showState('paused', 'ضبط موقتاً متوقف شده است.');
|
||||
} else if (recorder?.state === 'paused') {
|
||||
pausedMs += Date.now() - pausedAt;
|
||||
recorder.resume();
|
||||
ui.pause.textContent = 'مکث';
|
||||
showState('live', 'در حال ضبط؛ واضح و نزدیک به میکروفن صحبت کنید.');
|
||||
}
|
||||
};
|
||||
|
||||
ui.record.addEventListener('click', start);
|
||||
ui.pause.addEventListener('click', togglePause);
|
||||
ui.stop.addEventListener('click', () => stopAndSave());
|
||||
ui.cancel.addEventListener('click', () => stopAndSave({ discard: true }));
|
||||
ui.remove.addEventListener('click', removeVoice);
|
||||
ui.rerecord.addEventListener('click', start);
|
||||
root.ticketVoiceRecorder = { stopAndSave, hasVoice: () => Boolean(voiceFile), url: () => voiceUrl };
|
||||
window.addEventListener('pagehide', () => { stopTracks(); clearTimer(); });
|
||||
}
|
||||
|
||||
setupVoiceRecorder(voiceRoot);
|
||||
form.dataset.wizardEnhanced = '';
|
||||
|
||||
const serverErrorStep = steps.find(step => step.querySelector('.errorlist'));
|
||||
const initialStep = serverErrorStep ? Number(serverErrorStep.dataset.wizardStep) : 1;
|
||||
showStep(initialStep, { scroll: false });
|
||||
if (serverErrorStep) {
|
||||
const messages = [...serverErrorStep.querySelectorAll('.errorlist li')].map(item => item.textContent.trim()).filter(Boolean);
|
||||
const number = Number(serverErrorStep.dataset.wizardStep);
|
||||
requestAnimationFrame(() => showValidation(number, { message: messages.join(' '), fields: [] }));
|
||||
}
|
||||
})();
|
||||
@@ -3,29 +3,144 @@
|
||||
{% block content %}
|
||||
<link rel="stylesheet" href="{% static 'css/ticket-composer.css' %}?v={{ static_asset_version }}">
|
||||
<link rel="stylesheet" href="{% static 'css/ticket-composer-v2.css' %}?v={{ static_asset_version }}">
|
||||
<div class="page-head"><div><a class="back-link" href="{% url 'tickets:list' %}">→ بازگشت به درخواستها</a><p class="page-kicker">ثبت درخواست جدید</p><h1>چه کمکی از ما برمیآید؟</h1><p>درخواست خود را کامل بنویسید تا همان بار اول پاسخ دقیقتری دریافت کنید.</p></div></div>
|
||||
<div class="request-layout">
|
||||
<section class="request-form card"><form method="post" enctype="multipart/form-data" data-ticket-composer data-unit-options-url="{% url 'tickets:new_options' %}" data-ticket-search-url="{% url 'tickets:new_search' %}" data-chat-coach data-chat-coach-key="customer-new-{{ user.public_id }}">{% csrf_token %}{{ form.non_field_errors }}
|
||||
<section class="intent-finder" aria-labelledby="intent-finder-title"><span class="intent-icon">⌕</span><div class="intent-copy"><h2 id="intent-finder-title">موضوع درخواستتان چیست؟</h2><p>چند کلمه بنویسید؛ واحد و دستهبندی مناسب را پیشنهاد میدهیم.</p><label class="intent-search"><span class="sr-only">جستوجوی موضوع درخواست</span><input type="search" data-ticket-search autocomplete="off" placeholder="مثلاً فیش واریزی، موجودی کالا یا ارسال بار..."><kbd>جستوجو</kbd></label><div class="intent-results" data-ticket-search-results aria-live="polite"></div></div></section>
|
||||
<details class="manual-routing" open><summary><span>انتخاب دستی واحد و دستهبندی</span><small>در صورت تمایل خودتان مسیر درخواست را مشخص کنید</small></summary><div class="manual-routing-body">
|
||||
<div class="step-title"><span class="step-number">۱</span><div><h3>موضوع درخواست</h3><p class="muted">دستهبندی و یک عنوان کوتاه انتخاب کنید.</p></div></div>
|
||||
<div class="routing-selectors" data-routing-selectors><div class="field">{{ form.unit.label_tag }}{{ form.unit }}{{ form.unit.errors }}</div><div class="field">{{ form.category.label_tag }}{{ form.category }}{{ form.category.errors }}</div></div>
|
||||
<div class="field routing-subject">{{ form.subject.label_tag }}{{ form.subject }}{{ form.subject.errors }}<small>موضوع را بهصورت دستی و دقیق وارد کنید.</small></div>
|
||||
</div></details>
|
||||
<div class="step-title"><span class="step-number">۲</span><div><h3>موقعیت این درخواست</h3><p class="muted">اختیاری است؛ موقعیت ذخیرهشده پروفایل از قبل انتخاب شده و فقط در صورت نیاز آن را تغییر دهید.</p></div></div>
|
||||
<section class="location-fields" data-location-picker data-locations-url="{% static 'data/iran-locations.json' %}">
|
||||
<div class="grid gap-4 sm:grid-cols-2"><div class="field">{{ form.province.label_tag }}{{ form.province }}{{ form.province.errors }}</div><div class="field">{{ form.city.label_tag }}{{ form.city }}{{ form.city.errors }}</div></div>
|
||||
<p class="location-hint" data-location-hint>نام استان را جستوجو و انتخاب کنید.</p>
|
||||
<link rel="stylesheet" href="{% static 'css/customer-ticket-wizard.css' %}?v={{ static_asset_version }}">
|
||||
|
||||
<div class="ticket-wizard-page">
|
||||
<nav class="ticket-wizard-topbar" aria-label="مسیر صفحه">
|
||||
<a class="ticket-wizard-back" href="{% url 'tickets:list' %}">→ بازگشت به درخواستها</a>
|
||||
<span class="ticket-wizard-save"><i></i> اطلاعات تا زمان خروج از صفحه حفظ میشود</span>
|
||||
</nav>
|
||||
|
||||
<header class="ticket-wizard-intro">
|
||||
<p class="page-kicker">ثبت درخواست جدید</p>
|
||||
<h1>چطور میتوانیم کمک کنیم؟</h1>
|
||||
<p>قدمبهقدم پیش میرویم؛ در هر مرحله فقط اطلاعات ضروری همان بخش را میبینید.</p>
|
||||
</header>
|
||||
|
||||
<ol class="ticket-wizard-progress" data-wizard-progress aria-label="مراحل ثبت درخواست">
|
||||
<li class="is-active" data-wizard-progress-item="1"><span>۱</span><div><b>موضوع</b><small>مسیر درخواست</small></div></li>
|
||||
<li data-wizard-progress-item="2"><span>۲</span><div><b>موقعیت</b><small>اختیاری</small></div></li>
|
||||
<li data-wizard-progress-item="3"><span>۳</span><div><b>کارشناس</b><small>انتخاب مسئول</small></div></li>
|
||||
<li data-wizard-progress-item="4"><span>۴</span><div><b>جزئیات</b><small>متن و فایل</small></div></li>
|
||||
<li data-wizard-progress-item="5"><span>۵</span><div><b>بازبینی</b><small>تأیید نهایی</small></div></li>
|
||||
</ol>
|
||||
|
||||
<form class="ticket-wizard-card" method="post" enctype="multipart/form-data" novalidate
|
||||
data-ticket-composer data-ticket-wizard
|
||||
data-unit-options-url="{% url 'tickets:new_options' %}"
|
||||
data-ticket-search-url="{% url 'tickets:new_search' %}"
|
||||
data-chat-coach data-chat-coach-key="customer-new-{{ user.public_id }}">
|
||||
{% csrf_token %}
|
||||
|
||||
<section class="ticket-wizard-step is-active" data-wizard-step="1" aria-labelledby="wizard-step-1-title">
|
||||
<header class="ticket-wizard-step-head">
|
||||
<span class="ticket-wizard-symbol" aria-hidden="true">⌕</span>
|
||||
<div><small>مرحله ۱ از ۵</small><h2 id="wizard-step-1-title">موضوع درخواستتان چیست؟</h2><p>چند کلمه بنویسید تا مسیر مناسب پیشنهاد شود، یا واحد و دستهبندی را دستی انتخاب کنید.</p></div>
|
||||
</header>
|
||||
|
||||
<label class="ticket-intent-search">
|
||||
<span class="sr-only">جستوجوی موضوع درخواست</span><i aria-hidden="true">⌕</i>
|
||||
<input type="search" data-ticket-search autocomplete="off" placeholder="مثلاً فیش واریزی، موجودی کالا یا ارسال بار...">
|
||||
<kbd>جستوجو</kbd>
|
||||
</label>
|
||||
<div class="intent-results ticket-intent-results" data-ticket-search-results aria-live="polite"></div>
|
||||
|
||||
<details class="ticket-manual-routing manual-routing" {% if form.is_bound and form.errors %}open{% endif %}>
|
||||
<summary><span>انتخاب دستی واحد و دستهبندی</span><small>در صورت تمایل مسیر درخواست را خودتان مشخص کنید</small></summary>
|
||||
<div class="ticket-manual-body manual-routing-body">
|
||||
<div class="ticket-routing-grid" data-routing-selectors>
|
||||
<div class="field">{{ form.unit.label_tag }}{{ form.unit }}{{ form.unit.errors }}</div>
|
||||
<div class="field">{{ form.category.label_tag }}{{ form.category }}{{ form.category.errors }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div class="field ticket-subject-field">
|
||||
<label for="{{ form.subject.id_for_label }}">موضوع دقیق درخواست <em>*</em></label>
|
||||
{{ form.subject }}{{ form.subject.errors }}
|
||||
<small>این عنوان فقط توسط شما نوشته میشود و با تغییر واحد یا دستهبندی عوض نخواهد شد.</small>
|
||||
</div>
|
||||
<p class="ticket-wizard-validation" data-wizard-error="1" role="alert"></p>
|
||||
<footer class="ticket-wizard-actions"><span>حدود یک دقیقه تا پایان</span><button class="primary" type="button" data-wizard-next>ادامه <b>←</b></button></footer>
|
||||
</section>
|
||||
<div class="step-title"><span class="step-number">۳</span><div><h3>انتخاب کارشناس</h3><p class="muted">اختیاری است؛ صف عمومی معمولاً سریعترین انتخاب است.</p></div></div>
|
||||
<fieldset class="expert-picker" data-expert-picker><legend class="sr-only">کارشناس دلخواه</legend><label class="expert-card" data-general-queue data-unit-id="{{ selected_unit.pk|default:'' }}"><input type="radio" name="selected_expert" value="" {% if not form.selected_expert.value %}checked{% endif %}><span class="expert-avatar">⌁</span><strong>صف عمومی {{ selected_unit.name|default:'واحد' }}</strong><small>اولین کارشناس آزاد همین واحد</small></label>{% for expert in form.fields.selected_expert.queryset %}<label class="expert-card"><input type="radio" name="selected_expert" value="{{ expert.pk }}" {% if form.selected_expert.value|stringformat:'s' == expert.pk|stringformat:'s' %}checked{% endif %}><img src="{% url 'tickets:expert_avatar' expert.user.public_id %}?v={{ expert.updated_at|date:'U' }}" alt="تصویر {{ expert.display_name }}" width="56" height="56" loading="lazy"><strong>{{ expert.display_name }}</strong><small>{{ expert.job_title|default:'کارشناس واحد' }}</small></label>{% empty %}<p class="muted expert-empty">کارشناس قابل انتخابی ثبت نشده؛ درخواست به صف عمومی میرود.</p>{% endfor %}</fieldset>{{ form.selected_expert.errors }}
|
||||
<div class="step-title"><span class="step-number">۴</span><div><h3>شرح و مستندات</h3><p class="muted">متن، فایل یا پیام صوتی خود را اضافه کنید.</p></div></div>
|
||||
<div class="field">{{ form.body.label_tag }}{{ form.body }}{{ form.body.errors }}</div>
|
||||
<div class="upload-zone media-picker"><div class="media-picker-actions"><label class="media-picker-button gallery-button" for="ticket-gallery-input">▣ انتخاب عکس از گالری</label><label class="media-picker-button" for="ticket-file-input">+ انتخاب فایل</label></div><input id="ticket-gallery-input" type="file" accept="image/*" multiple data-gallery-input><input id="ticket-file-input" type="file" multiple data-document-input><span class="canonical-files">{{ form.files }}</span>{{ form.files.errors }}<small class="compression-note" data-compression-status>تصاویر بزرگ پیش از ارسال، روی گوشی شما بهینه و کمحجم میشوند.</small><div class="file-preview" data-file-preview></div></div>
|
||||
<div class="voice-recorder" data-voice-recorder><div class="voice-head"><b>پیام صوتی</b><span class="badge" data-timer>00:00</span></div><div class="voice-actions"><button type="button" data-record>● شروع ضبط</button><button type="button" data-pause disabled>مکث</button><button type="button" data-stop disabled>توقف</button><button type="button" class="ghost" data-delete hidden>حذف</button></div><audio class="mt-3 w-full" controls hidden></audio></div>
|
||||
<button class="submit-ticket" type="submit">ثبت و ارسال درخواست <span>←</span></button>
|
||||
</form></section>
|
||||
<aside class="request-aside"><div class="help-card"><h3>برای پاسخ سریعتر</h3><div class="help-list"><div class="help-item"><b>۱</b><span>نام محصول یا خدمت موردنظر را دقیق بنویسید.</span></div><div class="help-item"><b>۲</b><span>تعداد، شهر مقصد و زمان موردنظر را ذکر کنید.</span></div><div class="help-item"><b>۳</b><span>تصویر یا فایل مرتبط را همینجا ضمیمه کنید.</span></div></div></div></aside>
|
||||
|
||||
<section class="ticket-wizard-step" data-wizard-step="2" aria-labelledby="wizard-step-2-title">
|
||||
<header class="ticket-wizard-step-head">
|
||||
<span class="ticket-wizard-symbol" aria-hidden="true">⌖</span>
|
||||
<div><small>مرحله ۲ از ۵ · اختیاری</small><h2 id="wizard-step-2-title">این درخواست مربوط به کجاست؟</h2><p>اگر موقعیت در پاسخ بهتر کارشناسان مؤثر است آن را انتخاب کنید؛ در غیر این صورت عبور کنید.</p></div>
|
||||
</header>
|
||||
<section class="ticket-location-fields" data-location-picker data-locations-url="{% static 'data/iran-locations.json' %}">
|
||||
<div class="ticket-two-columns"><div class="field">{{ form.province.label_tag }}{{ form.province }}{{ form.province.errors }}</div><div class="field">{{ form.city.label_tag }}{{ form.city }}{{ form.city.errors }}</div></div>
|
||||
<p class="location-hint" data-location-hint>نام استان را جستوجو و انتخاب کنید.</p>
|
||||
</section>
|
||||
<div class="ticket-soft-tip"><span>i</span><p><b>نیازی به انتخاب ندارید</b> این بخش کاملاً اختیاری است و میتوانید مستقیماً ادامه دهید.</p></div>
|
||||
<p class="ticket-wizard-validation" data-wizard-error="2" role="alert"></p>
|
||||
<footer class="ticket-wizard-actions"><button class="secondary" type="button" data-wizard-prev>مرحله قبل</button><button class="primary" type="button" data-wizard-next>ادامه <b>←</b></button></footer>
|
||||
</section>
|
||||
|
||||
<section class="ticket-wizard-step" data-wizard-step="3" aria-labelledby="wizard-step-3-title">
|
||||
<header class="ticket-wizard-step-head">
|
||||
<span class="ticket-wizard-symbol" aria-hidden="true">◎</span>
|
||||
<div><small>مرحله ۳ از ۵ · اختیاری</small><h2 id="wizard-step-3-title">چه کسی درخواست را بررسی کند؟</h2><p>صف عمومی پیشنهاد ماست؛ درخواست به اولین کارشناس آزاد همان واحد میرسد.</p></div>
|
||||
</header>
|
||||
<fieldset class="expert-picker ticket-expert-picker" data-expert-picker>
|
||||
<legend class="sr-only">کارشناس دلخواه</legend>
|
||||
<label class="expert-card" data-general-queue data-unit-id="{{ selected_unit.pk|default:'' }}"><input type="radio" name="selected_expert" value="" {% if not form.selected_expert.value %}checked{% endif %}><span class="expert-avatar">⌁</span><strong>صف عمومی {{ selected_unit.name|default:'واحد' }}</strong><small>سریعترین انتخاب · اولین کارشناس آزاد</small></label>
|
||||
{% for expert in form.fields.selected_expert.queryset %}<label class="expert-card"><input type="radio" name="selected_expert" value="{{ expert.pk }}" {% if form.selected_expert.value|stringformat:'s' == expert.pk|stringformat:'s' %}checked{% endif %}><img src="{% url 'tickets:expert_avatar' expert.user.public_id %}?v={{ expert.updated_at|date:'U' }}" alt="تصویر {{ expert.display_name }}" width="56" height="56" loading="lazy"><strong>{{ expert.display_name }}</strong><small>{{ expert.job_title|default:'کارشناس واحد' }}</small></label>{% empty %}<p class="muted expert-empty">کارشناس قابل انتخابی ثبت نشده؛ درخواست به صف عمومی میرود.</p>{% endfor %}
|
||||
</fieldset>
|
||||
{{ form.selected_expert.errors }}
|
||||
<p class="ticket-wizard-validation" data-wizard-error="3" role="alert"></p>
|
||||
<footer class="ticket-wizard-actions"><button class="secondary" type="button" data-wizard-prev>مرحله قبل</button><button class="primary" type="button" data-wizard-next>ادامه <b>←</b></button></footer>
|
||||
</section>
|
||||
|
||||
<section class="ticket-wizard-step" data-wizard-step="4" aria-labelledby="wizard-step-4-title">
|
||||
<header class="ticket-wizard-step-head">
|
||||
<span class="ticket-wizard-symbol" aria-hidden="true">≡</span>
|
||||
<div><small>مرحله ۴ از ۵</small><h2 id="wizard-step-4-title">جزئیات را برای کارشناس بنویسید</h2><p>شرح متنی، فایل یا پیام صوتی اضافه کنید تا کارشناس مسئله را دقیقتر بررسی کند.</p></div>
|
||||
</header>
|
||||
<div class="field ticket-body-field">{{ form.body.label_tag }}{{ form.body }}{{ form.body.errors }}<small><span data-body-count>۰</span> نویسه</small></div>
|
||||
<div class="upload-zone media-picker ticket-attachment-zone">
|
||||
<header class="ticket-attachment-head"><span>+</span><div><b>عکس و فایلهای مرتبط</b><small>تصاویر بزرگ پیش از ارسال روی دستگاه شما بهینه میشوند.</small></div></header>
|
||||
<div class="media-picker-actions ticket-attachment-actions"><label class="media-picker-button gallery-button" for="ticket-gallery-input">▣ انتخاب عکس از گالری</label><label class="media-picker-button" for="ticket-file-input">+ انتخاب فایل</label></div>
|
||||
<input id="ticket-gallery-input" type="file" accept="image/*" multiple data-gallery-input><input id="ticket-file-input" type="file" multiple data-document-input>
|
||||
<span class="canonical-files">{{ form.files }}</span>{{ form.files.errors }}
|
||||
<small class="compression-note" data-compression-status>فایلهای انتخابشده پیش از ثبت قابل مشاهده و حذف هستند.</small>
|
||||
<div class="file-preview" data-file-preview aria-live="polite"></div>
|
||||
</div>
|
||||
|
||||
<section class="ticket-voice-recorder" data-ticket-voice-recorder aria-labelledby="ticket-voice-title">
|
||||
<header class="ticket-voice-head"><span class="ticket-voice-icon" aria-hidden="true">◉</span><div><b id="ticket-voice-title">پیام صوتی</b><small data-voice-status aria-live="polite">برای توضیح سریعتر، صدای خود را ضبط کنید.</small></div><span class="ticket-voice-timer" data-voice-timer>۰۰:۰۰</span></header>
|
||||
<div class="ticket-voice-idle" data-voice-idle><button class="ticket-voice-start" type="button" data-voice-record><span>●</span> شروع ضبط صدا</button><small>پس از اجازهدادن به میکروفن، ضبط آغاز میشود.</small></div>
|
||||
<div class="ticket-voice-live" data-voice-live hidden><div class="ticket-voice-wave" aria-hidden="true"><i></i><i></i><i></i><i></i><i></i><i></i><i></i></div><div class="ticket-voice-controls"><button type="button" data-voice-pause>مکث</button><button class="ticket-voice-stop" type="button" data-voice-stop>■ توقف و ذخیره</button><button class="ticket-voice-cancel" type="button" data-voice-cancel>لغو</button></div></div>
|
||||
<div class="ticket-voice-preview" data-voice-preview hidden><div class="ticket-voice-ready"><span>✓</span><p><b>پیام صوتی آماده است</b><small data-voice-meta>قابل پخش و بررسی پیش از ارسال</small></p></div><audio controls preload="metadata" data-voice-audio></audio><div class="ticket-voice-preview-actions"><button type="button" data-voice-rerecord>ضبط مجدد</button><button type="button" data-voice-delete>حذف پیام صوتی</button></div></div>
|
||||
<p class="ticket-voice-note"><span>ⓘ</span> با رفتن به مرحله بعد، ضبط فعال بهصورت خودکار متوقف و ذخیره میشود.</p>
|
||||
<p class="ticket-voice-error" data-voice-error role="alert" hidden></p>
|
||||
</section>
|
||||
|
||||
<div class="ticket-server-errors" data-step-server-errors>{% if form.non_field_errors %}{{ form.non_field_errors }}{% endif %}</div>
|
||||
<p class="ticket-wizard-validation" data-wizard-error="4" role="alert"></p>
|
||||
<footer class="ticket-wizard-actions"><button class="secondary" type="button" data-wizard-prev>مرحله قبل</button><button class="primary" type="button" data-wizard-next>مشاهده پیشنمایش <b>←</b></button></footer>
|
||||
</section>
|
||||
|
||||
<section class="ticket-wizard-step ticket-review-step" data-wizard-step="5" aria-labelledby="wizard-step-5-title">
|
||||
<header class="ticket-wizard-step-head"><span class="ticket-wizard-symbol is-success" aria-hidden="true">✓</span><div><small>مرحله ۵ از ۵</small><h2 id="wizard-step-5-title">همهچیز آماده ارسال است</h2><p>اطلاعات را یکبار مرور کنید؛ هر بخش را میتوانید جداگانه ویرایش کنید.</p></div></header>
|
||||
<div class="ticket-review-callout"><span>✓</span><div><b>یک مرور کوتاه پیش از ارسال</b><small>اگر موردی درست نیست، همان ردیف را ویرایش کنید؛ اطلاعات مراحل دیگر حفظ میشود.</small></div></div>
|
||||
<div class="ticket-review-list">
|
||||
<section class="ticket-review-row"><span class="ticket-review-icon">۱</span><div class="ticket-review-content"><small>موضوع و مسیر رسیدگی</small><h3 data-review-subject>—</h3><p><span data-review-unit>—</span><i>←</i><span data-review-category>—</span></p></div><button type="button" data-wizard-edit="1"><span>✎</span> ویرایش</button></section>
|
||||
<section class="ticket-review-row"><span class="ticket-review-icon">۲</span><div class="ticket-review-content"><small>موقعیت</small><h3 data-review-location>ثبت نشده</h3><p>اختیاری</p></div><button type="button" data-wizard-edit="2"><span>✎</span> ویرایش</button></section>
|
||||
<section class="ticket-review-row"><span class="ticket-review-icon">۳</span><div class="ticket-review-content"><small>نحوه ارجاع</small><h3 data-review-expert>صف عمومی</h3><p>در همان واحد سازمانی</p></div><button type="button" data-wizard-edit="3"><span>✎</span> ویرایش</button></section>
|
||||
<section class="ticket-review-row ticket-review-details"><span class="ticket-review-icon">۴</span><div class="ticket-review-content"><small>شرح و پیوستها</small><p class="ticket-review-body" data-review-body>—</p><div class="ticket-review-files" data-review-files></div><audio class="ticket-review-audio" controls hidden data-review-audio></audio></div><button type="button" data-wizard-edit="4"><span>✎</span> ویرایش</button></section>
|
||||
</div>
|
||||
<p class="ticket-wizard-validation" data-wizard-error="5" role="alert"></p>
|
||||
<footer class="ticket-wizard-actions ticket-final-actions"><button class="secondary" type="button" data-wizard-prev>مرحله قبل</button><button class="primary" type="submit" data-final-submit>ثبت و ارسال نهایی <b>←</b></button></footer>
|
||||
</section>
|
||||
</form>
|
||||
</div>
|
||||
<script src="{% static 'vendor/compressor.min.js' %}?v={{ static_asset_version }}"></script><script src="{% static 'js/location-picker.js' %}?v={{ static_asset_version }}"></script><script src="{% static 'js/ticket-unit-options.js' %}?v={{ static_asset_version }}"></script><script src="{% static 'js/ticket-intent-search.js' %}?v={{ static_asset_version }}"></script><script src="{% static 'js/attachment-uploader.js' %}?v={{ static_asset_version }}"></script>
|
||||
|
||||
<script src="{% static 'vendor/compressor.min.js' %}?v={{ static_asset_version }}"></script>
|
||||
<script src="{% static 'js/location-picker.js' %}?v={{ static_asset_version }}"></script>
|
||||
<script src="{% static 'js/ticket-unit-options.js' %}?v={{ static_asset_version }}"></script>
|
||||
<script src="{% static 'js/ticket-intent-search.js' %}?v={{ static_asset_version }}"></script>
|
||||
<script src="{% static 'js/attachment-uploader.js' %}?v={{ static_asset_version }}"></script>
|
||||
<script src="{% static 'js/customer-ticket-wizard.js' %}?v={{ static_asset_version }}"></script>
|
||||
{% endblock %}
|
||||
@@ -10,6 +10,7 @@
|
||||
<aside class="workspace-side actions-side" id="ticket-actions" data-workspace-panel>
|
||||
<button type="button" class="panel-close" data-close-panel>×</button>
|
||||
<section class="info-card"><p class="panel-index">وضعیت تیکت</p><div class="info-list"><div class="info-row"><span>وضعیت</span><b>{{ ticket.get_status_display }}</b></div><div class="info-row"><span>اولویت</span><b>{{ ticket.get_priority_display }}</b></div><div class="info-row"><span>دسته</span><b>{{ ticket.category }}</b></div><div class="info-row"><span>مالک</span><b>{{ ticket.assigned_expert_name }}</b></div>{% if ticket.first_response_due_at %}<div class="info-row"><span>مهلت پاسخ</span><b>{{ ticket.first_response_due_at|jalali_datetime }}</b></div>{% endif %}</div></section>
|
||||
{% if sms_jobs %}<section class="info-card"><h3>وضعیت پیامکهای مشتری</h3><div class="info-list">{% for sms_job in sms_jobs %}<div class="info-row"><span>{% if sms_job.event_type == 'STAFF_TICKET_CREATED' %}ثبت تیکت توسط کارشناس{% elif sms_job.event_type == 'CUSTOMER_TICKET_CREATED' %}ثبت تیکت مشتری{% elif sms_job.event_type == 'STAFF_REPLY' %}اعلان پاسخ کارشناس{% else %}پیامک سیستمی{% endif %}</span><b>{% if sms_job.delivery_status %}{{ sms_job.get_delivery_status_display }}{% else %}{{ sms_job.get_status_display }}{% endif %}</b></div>{% if sms_job.delivery_description %}<p class="muted">{{ sms_job.delivery_description }}</p>{% endif %}{% if sms_job.last_error %}<div class="create-form-errors">علت دقیق: {{ sms_job.last_error }}</div>{% endif %}{% endfor %}</div></section>{% endif %}
|
||||
<section class="info-card ticket-operations"><h3>عملیات تیکت</h3>{% if perms.tickets.change_ticket %}<form method="post" action="{% url 'staff_tickets:status' ticket.public_id %}">{% csrf_token %}<label>وضعیت<select name="status">{% for value,label in statuses %}<option value="{{ value }}" {% if ticket.status == value %}selected{% endif %}>{{ label }}</option>{% endfor %}</select></label><button>ثبت وضعیت</button></form>{% endif %}{% if perms.tickets.change_ticket_priority %}<form method="post" action="{% url 'staff_tickets:priority' ticket.public_id %}">{% csrf_token %}<label>اولویت<select name="priority">{% for value,label in priorities %}<option value="{{ value }}" {% if ticket.priority == value %}selected{% endif %}>{{ label }}</option>{% endfor %}</select></label><button class="secondary">ثبت اولویت</button></form>{% endif %}{% if can_transfer %}<details><summary>انتقال تیکت</summary><form method="post" action="{% url 'staff_tickets:transfer' ticket.public_id %}">{% csrf_token %}{{ transfer_form.as_p }}<button>انتقال</button></form></details>{% endif %}{% if can_share %}<details><summary>اشتراک با کارشناس</summary><form method="post" action="{% url 'staff_tickets:share' ticket.public_id %}">{% csrf_token %}{{ share_form.as_p }}<button>ایجاد دسترسی</button></form>{% for grant in grants %}<div class="grant"><span>{{ grant.user.staff_profile.display_name }}</span><form method="post" action="{% url 'staff_tickets:revoke' ticket.public_id grant.user.public_id %}">{% csrf_token %}<button class="danger-button">لغو</button></form></div>{% endfor %}</details>{% endif %}</section>
|
||||
{% if can_delete %}<section class="info-card danger-zone"><h3>حذف کامل تیکت</h3><p class="muted">تمام پیامها، فایلها و سوابق تیکت برای همیشه حذف میشوند.</p><form method="post" action="{% url 'staff_tickets:delete' ticket.public_id %}" onsubmit="return confirm('آیا از حذف کامل و غیرقابلبازگشت این تیکت مطمئن هستید؟')">{% csrf_token %}<button type="submit" class="danger-button">حذف کامل تیکت</button></form></section>{% endif %}
|
||||
</aside>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{% extends 'base.html' %}
|
||||
{% extends 'base.html' %}{% load static %}
|
||||
{% block title %}{{ page_title }}{% endblock %}
|
||||
{% block content %}<div class="operations-page operations-dispatch">
|
||||
<section class="operations-hero"><div><p class="eyebrow">ارسال انبوه امن</p><h1>ارسال گروهی ایزوله</h1><p>برای هر مخاطب یک تیکت و Magic Link مستقل ایجاد میشود؛ هیچ دریافتکنندهای پاسخ دیگران را نمیبیند.</p></div><div class="security-callout"><span>⌾</span><div><b>حریم خصوصی فعال</b><small>تیکت مستقل برای هر گیرنده</small></div></div></section>
|
||||
<section class="operations-metrics dispatch-metrics" aria-label="آمار ارسالهای گروهی"><article><small>کل عملیات</small><b>{{ metrics.total }}</b></article><article><small>کل گیرندگان</small><b>{{ metrics.recipients }}</b></article><article><small>ارسال کامل</small><b>{{ metrics.sent }}</b></article><article><small>نیازمند پیگیری</small><b>{{ metrics.attention }}</b></article></section>
|
||||
<div class="dispatch-layout"><form method="post" enctype="multipart/form-data" class="dispatch-composer" data-disable-on-submit>{% csrf_token %}{% if form.non_field_errors %}<div class="create-form-errors" role="alert">{{ form.non_field_errors }}</div>{% endif %}
|
||||
<div class="dispatch-layout"><form method="post" enctype="multipart/form-data" class="dispatch-composer" data-disable-on-submit data-ticket-composer>{% csrf_token %}{% if form.non_field_errors %}<div class="create-form-errors" role="alert">{{ form.non_field_errors }}</div>{% endif %}
|
||||
<section><header><span>۱</span><div><h2>نوع عملیات، مخاطبان و مالکیت</h2><p>قابلیتهای مجاز و گروه هدف را مشخص کنید.</p></div></header><div class="editor-grid"><label><span>{{ form.operation_type.label }}</span>{{ form.operation_type }}{{ form.operation_type.errors }}</label><label><span>{{ form.unit.label }}</span>{{ form.unit }}{{ form.unit.errors }}</label><label><span>{{ form.contact_group.label }}</span>{{ form.contact_group }}{{ form.contact_group.errors }}<small>فقط گروههای واحد انتخابشده معتبر هستند.</small></label></div></section>
|
||||
<section><header><span>۲</span><div><h2>موضوع و دستهبندی</h2><p>عنوان کوتاه و برچسبهای مناسب، گزارشگیری را دقیقتر میکند.</p></div></header><div class="editor-grid"><label><span>{{ form.category.label }}</span>{{ form.category }}{{ form.category.errors }}</label><label><span>{{ form.subject.label }}</span>{{ form.subject }}{{ form.subject.errors }}</label><label class="editor-wide"><span>{{ form.tags.label }}</span>{{ form.tags }}{{ form.tags.errors }}</label></div></section>
|
||||
<section><header><span>۳</span><div><h2>متن و اسناد پیام</h2><p>درخواست را شفاف بنویسید و اسناد لازم را برای همه تیکتهای خصوصی ضمیمه کنید.</p></div></header><label class="message-editor"><span>{{ form.body.label }}</span>{{ form.body }}{{ form.body.errors }}</label><label class="attachment-field"><span class="attachment-icon">+</span><div><b>{{ form.files.label }}</b><small>{{ form.files.help_text }}</small></div>{{ form.files }}{{ form.files.errors }}</label></section>
|
||||
<section><header><span>۳</span><div><h2>متن و اسناد پیام</h2><p>درخواست را شفاف بنویسید و اسناد لازم را برای همه تیکتهای خصوصی ضمیمه کنید.</p></div></header><label class="message-editor"><span>{{ form.body.label }}</span>{{ form.body }}{{ form.body.errors }}</label><div class="dispatch-attachment-picker media-picker"><div class="attachment-picker-copy"><span class="attachment-icon" aria-hidden="true">+</span><div><b>{{ form.files.label }}</b><small>{{ form.files.help_text }}</small></div></div><div class="media-picker-actions"><label class="media-picker-button gallery-button" for="dispatch-gallery-input">▣ افزودن عکس از گالری</label><label class="media-picker-button" for="dispatch-file-input">+ افزودن فایل</label></div><input id="dispatch-gallery-input" type="file" accept="image/*" multiple data-gallery-input><input id="dispatch-file-input" type="file" multiple data-document-input><span class="canonical-files">{{ form.files }}</span>{{ form.files.errors }}<small class="compression-note" data-compression-status>تصاویر بزرگ پیش از ارسال روی دستگاه شما بهینه میشوند؛ هر پیوست را میتوانید پیش از ثبت حذف کنید.</small><div class="file-preview" data-file-preview aria-live="polite" aria-label="پیشنمایش پیوستهای انتخابشده"></div></div></section>
|
||||
<footer><div><b>قبل از ارسال بررسی کنید</b><small>این عملیات برای همه اعضای گروه تیکت مستقل ایجاد میکند.</small></div><button type="submit">ایجاد تیکتها و قرار دادن پیامک در صف ←</button></footer>
|
||||
</form><aside class="dispatch-history"><header><div><small>ردیابی عملیات</small><h2>ارسالهای اخیر</h2></div><span>{{ page.paginator.count }}</span></header>
|
||||
<form method="get" class="dispatch-filter" aria-label="فیلتر ارسالها"><label class="dispatch-search"><span>جستجو</span><input type="search" name="q" value="{{ request.GET.q }}" placeholder="موضوع، گروه یا کارشناس"></label><div><label><span>وضعیت</span><select name="status"><option value="">همه وضعیتها</option>{% for value,label in dispatch_statuses %}<option value="{{ value }}" {% if request.GET.status == value %}selected{% endif %}>{{ label }}</option>{% endfor %}</select></label><label><span>واحد</span><select name="unit"><option value="">همه واحدها</option>{% for item in units %}<option value="{{ item.pk }}" {% if request.GET.unit == item.pk|stringformat:'s' %}selected{% endif %}>{{ item.name }}</option>{% endfor %}</select></label></div><label><span>گروه مخاطب</span><select name="group"><option value="">همه گروهها</option>{% for item in groups %}<option value="{{ item.pk }}" {% if request.GET.group == item.pk|stringformat:'s' %}selected{% endif %}>{{ item.unit.name }} — {{ item.name }}</option>{% endfor %}</select></label><div><label><span>از تاریخ</span><input type="date" name="created_from" value="{{ request.GET.created_from }}"></label><label><span>تا تاریخ</span><input type="date" name="created_to" value="{{ request.GET.created_to }}"></label></div><footer><button type="submit">اعمال فیلتر</button><a href="{% url 'staff_tickets:dispatch_create' %}">پاک کردن</a></footer></form>
|
||||
@@ -14,3 +14,4 @@
|
||||
{% if page.paginator.num_pages > 1 %}<nav class="operations-pagination" aria-label="صفحهبندی ارسالها">{% if page.has_previous %}<a href="?q={{ request.GET.q|urlencode }}&status={{ request.GET.status|urlencode }}&unit={{ request.GET.unit|urlencode }}&group={{ request.GET.group|urlencode }}&created_from={{ request.GET.created_from|urlencode }}&created_to={{ request.GET.created_to|urlencode }}&page={{ page.previous_page_number }}">قبلی</a>{% endif %}<span>صفحه {{ page.number }} از {{ page.paginator.num_pages }}</span>{% if page.has_next %}<a href="?q={{ request.GET.q|urlencode }}&status={{ request.GET.status|urlencode }}&unit={{ request.GET.unit|urlencode }}&group={{ request.GET.group|urlencode }}&created_from={{ request.GET.created_from|urlencode }}&created_to={{ request.GET.created_to|urlencode }}&page={{ page.next_page_number }}">بعدی</a>{% endif %}</nav>{% endif %}
|
||||
</aside></div>
|
||||
</div>{% endblock %}
|
||||
{% block extra_scripts %}<script defer src="{% static 'vendor/compressor.min.js' %}?v={{ static_asset_version }}"></script><script defer src="{% static 'js/attachment-uploader.js' %}?v={{ static_asset_version }}"></script>{% endblock %}
|
||||
@@ -1,24 +1,80 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}{{ page_title }}{% endblock %}
|
||||
{% block content %}<div class="operations-page operations-dispatch-detail">
|
||||
<section class="staff-page-head">
|
||||
<div><p class="eyebrow">شناسه ارسال {{ dispatch.public_id }}</p><h1>{{ dispatch.subject }}</h1><p>{{ dispatch.unit }} · گروه {{ dispatch.contact_group.name }} · {{ dispatch.recipient_count }} مخاطب</p></div>
|
||||
<div><a class="button" href="{% url 'staff_tickets:dispatch_create' %}">ارسال گروهی جدید</a>{% if metrics.failed %}<form method="post" action="{% url 'staff_tickets:dispatch_retry' dispatch.public_id %}" style="margin-top:.75rem">{% csrf_token %}<button class="button" type="submit">تلاش مجدد برای {{ metrics.failed }} ارسال ناموفق</button></form>{% endif %}</div>
|
||||
</section>
|
||||
<section class="stats-grid operations-metrics" aria-label="وضعیت ارسال گروهی"><article class="stat-card"><div class="stat-label">پیامک موفق</div><div class="stat-value">{{ metrics.sent }}</div></article><article class="stat-card"><div class="stat-label">در صف</div><div class="stat-value">{{ metrics.pending }}</div></article><article class="stat-card"><div class="stat-label">لینک مشاهدهشده</div><div class="stat-value">{{ metrics.opened }}</div></article><article class="stat-card"><div class="stat-label">پاسخ ثبتشده</div><div class="stat-value">{{ metrics.replied }}</div></article><article class="stat-card alert"><div class="stat-label">ارسال ناموفق</div><div class="stat-value">{{ metrics.failed }}</div></article></section>
|
||||
<section class="ticket-stream">
|
||||
<header class="stream-head"><div><span class="panel-index">نمای مقایسهای</span><h2>وضعیت دریافت و پاسخ مخاطبان</h2></div><span>{{ dispatch.get_status_display }}</span></header>
|
||||
<div class="table-scroll"><table><thead><tr><th>مخاطب</th><th>شماره</th><th>تیکت</th><th>وضعیت پیامک</th><th>وضعیت تیکت</th><th>بازدید لینک</th><th>پاسخ پیشنهادی</th></tr></thead><tbody>
|
||||
{% for ticket in tickets %}
|
||||
<tr>
|
||||
<td>{{ ticket.customer.customer_profile|default:ticket.customer.username }}</td>
|
||||
<td dir="ltr">{{ ticket.customer.local_mobile }}</td>
|
||||
<td><a href="{% url 'staff_tickets:detail' ticket.public_id %}">{{ ticket.ticket_number }}</a></td>
|
||||
<td>{% if ticket.sms_job %}{% if ticket.sms_job.status == 'SENT' %}پذیرفتهشده توسط درگاه{% else %}{{ ticket.sms_job.get_status_display }}{% endif %}{% if ticket.sms_job.provider_message_id %}<small class="table-subtitle" dir="ltr">مرجع: {{ ticket.sms_job.provider_message_id|truncatechars:45 }}</small>{% endif %}{% if ticket.sms_job.last_error %}<small class="table-subtitle">{{ ticket.sms_job.last_error|truncatechars:60 }}</small>{% endif %}{% else %}ثبت نشده{% endif %}</td><td>{{ ticket.get_status_display }}</td>
|
||||
<td>{% for link in ticket.magic_links.all %}{{ link.view_count }}{% if not forloop.last %} + {% endif %}{% empty %}ارسال نشده{% endfor %}</td>
|
||||
<td>{% if ticket.latest_customer_response %}{{ ticket.latest_customer_response.body|truncatechars:120 }}<small class="table-subtitle">{{ ticket.latest_customer_response.created_at|date:'Y/m/d H:i' }}</small>{% else %}هنوز پاسخی ثبت نشده{% endif %}</td>
|
||||
</tr>
|
||||
{% empty %}<tr><td colspan="7">برای این ارسال تیکتی ثبت نشده است.</td></tr>{% endfor %}
|
||||
</tbody></table></div>
|
||||
</section></div>
|
||||
{% extends 'base.html' %}{% load jalali %}
|
||||
{% block title %}{{ dispatch.subject }}{% endblock %}
|
||||
{% block content %}
|
||||
<div class="operations-page operations-dispatch-detail">
|
||||
<section class="dispatch-report-hero">
|
||||
<div class="dispatch-report-title">
|
||||
<div class="dispatch-title-line"><span class="report-status status-{{ dispatch.status|lower }}">{{ dispatch.get_status_display }}</span><span class="eyebrow">گزارش ارسال گروهی</span></div>
|
||||
<h1>{{ dispatch.subject }}</h1>
|
||||
<div class="dispatch-meta-list">
|
||||
<span><b>واحد</b>{{ dispatch.unit }}</span>
|
||||
<span><b>گروه مخاطبان</b>{{ dispatch.contact_group.name }}</span>
|
||||
<span><b>تعداد گیرندگان</b>{{ dispatch.recipient_count }} نفر</span>
|
||||
<span><b>زمان ایجاد</b><time datetime="{{ dispatch.created_at|date:'c' }}">{{ dispatch.created_at|jalali_datetime }}</time></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dispatch-head-actions">
|
||||
<a class="button" href="{% url 'staff_tickets:dispatch_create' %}">+ ارسال گروهی جدید</a>
|
||||
{% if metrics.failed %}<form method="post" action="{% url 'staff_tickets:dispatch_retry' dispatch.public_id %}">{% csrf_token %}<button class="button button-danger-soft" type="submit">↻ تلاش مجدد {{ metrics.failed }} پیامک</button></form>{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="dispatch-original-message" aria-labelledby="dispatch-message-title">
|
||||
<header><div><span class="panel-index">متن اولیه کارشناس</span><h2 id="dispatch-message-title">پیام اصلی این ارسال</h2></div><span>شناسه عملیات: <b dir="ltr">{{ dispatch.public_id }}</b></span></header>
|
||||
<blockquote>{{ dispatch.body|linebreaksbr }}</blockquote>
|
||||
<footer><span>ایجادکننده: <b>{{ dispatch.created_by.staff_profile.display_name|default:dispatch.created_by.username }}</b></span><span>این متن در یک تیکت مستقل برای هر مخاطب ثبت شده است.</span></footer>
|
||||
</section>
|
||||
|
||||
<section class="delivery-overview" aria-label="خلاصه دلیوری">
|
||||
<div class="delivery-progress-copy"><div><span class="panel-index">پیشرفت تحویل</span><strong>{{ metrics.delivery_percent }}٪</strong></div><p>{{ metrics.delivered }} از {{ dispatch.recipient_count }} پیامک تحویل قطعی شده است.</p></div>
|
||||
<div class="delivery-progress-track" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="{{ metrics.delivery_percent }}"><span style="width:{{ metrics.delivery_percent }}%"></span></div>
|
||||
<div class="delivery-overview-note">{% if metrics.tracking %}<b>{{ metrics.tracking }} پیامک در حال پیگیری است</b><span>نتیجههای موقت هر ۳ دقیقه تا ۴۵ دقیقه دوباره استعلام میشوند.</span>{% elif metrics.failed %}<b>موارد ناموفق نیاز به بررسی دارند</b><span>علت دقیق و دکمه تلاش مجدد در ردیف مخاطب قرار دارد.</span>{% else %}<b>پردازش صف پایان یافته است</b><span>برای جزئیات هر مخاطب، گزارش پایین صفحه را ببینید.</span>{% endif %}</div>
|
||||
</section>
|
||||
|
||||
<section class="dispatch-kpi-grid" aria-label="شاخصهای ارسال گروهی">
|
||||
<article class="dispatch-kpi kpi-success"><span class="kpi-icon">✓</span><div><small>تحویل قطعی</small><strong>{{ metrics.delivered }}</strong><p>رسیده به گوشی</p></div></article>
|
||||
<article class="dispatch-kpi kpi-info"><span class="kpi-icon">◌</span><div><small>پیگیری فعال</small><strong>{{ metrics.tracking }}</strong><p>{{ metrics.sent }} مورد پذیرفتهشده توسط درگاه</p></div></article>
|
||||
<article class="dispatch-kpi kpi-danger"><span class="kpi-icon">!</span><div><small>نیازمند اقدام</small><strong>{{ metrics.failed }}</strong><p>ناموفق یا تحویلنشده قطعی</p></div></article>
|
||||
<article class="dispatch-kpi kpi-view"><span class="kpi-icon">◉</span><div><small>تعامل با لینک</small><strong>{{ metrics.opened }}</strong><p>{{ metrics.total_views }} بازدید · {{ metrics.not_opened }} مخاطب بدون بازدید</p></div></article>
|
||||
<article class="dispatch-kpi kpi-reply"><span class="kpi-icon">↵</span><div><small>پاسخ دریافتی</small><strong>{{ metrics.replied }}</strong><p>پاسخ ثبتشده در تیکتها</p></div></article>
|
||||
</section>
|
||||
|
||||
<section class="ticket-stream dispatch-recipient-report">
|
||||
<header class="stream-head"><div><span class="panel-index">جزئیات هر گیرنده</span><h2>وضعیت پیامک، مشاهده و پاسخ</h2></div><span>{{ dispatch.recipient_count }} مخاطب</span></header>
|
||||
<div class="table-scroll"><table class="dispatch-detail-table"><thead><tr><th>مخاطب</th><th>تیکت</th><th>وضعیت پیامک</th><th>وضعیت تیکت</th><th>مشاهده لینک</th><th>آخرین پاسخ</th></tr></thead><tbody>
|
||||
{% for ticket in tickets %}
|
||||
<tr>
|
||||
<td data-label="مخاطب"><div class="recipient-cell"><span class="recipient-avatar">{{ ticket.customer.customer_profile.first_name|first|default:'•' }}</span><div><strong>{{ ticket.customer.customer_profile|default:ticket.customer.username }}</strong><small dir="ltr">{{ ticket.customer.local_mobile }}</small></div></div></td>
|
||||
<td data-label="تیکت"><a class="ticket-reference" href="{% url 'staff_tickets:detail' ticket.public_id %}"><strong dir="ltr">{{ ticket.ticket_number }}</strong><span>مشاهده تیکت ←</span></a></td>
|
||||
<td data-label="وضعیت پیامک" class="dispatch-sms-state">
|
||||
<div class="sms-state-card tone-{{ ticket.sms_state_tone }}">
|
||||
<span class="sms-state-icon">{% if ticket.sms_state_tone == 'success' %}✓{% elif ticket.sms_state_tone == 'danger' %}!{% elif ticket.sms_state_tone == 'warning' %}◌{% else %}↑{% endif %}</span>
|
||||
<div><strong>{{ ticket.sms_state_title }}</strong><p>{{ ticket.sms_state_hint }}</p>{% if ticket.sms_job.delivery_next_check_at %}<small>استعلام بعدی: {{ ticket.sms_job.delivery_next_check_at|timeuntil }} دیگر</small>{% endif %}</div>
|
||||
</div>
|
||||
{% if ticket.sms_job %}
|
||||
<details class="sms-technical-details">
|
||||
<summary>جزئیات فنی و رهگیری</summary>
|
||||
<dl>
|
||||
<div><dt>ارسال</dt><dd>{% if ticket.sms_job.attempts == 1 %}موفق در اولین تلاش{% else %}{{ ticket.sms_job.attempts }} تلاش از {{ sms_max_attempts }}{% endif %}</dd></div>
|
||||
{% if ticket.sms_job.provider_message_id %}<div><dt>شناسه رهگیری</dt><dd dir="ltr">{{ ticket.sms_job.provider_message_id }}</dd></div>{% endif %}
|
||||
{% if ticket.sms_job.delivery_status_code %}<div><dt>کد دلیوری</dt><dd dir="ltr">{{ ticket.sms_job.delivery_status_code }}</dd></div>{% endif %}
|
||||
{% if ticket.sms_job.delivery_check_attempts %}<div><dt>تعداد استعلام</dt><dd>{{ ticket.sms_job.delivery_check_attempts }} نوبت</dd></div>{% endif %}
|
||||
{% if ticket.sms_job.delivery_checked_at %}<div><dt>آخرین استعلام</dt><dd>{{ ticket.sms_job.delivery_checked_at|jalali_datetime }}</dd></div>{% endif %}
|
||||
</dl>
|
||||
{% if ticket.sms_job.delivery_description %}<p class="gateway-technical-message">{{ ticket.sms_job.delivery_description }}</p>{% endif %}
|
||||
</details>
|
||||
{% if ticket.sms_job.last_error %}<div class="sms-error-panel"><b>علت دقیق عدم ارسال</b><p>{{ ticket.sms_job.last_error }}</p></div>{% endif %}
|
||||
{% if ticket.sms_retryable %}<form method="post" action="{% url 'staff_tickets:dispatch_retry' dispatch.public_id %}" class="inline-retry-form">{% csrf_token %}<input type="hidden" name="job_id" value="{{ ticket.sms_job.pk }}"><button type="submit">↻ تلاش مجدد همین پیامک</button></form>{% endif %}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td data-label="وضعیت تیکت"><span class="ticket-state-pill state-{{ ticket.status|lower }}">{{ ticket.get_status_display }}</span></td>
|
||||
<td data-label="مشاهده لینک">
|
||||
<div class="engagement-state {% if ticket.magic_total_views %}has-engagement{% endif %}"><span>{% if ticket.magic_total_views %}◉{% else %}○{% endif %}</span><div>{% if ticket.magic_total_views %}<strong>{{ ticket.magic_total_views }} بازدید</strong>{% if ticket.magic_last_used_at %}<small>آخرین بازدید {{ ticket.magic_last_used_at|jalali_datetime }}</small>{% endif %}{% elif ticket.magic_link_count %}<strong>مشاهده نشده</strong><small>{{ ticket.magic_active_count }} لینک فعال</small>{% else %}<strong>لینک صادر نشده</strong>{% endif %}</div></div>
|
||||
</td>
|
||||
<td data-label="آخرین پاسخ">{% if ticket.latest_customer_response %}<div class="recipient-reply"><p>{{ ticket.latest_customer_response.body|truncatechars:140 }}</p><small>{{ ticket.latest_customer_response.created_at|jalali_datetime }}</small></div>{% else %}<span class="no-reply-state">هنوز پاسخی ثبت نشده</span>{% endif %}</td>
|
||||
</tr>
|
||||
{% empty %}<tr><td colspan="6">برای این ارسال تیکتی ثبت نشده است.</td></tr>{% endfor %}
|
||||
</tbody></table></div>
|
||||
</section>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -37,7 +37,8 @@ class TicketConversationPage(BasePage):
|
||||
class PublicTicketCreatePage(BasePage):
|
||||
def open(self):
|
||||
self.goto("/tickets/new/")
|
||||
expect(self.unit).to_be_visible()
|
||||
expect(self.page.locator('[data-wizard-step="1"]')).to_be_visible()
|
||||
expect(self.page.locator('[data-ticket-subject]')).to_be_visible()
|
||||
|
||||
@property
|
||||
def unit(self):
|
||||
@@ -66,7 +67,10 @@ class PublicTicketCreatePage(BasePage):
|
||||
and f"unit={unit_id}" in response.url
|
||||
and response.status == 200
|
||||
) as response_info:
|
||||
self.unit.select_option(unit_id)
|
||||
self.unit.evaluate(
|
||||
"(node, value) => { node.value = value; node.dispatchEvent(new Event('change', {bubbles: true})); }",
|
||||
unit_id,
|
||||
)
|
||||
payload = response_info.value.json()
|
||||
expect(self.page.locator("[data-general-queue]")).to_have_attribute("data-unit-id", unit_id)
|
||||
expected_ids = {str(item["id"]) for item in payload["experts"]}
|
||||
@@ -84,17 +88,24 @@ class PublicTicketCreatePage(BasePage):
|
||||
card = self.expert_picker.locator("label.expert-card").filter(has_text=display_name)
|
||||
expect(card).to_have_count(1)
|
||||
radio = card.locator('input[name="selected_expert"]')
|
||||
radio.check()
|
||||
radio.evaluate(
|
||||
"node => { node.checked = true; node.dispatchEvent(new Event('change', {bubbles: true})); }"
|
||||
)
|
||||
expect(radio).to_be_checked()
|
||||
return radio.get_attribute("value")
|
||||
|
||||
def select_general_queue(self):
|
||||
radio = self.queue.locator('input[name="selected_expert"]')
|
||||
radio.check()
|
||||
radio.evaluate(
|
||||
"node => { node.checked = true; node.dispatchEvent(new Event('change', {bubbles: true})); }"
|
||||
)
|
||||
expect(radio).to_be_checked()
|
||||
|
||||
def select_category(self, category_id):
|
||||
self.category.select_option(str(category_id))
|
||||
self.category.evaluate(
|
||||
"(node, value) => { node.value = value; node.dispatchEvent(new Event('change', {bubbles: true})); }",
|
||||
str(category_id),
|
||||
)
|
||||
expect(self.category).to_have_value(str(category_id))
|
||||
|
||||
def upload_gallery(self, files, *, total_count=None):
|
||||
@@ -108,13 +119,22 @@ class PublicTicketCreatePage(BasePage):
|
||||
def remove_attachment(self, filename):
|
||||
chip = self.file_preview.locator(".file-chip").filter(has_text=filename)
|
||||
expect(chip).to_have_count(1)
|
||||
chip.get_by_role("button", name="حذف فایل").click()
|
||||
chip.locator("button").evaluate("node => node.click()")
|
||||
expect(self.file_preview.locator(".file-chip").filter(has_text=filename)).to_have_count(0)
|
||||
|
||||
def submit(self, *, subject, body):
|
||||
self.page.locator('input[name="subject"]').fill(subject)
|
||||
self.page.locator('[data-wizard-step="1"] [data-wizard-next]').click()
|
||||
expect(self.page.locator('[data-wizard-step="2"]')).to_be_visible()
|
||||
self.page.locator('[data-wizard-step="2"] [data-wizard-next]').click()
|
||||
expect(self.page.locator('[data-wizard-step="3"]')).to_be_visible()
|
||||
self.page.locator('[data-wizard-step="3"] [data-wizard-next]').click()
|
||||
expect(self.page.locator('[data-wizard-step="4"]')).to_be_visible()
|
||||
self.page.locator('textarea[name="body"]').fill(body)
|
||||
self.page.locator('[data-wizard-step="4"] [data-wizard-next]').click()
|
||||
expect(self.page.locator('[data-wizard-step="5"]')).to_be_visible()
|
||||
expect(self.page.locator('[data-review-subject]')).to_have_text(subject)
|
||||
with self.page.expect_navigation(wait_until="domcontentloaded"):
|
||||
self.page.get_by_role("button", name="ثبت و ارسال درخواست").click()
|
||||
self.page.get_by_role("button", name="ثبت و ارسال نهایی").click()
|
||||
expect(self.page).to_have_url(re.compile(r"/tickets/(?!new/)[^/]+/$"))
|
||||
expect(self.page.locator("h1")).to_have_text(subject)
|
||||
@@ -1,4 +1,5 @@
|
||||
import io
|
||||
import re
|
||||
|
||||
import pytest
|
||||
from django.core.management import call_command
|
||||
@@ -133,6 +134,8 @@ def test_e2e_subject_stays_empty_for_manual_and_search_routing_until_user_types(
|
||||
with browser_page() as page:
|
||||
form = open_form(page, live_server, customer)
|
||||
subject_input = page.locator("[data-ticket-subject]")
|
||||
page.locator(".ticket-manual-routing summary").click()
|
||||
expect(form.unit).to_be_visible()
|
||||
unit_box = form.unit.bounding_box()
|
||||
category_box = form.category.bounding_box()
|
||||
assert unit_box and category_box
|
||||
@@ -159,6 +162,115 @@ def test_e2e_subject_stays_empty_for_manual_and_search_routing_until_user_types(
|
||||
)
|
||||
|
||||
|
||||
def test_e2e_customer_wizard_validation_preview_edit_and_floating_submit(
|
||||
live_server, browser_page, public_workflow
|
||||
):
|
||||
customer, units, categories = public_workflow
|
||||
subject = "بازبینی مرحلهای و ویرایش E2E"
|
||||
with browser_page() as page:
|
||||
page.set_viewport_size({"width": 390, "height": 640})
|
||||
form = open_form(page, live_server, customer)
|
||||
expect(page.locator('[data-wizard-step="1"]')).to_be_visible()
|
||||
expect(page.locator('[data-wizard-step="2"]')).to_be_hidden()
|
||||
expect(page.locator('[data-ticket-search-results]')).to_be_hidden()
|
||||
|
||||
page.locator('[data-wizard-step="1"] [data-wizard-next]').click()
|
||||
error = page.locator('[data-wizard-error="1"]')
|
||||
expect(error).to_be_visible()
|
||||
expect(error).to_be_in_viewport()
|
||||
expect(page.locator('[data-ticket-subject]')).to_have_attribute("aria-invalid", "true")
|
||||
expect(form.category).to_be_visible()
|
||||
assert page.evaluate("document.activeElement === document.querySelector('[data-wizard-error=\"1\"]')")
|
||||
|
||||
form.select_search_result("عمومی فروش", "عمومی فروش")
|
||||
expect(page.locator('[data-ticket-subject]')).to_have_value("")
|
||||
page.locator('[data-ticket-subject]').fill(subject)
|
||||
expect(page.locator('[data-ticket-subject]')).to_have_value(subject)
|
||||
page.locator('[data-wizard-step="1"] [data-wizard-next]').click()
|
||||
page.locator('[data-wizard-step="2"] [data-wizard-next]').click()
|
||||
page.locator('[data-wizard-step="3"] [data-wizard-next]').click()
|
||||
page.locator('textarea[name="body"]').fill("شرح خوانا برای پیشنمایش مرحله پنجم")
|
||||
page.locator('[data-wizard-step="4"] [data-wizard-next]').click()
|
||||
|
||||
expect(page.locator('[data-wizard-step="5"]')).to_be_visible()
|
||||
expect(page.locator('[data-review-subject]')).to_have_text(subject)
|
||||
expect(page.locator('[data-review-body]')).to_contain_text("شرح خوانا")
|
||||
final_submit = page.get_by_role("button", name="ثبت و ارسال نهایی")
|
||||
expect(final_submit).to_be_visible()
|
||||
submit_box = final_submit.bounding_box()
|
||||
assert submit_box and submit_box["y"] + submit_box["height"] <= page.viewport_size["height"]
|
||||
assert page.locator('[data-review-subject]').evaluate("node => parseFloat(getComputedStyle(node).fontSize)") >= 16
|
||||
assert page.locator('[data-review-body]').evaluate("node => parseFloat(getComputedStyle(node).fontSize)") >= 14
|
||||
|
||||
page.locator('[data-wizard-edit="1"]').click()
|
||||
expect(page.locator('[data-ticket-subject]')).to_have_value(subject)
|
||||
expect(page.locator('[data-ticket-unit]')).to_have_value(str(units["sales"].pk))
|
||||
expect(page.locator('[data-ticket-category]')).to_have_value(str(categories["sales_general"].pk))
|
||||
|
||||
|
||||
def test_e2e_customer_wizard_auto_stops_voice_and_submits_audio_only_ticket(
|
||||
live_server, browser_page, public_workflow
|
||||
):
|
||||
customer, units, categories = public_workflow
|
||||
subject = "ثبت تیکت فقط با پیام صوتی E2E"
|
||||
with browser_page() as page:
|
||||
page.add_init_script(
|
||||
"""
|
||||
window.__microphoneTrackStops = 0;
|
||||
Object.defineProperty(navigator, 'mediaDevices', {
|
||||
configurable: true,
|
||||
value: {getUserMedia: async () => ({getTracks: () => [{stop: () => {window.__microphoneTrackStops += 1;}}]})},
|
||||
});
|
||||
class FakeMediaRecorder {
|
||||
constructor(stream, options = {}) {this.stream = stream; this.mimeType = options.mimeType || 'audio/wav'; this.state = 'inactive';}
|
||||
start() {this.state = 'recording';}
|
||||
pause() {this.state = 'paused';}
|
||||
resume() {this.state = 'recording';}
|
||||
stop() {
|
||||
this.state = 'inactive';
|
||||
const wav = new Uint8Array([
|
||||
82,73,70,70,40,0,0,0,87,65,86,69,102,109,116,32,
|
||||
16,0,0,0,1,0,1,0,68,172,0,0,136,88,1,0,
|
||||
2,0,16,0,100,97,116,97,4,0,0,0,0,0,0,0,
|
||||
]);
|
||||
this.ondataavailable?.({data: new Blob([wav], {type: this.mimeType})});
|
||||
setTimeout(() => this.onstop?.(), 0);
|
||||
}
|
||||
static isTypeSupported(type) {return type === 'audio/wav';}
|
||||
}
|
||||
Object.defineProperty(window, 'MediaRecorder', {configurable: true, value: FakeMediaRecorder});
|
||||
"""
|
||||
)
|
||||
form = open_form(page, live_server, customer)
|
||||
form.select_unit(units["sales"].pk)
|
||||
form.select_category(categories["sales_general"].pk)
|
||||
form.select_general_queue()
|
||||
page.locator('[data-ticket-subject]').fill(subject)
|
||||
page.locator('[data-wizard-step="1"] [data-wizard-next]').click()
|
||||
page.locator('[data-wizard-step="2"] [data-wizard-next]').click()
|
||||
page.locator('[data-wizard-step="3"] [data-wizard-next]').click()
|
||||
expect(page.locator('[data-wizard-step="4"]')).to_be_visible()
|
||||
|
||||
page.get_by_role("button", name="شروع ضبط صدا").click()
|
||||
expect(page.locator('[data-voice-live]')).to_be_visible()
|
||||
page.get_by_role("button", name="مشاهده پیشنمایش").click()
|
||||
expect(page.locator('[data-wizard-step="5"]')).to_be_visible()
|
||||
expect(page.locator('[data-review-audio]')).to_be_visible()
|
||||
assert page.evaluate("window.__microphoneTrackStops") == 1
|
||||
|
||||
with page.expect_navigation(wait_until="domcontentloaded"):
|
||||
page.get_by_role("button", name="ثبت و ارسال نهایی").click()
|
||||
expect(page).to_have_url(re.compile(r"/tickets/(?!new/)[^/]+/$"))
|
||||
|
||||
ticket = Ticket.objects.get(subject=subject)
|
||||
assert ticket.owning_unit == units["sales"]
|
||||
assert ticket.category == categories["sales_general"]
|
||||
attachments = list(ticket.ticket_messages.get().attachments.all())
|
||||
assert len(attachments) == 1
|
||||
assert attachments[0].mime_type.startswith("audio/")
|
||||
assert attachments[0].original_name.startswith("voice-")
|
||||
|
||||
|
||||
def test_e2e_experts_follow_configured_numeric_order(live_server, browser_page, public_workflow):
|
||||
customer, units, _ = public_workflow
|
||||
configured = {
|
||||
|
||||
@@ -3,15 +3,16 @@ import base64
|
||||
import pytest
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from playwright.sync_api import expect
|
||||
|
||||
from apps.accounts.models import TicketMagicLink, User
|
||||
from apps.customers.models import ContactGroup, CustomerProfile
|
||||
from apps.core.models import PageTitle, SiteBranding
|
||||
from apps.notifications.models import SMSTemplate
|
||||
from apps.notifications.models import NotificationJob, SMSTemplate
|
||||
from apps.staff.models import OrganizationalUnit, StaffProfile
|
||||
from apps.tickets.models import TicketCategory
|
||||
from apps.tickets.services import create_ticket
|
||||
from apps.tickets.services import create_ticket, dispatch_group_tickets
|
||||
from tests.e2e.pages.auth import AuthenticationPage
|
||||
|
||||
|
||||
@@ -79,6 +80,20 @@ def test_staff_operations_pages_are_authorized_and_visually_styled(live_server,
|
||||
expect(page.locator(".staff-developer-credit")).to_contain_text("امضای مستقل sidebar E2E")
|
||||
expect(page.get_by_role("searchbox", name="جستجو")).to_be_visible()
|
||||
expect(page.locator('link[href*="dispatches.css"]')).to_have_count(1)
|
||||
expect(page.get_by_text("افزودن عکس از گالری")).to_be_visible()
|
||||
expect(page.locator('label[for="dispatch-file-input"]')).to_be_visible()
|
||||
tiny_png = base64.b64decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=")
|
||||
page.locator("#dispatch-gallery-input").set_input_files([
|
||||
{"name":"dispatch-first.png","mimeType":"image/png","buffer":tiny_png},
|
||||
{"name":"dispatch-second.png","mimeType":"image/png","buffer":tiny_png},
|
||||
])
|
||||
expect(page.locator(".dispatch-attachment-picker [data-file-preview] .file-chip")).to_have_count(2)
|
||||
expect(page.locator(".dispatch-attachment-picker [data-file-preview] img")).to_have_count(2)
|
||||
page.locator("#dispatch-file-input").set_input_files({"name":"dispatch-document.pdf","mimeType":"application/pdf","buffer":b"%PDF-1.4\n%%EOF"})
|
||||
expect(page.locator(".dispatch-attachment-picker [data-file-preview] .file-chip")).to_have_count(3)
|
||||
page.locator(".dispatch-attachment-picker [data-file-preview] .file-chip button").first.click()
|
||||
expect(page.locator(".dispatch-attachment-picker [data-file-preview] .file-chip")).to_have_count(2)
|
||||
assert page.locator('.dispatch-attachment-picker input[name="files"]').evaluate("input => input.files.length") == 2
|
||||
|
||||
page.goto(f"{live_server.url}{reverse('staff_tickets:sms_templates')}", wait_until="domcontentloaded")
|
||||
expect(page.get_by_text("الگوی عمومی سیستم").first).to_be_visible()
|
||||
@@ -105,7 +120,6 @@ def test_staff_operations_pages_are_authorized_and_visually_styled(live_server,
|
||||
expect(page.locator(f'input[name="groups"][value="{secondary_group.pk}"]')).to_be_checked()
|
||||
|
||||
page.goto(f"{live_server.url}{reverse('staff_tickets:create')}", wait_until="domcontentloaded")
|
||||
tiny_png = base64.b64decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=")
|
||||
page.locator("#staff-ticket-gallery-input").set_input_files([
|
||||
{"name": "first.png", "mimeType": "image/png", "buffer": tiny_png},
|
||||
{"name": "second.png", "mimeType": "image/png", "buffer": tiny_png},
|
||||
@@ -115,3 +129,52 @@ def test_staff_operations_pages_are_authorized_and_visually_styled(live_server,
|
||||
page.locator("[data-file-preview] .file-chip button").first.click()
|
||||
expect(page.locator("[data-file-preview] .file-chip")).to_have_count(1)
|
||||
assert page.locator('input[name="files"]').evaluate("input => input.files.length") == 1
|
||||
|
||||
|
||||
def test_dispatch_report_presents_delivery_and_engagement_professionally(live_server, browser_page):
|
||||
from apps.core.templatetags.jalali import jalali_datetime
|
||||
|
||||
unit=OrganizationalUnit.objects.create(name="فروش گزارش E2E",slug="dispatch-report-e2e")
|
||||
unit.permissions.add(*Permission.objects.filter(content_type__app_label="tickets",codename__in=("view_ticket","dispatch_group_tickets")))
|
||||
staff=User.objects.create_user(username="dispatch-report-agent",password=PASSWORD,account_type=User.AccountType.STAFF)
|
||||
StaffProfile.objects.create(user=staff,display_name="کارشناس گزارش",primary_unit=unit)
|
||||
customer=User.objects.create_user(username="dispatch-report-customer",mobile="09120009131",account_type=User.AccountType.CUSTOMER)
|
||||
profile=CustomerProfile.objects.create(user=customer,first_name="مخاطب",last_name="آزمایشی")
|
||||
group=ContactGroup.objects.create(unit=unit,name="گروه گزارش حرفهای")
|
||||
profile.groups.add(group)
|
||||
category=TicketCategory.objects.create(unit=unit,title="استعلام گزارش",slug="dispatch-report-category-e2e")
|
||||
dispatch,tickets=dispatch_group_tickets(user=staff,unit=unit,contact_group=group,category=category,subject="استعلام حرفهای",body="این متن اولیه کارشناس باید بالای گزارش دیده شود.")
|
||||
ticket=tickets[0]
|
||||
job=NotificationJob.objects.get(ticket=ticket,event_type=SMSTemplate.Event.STAFF_TICKET_CREATED)
|
||||
NotificationJob.objects.filter(pk=job.pk).update(
|
||||
status=NotificationJob.Status.SENT,attempts=1,sent_at=timezone.now(),
|
||||
provider_message_id="1111182358",provider_status_code="1111182358",
|
||||
delivery_status=NotificationJob.DeliveryStatus.DELIVERED,delivery_status_code="5",
|
||||
delivery_description="0098SMS [کد 5]: پیامک به گوشی گیرنده رسیده است.",
|
||||
delivery_check_attempts=2,delivery_checked_at=timezone.now(),delivery_next_check_at=None,
|
||||
)
|
||||
links=[TicketMagicLink.issue_with_record(ticket,created_by=staff)[1] for _ in range(4)]
|
||||
links[0].view_count=2; links[0].used_at=timezone.now(); links[0].save(update_fields=["view_count","used_at"])
|
||||
|
||||
with browser_page() as page:
|
||||
AuthenticationPage(page,live_server.url).login_staff(staff,PASSWORD)
|
||||
page.goto(f"{live_server.url}{reverse('staff_tickets:dispatch_detail',args=[dispatch.public_id])}",wait_until="domcontentloaded")
|
||||
expect(page.get_by_role("heading",name="استعلام حرفهای",level=1)).to_be_visible()
|
||||
expect(page.get_by_text("این متن اولیه کارشناس باید بالای گزارش دیده شود.")).to_be_visible()
|
||||
expect(page.locator(".sms-state-card")).to_contain_text("تحویل به گوشی تأیید شد")
|
||||
expect(page.locator(".engagement-state")).to_contain_text("2 بازدید")
|
||||
expect(page.locator('[role="progressbar"]')).to_have_attribute("aria-valuenow","100")
|
||||
expect(page.locator(".delivery-progress-copy")).to_contain_text("100٪")
|
||||
assert float(page.locator(".sms-state-card strong").evaluate("node => parseFloat(getComputedStyle(node).fontSize)")) >= 12
|
||||
assert float(page.locator(".delivery-progress-track").evaluate("node => parseFloat(getComputedStyle(node).height)")) <= 8
|
||||
created_text=page.locator(".dispatch-meta-list time").inner_text()
|
||||
assert created_text == jalali_datetime(dispatch.created_at)
|
||||
assert "0 + 0" not in page.locator("body").inner_text()
|
||||
page.get_by_text("جزئیات فنی و رهگیری").click()
|
||||
expect(page.get_by_text("1111182358")).to_be_visible()
|
||||
expect(page.get_by_text("موفق در اولین تلاش")).to_be_visible()
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= document.documentElement.clientWidth + 1")
|
||||
|
||||
page.set_viewport_size({"width":390,"height":844})
|
||||
expect(page.locator(".dispatch-detail-table tbody tr").first).to_be_visible()
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= document.documentElement.clientWidth + 1")
|
||||
+143
-10
@@ -421,6 +421,105 @@ def test_due_unread_staff_reply_is_sent_once(category,customer,monkeypatch):
|
||||
call_command("process_notifications",once=True,job_id=job.pk)
|
||||
assert len(sent)==1
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_every_failed_sms_gets_exactly_three_retries_three_minutes_apart(category,customer,monkeypatch,settings):
|
||||
from django.core.management import call_command
|
||||
from apps.integrations.sms.base import SMSResult
|
||||
from apps.notifications.management.commands import process_notifications
|
||||
settings.SMS_MAX_RETRIES=3
|
||||
settings.SMS_RETRY_DELAY_SECONDS=180
|
||||
ticket=create_ticket(customer=customer,category=category,subject="آزمون retry پیامک",body="متن")
|
||||
job=NotificationJob.objects.get(ticket=ticket,event_type="CUSTOMER_TICKET_CREATED")
|
||||
calls=[]
|
||||
class Provider:
|
||||
def send_message(self,mobile,message):
|
||||
calls.append((mobile,message))
|
||||
return SMSResult(False,error="0098SMS [کد -19]: شارژ پنل برای ارسال کافی نیست.",provider_code="-19")
|
||||
monkeypatch.setattr(process_notifications,"get_sms_provider",lambda:Provider())
|
||||
|
||||
for attempt in range(1,5):
|
||||
before=timezone.now()
|
||||
call_command("process_notifications",once=True,job_id=job.pk)
|
||||
job.refresh_from_db()
|
||||
assert job.attempts==attempt
|
||||
assert job.provider_status_code=="-19"
|
||||
assert "کد -19" in job.last_error
|
||||
if attempt<4:
|
||||
assert job.status==NotificationJob.Status.RETRYING
|
||||
assert timedelta(seconds=179) <= job.next_attempt_at-before <= timedelta(seconds=181)
|
||||
NotificationJob.objects.filter(pk=job.pk).update(next_attempt_at=timezone.now()-timedelta(seconds=1))
|
||||
else:
|
||||
assert job.status==NotificationJob.Status.FAILED
|
||||
|
||||
call_command("process_notifications",once=True,job_id=job.pk)
|
||||
assert len(calls)==4
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_worker_persists_0098_delivery_result(category,customer,monkeypatch,settings):
|
||||
from django.core.management import call_command
|
||||
from apps.integrations.sms.base import SMSDeliveryResult
|
||||
from apps.notifications.management.commands import process_notifications
|
||||
ticket=create_ticket(customer=customer,category=category,subject="استعلام دلیوری",body="متن")
|
||||
job=NotificationJob.objects.get(ticket=ticket,event_type="CUSTOMER_TICKET_CREATED")
|
||||
NotificationJob.objects.filter(pk=job.pk).update(
|
||||
status=NotificationJob.Status.SENT,
|
||||
provider_message_id="123456789",
|
||||
delivery_status=NotificationJob.DeliveryStatus.PENDING,
|
||||
delivery_next_check_at=timezone.now()-timedelta(seconds=1),
|
||||
)
|
||||
class Provider:
|
||||
def get_delivery_status(self,provider_id):
|
||||
assert provider_id=="123456789"
|
||||
return SMSDeliveryResult("DELIVERED","5","0098SMS [کد 5]: پیامک به گوشی گیرنده رسیده است.")
|
||||
monkeypatch.setattr(process_notifications,"get_sms_provider",lambda:Provider())
|
||||
|
||||
call_command("process_notifications",once=True,job_id=job.pk)
|
||||
job.refresh_from_db()
|
||||
assert job.delivery_status==NotificationJob.DeliveryStatus.DELIVERED
|
||||
assert job.delivery_status_code=="5"
|
||||
assert job.delivery_next_check_at is None
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_worker_rechecks_provisional_delivery_until_definitive_result(category,customer,monkeypatch,settings):
|
||||
from django.core.management import call_command
|
||||
from apps.integrations.sms.base import SMSDeliveryResult
|
||||
from apps.notifications.management.commands import process_notifications
|
||||
settings.SMS_DELIVERY_CHECK_DELAY_SECONDS=180
|
||||
settings.SMS_DELIVERY_MAX_CHECKS=15
|
||||
ticket=create_ticket(customer=customer,category=category,subject="پیگیری طولانی دلیوری",body="متن")
|
||||
job=NotificationJob.objects.get(ticket=ticket,event_type="CUSTOMER_TICKET_CREATED")
|
||||
NotificationJob.objects.filter(pk=job.pk).update(
|
||||
status=NotificationJob.Status.SENT, sent_at=timezone.now(), provider_message_id="123456789",
|
||||
delivery_status=NotificationJob.DeliveryStatus.PENDING,
|
||||
delivery_next_check_at=timezone.now()-timedelta(seconds=1),
|
||||
)
|
||||
results=[
|
||||
SMSDeliveryResult("UNKNOWN","8","وضعیت هنوز مشخص نشده است.",is_final=False),
|
||||
SMSDeliveryResult("UNDELIVERED","6","تحویل هنوز تأیید نشده است.",is_final=False),
|
||||
SMSDeliveryResult("DELIVERED","5","پیامک به گوشی رسیده است."),
|
||||
]
|
||||
class Provider:
|
||||
def get_delivery_status(self,provider_id): return results.pop(0)
|
||||
provider=Provider()
|
||||
monkeypatch.setattr(process_notifications,"get_sms_provider",lambda:provider)
|
||||
|
||||
for expected_status,expected_attempts in ((NotificationJob.DeliveryStatus.UNKNOWN,1),(NotificationJob.DeliveryStatus.UNDELIVERED,2)):
|
||||
before=timezone.now()
|
||||
call_command("process_notifications",once=True,job_id=job.pk)
|
||||
job.refresh_from_db()
|
||||
assert job.delivery_status==expected_status
|
||||
assert job.delivery_check_attempts==expected_attempts
|
||||
assert timedelta(seconds=179) <= job.delivery_next_check_at-before <= timedelta(seconds=181)
|
||||
NotificationJob.objects.filter(pk=job.pk).update(delivery_next_check_at=timezone.now()-timedelta(seconds=1))
|
||||
|
||||
call_command("process_notifications",once=True,job_id=job.pk)
|
||||
job.refresh_from_db()
|
||||
assert job.delivery_status==NotificationJob.DeliveryStatus.DELIVERED
|
||||
assert job.delivery_check_attempts==3 and job.delivery_next_check_at is None
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_customer_ticket_created_sms_has_seven_day_magic_link(category,customer,settings,monkeypatch):
|
||||
from datetime import timedelta
|
||||
@@ -512,16 +611,50 @@ def test_behin_provider_maps_send_array_contract(settings):
|
||||
assert calls["CheckingMessageID"]=={"long":[0]}
|
||||
assert calls["SpecialNumber"]=="3000" and calls["IsFlashMessage"] is False
|
||||
|
||||
def test_0098_provider_maps_http_contract(settings,monkeypatch):
|
||||
def test_0098_provider_maps_soap_contract(settings):
|
||||
from apps.integrations.sms.sms0098 import ZeroZeroNinetyEightSMSProvider
|
||||
settings.SMS0098_USERNAME="user"; settings.SMS0098_PASSWORD="pass"; settings.SMS0098_FROM="3000"
|
||||
calls={}
|
||||
class Response:
|
||||
text="42"
|
||||
def raise_for_status(self): return None
|
||||
def fake_get(url,**kwargs): calls.update(url=url,**kwargs); return Response()
|
||||
monkeypatch.setattr("apps.integrations.sms.sms0098.requests.get",fake_get)
|
||||
result=ZeroZeroNinetyEightSMSProvider().send_message("09202202129","پیام آزمایشی")
|
||||
assert result.success and result.provider_id=="42"
|
||||
assert calls["params"]["TO"]==9202202129
|
||||
assert calls["params"]["TEXT"]=="پیام آزمایشی"
|
||||
class Service:
|
||||
def SendSMSWithID(self,**parameters): calls.update(parameters); return "123456789"
|
||||
class Client: service=Service()
|
||||
provider=ZeroZeroNinetyEightSMSProvider(); provider._client=Client()
|
||||
result=provider.send_message("09202202129","پیام آزمایشی")
|
||||
assert result.success and result.provider_id=="123456789"
|
||||
assert calls["mobileno"]=="09202202129"
|
||||
assert calls["text"]=="پیام آزمایشی"
|
||||
assert calls["pnlno"]=="3000"
|
||||
assert set(calls)=={"username","password","text","mobileno","pnlno"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("code,expected", [
|
||||
("-3", "تطابق ندارند"), ("10", "اشتباه است"), ("11", "کاراکتر غیرمجاز"),
|
||||
("-17", "خالی است"), ("-18", "خطای شارژ"), ("-19", "شارژ پنل"),
|
||||
("-22", "شماره موبایل"), ("66", "تطابق ندارند"), ("1111", "کاراکتر غیرمجاز"),
|
||||
("Hang", "مسدود است"), ("No Doc", "مرحله دوم"), ("-23", "مجوز ارسال"),
|
||||
("-24", "شماره پنل"), ("-25", "اعتبار شماره پنل"), ("-26", "تنظیمات کد OTP"),
|
||||
("-27", "فرمت کد OTP"), ("-28", "تأیید نشده"), ("Wrong timestamp", "timestamp"),
|
||||
("Wrong time", "خارج از بازه"), ("-29", "فرمت کد OTP"), ("-31", "یافت نشد"),
|
||||
("-32", "مطابقت ندارد"), ("13", "IP غیرمجاز"),
|
||||
])
|
||||
def test_0098_provider_maps_every_documented_send_error(settings,code,expected):
|
||||
from apps.integrations.sms.sms0098 import ZeroZeroNinetyEightSMSProvider
|
||||
settings.SMS0098_USERNAME="user"; settings.SMS0098_PASSWORD="pass"; settings.SMS0098_FROM="3000"
|
||||
provider=ZeroZeroNinetyEightSMSProvider()
|
||||
result=provider._parse_send_result(code)
|
||||
assert not result.success
|
||||
assert result.provider_code==code
|
||||
assert expected in result.error
|
||||
|
||||
|
||||
@pytest.mark.parametrize("code,status,is_final", [("4","BLOCKED",True),("5","DELIVERED",True),("6","UNDELIVERED",False),("7","EXPIRED",True),("8","UNKNOWN",False),("9","ERROR",True),("12","ERROR",True),("13","ERROR",True)])
|
||||
def test_0098_provider_maps_documented_delivery_states(settings,code,status,is_final):
|
||||
from apps.integrations.sms.sms0098 import ZeroZeroNinetyEightSMSProvider
|
||||
settings.SMS0098_USERNAME="user"; settings.SMS0098_PASSWORD="pass"; settings.SMS0098_FROM="3000"
|
||||
class Service:
|
||||
def smsdeliveryState(self,**parameters): return code
|
||||
class Client: service=Service()
|
||||
provider=ZeroZeroNinetyEightSMSProvider(); provider._client=Client()
|
||||
result=provider.get_delivery_status("123456789")
|
||||
assert result.status==status and result.provider_code==code and result.is_final is is_final
|
||||
assert f"کد {code}" in result.description
|
||||
@@ -1,7 +1,11 @@
|
||||
import base64
|
||||
from datetime import timedelta
|
||||
|
||||
import pytest
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.accounts.models import MagicLinkAccessLog, TicketMagicLink, User
|
||||
from apps.attachments.models import AttachmentPolicy
|
||||
@@ -157,6 +161,41 @@ def test_group_dispatch_copies_shared_document_into_each_private_ticket():
|
||||
assert len(set(paths))==2
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_dispatch_page_supports_professional_multi_attachment_picker_and_submission(client):
|
||||
unit=OrganizationalUnit.objects.create(name="واحد پیوست گروهی",slug="dispatch-attachment-unit")
|
||||
unit.permissions.add(Permission.objects.get(codename="dispatch_group_tickets",content_type__app_label="tickets"))
|
||||
staff=make_staff("dispatch-attachment-staff",unit)
|
||||
customer=make_contact("dispatch-attachment-customer","09120000313")
|
||||
group=ContactGroup.objects.create(name="گروه پیوست",unit=unit)
|
||||
customer.customer_profile.groups.add(group)
|
||||
category=TicketCategory.objects.create(unit=unit,title="اسناد گروهی",slug="dispatch-attachments-category")
|
||||
AttachmentPolicy.objects.update_or_create(attachment_type="IMAGE",defaults={"max_file_size":1024*1024,"max_files_per_message":10,"allowed_extensions":[".png"],"allowed_mime_types":["image/png"]})
|
||||
AttachmentPolicy.objects.update_or_create(attachment_type="PDF",defaults={"max_file_size":1024*1024,"max_files_per_message":10,"allowed_extensions":[".pdf"],"allowed_mime_types":["application/pdf"]})
|
||||
client.force_login(staff)
|
||||
url=reverse("staff_tickets:dispatch_create")
|
||||
|
||||
page=client.get(url)
|
||||
content=page.content.decode()
|
||||
assert page.status_code==200
|
||||
for marker in ("data-ticket-composer","data-gallery-input","data-document-input","data-file-preview","attachment-uploader.js"):
|
||||
assert marker in content
|
||||
assert "افزودن عکس از گالری" in content and "افزودن فایل" in content
|
||||
|
||||
response=client.post(url,{
|
||||
"operation_type":GroupDispatch.OperationType.BULK,"unit":unit.pk,"contact_group":group.pk,
|
||||
"category":category.pk,"subject":"ارسال چند پیوست گروهی","body":"تصویر و سند را بررسی کنید.",
|
||||
"files":[
|
||||
SimpleUploadedFile("preview.png",base64.b64decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="),content_type="image/png"),
|
||||
SimpleUploadedFile("document.pdf",b"%PDF-1.4\n%%EOF",content_type="application/pdf"),
|
||||
],
|
||||
})
|
||||
|
||||
assert response.status_code==302, response.context["form"].errors.as_json() if response.context else response.content.decode()
|
||||
ticket=Ticket.objects.get(subject="ارسال چند پیوست گروهی")
|
||||
assert set(ticket.ticket_messages.get().attachments.values_list("original_name",flat=True))=={"preview.png","document.pdf"}
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_magic_link_records_every_open_with_ip_and_user_agent(client):
|
||||
unit = OrganizationalUnit.objects.create(name="حسابداری ممیزی", slug="audit-accounting")
|
||||
@@ -367,29 +406,87 @@ def test_unit_scoped_add_ticket_preserves_contact_ownership_and_stakeholder(clie
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_dispatch_retry_is_unit_scoped_and_refreshes_persistent_status(client):
|
||||
from apps.core.templatetags.jalali import jalali_datetime
|
||||
|
||||
unit=OrganizationalUnit.objects.create(name="لوجستیک retry",slug="logistics-retry")
|
||||
other=OrganizationalUnit.objects.create(name="فروش retry",slug="sales-retry")
|
||||
permission=Permission.objects.get(content_type__app_label="tickets",codename="dispatch_group_tickets")
|
||||
unit.permissions.add(permission)
|
||||
unit.permissions.add(permission,Permission.objects.get(content_type__app_label="tickets",codename="view_ticket"))
|
||||
staff=make_staff("retry-dispatcher",unit); outsider=make_staff("retry-outsider",other)
|
||||
contact=make_contact("retry-carrier","09120000302")
|
||||
group=ContactGroup.objects.create(name="باربری retry",unit=unit); contact.customer_profile.groups.add(group)
|
||||
category=TicketCategory.objects.create(title="استعلام retry",slug="quote-retry")
|
||||
dispatch,tickets=dispatch_group_tickets(user=staff,unit=unit,contact_group=group,category=category,subject="استعلام هزینه جدید",body="قیمت را ثبت کنید.")
|
||||
job=NotificationJob.objects.get(ticket=tickets[0],channel="SMS")
|
||||
job.status=NotificationJob.Status.FAILED; job.attempts=3; job.last_error="provider"; job.save()
|
||||
job.status=NotificationJob.Status.FAILED; job.attempts=4; job.last_error="0098SMS [کد -19]: شارژ پنل برای ارسال کافی نیست."; job.provider_status_code="-19"; job.save()
|
||||
for _ in range(4):
|
||||
TicketMagicLink.issue_with_record(tickets[0],reason="staff_created_ticket",created_by=staff)
|
||||
# Supervisor alerts are separate from the recipient message and must not
|
||||
# alter dispatch metrics or become the row's displayed/retryable SMS.
|
||||
NotificationJob.objects.create(
|
||||
channel=NotificationJob.Channel.SMS,event_type="UNIT_SUPERVISOR_NEW_TICKET",
|
||||
ticket=tickets[0],customer=tickets[0].customer,payload={"recipient_mobile":"09120000999"},
|
||||
status=NotificationJob.Status.SENT,attempts=1,next_attempt_at=timezone.now(),
|
||||
dedupe_key=f"test-supervisor-{tickets[0].pk}",
|
||||
)
|
||||
refresh_group_dispatch_status(dispatch.pk); dispatch.refresh_from_db()
|
||||
assert dispatch.status==GroupDispatch.Status.PARTIAL
|
||||
|
||||
client.force_login(staff)
|
||||
detail=client.get(reverse("staff_tickets:dispatch_detail",args=[dispatch.public_id]))
|
||||
content=detail.content.decode()
|
||||
assert detail.status_code==200
|
||||
assert "قیمت را ثبت کنید." in content
|
||||
assert "متن اولیه کارشناس" in content
|
||||
assert "کد -19" in content and "شارژ پنل" in content
|
||||
assert "جزئیات فنی و رهگیری" in content
|
||||
assert jalali_datetime(dispatch.created_at) in content
|
||||
assert dispatch.created_at.strftime("%Y/%m/%d %H:%M") not in content
|
||||
assert "0 + 0" not in content
|
||||
assert "مشاهده نشده" in content
|
||||
assert detail.context["tickets"][0].sms_job.pk==job.pk
|
||||
assert detail.context["metrics"]["failed"]==1
|
||||
ticket_detail=client.get(reverse("staff_tickets:detail",args=[tickets[0].public_id]))
|
||||
assert ticket_detail.status_code==200
|
||||
assert "علت دقیق" in ticket_detail.content.decode() and "کد -19" in ticket_detail.content.decode()
|
||||
|
||||
client.force_login(outsider)
|
||||
assert client.post(reverse("staff_tickets:dispatch_retry",args=[dispatch.public_id])).status_code==403
|
||||
client.force_login(staff)
|
||||
assert client.post(reverse("staff_tickets:dispatch_retry",args=[dispatch.public_id])).status_code==302
|
||||
assert client.post(reverse("staff_tickets:dispatch_retry",args=[dispatch.public_id]),{"job_id":job.pk}).status_code==302
|
||||
job.refresh_from_db(); dispatch.refresh_from_db()
|
||||
assert (job.status,job.attempts,job.last_error)==(NotificationJob.Status.PENDING,0,"")
|
||||
assert job.provider_status_code=="" and job.delivery_status==""
|
||||
assert dispatch.status==GroupDispatch.Status.QUEUED
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_provisional_delivery_is_tracking_not_failure_or_manual_retry(client):
|
||||
unit=OrganizationalUnit.objects.create(name="پیگیری دلیوری",slug="delivery-tracking-unit")
|
||||
unit.permissions.add(*Permission.objects.filter(content_type__app_label="tickets",codename__in=("view_ticket","dispatch_group_tickets")))
|
||||
staff=make_staff("delivery-tracking-agent",unit)
|
||||
contact=make_contact("delivery-tracking-contact","09120000322")
|
||||
group=ContactGroup.objects.create(name="گروه پیگیری",unit=unit); contact.customer_profile.groups.add(group)
|
||||
category=TicketCategory.objects.create(unit=unit,title="پیگیری",slug="delivery-tracking-category")
|
||||
dispatch,tickets=dispatch_group_tickets(user=staff,unit=unit,contact_group=group,category=category,subject="پیگیری تحویل",body="متن")
|
||||
job=NotificationJob.objects.get(ticket=tickets[0],event_type="STAFF_TICKET_CREATED")
|
||||
NotificationJob.objects.filter(pk=job.pk).update(
|
||||
status=NotificationJob.Status.SENT,attempts=1,sent_at=timezone.now(),provider_message_id="123456789",
|
||||
delivery_status=NotificationJob.DeliveryStatus.UNKNOWN,delivery_status_code="8",
|
||||
delivery_next_check_at=timezone.now()+timedelta(minutes=3),delivery_check_attempts=1,
|
||||
)
|
||||
refresh_group_dispatch_status(dispatch.pk); dispatch.refresh_from_db()
|
||||
assert dispatch.status==GroupDispatch.Status.SENT
|
||||
|
||||
client.force_login(staff)
|
||||
detail=client.get(reverse("staff_tickets:dispatch_detail",args=[dispatch.public_id]))
|
||||
assert detail.context["metrics"]["tracking"]==1 and detail.context["metrics"]["failed"]==0
|
||||
assert detail.context["tickets"][0].sms_retryable is False
|
||||
client.post(reverse("staff_tickets:dispatch_retry",args=[dispatch.public_id]),{"job_id":job.pk})
|
||||
job.refresh_from_db()
|
||||
assert job.status==NotificationJob.Status.SENT and job.delivery_status==NotificationJob.DeliveryStatus.UNKNOWN
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_magic_link_can_be_revoked_only_with_unit_permission(client):
|
||||
unit=OrganizationalUnit.objects.create(name="حسابرسی",slug="audit-unit")
|
||||
|
||||
+34
-2
@@ -6,7 +6,7 @@ from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from apps.accounts.models import User
|
||||
from apps.staff.models import OrganizationalUnit, StaffProfile
|
||||
from apps.tickets.models import TicketCategory
|
||||
from apps.tickets.models import Ticket, TicketCategory
|
||||
from apps.tickets.services import create_ticket, claim_ticket, reply_ticket
|
||||
from apps.messaging.models import Message,MessageReadReceipt
|
||||
from apps.customers.models import CustomerProfile
|
||||
@@ -112,10 +112,42 @@ def test_new_ticket_page_has_expert_cards_voice_and_file_preview(client):
|
||||
client.force_login(customer)
|
||||
content = client.get(reverse("tickets:new"),{"unit":unit.pk}).content.decode()
|
||||
assert "کارشناس نمونه" in content
|
||||
assert "data-voice-recorder" in content
|
||||
assert "data-ticket-voice-recorder" in content
|
||||
assert "data-file-preview" in content
|
||||
assert "data-ticket-wizard" in content
|
||||
assert "customer-ticket-wizard.js" in content
|
||||
assert reverse("tickets:expert_avatar", args=[expert.public_id]) in content
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_customer_ticket_wizard_keeps_server_validation_and_submitted_values(client):
|
||||
unit = OrganizationalUnit.objects.create(name="واحد اعتبارسنجی مرحلهای", slug="wizard-validation-unit")
|
||||
category = TicketCategory.objects.create(unit=unit, title="دسته مرحلهای", slug="wizard-validation-category")
|
||||
customer = User.objects.create_user(
|
||||
username="wizard-validation-customer",
|
||||
mobile="09126666001",
|
||||
account_type="CUSTOMER",
|
||||
)
|
||||
client.force_login(customer)
|
||||
response = client.post(
|
||||
reverse("tickets:new"),
|
||||
{
|
||||
"unit": unit.pk,
|
||||
"category": category.pk,
|
||||
"subject": "موضوع حفظشده پس از خطای سرور",
|
||||
"province": "",
|
||||
"city": "",
|
||||
"selected_expert": "",
|
||||
"body": "",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert "متن یا پیوست الزامی است." in response.context["form"].non_field_errors()
|
||||
content = response.content.decode()
|
||||
assert "data-step-server-errors" in content
|
||||
assert "موضوع حفظشده پس از خطای سرور" in content
|
||||
assert not Ticket.objects.filter(subject="موضوع حفظشده پس از خطای سرور").exists()
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_profile_location_prefills_ticket_but_can_be_overridden_per_ticket(client):
|
||||
unit=OrganizationalUnit.objects.create(name="فروش تست موقعیت",slug="location-sales")
|
||||
|
||||
Reference in new issue
Block a user