Files
ACORE/templates/pages/dislocation.html
T
lokses 897ffc99c9 locations: право редактирования аптеки, формы и дислокация
Отдельное право на редактирование карточки аптеки, формы, пресеты колонок
дислокации и правки видимости карточки.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-09 18:03:32 +03:00

983 lines
60 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" selected>{% trans "Активные" %}</option>
<option value="inactive">{% trans "Неактивные" %}</option>
</select>
</div>
<div style="display:flex; gap:0.5rem; flex-shrink:0; align-items:center;">
<button type="button" id="dislo-btn-columns" onclick="disloToggleColumnsPanel()"
class="btn btn-secondary"
style="display:flex;align-items:center;gap:0.375rem;padding:0.3125rem 0.75rem;font-size:0.8125rem;font-weight:500;border:1px solid var(--color-border);background:var(--color-card);color:var(--color-foreground);box-shadow:0 1px 1px rgb(0 0 0/0.04);">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 6h16M4 12h10M4 18h14"/><circle cx="18" cy="12" r="2"/></svg>
{% trans "Колонки" %}
</button>
<div style="display:flex; gap:2px; background:var(--color-muted); border-radius:var(--radius); padding:3px; align-items:center;">
<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>
<div id="dislo-columns-panel" style="display:none; margin-bottom:1rem; background:var(--color-card); border:1px solid var(--color-border); border-radius:0.75rem; padding:1rem 1.25rem; box-shadow:0 1px 2px rgb(0 0 0/0.05);">
<div style="display:flex; align-items:center; justify-content:space-between; gap:0.75rem; flex-wrap:wrap; margin-bottom:0.75rem;">
<div style="font-size:0.875rem; font-weight:600; color:var(--color-foreground);">{% trans "Колонки таблицы" %}</div>
<div style="display:flex; align-items:center; gap:0.5rem;">
<label for="dislo-preset-select" style="font-size:0.75rem; color:var(--color-muted-foreground);">{% trans "Пресет" %}</label>
<select id="dislo-preset-select" class="input" style="font-size:0.8125rem; padding:0.3rem 0.5rem; height:auto;" onchange="disloApplyPreset(this)">
<option value="">{% trans "— свой набор —" %}</option>
{% for preset in dislocation_presets %}
<option value="{{ preset.code }}" data-columns="{{ preset.columns|join:',' }}">{{ preset.name }}{% if preset.is_default %} ★{% endif %}{% if preset.department %} · {{ preset.department.name }}{% endif %}</option>
{% endfor %}
</select>
</div>
</div>
<form id="dislo-columns-form" hx-post="/dislocation/columns/" hx-target="#main-content" hx-indicator="#loader" hx-disabled-elt="find button[type=submit]">
{% csrf_token %}
<div id="dislo-col-order" style="display:flex; flex-direction:column; gap:0.35rem; margin-bottom:0.75rem;">
{% for col in panel_column_defs %}
<div class="dislo-col-row" data-key="{{ col.key }}" style="display:flex; align-items:center; gap:0.5rem; font-size:0.8125rem;">
{% if col.locked %}
<input type="checkbox" checked disabled>
<input type="hidden" name="columns" value="{{ col.key }}">
{% else %}
<input type="checkbox" name="columns" value="{{ col.key }}" {% if col.key in visible_column_keys %}checked{% endif %}>
{% endif %}
<span style="flex:1; color:var(--color-foreground);">{{ col.label }}{% if col.locked %} <span style="color:var(--color-muted-foreground); font-size:0.7rem;">({% trans "обяз." %})</span>{% endif %}</span>
<button type="button" class="btn btn-ghost" style="padding:0.15rem 0.4rem; font-size:0.7rem;" onclick="disloColMove(this, -1)" title="{% trans 'Выше' %}">▲</button>
<button type="button" class="btn btn-ghost" style="padding:0.15rem 0.4rem; font-size:0.7rem;" onclick="disloColMove(this, 1)" title="{% trans 'Ниже' %}">▼</button>
</div>
{% endfor %}
</div>
<div style="display:flex; gap:0.5rem; flex-wrap:wrap;">
<button type="submit" class="btn btn-primary" style="font-size:0.8125rem;">{% trans "Сохранить для себя" %}</button>
<button type="button" class="btn btn-secondary" style="font-size:0.8125rem; border:1px solid var(--color-border);" onclick="disloToggleColumnsPanel()">{% trans "Отмена" %}</button>
</div>
</form>
{% if can_manage_dislo_presets %}
<div style="margin-top:1rem; padding-top:1rem; border-top:1px solid var(--color-border);">
<div style="font-size:0.8125rem; font-weight:600; color:var(--color-foreground); margin-bottom:0.65rem;">{% trans "Пресеты (админ)" %}</div>
<div style="display:grid; gap:0.55rem; grid-template-columns:repeat(auto-fit, minmax(11rem, 1fr)); margin-bottom:0.65rem;">
<div>
<label for="dislo-preset-name" style="display:block; font-size:0.7rem; color:var(--color-muted-foreground); margin-bottom:0.2rem;">{% trans "Название" %}</label>
<input type="text" id="dislo-preset-name" class="input" style="width:100%; font-size:0.8125rem; padding:0.35rem 0.5rem; height:auto;" placeholder="{% trans 'Например: IT' %}">
</div>
<div>
<label for="dislo-preset-code" style="display:block; font-size:0.7rem; color:var(--color-muted-foreground); margin-bottom:0.2rem;">{% trans "Код" %}</label>
<input type="text" id="dislo-preset-code" class="input" style="width:100%; font-size:0.8125rem; padding:0.35rem 0.5rem; height:auto;" placeholder="{% trans 'it (необяз.)' %}">
</div>
<div>
<label for="dislo-preset-department" style="display:block; font-size:0.7rem; color:var(--color-muted-foreground); margin-bottom:0.2rem;">{% trans "Отдел" %}</label>
<select id="dislo-preset-department" class="input" style="width:100%; font-size:0.8125rem; padding:0.35rem 0.5rem; height:auto;">
<option value="">{% trans "— без отдела —" %}</option>
{% for dept in dislocation_departments %}
<option value="{{ dept.id }}">{{ dept.name }}</option>
{% endfor %}
</select>
</div>
</div>
<label style="display:inline-flex; align-items:center; gap:0.4rem; font-size:0.8125rem; color:var(--color-foreground); margin-bottom:0.65rem;">
<input type="checkbox" id="dislo-preset-default">
{% trans "Пресет по умолчанию" %}
</label>
<div style="display:flex; gap:0.5rem; flex-wrap:wrap;">
<button type="button" class="btn btn-primary" style="font-size:0.8125rem;" onclick="disloPresetAction('create')">{% trans "Создать пресет" %}</button>
<button type="button" class="btn btn-secondary" style="font-size:0.8125rem; border:1px solid var(--color-border);" onclick="disloPresetAction('update')">{% trans "Обновить выбранный" %}</button>
<button type="button" class="btn btn-secondary" style="font-size:0.8125rem; border:1px solid color-mix(in srgb, #ef4444 35%, var(--color-border)); color:#b91c1c;" onclick="disloPresetAction('delete')">{% trans "Удалить выбранный" %}</button>
</div>
<p style="margin:0.5rem 0 0; font-size:0.7rem; color:var(--color-muted-foreground);">{% trans "Создание и обновление берут отмеченные колонки выше. Обновление/удаление — по выбранному пресету в списке." %}</p>
</div>
{% endif %}
{{ dislocation_presets_payload|json_script:"dislo-presets-data" }}
</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>
{% for col in visible_columns %}
<th class="dislo-sort-th" onclick="disloSortBy('{{ col.key }}','{{ col.sort_type }}')" 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;">{{ col.label }} <span class="dislo-sort-icon" data-key="{{ col.key }}" style="opacity:0.35;">⇅</span></th>
{% endfor %}
</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-organization="{{ 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 %}"
data-sort-region="{{ pharmacy.location_region.name|default:""|escape }}"
data-sort-category="{{ pharmacy.category|default:"" }}"
data-sort-curator="{% if pharmacy.curator %}{{ pharmacy.curator.last_name }} {{ pharmacy.curator.first_name }}{% endif %}"
data-sort-manager="{% if pharmacy.responsible_manager %}{{ pharmacy.responsible_manager.last_name }} {{ pharmacy.responsible_manager.first_name }}{% endif %}"
data-sort-license="{{ pharmacy.license_number|default:"" }}"
data-sort-vat="{% if pharmacy.vat_category %}{{ pharmacy.get_vat_category_display }}{% endif %}"
data-sort-email="{{ pharmacy.carbonio_email|default:"" }}">
<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>
{% for col in visible_columns %}
{% include "partials/dislo_cell.html" with column=col pharmacy=pharmacy no_addr_short=no_addr_short %}
{% endfor %}
</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="{{ visible_columns|length }}" 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="{{ dislo_colspan }}" 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>
{% if pharmacy.has_generator or pharmacy.has_inverter or pharmacy.has_mikrotik_router or pharmacy.dislo_provider or pharmacy.has_gpon %}
<div style="font-size:0.7rem;color:var(--color-muted-foreground);margin-top:0.35rem;line-height:1.4;">
{% if pharmacy.has_generator %}<div>{% trans "Генератор" %}: {% trans "да" %}</div>{% endif %}
{% if pharmacy.has_inverter %}<div>{% trans "Инвертор" %}: {% trans "да" %}</div>{% endif %}
{% if pharmacy.has_mikrotik_router %}<div>{% trans "MikroTik" %}: {% trans "да" %}</div>{% endif %}
{% if pharmacy.dislo_provider %}<div>{% trans "Провайдер" %}: {{ pharmacy.dislo_provider }}</div>{% endif %}
{% if pharmacy.dislo_contract %}<div>{% trans "Договор" %}: {{ pharmacy.dislo_contract }}</div>{% endif %}
{% if pharmacy.has_gpon %}<div>{% trans "GPON" %}: {% trans "да" %}</div>{% endif %}
</div>
{% endif %}
</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>
<!-- ══ /ВКЛАДКА: ОФІС / СКЛАД ══ -->
{{ region_tree|json_script:"dislo-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 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 }},
hasGenerator: {{ pharmacy.has_generator|yesno:"true,false" }},
hasInverter: {{ pharmacy.has_inverter|yesno:"true,false" }},
hasMikrotik: {{ pharmacy.has_mikrotik_router|yesno:"true,false" }},
provider: "{{ pharmacy.dislo_provider|escapejs|default:'' }}",
contract: "{{ pharmacy.dislo_contract|escapejs|default:'' }}",
hasGpon: {{ pharmacy.has_gpon|yesno:"true,false" }},
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) {
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 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 equipBits = [];
if (pharmacy.hasGenerator) equipBits.push('{% trans "Генератор" %}: {% trans "да" %}');
if (pharmacy.hasInverter) equipBits.push('{% trans "Инвертор" %}: {% trans "да" %}');
if (pharmacy.hasMikrotik) equipBits.push('{% trans "MikroTik" %}: {% trans "да" %}');
if (pharmacy.provider) equipBits.push(`{% trans "Провайдер" %}: ${pharmacy.provider}`);
if (pharmacy.contract) equipBits.push(`{% trans "Договор" %}: ${pharmacy.contract}`);
if (pharmacy.hasGpon) equipBits.push('{% trans "GPON" %}: {% trans "да" %}');
const equipLine = equipBits.length
? `<small style="display:block;margin-top:4px;">${equipBits.join('<br/>')}</small>`
: '';
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>
${equipLine}<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 disloSetColumnsButtonActive(active) {
const btn = document.getElementById('dislo-btn-columns');
if (!btn) return;
if (active) {
btn.style.background = 'color-mix(in srgb, var(--color-primary) 12%, var(--color-card))';
btn.style.borderColor = 'color-mix(in srgb, var(--color-primary) 45%, var(--color-border))';
btn.style.color = 'var(--color-foreground)';
} else {
btn.style.background = 'var(--color-card)';
btn.style.borderColor = 'var(--color-border)';
btn.style.color = 'var(--color-foreground)';
}
}
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');
const columnsPanel = document.getElementById('dislo-columns-panel');
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';
if (columnsPanel) columnsPanel.style.display = 'none';
disloSetColumnsButtonActive(false);
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 disloToggleColumnsPanel() {
const panel = document.getElementById('dislo-columns-panel');
if (!panel) return;
const opening = panel.style.display === 'none' || !panel.style.display;
panel.style.display = opening ? 'block' : 'none';
disloSetColumnsButtonActive(opening);
if (opening) disloView('table');
}
function disloColMove(btn, dir) {
const row = btn.closest('.dislo-col-row');
const container = document.getElementById('dislo-col-order');
if (!row || !container) return;
const siblings = Array.from(container.children);
const idx = siblings.indexOf(row);
const targetIdx = idx + dir;
if (targetIdx < 0 || targetIdx >= siblings.length) return;
const target = siblings[targetIdx];
if (dir < 0) {
container.insertBefore(row, target);
} else {
container.insertBefore(target, row);
}
}
function disloSelectedColumns() {
const form = document.getElementById('dislo-columns-form');
if (!form) return [];
return Array.from(form.querySelectorAll('input[name="columns"]'))
.filter((el) => el.type === 'hidden' || el.checked)
.map((el) => el.value);
}
function disloPresetAction(action) {
const selectEl = document.getElementById('dislo-preset-select');
const presetCode = selectEl ? selectEl.value : '';
if ((action === 'update' || action === 'delete') && !presetCode) {
alert('{% trans "Выберите пресет в списке." %}');
return;
}
if (action === 'delete' && !confirm('{% trans "Удалить выбранный пресет?" %}')) {
return;
}
if (action === 'create') {
const name = (document.getElementById('dislo-preset-name') || {}).value || '';
if (!name.trim()) {
alert('{% trans "Укажите название пресета." %}');
return;
}
}
const csrf = document.querySelector('#dislo-columns-panel input[name=csrfmiddlewaretoken]');
const values = {
action: action,
preset_code: presetCode,
name: (document.getElementById('dislo-preset-name') || {}).value || '',
code: (document.getElementById('dislo-preset-code') || {}).value || '',
department_id: (document.getElementById('dislo-preset-department') || {}).value || '',
is_default: document.getElementById('dislo-preset-default')?.checked ? '1' : '',
csrfmiddlewaretoken: csrf ? csrf.value : '',
columns: disloSelectedColumns(),
};
if (window.htmx) {
htmx.ajax('POST', '/dislocation/column-presets/', {
target: '#main-content',
swap: 'innerHTML',
values: values,
headers: csrf ? { 'X-CSRFToken': csrf.value } : {},
});
return;
}
const form = document.createElement('form');
form.method = 'POST';
form.action = '/dislocation/column-presets/';
Object.entries(values).forEach(([key, value]) => {
if (Array.isArray(value)) {
value.forEach((item) => {
const input = document.createElement('input');
input.type = 'hidden';
input.name = key;
input.value = item;
form.appendChild(input);
});
return;
}
const input = document.createElement('input');
input.type = 'hidden';
input.name = key;
input.value = value;
form.appendChild(input);
});
document.body.appendChild(form);
form.submit();
}
function disloApplyPreset(selectEl) {
const code = selectEl.value;
if (!code) return;
const csrf = document.querySelector('#dislo-columns-panel input[name=csrfmiddlewaretoken]');
if (window.htmx) {
htmx.ajax('POST', '/dislocation/columns/', {
target: '#main-content',
swap: 'innerHTML',
values: { preset_code: code, csrfmiddlewaretoken: csrf ? csrf.value : '' },
headers: csrf ? { 'X-CSRFToken': csrf.value } : {},
});
return;
}
const form = document.createElement('form');
form.method = 'POST';
form.action = '/dislocation/columns/';
if (csrf) {
const csrfInput = document.createElement('input');
csrfInput.type = 'hidden';
csrfInput.name = 'csrfmiddlewaretoken';
csrfInput.value = csrf.value;
form.appendChild(csrfInput);
}
const presetInput = document.createElement('input');
presetInput.type = 'hidden';
presetInput.name = 'preset_code';
presetInput.value = code;
form.appendChild(presetInput);
document.body.appendChild(form);
form.submit();
}
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>