045171275e
Co-authored-by: Cursor <cursoragent@cursor.com>
677 lines
37 KiB
HTML
677 lines
37 KiB
HTML
{% load i18n %}
|
||
<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.count }} аптек · {{ active_count }} активних</p>
|
||
</div>
|
||
<div style="display:flex; gap:0.5rem;">
|
||
<a href="/pharmacies/export.xlsx" class="btn btn-secondary" style="text-decoration:none;">
|
||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" x2="12" y1="15" y2="3"/>
|
||
</svg>
|
||
{% trans "Экспорт в Excel" %}
|
||
</a>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Метрики -->
|
||
<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.count }}</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;">{% trans "Организаций" %}</div>
|
||
<div style="font-size:1.5rem; font-weight:500; color:var(--color-foreground);">-</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;">{% trans "Средняя ФА на аптеку" %}</div>
|
||
<div style="font-size:1.5rem; font-weight:500; color:var(--color-foreground);">-</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;">{% trans "Вакансий" %}</div>
|
||
<div style="font-size:1.5rem; font-weight:500; color:var(--color-foreground);">0</div>
|
||
<div style="font-size:0.75rem; color:var(--color-muted-foreground); margin-top:0.25rem;">відкритих</div>
|
||
</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="pharma-search" placeholder="{% trans 'Поиск аптеки...' %}" oninput="pharmaFilter()"
|
||
class="input" style="border:none; background:transparent; padding:0; box-shadow:none; font-size:0.8125rem;">
|
||
</div>
|
||
<select class="input" id="filter-enterprise" onchange="handleEnterpriseChange()" 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>
|
||
<select class="input" id="filter-pharmacy" onchange="pharmaFilter()" style="width:auto; font-size:0.8125rem; padding:0.375rem 0.625rem; max-width:16rem; display:none;">
|
||
<option value="">{% trans "Все аптеки" %}</option>
|
||
</select>
|
||
<div class="filter-tree" style="position:relative; min-width:14rem;">
|
||
<button id="filter-region-button" type="button" class="input" onclick="toggleRegionTree()"
|
||
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="filter-region-label">{% trans "Все города/регионы" %}</span>
|
||
<span style="font-size:0.75rem; color:var(--color-muted-foreground);">▾</span>
|
||
</button>
|
||
<div id="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 class="region-tree-item" style="padding:0.55rem 0.65rem; cursor:pointer; border-bottom:1px solid rgba(0,0,0,.06); font-size:0.8rem; font-size:0.8rem;"
|
||
onclick="selectRegionNode('', 'Всі міста/регіони')">
|
||
Всі міста/регіони
|
||
</div>
|
||
<div id="region-tree-root"></div>
|
||
</div>
|
||
<input type="hidden" id="filter-region" value="">
|
||
</div>
|
||
<select class="input" id="filter-status" onchange="pharmaFilter()" 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="btn-table" onclick="pharmaView('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="btn-map" onclick="pharmaView('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="pv-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="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>
|
||
<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>
|
||
<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 pharmacy in pharmacies %}
|
||
<tr style="border-bottom:1px solid var(--color-border); transition:background 150ms; cursor:pointer;"
|
||
onmouseover="this.style.background='var(--color-muted)'"
|
||
onmouseout="this.style.background='transparent';"
|
||
hx-get="/pharmacies/{{ pharmacy.id }}/modal/"
|
||
hx-target="#pharmacy-modal-root"
|
||
hx-swap="innerHTML"
|
||
hx-indicator="#loader"
|
||
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 }}">
|
||
<td style="padding:0.75rem 1rem; color:var(--color-foreground);"><strong>№ {{ pharmacy.pharmacy_number }}</strong></td>
|
||
<td style="padding:0.75rem 1rem; color:var(--color-foreground);">{{ pharmacy.organization.name }}</td>
|
||
<td style="padding:0.75rem 1rem; color:var(--color-foreground); max-width:200px; overflow:hidden; text-overflow:ellipsis;">
|
||
{% if pharmacy.address %}{{ pharmacy.address }}{% else %}-{% endif %}
|
||
</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-muted-foreground);">-</span>
|
||
{% endif %}
|
||
</td>
|
||
<td style="padding:0.75rem 1rem; color:var(--color-foreground); text-align:center;">{{ pharmacy.pharmacists_count }}</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 style="display:inline-block; padding:0.25rem 0.75rem; background:var(--color-success); color:white; border-radius:9999px; font-size:0.75rem; font-weight:600;">{% trans "Активна" %}</span>
|
||
{% else %}
|
||
<span style="display:inline-block; padding:0.25rem 0.75rem; background:var(--color-destructive); color:white; border-radius:9999px; font-size:0.75rem; font-weight:600;">{% trans "Неактивна" %}</span>
|
||
{% 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="pharma-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="pharma-page-size" class="input" style="font-size:0.8125rem;padding:0.3rem 0.5rem;height:auto;width:auto;" onchange="pharmaSetPageSize(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="pharma-prev-btn" class="btn btn-secondary" style="padding:0.375rem 0.75rem;font-size:0.8125rem;" onclick="pharmaGoToPage(pharmaPage - 1)">← Попередня</button>
|
||
<span id="pharma-page-label" style="font-size:0.8125rem;color:var(--color-muted-foreground);white-space:nowrap;">{% trans "Стр. 1 из 1" %}</span>
|
||
<button type="button" id="pharma-next-btn" class="btn btn-secondary" style="padding:0.375rem 0.75rem;font-size:0.8125rem;" onclick="pharmaGoToPage(pharmaPage + 1)">{% trans "Следующая" %} →</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Карта -->
|
||
<div id="pv-map" style="display:none;">
|
||
<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="pharma-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';"
|
||
hx-get="/pharmacies/{{ pharmacy.id }}/modal/"
|
||
hx-target="#pharmacy-modal-root"
|
||
hx-swap="innerHTML"
|
||
hx-indicator="#loader"
|
||
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 id="pharmacy-modal-root">{% if pharmacy_edit_form and pharmacy_modal %}{% include "partials/pharmacy-edit-modal.html" with pharmacy=pharmacy_modal %}{% endif %}</div>
|
||
{{ region_tree|json_script:"region-tree-data" }}
|
||
<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>
|
||
let pharmaciesMap = null;
|
||
let pharmaciesLayer = null;
|
||
const pharmaciesData = [
|
||
{% for pharmacy in pharmacies %}
|
||
{
|
||
id: {{ pharmacy.id }},
|
||
num: "{{ pharmacy.pharmacy_number|escapejs|default:'0' }}",
|
||
name: "{{ pharmacy.organization.name|escapejs|default:'' }} #{{ pharmacy.pharmacy_number|escapejs|default:'0' }}",
|
||
address: "{{ pharmacy.address|escapejs|default:'Адреса невідома' }}",
|
||
region: "{{ pharmacy.location_region.name|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:'-' }}",
|
||
active: {{ pharmacy.is_active|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 regionTreeData = JSON.parse(document.getElementById('region-tree-data').textContent || '[]');
|
||
|
||
function getAllowedRegionIdsByEnterprise() {
|
||
const enterpriseFilter = document.getElementById('filter-enterprise').value;
|
||
const rows = document.querySelectorAll('tbody tr[data-pharmacy-id]');
|
||
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 filterRegionTreeByAllowed(nodes, allowedIds) {
|
||
return nodes
|
||
.map(node => {
|
||
const children = filterRegionTreeByAllowed(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 getFilteredRegionTreeData() {
|
||
const enterpriseFilter = document.getElementById('filter-enterprise').value;
|
||
if (!enterpriseFilter) {
|
||
return regionTreeData;
|
||
}
|
||
const allowedIds = getAllowedRegionIdsByEnterprise();
|
||
return filterRegionTreeByAllowed(regionTreeData, allowedIds);
|
||
}
|
||
|
||
function handleEnterpriseChange() {
|
||
const regionInput = document.getElementById('filter-region');
|
||
const selectedRegion = regionInput.value;
|
||
const allowedIds = getAllowedRegionIdsByEnterprise();
|
||
|
||
if (selectedRegion && !allowedIds.has(selectedRegion)) {
|
||
regionInput.value = '';
|
||
document.getElementById('filter-region-label').textContent = 'Всі міста/регіони';
|
||
}
|
||
|
||
// Оновити список аптек
|
||
rebuildPharmacySelect();
|
||
|
||
renderRegionTree();
|
||
pharmaFilter();
|
||
}
|
||
|
||
function rebuildPharmacySelect() {
|
||
const enterpriseFilter = document.getElementById('filter-enterprise').value;
|
||
const pharmSel = document.getElementById('filter-pharmacy');
|
||
if (!pharmSel) return;
|
||
|
||
const rows = document.querySelectorAll('tbody tr[data-pharmacy-id]');
|
||
const options = [];
|
||
rows.forEach(function(row) {
|
||
const enterprise = row.dataset.enterprise || '';
|
||
if (!enterpriseFilter || enterprise === enterpriseFilter) {
|
||
options.push({ id: row.dataset.pharmacyId, name: row.dataset.name || ('#' + row.dataset.pharmacyId) });
|
||
}
|
||
});
|
||
|
||
if (enterpriseFilter && options.length > 0) {
|
||
pharmSel.style.display = '';
|
||
const currentVal = pharmSel.value;
|
||
pharmSel.innerHTML = '<option value="">{% trans "Все аптеки" %}</option>' +
|
||
options.map(function(o) {
|
||
return '<option value="' + o.id + '"' + (o.id === currentVal ? ' selected' : '') + '>' + o.name + '</option>';
|
||
}).join('');
|
||
// Скинули підприємство — скидаємо вибір аптеки якщо її немає в новому списку
|
||
if (currentVal && !options.find(function(o){ return o.id === currentVal; })) {
|
||
pharmSel.value = '';
|
||
}
|
||
} else {
|
||
pharmSel.style.display = 'none';
|
||
pharmSel.value = '';
|
||
}
|
||
}
|
||
|
||
function toggleRegionTree() {
|
||
const panel = document.getElementById('region-tree-panel');
|
||
panel.style.display = panel.style.display === 'block' ? 'none' : 'block';
|
||
}
|
||
|
||
function closeRegionTree() {
|
||
document.getElementById('region-tree-panel').style.display = 'none';
|
||
}
|
||
|
||
function selectRegionNode(regionId, label) {
|
||
document.getElementById('filter-region').value = regionId || '';
|
||
document.getElementById('filter-region-label').textContent = label || 'Всі міста/регіони';
|
||
closeRegionTree();
|
||
pharmaFilter();
|
||
}
|
||
|
||
function createRegionNodeElement(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() {
|
||
selectRegionNode(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(createRegionNodeElement(child, level + 1));
|
||
});
|
||
wrapper.appendChild(childrenContainer);
|
||
}
|
||
|
||
return wrapper;
|
||
}
|
||
|
||
function renderRegionTree() {
|
||
const root = document.getElementById('region-tree-root');
|
||
root.innerHTML = '';
|
||
const dataToRender = getFilteredRegionTreeData();
|
||
dataToRender.forEach(node => {
|
||
root.appendChild(createRegionNodeElement(node, 0));
|
||
});
|
||
}
|
||
|
||
renderRegionTree();
|
||
pharmaFilter();
|
||
|
||
function addMapBaseLayerWithFallback(map) {
|
||
if (typeof acoreAddMapBaseLayer === 'function') {
|
||
acoreAddMapBaseLayer(map);
|
||
return;
|
||
}
|
||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||
attribution: '© OpenStreetMap contributors', maxZoom: 19
|
||
}).addTo(map);
|
||
}
|
||
|
||
function initPharmaciesMap() {
|
||
// Центр карты — средняя точка всех аптек
|
||
let centerLat = 50.5;
|
||
let centerLng = 26.5;
|
||
|
||
if (pharmaciesData.length > 0) {
|
||
centerLat = pharmaciesData.reduce((sum, p) => sum + p.lat, 0) / pharmaciesData.length;
|
||
centerLng = pharmaciesData.reduce((sum, p) => sum + p.lng, 0) / pharmaciesData.length;
|
||
}
|
||
|
||
pharmaciesMap = L.map('pharma-map').setView([centerLat, centerLng], 6);
|
||
|
||
addMapBaseLayerWithFallback(pharmaciesMap);
|
||
|
||
pharmaciesLayer = L.featureGroup().addTo(pharmaciesMap);
|
||
|
||
// Добавить маркеры для каждой аптеки
|
||
pharmaciesData.forEach(function(pharmacy) {
|
||
const markerColor = pharmacy.active ? '#22c55e' : '#ef4444';
|
||
const markerHtml = `
|
||
<div style="
|
||
background: ${markerColor};
|
||
color: white;
|
||
padding: 4px 8px;
|
||
border-radius: 4px;
|
||
font-weight: bold;
|
||
font-size: 12px;
|
||
border: 2px solid white;
|
||
box-shadow: 0 2px 4px rgba(0,0,0,0.3);
|
||
">#${pharmacy.id}</div>
|
||
`;
|
||
|
||
const customIcon = L.divIcon({
|
||
html: markerHtml,
|
||
className: '',
|
||
iconSize: [40, 30],
|
||
iconAnchor: [20, 30],
|
||
popupAnchor: [0, -30]
|
||
});
|
||
|
||
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/>
|
||
<span style="display:inline-block;margin-top:4px;padding:2px 6px;border-radius:3px;background:${markerColor};color:white;font-size:11px;">
|
||
${pharmacy.active ? 'Активна' : 'Неактивна'}
|
||
</span>
|
||
</div>
|
||
`)
|
||
.addTo(pharmaciesLayer);
|
||
|
||
// Клик на маркер — выделить в списке справа
|
||
marker.on('click', function() {
|
||
const sidebarItems = document.querySelectorAll('[data-pharmacy-id]');
|
||
sidebarItems.forEach(item => item.style.background = 'transparent');
|
||
const activeItem = document.querySelector(`[data-pharmacy-id="${pharmacy.id}"]`);
|
||
if (activeItem) activeItem.style.background = 'var(--color-muted)';
|
||
});
|
||
});
|
||
|
||
// Зум на все маркеры
|
||
if (pharmaciesLayer.getLayers().length > 0) {
|
||
pharmaciesMap.fitBounds(pharmaciesLayer.getBounds(), { padding: [50, 50] });
|
||
}
|
||
|
||
// Закрыть попапы при зуме
|
||
pharmaciesMap.on('zoomstart', function() {
|
||
pharmaciesLayer.eachLayer(function(marker) {
|
||
marker.closePopup();
|
||
});
|
||
});
|
||
}
|
||
|
||
function pharmaView(view) {
|
||
const tableView = document.getElementById('pv-table');
|
||
const mapView = document.getElementById('pv-map');
|
||
const btnTable = document.getElementById('btn-table');
|
||
const btnMap = document.getElementById('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 (!pharmaciesMap) {
|
||
setTimeout(initPharmaciesMap, 100);
|
||
} else {
|
||
pharmaciesMap.invalidateSize();
|
||
}
|
||
}
|
||
}
|
||
|
||
// Пагінація таблиці (клієнтська — усі рядки вже в DOM, карта показує весь відфільтрований набір)
|
||
let pharmaPage = 1;
|
||
let pharmaPageSize = 25;
|
||
|
||
function pharmaSetPageSize(size) {
|
||
pharmaPageSize = parseInt(size, 10) || 25;
|
||
pharmaPage = 1;
|
||
pharmaFilter();
|
||
}
|
||
|
||
function pharmaGoToPage(page) {
|
||
pharmaPage = Math.max(1, page);
|
||
pharmaFilter();
|
||
}
|
||
|
||
function pharmaUpdatePaginationBar(total, start, end, page, totalPages) {
|
||
const info = document.getElementById('pharma-page-info');
|
||
const label = document.getElementById('pharma-page-label');
|
||
const prevBtn = document.getElementById('pharma-prev-btn');
|
||
const nextBtn = document.getElementById('pharma-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 pharmaFilter() {
|
||
const searchText = document.getElementById('pharma-search').value.toLowerCase();
|
||
const regionFilter = document.getElementById('filter-region').value;
|
||
const statusFilter = document.getElementById('filter-status').value;
|
||
const enterpriseFilter = document.getElementById('filter-enterprise').value;
|
||
const pharmacyFilter = document.getElementById('filter-pharmacy') ? document.getElementById('filter-pharmacy').value : '';
|
||
|
||
// Фільтрація таблиці
|
||
const tableRows = document.querySelectorAll('tbody tr[data-pharmacy-id]');
|
||
const visibleIds = new Set();
|
||
const matchedRows = [];
|
||
|
||
tableRows.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 pharmacyId = row.dataset.pharmacyId || '';
|
||
|
||
const matchSearch = searchText === '' || name.includes(searchText);
|
||
const matchRegion = regionFilter === '' || regionIds.includes(regionFilter);
|
||
const matchStatus = statusFilter === '' || status === statusFilter;
|
||
const matchEnterprise = enterpriseFilter === '' || enterprise === enterpriseFilter;
|
||
const matchPharmacy = pharmacyFilter === '' || pharmacyId === pharmacyFilter;
|
||
|
||
const matches = matchSearch && matchRegion && matchStatus && matchEnterprise && matchPharmacy;
|
||
|
||
if (matches) {
|
||
visibleIds.add(parseInt(row.dataset.pharmacyId));
|
||
matchedRows.push(row);
|
||
} else {
|
||
row.style.display = 'none';
|
||
}
|
||
});
|
||
|
||
// Пагінація в межах відфільтрованих рядків таблиці (карта/сайдбар показують весь набір — див. нижче)
|
||
const totalMatched = matchedRows.length;
|
||
const totalPages = Math.max(1, Math.ceil(totalMatched / pharmaPageSize));
|
||
if (pharmaPage > totalPages) pharmaPage = totalPages;
|
||
const start = (pharmaPage - 1) * pharmaPageSize;
|
||
const end = Math.min(start + pharmaPageSize, totalMatched);
|
||
|
||
matchedRows.forEach((row, idx) => {
|
||
row.style.display = (idx >= start && idx < end) ? '' : 'none';
|
||
});
|
||
|
||
pharmaUpdatePaginationBar(totalMatched, totalMatched ? start : 0, end, pharmaPage, totalPages);
|
||
|
||
// Фильтрация элементов сайдбара на карте
|
||
const sidebarItems = document.querySelectorAll('div[data-pharmacy-id]');
|
||
sidebarItems.forEach(item => {
|
||
const pharmacyId = parseInt(item.dataset.pharmacyId);
|
||
const visible = visibleIds.has(pharmacyId);
|
||
item.style.opacity = visible ? '1' : '0.3';
|
||
item.style.pointerEvents = visible ? 'auto' : 'none';
|
||
});
|
||
|
||
// Фильтрация маркеров на карте
|
||
if (pharmaciesMap && pharmaciesLayer) {
|
||
const visibleData = pharmaciesData.filter(p => visibleIds.has(p.id));
|
||
|
||
pharmaciesLayer.clearLayers();
|
||
|
||
visibleData.forEach(function(pharmacy) {
|
||
const markerColor = pharmacy.active ? '#22c55e' : '#ef4444';
|
||
const markerHtml = `
|
||
<div style="
|
||
background: ${markerColor};
|
||
color: white;
|
||
padding: 4px 8px;
|
||
border-radius: 4px;
|
||
font-weight: bold;
|
||
font-size: 12px;
|
||
border: 2px solid white;
|
||
box-shadow: 0 2px 4px rgba(0,0,0,0.3);
|
||
">#${pharmacy.num}</div>
|
||
`;
|
||
|
||
const customIcon = L.divIcon({
|
||
html: markerHtml,
|
||
className: '',
|
||
iconSize: [40, 30],
|
||
iconAnchor: [20, 30],
|
||
popupAnchor: [0, -30]
|
||
});
|
||
|
||
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/>
|
||
<span style="display:inline-block;margin-top:4px;padding:2px 6px;border-radius:3px;background:${markerColor};color:white;font-size:11px;">
|
||
${pharmacy.active ? 'Активна' : 'Неактивна'}
|
||
</span>
|
||
</div>
|
||
`)
|
||
.addTo(pharmaciesLayer);
|
||
});
|
||
}
|
||
|
||
console.log(`Visible pharmacies: ${visibleIds.size}`);
|
||
}
|
||
|
||
|
||
// Инициализировать карту в конце загрузки
|
||
document.addEventListener('htmx:afterSwap', function(evt) {
|
||
if (evt.detail.target.id === 'main-content') {
|
||
// Карта инициализируется при клике на кнопку "Карта"
|
||
}
|
||
});
|
||
</script>
|
||
|
||
</div>
|