Add RRO, portal section perms, dislocation presets, and hiring intern flow.

Ship cash-register registry, section-based portal access, configurable dislocation columns, and trial-hire intern accounts with credentials PDF.
This commit is contained in:
2026-08-09 14:58:17 +03:00
parent 237275abd0
commit ae5c1f1848
63 changed files with 5987 additions and 2217 deletions
+119 -48
View File
@@ -91,7 +91,12 @@
<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;">
<div style="display:flex; gap:2px; background:var(--color-muted); border-radius:var(--radius); padding:3px; 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>
{% 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>
@@ -105,6 +110,44 @@
</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 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>
{% endfor %}
</select>
</div>
</div>
<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>
{{ 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);">
@@ -113,13 +156,9 @@
<thead>
<tr style="border-bottom:1px solid var(--color-border);">
<th style="width:2rem;"></th>
<th class="dislo-sort-th" onclick="disloSortBy('pharmacy','number')" 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;">{% trans "Аптека" %} <span class="dislo-sort-icon" data-key="pharmacy" style="opacity:0.35;">⇅</span></th>
<th class="dislo-sort-th" onclick="disloSortBy('org','string')" 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;">{% trans "Организация" %} <span class="dislo-sort-icon" data-key="org" style="opacity:0.35;">⇅</span></th>
<th class="dislo-sort-th" onclick="disloSortBy('head','string')" 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;">{% trans "Заведующий" %} <span class="dislo-sort-icon" data-key="head" style="opacity:0.35;">⇅</span></th>
<th class="dislo-sort-th" onclick="disloSortBy('staff','number')" 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;">{% trans "Персонал" %} <span class="dislo-sort-icon" data-key="staff" style="opacity:0.35;">⇅</span></th>
<th class="dislo-sort-th" onclick="disloSortBy('phone','string')" 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;">{% trans "Телефон" %} <span class="dislo-sort-icon" data-key="phone" style="opacity:0.35;">⇅</span></th>
<th class="dislo-sort-th" onclick="disloSortBy('hours','string')" 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;">{% trans "Режим работы" %} <span class="dislo-sort-icon" data-key="hours" style="opacity:0.35;">⇅</span></th>
<th class="dislo-sort-th" onclick="disloSortBy('status','number')" 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;">{% trans "Статус" %} <span class="dislo-sort-icon" data-key="status" style="opacity:0.35;">⇅</span></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>
@@ -137,55 +176,29 @@
data-enterprise="{{ pharmacy.organization.name|escape }}"
data-name="{{ pharmacy.organization.name }} #{{ pharmacy.pharmacy_number }}"
data-sort-pharmacy="{{ pharmacy.pharmacy_number|default:0 }}"
data-sort-org="{{ pharmacy.organization.name|default:""|escape }}"
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-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>
<td style="padding:0.75rem 1rem; color:var(--color-foreground);">
<a href="/pharmacies/{{ pharmacy.id }}/" hx-get="/pharmacies/{{ pharmacy.id }}/" hx-target="#main-content" hx-push-url="/pharmacies/{{ pharmacy.id }}/" hx-indicator="#loader"
onclick="event.stopPropagation();" style="font-weight:600; color:var(--color-foreground); text-decoration:none;">№ {{ pharmacy.pharmacy_number }}</a>
<div style="font-size:0.75rem; color:var(--color-muted-foreground); margin-top:0.125rem; max-width:220px; overflow:hidden; text-overflow:ellipsis;">{{ pharmacy.address|default:no_addr_short }}</div>
</td>
<td style="padding:0.75rem 1rem; color:var(--color-foreground);">{{ pharmacy.organization.name|default:"—" }}</td>
<td style="padding:0.75rem 1rem; color:var(--color-foreground);">
{% if pharmacy.head_pharmacist %}
{{ pharmacy.head_pharmacist.first_name }} {{ pharmacy.head_pharmacist.last_name|default:'' }}
{% else %}
<span style="color:var(--color-warning); font-size:0.8125rem;">{% trans "не назначен" %}</span>
{% endif %}
</td>
<td style="padding:0.75rem 1rem;">
<div style="display:flex; align-items:center; gap:0.5rem;">
<span style="color:var(--color-foreground); font-weight:500;">{{ pharmacy.staff_count }}</span>
{% if pharmacy.staff_status == 'danger' %}
<span class="badge badge-danger">{% trans "Нехватка" %}</span>
{% elif pharmacy.staff_status == 'warning' %}
<span class="badge badge-warning">{% trans "Внимание" %}</span>
{% else %}
<span class="badge badge-success">{% trans "Норма" %}</span>
{% endif %}
</div>
</td>
<td style="padding:0.75rem 1rem; color:var(--color-foreground);">
{% if pharmacy.phone %}{{ pharmacy.phone }}{% else %}<span style="color:var(--color-muted-foreground);">-</span>{% endif %}
</td>
<td style="padding:0.75rem 1rem; color:var(--color-foreground); font-size:0.8125rem;">{{ pharmacy.working_hours }}</td>
<td style="padding:0.75rem 1rem;">
{% if pharmacy.is_active %}
<span class="badge badge-success">{% trans "Активна" %}</span>
{% else %}
<span class="badge badge-danger">{% trans "Неактивна" %}</span>
{% endif %}
</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="7" style="padding:0.75rem 1rem 1rem;">
<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 %}
@@ -211,7 +224,7 @@
</tr>
{% empty %}
<tr>
<td colspan="8" style="padding:2rem; text-align:center; color:var(--color-muted-foreground);">{% trans "Аптеки не найдены" %}</td>
<td colspan="{{ dislo_colspan }}" style="padding:2rem; text-align:center; color:var(--color-muted-foreground);">{% trans "Аптеки не найдены" %}</td>
</tr>
{% endfor %}
</tbody>
@@ -603,6 +616,7 @@ function disloView(view) {
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';
@@ -614,6 +628,7 @@ function disloView(view) {
} else {
tableView.style.display = 'none';
mapView.style.display = 'block';
if (columnsPanel) columnsPanel.style.display = 'none';
btnTable.style.background = 'transparent';
btnTable.style.color = 'var(--color-muted-foreground)';
btnMap.style.background = 'var(--color-card)';
@@ -626,6 +641,62 @@ function disloView(view) {
}
}
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';
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 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;