improve bazdid 4

This commit is contained in:
javad committed 2026-10-21 15:13:35 +03:30
1 parent f86d060e4d
commit 8fd881e0ea
10 files changed
+1752 -320

No files matched your search

@@ -239,6 +239,8 @@
{% block bodyPage %}
{% endblock %}
{# بستن <div class=""> که بالاتر باز شده بود؛ پیش‌تر بسته نمی‌شد و HTML نامعتبر می‌شد. #}
</div>
</body>
<script>
@@ -164,6 +164,8 @@
{% block bodyPage %}
{% endblock %}
{# بستن <div class=""> که بالاتر باز شده بود (همان باگ base.html). #}
</div>
</body>
<script>
+315 -149
View File
@@ -24,7 +24,7 @@
-webkit-overflow-scrolling: touch;
}
@media (min-width: 1024px) {
.ez-stats { grid-auto-flow: row; grid-template-columns: repeat(6, 1fr); overflow: visible; }
.ez-stats { grid-auto-flow: row; grid-template-columns: repeat(4, 1fr); overflow: visible; }
}
.ez-stat {
display: flex; flex-direction: column; gap: .18rem;
@@ -41,6 +41,7 @@
.ez-stat.warning { border-top-color: #d97706; }
.ez-stat.link { border-top-color: #0284c7; }
.ez-stat.violet { border-top-color: #7c3aed; }
.ez-stat.followup { border-top-color: #0e7490; }
.ez-stat-top {
display: flex; align-items: baseline; justify-content: space-between; gap: .4rem;
@@ -69,21 +70,84 @@
}
.ez-legend b { font-variant-numeric: tabular-nums; color: #334155; }
/* ---------- ابزارک‌ها ---------- */
.ez-toolbar {
display: flex; flex-wrap: wrap; align-items: center; gap: .5rem; margin: 0 0 .75rem;
/* ---------- نوار تاریخ (نمای «نیازمند پیگیری») ---------- */
.ez-datebar {
display: flex; flex-wrap: wrap; align-items: flex-end; gap: .4rem;
padding: .5rem .6rem; margin: .5rem 0 .4rem;
background: #f8fbfd; border: 1px solid #d9e8f0; border-radius: .5rem;
}
.ez-toolbar .button { margin: 0 !important; }
.ez-seg {
display: inline-flex; border: 1px solid #e2e8f0; border-radius: .5rem; overflow: hidden;
.ez-datebar .button { margin: 0 !important; }
.ez-dateform { margin: 0; }
.ez-datefield { display: flex; flex-direction: column; gap: .15rem; margin: 0; }
.ez-datefield-row { display: flex; gap: .25rem; align-items: center; }
.ez-datefield-row .input {
height: 2.1rem; font-size: .78rem; width: 8.5rem;
font-variant-numeric: tabular-nums; direction: ltr; text-align: center;
}
.ez-seg a {
padding: .3rem .6rem; font-size: .72rem; color: #475569; text-decoration: none;
background: #fff; border-inline-end: 1px solid #e2e8f0; white-space: nowrap;
.ez-datenote {
flex: 1 1 100%; font-size: .7rem; color: #64748b; line-height: 1.7;
}
.ez-seg a:last-child { border-inline-end: 0; }
.ez-seg a:hover { background: #f1f5f9; }
.ez-seg a.is-active { background: #33b5d3; color: #fff; font-weight: 700; }
.ez-datenote b { color: #334155; }
@media (min-width: 700px) {
.ez-datenote { flex: 1 1 auto; }
}
/* ---------- هشدار مینیمال «پیگیری‌های عقب‌افتاده» ---------- */
.ez-overdue-alert,
.ez-overdue-mode {
flex: 1 1 100%; display: inline-flex; align-items: center; gap: .35rem;
font-size: .72rem; line-height: 1.6; text-decoration: none;
}
.ez-overdue-alert { color: #b91c1c; font-weight: 600; }
.ez-overdue-alert:hover { color: #991b1b; text-decoration: underline; }
.ez-overdue-alert b { font-variant-numeric: tabular-nums; font-size: .8rem; }
.ez-overdue-dot {
width: .42rem; height: .42rem; border-radius: 50%;
background: #dc2626; flex: 0 0 auto;
}
.ez-overdue-mode {
color: #7f1d1d; background: #fef2f2;
border: 1px solid #fecaca; border-radius: .375rem; padding: .2rem .5rem;
}
.ez-overdue-mode b { font-variant-numeric: tabular-nums; }
.ez-overdue-mode a {
color: #b91c1c; font-weight: 700; text-decoration: none;
margin-inline-start: .35rem;
}
.ez-overdue-mode a:hover { text-decoration: underline; }
/* ---------- نوار فیلتر ---------- */
.ez-filters {
display: flex; flex-wrap: wrap; align-items: flex-end;
gap: .5rem; margin: .6rem 0 .4rem;
}
.ez-filter { display: flex; flex-direction: column; gap: .15rem; margin: 0; }
.ez-filter-grow { flex: 1 1 18rem; min-width: 14rem; }
.ez-filter-lbl { font-size: .68rem; color: #64748b; font-weight: 700; }
.ez-filters .select select { font-size: .76rem; height: 2.1rem; }
.ez-search { display: flex; gap: .25rem; align-items: center; }
.ez-search .input { height: 2.1rem; font-size: .76rem; }
.ez-search .button { margin: 0 !important; }
.ez-clear { margin: 0 !important; }
/* ---------- سرستون‌های مرتب‌شدنی ---------- */
.ez-th > a {
display: inline-flex; align-items: center; gap: .15rem;
color: #06323d; text-decoration: none; white-space: nowrap;
}
.ez-th > a:hover { text-decoration: underline; }
.ez-th.is-sorted > a { color: #0b4e60; }
.ez-arrow { font-size: .62rem; opacity: .55; }
.ez-th.is-sorted .ez-arrow { opacity: 1; }
/* ---------- تفکیک تأخیر (کلیک‌پذیر) ---------- */
.ez-legend a {
color: #64748b; text-decoration: none; padding: .05rem .35rem;
border-radius: .25rem; white-space: nowrap;
}
.ez-legend a:hover { background: #f1f5f9; }
.ez-legend a.is-active { background: #33b5d3; color: #fff; }
.ez-legend a.is-active b { color: #fff; }
/* ---------- جدول ---------- */
.ez-table { width: 100%; margin: 0; font-size: .82rem; border-collapse: collapse; }
@@ -196,134 +260,231 @@
<div class="ez-wrap">
<div class="columns m-0">
<div class="column is-12 p-0">
{# ===== نوار آمار: هر عدد لینک فیلتر ===== #}
{# ===== نوار آمار: چهار کارت، هر عدد لینک فیلتر ===== #}
<div class="ez-stats-head">
<p class="ez-stats-title">وضعیت مشتریانِ عقب‌افتاده</p>
<p class="ez-stats-title">وضعیت پیگیری مشتریان</p>
<p class="ez-stats-hint">
«عقب‌افتاده» یعنی از آخرین بازدید
<b>بیش از {{ counts.period_months }} ماه</b> گذشته باشد.
روی هر کارت بزنید تا فقط همان گروه نمایش داده شود؛
<b>کل = بدون قرار + با قرار</b>.
سه کارتِ آخر سه نوع قرارِ جدا هستند؛ یک مشتری می‌تواند بیش از
یکی داشته باشد، پس جمع آن‌ها ممکن است از «با قرار» بیشتر شود.
<b>کل = تعیین‌تکلیف‌نشده + تعیین‌تکلیف‌شده</b>.
</p>
</div>
<div class="ez-stats">
<a href="?view=all&sort={{ sort }}{% if searchVal %}&searchVal={{ searchVal }}&searchOn={{ searchOn }}{% endif %}"
class="ez-stat total {% if view == 'all' %}is-active{% endif %}"
title="همهٔ مشتریان عقب‌افتاده، با هر وضعیت قرار">
{# کارت «نیازمند پیگیری امروز» — مستقل از «عقب‌افتاده»؛ بازدیدها حذف می‌شوند. #}
<a href="{{ view_links.today }}"
class="ez-stat followup {% if view == followup_view %}is-active{% endif %}"
title="مشتریانی که موعد پیگیری/تماسشان امروز است و برایشان بازدید برنامه‌ریزی نشده">
<span class="ez-stat-top">
<span class="ez-lbl">کل عقب‌افتاده‌ها</span>
<span class="ez-num">{{ counts.total }}</span>
<span class="ez-lbl">نیازمند پیگیری امروز</span>
<span class="ez-num has-text-info">{{ counts.followup_today }}</span>
</span>
<span class="ez-desc">همه، فارغ از داشتن یا نداشتن قرار</span>
<span class="ez-desc">موعد تماسشان امروز است — بازدید ندارند</span>
</a>
<a href="?view=uncoordinated&sort={{ sort }}{% if searchVal %}&searchVal={{ searchVal }}&searchOn={{ searchOn }}{% endif %}"
class="ez-stat danger {% if view == 'uncoordinated' %}is-active{% endif %}"
title="مشتریانی که هنوز هیچ قرار بعدی برایشان ثبت نشده است">
<a href="{{ view_links.all }}"
class="ez-stat total {% if view == 'all' %}is-active{% endif %}"
title="همهٔ مشتریانی که بازدیدشان عقب افتاده، با هر وضعیت پیگیری">
<span class="ez-stat-top">
<span class="ez-lbl">بدون قرار</span>
<span class="ez-lbl">همهٔ مشتریان عقب‌افتاده</span>
<span class="ez-num">{{ counts.total }}</span>
</span>
<span class="ez-desc">با هر وضعیت پیگیری</span>
</a>
<a href="{{ view_links.uncoordinated }}"
class="ez-stat danger {% if view == 'uncoordinated' %}is-active{% endif %}"
title="مشتریانی که هنوز هیچ وقت بازدید/پیگیری برایشان تعیین نشده است">
<span class="ez-stat-top">
<span class="ez-lbl">تعیین تکلیف نشده</span>
<span class="ez-num has-text-danger">{{ counts.uncoordinated }}</span>
</span>
<span class="ez-desc">هنوز وقت بازدید تعیین نشده — نیاز به تماس</span>
</a>
<a href="?view=coordinated&sort={{ sort }}{% if searchVal %}&searchVal={{ searchVal }}&searchOn={{ searchOn }}{% endif %}"
<a href="{{ view_links.coordinated }}"
class="ez-stat success {% if view == 'coordinated' %}is-active{% endif %}"
title="مشتریانی که وقت بازدید بعدی برایشان مشخص شده است">
title="مشتریانی که وقت بازدید یا پیگیریِ بعدی برایشان تعیین شده است">
<span class="ez-stat-top">
<span class="ez-lbl">با قرار</span>
<span class="ez-lbl">تعیین تکلیف شده</span>
<span class="ez-num has-text-success">{{ counts.coordinated }}</span>
</span>
<span class="ez-desc">وقت بازدیدشان مشخص است (هر دو نوع قرار)</span>
</a>
<a href="?view=manual&sort={{ sort }}{% if searchVal %}&searchVal={{ searchVal }}&searchOn={{ searchOn }}{% endif %}"
class="ez-stat warning {% if view == 'manual' %}is-active{% endif %}"
title="قرار تعویق که از فرم «هماهنگی» ثبت شده است">
<span class="ez-stat-top">
<span class="ez-lbl">قرار هماهنگی</span>
<span class="ez-num has-text-warning-dark">{{ counts.manual }}</span>
</span>
<span class="ez-desc">تعویق دستی از فرم هماهنگی (مثلاً «ماه بعد زنگ بزنید»)</span>
</a>
<a href="?view=scheduled&sort={{ sort }}{% if searchVal %}&searchVal={{ searchVal }}&searchOn={{ searchOn }}{% endif %}"
class="ez-stat link {% if view == 'scheduled' %}is-active{% endif %}"
title="اقدام «بازدید» با تاریخ آینده">
<span class="ez-stat-top">
<span class="ez-lbl">بازدید برنامه‌ریزی‌شده</span>
<span class="ez-num has-text-link">{{ counts.scheduled }}</span>
</span>
<span class="ez-desc">اقدام «بازدید» با تاریخ آینده ثبت شده است</span>
</a>
<a href="?view=coordination&sort={{ sort }}{% if searchVal %}&searchVal={{ searchVal }}&searchOn={{ searchOn }}{% endif %}"
class="ez-stat violet {% if view == 'coordination' %}is-active{% endif %}"
title="اقدام «هماهنگی» با تاریخ پیگیری در آینده">
<span class="ez-stat-top">
<span class="ez-lbl">هماهنگی</span>
<span class="ez-num" style="color:#7c3aed">{{ counts.coordination }}</span>
</span>
<span class="ez-desc">تماس گرفته شده و پیگیری به آینده موکول شده است</span>
<span class="ez-desc">وقت بعدی‌شان مشخص است</span>
</a>
</div>
{# تفکیک مدت تأخیر در نمای جاری #}
<div class="ez-legend">
<span>میزان تأخیر در همین نما:</span>
<span><b>{{ counts.buckets.lt30 }}</b> تا ۳۰ روز</span>
<span><b>{{ counts.buckets.30to90 }}</b> ۳۰ تا ۹۰ روز</span>
<span><b>{{ counts.buckets.90to180 }}</b> ۹۰ تا ۱۸۰ روز</span>
<span><b>{{ counts.buckets.gt180 }}</b> بیش از ۶ ماه</span>
</div>
{# ===== نوار تاریخ در نمای «نیازمند پیگیری» ===== #}
{% if is_followup_view %}
<div class="ez-datebar">
<a href="{{ followup_nav.prev }}" class="button is-small is-light"
title="روز قبل">‹ روز قبل</a>
{# ===== ابزارک‌ها: جستجو و مرتب‌سازی ===== #}
<div class="ez-toolbar">
<a href="#" id="openSearchBox" class="button is-info is-outlined is-rounded is-small">
جستجو{% if searchVal %}: «{{ searchVal }}»{% endif %}
</a>
<form method="get" class="ez-dateform">
<input type="hidden" name="view" value="{{ followup_view }}">
{% if searchVal %}<input type="hidden" name="searchVal" value="{{ searchVal }}">{% endif %}
{% if searchVal %}<input type="hidden" name="searchOn" value="{{ searchOn }}">{% endif %}
<label class="ez-datefield">
<span class="ez-filter-lbl">تاریخ پیگیری (شمسی)</span>
<span class="ez-datefield-row">
<input type="text" name="fdate" id="followupDatePicker"
value="{{ followup_date_jalali }}" class="input is-small ss02"
placeholder="۱۴۰۵/۰۸/۲۹" autocomplete="off" inputmode="numeric">
<button type="submit" class="button is-info is-small">نمایش</button>
</span>
</label>
</form>
<span class="is-size-7 has-text-grey">مرتب‌سازی</span>
<span class="ez-seg">
<a href="?view={{ view }}&sort=oldest{% if searchVal %}&searchVal={{ searchVal }}&searchOn={{ searchOn }}{% endif %}"
class="{% if sort == 'oldest' %}is-active{% endif %}">قدیمی‌ترین</a>
<a href="?view={{ view }}&sort=overdue{% if searchVal %}&searchVal={{ searchVal }}&searchOn={{ searchOn }}{% endif %}"
class="{% if sort == 'overdue' %}is-active{% endif %}">بیشترین تأخیر</a>
<a href="?view={{ view }}&sort=newest{% if searchVal %}&searchVal={{ searchVal }}&searchOn={{ searchOn }}{% endif %}"
class="{% if sort == 'newest' %}is-active{% endif %}">جدیدترین</a>
<a href="{{ followup_nav.next }}" class="button is-small is-light"
title="روز بعد">روز بعد ›</a>
{% if not followup_nav.is_today %}
<a href="{{ followup_nav.today }}" class="button is-success is-light is-small">
بازگشت به امروز
</a>
{% endif %}
<span class="ez-datenote">
{% if followup_nav.is_today %}
فقط موعدهای <b>امروز</b> نمایش داده می‌شوند.
{% else %}
نمایش موعدهای <b>{{ followup_date_jalali }}</b> (نه امروز).
{% endif %}
مشتریانی که برایشان <b>بازدید</b> گذاشته شده در این فهرست نمی‌آیند.
</span>
{% if searchVal %}
<a href="?view={{ view }}&sort={{ sort }}" class="button is-small is-light">حذف جستجو</a>
{# هشدار مینیمال: موعدهایی که گذشته‌اند و هنوز تعیین تکلیف نشده‌اند. #}
{% if counts.followup_overdue and not show_missed %}
<a class="ez-overdue-alert"
href="{{ followup_overdue_link }}"
title="موعد پیگیری‌شان گذشته و هنوز تعیین تکلیف نشده‌اند — برای دیدن فهرستشان کلیک کنید">
<span class="ez-overdue-dot" aria-hidden="true"></span>
<b>{{ counts.followup_overdue }}</b> مورد نیازمند پیگیری از روزهای قبل باقی مانده
</a>
{% endif %}
{% if show_missed %}
<span class="ez-overdue-mode">
نمایش <b>{{ counts.followup_overdue }}</b> پیگیریِ عقب‌افتاده
<a href="{{ followup_ontime_link }}" title="بازگشت به فهرست امروز">✕ بازگشت به امروز</a>
</span>
{% endif %}
</div>
{% endif %}
{# ===== نوار فیلتر و جست‌وجو: یک ردیف، همهٔ کنترل‌ها ===== #}
<form class="ez-filters" method="get" action="">
{% if is_followup_view %}
<input type="hidden" name="fdate" value="{{ followup_date_jalali }}">
{% endif %}
{# حالت «فقط عقب‌افتاده‌ها» هنگام جست‌وجو/فیلتر حفظ شود. #}
{% if show_missed %}
<input type="hidden" name="missed" value="1">
{% endif %}
<label class="ez-filter">
<span class="ez-filter-lbl">نوع قرار</span>
<span class="select is-small">
<select name="view" class="ez-auto-submit">
<option value="today" {% if is_followup_view %}selected{% endif %}>نیازمند پیگیری (تاریخ انتخابی)</option>
<option value="all" {% if view == 'all' %}selected{% endif %}>همه (با و بدون قرار)</option>
<option value="uncoordinated" {% if view == 'uncoordinated' %}selected{% endif %}>تعیین تکلیف نشده</option>
<option value="coordinated" {% if view == 'coordinated' %}selected{% endif %}>تعیین تکلیف شده (همه)</option>
<option value="manual" {% if view == 'manual' %}selected{% endif %}>تعویق دستی</option>
<option value="scheduled" {% if view == 'scheduled' %}selected{% endif %}>بازدید برنامه‌ریزی‌شده</option>
<option value="coordination" {% if view == 'coordination' %}selected{% endif %}>پیگیری تلفنی</option>
</select>
</span>
</label>
{% if not is_followup_view %}
<label class="ez-filter">
<span class="ez-filter-lbl">میزان تأخیر</span>
<span class="select is-small">
<select name="delay" class="ez-auto-submit">
<option value="" {% if not delay %}selected{% endif %}>همه</option>
<option value="lt30" {% if delay == 'lt30' %}selected{% endif %}>تا ۳۰ روز ({{ counts.buckets.lt30 }})</option>
<option value="30to90" {% if delay == '30to90' %}selected{% endif %}>۳۰ تا ۹۰ روز ({{ counts.buckets.30to90 }})</option>
<option value="90to180" {% if delay == '90to180' %}selected{% endif %}>۹۰ تا ۱۸۰ روز ({{ counts.buckets.90to180 }})</option>
<option value="gt180" {% if delay == 'gt180' %}selected{% endif %}>بیش از ۶ ماه ({{ counts.buckets.gt180 }})</option>
</select>
</span>
</label>
{% endif %}
<label class="ez-filter">
<span class="ez-filter-lbl">مرتب‌سازی</span>
<span class="select is-small">
<select name="sort" class="ez-auto-submit">
<option value="last_visit" {% if sort == 'last_visit' and sort_dir == 'asc' %}selected{% endif %}>قدیمی‌ترین بازدید</option>
<option value="last_visit-desc" {% if sort == 'last_visit' and sort_dir == 'desc' %}selected{% endif %}>جدیدترین بازدید</option>
<option value="overdue" {% if sort == 'overdue' %}selected{% endif %}>بیشترین تأخیر</option>
<option value="name" {% if sort == 'name' %}selected{% endif %}>نام (الفبا)</option>
<option value="status" {% if sort == 'status' %}selected{% endif %}>وضعیت قرار</option>
<option value="coordination" {% if sort == 'coordination' %}selected{% endif %}>تاریخ پیگیری</option>
</select>
</span>
</label>
<label class="ez-filter ez-filter-grow">
<span class="ez-filter-lbl">جست‌وجو</span>
<span class="ez-search">
<input type="text" name="searchVal" value="{{ searchVal }}"
class="input is-small" placeholder="نام، تلفن، کد یا آدرس">
<span class="select is-small">
<select name="searchOn">
<option value="name" {% if searchOn == 'name' %}selected{% endif %}>نام</option>
<option value="phone" {% if searchOn == 'phone' %}selected{% endif %}>تلفن</option>
<option value="code" {% if searchOn == 'code' %}selected{% endif %}>کد</option>
<option value="address" {% if searchOn == 'address' %}selected{% endif %}>آدرس</option>
</select>
</span>
<button type="submit" class="button is-info is-small">جست‌وجو</button>
</span>
</label>
{% if has_filters %}
<a href="{{ clear_link }}" class="button is-light is-small ez-clear">✕ حذف فیلترها</a>
{% endif %}
</form>
{# تفکیک مدت تأخیر در نمای جاری (کلیک‌پذیر) #}
{% if not is_followup_view %}
<div class="ez-legend">
<span>میزان تأخیر در همین نما:</span>
<a href="{{ delay_links.ez_all }}" class="{% if not delay %}is-active{% endif %}">
<b>{{ counts.view_total }}</b> همه
</a>
<a href="{{ delay_links.lt30 }}" class="{% if delay == 'lt30' %}is-active{% endif %}">
<b>{{ counts.buckets.lt30 }}</b> تا ۳۰ روز
</a>
<a href="{{ delay_links.30to90 }}" class="{% if delay == '30to90' %}is-active{% endif %}">
<b>{{ counts.buckets.30to90 }}</b> ۳۰ تا ۹۰ روز
</a>
<a href="{{ delay_links.90to180 }}" class="{% if delay == '90to180' %}is-active{% endif %}">
<b>{{ counts.buckets.90to180 }}</b> ۹۰ تا ۱۸۰ روز
</a>
<a href="{{ delay_links.gt180 }}" class="{% if delay == 'gt180' %}is-active{% endif %}">
<b>{{ counts.buckets.gt180 }}</b> بیش از ۶ ماه
</a>
</div>
{% endif %}
<div class="table-container">
<table class="table ez-table">
<colgroup>
<col style="width: 21%">
<col style="width: 12%">
<col style="width: 10%">
<col style="width: 10%">
<col style="width: 10%">
<col style="width: 9%">
<col style="width: 8%">
<col style="width: 9%">
<col style="width: 11%">
{% for col in table_columns %}
<col style="width: {{ col.width }}%">
{% endfor %}
</colgroup>
<thead>
<tr>
<th>نام و نام خانوادگی</th>
<th>وضعیت قرار</th>
<th>قرار دستی</th>
<th>بازدید برنامه‌ریزی‌شده</th>
<th>هماهنگی</th>
<th>آخرین بازدید</th>
<th>تأخیر</th>
<th>تلفن</th>
<th>آدرس</th>
{% for col in sort_header %}
<th class="ez-th {% if col.active %}is-sorted{% endif %}">
<a href="{{ col.link }}" title="مرتب‌سازی بر اساس {{ col.label }}">
{{ col.label }}<span class="ez-arrow">{% if col.active %}{% if col.dir == 'asc' %}▲{% else %}▼{% endif %}{% else %}⇅{% endif %}</span>
</a>
</th>
{% endfor %}
</tr>
</thead>
<tbody>
@@ -364,6 +525,7 @@
<td data-label="آخرین بازدید" class="ez-num ez-nowrap lastView">
{% if appt.last_visit %}{{ appt.last_visit|toJalali }}{% else %}<span class="ez-muted">بدون بازدید</span>{% endif %}
</td>
{% if 'overdue' not in hidden_columns %}
<td data-label="تأخیر" class="ez-nowrap">
{% if appt.overdue_days >= 180 %}
<span class="ez-late lv3">{{ appt.overdue_days }} روز</span>
@@ -375,6 +537,7 @@
<span class="ez-late lv0">{{ appt.overdue_days }} روز</span>
{% endif %}
</td>
{% endif %}
{% endwith %}
<td data-label="تلفن" class="ez-nowrap">
@@ -397,8 +560,14 @@
</tr>
{% empty %}
<tr>
<td colspan="9" class="has-text-centered py-5">
<span class="has-text-grey">موردی در این نما پیدا نشد.</span>
<td colspan="{{ column_count }}" class="has-text-centered py-5">
<span class="has-text-grey">
{% if is_followup_view %}
برای تاریخ {{ followup_date_jalali }} موردی برای پیگیری نیست.
{% else %}
موردی در این نما پیدا نشد.
{% endif %}
</span>
</td>
</tr>
{% endfor %}
@@ -409,7 +578,7 @@
{# ===== صفحه‌بندی ===== #}
<div class="is-flex is-align-items-center is-justify-content-space-between mt-3 mb-5">
{% if customers.has_previous %}
<a class="button is-danger is-small" href="?view={{ view }}&sort={{ sort }}{% if searchVal %}&searchVal={{ searchVal }}&searchOn={{ searchOn }}{% endif %}&page={{ customers.previous_page_number }}">صفحه قبل</a>
<a class="button is-danger is-small" href="{{ page_prev }}">صفحه قبل</a>
{% else %}<span></span>{% endif %}
<span class="is-size-7 has-text-grey">
@@ -418,60 +587,57 @@
</span>
{% if customers.has_next %}
<a class="button is-danger is-small" href="?view={{ view }}&sort={{ sort }}{% if searchVal %}&searchVal={{ searchVal }}&searchOn={{ searchOn }}{% endif %}&page={{ customers.next_page_number }}">صفحه بعد</a>
<a class="button is-danger is-small" href="{{ page_next }}">صفحه بعد</a>
{% else %}<span></span>{% endif %}
</div>
</div>
</div>
</div>
<div class="modal SearchBox">
<div class="modal-background closeSearch"></div>
<div class="modal-content">
<form method="get" class="columns is-mobile is-multiline">
{# حفظ نما و ترتیب هنگام جست‌وجو #}
<input type="hidden" name="view" value="{{ view }}">
<input type="hidden" name="sort" value="{{ sort }}">
<div class="field column is-7-desktop is-12-mobile m-0">
<label class="label">جستجو کنید</label>
<div class="control">
<input name="searchVal" class="input search" type="text" value="{{ searchVal }}" placeholder="نام، تلفن، کد یا آدرس">
</div>
</div>
<div class="field column is-5-desktop is-12-mobile m-0">
<label class="label">فیلد</label>
<div class="select is-fullwidth">
<select name="searchOn">
<option value="name" {% if searchOn == 'name' %}selected{% endif %}>نام و نام خانوادگی</option>
<option value="phone" {% if searchOn == 'phone' %}selected{% endif %}>تلفن</option>
<option value="address" {% if searchOn == 'address' %}selected{% endif %}>آدرس</option>
<option value="code" {% if searchOn == 'code' %}selected{% endif %}>کد مشتری</option>
</select>
</div>
</div>
<div class="field column is-12-desktop is-12-mobile m-0">
<input class="button is-success is-fullwidth" id="searchbtn" value="جستجو مشتری" type="submit">
</div>
</form>
</div>
<button class="modal-close is-large closeSearch" aria-label="close"></button>
</div>
{% endblock %}
{% block jsEnd %}
<script>
$(document).ready(function() {
$("#openSearchBox").click(function(e) {
e.preventDefault();
$(".SearchBox").addClass("is-active");
});
$(".closeSearch").click(function() {
$(".SearchBox").removeClass("is-active");
// تغییر هر دراپ‌داون ⇒ ارسال خودکار فرم فیلترها.
$(".ez-auto-submit").on('change', function () {
$(this).closest('form').trigger('submit');
});
// مقدار «مرتب‌سازی» با علامتِ جهت ترکیب می‌شود (مثلاً last_visit-desc)
// تا یک دراپ‌داون بتواند هر دو جهت را پوشش دهد.
$(".ez-filters select[name=sort]").on('change', function () {
var value = $(this).val() || '';
var form = $(this).closest('form');
var dash = value.lastIndexOf('-');
if (dash > -1) {
form.find("input[name=sort]").val(value.slice(0, dash));
form.find("input[name=dir]").val(value.slice(dash + 1));
} else {
form.find("input[name=sort]").val(value);
form.find("input[name=dir]").val('');
}
});
// تقویم شمسی برای ناوبری روزهای نمای «نیازمند پیگیری».
// همان کامپوننت pDatepicker که در بقیهٔ صفحه‌ها استفاده شده است.
{% if is_followup_view %}
var $followupDate = $("#followupDatePicker");
if ($followupDate.length && typeof $followupDate.pDatepicker === 'function') {
$followupDate.pDatepicker({
initialValue: false,
initialValueType: 'persian',
format: 'YYYY/MM/DD',
autoClose: true,
calendar: {
persian: { locale: 'fa', leapYearMode: 'astronomical' }
},
onSelect: function () {
// انتخاب تاریخ ⇒ فوراً همان روز نمایش داده شود.
$followupDate.closest('form').trigger('submit');
}
});
}
{% endif %}
});
</script>
{% endblock %}
+381 -76
View File
@@ -19,19 +19,47 @@ from ..sms.phones import (normalize_primary_values,
from ..sms.triggers import notify_payment_thanks, notify_welcome
from ..sms.rules import customer_toggle_map
from ..visit_policy import (SOURCE_COORDINATION, SOURCE_MANUAL, SOURCE_SCHEDULED,
appointment_sources_map, last_visit_map, visit_due_at,
visit_due_date, visit_period_months)
appointment_sources_map, as_aware,
followups_on_date, last_visit_map, overdue_followups,
visit_due_at, visit_due_date, visit_period_months)
# نماهای صفحهٔ «انتظار» (فیلتر ?view=). پیش‌فرض = فقط «بدون قرار» تا رفتار
# قبلی صفحه برای کاربران حفظ شود.
ENTEZAR_VIEWS = ('uncoordinated', 'manual', 'scheduled', 'coordination',
# نماهای صفحهٔ «انتظار» (فیلتر ?view=).
# پیش‌فرض = «نیازمند پیگیری امروز»: فهرست کاریِ روزانهٔ کارشناس (موعد تماس‌های
# امروز). سایر نماها با کارت‌ها/دراپ‌داون «نوع قرار» در دسترس‌اند.
# ``today`` یک نمای **مستقل** است: پایهٔ آن «عقب‌افتاده بودن» نیست — مشتری
# سالمی هم که با او قرار تماس گذاشته شده باید در این فهرست دیده شود.
ENTEZAR_VIEWS = ('today', 'uncoordinated', 'manual', 'scheduled', 'coordination',
'coordinated', 'all')
ENTEZAR_DEFAULT_VIEW = 'uncoordinated'
# حالت‌های مرتب‌سازی (?sort=):
# oldest ⇒ قدیمی‌ترین بازدید اول (پیش‌فرض/اولویت کارشناس)
# newest ⇒ جدیدترین بازدید اول
# overdue ⇒ بیشترین تأخیر اول
ENTEZAR_SORTS = ('oldest', 'newest', 'overdue')
ENTEZAR_DEFAULT_VIEW = 'today'
# نمای «نیازمند پیگیری» — برای بررسی «آیا این نما فعال است؟» در قالب/ویو.
ENTEZAR_FOLLOWUP_VIEW = 'today'
# تعداد ردیف در هر صفحه. فهرست کاری روزانه با یک نگاه مرور می‌شود، پس صفحه‌بندی
# دیرتر فعال شود.
ENTEZAR_PAGE_SIZE = 100
# فیلتر «میزان تأخیر» (?delay=). مرزها با «مدت تأخیر» (روز) مقایسه می‌شوند.
ENTEZAR_DELAYS = ('lt30', '30to90', '90to180', 'gt180')
# ستون‌های قابل مرتب‌سازی: نام فیلد ⇒ جهت پیش‌فرضِ اولین کلیک.
# ``oldest``/``newest``/``overdue`` مقادیر قدیمی‌اند و برای سازگاری با
# بوکمارک‌های قبلی نگه داشته شده‌اند (به ``last_visit``/``overdue`` نگاشت می‌شوند).
ENTEZAR_SORT_COLUMNS = {
'name': 'asc',
'status': 'asc',
'manual': 'desc',
'scheduled': 'desc',
'coordination': 'desc',
'last_visit': 'asc',
'overdue': 'desc',
'phone': 'asc',
'address': 'asc',
}
_ENTEZAR_LEGACY_SORTS = {
'oldest': ('last_visit', 'asc'),
'newest': ('last_visit', 'desc'),
'overdue': ('overdue', 'desc'),
}
from ._helpers import logger, _parse_jalali_date, _parse_phone_rows
@@ -359,9 +387,26 @@ def entezar(request):
view = (request.GET.get('view') or ENTEZAR_DEFAULT_VIEW).strip().lower()
if view not in ENTEZAR_VIEWS:
view = ENTEZAR_DEFAULT_VIEW # مقدار نامعتبر ⇒ پیش‌فرض، بدون خطا
sort = (request.GET.get('sort') or 'oldest').strip().lower()
if sort not in ENTEZAR_SORTS:
sort = 'oldest'
delay = (request.GET.get('delay') or '').strip().lower()
if delay not in ENTEZAR_DELAYS:
delay = '' # '' یعنی بدون فیلتر تأخیر
# مرتب‌سازی: ``?sort=<ستون>&dir=asc|desc``؛ مقادیر قدیمی هم پذیرفته می‌شوند.
# نکته: اگر ``dir`` صریح آمده باشد، مقدم است — وگرنه کلیک روی سرستون
# «تأخیر» با جهت صعودی، به‌خاطر نگاشت قدیمی، نادیده گرفته می‌شد.
raw_sort = (request.GET.get('sort') or '').strip().lower()
raw_dir = (request.GET.get('dir') or '').strip().lower()
if raw_sort in ENTEZAR_SORT_COLUMNS:
sort_field = raw_sort
if raw_dir in ('asc', 'desc'):
sort_dir = raw_dir
else:
sort_dir = 'desc' if raw_sort in _ENTEZAR_LEGACY_SORTS else \
ENTEZAR_SORT_COLUMNS[raw_sort]
elif raw_sort in _ENTEZAR_LEGACY_SORTS:
sort_field, sort_dir = _ENTEZAR_LEGACY_SORTS[raw_sort]
else:
sort_field, sort_dir = _ENTEZAR_LEGACY_SORTS['oldest']
searchStatus = True
full_path = request.get_full_path()
@@ -370,32 +415,47 @@ def entezar(request):
# دوره فقط یک بار خوانده می‌شود (جلوگیری از N+1 در حلقهٔ محاسبهٔ تأخیر).
period_months = visit_period_months()
# مرز تاریخ به وقت محلی (نه UTC) تا صفحه و اسکنر پیامک یک جواب بدهند.
due_cut_date = due_cut.astimezone(timezone.get_current_timezone()).date()
current_tz = timezone.get_current_timezone()
today_date = now.astimezone(current_tz).date()
due_cut_date = due_cut.astimezone(current_tz).date()
# تاریخ نمای «نیازمند پیگیری»: از تقویم شمسی می‌آید (``?fdate=1405/08/29``).
# خالی/نامعتبر ⇒ امروز. مقدار گذشته و آینده هر دو مجازند.
followup_date_raw = (request.GET.get('fdate') or '').strip()
followup_date = _parse_jalali_date(followup_date_raw) or today_date
followup_date_jalali = JalaliDate(followup_date).strftime('%Y/%m/%d')
is_followup_view = view == ENTEZAR_FOLLOWUP_VIEW
visits = last_visit_map(now=now)
sources = appointment_sources_map(now=now)
# جست‌وجو: نام، آدرس، تلفن (همه شماره‌ها) و کد شناسایی.
def _apply_search(queryset):
"""فیلتر جست‌وجو؛ فیلد نامعتبر ⇒ بدون فیلتر (سازگاری با رفتار قبلی)."""
if not searchVal:
return queryset
if searchOn == 'address':
return queryset.filter(address__contains=searchVal)
if searchOn == 'name':
return queryset.filter(name_family__contains=searchVal)
if searchOn == 'phone':
return queryset.filter(
Q(phone1__contains=searchVal) | Q(phone2__contains=searchVal)
| Q(phone3__contains=searchVal)
)
if searchOn == 'code':
return queryset.filter(idCode__contains=searchVal)
return queryset
searchStatus = bool(searchVal) and searchOn in (
'address', 'name', 'phone', 'code')
# پایهٔ فهرست انتظار: فقط مشتریانی که دوره‌شان گذشته (کارت‌های آمار).
customers_objj = Customer.objects.filter(
laghv_bazdid=False, name_family__isnull=False,
installDate__lte=due_cut_date,
)
# جست‌وجو: نام، آدرس، تلفن (همه شماره‌ها) و کد شناسایی.
if searchVal:
if searchOn == 'address':
customers_objj = customers_objj.filter(address__contains=searchVal)
elif searchOn == 'name':
customers_objj = customers_objj.filter(name_family__contains=searchVal)
elif searchOn == 'phone':
customers_objj = customers_objj.filter(
Q(phone1__contains=searchVal) | Q(phone2__contains=searchVal)
| Q(phone3__contains=searchVal)
)
elif searchOn == 'code':
customers_objj = customers_objj.filter(idCode__contains=searchVal)
else:
searchStatus = False
else:
searchStatus = False
customers_objj = _apply_search(customers_objj)
# مشتریان عقب‌افتاده: بدون بازدید معتبر، یا بازدید معتبرشان پیش از مقطع دوره.
stale_ids = [
@@ -431,12 +491,79 @@ def entezar(request):
'coordinated': coordinated_ids,
'all': stale_ids,
}
# ---------- نمای «نیازمند پیگیری» (یک تاریخ مشخص) ----------
# این نما **مستقل** از «عقب‌افتاده» است: مشتری سالمی که با او قرار تماس
# گذاشته شده هم باید دیده شود. قاعدهٔ حذف «بازدید» در خود
# ``followups_on_date`` اعمال می‌شود (مشتریِ دارای قرار بازدید نمی‌آید).
today_followups = followups_on_date(today_date, now=now)
# پیگیری‌های عقب‌افتاده: موعدشان گذشته و هنوز تعیین تکلیف نشده‌اند.
# فقط در نمای پیگیری محاسبه می‌شود (کوئری اضافه برای بقیهٔ نماها لازم نیست).
overdue_map = overdue_followups(now=now, target_date=today_date) \
if is_followup_view else {}
overdue_oldest = min(overdue_map.values()) if overdue_map else None
# فیلتر «فقط عقب‌افتاده‌ها» (?missed=1) — همان نما با منبعِ دیگر.
show_missed = is_followup_view and \
(request.GET.get('missed') or '').strip().lower() in ('1', 'true', 'on')
if is_followup_view:
if show_missed:
# تاریخِ موعدِ ازدست‌رفته به‌صورت datetime تا با بقیهٔ مسیرها یکسان باشد.
followups = {
cid: {SOURCE_COORDINATION: as_aware(day)}
for cid, day in overdue_map.items()
}
elif followup_date == today_date:
followups = today_followups
else:
followups = followups_on_date(followup_date, now=now)
else:
followups = {}
def _followup_ids(mapping):
"""شناسه‌های واجد شرایط (فعال/لغو‌نشده/دارای نام + جست‌وجو) با ترتیب زمانی."""
if not mapping:
return []
eligible = _apply_search(
Customer.objects.filter(
id__in=list(mapping),
is_active=True,
laghv_bazdid=False,
name_family__isnull=False,
)
)
allowed = set(eligible.values_list('id', flat=True))
return sorted(
(cid for cid in mapping if cid in allowed),
key=lambda cid: (min(mapping[cid].values()), cid),
)
followup_ids = _followup_ids(followups) if is_followup_view else []
# شمارنده‌ها از همان مسیرِ فهرست می‌آیند تا «عدد هشدار = تعداد ردیف‌ها» بماند
# (مشتری غیرفعال/لغو‌شده/بی‌نام یا فیلترشده شمرده نمی‌شود).
today_followup_count = len(_followup_ids(today_followups))
overdue_followup_count = len(_followup_ids(
{cid: {SOURCE_COORDINATION: as_aware(day)} for cid, day in overdue_map.items()}
))
selected_map[ENTEZAR_FOLLOWUP_VIEW] = followup_ids
selected = selected_map[view]
# برای نمای پیگیری، فهرست مشتریان از همان شناسه‌های تأییدشده می‌آید
# (ممکن است در «عقب‌افتاده» نباشند).
if is_followup_view:
customers_objj = _apply_search(
Customer.objects.filter(id__in=followup_ids)
)
# «مدت تأخیر» = از چه زمانی بازدید عقب افتاده است (به روز).
# مبنای محاسبه «موعد بازدید» است، نه خود بازدید: موعد = بازدید + دوره.
# این‌طور مشتریِ تازه‌معوق هم در بازهٔ کوتاه قرار می‌گیرد و عدد معنا پیدا می‌کند.
today = now.astimezone(timezone.get_current_timezone()).date()
# محاسبه روی کلِ نمای جاری انجام می‌شود تا شمارنده‌های بازه‌ها با فیلتر
# «میزان تأخیر» هم‌خوان باشند (اول نمای جاری، بعد فیلتر تأخیر).
today = today_date
overdue_days = {}
for customer_id in selected:
anchor = visits.get(customer_id)
@@ -448,42 +575,7 @@ def entezar(request):
base = due_cut_date
overdue_days[customer_id] = max((today - base).days, 0)
# مرتب‌سازی:
# oldest ⇒ قدیمی‌ترین بازدید اول (اولویت کارشناس)
# newest ⇒ جدیدترین بازدید اول
# overdue ⇒ بیشترین تأخیر اول (چه کسی بیشتر از همه عقب افتاده)
if sort == 'overdue':
ordered_ids = sorted(selected, key=lambda cid: (-overdue_days.get(cid, 0), cid))
customers_objj = customers_objj.filter(id__in=ordered_ids)
if ordered_ids:
# ترتیب در لایهٔ دیتابیس با CASE/WHEN (بدون بار حافظه‌ای).
ordering = Case(
*[When(id=cid, then=Value(index))
for index, cid in enumerate(ordered_ids)],
output_field=models.IntegerField(),
)
customers_objj = customers_objj.order_by(ordering)
else:
customers_objj = customers_objj.order_by('id')
else:
# مرتب‌سازی بر مبنای **تاریخ مؤثر بازدید** (از Action)، نه ستون ممکن‌القدمت.
ordered_ids = sorted(
selected,
key=lambda cid: (
(visits.get(cid) or due_cut),
cid,
),
reverse=(sort == 'newest'),
)
customers_objj = customers_objj.filter(id__in=ordered_ids)
ordering = Case(
*[When(id=cid, then=Value(index))
for index, cid in enumerate(ordered_ids)],
output_field=models.IntegerField(),
)
customers_objj = customers_objj.order_by(ordering)
# آمار «تأخیر» (روی کل گروه انتخابی، نه فقط صفحهٔ جاری).
# آمار «تأخیر» روی نمای جاری (قبل از اعمال فیلتر تأخیر).
buckets = {'lt30': 0, '30to90': 0, '90to180': 0, 'gt180': 0}
for days in overdue_days.values():
if days < 30:
@@ -495,17 +587,110 @@ def entezar(request):
else:
buckets['gt180'] += 1
# اعمال فیلتر «میزان تأخیر».
delay_ranges = {
'lt30': (0, 30),
'30to90': (30, 90),
'90to180': (90, 180),
'gt180': (180, None),
}
# فیلتر «میزان تأخیر» فقط برای نمای «عقب‌افتاده» معنا دارد؛ در نمای
# «پیگیری امروز» مشتری ممکن است اصلاً تأخیری نداشته باشد.
if delay and not is_followup_view:
low, high = delay_ranges[delay]
selected = [
cid for cid in selected
if overdue_days.get(cid, 0) >= low
and (high is None or overdue_days.get(cid, 0) < high)
]
# ---------- مرتب‌سازی (روی همهٔ ستون‌ها) ----------
# یک کوئری سبک برای فیلدهای Customer که برای مرتب‌سازی لازم است.
info = {
row['id']: row
for row in Customer.objects.filter(id__in=selected).values(
'id', 'name_family', 'phone1', 'address')
} if selected else {}
def _source_date(cid, key):
return (sources.get(cid) or {}).get(key)
def _sort_value(cid):
"""مقدار مرتب‌سازی برای هر ستون (تاریخ‌ها aware یا None)."""
if sort_field == 'name':
return (info.get(cid, {}).get('name_family') or '')
if sort_field == 'phone':
return (info.get(cid, {}).get('phone1') or '')
if sort_field == 'address':
return (info.get(cid, {}).get('address') or '')
if sort_field == 'status':
# ترتیب منطقی: بدون قرار ← قرار دستی ← بازدید ← هماهنگی ← چندقراره
entry = sources.get(cid) or {}
if not entry:
return 0
if len(entry) > 1:
return 4
if SOURCE_MANUAL in entry:
return 1
if SOURCE_SCHEDULED in entry:
return 2
return 3
if sort_field == 'manual':
return _source_date(cid, SOURCE_MANUAL)
if sort_field == 'scheduled':
return _source_date(cid, SOURCE_SCHEDULED)
if sort_field == 'coordination':
return _source_date(cid, SOURCE_COORDINATION)
if sort_field == 'overdue':
return overdue_days.get(cid, 0)
# last_visit: مشتریِ بدون بازدید، قدیمی‌ترین حساب می‌شود.
return visits.get(cid) or due_cut
if selected:
if is_followup_view and not raw_sort:
# در نمای پیگیری، ترتیب طبیعی «زودترین موعد پیگیری» است
# (ترتیب از ``_followup_ids`` آمده) — نه قدیمی‌ترین بازدید.
ordered_ids = list(selected)
else:
values = {cid: _sort_value(cid) for cid in selected}
with_value = [cid for cid in selected if values[cid] is not None]
without_value = [cid for cid in selected if values[cid] is None]
with_value.sort(key=lambda cid: (values[cid], cid),
reverse=(sort_dir == 'desc'))
# مقادیر خالی همیشه آخر می‌آیند (مستقل از جهت مرتب‌سازی) تا فهرست
# با ستون‌های «قرار دستی/بازدید/هماهنگی» بی‌معنا نشود.
ordered_ids = with_value + without_value
else:
ordered_ids = []
customers_objj = customers_objj.filter(id__in=ordered_ids)
if ordered_ids:
ordering = Case(
*[When(id=cid, then=Value(index))
for index, cid in enumerate(ordered_ids)],
output_field=models.IntegerField(),
)
customers_objj = customers_objj.order_by(ordering)
else:
customers_objj = customers_objj.order_by('id')
counts = {
'total': len(stale_ids),
'uncoordinated': len(selected_map['uncoordinated']),
# عددِ هر کارت دقیقاً همان چیزی است که با کلیک روی آن دیده می‌شود؛
# چون یک مشتری می‌تواند چند منبع داشته باشد، جمع کارت‌ها ممکن است
# از «با قرار» بیشتر شود. در راهنمای بالای صفحه توضیح داده می‌شود.
# عددِ هر کارت دقیقاً همان چیزی است که با کلیک روی آن دیده می‌شود.
# یک مشتری می‌تواند چند منبع داشته باشد، پس جمع سه منبع می‌تواند از
# «تعیین تکلیف شده» بیشتر شود؛ جزئیات در دراپ‌داون «نوع قرار» است.
'manual': len(selected_map['manual']),
'scheduled': len(selected_map['scheduled']),
'coordination': len(selected_map['coordination']),
'coordinated': len(selected_map['coordinated']),
# کارت «نیازمند پیگیری امروز» — همیشه عدد امروز را نشان می‌دهد،
# مستقل از تاریخی که کاربر در تقویم انتخاب کرده است.
'followup_today': today_followup_count,
# پیگیری‌های موعدگذشته که هنوز تعیین تکلیف نشده‌اند (هشدار قرمز).
'followup_overdue': overdue_followup_count,
'selected': len(selected),
'view_total': len(selected_map[view]),
'buckets': buckets,
# برای متن راهنمای بالای صفحه («بیش از N ماه از بازدید گذشته»).
'period_months': period_months,
@@ -513,20 +698,118 @@ def entezar(request):
try:
entezar_bazdid = CacheTable.objects.get(key='9mahbazdid')
# کارت داشبورد = «کارِ فوری» = بدون قرار.
# کارت داشبورد = «کارِ فوری» = تعیین تکلیف نشده.
entezar_bazdid.value = counts['uncoordinated']
entezar_bazdid.save()
except CacheTable.DoesNotExist:
pass
paginator = Paginator(customers_objj, 20)
paginator = Paginator(customers_objj, ENTEZAR_PAGE_SIZE)
page_number = request.GET.get('page', None)
customerHa = paginator.get_page(page_number)
# لینک صفحه‌بندی با حفظ همهٔ فیلترهای فعلی.
def _page_link(number):
params = request.GET.copy()
params['page'] = number
return '?' + params.urlencode()
page_prev = _page_link(customerHa.previous_page_number()) if customerHa.has_previous() else ''
page_next = _page_link(customerHa.next_page_number()) if customerHa.has_next() else ''
# ---------- لینک‌های حفظ‌کنندهٔ فیلترها ----------
def _qs(**overrides):
"""پارامترهای فعلی + تغییرهای داده‌شده (بدون page). None ⇒ حذف."""
params = request.GET.copy()
params.pop('page', None)
for key, value in overrides.items():
if value is None:
params.pop(key, None)
else:
params[key] = value
query = params.urlencode()
return '?' + query if query else '?'
view_links = {name: _qs(view=name) for name in ENTEZAR_VIEWS}
# کارت «نیازمند پیگیری امروز» همیشه به امروز می‌رود (تاریخ انتخابی پاک می‌شود).
view_links[ENTEZAR_FOLLOWUP_VIEW] = _qs(view=ENTEZAR_FOLLOWUP_VIEW, fdate=None)
# ---------- ناوبری تقویم در نمای پیگیری ----------
def _followup_link(target_date):
return _qs(view=ENTEZAR_FOLLOWUP_VIEW,
fdate=JalaliDate(target_date).strftime('%Y/%m/%d'))
followup_nav = {
'prev': _followup_link(followup_date - datetime.timedelta(days=1)),
'next': _followup_link(followup_date + datetime.timedelta(days=1)),
'today': _followup_link(today_date),
'is_today': followup_date == today_date,
}
# لینک هشدار «عقب‌افتاده‌ها» و لینک بازگشت از آن حالت.
followup_overdue_link = _qs(view=ENTEZAR_FOLLOWUP_VIEW, fdate=None, missed=1)
followup_ontime_link = _qs(view=ENTEZAR_FOLLOWUP_VIEW, fdate=None, missed=None)
delay_links = {'ez_all': _qs(delay=None)}
delay_links.update({name: _qs(delay=name) for name in ENTEZAR_DELAYS})
sort_links = {}
for column, default_dir in ENTEZAR_SORT_COLUMNS.items():
# کلیک دوباره روی همان ستون، جهت را برمی‌گرداند.
next_dir = ('desc' if sort_dir == 'asc' else 'asc') \
if sort_field == column else default_dir
sort_links[column] = _qs(sort=column, dir=next_dir)
# سرستون‌های مرتب‌شدنی (به همان ترتیب ستون‌های جدول).
# در نمای «نیازمند پیگیری امروز» ستون «تأخیر» نمایش داده **نمی‌شود**:
# این فهرست دربارهٔ «موعد امروز» است، نه عقب‌افتادگی بازدید.
columns = [
('name', 'نام و نام خانوادگی', 21),
('status', 'وضعیت قرار', 12),
('manual', 'قرار دستی', 10),
('scheduled', 'بازدید برنامه‌ریزی‌شده', 10),
('coordination', 'هماهنگی', 10),
('last_visit', 'آخرین بازدید', 9),
('overdue', 'تأخیر', 8),
('phone', 'تلفن', 9),
('address', 'آدرس', 11),
]
if is_followup_view:
hidden_width = next(width for key, _, width in columns if key == 'overdue')
columns = [col for col in columns if col[0] != 'overdue']
# عرض ستون حذف‌شده به نام اضافه می‌شود تا مجموع همچنان ۱۰۰٪ بماند.
columns[0] = (columns[0][0], columns[0][1], columns[0][2] + hidden_width)
table_columns = [{'key': key, 'label': label, 'width': width}
for key, label, width in columns]
hidden_columns = ['overdue'] if is_followup_view else []
sort_header = [
{
'key': column,
'label': label,
'link': sort_links[column],
'active': sort_field == column,
'dir': sort_dir if sort_field == column else '',
}
for column, label in ((key, label) for key, label, _ in columns)
]
# لینک «حذف همهٔ فیلترها»: نمای پیش‌فرض (امروز)، بدون تأخیر/جست‌وجو/مرتب‌سازی.
clear_link = '?view=' + ENTEZAR_DEFAULT_VIEW
has_filters = bool(
delay or searchVal or sort_field != 'last_visit'
or sort_dir != 'asc'
# تاریخِ انتخاب‌شده یا حالت «فقط عقب‌افتاده‌ها» هم فیلتر است.
or (is_followup_view and followup_date != today_date)
or show_missed
)
# اطلاعات هر ردیف برای قالب: قرارها + مدت تأخیر + آخرین بازدید مؤثر.
appointment_rows = {}
for customer in customerHa.object_list:
entry = sources.get(customer.id) or {}
entry = dict(sources.get(customer.id) or {})
# در نمای پیگیری، تاریخ همان روز ملاک نمایش است (حتی اگر گذشته باشد و
# در ``appointment_sources_map`` که فقط آینده را می‌بیند، نباشد).
followup_entry = followups.get(customer.id) or {}
entry.update(followup_entry)
flags = [key for key in (SOURCE_MANUAL, SOURCE_SCHEDULED,
SOURCE_COORDINATION) if key in entry]
appointment_rows[customer.id] = {
@@ -537,14 +820,36 @@ def entezar(request):
'last_visit': visits.get(customer.id),
# تعداد منابع (۰ تا ۳) — قالب بر اساس آن برچسب وضعیت را می‌سازد.
'source_count': len(flags),
# تاریخ پیگیری این نمای خاص (برای ستون اختصاصی نمای «امروز»).
'followup_at': (min(followup_entry.values())
if followup_entry else None),
}
return render(request, 'admin_panel/entezar.html', {
'customers': customerHa, 'searchStatus': searchStatus, 'full_path': full_path,
'searchVal': searchVal, 'searchOn': searchOn,
'view': view, 'counts': counts, 'appointment_rows': appointment_rows,
'sort': sort, 'sorts': ENTEZAR_SORTS,
'view_links': view_links, 'delay_links': delay_links,
'sort_links': sort_links, 'sort_header': sort_header,
'clear_link': clear_link,
'has_filters': has_filters,
'delay': delay, 'delays': ENTEZAR_DELAYS,
'sort': sort_field, 'sort_dir': sort_dir,
'sort_columns': ENTEZAR_SORT_COLUMNS,
'table_columns': table_columns,
'hidden_columns': hidden_columns,
'column_count': len(table_columns),
'page_prev': page_prev, 'page_next': page_next,
'views': ENTEZAR_VIEWS,
# نمای «نیازمند پیگیری»
'is_followup_view': is_followup_view,
'followup_view': ENTEZAR_FOLLOWUP_VIEW,
'followup_date': followup_date,
'followup_date_jalali': followup_date_jalali,
'followup_nav': followup_nav,
'show_missed': show_missed,
'followup_overdue_link': followup_overdue_link,
'followup_ontime_link': followup_ontime_link,
})
+194 -19
View File
@@ -402,33 +402,24 @@ def coordination_holds_map(*, now=None):
)
def manual_holds_map(*, now=None):
"""قرارهای دستی (ستون ``visit_hold_until``) — **با اعتبارسنجی**.
def _valid_manual_holds(holds):
"""اعتبارسنجی قرارهای دستی — قاعدهٔ قطعی «ستون، مرجع نیست».
قاعدهٔ قطعی: ستون به‌تنهایی معتبر نیست. یک قرار دستی فقط وقتی اثر دارد که:
ورودی ``{customer_id: visit_hold_until}`` و خروجی همان نگاشت، فقط برای
قرارهایی که معتبرند:
۱. تاریخش از حالا جلوتر باشد،
۲. یک اقدام «هماهنگی» فعال (``waite``/``finish``) برای مشتری وجود داشته باشد،
۳. و **آخرین فعالیتِ مشتری همان هماهنگی باشد** — اگر پس از هماهنگی، اقدام
۱. یک اقدام «هماهنگی» فعال (``waite``/``finish``) برای مشتری وجود دارد،
۲. و **آخرین فعالیتِ مشتری همان هماهنگی است** — اگر پس از هماهنگی، اقدام
دیگری (به‌ویژه «بازدید») ثبت شده باشد، قرار باطل است.
بند ۳ همان چیزی است که «ثبت بازدید پس از هماهنگی» را خودکار بی‌اثر می‌کند؛
بدون آن، قرارِ قدیمی تا تاریخش باقی می‌ماند و مشتری در فهرست «با قرار»
می‌ماند، هرچند بازدید (یا برنامهٔ بازدید) جای هماهنگی را گرفته است.
بند ۲ همان چیزی است که «ثبت بازدید پس از هماهنگی» را خودکار بی‌اثر می‌کند.
نتیجه: حذف/لغو هماهنگی، یا هر اقدام جدیدتر از آن ⇒ قرار بی‌اثر می‌شود؛
و کهنه‌ماندن ستون هیچ اثر مخربی ندارد.
این تابع جدا شده تا هم :func:`manual_holds_map` (قرارهای آینده) و هم
:func:`followups_on_date` (قرارهای یک روز مشخص، حتی گذشته) از **یک قاعدهٔ
واحد** استفاده کنند و دو تعریف واگرا شکل نگیرد.
"""
now = _now(now)
rows = (
Customer.objects.filter(visit_hold_until__gt=now)
.values_list("id", "visit_hold_until")
.iterator()
)
holds = {customer_id: as_aware(until) for customer_id, until in rows}
if not holds:
return {}
customer_ids = list(holds)
# مرجع اعتبار: وجود یک اقدام «هماهنگیِ فعال» برای همان مشتری (نه لزوماً با
@@ -469,6 +460,190 @@ def manual_holds_map(*, now=None):
return valid
def manual_holds_map(*, now=None):
"""قرارهای دستی **آینده** (ستون ``visit_hold_until``) — با اعتبارسنجی.
برای فهرست انتظار و آمار؛ قاعدهٔ اعتبار در :func:`_valid_manual_holds`.
"""
now = _now(now)
rows = (
Customer.objects.filter(visit_hold_until__gt=now)
.values_list("id", "visit_hold_until")
.iterator()
)
return _valid_manual_holds(
{customer_id: as_aware(until) for customer_id, until in rows}
)
# ---------------------------------------------------------------------------
# «نیازمند پیگیری» در یک روز مشخص
# ---------------------------------------------------------------------------
def _local_day_bounds(target_date):
"""بازهٔ ``[آغاز روز، پایان روز]`` به وقت محلی (aware)."""
current_tz = timezone.get_current_timezone()
start = datetime.combine(target_date, time.min)
end = datetime.combine(target_date, time.max)
try:
return (
timezone.make_aware(start, current_tz),
timezone.make_aware(end, current_tz),
)
except Exception:
return (
timezone.make_aware(start, current_tz, is_dst=False),
timezone.make_aware(end, current_tz, is_dst=False),
)
def _pending_visit_customer_ids(*, since):
"""مشتریانی که **قرار بازدید** دارند و نباید در فهرست پیگیری بیایند.
قاعدهٔ محصولی: «اگر برای مشتری بازدید گذاشته شده، دیگر لازم نیست تماس
بگیریم». پس هر مشتری با اقدام ``bazdid + waite`` که تاریخش از ``since``
(آغاز روز مورد نظر) عقب‌تر نباشد، از فهرست پیگیری **حذف** می‌شود.
چرا ``>= since`` و نه «هر بازدیدی»؟ بازدیدِ در انتظارِ بسیار قدیمی
(backlog بسته‌نشده) قرار فعال نیست؛ آن مشتری هنوز نیاز به تماس دارد.
"""
return set(
Action.objects.filter(
actionType=Action.ActionType.BAZDID,
status=Action.Status.WAITE,
)
.filter(
models.Q(actionDateTime__gte=since)
| models.Q(actionDateTime__isnull=True, finishDateTime__gte=since)
)
.values_list("customer_id", flat=True)
)
def followups_on_date(target_date, *, now=None):
"""مشتریانِ «نیازمند پیگیری» در یک تاریخ مشخص (تقویم شمسی/میلادی).
«نیازمند پیگیری» یعنی موعدی که با مشتری توافق شده **همان روز** است:
* **قرار دستی** — ``Customer.visit_hold_until`` که تاریخ آن همان روز است
(با همان اعتبارسنجی :func:`_valid_manual_holds`).
* **هماهنگی** — اقدام ``hamahangi`` با ``actionDateTime`` (وگرنه
``finishDateTime``) در همان روز، با وضعیت ``waite`` یا ``finish``.
تفاوت با :func:`appointment_sources_map`: آن تابع فقط **آینده** را می‌بیند
(برای تصمیم «خارج از فهرست انتظار»)، ولی این یکی **هر روزی** — گذشته،
امروز یا آینده — را می‌بیند تا کارشناس بتواند با تقویم عقب/جلو برود.
خروجی: ``{customer_id: {منبع: تاریخ}}`` مرتب بر اساس زودترین ساعت موعد.
مشتریانی که قرار **بازدید** دارند در خروجی نیستند.
"""
day_start, day_end = _local_day_bounds(target_date)
result = {}
# ۱) قرارهای دستی همان روز (مستقل از گذشته/آینده بودن).
rows = (
Customer.objects.filter(
visit_hold_until__gte=day_start, visit_hold_until__lte=day_end,
)
.values_list("id", "visit_hold_until")
.iterator()
)
for customer_id, until in _valid_manual_holds(
{cid: as_aware(when) for cid, when in rows}
).items():
result.setdefault(customer_id, {})[SOURCE_MANUAL] = until
# ۲) اقدام‌های «هماهنگی» همان روز.
coordination_rows = (
Action.objects.filter(
actionType=Action.ActionType.HAMAHANGI,
status__in=(Action.Status.WAITE, Action.Status.FINISH),
)
.filter(
models.Q(actionDateTime__gte=day_start, actionDateTime__lte=day_end)
| models.Q(
actionDateTime__isnull=True,
finishDateTime__gte=day_start,
finishDateTime__lte=day_end,
)
)
.values_list("customer_id", "actionDateTime", "finishDateTime")
.iterator()
)
for customer_id, action_dt, finish_dt in coordination_rows:
when = as_aware(action_dt) or as_aware(finish_dt)
if when is None:
continue
bucket = result.setdefault(customer_id, {})
current = bucket.get(SOURCE_COORDINATION)
if current is None or when < current:
bucket[SOURCE_COORDINATION] = when
# ۳) حذف مشتریانی که برایشان «بازدید» گذاشته شده است.
if result:
for customer_id in _pending_visit_customer_ids(since=day_start):
result.pop(customer_id, None)
return result
def overdue_followups(*, now=None, target_date=None):
"""پیگیری‌های **عقب‌افتاده**: موعدشان گذشته و هنوز تعیین تکلیف نشده‌اند.
«عقب‌افتاده» یعنی برای هر مشتری، **آخرین** اقدام هماهنگی (``waite``/``finish``)
تاریخی پیش از امروز دارد؛ یعنی موعد توافق‌شده گذشته و هیچ موعد تازه‌ای
جایگزینش نشده است.
قواعد:
* اگر مشتری هماهنگیِ **امروز یا آینده** داشته باشد، عقب‌افتاده نیست
(موعدش تازه است).
* اگر برایش **قرار بازدید** ثبت شده باشد، از فهرست خارج می‌شود — همان قاعدهٔ
:func:`followups_on_date` («بازدید گذاشته شده ⇒ تماس لازم نیست»).
* هماهنگیِ لغوشده (``cancell``) اصلاً دیده نمی‌شود.
خروجی: ``{customer_id: تاریخِ موعدِ ازدست‌رفته}`` — تا هم شمارش کنیم و هم
بتوانیم کاربر را به همان روز ببریم.
"""
now = _now(now)
today = target_date or now.astimezone(timezone.get_current_timezone()).date()
rows = (
Action.objects.filter(
actionType=Action.ActionType.HAMAHANGI,
status__in=(Action.Status.WAITE, Action.Status.FINISH),
)
.values_list("customer_id", "actionDateTime", "finishDateTime")
.iterator()
)
# آخرین موعد هر مشتری (تاریخ، نه لحظه).
latest = {}
for customer_id, action_dt, finish_dt in rows:
when = as_aware(action_dt) or as_aware(finish_dt)
if when is None:
continue
day = when.astimezone(timezone.get_current_timezone()).date()
current = latest.get(customer_id)
if current is None or day > current:
latest[customer_id] = day
overdue = {cid: day for cid, day in latest.items() if day < today}
if not overdue:
return {}
# حذف مشتریانی که برایشان بازدید گذاشته شده است (قرار بازدید از آن روز).
# «از آن روز» = زودترین موعدِ ازدست‌رفته؛ بازدیدی که بعد از آن گذاشته شده
# تکلیف مشتری را روشن کرده است.
pending_visits = _pending_visit_customer_ids(
since=_local_day_bounds(min(overdue.values()))[0]
)
for customer_id in pending_visits:
overdue.pop(customer_id, None)
return overdue
def appointment_sources_map(*, now=None):
"""نگاشت ``customer_id → {منبع: تاریخ}`` برای هر سه منبع (فقط آینده).