/ can_edit за поточною політикою."""
+ policy = PharmacyCardVisibility.load()
+ flags = {
f'can_view_{section}': user_can_view_pharmacy_section(
user, pharmacy, policy.level_for(section),
)
for section in PharmacyCardVisibility.SECTIONS
}
+ flags['can_edit'] = user_can_view_pharmacy_section(
+ user, pharmacy, policy.edit_pharmacy,
+ )
+ return flags
diff --git a/apps/locations/tests.py b/apps/locations/tests.py
index 439ddd7..a92433c 100644
--- a/apps/locations/tests.py
+++ b/apps/locations/tests.py
@@ -260,6 +260,48 @@ class DislocationColumnsTests(TestCase):
self.assertContains(resp, "disloSortBy('manager'")
self.assertNotContains(resp, "disloSortBy('phone'")
+ def test_staff_can_create_preset(self):
+ from apps.locations.models import DislocationColumnPreset
+ from core.test_utils import login_2fa
+
+ self.user.is_staff = True
+ self.user.save(update_fields=['is_staff'])
+ login_2fa(self.client, self.user)
+
+ resp = self.client.post(
+ '/dislocation/column-presets/',
+ {
+ 'action': 'create',
+ 'name': 'IT набор',
+ 'code': 'it',
+ 'columns': ['pharmacy', 'mikrotik', 'provider', 'gpon'],
+ 'is_default': '',
+ },
+ HTTP_HX_REQUEST='true',
+ )
+ self.assertEqual(resp.status_code, 200)
+ preset = DislocationColumnPreset.objects.get(code='it')
+ self.assertEqual(preset.name, 'IT набор')
+ self.assertEqual(preset.columns, ['pharmacy', 'mikrotik', 'provider', 'gpon'])
+
+ def test_non_staff_cannot_create_preset(self):
+ self.client.force_login(self.user)
+ session = self.client.session
+ session['2fa_verified'] = True
+ session.save()
+
+ resp = self.client.post(
+ '/dislocation/column-presets/',
+ {
+ 'action': 'create',
+ 'name': 'Hack',
+ 'code': 'hack',
+ 'columns': ['pharmacy', 'phone'],
+ },
+ HTTP_HX_REQUEST='true',
+ )
+ self.assertEqual(resp.status_code, 403)
+
class PharmacyCardVisibilityTests(TestCase):
"""Матрица видимости вкладок карточки аптеки (синглтон в админке)."""
@@ -316,7 +358,8 @@ class PharmacyCardVisibilityTests(TestCase):
from apps.locations.models import PharmacyCardVisibility
policy = PharmacyCardVisibility.load()
- setattr(policy, f'show_{section}', level)
+ attr = f'show_{section}' if section != 'edit' else 'edit_pharmacy'
+ setattr(policy, attr, level)
policy.save()
def test_own_or_staff_default(self):
@@ -329,6 +372,88 @@ class PharmacyCardVisibilityTests(TestCase):
self.assertFalse(user_can_view_pharmacy_section(self.stranger, self.pharmacy, level))
self.assertTrue(user_can_view_pharmacy_section(self.staff, self.pharmacy, level))
+ def test_edit_pharmacy_default_staff_only(self):
+ from apps.locations.models import pharmacy_card_flags, user_can_edit_pharmacy
+
+ self.assertFalse(user_can_edit_pharmacy(self.own, self.pharmacy))
+ self.assertTrue(user_can_edit_pharmacy(self.staff, self.pharmacy))
+ flags = pharmacy_card_flags(self.own, self.pharmacy)
+ self.assertFalse(flags['can_edit'])
+ flags_staff = pharmacy_card_flags(self.staff, self.pharmacy)
+ self.assertTrue(flags_staff['can_edit'])
+
+ def test_edit_pharmacy_own_or_staff_allows_own(self):
+ from apps.locations.models import PharmacyCardVisibility, user_can_edit_pharmacy
+
+ self._set_level('edit', PharmacyCardVisibility.Level.OWN_OR_STAFF)
+ self.assertTrue(user_can_edit_pharmacy(self.own, self.pharmacy))
+ self.assertFalse(user_can_edit_pharmacy(self.stranger, self.pharmacy))
+ self.assertTrue(user_can_edit_pharmacy(self.staff, self.pharmacy))
+
+ def test_portal_edit_forbidden_without_permission(self):
+ self.client.force_login(self.own)
+ session = self.client.session
+ session['2fa_verified'] = True
+ session.save()
+ resp = self.client.post(
+ f'/pharmacies/{self.pharmacy.pk}/edit/',
+ {'working_hours': '09:00-21:00'},
+ HTTP_HX_REQUEST='true',
+ )
+ self.assertEqual(resp.status_code, 403)
+
+ def test_portal_edit_saves_when_allowed(self):
+ from apps.locations.models import PharmacyCardVisibility
+
+ self._set_level('edit', PharmacyCardVisibility.Level.OWN_OR_STAFF)
+ self.client.force_login(self.own)
+ session = self.client.session
+ session['2fa_verified'] = True
+ session.save()
+ resp = self.client.post(
+ f'/pharmacies/{self.pharmacy.pk}/edit/',
+ {
+ 'organization': self.pharmacy.organization_id,
+ 'pharmacy_number': self.pharmacy.pharmacy_number,
+ 'status': 'open',
+ 'category': 'A',
+ 'vat_category': '',
+ 'working_hours': '09:00-21:00',
+ 'location_region': self.pharmacy.location_region_id,
+ 'street': 'Нова',
+ 'house_number': '10',
+ 'building': '',
+ 'apartment': '',
+ 'location': '46.4825,30.7233',
+ 'phone': '',
+ 'license_number': '',
+ 'license_expiry_date': '',
+ 'head_pharmacist': '',
+ 'responsible_manager': '',
+ 'curator': '',
+ 'pharmacists_count': 2,
+ 'comments': 'Оновлено з порталу',
+ },
+ HTTP_HX_REQUEST='true',
+ )
+ self.assertEqual(resp.status_code, 200)
+ self.pharmacy.refresh_from_db()
+ self.assertEqual(self.pharmacy.working_hours, '09:00-21:00')
+ self.assertEqual(self.pharmacy.street, 'Нова')
+ self.assertEqual(self.pharmacy.comments, 'Оновлено з порталу')
+ self.assertContains(resp, 'Изменения сохранены')
+
+ def test_detail_shows_edit_button_for_staff(self):
+ self.client.force_login(self.staff)
+ session = self.client.session
+ session['2fa_verified'] = True
+ session.save()
+ resp = self.client.get(f'/pharmacies/{self.pharmacy.pk}/', HTTP_HX_REQUEST='true')
+ self.assertEqual(resp.status_code, 200)
+ self.assertTrue(resp.context['can_edit'])
+ self.assertContains(resp, 'edit=1')
+ self.assertNotContains(resp, 'Редактировать в админке')
+
def test_everyone_and_hidden(self):
from apps.locations.models import (
PharmacyCardVisibility, user_can_view_pharmacy_section,
@@ -401,3 +526,61 @@ class PharmacyCardVisibilityTests(TestCase):
self.assertTrue(resp.context.get('can_view_rro'))
self.assertContains(resp, 'FN-VIS-1')
self.assertContains(resp, 'ph-panel-rro')
+
+
+class PharmacyHistoryTests(TestCase):
+ """PharmacyHistory заполняется при изменении аптеки, а не при создании."""
+
+ @classmethod
+ def setUpTestData(cls):
+ org_type = DirectoryType.objects.create(code='organization', name='Организация')
+ region_type = DirectoryType.objects.create(code='region', name='Регион')
+ cls.region = Directory.objects.create(directory_type=region_type, code='od', name='Одесса')
+ cls.aks = Directory.objects.create(
+ directory_type=org_type, code='akc', name='АКС', extra_data={'mail_prefix': 'a'})
+
+ def _pharmacy(self, number=1, **kw):
+ return Pharmacy.objects.create(
+ organization=self.aks, location_region=self.region,
+ pharmacy_number=number, location_type='pharmacy', **kw)
+
+ def test_create_does_not_write_history(self):
+ from apps.locations.models import PharmacyHistory
+ p = self._pharmacy(30)
+ self.assertEqual(PharmacyHistory.objects.filter(pharmacy=p).count(), 0)
+
+ def test_field_change_writes_history(self):
+ from apps.locations.models import PharmacyHistory
+ p = self._pharmacy(31, phone='+380501111111')
+ p.phone = '+380502222222'
+ p.save()
+ rows = list(PharmacyHistory.objects.filter(pharmacy=p))
+ self.assertEqual(len(rows), 1)
+ self.assertEqual(rows[0].changed_field, 'phone')
+ self.assertEqual(rows[0].old_value, '+380501111111')
+ self.assertEqual(rows[0].new_value, '+380502222222')
+ self.assertIsNone(rows[0].changed_by)
+
+ def test_unchanged_save_writes_nothing(self):
+ from apps.locations.models import PharmacyHistory
+ p = self._pharmacy(32)
+ p.save()
+ self.assertEqual(PharmacyHistory.objects.filter(pharmacy=p).count(), 0)
+
+ def test_history_user_is_recorded(self):
+ from apps.locations.models import PharmacyHistory
+ from apps.employees.tests import make_employee
+ user = make_employee('hist-user')
+ p = self._pharmacy(33, working_hours='08:00-20:00')
+ p.working_hours = '09:00-21:00'
+ p._history_user = user
+ p.save()
+ row = PharmacyHistory.objects.get(pharmacy=p, changed_field='working_hours')
+ self.assertEqual(row.changed_by, user)
+
+ def test_untracked_update_fields_skip_history(self):
+ from apps.locations.models import PharmacyHistory
+ p = self._pharmacy(34, carbonio_sync_status='pending')
+ p.carbonio_sync_status = 'synced'
+ p.save(update_fields=['carbonio_sync_status'])
+ self.assertEqual(PharmacyHistory.objects.filter(pharmacy=p).count(), 0)
diff --git a/apps/locations/views.py b/apps/locations/views.py
index 91ea44a..4ba48f3 100644
--- a/apps/locations/views.py
+++ b/apps/locations/views.py
@@ -1,3 +1,131 @@
-from django.shortcuts import render
+"""Views портала для локаций / аптек."""
+from __future__ import annotations
-# Create your views here.
+from django.contrib.auth.decorators import login_required
+from django.http import HttpResponseForbidden
+from django.shortcuts import get_object_or_404
+from django.views.decorators.http import require_POST
+
+from core.utils.htmx import render_page
+
+from .forms import PortalPharmacyEditForm
+from .models import Pharmacy, pharmacy_card_flags, user_can_edit_pharmacy
+
+
+def _pharmacy_detail_context(request, pharmacy, *, form=None, edit_mode=False,
+ form_message=None, form_error=None):
+ """Контекст карточки аптеки (общий для GET/POST)."""
+ from apps.employees.models import Employee
+ from apps.it_manager.models import Equipment, InternetContract
+ from apps.rro.models import CashRegister
+ from apps.utilities.models import UtilityReading
+
+ flags = pharmacy_card_flags(request.user, pharmacy)
+
+ staff = []
+ if flags['can_view_staff']:
+ staff = list(
+ Employee.objects.filter(user_pharmacy=pharmacy, is_active=True)
+ .select_related('post').order_by('last_name', 'first_name')
+ )
+
+ equipment = []
+ if flags['can_view_equipment']:
+ equipment = list(
+ Equipment.objects.filter(location=pharmacy)
+ .select_related(
+ 'equipment_model__equipment_type', 'equipment_model__brand',
+ 'status', 'assigned_to',
+ )
+ .order_by('equipment_model__equipment_type__name', 'equipment_model__name')
+ )
+
+ internet_contracts = []
+ if flags['can_view_internet']:
+ internet_contracts = list(
+ InternetContract.objects.filter(location=pharmacy)
+ .select_related('provider', 'service_type', 'connection_type', 'status')
+ .order_by('-start_date')
+ )
+
+ cash_registers = []
+ if flags['can_view_rro']:
+ cash_registers = list(
+ CashRegister.objects.filter(pharmacy=pharmacy)
+ .select_related('vendor', 'status')
+ .order_by('-is_primary', 'kind', 'fiscal_number')
+ )
+
+ utility_readings = []
+ if flags['can_view_utilities']:
+ from apps.utilities.helpers import attach_reading_display
+ utility_readings = list(
+ UtilityReading.objects.filter(pharmacy=pharmacy)
+ .select_related('utility_type', 'submitted_by')
+ .order_by('-reading_date')[:50]
+ )
+ attach_reading_display(utility_readings)
+
+ can_edit = flags['can_edit']
+ if edit_mode and can_edit and form is None:
+ form = PortalPharmacyEditForm(instance=pharmacy)
+
+ return {
+ 'pharmacy': pharmacy,
+ 'staff': staff,
+ 'staff_count': len(staff),
+ 'phones': list(pharmacy.phones.filter(is_active=True)),
+ 'equipment': equipment,
+ 'internet_contracts': internet_contracts,
+ 'cash_registers': cash_registers,
+ 'utility_readings': utility_readings,
+ **flags,
+ 'can_view_ops': any(flags[f'can_view_{s}'] for s in (
+ 'staff', 'equipment', 'internet', 'rro', 'utilities', 'documents',
+ )),
+ 'pharmacy_edit_mode': bool(edit_mode and can_edit),
+ 'pharmacy_edit_form': form if (edit_mode and can_edit) else None,
+ 'pharmacy_form_message': form_message,
+ 'pharmacy_form_error': form_error,
+ }
+
+
+@login_required
+@require_POST
+def pharmacy_update_view(request, pk):
+ pharmacy = get_object_or_404(
+ Pharmacy.objects.select_related(
+ 'organization', 'location_region', 'head_pharmacist__post',
+ 'responsible_manager', 'curator',
+ ).prefetch_related('phones'),
+ pk=pk,
+ )
+ if not user_can_edit_pharmacy(request.user, pharmacy):
+ return HttpResponseForbidden()
+
+ form = PortalPharmacyEditForm(request.POST, instance=pharmacy)
+ if form.is_valid():
+ pharmacy = form.save(commit=False)
+ pharmacy._history_user = request.user
+ pharmacy.location_type = 'pharmacy'
+ pharmacy.save()
+ form.save_m2m()
+ pharmacy.refresh_from_db()
+ context = _pharmacy_detail_context(
+ request, pharmacy,
+ form_message='Изменения сохранены.',
+ )
+ return render_page(request, 'pharmacy-detail.html', context)
+
+ errors = []
+ for field, msgs in form.errors.items():
+ label = form.fields[field].label if field in form.fields else field
+ for msg in msgs:
+ errors.append(f'{label}: {msg}' if field != '__all__' else msg)
+
+ context = _pharmacy_detail_context(
+ request, pharmacy,
+ form=form, edit_mode=True,
+ form_error=' '.join(errors) or 'Не удалось сохранить.',
+ )
+ return render_page(request, 'pharmacy-detail.html', context)
diff --git a/static/assets/js/pharmacies.js b/static/assets/js/pharmacies.js
index 5974ffe..b933c94 100644
--- a/static/assets/js/pharmacies.js
+++ b/static/assets/js/pharmacies.js
@@ -132,40 +132,14 @@ function pharmaFocus(i) {
}
function pharmaAddBaseLayer(map) {
- var sources = [
- {
- url: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png',
- options: {
- attribution: '© OpenStreetMap contributors © CARTO',
- maxZoom: 20,
- subdomains: 'abcd'
- }
- },
- {
- url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
- options: {
- attribution: '© OpenStreetMap',
- maxZoom: 19
- }
- }
- ];
-
- var idx = 0;
- var layer;
-
- function mountLayer() {
- if (idx >= sources.length) return;
- var src = sources[idx];
- layer = L.tileLayer(src.url, src.options).addTo(map);
- layer.on('tileerror', function() {
- if (idx + 1 >= sources.length) return;
- map.removeLayer(layer);
- idx += 1;
- mountLayer();
- });
+ if (typeof acoreAddMapBaseLayer === 'function') {
+ acoreAddMapBaseLayer(map);
+ return;
}
-
- mountLayer();
+ L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
+ attribution: '© OpenStreetMap',
+ maxZoom: 19
+ }).addTo(map);
}
function pharmaInitMap() {
diff --git a/templates/pages/dislocation.html b/templates/pages/dislocation.html
index a6bc653..a2f61cf 100644
--- a/templates/pages/dislocation.html
+++ b/templates/pages/dislocation.html
@@ -87,26 +87,29 @@
-
+
-
-
+
+
+
+
@@ -114,16 +117,16 @@
{% trans "Колонки таблицы" %}
-
+
-
+
+ {% if can_manage_dislo_presets %}
+
+
{% trans "Пресеты (админ)" %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{% trans "Создание и обновление берут отмеченные колонки выше. Обновление/удаление — по выбранному пресету в списке." %}
+
+ {% endif %}
+
{{ dislocation_presets_payload|json_script:"dislo-presets-data" }}
@@ -284,6 +323,16 @@
data-name="{{ pharmacy.organization.name }} #{{ pharmacy.pharmacy_number }}">
{{ pharmacy.organization.name }} #{{ pharmacy.pharmacy_number }}
{{ pharmacy.address }}
+ {% if pharmacy.has_generator or pharmacy.has_inverter or pharmacy.has_mikrotik_router or pharmacy.dislo_provider or pharmacy.has_gpon %}
+
+ {% if pharmacy.has_generator %}
{% trans "Генератор" %}: {% trans "да" %}
{% endif %}
+ {% if pharmacy.has_inverter %}
{% trans "Инвертор" %}: {% trans "да" %}
{% endif %}
+ {% if pharmacy.has_mikrotik_router %}
{% trans "MikroTik" %}: {% trans "да" %}
{% endif %}
+ {% if pharmacy.dislo_provider %}
{% trans "Провайдер" %}: {{ pharmacy.dislo_provider }}
{% endif %}
+ {% if pharmacy.dislo_contract %}
{% trans "Договор" %}: {{ pharmacy.dislo_contract }}
{% endif %}
+ {% if pharmacy.has_gpon %}
{% trans "GPON" %}: {% trans "да" %}
{% endif %}
+
+ {% endif %}
{% endfor %}
@@ -377,6 +426,12 @@ const disloData = [
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 %}
@@ -522,30 +577,13 @@ function disloRenderRegionTree() {
disloRenderRegionTree();
function disloAddMapBaseLayerWithFallback(map) {
- const sources = [
- {
- url: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png',
- options: { attribution: '© OpenStreetMap contributors © 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();
+ 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) {
@@ -559,6 +597,16 @@ function disloCreateMarker(pharmacy) {
${pharmacy.staff}
`;
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
+ ? `${equipBits.join('
')}`
+ : '';
const marker = L.marker([pharmacy.lat, pharmacy.lng], { icon: customIcon })
.bindPopup(`
@@ -567,7 +615,8 @@ function disloCreateMarker(pharmacy) {
🧑 ${pharmacy.head || '-'}
☎️ ${pharmacy.phone}
🕐 ${pharmacy.hours}
-
👥 {% trans "Персонал" %}: ${pharmacy.staff}
+
👥 {% trans "Персонал" %}: ${pharmacy.staff}
+ ${equipLine}
${pharmacy.active ? '{% trans "Активна" %}' : '{% trans "Неактивна" %}'}
@@ -611,6 +660,20 @@ function disloInitMap() {
});
}
+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');
@@ -629,6 +692,7 @@ function disloView(view) {
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)';
@@ -646,6 +710,7 @@ function disloToggleColumnsPanel() {
if (!panel) return;
const opening = panel.style.display === 'none' || !panel.style.display;
panel.style.display = opening ? 'block' : 'none';
+ disloSetColumnsButtonActive(opening);
if (opening) disloView('table');
}
@@ -665,6 +730,75 @@ function disloColMove(btn, dir) {
}
}
+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;
diff --git a/templates/pages/pharmacies.html b/templates/pages/pharmacies.html
index 7620434..591e199 100644
--- a/templates/pages/pharmacies.html
+++ b/templates/pages/pharmacies.html
@@ -412,40 +412,13 @@ renderRegionTree();
pharmaFilter();
function addMapBaseLayerWithFallback(map) {
- const sources = [
- {
- url: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png',
- options: {
- attribution: '© OpenStreetMap contributors © 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();
+ 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() {
diff --git a/templates/pages/pharmacy-detail.html b/templates/pages/pharmacy-detail.html
index a32f146..14b04a6 100644
--- a/templates/pages/pharmacy-detail.html
+++ b/templates/pages/pharmacy-detail.html
@@ -12,11 +12,29 @@
До дислокації
{% if can_edit %}
-
{% trans "Редактировать в админке" %}
+
{% endif %}
+ {% if pharmacy_form_message %}
+
+ {{ pharmacy_form_message }}
+
+ {% endif %}
+ {% if pharmacy_form_error %}
+
+ {{ pharmacy_form_error }}
+
+ {% endif %}
+
@@ -100,6 +118,105 @@
{% trans "Основная информация" %}
+ {% if pharmacy_edit_mode and pharmacy_edit_form %}
+ {% with f=pharmacy_edit_form %}
+
+ {% endwith %}
+ {% else %}
{% trans "Организация" %}
{{ pharmacy.organization.name|default:"—" }}
{% trans "Регион" %}
{{ pharmacy.location_region.name|default:"—" }}
@@ -116,6 +233,7 @@
{{ pharmacy.comments }}
{% endif %}
+ {% endif %}
@@ -183,9 +301,13 @@
(function () {
var lat = {{ pharmacy.latitude|floatformat:'6' }}, lng = {{ pharmacy.longitude|floatformat:'6' }};
var map = L.map('pharmacy-map', { zoomControl: false, dragging: false, scrollWheelZoom: false }).setView([lat, lng], 15);
- L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', {
- attribution: '© OpenStreetMap contributors © CARTO', maxZoom: 20, subdomains: 'abcd',
- }).addTo(map);
+ if (typeof acoreAddMapBaseLayer === 'function') {
+ acoreAddMapBaseLayer(map);
+ } else {
+ L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
+ attribution: '© OpenStreetMap contributors', maxZoom: 19
+ }).addTo(map);
+ }
L.marker([lat, lng]).addTo(map);
})();
@@ -355,10 +477,10 @@
{% endif %}
@@ -384,11 +506,15 @@
{% for r in utility_readings %}
- | {{ r.utility_type.name }} |
+ {{ r.utility_type.name }}{% if r.reading_mode == 'day_night' %} день/ніч{% endif %} |
{{ r.reading_date|date:"d.m.Y" }} |
- {{ r.value }} |
+ {{ r.value_display }} |
- {% if r.consumption is None %}—
+ {% if r.reading_mode == 'day_night' %}
+ {% if r.consumption_total is not None %}
+ Σ {{ r.consumption_total }}
+ {% else %}—{% endif %}
+ {% elif r.consumption is None %}—
{% elif r.consumption < 0 %}⚠ {{ r.consumption }}
{% else %}{{ r.consumption }}{% endif %}
|
@@ -403,7 +529,7 @@
{% endif %}
diff --git a/templates/partials/dislo_cell.html b/templates/partials/dislo_cell.html
index 034409a..379d735 100644
--- a/templates/partials/dislo_cell.html
+++ b/templates/partials/dislo_cell.html
@@ -60,6 +60,18 @@
{% if pharmacy.vat_category %}{{ pharmacy.get_vat_category_display }}{% else %}—{% endif %} |
{% elif column.key == 'email' %}
{{ pharmacy.carbonio_email|default:"—" }} |
+{% elif column.key == 'generator' %}
+{% if pharmacy.has_generator %}{% trans "Да" %}{% else %}—{% endif %} |
+{% elif column.key == 'inverter' %}
+{% if pharmacy.has_inverter %}{% trans "Да" %}{% else %}—{% endif %} |
+{% elif column.key == 'mikrotik' %}
+{% if pharmacy.has_mikrotik_router %}{% trans "Да" %}{% else %}—{% endif %} |
+{% elif column.key == 'provider' %}
+{% if pharmacy.dislo_provider %}{{ pharmacy.dislo_provider }}{% else %}—{% endif %} |
+{% elif column.key == 'contract' %}
+{% if pharmacy.dislo_contract %}{{ pharmacy.dislo_contract }}{% else %}—{% endif %} |
+{% elif column.key == 'gpon' %}
+{% if pharmacy.has_gpon %}{% trans "Да" %}{% else %}—{% endif %} |
{% else %}
— |
{% endif %}