Files
ACORE/templates/pages/dislocation.html
T
lokses 5e4528b23f Портал: страницы на реальных данных, детальные страницы, утилиты
- 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>
2026-07-22 17:33:48 +03:00

776 lines
49 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% 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>