projects: деревовидный выбор отделов вместо плоского списка.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-23 17:18:37 +03:00
parent 7da277b9ab
commit 18cabe0e63
5 changed files with 396 additions and 59 deletions
+28 -8
View File
@@ -45,15 +45,35 @@ def _departments():
return list(
Directory.objects.filter(
directory_type__code='department', is_active=True,
).order_by('name')
).order_by('_path', 'name')
)
def _departments_json():
return [
{'id': d.id, 'name': d.name, 'code': d.code or ''}
for d in _departments()
]
def _departments_tree():
"""Дерево отделов {id, name, code, children} для DeptTreePicker."""
depts = list(
Directory.objects.filter(
directory_type__code='department', is_active=True,
).order_by('_path', 'name').only('pk', 'name', 'code', 'parent_id')
)
nodes = {
d.pk: {
'id': d.pk,
'name': d.name,
'code': d.code or '',
'children': [],
}
for d in depts
}
roots = []
for d in depts:
node = nodes[d.pk]
parent = nodes.get(d.parent_id)
if parent is not None:
parent['children'].append(node)
else:
roots.append(node)
return roots
def _project_qs():
@@ -102,7 +122,7 @@ def projects_list_view(request):
'page_tab': (request.GET.get('tab') or 'projects').strip(),
'templates': templates,
'departments': _departments(),
'departments_json': _departments_json(),
'departments_tree': _departments_tree(),
'ug_message': request.session.pop('projects_message', ''),
'ug_error': request.session.pop('projects_error', ''),
})
@@ -122,7 +142,7 @@ def project_detail_view(request, pk: int):
'pct': project.progress_percent,
'can_manage': _can_manage_project(request.user, project),
'departments': _departments(),
'departments_json': _departments_json(),
'departments_tree': _departments_tree(),
'ug_message': request.session.pop('projects_message', ''),
'ug_error': request.session.pop('projects_error', ''),
})
+254
View File
@@ -0,0 +1,254 @@
/**
* Деревовидный выпадающий выбор отдела (Directory type=department).
*
* Данные: {{ tree|json_script:"…-tree" }}
* [{id, name, children: [...]}, ...]
*
* Использование:
* var el = DeptTreePicker.create({
* name: 'department_id',
* value: '',
* emptyLabel: 'Отдел…',
* treeDataId: 'proj-departments-tree',
* });
* container.appendChild(el);
*/
(function (global) {
'use strict';
var treeCache = {};
function attachParentRefs(nodes, parent) {
(nodes || []).forEach(function (node) {
node.parentRef = parent || null;
if (node.children && node.children.length) {
attachParentRefs(node.children, node);
}
});
}
function readTree(treeDataId) {
if (treeCache[treeDataId]) return treeCache[treeDataId];
var el = document.getElementById(treeDataId);
var tree = [];
if (el) {
try { tree = JSON.parse(el.textContent) || []; } catch (e) { tree = []; }
}
attachParentRefs(tree, null);
treeCache[treeDataId] = tree;
return tree;
}
function buildPathLabel(node) {
var parts = [];
var cur = node;
while (cur) {
parts.unshift(cur.name);
cur = cur.parentRef;
}
return parts.join(' → ');
}
function findNode(nodes, id) {
if (!id) return null;
var sid = String(id);
for (var i = 0; i < (nodes || []).length; i++) {
var n = nodes[i];
if (String(n.id) === sid) return n;
var found = findNode(n.children, id);
if (found) return found;
}
return null;
}
function nodeMatches(node, q) {
if (!q) return true;
if ((node.name || '').toLowerCase().indexOf(q) !== -1) return true;
return (node.children || []).some(function (c) { return nodeMatches(c, q); });
}
function create(options) {
options = options || {};
var name = options.name || 'department_id';
var emptyLabel = options.emptyLabel || '—';
var treeDataId = options.treeDataId || 'dept-tree-data';
var initial = options.value != null ? String(options.value) : '';
var wrap = document.createElement('div');
wrap.className = 'dept-tree-picker' + (options.className ? ' ' + options.className : '');
wrap.style.position = 'relative';
wrap.style.width = '100%';
var input = document.createElement('input');
input.type = 'hidden';
input.name = name;
input.value = initial;
wrap.appendChild(input);
var btn = document.createElement('button');
btn.type = 'button';
btn.className = 'input';
btn.style.cssText = 'display:flex;align-items:center;justify-content:space-between;gap:0.5rem;width:100%;height:auto;padding:0.3rem 0.65rem;cursor:pointer;text-align:left;font-size:0.8125rem;';
var label = document.createElement('span');
label.style.cssText = 'overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;';
label.setAttribute('data-empty-label', emptyLabel);
label.textContent = emptyLabel;
var chevron = document.createElement('span');
chevron.innerHTML = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="flex-shrink:0;opacity:0.6"><path d="m6 9 6 6 6-6"/></svg>';
btn.appendChild(label);
btn.appendChild(chevron);
wrap.appendChild(btn);
var panel = document.createElement('div');
panel.style.cssText = 'display:none;position:absolute;top:calc(100% + 0.25rem);left:0;right:0;z-index:80;background:var(--color-card);border:1px solid var(--color-border);border-radius:0.625rem;box-shadow:0 4px 16px rgb(0 0 0/0.12);max-height:16rem;overflow:hidden;';
wrap.appendChild(panel);
function setValue(node) {
if (node) {
input.value = String(node.id);
label.textContent = buildPathLabel(node);
label.title = label.textContent;
} else {
input.value = '';
label.textContent = emptyLabel;
label.title = '';
}
close();
}
function close() {
panel.style.display = 'none';
}
function renderNode(node, container, depth, query) {
if (query && !nodeMatches(node, query)) return;
var row = document.createElement('div');
row.style.cssText = 'display:flex;align-items:center;gap:0.25rem;padding:0.35rem 0.5rem;padding-left:' +
(0.5 + depth) + 'rem;border-radius:calc(var(--radius) - 2px);cursor:pointer;font-size:0.8125rem;';
row.onmouseover = function () { row.style.background = 'var(--color-muted)'; };
row.onmouseout = function () { row.style.background = ''; };
var hasChildren = node.children && node.children.length > 0;
var toggle = document.createElement('span');
toggle.textContent = hasChildren ? '▸' : '';
toggle.style.cssText = 'width:0.875rem;flex-shrink:0;color:var(--color-muted-foreground);user-select:none;';
row.appendChild(toggle);
var nameEl = document.createElement('span');
nameEl.textContent = node.name;
nameEl.style.cssText = 'flex:1;color:var(--color-foreground);';
row.appendChild(nameEl);
container.appendChild(row);
var childrenWrap = null;
if (hasChildren) {
childrenWrap = document.createElement('div');
// при поиске раскрываем совпадения
childrenWrap.style.display = query ? 'block' : 'none';
if (query) toggle.textContent = '▾';
container.appendChild(childrenWrap);
node.children.forEach(function (child) {
renderNode(child, childrenWrap, depth + 1, query);
});
toggle.addEventListener('click', function (e) {
e.stopPropagation();
var open = childrenWrap.style.display !== 'none';
childrenWrap.style.display = open ? 'none' : 'block';
toggle.textContent = open ? '▸' : '▾';
});
}
nameEl.addEventListener('click', function (e) {
e.stopPropagation();
setValue(node);
});
}
function rebuild(query) {
panel.innerHTML = '';
var searchWrap = document.createElement('div');
searchWrap.style.cssText = 'padding:0.4rem 0.5rem;border-bottom:1px solid var(--color-border);position:sticky;top:0;background:var(--color-card);';
var search = document.createElement('input');
search.type = 'search';
search.className = 'input';
search.placeholder = 'Поиск…';
search.style.cssText = 'width:100%;font-size:0.75rem;padding:0.25rem 0.5rem;height:auto;box-sizing:border-box;';
search.value = query || '';
searchWrap.appendChild(search);
panel.appendChild(searchWrap);
var list = document.createElement('div');
list.style.cssText = 'max-height:12.5rem;overflow-y:auto;padding:0.35rem;';
panel.appendChild(list);
var clearRow = document.createElement('div');
clearRow.textContent = emptyLabel;
clearRow.style.cssText = 'padding:0.35rem 0.5rem;border-radius:calc(var(--radius) - 2px);cursor:pointer;font-size:0.8125rem;font-weight:500;color:var(--color-muted-foreground);margin-bottom:0.15rem;';
clearRow.onmouseover = function () { clearRow.style.background = 'var(--color-muted)'; };
clearRow.onmouseout = function () { clearRow.style.background = ''; };
clearRow.addEventListener('click', function (e) {
e.stopPropagation();
setValue(null);
});
list.appendChild(clearRow);
var q = (query || '').trim().toLowerCase();
readTree(treeDataId).forEach(function (node) {
renderNode(node, list, 0, q);
});
search.addEventListener('input', function () {
var pos = search.selectionStart;
rebuild(search.value);
var again = panel.querySelector('input[type="search"]');
if (again) {
again.focus();
try { again.setSelectionRange(pos, pos); } catch (e) {}
}
});
search.addEventListener('click', function (e) { e.stopPropagation(); });
}
function open(e) {
if (e) e.stopPropagation();
rebuild('');
panel.style.display = 'block';
var search = panel.querySelector('input[type="search"]');
if (search) search.focus();
document.addEventListener('click', function onDoc(ev) {
if (!wrap.contains(ev.target)) {
close();
document.removeEventListener('click', onDoc);
}
});
}
btn.addEventListener('click', function (e) {
if (panel.style.display === 'none') open(e);
else close();
});
if (initial) {
var node = findNode(readTree(treeDataId), initial);
if (node) {
label.textContent = buildPathLabel(node);
label.title = label.textContent;
}
}
wrap._deptTreeSetValue = setValue;
wrap._deptTreeInput = input;
return wrap;
}
/** Invalidate cache after HTMX swaps new json_script. */
function clearCache() {
treeCache = {};
}
global.DeptTreePicker = {
create: create,
clearCache: clearCache,
readTree: readTree,
};
})(window);
+1
View File
@@ -40,6 +40,7 @@
<script src="{% static 'assets/js/timetrack.js' %}"></script>
<script src="{% static 'assets/js/utilities.js' %}"></script>
<script src="{% static 'assets/js/people-picker.js' %}"></script>
<script src="{% static 'assets/js/dept-tree-picker.js' %}"></script>
<script src="{% static 'assets/js/searchable-select.js' %}"></script>
<script src="{% static 'assets/js/hr-portal.js' %}"></script>
<script src="{% static 'assets/js/tasks.js' %}"></script>
+15 -6
View File
@@ -102,12 +102,7 @@
<form method="post" action="{% url 'project_add_step' project.pk %}">
{% csrf_token %}
<input type="text" name="title" required class="input" style="width:100%; margin-bottom:0.5rem;" placeholder="{% trans 'Название шага' %}">
<select name="department_id" class="input" style="width:100%; margin-bottom:0.5rem;">
<option value="">{% trans "Отдел шага по умолчанию (необяз.)…" %}</option>
{% for d in departments %}
<option value="{{ d.id }}">{{ d.name }}</option>
{% endfor %}
</select>
<div id="project-add-step-dept" style="margin-bottom:0.5rem;"></div>
<textarea name="items" class="input" rows="4" style="width:100%; margin-bottom:0.75rem;"
placeholder="{% trans 'IT | Настроить сеть&#10;HR | Нанять персонал&#10;или просто название (возьмёт отдел шага)' %}"></textarea>
<p style="font-size:0.75rem; color:var(--color-muted-foreground); margin:0 0 0.75rem;">
@@ -118,3 +113,17 @@
</div>
{% endif %}
</div>
{{ departments_tree|json_script:"proj-departments-tree" }}
<script>
(function () {
var mount = document.getElementById('project-add-step-dept');
if (!mount || !window.DeptTreePicker) return;
DeptTreePicker.clearCache();
mount.appendChild(DeptTreePicker.create({
name: 'department_id',
emptyLabel: '{% trans "Отдел шага по умолчанию (необяз.)…" %}',
treeDataId: 'proj-departments-tree',
}));
})();
</script>
+98 -45
View File
@@ -141,7 +141,7 @@
{% endif %}
</div>
{{ departments_json|json_script:"proj-departments-data" }}
{{ departments_tree|json_script:"proj-departments-tree" }}
<style>
dialog.proj-dialog {
@@ -150,6 +150,8 @@
padding: 0;
width: min(40rem, calc(100vw - 1.5rem));
max-height: min(90vh, 52rem);
overflow-y: auto;
overflow-x: visible;
background: var(--color-card);
color: var(--color-foreground);
margin: auto;
@@ -158,8 +160,7 @@
dialog.proj-dialog::backdrop { background: rgb(0 0 0 / 0.45); }
.proj-dialog-body {
padding: 1.25rem 1.25rem 0.5rem;
overflow-y: auto;
max-height: calc(min(90vh, 52rem) - 4.5rem);
overflow: visible;
}
.proj-dialog-foot {
display: flex;
@@ -196,6 +197,7 @@
padding: 0.85rem;
margin-bottom: 0.65rem;
background: var(--color-muted);
overflow: visible;
}
.proj-step-head {
display: flex;
@@ -206,11 +208,12 @@
.proj-step-head .input { flex: 1; }
.proj-item-row {
display: grid;
grid-template-columns: minmax(7rem, 10rem) 1fr auto;
grid-template-columns: minmax(10rem, 15rem) 1fr auto;
gap: 0.4rem;
margin-bottom: 0.4rem;
align-items: center;
}
.proj-item-row .dept-tree-picker { min-width: 0; }
@media (max-width: 520px) {
.proj-item-row { grid-template-columns: 1fr; }
}
@@ -277,18 +280,22 @@
<script>
(function () {
function depts() {
var el = document.getElementById('proj-departments-data');
try { return el ? JSON.parse(el.textContent) : []; } catch (e) { return []; }
}
var TREE_ID = 'proj-departments-tree';
var EMPTY = '{% trans "Отдел…" %}';
function deptOptionsHtml(selected) {
var opts = '<option value="">' + '{% trans "Отдел…" %}' + '</option>';
depts().forEach(function (d) {
var sel = String(selected || '') === String(d.id) ? ' selected' : '';
opts += '<option value="' + d.id + '"' + sel + '>' + (d.name || '') + '</option>';
});
return opts;
function makeDeptPicker(name) {
if (window.DeptTreePicker) {
return DeptTreePicker.create({
name: name,
emptyLabel: EMPTY,
treeDataId: TREE_ID,
});
}
var sel = document.createElement('select');
sel.name = name;
sel.className = 'input';
sel.innerHTML = '<option value="">' + EMPTY + '</option>';
return sel;
}
function reindexSteps(builderId) {
@@ -310,11 +317,27 @@
var list = block.querySelector('[data-items]');
var row = document.createElement('div');
row.className = 'proj-item-row';
row.innerHTML =
'<select name="item_department_id" class="input">' + deptOptionsHtml() + '</select>' +
'<input type="text" name="item_title" class="input" placeholder="{% trans "Название задачи" %}" required>' +
'<input type="hidden" name="item_step" value="' + stepIndex + '">' +
'<button type="button" class="btn btn-secondary" style="font-size:0.7rem;" onclick="this.parentElement.remove()">×</button>';
var dept = makeDeptPicker('item_department_id');
var hid = document.createElement('input');
hid.type = 'hidden';
hid.name = 'item_step';
hid.value = stepIndex;
dept.appendChild(hid);
row.appendChild(dept);
var title = document.createElement('input');
title.type = 'text';
title.name = 'item_title';
title.className = 'input';
title.placeholder = '{% trans "Название задачи" %}';
title.required = true;
row.appendChild(title);
var rm = document.createElement('button');
rm.type = 'button';
rm.className = 'btn btn-secondary';
rm.style.fontSize = '0.7rem';
rm.textContent = '×';
rm.onclick = function () { row.remove(); };
row.appendChild(rm);
list.appendChild(row);
};
@@ -326,33 +349,62 @@
block.className = 'proj-step-block';
block.setAttribute('data-step-block', '1');
block.setAttribute('data-step-index', String(idx));
var timing = '';
var head = document.createElement('div');
head.className = 'proj-step-head';
head.innerHTML =
'<div style="flex:1;">' +
'<div data-step-label style="font-size:0.7rem; color:var(--color-muted-foreground); margin-bottom:0.25rem;">{% trans "Шаг" %} ' + (idx + 1) + '</div>' +
'<input type="text" name="step_title" class="input" placeholder="{% trans "Название шага" %}" required>' +
'</div>';
var rmStep = document.createElement('button');
rmStep.type = 'button';
rmStep.className = 'btn btn-secondary';
rmStep.style.cssText = 'font-size:0.7rem;margin-top:1.1rem;';
rmStep.textContent = '×';
rmStep.onclick = function () {
block.remove();
reindexSteps(builderId);
};
head.appendChild(rmStep);
block.appendChild(head);
var deptField = document.createElement('div');
deptField.className = 'proj-field';
deptField.style.marginBottom = '0.5rem';
var deptLabel = document.createElement('label');
deptLabel.textContent = '{% trans "Отдел шага (по умолчанию)" %}';
deptField.appendChild(deptLabel);
deptField.appendChild(makeDeptPicker('step_department_id'));
block.appendChild(deptField);
if (withTiming) {
timing =
'<div class="proj-row-2" style="margin-bottom:0.5rem;">' +
'<div><label style="font-size:0.7rem;">{% trans "Смещение, дн." %}</label>' +
'<input type="number" name="step_offset_days" class="input" value="0" min="0"></div>' +
'<div><label style="font-size:0.7rem;">{% trans "Длительность, дн." %}</label>' +
'<input type="number" name="step_duration_days" class="input" value="14" min="1"></div>' +
'</div>';
var timing = document.createElement('div');
timing.className = 'proj-row-2';
timing.style.marginBottom = '0.5rem';
timing.innerHTML =
'<div><label style="font-size:0.7rem;">{% trans "Смещение, дн." %}</label>' +
'<input type="number" name="step_offset_days" class="input" value="0" min="0"></div>' +
'<div><label style="font-size:0.7rem;">{% trans "Длительность, дн." %}</label>' +
'<input type="number" name="step_duration_days" class="input" value="14" min="1"></div>';
block.appendChild(timing);
}
block.innerHTML =
'<div class="proj-step-head">' +
'<div style="flex:1;">' +
'<div data-step-label style="font-size:0.7rem; color:var(--color-muted-foreground); margin-bottom:0.25rem;">{% trans "Шаг" %} ' + (idx + 1) + '</div>' +
'<input type="text" name="step_title" class="input" placeholder="{% trans "Название шага" %}" required>' +
'</div>' +
'<button type="button" class="btn btn-secondary" style="font-size:0.7rem; margin-top:1.1rem;" onclick="this.closest(\'[data-step-block]\').remove(); reindexSteps(\'' + builderId + '\')">×</button>' +
'</div>' +
'<div class="proj-field" style="margin-bottom:0.5rem;">' +
'<label>{% trans "Отдел шага (по умолчанию)" %}</label>' +
'<select name="step_department_id" class="input">' + deptOptionsHtml() + '</select>' +
'</div>' +
timing +
'<div data-items></div>' +
'<button type="button" class="btn btn-secondary" style="font-size:0.7rem;" data-add-item onclick="addItemRow(this)">{% trans "+ Задача" %}</button>';
var items = document.createElement('div');
items.setAttribute('data-items', '');
block.appendChild(items);
var addItem = document.createElement('button');
addItem.type = 'button';
addItem.className = 'btn btn-secondary';
addItem.style.fontSize = '0.7rem';
addItem.setAttribute('data-add-item', '');
addItem.textContent = '{% trans "+ Задача" %}';
addItem.onclick = function () { addItemRow(addItem); };
block.appendChild(addItem);
root.appendChild(block);
addItemRow(block.querySelector('[data-add-item]'));
addItemRow(addItem);
reindexSteps(builderId);
};
@@ -365,6 +417,7 @@
};
window.openProjectDialog = function (templateId) {
if (window.DeptTreePicker) DeptTreePicker.clearCache();
var dlg = document.getElementById('project-create');
var mode = document.getElementById('project-mode');
if (templateId) {
@@ -379,7 +432,7 @@
dlg.showModal();
};
// Первый шаг шаблона сразу на вкладке
if (window.DeptTreePicker) DeptTreePicker.clearCache();
var tplBuilder = document.getElementById('template-steps-builder');
if (tplBuilder && !tplBuilder.children.length) {
addStepBlock('template-steps-builder', true);