diff --git a/apps/tickets/forms.py b/apps/tickets/forms.py index ea2fe6d..07c0b1d 100644 --- a/apps/tickets/forms.py +++ b/apps/tickets/forms.py @@ -181,7 +181,13 @@ class StaffContactForm(forms.Form): last_name = forms.CharField(label="نام خانوادگی", max_length=100, required=False) organization_name = forms.CharField(label="نام شرکت / سازمان", max_length=180, required=False) stakeholder_type = forms.ChoiceField(label="نوع مخاطب", choices=CustomerProfile.StakeholderType.choices) - groups = forms.ModelMultipleChoiceField(label="عضویت در گروه‌ها", queryset=ContactGroup.objects.none(), required=True, help_text="حداقل یک گروه برای تعیین مالکیت واحدی مخاطب انتخاب کنید.") + groups = forms.ModelMultipleChoiceField( + label="عضویت در گروه‌ها", + queryset=ContactGroup.objects.none(), + required=True, + help_text="گروه‌های موردنظر را روشن یا خاموش کنید؛ حداقل یک گروه باید باقی بماند.", + widget=forms.CheckboxSelectMultiple(attrs={"data-contact-group-option": ""}), + ) province = forms.ChoiceField(label="استان", required=False) city = forms.ChoiceField(label="شهر", required=False) diff --git a/static/css/operations.css b/static/css/operations.css index ca93423..f99c220 100644 --- a/static/css/operations.css +++ b/static/css/operations.css @@ -39,7 +39,8 @@ .contact-card-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:.8rem;padding:1rem;background:#f7fafc}.contact-card{display:grid;gap:.75rem;min-width:0;border:1px solid #dce7ef;border-radius:1rem;background:#fff;padding:1rem;transition:.18s ease;box-shadow:0 10px 28px -26px #08376b}.contact-card:hover{transform:translateY(-2px);border-color:#9dcbe2;box-shadow:0 18px 36px -29px #08376b}.contact-card header{display:flex;align-items:center;gap:.7rem}.contact-avatar{display:grid;width:2.65rem;height:2.65rem;flex:none;place-items:center;border-radius:.8rem;background:linear-gradient(145deg,#dff4ff,#dff7ef);color:#084d73;font-size:1rem;font-weight:900}.contact-identity{min-width:0;flex:1}.contact-identity h2{overflow:hidden;margin:0;color:#153f60;font-size:.88rem;text-overflow:ellipsis;white-space:nowrap}.contact-identity p{overflow:hidden;margin:.15rem 0 0;color:#718698;font-size:.63rem;text-overflow:ellipsis;white-space:nowrap}.contact-type{flex:none;border-radius:999px;background:#e7f4fb;padding:.2rem .48rem;color:#146b98;font-size:.57rem;font-weight:900}.contact-type.type-supplier{background:#fff3d8;color:#966000}.contact-type.type-carrier{background:#f0eaff;color:#6841b8}.contact-meta{display:flex;flex-wrap:wrap;gap:.4rem .9rem;color:#5f7689;font-size:.64rem}.contact-groups{display:flex;min-height:1.6rem;flex-wrap:wrap;gap:.3rem}.contact-groups span{border-radius:999px;background:#edf5f8;padding:.18rem .48rem;color:#49677c;font-size:.58rem;font-weight:800}.contact-groups small{color:#93a2ae;font-size:.6rem}.contact-ticket-stats{display:grid;grid-template-columns:repeat(4,1fr);gap:.35rem}.contact-ticket-stats span{display:grid;gap:.1rem;border-radius:.6rem;background:#f3f6f8;padding:.45rem;text-align:center;color:#718394;font-size:.55rem}.contact-ticket-stats b{color:#173f60;font-size:.82rem}.contact-ticket-stats .needs-action{background:#fff5df;color:#916000}.contact-ticket-stats .needs-action b{color:#c37700}.contact-card footer{display:flex;align-items:center;justify-content:space-between;border-top:1px solid #edf2f5;padding-top:.7rem}.contact-card footer a{color:#557187;font-size:.64rem;font-weight:900}.contact-card footer .primary-action{border-radius:.6rem;background:#e8f6ff;padding:.4rem .65rem;color:#0875b5}.contact-empty{grid-column:1/-1}.contact-empty>span{display:grid;width:3rem;height:3rem;margin:0 auto .5rem;place-items:center;border-radius:1rem;background:#eaf5fb;color:#1679aa;font-size:1.3rem} .operations-split,.template-layout,.dispatch-layout{display:grid;grid-template-columns:minmax(0,1fr) 20rem;align-items:start;gap:1rem}.panel-heading{display:flex;align-items:center;justify-content:space-between;padding:1rem 1.1rem;border-bottom:1px solid #e5edf3;background:#f8fbfd}.panel-heading small{color:#74889a;font-size:.6rem}.panel-heading h2{margin:.15rem 0 0;color:#173f60;font-size:.95rem}.panel-heading>span{border-radius:999px;background:#e7f4fb;padding:.3rem .6rem;color:#126c9d;font-size:.6rem;font-weight:900}.group-card-list,.template-card-list{display:grid;gap:.55rem;padding:.8rem}.group-card,.template-card{display:grid;grid-template-columns:auto minmax(0,1fr) auto auto;align-items:center;gap:.75rem;border:1px solid #e0e9ef;border-radius:.8rem;padding:.75rem;transition:.15s}.group-card:hover,.template-card:hover{border-color:#acd2e5;background:#fafdff}.group-symbol,.template-icon{display:grid;width:2.35rem;height:2.35rem;place-items:center;border-radius:.7rem;background:#e8f5fb;color:#0870a6;font-weight:900}.group-card h3,.template-card h3{margin:0;color:#173f60;font-size:.78rem}.group-card p,.template-card p{margin:.12rem 0;color:#667d90;font-size:.62rem}.group-card small,.template-card small{color:#8a9aa7;font-size:.56rem}.group-card>a,.template-card>a{color:#0875b5;font-size:.62rem;font-weight:900}.operations-form-side{overflow:hidden;border:1px solid #d8e4ec;border-radius:1rem;background:#fff;box-shadow:0 18px 40px -36px #08376b;position:sticky;top:5.6rem}.operations-form-side>header{display:flex;align-items:center;gap:.65rem;padding:1rem;background:linear-gradient(135deg,#0a4d78,#087b91);color:#fff}.operations-form-side>header>span{display:grid;width:2rem;height:2rem;place-items:center;border-radius:.6rem;background:rgba(255,255,255,.16);font-weight:900}.operations-form-side header small{font-size:.56rem;color:rgba(255,255,255,.65)}.operations-form-side header h2{margin:.12rem 0 0;font-size:.85rem}.operations-form{display:grid;gap:.75rem;padding:1rem}.operations-form label{display:grid;gap:.35rem}.operations-form label>span{color:#405e75;font-size:.65rem;font-weight:900}.operations-form input,.operations-form select,.operations-form textarea{width:100%;min-height:2.65rem;border:1px solid #c8d8e4;border-radius:.7rem;background:#fbfdff;padding:.6rem .7rem;color:#143e60}.operations-form textarea{min-height:6rem;resize:vertical}.operations-form button{min-height:2.75rem;border:0;border-radius:.7rem;background:linear-gradient(135deg,#0879b7,#075b8d);color:#fff;font-size:.7rem;font-weight:900}.operations-form>a{text-align:center;color:#6e8293;font-size:.62rem;font-weight:800}.operations-form .errorlist,.editor-section .errorlist,.dispatch-composer .errorlist{margin:0;padding:0;color:#b42318;font-size:.6rem;list-style:none} .contact-editor,.dispatch-composer{display:grid;gap:.85rem}.contact-editor{max-width:58rem;width:100%;margin-inline:auto}.editor-section,.dispatch-composer>section{overflow:hidden;border:1px solid #dbe6ee;border-radius:1rem;background:#fff;box-shadow:0 15px 38px -35px #08376b}.editor-section>header,.dispatch-composer>section>header{display:flex;align-items:center;gap:.7rem;border-bottom:1px solid #e8eef3;padding:.85rem 1rem;background:#f9fcfe}.editor-section>header>span,.dispatch-composer>section>header>span{display:grid;width:2rem;height:2rem;flex:none;place-items:center;border-radius:.65rem;background:linear-gradient(135deg,#0a699f,#098d9c);color:#fff;font-size:.7rem;font-weight:900}.editor-section h2,.dispatch-composer h2{margin:0;color:#173f60;font-size:.82rem}.editor-section header p,.dispatch-composer header p{margin:.15rem 0 0;color:#73889a;font-size:.6rem}.editor-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:.8rem;padding:1rem}.editor-grid label,.group-selector,.message-editor{display:grid;align-content:start;gap:.35rem;color:#3f5f77;font-size:.65rem;font-weight:900}.editor-grid input,.editor-grid select,.group-selector select,.message-editor textarea{width:100%;min-height:2.75rem;border:1px solid #c8d8e4;border-radius:.72rem;background:#fbfdff;padding:.62rem .72rem;color:#123f61}.editor-grid input:focus,.editor-grid select:focus,.group-selector select:focus,.message-editor textarea:focus{border-color:#1689bd;outline:3px solid rgba(22,137,189,.11);background:#fff}.editor-grid small,.group-selector small{color:#7a8e9e;font-size:.56rem;font-weight:500}.editor-grid em,.group-selector em{color:#d05c3b;font-style:normal}.editor-wide{grid-column:1/-1}.group-selector,.message-editor{margin:1rem}.group-selector select{min-height:9rem}.location-status{margin:-.35rem 1rem .85rem;border-radius:.6rem;background:#edf7fc;padding:.55rem .7rem;color:#39708e;font-size:.58rem}.location-status.is-error{background:#fff0ed;color:#b04432}.editor-actions{display:flex;align-items:center;gap:.7rem;border:1px solid #d9e5ed;border-radius:1rem;background:#fff;padding:.8rem}.editor-actions button{min-height:2.75rem;border:0;border-radius:.7rem;background:linear-gradient(135deg,#0879b7,#075b8d);padding:.65rem 1.4rem;color:#fff;font-weight:900}.editor-actions a{color:#657d90;font-size:.65rem;font-weight:900}.editor-actions span{margin-right:auto;color:#8798a5;font-size:.58rem} +.group-selector{border:0}.group-selector legend{color:#3f5f77;font-size:.7rem;font-weight:900}.group-selector-tools{display:grid;grid-template-columns:minmax(12rem,1fr) auto auto;align-items:center;gap:.55rem;margin:.4rem 0 .65rem}.group-selector-tools label{display:block}.group-selector-tools input{width:100%;min-height:2.65rem;border:1px solid #c8d8e4;border-radius:.72rem;background:#fbfdff;padding:.58rem .75rem}.group-selection-count{border-radius:999px;background:#e7f4fb;padding:.42rem .7rem;color:#126c9d;white-space:nowrap}.group-selector-tools button{min-height:2.55rem;border:1px solid #d8e4ec;border-radius:.65rem;background:#fff;padding:.45rem .7rem;color:#64798c;font-size:.6rem;font-weight:900}.group-selector-tools button:disabled{cursor:not-allowed;opacity:.45}.group-choice-list>div{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:.55rem}.group-choice-list>div>div{border:1px solid #dbe5ec;border-radius:.75rem;background:#fbfdff;transition:.15s}.group-choice-list label{display:flex;align-items:center;gap:.65rem;min-height:3.15rem;padding:.65rem .75rem;cursor:pointer;color:#35566e;font-size:.65rem}.group-choice-list input{width:1.1rem;height:1.1rem;flex:none;accent-color:#0879a7}.group-choice-list>div>div.is-selected{border-color:#58aeca;background:#eaf8fb;box-shadow:0 0 0 2px rgba(22,137,189,.08)}.group-choice-list>div>div[hidden]{display:none}.group-empty{margin:.5rem 0;border-radius:.65rem;background:#f5f8fa;padding:.75rem;color:#758999;font-size:.62rem}.staff-attachment-picker{display:grid;gap:.8rem}.attachment-picker-copy{display:flex;align-items:center;gap:.7rem}.staff-attachment-picker .media-picker-actions{margin:0}.staff-attachment-picker .file-preview:empty{display:none} .dispatch-composer>footer{display:flex;align-items:center;justify-content:space-between;gap:1rem;border:1px solid #d7e4ec;border-radius:1rem;background:#fff;padding:.9rem 1rem}.dispatch-composer footer b,.dispatch-composer footer small{display:block}.dispatch-composer footer b{color:#173f60;font-size:.68rem}.dispatch-composer footer small{margin-top:.18rem;color:#73889a;font-size:.56rem}.dispatch-composer footer button{min-height:2.8rem;border:0;border-radius:.72rem;background:linear-gradient(135deg,#0879b7,#087c86);padding:.65rem 1rem;color:#fff;font-size:.68rem;font-weight:900}.message-editor textarea{min-height:11rem;resize:vertical;line-height:1.9}.dispatch-history{overflow:hidden;border:1px solid #d8e4ec;border-radius:1rem;background:#fff;position:sticky;top:5.6rem}.dispatch-history>header{display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid #e5edf3;padding:1rem}.dispatch-history header small{color:#7b8d9d;font-size:.56rem}.dispatch-history header h2{margin:.12rem 0 0;color:#173f60;font-size:.88rem}.dispatch-history header>span{display:grid;width:2rem;height:2rem;place-items:center;border-radius:.65rem;background:#e8f4fb;color:#0873a9;font-size:.65rem;font-weight:900}.dispatch-history>div{display:grid;max-height:39rem;overflow:auto}.dispatch-history-card{display:grid;grid-template-columns:auto minmax(0,1fr) auto;align-items:center;gap:.55rem;border-bottom:1px solid #edf2f5;padding:.75rem;color:inherit}.dispatch-history-card:hover{background:#f7fbfd}.dispatch-state{width:.5rem;height:.5rem;border-radius:50%;background:#8da0ae}.dispatch-state.state-sent{background:#12a36d}.dispatch-state.state-queued{background:#e19b18}.dispatch-state.state-partial{background:#dc5c42}.dispatch-history-card b{display:block;overflow:hidden;color:#24485f;font-size:.68rem;text-overflow:ellipsis;white-space:nowrap}.dispatch-history-card small{display:block;margin-top:.15rem;color:#718698;font-size:.56rem}.dispatch-history-card p{display:flex;gap:.6rem;margin:.35rem 0 0;color:#8798a5;font-size:.52rem}.dispatch-history-card em{color:#8aa0b0;font-style:normal} .template-card-list{max-height:44rem;overflow:auto}.variable-box{border:1px dashed #b9cedb;border-radius:.7rem;background:#f7fbfd;padding:.7rem}.variable-box>b{display:block;margin-bottom:.45rem;color:#3e6077;font-size:.62rem}.variable-box>div{display:flex;flex-wrap:wrap;gap:.3rem}.variable-box code{border-radius:.35rem;background:#e7f3f9;padding:.18rem .35rem;color:#075e8d;font-size:.53rem} @media(max-width:1100px){.operations-kpis{grid-template-columns:repeat(3,1fr)}.contact-filter-bar{grid-template-columns:repeat(2,minmax(0,1fr))}.contact-search{grid-column:1/-1}.operations-split,.template-layout,.dispatch-layout{grid-template-columns:1fr}.operations-form-side,.dispatch-history{position:static}.dispatch-history>div,.template-card-list{max-height:none}} -@media(max-width:700px){.operations-hero{align-items:stretch;flex-direction:column;padding:1.1rem}.operations-hero h1{font-size:1.3rem}.operations-hero-actions{align-items:stretch;flex-direction:column}.security-callout,.template-guide{align-self:stretch}.operations-kpis,.operations-kpis.compact{display:flex;max-width:none;overflow-x:auto;padding-bottom:.25rem}.operations-kpis article{min-width:9rem}.contact-filter-bar{grid-template-columns:1fr}.contact-search{grid-column:auto}.contact-card-grid{grid-template-columns:1fr;padding:.65rem}.contact-result-meta{align-items:flex-start;flex-direction:column;gap:.4rem}.editor-grid{grid-template-columns:1fr;padding:.8rem}.editor-wide{grid-column:auto}.editor-actions,.dispatch-composer>footer{align-items:stretch;flex-direction:column}.editor-actions span{margin:0}.group-card,.template-card{grid-template-columns:auto minmax(0,1fr) auto}.group-card .status-pill,.template-card .status-pill{grid-column:2}.group-card>a,.template-card>a{grid-column:3;grid-row:1/3}.contact-ticket-stats{grid-template-columns:repeat(2,1fr)}} +@media(max-width:700px){.operations-hero{align-items:stretch;flex-direction:column;padding:1.1rem}.operations-hero h1{font-size:1.3rem}.operations-hero-actions{align-items:stretch;flex-direction:column}.security-callout,.template-guide{align-self:stretch}.operations-kpis,.operations-kpis.compact{display:flex;max-width:none;overflow-x:auto;padding-bottom:.25rem}.operations-kpis article{min-width:9rem}.contact-filter-bar{grid-template-columns:1fr}.contact-search{grid-column:auto}.contact-card-grid{grid-template-columns:1fr;padding:.65rem}.contact-result-meta{align-items:flex-start;flex-direction:column;gap:.4rem}.editor-grid{grid-template-columns:1fr;padding:.8rem}.editor-wide{grid-column:auto}.editor-actions,.dispatch-composer>footer{align-items:stretch;flex-direction:column}.editor-actions span{margin:0}.group-selector-tools{grid-template-columns:1fr}.group-selection-count{justify-self:start}.group-choice-list>div{grid-template-columns:1fr}.group-card,.template-card{grid-template-columns:auto minmax(0,1fr) auto}.group-card .status-pill,.template-card .status-pill{grid-column:2}.group-card>a,.template-card>a{grid-column:3;grid-row:1/3}.contact-ticket-stats{grid-template-columns:repeat(2,1fr)}} diff --git a/static/js/contact-group-selector.js b/static/js/contact-group-selector.js new file mode 100644 index 0000000..7091de0 --- /dev/null +++ b/static/js/contact-group-selector.js @@ -0,0 +1,35 @@ +document.querySelectorAll('[data-contact-group-selector]').forEach(selector => { + const search = selector.querySelector('[data-group-search]'); + const count = selector.querySelector('[data-group-selection-count]'); + const clear = selector.querySelector('[data-clear-groups]'); + const empty = selector.querySelector('[data-group-empty]'); + const choices = Array.from(selector.querySelectorAll('.group-choice-list > div > div')); + + const update = () => { + let selected = 0; + let visible = 0; + const query = (search?.value || '').trim().toLocaleLowerCase('fa'); + choices.forEach(choice => { + const input = choice.querySelector('input[type="checkbox"]'); + const matches = !query || choice.textContent.toLocaleLowerCase('fa').includes(query); + choice.hidden = !matches; + choice.classList.toggle('is-selected', Boolean(input?.checked)); + selected += input?.checked ? 1 : 0; + visible += matches ? 1 : 0; + }); + if (count) count.textContent = `${selected.toLocaleString('fa-IR')} گروه انتخاب شده`; + if (clear) clear.disabled = selected === 0; + if (empty) empty.hidden = visible !== 0; + }; + + selector.addEventListener('change', update); + search?.addEventListener('input', update); + clear?.addEventListener('click', () => { + choices.forEach(choice => { + const input = choice.querySelector('input[type="checkbox"]'); + if (input) input.checked = false; + }); + update(); + }); + update(); +}); diff --git a/templates/tickets/staff_create.html b/templates/tickets/staff_create.html index 888b675..9ecd755 100644 --- a/templates/tickets/staff_create.html +++ b/templates/tickets/staff_create.html @@ -1,11 +1,13 @@ {% extends 'base.html' %}{% load static %} {% block title %}{{ page_title }}{% endblock %} {% block content %} + +
-
→ بازگشت به میز کار

