locations: право редактирования аптеки, формы и дислокация

Отдельное право на редактирование карточки аптеки, формы, пресеты колонок
дислокации и правки видимости карточки.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-09-09 18:03:32 +03:00
parent ac0418083b
commit 897ffc99c9
15 changed files with 1039 additions and 142 deletions
+177 -43
View File
@@ -87,26 +87,29 @@
</div>
<select class="input" id="dislo-filter-status" onchange="disloFilter()" style="width:auto; font-size:0.8125rem; padding:0.375rem 0.625rem;">
<option value="">{% trans "Все статусы" %}</option>
<option value="active">{% trans "Активные" %}</option>
<option value="active" selected>{% 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; align-items:center;">
<div style="display:flex; gap:0.5rem; flex-shrink:0; align-items:center;">
<button type="button" id="dislo-btn-columns" onclick="disloToggleColumnsPanel()"
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;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="M4 6h16M4 12h10M4 18h14"/></svg>
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>
<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 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>
@@ -114,16 +117,16 @@
<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 style="font-size:0.75rem; color:var(--color-muted-foreground);">{% trans "Пресет" %}</label>
<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 %}</option>
<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 hx-post="/dislocation/columns/" hx-target="#main-content" hx-indicator="#loader" hx-disabled-elt="find button[type=submit]">
<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 %}
@@ -141,10 +144,46 @@
{% 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="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>
@@ -284,6 +323,16 @@
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>
@@ -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: '&copy; OpenStreetMap contributors &copy; CARTO', maxZoom: 20, subdomains: 'abcd' }
},
{
url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
options: { attribution: '© OpenStreetMap contributors', maxZoom: 19 }
}
];
let idx = 0;
let layer = null;
const mount = () => {
if (idx >= sources.length) return;
const src = sources[idx];
layer = L.tileLayer(src.url, src.options).addTo(map);
layer.on('tileerror', () => {
if (idx + 1 >= sources.length) return;
map.removeLayer(layer);
idx += 1;
mount();
});
};
mount();
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) {
<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;">
@@ -567,7 +615,8 @@ function disloCreateMarker(pharmacy) {
<small>🧑 ${pharmacy.head || '-'}</small><br/>
<small>☎️ ${pharmacy.phone}</small><br/>
<small>🕐 ${pharmacy.hours}</small><br/>
<small>👥 {% trans "Персонал" %}: ${pharmacy.staff}</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/>
@@ -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;