Портал: страницы на реальных данных, детальные страницы, утилиты

- core/views: dashboard/databases/it-support/structure на живых данных;
  детальная сотрудника (/employees/<pk>/) с PII-гейтингом (staff/сам/
  руководитель), детальная аптеки, дислокация, профиль, показания счётчиков
- core/utils: crypto (Fernet-обёртка), pagination; партиалы пагинации и
  AD-вкладок
- шаблоны страниц обновлены под реальные контексты
- тесты (21): смоук всех страниц, PII-доступ сотрудника, дерево справочников

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-22 17:33:48 +03:00
parent 6ff01c4119
commit 5e4528b23f
19 changed files with 3900 additions and 638 deletions
+775
View File
@@ -0,0 +1,775 @@
{% load common_tags %}
<div class="page-content">
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:1.5rem;">
<div>
<h1 style="font-size:1.25rem; font-weight:500; color:var(--color-foreground);">Дислокація</h1>
<p style="font-size:0.875rem; color:var(--color-muted-foreground); margin-top:0.25rem;">{{ pharmacies|length }} аптек · {{ office_employees_count }} співробітників офісу/складу · персонал по кожній локації</p>
</div>
</div>
<!-- Вкладки -->
<div style="display:flex; gap:0.25rem; background:var(--color-muted); border-radius:var(--radius); padding:0.25rem; width:fit-content; margin-bottom:1.5rem;">
<button type="button" class="dislo-tab-btn dislo-tab-active" data-tab="pharmacy" onclick="disloTabSwitch('pharmacy')"
style="padding:0.375rem 1rem; font-size:0.8125rem; font-weight:500; border:none; border-radius:calc(var(--radius) - 2px); cursor:pointer; background:var(--color-card); color:var(--color-card-foreground); box-shadow:0 1px 2px rgb(0 0 0/0.08);">Аптеки</button>
<button type="button" class="dislo-tab-btn" data-tab="office" onclick="disloTabSwitch('office')"
style="padding:0.375rem 1rem; font-size:0.8125rem; font-weight:500; border:none; border-radius:calc(var(--radius) - 2px); cursor:pointer; background:transparent; color:var(--color-muted-foreground);">Офіс / Склад</button>
</div>
<!-- ══ ВКЛАДКА: АПТЕКИ ══ -->
<div id="dislo-panel-pharmacy" class="dislo-panel">
<!-- Метрики -->
<div style="display:grid; grid-template-columns:repeat(4, minmax(0,1fr)); gap:0.75rem; margin-bottom:1.5rem;">
<div class="stat-card" style="padding:1rem 1.25rem;">
<div style="font-size:0.6875rem; color:var(--color-muted-foreground); text-transform:uppercase; letter-spacing:0.05em; margin-bottom:0.375rem;">Всього аптек</div>
<div style="font-size:1.5rem; font-weight:500; color:var(--color-foreground);">{{ pharmacies|length }}</div>
<div style="font-size:0.75rem; color:var(--color-success); margin-top:0.25rem;">{{ active_count }} активних</div>
</div>
<div class="stat-card" style="padding:1rem 1.25rem;">
<div style="font-size:0.6875rem; color:var(--color-muted-foreground); text-transform:uppercase; letter-spacing:0.05em; margin-bottom:0.375rem;">Організацій</div>
<div style="font-size:1.5rem; font-weight:500; color:var(--color-foreground);">{{ organizations_count }}</div>
<div style="font-size:0.75rem; color:var(--color-muted-foreground); margin-top:0.25rem;">юридичних осіб</div>
</div>
<div class="stat-card" style="padding:1rem 1.25rem;">
<div style="font-size:0.6875rem; color:var(--color-muted-foreground); text-transform:uppercase; letter-spacing:0.05em; margin-bottom:0.375rem;">Без завідувача</div>
<div style="font-size:1.5rem; font-weight:500; color:var(--color-foreground);">{{ pharmacies_no_head }}</div>
{% if pharmacies_no_head %}
<div style="font-size:0.75rem; color:var(--color-warning); margin-top:0.25rem;">потребують призначення</div>
{% else %}
<div style="font-size:0.75rem; color:var(--color-success); margin-top:0.25rem;">всі аптеки із завідувачем</div>
{% endif %}
</div>
<div class="stat-card" style="padding:1rem 1.25rem;">
<div style="font-size:0.6875rem; color:var(--color-muted-foreground); text-transform:uppercase; letter-spacing:0.05em; margin-bottom:0.375rem;">Без персоналу</div>
<div style="font-size:1.5rem; font-weight:500; color:var(--color-foreground);">{{ pharmacies_empty }}</div>
{% if pharmacies_empty %}
<div style="font-size:0.75rem; color:var(--color-destructive); margin-top:0.25rem;">аптек без співробітників</div>
{% else %}
<div style="font-size:0.75rem; color:var(--color-success); margin-top:0.25rem;">всі укомплектовані</div>
{% endif %}
</div>
</div>
<!-- Фільтри + переключатель вида -->
{% regroup pharmacies by organization.name as enterprise_list %}
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:1rem; gap:1rem; flex-wrap:wrap;">
<div style="display:flex; align-items:center; gap:0.5rem; flex-wrap:wrap;">
<div style="display:flex; align-items:center; gap:0.5rem; background:var(--color-muted); border:1px solid var(--color-border); border-radius:var(--radius); padding:0.375rem 0.75rem; width:14rem;">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="color:var(--color-muted-foreground); flex-shrink:0;">
<circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/>
</svg>
<input type="text" id="dislo-search" placeholder="Пошук аптеки..." oninput="disloFilter()"
class="input" style="border:none; background:transparent; padding:0; box-shadow:none; font-size:0.8125rem;">
</div>
<select class="input" id="dislo-filter-enterprise" onchange="disloEnterpriseChange()" style="width:auto; font-size:0.8125rem; padding:0.375rem 0.625rem; max-width:15rem;">
<option value="">Всі підприємства</option>
{% for enterprise in enterprise_list %}
<option value="{{ enterprise.grouper|escape }}">{{ enterprise.grouper }}</option>
{% endfor %}
</select>
<div class="filter-tree" style="position:relative; min-width:14rem;">
<button id="dislo-filter-region-button" type="button" class="input" onclick="disloToggleRegionTree()"
style="width:100%; text-align:left; font-size:0.78rem; padding:0.3rem 0.55rem; display:flex; justify-content:space-between; align-items:center; gap:0.4rem; border:1px solid var(--color-border); background:var(--color-muted); color:var(--color-foreground);">
<span id="dislo-filter-region-label">Всі міста/регіони</span>
<span style="font-size:0.75rem; color:var(--color-muted-foreground);">▾</span>
</button>
<div id="dislo-region-tree-panel" style="position:absolute; top:100%; left:0; right:0; z-index:20; max-height:240px; overflow:auto; background:var(--color-card); border:1px solid var(--color-border); border-radius:0.75rem; margin-top:0.25rem; box-shadow:0 12px 24px rgb(0 0 0 / 8%); display:none;">
<div style="padding:0.55rem 0.65rem; cursor:pointer; border-bottom:1px solid rgba(0,0,0,.06); font-size:0.8rem;"
onclick="disloSelectRegionNode('', 'Всі міста/регіони')">
Всі міста/регіони
</div>
<div id="dislo-region-tree-root"></div>
</div>
<input type="hidden" id="dislo-filter-region" value="">
</div>
<select class="input" id="dislo-filter-status" onchange="disloFilter()" style="width:auto; font-size:0.8125rem; padding:0.375rem 0.625rem;">
<option value="">Всі статуси</option>
<option value="active">Активні</option>
<option value="inactive">Неактивні</option>
</select>
</div>
<div style="display:flex; gap:2px; background:var(--color-muted); border-radius:var(--radius); padding:3px; flex-shrink:0;">
<button id="dislo-btn-table" onclick="disloView('table')"
style="display:flex;align-items:center;gap:0.375rem;padding:0.3125rem 0.75rem;border-radius:calc(var(--radius) - 2px);font-size:0.8125rem;font-weight:500;border:none;cursor:pointer;transition:background 150ms;background:var(--color-card);color:var(--color-foreground);">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 3h18v18H3zM3 9h18M3 15h18M9 3v18"/></svg>
Таблиця
</button>
<button id="dislo-btn-map" onclick="disloView('map')"
style="display:flex;align-items:center;gap:0.375rem;padding:0.3125rem 0.75rem;border-radius:calc(var(--radius) - 2px);font-size:0.8125rem;font-weight:500;border:none;cursor:pointer;transition:background 150ms;background:transparent;color:var(--color-muted-foreground);">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14.106 5.553a2 2 0 0 0 1.788 0l3.659-1.83A1 1 0 0 1 21 4.619v12.764a1 1 0 0 1-.553.894l-4.553 2.277a2 2 0 0 1-1.788 0l-4.212-2.106a2 2 0 0 0-1.788 0l-3.659 1.83A1 1 0 0 1 3 19.381V6.618a1 1 0 0 1 .553-.894l4.553-2.277a2 2 0 0 1 1.788 0z"/><path d="M15 5.764v15"/><path d="M9 3.236v15"/></svg>
Карта
</button>
</div>
</div>
<!-- Таблица -->
<div id="dislo-view-table">
<div style="background:var(--color-card); border-radius:0.75rem; border:1px solid var(--color-border); box-shadow:0 1px 2px rgb(0 0 0/0.05);">
<div style="overflow-x:auto;">
<table style="width:100%; border-collapse:collapse; font-size:0.875rem;">
<thead>
<tr style="border-bottom:1px solid var(--color-border);">
<th style="width:2rem;"></th>
<th class="dislo-sort-th" onclick="disloSortBy('pharmacy','number')" style="padding:0.75rem 1rem;text-align:left;color:var(--color-muted-foreground);font-weight:600;font-size:0.8125rem;cursor:pointer;user-select:none;white-space:nowrap;">Аптека <span class="dislo-sort-icon" data-key="pharmacy" style="opacity:0.35;">⇅</span></th>
<th class="dislo-sort-th" onclick="disloSortBy('org','string')" style="padding:0.75rem 1rem;text-align:left;color:var(--color-muted-foreground);font-weight:600;font-size:0.8125rem;cursor:pointer;user-select:none;white-space:nowrap;">Організація <span class="dislo-sort-icon" data-key="org" style="opacity:0.35;">⇅</span></th>
<th class="dislo-sort-th" onclick="disloSortBy('head','string')" style="padding:0.75rem 1rem;text-align:left;color:var(--color-muted-foreground);font-weight:600;font-size:0.8125rem;cursor:pointer;user-select:none;white-space:nowrap;">Завідуючий <span class="dislo-sort-icon" data-key="head" style="opacity:0.35;">⇅</span></th>
<th class="dislo-sort-th" onclick="disloSortBy('staff','number')" style="padding:0.75rem 1rem;text-align:left;color:var(--color-muted-foreground);font-weight:600;font-size:0.8125rem;cursor:pointer;user-select:none;white-space:nowrap;">Персонал <span class="dislo-sort-icon" data-key="staff" style="opacity:0.35;">⇅</span></th>
<th class="dislo-sort-th" onclick="disloSortBy('phone','string')" style="padding:0.75rem 1rem;text-align:left;color:var(--color-muted-foreground);font-weight:600;font-size:0.8125rem;cursor:pointer;user-select:none;white-space:nowrap;">Телефон <span class="dislo-sort-icon" data-key="phone" style="opacity:0.35;">⇅</span></th>
<th class="dislo-sort-th" onclick="disloSortBy('hours','string')" style="padding:0.75rem 1rem;text-align:left;color:var(--color-muted-foreground);font-weight:600;font-size:0.8125rem;cursor:pointer;user-select:none;white-space:nowrap;">Режим роботи <span class="dislo-sort-icon" data-key="hours" style="opacity:0.35;">⇅</span></th>
<th class="dislo-sort-th" onclick="disloSortBy('status','number')" style="padding:0.75rem 1rem;text-align:left;color:var(--color-muted-foreground);font-weight:600;font-size:0.8125rem;cursor:pointer;user-select:none;white-space:nowrap;">Статус <span class="dislo-sort-icon" data-key="status" style="opacity:0.35;">⇅</span></th>
</tr>
</thead>
<tbody>
{% for pharmacy in pharmacies %}
<tr class="dislo-row" style="border-bottom:1px solid var(--color-border); cursor:pointer;"
onclick="disloToggleStaff({{ pharmacy.id }})"
data-pharmacy-id="{{ pharmacy.id }}"
{% if pharmacy.location_region %}
data-region="{{ pharmacy.location_region.name }}"
{% else %}
data-region=""
{% endif %}
data-region-ids="{% if pharmacy.location_region %}{% for ancestor in pharmacy.location_region.get_ancestors %}{{ ancestor.id }},{% endfor %}{{ pharmacy.location_region.id }}{% endif %}"
data-status="{% if pharmacy.is_active %}active{% else %}inactive{% endif %}"
data-enterprise="{{ pharmacy.organization.name|escape }}"
data-name="{{ pharmacy.organization.name }} #{{ pharmacy.pharmacy_number }}"
data-sort-pharmacy="{{ pharmacy.pharmacy_number|default:0 }}"
data-sort-org="{{ pharmacy.organization.name|default:""|escape }}"
data-sort-head="{% if pharmacy.head_pharmacist %}{{ pharmacy.head_pharmacist.last_name }} {{ pharmacy.head_pharmacist.first_name }}{% endif %}"
data-sort-staff="{{ pharmacy.staff_count|default:0 }}"
data-sort-phone="{{ pharmacy.phone|default:"" }}"
data-sort-hours="{{ pharmacy.working_hours|default:"" }}"
data-sort-status="{% if pharmacy.is_active %}1{% else %}0{% endif %}">
<td style="padding:0.75rem 0; text-align:center; color:var(--color-muted-foreground);">
<span id="dislo-toggle-{{ pharmacy.id }}" style="display:inline-block; transition:transform 150ms;">▸</span>
</td>
<td style="padding:0.75rem 1rem; color:var(--color-foreground);">
<a href="/pharmacies/{{ pharmacy.id }}/" hx-get="/pharmacies/{{ pharmacy.id }}/" hx-target="#main-content" hx-push-url="/pharmacies/{{ pharmacy.id }}/" hx-indicator="#loader"
onclick="event.stopPropagation();" style="font-weight:600; color:var(--color-foreground); text-decoration:none;">№ {{ pharmacy.pharmacy_number }}</a>
<div style="font-size:0.75rem; color:var(--color-muted-foreground); margin-top:0.125rem; max-width:220px; overflow:hidden; text-overflow:ellipsis;">{{ pharmacy.address|default:"адресу не вказано" }}</div>
</td>
<td style="padding:0.75rem 1rem; color:var(--color-foreground);">{{ pharmacy.organization.name|default:"—" }}</td>
<td style="padding:0.75rem 1rem; color:var(--color-foreground);">
{% if pharmacy.head_pharmacist %}
{{ pharmacy.head_pharmacist.first_name }} {{ pharmacy.head_pharmacist.last_name|default:'' }}
{% else %}
<span style="color:var(--color-warning); font-size:0.8125rem;">не призначений</span>
{% endif %}
</td>
<td style="padding:0.75rem 1rem;">
<div style="display:flex; align-items:center; gap:0.5rem;">
<span style="color:var(--color-foreground); font-weight:500;">{{ pharmacy.staff_count }}</span>
{% if pharmacy.staff_status == 'danger' %}
<span class="badge badge-danger">Нестача</span>
{% elif pharmacy.staff_status == 'warning' %}
<span class="badge badge-warning">Увага</span>
{% else %}
<span class="badge badge-success">Норма</span>
{% endif %}
</div>
</td>
<td style="padding:0.75rem 1rem; color:var(--color-foreground);">
{% if pharmacy.phone %}{{ pharmacy.phone }}{% else %}<span style="color:var(--color-muted-foreground);">-</span>{% endif %}
</td>
<td style="padding:0.75rem 1rem; color:var(--color-foreground); font-size:0.8125rem;">{{ pharmacy.working_hours }}</td>
<td style="padding:0.75rem 1rem;">
{% if pharmacy.is_active %}
<span class="badge badge-success">Активна</span>
{% else %}
<span class="badge badge-danger">Неактивна</span>
{% endif %}
</td>
</tr>
<tr id="dislo-staff-{{ pharmacy.id }}" class="dislo-staff-row" style="display:none; border-bottom:1px solid var(--color-border); background:var(--color-muted);">
<td></td>
<td colspan="7" style="padding:0.75rem 1rem 1rem;">
{% if pharmacy.active_staff %}
<div style="display:grid; grid-template-columns:repeat(auto-fill, minmax(15rem, 1fr)); gap:0.5rem;">
{% for employee in pharmacy.active_staff %}
<div class="li-hover" style="display:flex; align-items:center; gap:0.5rem; padding:0.5rem 0.625rem; background:var(--color-card); border:1px solid var(--color-border); border-radius:0.5rem; cursor:pointer;"
hx-get="/employees/{{ employee.pk }}/" hx-target="#main-content" hx-push-url="/employees/{{ employee.pk }}/" hx-indicator="#loader"
onclick="event.stopPropagation();">
{% if employee.photo %}
<img src="{{ employee.photo.url }}" alt="" style="width:1.75rem; height:1.75rem; border-radius:50%; object-fit:cover; flex-shrink:0;">
{% else %}
<span style="width:1.75rem; height:1.75rem; border-radius:50%; background:color-mix(in srgb, var(--color-primary) 10%, transparent); color:var(--color-primary); display:flex; align-items:center; justify-content:center; font-size:0.6875rem; font-weight:500; flex-shrink:0;">{{ employee|initials }}</span>
{% endif %}
<div style="min-width:0;">
<div style="font-size:0.8125rem; font-weight:500; color:var(--color-card-foreground); white-space:nowrap; overflow:hidden; text-overflow:ellipsis;">{{ employee.last_name }} {{ employee.first_name }}</div>
<div style="font-size:0.75rem; color:var(--color-muted-foreground); white-space:nowrap; overflow:hidden; text-overflow:ellipsis;">{{ employee.post.name|default:"Посада не вказана" }}{% if employee.phone %} · {{ employee.phone }}{% endif %}</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div style="padding:0.5rem 0; color:var(--color-muted-foreground); font-size:0.8125rem;">У цій аптеці поки немає активних співробітників</div>
{% endif %}
</td>
</tr>
{% empty %}
<tr>
<td colspan="8" style="padding:2rem; text-align:center; color:var(--color-muted-foreground);">Аптеки не знайдені</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div style="padding:0.875rem 1.5rem;border-top:1px solid var(--color-border);display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:0.75rem;">
<span id="dislo-page-info" style="font-size:0.8125rem;color:var(--color-muted-foreground);">Показано 0–0 з 0</span>
<div style="display:flex;align-items:center;gap:1rem;flex-wrap:wrap;">
<div style="display:flex;align-items:center;gap:0.375rem;">
<span style="font-size:0.8125rem;color:var(--color-muted-foreground);white-space:nowrap;">На сторінці:</span>
<select id="dislo-page-size" class="input" style="font-size:0.8125rem;padding:0.3rem 0.5rem;height:auto;width:auto;" onchange="disloSetPageSize(this.value)">
<option value="10">10</option>
<option value="15">15</option>
<option value="25" selected>25</option>
<option value="100">100</option>
</select>
</div>
<div style="display:flex;align-items:center;gap:0.375rem;">
<button type="button" id="dislo-prev-btn" class="btn btn-secondary" style="padding:0.375rem 0.75rem;font-size:0.8125rem;" onclick="disloGoToPage(disloPage - 1)">← Попередня</button>
<span id="dislo-page-label" style="font-size:0.8125rem;color:var(--color-muted-foreground);white-space:nowrap;">Стор. 1 з 1</span>
<button type="button" id="dislo-next-btn" class="btn btn-secondary" style="padding:0.375rem 0.75rem;font-size:0.8125rem;" onclick="disloGoToPage(disloPage + 1)">Наступна →</button>
</div>
</div>
</div>
</div>
</div>
<!-- Карта -->
<div id="dislo-view-map" style="display:none;">
{% if pharmacies_no_location %}
<div style="font-size:0.8125rem; color:var(--color-warning); margin-bottom:0.75rem;">
⚠ {{ pharmacies_no_location }} апт. без вказаної адреси не показані на карті (немає координат)
</div>
{% endif %}
<div style="display:grid;grid-template-columns:1fr 300px;gap:1rem;">
<div style="background:var(--color-card);border-radius:0.75rem;border:1px solid var(--color-border);overflow:hidden;">
<div id="dislo-map" style="height:520px;width:100%;"></div>
</div>
<div style="background:var(--color-card);border-radius:0.75rem;border:1px solid var(--color-border);max-height:520px;overflow-y:auto;">
<div style="padding:0.875rem 1rem;border-bottom:1px solid var(--color-border);font-size:0.8125rem;font-weight:600;color:var(--color-foreground);position:sticky;top:0;background:var(--color-card);z-index:1;">
Список аптек
</div>
<div style="padding:0.75rem 0.75rem;">
{% for pharmacy in pharmacies %}
<div style="padding:0.625rem 0.75rem;border-bottom:1px solid var(--color-border);cursor:pointer;transition:background 150ms;opacity:1;"
onmouseover="this.style.background='var(--color-muted)'"
onmouseout="this.style.background='transparent';"
data-pharmacy-id="{{ pharmacy.id }}"
{% if pharmacy.location_region %}
data-region="{{ pharmacy.location_region.name }}"
{% else %}
data-region=""
{% endif %}
data-region-ids="{% if pharmacy.location_region %}{% for ancestor in pharmacy.location_region.get_ancestors %}{{ ancestor.id }},{% endfor %}{{ pharmacy.location_region.id }}{% endif %}"
data-status="{% if pharmacy.is_active %}active{% else %}inactive{% endif %}"
data-enterprise="{{ pharmacy.organization.name|escape }}"
data-name="{{ pharmacy.organization.name }} #{{ pharmacy.pharmacy_number }}">
<div style="font-size:0.8125rem;font-weight:600;color:var(--color-foreground);">{{ pharmacy.organization.name }} #{{ pharmacy.pharmacy_number }}</div>
<div style="font-size:0.75rem;color:var(--color-muted-foreground);margin-top:0.25rem;">{{ pharmacy.address }}</div>
</div>
{% endfor %}
</div>
</div>
</div>
</div>
</div>
<!-- ══ /ВКЛАДКА: АПТЕКИ ══ -->
<!-- ══ ВКЛАДКА: ОФІС / СКЛАД ══ -->
<div id="dislo-panel-office" class="dislo-panel" style="display:none;">
<div style="margin-bottom:1rem;">
<span style="font-size:0.875rem; color:var(--color-muted-foreground);">{{ office_employees_count }} співробітників офісу та складу, згруповано по відділах</span>
</div>
<div style="background:var(--color-card); border-radius:0.75rem; border:1px solid var(--color-border); box-shadow:0 1px 2px rgb(0 0 0/0.05); overflow:hidden;">
<div style="overflow-x:auto;">
<table style="width:100%; border-collapse:collapse; font-size:0.875rem;">
<thead>
<tr style="border-bottom:1px solid var(--color-border);">
<th style="padding:0.75rem 1rem;text-align:left;color:var(--color-muted-foreground);font-weight:600;font-size:0.8125rem; width:8rem;">Внутр. номер</th>
<th style="padding:0.75rem 1rem;text-align:left;color:var(--color-muted-foreground);font-weight:600;font-size:0.8125rem;">Посада</th>
<th style="padding:0.75rem 1rem;text-align:left;color:var(--color-muted-foreground);font-weight:600;font-size:0.8125rem;">П.І.Б.</th>
<th style="padding:0.75rem 1rem;text-align:left;color:var(--color-muted-foreground);font-weight:600;font-size:0.8125rem;">Корпоративний телефон</th>
</tr>
</thead>
<tbody>
{% for dept in office_departments %}
<tr>
<td colspan="4" style="padding:0.5rem 1rem; background:color-mix(in srgb, var(--color-success) 18%, var(--color-card)); font-weight:600; color:var(--color-foreground); padding-left:{% widthratio dept.indent_depth 1 1 %}.5rem;">{{ dept.name }}</td>
</tr>
{% for employee in dept.staff %}
<tr class="tr-hover" style="border-bottom:1px solid var(--color-border); cursor:pointer;"
hx-get="/employees/{{ employee.pk }}/" hx-target="#main-content" hx-push-url="/employees/{{ employee.pk }}/" hx-indicator="#loader">
<td style="padding:0.625rem 1rem; color:var(--color-foreground);">{{ employee.internal_number|default:"—" }}</td>
<td style="padding:0.625rem 1rem; color:var(--color-muted-foreground);">{{ employee.post.name|default:"—" }}</td>
<td style="padding:0.625rem 1rem; color:var(--color-card-foreground); font-weight:500;">{{ employee.last_name }} {{ employee.first_name }} {{ employee.middle_name }}</td>
<td style="padding:0.625rem 1rem; color:var(--color-foreground);">{{ employee.phone|default:"—" }}</td>
</tr>
{% endfor %}
{% endfor %}
{% if office_no_department %}
<tr>
<td colspan="4" style="padding:0.5rem 1rem; background:color-mix(in srgb, var(--color-warning) 18%, var(--color-card)); font-weight:600; color:var(--color-foreground);">Без відділу</td>
</tr>
{% for employee in office_no_department %}
<tr class="tr-hover" style="border-bottom:1px solid var(--color-border); cursor:pointer;"
hx-get="/employees/{{ employee.pk }}/" hx-target="#main-content" hx-push-url="/employees/{{ employee.pk }}/" hx-indicator="#loader">
<td style="padding:0.625rem 1rem; color:var(--color-foreground);">{{ employee.internal_number|default:"—" }}</td>
<td style="padding:0.625rem 1rem; color:var(--color-muted-foreground);">{{ employee.post.name|default:"—" }}</td>
<td style="padding:0.625rem 1rem; color:var(--color-card-foreground); font-weight:500;">{{ employee.last_name }} {{ employee.first_name }} {{ employee.middle_name }}</td>
<td style="padding:0.625rem 1rem; color:var(--color-foreground);">{{ employee.phone|default:"—" }}</td>
</tr>
{% endfor %}
{% endif %}
{% if not office_departments and not office_no_department %}
<tr>
<td colspan="4" style="padding:2rem; text-align:center; color:var(--color-muted-foreground);">Співробітників офісу/складу не знайдено</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
</div>
</div>
<!-- ══ /ВКЛАДКА: ОФІС / СКЛАД ══ -->
<script id="dislo-region-tree-data" type="application/json">{{ region_tree_json|safe }}</script>
<script src="https://cdn.jsdelivr.net/npm/leaflet@1.9.4/dist/leaflet.js"></script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/leaflet@1.9.4/dist/leaflet.css">
<script src="https://cdn.jsdelivr.net/npm/leaflet.markercluster@1.5.3/dist/leaflet.markercluster.js"></script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/leaflet.markercluster@1.5.3/dist/MarkerCluster.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/leaflet.markercluster@1.5.3/dist/MarkerCluster.Default.css">
<script>
let disloMap = null;
let disloLayer = null;
const disloData = [
{% for pharmacy in pharmacies %}
{
id: {{ pharmacy.id }},
name: "{{ pharmacy.organization.name|escapejs|default:'' }} #{{ pharmacy.pharmacy_number|escapejs|default:'0' }}",
address: "{{ pharmacy.address|escapejs|default:'Адреса невідома' }}",
head: "{{ pharmacy.head_pharmacist.first_name|escapejs|default:'' }} {{ pharmacy.head_pharmacist.last_name|escapejs|default:'' }}".trim() || "—",
phone: "{{ pharmacy.phone|escapejs|default:'-' }}",
hours: "{{ pharmacy.working_hours|escapejs|default:'-' }}",
staff: {{ pharmacy.staff_count }},
active: {{ pharmacy.is_active|lower }},
hasLocation: {{ pharmacy.has_map_location|lower }},
lat: parseFloat("{{ pharmacy.latitude|default:50.5|floatformat:'6' }}".replace(',', '.')),
lng: parseFloat("{{ pharmacy.longitude|default:26.5|floatformat:'6' }}".replace(',', '.'))
}{% if not forloop.last %},{% endif %}
{% endfor %}
];
const disloRegionTreeData = JSON.parse(document.getElementById('dislo-region-tree-data').textContent || '[]');
function disloToggleStaff(pharmacyId) {
const row = document.getElementById('dislo-staff-' + pharmacyId);
const toggle = document.getElementById('dislo-toggle-' + pharmacyId);
if (!row) return;
const opening = row.style.display === 'none';
row.style.display = opening ? 'table-row' : 'none';
if (toggle) toggle.style.transform = opening ? 'rotate(90deg)' : '';
}
function disloTabSwitch(tab) {
document.querySelectorAll('.dislo-panel').forEach(function (panel) {
panel.style.display = panel.id === 'dislo-panel-' + tab ? '' : 'none';
});
document.querySelectorAll('.dislo-tab-btn').forEach(function (btn) {
const active = btn.getAttribute('data-tab') === tab;
btn.classList.toggle('dislo-tab-active', active);
btn.style.background = active ? 'var(--color-card)' : 'transparent';
btn.style.color = active ? 'var(--color-card-foreground)' : 'var(--color-muted-foreground)';
btn.style.boxShadow = active ? '0 1px 2px rgb(0 0 0/0.08)' : '';
});
if (tab === 'pharmacy' && disloMap) {
disloMap.invalidateSize();
}
}
function disloGetAllowedRegionIdsByEnterprise() {
const enterpriseFilter = document.getElementById('dislo-filter-enterprise').value;
const rows = document.querySelectorAll('tbody tr.dislo-row');
const allowed = new Set();
rows.forEach(row => {
const enterprise = row.dataset.enterprise || '';
if (!enterpriseFilter || enterprise === enterpriseFilter) {
(row.dataset.regionIds || '').split(',').filter(Boolean).forEach(id => allowed.add(String(id)));
}
});
return allowed;
}
function disloFilterRegionTreeByAllowed(nodes, allowedIds) {
return nodes
.map(node => {
const children = disloFilterRegionTreeByAllowed(node.children || [], allowedIds);
const includeSelf = allowedIds.has(String(node.id)) || children.length > 0;
if (!includeSelf) return null;
return { id: node.id, name: node.name, children: children };
})
.filter(Boolean);
}
function disloGetFilteredRegionTreeData() {
const enterpriseFilter = document.getElementById('dislo-filter-enterprise').value;
if (!enterpriseFilter) return disloRegionTreeData;
const allowedIds = disloGetAllowedRegionIdsByEnterprise();
return disloFilterRegionTreeByAllowed(disloRegionTreeData, allowedIds);
}
function disloEnterpriseChange() {
const regionInput = document.getElementById('dislo-filter-region');
const selectedRegion = regionInput.value;
const allowedIds = disloGetAllowedRegionIdsByEnterprise();
if (selectedRegion && !allowedIds.has(selectedRegion)) {
regionInput.value = '';
document.getElementById('dislo-filter-region-label').textContent = 'Всі міста/регіони';
}
disloRenderRegionTree();
disloFilter();
}
function disloToggleRegionTree() {
const panel = document.getElementById('dislo-region-tree-panel');
panel.style.display = panel.style.display === 'block' ? 'none' : 'block';
}
function disloCloseRegionTree() {
document.getElementById('dislo-region-tree-panel').style.display = 'none';
}
function disloSelectRegionNode(regionId, label) {
document.getElementById('dislo-filter-region').value = regionId || '';
document.getElementById('dislo-filter-region-label').textContent = label || 'Всі міста/регіони';
disloCloseRegionTree();
disloFilter();
}
function disloCreateRegionNodeElement(node, level) {
const wrapper = document.createElement('div');
wrapper.style.paddingLeft = `${level * 0.75}rem`;
const titleRow = document.createElement('div');
titleRow.style.padding = '0.55rem 0.75rem';
titleRow.style.cursor = 'pointer';
titleRow.style.fontSize = '0.84rem';
titleRow.style.color = 'var(--color-foreground)';
titleRow.style.borderBottom = '1px solid rgba(0,0,0,.06)';
titleRow.style.display = 'flex';
titleRow.style.alignItems = 'center';
titleRow.style.justifyContent = 'space-between';
const title = document.createElement('span');
title.textContent = node.name;
title.style.flex = '1';
title.style.whiteSpace = 'nowrap';
title.style.overflow = 'hidden';
title.style.textOverflow = 'ellipsis';
const arrow = document.createElement('span');
arrow.textContent = node.children && node.children.length ? '▸' : '';
arrow.style.opacity = node.children && node.children.length ? '0.7' : '0';
arrow.style.marginLeft = '0.5rem';
arrow.style.fontSize = '0.75rem';
titleRow.appendChild(title);
titleRow.appendChild(arrow);
titleRow.onclick = function() { disloSelectRegionNode(String(node.id), node.name); };
wrapper.appendChild(titleRow);
if (node.children && node.children.length) {
const childrenContainer = document.createElement('div');
node.children.forEach(child => {
childrenContainer.appendChild(disloCreateRegionNodeElement(child, level + 1));
});
wrapper.appendChild(childrenContainer);
}
return wrapper;
}
function disloRenderRegionTree() {
const root = document.getElementById('dislo-region-tree-root');
root.innerHTML = '';
disloGetFilteredRegionTreeData().forEach(node => {
root.appendChild(disloCreateRegionNodeElement(node, 0));
});
}
disloRenderRegionTree();
function disloAddMapBaseLayerWithFallback(map) {
const sources = [
{
url: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png',
options: { attribution: '&copy; OpenStreetMap contributors &copy; CARTO', maxZoom: 20, subdomains: 'abcd' }
},
{
url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
options: { attribution: '© OpenStreetMap contributors', maxZoom: 19 }
}
];
let idx = 0;
let layer = null;
const mount = () => {
if (idx >= sources.length) return;
const src = sources[idx];
layer = L.tileLayer(src.url, src.options).addTo(map);
layer.on('tileerror', () => {
if (idx + 1 >= sources.length) return;
map.removeLayer(layer);
idx += 1;
mount();
});
};
mount();
}
function disloCreateMarker(pharmacy) {
const markerColor = pharmacy.active ? '#22c55e' : '#ef4444';
// Класична крапля-пін замість квадратика з числом: гострий кінчик вказує на точну
// координату (iconAnchor у вершині), кількість персоналу — у білому кружку зверху
const markerHtml = `
<svg width="30" height="40" viewBox="0 0 30 40" xmlns="http://www.w3.org/2000/svg" style="filter:drop-shadow(0 2px 3px rgba(0,0,0,0.35));">
<path d="M15 0C6.716 0 0 6.716 0 15c0 10.5 15 25 15 25s15-14.5 15-25C30 6.716 23.284 0 15 0z" fill="${markerColor}"/>
<circle cx="15" cy="15" r="10" fill="white"/>
<text x="15" y="19.5" text-anchor="middle" font-size="11" font-weight="700" font-family="system-ui, sans-serif" fill="${markerColor}">${pharmacy.staff}</text>
</svg>`;
const customIcon = L.divIcon({ html: markerHtml, className: '', iconSize: [30, 40], iconAnchor: [15, 40], popupAnchor: [0, -36] });
const marker = L.marker([pharmacy.lat, pharmacy.lng], { icon: customIcon })
.bindPopup(`
<div style="font-size: 12px;">
<strong style="font-size: 13px;">${pharmacy.name}</strong><br/>
<small>${pharmacy.address}</small><br/>
<small>🧑 ${pharmacy.head || '-'}</small><br/>
<small>☎️ ${pharmacy.phone}</small><br/>
<small>🕐 ${pharmacy.hours}</small><br/>
<small>👥 Персонал: ${pharmacy.staff}</small><br/>
<span style="display:inline-block;margin-top:4px;padding:2px 6px;border-radius:3px;background:${markerColor};color:white;font-size:11px;">
${pharmacy.active ? 'Активна' : 'Неактивна'}
</span><br/>
<a href="/pharmacies/${pharmacy.id}/" style="display:inline-block;margin-top:6px;font-size:11px;">Детальніше →</a>
</div>
`);
marker.on('click', function() {
const sidebarItems = document.querySelectorAll('#dislo-view-map [data-pharmacy-id]');
sidebarItems.forEach(item => item.style.background = 'transparent');
const activeItem = document.querySelector(`#dislo-view-map [data-pharmacy-id="${pharmacy.id}"]`);
if (activeItem) activeItem.style.background = 'var(--color-muted)';
});
return marker;
}
// Без адреси немає реальних координат (PlainLocationField тихо лишає дефолт) —
// такі аптеки на карту не виводимо, лише в таблицю/список
function disloRenderMapMarkers(visibleIds) {
disloLayer.clearLayers();
const mappable = disloData.filter(p => p.hasLocation && visibleIds.has(p.id));
mappable.forEach(p => disloCreateMarker(p).addTo(disloLayer));
return mappable;
}
function disloInitMap() {
disloMap = L.map('dislo-map').setView([50.5, 26.5], 6);
disloAddMapBaseLayerWithFallback(disloMap);
// Кластеризація — без неї аптеки з однаковими/близькими координатами (типово в
// центрі міста) накладаються одна на одну суцільною купою міток
disloLayer = L.markerClusterGroup({ maxClusterRadius: 50 }).addTo(disloMap);
// Ініціалізація карти лінива (перший клік на вкладку «Карта») — якщо до цього
// вже застосували фільтр в таблиці, він має враховуватись одразу, а не показувати все
disloRenderMapMarkers(disloGetVisiblePharmacyIds());
if (disloLayer.getLayers().length > 0) {
disloMap.fitBounds(disloLayer.getBounds(), { padding: [50, 50] });
}
disloMap.on('zoomstart', function() {
disloLayer.eachLayer(function(marker) { marker.closePopup(); });
});
}
function disloView(view) {
const tableView = document.getElementById('dislo-view-table');
const mapView = document.getElementById('dislo-view-map');
const btnTable = document.getElementById('dislo-btn-table');
const btnMap = document.getElementById('dislo-btn-map');
if (view === 'table') {
tableView.style.display = 'block';
mapView.style.display = 'none';
btnTable.style.background = 'var(--color-card)';
btnTable.style.color = 'var(--color-foreground)';
btnMap.style.background = 'transparent';
btnMap.style.color = 'var(--color-muted-foreground)';
} else {
tableView.style.display = 'none';
mapView.style.display = 'block';
btnTable.style.background = 'transparent';
btnTable.style.color = 'var(--color-muted-foreground)';
btnMap.style.background = 'var(--color-card)';
btnMap.style.color = 'var(--color-foreground)';
if (!disloMap) {
setTimeout(disloInitMap, 100);
} else {
disloMap.invalidateSize();
}
}
}
function disloGetVisiblePharmacyIds() {
const searchText = document.getElementById('dislo-search').value.toLowerCase();
const regionFilter = document.getElementById('dislo-filter-region').value;
const statusFilter = document.getElementById('dislo-filter-status').value;
const enterpriseFilter = document.getElementById('dislo-filter-enterprise').value;
const visibleIds = new Set();
document.querySelectorAll('tbody tr.dislo-row').forEach(row => {
const name = (row.dataset.name || '').toLowerCase();
const regionIds = (row.dataset.regionIds || '').split(',').filter(Boolean);
const status = row.dataset.status || '';
const enterprise = row.dataset.enterprise || '';
const matchSearch = searchText === '' || name.includes(searchText);
const matchRegion = regionFilter === '' || regionIds.includes(regionFilter);
const matchStatus = statusFilter === '' || status === statusFilter;
const matchEnterprise = enterpriseFilter === '' || enterprise === enterpriseFilter;
if (matchSearch && matchRegion && matchStatus && matchEnterprise) {
visibleIds.add(parseInt(row.dataset.pharmacyId));
}
});
return visibleIds;
}
var disloSortState = { key: null, dir: 1 };
function disloSortBy(key, type) {
const tbody = document.querySelector('#dislo-view-table tbody');
if (!tbody) return;
if (disloSortState.key === key) {
disloSortState.dir *= -1;
} else {
disloSortState.key = key;
disloSortState.dir = 1;
}
const rows = Array.from(tbody.querySelectorAll('tr.dislo-row'));
rows.sort((a, b) => {
const rawA = a.getAttribute('data-sort-' + key) || '';
const rawB = b.getAttribute('data-sort-' + key) || '';
let result;
if (type === 'number') {
result = (parseFloat(rawA) || 0) - (parseFloat(rawB) || 0);
} else {
result = rawA.localeCompare(rawB, 'uk');
}
return result * disloSortState.dir;
});
rows.forEach(row => {
tbody.appendChild(row);
const staffRow = document.getElementById('dislo-staff-' + row.dataset.pharmacyId);
if (staffRow) tbody.appendChild(staffRow);
});
document.querySelectorAll('.dislo-sort-icon').forEach(icon => {
if (icon.dataset.key === key) {
icon.textContent = disloSortState.dir === 1 ? '▲' : '▼';
icon.style.opacity = '1';
} else {
icon.textContent = '⇅';
icon.style.opacity = '0.35';
}
});
disloFilter();
}
// Пагінація таблиці (клієнтська — карта та бічна панель показують весь відфільтрований набір,
// пагінація впливає лише на таблицю аптек, з урахуванням поточного сортування)
let disloPage = 1;
let disloPageSize = 25;
function disloSetPageSize(size) {
disloPageSize = parseInt(size, 10) || 25;
disloPage = 1;
disloFilter();
}
function disloGoToPage(page) {
disloPage = Math.max(1, page);
disloFilter();
}
function disloUpdatePaginationBar(total, start, end, page, totalPages) {
const info = document.getElementById('dislo-page-info');
const label = document.getElementById('dislo-page-label');
const prevBtn = document.getElementById('dislo-prev-btn');
const nextBtn = document.getElementById('dislo-next-btn');
if (info) info.textContent = total ? `Показано ${start + 1}–${end} з ${total}` : 'Немає записів';
if (label) label.textContent = `Стор. ${page} з ${totalPages}`;
if (prevBtn) prevBtn.disabled = page <= 1;
if (nextBtn) nextBtn.disabled = page >= totalPages;
}
function disloFilter() {
const visibleIds = disloGetVisiblePharmacyIds();
const matchedRows = [];
document.querySelectorAll('tbody tr.dislo-row').forEach(row => {
const pharmacyId = row.dataset.pharmacyId;
if (visibleIds.has(parseInt(pharmacyId))) {
matchedRows.push(row);
} else {
row.style.display = 'none';
const staffRow = document.getElementById('dislo-staff-' + pharmacyId);
if (staffRow) staffRow.style.display = 'none';
const toggle = document.getElementById('dislo-toggle-' + pharmacyId);
if (toggle) toggle.style.transform = '';
}
});
const totalMatched = matchedRows.length;
const totalPages = Math.max(1, Math.ceil(totalMatched / disloPageSize));
if (disloPage > totalPages) disloPage = totalPages;
const start = (disloPage - 1) * disloPageSize;
const end = Math.min(start + disloPageSize, totalMatched);
matchedRows.forEach((row, idx) => {
const pharmacyId = row.dataset.pharmacyId;
const onPage = idx >= start && idx < end;
row.style.display = onPage ? '' : 'none';
if (!onPage) {
const staffRow = document.getElementById('dislo-staff-' + pharmacyId);
if (staffRow) staffRow.style.display = 'none';
const toggle = document.getElementById('dislo-toggle-' + pharmacyId);
if (toggle) toggle.style.transform = '';
}
});
disloUpdatePaginationBar(totalMatched, totalMatched ? start : 0, end, disloPage, totalPages);
const sidebarItems = document.querySelectorAll('#dislo-view-map div[data-pharmacy-id]');
sidebarItems.forEach(item => {
const pharmacyId = parseInt(item.dataset.pharmacyId);
item.style.display = visibleIds.has(pharmacyId) ? '' : 'none';
});
if (disloMap && disloLayer) {
disloRenderMapMarkers(visibleIds);
}
}
disloFilter();
</script>
</div>