ایجاد پرونده از طرف واحد فروش

ثبت تیکت برای مشتری

ابتدا مشتری را شناسایی کنید، سپس موضوع و پیام کامل را ثبت کنید. مشتری یک لینک امن برای مشاهده دریافت می‌کند.

♙
برنامه مشتریانجست‌وجو و مشاهده سوابق
+
→ بازگشت به میز کار

ایجاد پرونده از طرف {{ staff_unit.name|default:'واحد سازمانی' }}

ثبت تیکت برای مشتری

ابتدا مشتری را شناسایی کنید، سپس موضوع و پیام کامل را ثبت کنید. مشتری یک لینک امن برای مشاهده دریافت می‌کند.

♙
برنامه مشتریانجست‌وجو و مشاهده سوابق
  1. ۱
    انتخاب مشتریجست‌وجو یا ثبت شماره جدید
  2. ۲
    تعریف درخواستدسته‌بندی و موضوع
  3. ۳
    پیام و ارسالشرح کامل و پیوست‌ها
-
{% csrf_token %}{{ form.customer_id }} + {% csrf_token %}{{ form.customer_id }} {% if form.non_field_errors %}{% endif %}
۱

مشتری این تیکت کیست؟

در بانک مشتریان جست‌وجو کنید یا شماره موبایل را مستقیماً وارد کنید.

