Files
ACORE/templates/pages/dislocation.html
T
lokses 92527cc52a i18n: перевод страницы dislocation (ru/uk/en)
Вкладки аптеки/офис, метрики, фильтры, таблицы, пагинация, карта и её
popup/JS-строки помечены {% trans %} (в т.ч. внутри <script>, с обходом
кавычек) и переведены на uk/en. 51 строка.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 16:54:20 +03:00

778 lines
50 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 %}
{% load i18n %}
{% trans "адрес не указан" as no_addr_short %}{% trans "Должность не указана" as no_post %}{% trans "Адрес неизвестен" as no_addr %}
<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);">{% trans "Дислокация" %}</h1>
<p style="font-size:0.875rem; color:var(--color-muted-foreground); margin-top:0.25rem;">{{ pharmacies|length }} {% trans "аптек" %} · {{ office_employees_count }} {% trans "сотрудников офиса/склада" %} · {% trans "персонал по каждой локации" %}</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);">{% trans "Аптеки" %}</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);">{% trans "Офис / Склад" %}</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;">{% trans "Всего аптек" %}</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 }} {% trans "активных" %}</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;">{% trans "Организаций" %}</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;">{% trans "юридических лиц" %}</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;">{% trans "Без заведующего" %}</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;">{% trans "требуют назначения" %}</div>
{% else %}
<div style="font-size:0.75rem; color:var(--color-success); margin-top:0.25rem;">{% trans "все аптеки с заведующим" %}</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;">{% trans "Без персонала" %}</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;">{% trans "аптек без сотрудников" %}</div>
{% else %}
<div style="font-size:0.75rem; color:var(--color-success); margin-top:0.25rem;">{% trans "все укомплектованы" %}</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="{% trans 'Поиск аптеки...' %}" 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="">{% trans "Все предприятия" %}</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">{% trans "Все города/регионы" %}</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('', &quot;{% trans 'Все города/регионы' %}&quot;)">
{% trans "Все города/регионы" %}
</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="">{% trans "Все статусы" %}</option>
<option value="active">{% trans "Активные" %}</option>
<option value="inactive">{% trans "Неактивные" %}</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>
{% trans "Таблица" %}
</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>
{% trans "Карта" %}
</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;">{% trans "Аптека" %} <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;">{% trans "Организация" %} <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;">{% trans "Заведующий" %} <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;">{% trans "Персонал" %} <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;">{% trans "Телефон" %} <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;">{% trans "Режим работы" %} <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;">{% trans "Статус" %} <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:no_addr_short }}</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;">{% trans "не назначен" %}</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">{% trans "Нехватка" %}</span>
{% elif pharmacy.staff_status == 'warning' %}
<span class="badge badge-warning">{% trans "Внимание" %}</span>
{% else %}
<span class="badge badge-success">{% trans "Норма" %}</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">{% trans "Активна" %}</span>
{% else %}
<span class="badge badge-danger">{% trans "Неактивна" %}</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:no_post }}{% 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;">{% trans "В этой аптеке пока нет активных сотрудников" %}</div>
{% endif %}
</td>
</tr>
{% empty %}
<tr>
<td colspan="8" style="padding:2rem; text-align:center; color:var(--color-muted-foreground);">{% trans "Аптеки не найдены" %}</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);">{% trans "Показано 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;">{% trans "На странице:" %}</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)">← {% trans "Предыдущая" %}</button>
<span id="dislo-page-label" style="font-size:0.8125rem;color:var(--color-muted-foreground);white-space:nowrap;">{% trans "Стр. 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)">{% trans "Следующая" %} →</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 }} {% trans "апт. без указанного адреса не показаны на карте (нет координат)" %}
</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;">
{% trans "Список аптек" %}
</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 }} {% trans "сотрудников офиса и склада, сгруппировано по отделам" %}</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;">{% trans "Внутр. номер" %}</th>
<th style="padding:0.75rem 1rem;text-align:left;color:var(--color-muted-foreground);font-weight:600;font-size:0.8125rem;">{% trans "Должность" %}</th>
<th style="padding:0.75rem 1rem;text-align:left;color:var(--color-muted-foreground);font-weight:600;font-size:0.8125rem;">{% trans "Ф.И.О." %}</th>
<th style="padding:0.75rem 1rem;text-align:left;color:var(--color-muted-foreground);font-weight:600;font-size:0.8125rem;">{% trans "Корпоративный телефон" %}</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);">{% trans "Без отдела" %}</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);">{% trans "Сотрудников офиса/склада не найдено" %}</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:no_addr }}",
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 = '{% trans "Все города/регионы" %}';
}
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 || '{% trans "Все города/регионы" %}';
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>👥 {% trans "Персонал" %}: ${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 ? '{% trans "Активна" %}' : '{% trans "Неактивна" %}'}
</span><br/>
<a href="/pharmacies/${pharmacy.id}/" style="display:inline-block;margin-top:6px;font-size:11px;">{% trans "Подробнее" %} →</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 ? `{% trans "Показано" %} ${start + 1}–${end} {% trans "из" %} ${total}` : '{% trans "Нет записей" %}';
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>