diff --git a/apps/locations/tests.py b/apps/locations/tests.py index 58356fe..be948c9 100644 --- a/apps/locations/tests.py +++ b/apps/locations/tests.py @@ -469,6 +469,9 @@ class PharmacyCardVisibilityTests(TestCase): self.assertContains(resp, 'id="pharmacy-edit-modal"') self.assertContains(resp, 'name="source"') self.assertContains(resp, 'Сохранить') + self.assertContains(resp, 'data-tab="address"') + self.assertContains(resp, 'id="pharmacy-picker-map"') + self.assertContains(resp, 'pharmacyModalTab') def test_list_modal_save_returns_list(self): from apps.locations.models import PharmacyCardVisibility @@ -512,6 +515,47 @@ class PharmacyCardVisibilityTests(TestCase): self.assertEqual(self.pharmacy.street, 'Модальна') self.assertContains(resp, 'id="pharmacy-modal-root"') + def test_list_modal_save_map_coordinates(self): + from apps.locations.models import PharmacyCardVisibility + + self._set_level('edit', PharmacyCardVisibility.Level.OWN_OR_STAFF) + self._grant_pharmacies_section(self.own) + 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/', + { + 'source': 'list', + 'organization': self.pharmacy.organization_id, + 'pharmacy_number': self.pharmacy.pharmacy_number, + 'status': 'open', + 'category': 'A', + 'vat_category': '', + 'working_hours': self.pharmacy.working_hours or '08:00-20:00', + 'location_region': self.pharmacy.location_region_id, + 'street': self.pharmacy.street or 'Тестова', + 'house_number': self.pharmacy.house_number or '1', + 'building': '', + 'apartment': '', + 'location': '46.581955,30.7891', + '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.location, '46.581955,30.7891') + self.assertTrue(self.pharmacy.has_coordinates) + def test_pharmacies_excel_export(self): self._grant_pharmacies_section(self.staff) self.client.force_login(self.staff) diff --git a/apps/locations/views.py b/apps/locations/views.py index 1a3b0d2..0d1afe6 100644 --- a/apps/locations/views.py +++ b/apps/locations/views.py @@ -169,6 +169,7 @@ def pharmacy_update_view(request, pk): pharmacy_modal=pharmacy, pharmacy_edit_form=form, pharmacy_form_error=form_error, + pharmacy_modal_tab=_pharmacy_modal_tab(form), can_edit=True, )) @@ -180,6 +181,20 @@ def pharmacy_update_view(request, pk): return render_page(request, 'pharmacy-detail.html', context) +def _pharmacy_modal_tab(form) -> str: + names = set(getattr(form, 'errors', {}) or ()) + if names & { + 'location', 'street', 'house_number', 'building', 'apartment', 'location_region', + }: + return 'address' + if names & { + 'license_number', 'license_expiry_date', + 'head_pharmacist', 'responsible_manager', 'curator', + }: + return 'people' + return 'main' + + def _pharmacy_for_portal(request, pk): return get_object_or_404( accessible_pharmacies(request.user).select_related( diff --git a/static/assets/js/pharmacies.js b/static/assets/js/pharmacies.js index 03f7568..d7e715e 100644 --- a/static/assets/js/pharmacies.js +++ b/static/assets/js/pharmacies.js @@ -1,14 +1,174 @@ +var _pharmacyPicker = { map: null, marker: null, input: null }; + +function pharmacyPickerDestroy() { + if (_pharmacyPicker.map) { + _pharmacyPicker.map.remove(); + } + _pharmacyPicker = { map: null, marker: null, input: null }; +} + function pharmacyModalClose() { + pharmacyPickerDestroy(); var root = document.getElementById('pharmacy-modal-root'); if (root) root.innerHTML = ''; } +function pharmacyModalTab(tab) { + var modal = document.getElementById('pharmacy-edit-modal'); + if (!modal) return; + modal.querySelectorAll('.ph-edit-panel').forEach(function (panel) { + var active = panel.getAttribute('data-tab') === tab; + if (active) { + panel.style.display = panel.getAttribute('data-display') || ''; + } else { + panel.style.display = 'none'; + } + }); + modal.querySelectorAll('.ph-edit-tab-btn').forEach(function (btn) { + var active = btn.getAttribute('data-tab') === tab; + btn.classList.toggle('ph-edit-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 === 'address') pharmacyPickerEnsure(); +} + +function pharmacyModalReady(opts) { + opts = opts || {}; + var modal = document.getElementById('pharmacy-edit-modal'); + if (!modal) return; + pharmacyModalTab(opts.tab || 'main'); +} + +function pharmacyPickerParse(value) { + if (!value) return null; + var parts = String(value).split(','); + if (parts.length < 2) return null; + var lat = parseFloat(parts[0].trim()); + var lng = parseFloat(parts[1].trim()); + if (!isFinite(lat) || !isFinite(lng)) return null; + return [lat, lng]; +} + +function pharmacyPickerFormat(latlng) { + return (Math.round(latlng.lat * 1e6) / 1e6) + ',' + (Math.round(latlng.lng * 1e6) / 1e6); +} + +function pharmacyPickerWrite(latlng) { + if (_pharmacyPicker.input) _pharmacyPicker.input.value = pharmacyPickerFormat(latlng); +} + +function pharmacyPickerSetMarker(latlng, pan) { + if (!_pharmacyPicker.map || !window.L) return; + if (_pharmacyPicker.marker) { + _pharmacyPicker.marker.setLatLng(latlng); + } else { + _pharmacyPicker.marker = L.marker(latlng, { draggable: !_pharmacyPicker.readonly }).addTo(_pharmacyPicker.map); + if (!_pharmacyPicker.readonly) { + _pharmacyPicker.marker.on('dragend', function (ev) { + pharmacyPickerWrite(ev.target.getLatLng()); + }); + } + } + if (pan) _pharmacyPicker.map.panTo(latlng); + pharmacyPickerWrite(latlng); +} + +function pharmacyPickerClear() { + if (_pharmacyPicker.readonly) return; + if (_pharmacyPicker.marker && _pharmacyPicker.map) { + _pharmacyPicker.map.removeLayer(_pharmacyPicker.marker); + } + _pharmacyPicker.marker = null; + if (_pharmacyPicker.input) _pharmacyPicker.input.value = ''; +} + +function pharmacyPickerLoadLeaflet(cb) { + if (window.L) { cb(); return; } + if (document.getElementById('leaflet-js')) { + document.getElementById('leaflet-js').addEventListener('load', cb); + return; + } + var lc = document.createElement('link'); + lc.rel = 'stylesheet'; + lc.href = 'https://cdn.jsdelivr.net/npm/leaflet@1.9.4/dist/leaflet.css'; + document.head.appendChild(lc); + var ls = document.createElement('script'); + ls.id = 'leaflet-js'; + ls.src = 'https://cdn.jsdelivr.net/npm/leaflet@1.9.4/dist/leaflet.js'; + ls.onload = cb; + document.head.appendChild(ls); +} + +function pharmacyPickerEnsure() { + var el = document.getElementById('pharmacy-picker-map'); + if (!el) return; + pharmacyPickerLoadLeaflet(function () { + var mapEl = document.getElementById('pharmacy-picker-map'); + if (!mapEl || !window.L) return; + if (_pharmacyPicker.map) { + setTimeout(function () { _pharmacyPicker.map.invalidateSize(); }, 50); + return; + } + var input = document.getElementById('id_location'); + _pharmacyPicker.input = input; + _pharmacyPicker.readonly = mapEl.getAttribute('data-readonly') === '1'; + var hasCoords = mapEl.getAttribute('data-has-coords') !== '0'; + var parsed = null; + if (hasCoords) { + parsed = pharmacyPickerParse(input && input.value); + if (!parsed && mapEl.getAttribute('data-lat')) { + parsed = [parseFloat(mapEl.getAttribute('data-lat')), parseFloat(mapEl.getAttribute('data-lng'))]; + } + } + var center = parsed || [46.4825, 30.7233]; + var zoom = parsed ? 16 : 12; + var map = L.map(mapEl, { + scrollWheelZoom: !_pharmacyPicker.readonly, + dragging: true, + zoomControl: true, + }).setView(center, zoom); + 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); + } + _pharmacyPicker.map = map; + if (parsed) pharmacyPickerSetMarker({ lat: parsed[0], lng: parsed[1] }, false); + if (!_pharmacyPicker.readonly) { + map.on('click', function (ev) { + pharmacyPickerSetMarker(ev.latlng, false); + }); + if (input) { + input.addEventListener('change', function () { + var next = pharmacyPickerParse(input.value); + if (next) pharmacyPickerSetMarker({ lat: next[0], lng: next[1] }, true); + }); + } + } + setTimeout(function () { map.invalidateSize(); }, 80); + }); +} + document.addEventListener('keydown', function (e) { if (e.key !== 'Escape') return; var modal = document.getElementById('pharmacy-edit-modal'); if (modal) pharmacyModalClose(); }); +document.addEventListener('htmx:afterSwap', function (evt) { + var target = evt.detail && evt.detail.target; + if (target && target.id === 'pharmacy-modal-root') { + var modal = document.getElementById('pharmacy-edit-modal'); + if (!modal) return; + var active = modal.querySelector('.ph-edit-tab-btn.ph-edit-tab-active'); + pharmacyModalReady({ tab: active ? active.getAttribute('data-tab') : 'main' }); + } +}); + var PHARMACIES_DATA = [ {id:'А01',name:'Аптека №1 — Центральна', addr:'вул. Дерибасівська, 12', city:'Одеса', lat:46.4825,lng:30.7233,head:'1/1',pharm:'3/3',cashier:'2/3',schedule:'Пн–Нд 8–22',status:'ok',enterprise:'Одесская аптечная сеть'}, {id:'А07',name:'Аптека №7 — Черемушки', addr:'вул. Маршала Жукова, 54', city:'Одеса', lat:46.4191,lng:30.7058,head:'1/1',pharm:'1/2',cashier:'2/2',schedule:'Пн–Сб 9–20',status:'warn',enterprise:'Одесская аптечная сеть'}, diff --git a/templates/partials/pharmacy-edit-fields.html b/templates/partials/pharmacy-edit-fields.html index e9c8523..030abe2 100644 --- a/templates/partials/pharmacy-edit-fields.html +++ b/templates/partials/pharmacy-edit-fields.html @@ -1,5 +1,6 @@ {% load i18n %}
{% trans "Кликните по карте, чтобы поставить точку. Маркер можно перетащить." %}
+ +