@@ -14,11 +16,11 @@

استان و شهر اختیاری هستند.

۲

مالکیت و موضوع درخواست

واحد ایجادکننده به‌صورت امن از عضویت اصلی شما تعیین می‌شود.

{{ form.unit }}{{ form.unit.errors }}
{{ form.unit.label }}{{ staff_unit.name|default:'واحد فعالی برای کاربر تعریف نشده' }}این مقدار قابل تغییر نیست.
-
۳

پیام کامل کارشناس

موضوع، اقدام انجام‌شده و انتظار از مشتری را یک‌جا و کامل توضیح دهید.

+
۳

پیام کامل کارشناس

موضوع، اقدام انجام‌شده و انتظار از مشتری را یک‌جا و کامل توضیح دهید.

+
{{ form.files.label }}چند عکس، فایل یا سند را انتخاب کنید؛ پیش از ثبت می‌توانید هر مورد را جداگانه حذف کنید.
{{ form.files }}{{ form.files.errors }}تصاویر بزرگ پیش از ارسال، روی دستگاه شما بهینه و کم‌حجم می‌شوند.
- + {% endblock %} diff --git a/templates/tickets/staff_entity_form.html b/templates/tickets/staff_entity_form.html index 5db7444..14fd6a6 100644 --- a/templates/tickets/staff_entity_form.html +++ b/templates/tickets/staff_entity_form.html @@ -6,7 +6,7 @@ {% if form.non_field_errors %}{% endif %}
۱

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

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

۲

موقعیت جغرافیایی

استان و شهر را از پایگاه مکان‌های ایران انتخاب کنید

برای نمایش شهرها، ابتدا استان را انتخاب کنید.

-
۳

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

حداقل یک گروه انتخاب کنید؛ عضویت همزمان در چند گروه پشتیبانی می‌شود

+
۳

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

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

{{ form.groups.label }} *
۰ گروه انتخاب شده
{{ form.groups }}
{{ form.groups.errors }}{{ form.groups.help_text }}
{% endblock %} -{% block extra_scripts %}{% endblock %} +{% block extra_scripts %}{% endblock %} diff --git a/tests/e2e/test_staff_operations_ui.py b/tests/e2e/test_staff_operations_ui.py index 20b016c..b8b3955 100644 --- a/tests/e2e/test_staff_operations_ui.py +++ b/tests/e2e/test_staff_operations_ui.py @@ -1,3 +1,5 @@ +import base64 + import pytest from django.contrib.auth.models import Permission from django.urls import reverse @@ -30,7 +32,7 @@ def test_staff_operations_pages_are_authorized_and_visually_styled(live_server, unit = OrganizationalUnit.objects.create(name="عملیات E2E", slug="operations-e2e") permissions = { "customers": {"view_customerprofile", "add_customerprofile", "change_customerprofile", "view_contactgroup", "add_contactgroup", "change_contactgroup"}, - "tickets": {"view_ticket", "dispatch_group_tickets"}, + "tickets": {"view_ticket", "add_ticket", "dispatch_group_tickets"}, "notifications": {"view_smstemplate", "add_smstemplate", "change_smstemplate"}, "accounts": {"view_ticketmagiclink"}, } @@ -45,6 +47,7 @@ def test_staff_operations_pages_are_authorized_and_visually_styled(live_server, customer = User.objects.create_user(username="e2e-carrier", mobile="09120009121", account_type=User.AccountType.CUSTOMER) profile = CustomerProfile.objects.create(user=customer, first_name="باربری", stakeholder_type=CustomerProfile.StakeholderType.CARRIER) group = ContactGroup.objects.create(unit=unit, name="باربری‌ها") + secondary_group = ContactGroup.objects.create(unit=unit, name="مخاطبان منتخب") profile.groups.add(group) category = TicketCategory.objects.create(title="استعلام E2E", slug="operations-e2e") ticket = create_ticket(customer=customer, created_by=staff, unit=unit, category=category, subject="استعلام آزمایشی", body="قیمت را اعلام کنید") @@ -86,3 +89,29 @@ def test_staff_operations_pages_are_authorized_and_visually_styled(live_server, expect(province.locator("option")).not_to_have_count(1) province.select_option(label="اصفهان") expect(city.locator("option", has_text="کاشان")).to_have_count(1) + + page.goto(f"{live_server.url}{reverse('staff_tickets:contact_edit', args=[customer.public_id])}", wait_until="domcontentloaded") + first_group = page.locator(f'input[name="groups"][value="{group.pk}"]') + second_group = page.locator(f'input[name="groups"][value="{secondary_group.pk}"]') + expect(first_group).to_be_checked() + expect(second_group).not_to_be_checked() + second_group.check() + first_group.uncheck() + expect(page.locator("[data-group-selection-count]")).to_contain_text("۱") + page.get_by_role("button", name="ذخیره مخاطب").click() + page.wait_for_url(f"**{reverse('staff_tickets:contacts')}") + page.goto(f"{live_server.url}{reverse('staff_tickets:contact_edit', args=[customer.public_id])}", wait_until="domcontentloaded") + expect(page.locator(f'input[name="groups"][value="{group.pk}"]')).not_to_be_checked() + 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}, + ]) + expect(page.locator("[data-file-preview] .file-chip")).to_have_count(2) + expect(page.locator("[data-file-preview] .file-chip img")).to_have_count(2) + 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 diff --git a/tests/test_organizational_ticketing.py b/tests/test_organizational_ticketing.py index 151aa08..bd6ccbb 100644 --- a/tests/test_organizational_ticketing.py +++ b/tests/test_organizational_ticketing.py @@ -257,6 +257,92 @@ def test_superuser_without_staff_profile_can_open_all_staff_contacts(client): assert "customer-without-profile" not in response.content.decode() +@pytest.mark.django_db +def test_staff_can_visually_add_and_remove_contact_group_memberships(client): + unit = OrganizationalUnit.objects.create(name="واحد ویرایش مخاطب", slug="contact-membership-editor") + unit.permissions.add(*Permission.objects.filter( + content_type__app_label="customers", + codename__in=("view_customerprofile", "change_customerprofile"), + )) + staff = make_staff("contact-membership-agent", unit) + contact = make_contact("editable-contact", "09120000505") + first = ContactGroup.objects.create(name="گروه اولیه", unit=unit) + second = ContactGroup.objects.create(name="گروه جایگزین", unit=unit) + contact.customer_profile.groups.add(first) + client.force_login(staff) + url = reverse("staff_tickets:contact_edit", args=[contact.public_id]) + + page = client.get(url) + content = page.content.decode() + assert page.status_code == 200 + assert "data-contact-group-selector" in content + assert "data-group-search" in content + assert str(first.pk) in {str(value) for value in page.context["form"]["groups"].value()} + assert f'value="{second.pk}"' in content + + response = client.post(url, { + "mobile": contact.local_mobile, + "first_name": "مخاطب", + "last_name": "ویرایش‌شده", + "organization_name": "", + "stakeholder_type": CustomerProfile.StakeholderType.CUSTOMER, + "groups": [second.pk], + "province": "", + "city": "", + }) + + assert response.status_code == 302 + assert set(contact.customer_profile.groups.values_list("pk", flat=True)) == {second.pk} + + +@pytest.mark.django_db +def test_staff_ticket_create_supports_multiple_previewable_attachments(client, monkeypatch, settings, tmp_path): + settings.MEDIA_ROOT = tmp_path + unit = OrganizationalUnit.objects.create(name="واحد پیوست کارشناس", slug="staff-attachment-unit") + unit.permissions.add(Permission.objects.get(content_type__app_label="tickets", codename="add_ticket")) + staff = make_staff("staff-attachment-agent", unit) + category = TicketCategory.objects.create(unit=unit, title="مدارک", slug="staff-attachment-category") + AttachmentPolicy.objects.create(attachment_type="IMAGE", max_file_size=1024 * 1024, allowed_extensions=[".jpg"]) + AttachmentPolicy.objects.create(attachment_type="PDF", max_file_size=1024 * 1024, allowed_extensions=[".pdf"]) + monkeypatch.setattr( + "apps.attachments.services.magic.from_buffer", + lambda content, mime=True: "image/jpeg" if content.startswith(b"\xff\xd8") else "application/pdf", + ) + client.force_login(staff) + url = reverse("staff_tickets:create") + + page = client.get(url) + content = page.content.decode() + assert page.status_code == 200 + assert "data-ticket-composer" in content + assert "data-gallery-input" in content + assert "data-document-input" in content + assert "data-file-preview" in content + assert "attachment-uploader.js" in content + assert "ایجاد پرونده از طرف واحد پیوست کارشناس" in content + + response = client.post(url, { + "unit": unit.pk, + "customer_mobile": "09120000506", + "first_name": "مشتری", + "last_name": "پیوست", + "stakeholder_type": CustomerProfile.StakeholderType.CUSTOMER, + "category": category.pk, + "subject": "ارسال همزمان تصویر و سند", + "body": "دو فایل برای این پرونده ثبت می‌شود.", + "files": [ + SimpleUploadedFile("photo.jpg", b"\xff\xd8\xff\xe0image", content_type="image/jpeg"), + SimpleUploadedFile("document.pdf", b"%PDF-1.4\n%%EOF", content_type="application/pdf"), + ], + }) + + assert response.status_code == 302 + ticket = Ticket.objects.get(subject="ارسال همزمان تصویر و سند") + assert set(ticket.ticket_messages.get().attachments.values_list("original_name", flat=True)) == { + "photo.jpg", "document.pdf", + } + + @pytest.mark.django_db def test_unit_scoped_add_ticket_preserves_contact_ownership_and_stakeholder(client): unit=OrganizationalUnit.objects.create(name="حسابداری عملیاتی",slug="accounting-ops")