)."""
+ details: dict[str, str] = {}
+ for key in required_details_for(work):
+ details[key] = (post.get(f'detail_{key}') or '').strip()
+ return details
+
+
+def validate_worklog_details(work: Directory, details: dict | None) -> str:
+ """Пустая строка если ок, иначе текст ошибки."""
+ details = details or {}
+ missing = []
+ for key in required_details_for(work):
+ val = details.get(key)
+ if val is None or (isinstance(val, str) and not val.strip()):
+ missing.append(key)
+ if not missing:
+ return ''
+ labels = detail_labels_from_db()
+ named = [labels.get(k, k) for k in missing]
+ return 'Заполните обязательные уточнения: ' + ', '.join(named) + '.'
+
+
+def format_details_short(details: dict | None, labels: dict[str, str] | None = None) -> str:
+ if not details:
+ return ''
+ labels = labels or {}
+ parts = []
+ for k, v in details.items():
+ if v is None or v == '':
+ continue
+ parts.append(f'{labels.get(k, k)}: {v}')
+ return '; '.join(parts)
+
+
+def worklog_display_title(log, labels: dict[str, str] | None = None) -> str:
+ """Заголовок для списка/отчёта."""
+ if getattr(log, 'catalog_work_id', None) and log.catalog_work_id:
+ name = log.catalog_work.name
+ short = format_details_short(log.details or {}, labels)
+ return f'{name} ({short})' if short else name
+ return (log.description or '').strip() or '—'
+
+
+def get_catalog_work(pk) -> Directory | None:
+ if not pk:
+ return None
+ return (
+ Directory.objects.filter(
+ pk=pk, directory_type__code=WORK_TYPE, is_active=True,
+ )
+ .exclude(code='_meta')
+ .select_related('parent')
+ .first()
+ )
diff --git a/apps/reports/it_monthly/builder.py b/apps/reports/it_monthly/builder.py
index 7a74571..1fd0e82 100644
--- a/apps/reports/it_monthly/builder.py
+++ b/apps/reports/it_monthly/builder.py
@@ -11,6 +11,7 @@ from django.utils import timezone
from apps.it_manager.models import WorkLog
from apps.it_manager.report_import import default_report_tool_root, load_company, normalize_name
+from apps.it_manager.work_directory import detail_labels_from_db, worklog_display_title
from apps.locations.models.base import pharmacy_object_code
from apps.reports.it_monthly.conclusions import build_conclusions
@@ -272,9 +273,13 @@ def build_monthly_report(year: int, month: int, *, company: dict | None = None)
start = date(year, month, 1)
end = date(year, month, calendar.monthrange(year, month)[1])
+ detail_labels = detail_labels_from_db()
logs = list(
WorkLog.objects.filter(date__gte=start, date__lte=end)
- .select_related('technician', 'location', 'work_type', 'ticket')
+ .select_related(
+ 'technician', 'location', 'work_type', 'ticket',
+ 'catalog_work', 'catalog_work__parent',
+ )
.prefetch_related('location__pharmacy__organization')
.order_by('date', 'id')
)
@@ -322,16 +327,20 @@ def build_monthly_report(year: int, month: int, *, company: dict | None = None)
systems = []
for log in tech_logs:
obj = location_object_code(log.location)
- if not obj:
+ if log.catalog_work_id:
+ work = worklog_display_title(log, detail_labels)
+ if log.description:
+ work = f'{work}. {log.description}'.strip()
+ elif not obj:
# префикс «А12 — …» из импорта без локации
- m = re.match(r'^([АAПPТTГGЛL]\d+|ОФИС|Склад)\s*[—\-]\s*(.+)$', log.description, re.I | re.S)
+ m = re.match(r'^([АAПPТTГGЛL]\d+|ОФИС|Склад)\s*[—\-]\s*(.+)$', log.description or '', re.I | re.S)
if m:
obj = m.group(1).upper().replace('A', 'А')
work = m.group(2).strip()
else:
- work = log.description
+ work = log.description or ''
else:
- work = log.description
+ work = log.description or ''
sys_m = SYSTEM_DESC_RE.match(work)
if sys_m and (log.result or '').startswith('import:report-tool:'):
@@ -339,12 +348,18 @@ def build_monthly_report(year: int, month: int, *, company: dict | None = None)
direction_counts[INFRA_DIRECTION] += 1
direction_people[INFRA_DIRECTION].add(short)
else:
+ work_type_label = ''
+ if log.catalog_work_id:
+ parent = log.catalog_work.parent
+ work_type_label = parent.name if parent else log.catalog_work.name
+ elif log.work_type_id:
+ work_type_label = log.work_type.name
tasks.append({
'date': log.date.strftime('%d.%m.%Y'),
'object': obj,
'work': work,
'work_items': split_work_items(work),
- 'work_type': log.work_type.name if log.work_type_id else '',
+ 'work_type': work_type_label,
'ticket': log.ticket.number if log.ticket_id else '',
'hours': float(log.hours_spent) if log.hours_spent is not None else None,
})
diff --git a/core/templatetags/common_tags.py b/core/templatetags/common_tags.py
index deaf2bc..1a15f23 100644
--- a/core/templatetags/common_tags.py
+++ b/core/templatetags/common_tags.py
@@ -19,6 +19,17 @@ def initials(user):
return f'{last}{first}'
+@register.filter
+def get_item(mapping, key):
+ """Доступ к dict по ключу: {{ labels|get_item:code }}."""
+ if not mapping:
+ return ''
+ try:
+ return mapping.get(key, '')
+ except AttributeError:
+ return ''
+
+
@register.filter
def clean_excerpt(html_text, length=140):
"""Чистий текстовий уривок з HTML (тіло поста тощо): знімає теги, розкодовує
diff --git a/static/assets/js/it-manager.js b/static/assets/js/it-manager.js
index 0713e3a..08020f3 100644
--- a/static/assets/js/it-manager.js
+++ b/static/assets/js/it-manager.js
@@ -165,6 +165,84 @@ function itSetSearchableSelectValue(selectId, value) {
}
}
+function itWorkCatalogData() {
+ var el = document.getElementById('it-work-catalog-data');
+ if (!el) return { categories: [], works: [], detail_labels: {} };
+ try {
+ return JSON.parse(el.textContent || '{}');
+ } catch (e) {
+ return { categories: [], works: [], detail_labels: {} };
+ }
+}
+
+function itWorklogCategoryChanged() {
+ var cat = document.getElementById('it-worklog-category');
+ var work = document.getElementById('it-worklog-work');
+ if (!work) return;
+ var catId = cat ? cat.value : '';
+ var data = itWorkCatalogData();
+ var works = (data.works || []).filter(function (w) {
+ return !catId || String(w.category_id) === String(catId);
+ });
+ work.innerHTML = '';
+ var empty = document.createElement('option');
+ empty.value = '';
+ empty.textContent = catId ? '— Выберите работу —' : '— Сначала выберите категорию —';
+ work.appendChild(empty);
+ works.forEach(function (w) {
+ var opt = document.createElement('option');
+ opt.value = String(w.id);
+ opt.textContent = w.name;
+ work.appendChild(opt);
+ });
+ itWorklogWorkChanged();
+}
+
+function itWorklogWorkChanged() {
+ var workSelect = document.getElementById('it-worklog-work');
+ var detailsBox = document.getElementById('it-worklog-details');
+ var notesEl = document.getElementById('it-worklog-work-notes');
+ if (!detailsBox) return;
+ var data = itWorkCatalogData();
+ var labels = data.detail_labels || {};
+ var workId = workSelect ? workSelect.value : '';
+ var work = (data.works || []).find(function (w) { return String(w.id) === String(workId); });
+ detailsBox.innerHTML = '';
+ if (!work || !(work.required_details || []).length) {
+ detailsBox.style.display = 'none';
+ } else {
+ detailsBox.style.display = 'flex';
+ (work.required_details || []).forEach(function (key) {
+ var wrap = document.createElement('div');
+ var label = document.createElement('label');
+ label.style.cssText = 'display:block;font-size:0.8125rem;font-weight:500;color:var(--color-foreground);margin-bottom:0.375rem;';
+ label.textContent = labels[key] || key;
+ var star = document.createElement('span');
+ star.style.color = 'var(--color-destructive)';
+ star.textContent = ' *';
+ label.appendChild(star);
+ var input = document.createElement('input');
+ input.type = 'text';
+ input.name = 'detail_' + key;
+ input.required = true;
+ input.className = 'input';
+ input.style.width = '100%';
+ wrap.appendChild(label);
+ wrap.appendChild(input);
+ detailsBox.appendChild(wrap);
+ });
+ }
+ if (notesEl) {
+ if (work && work.notes) {
+ notesEl.style.display = '';
+ notesEl.textContent = work.notes;
+ } else {
+ notesEl.style.display = 'none';
+ notesEl.textContent = '';
+ }
+ }
+}
+
function itWorklogFromTicket(ticket) {
if (!ticket || !ticket.id) return;
itTabSwitch('worklog');
@@ -187,8 +265,8 @@ function itWorklogFromTicket(ticket) {
hint.style.display = '';
hint.textContent = 'Заявка ' + ticket.number + (ticket.title ? ' — ' + ticket.title : '');
}
- var typeSelect = document.getElementById('it-worklog-type');
- if (typeSelect) typeSelect.focus();
+ var catSelect = document.getElementById('it-worklog-category');
+ if (catSelect) catSelect.focus();
}
function itWorklogTicketChanged() {
diff --git a/templates/pages/it-manager.html b/templates/pages/it-manager.html
index 45318e8..2551865 100644
--- a/templates/pages/it-manager.html
+++ b/templates/pages/it-manager.html
@@ -574,10 +574,13 @@
{% for log in ticket.work_logs.all %}
- {{ log.date|date:"d.m.Y" }} · {{ log.technician.get_full_name|default:log.technician.username }} · {{ log.work_type.name }}
+
+ {{ log.date|date:"d.m.Y" }} · {{ log.technician.get_full_name|default:log.technician.username }}
+ {% if log.catalog_work %} · {{ log.catalog_work.parent.name|default:"" }}{% elif log.work_type %} · {{ log.work_type.name }}{% endif %}
+
{% if log.hours_spent %}{{ log.hours_spent }} {% trans "ч" %}{% endif %}
- {{ log.description|truncatechars:160 }}
+ {{ log.display_title|truncatechars:160 }}
{% endfor %}
@@ -645,6 +648,15 @@
{% endfor %}
+
+
+
+
{% if it_can_edit_worklogs %}
{% trans "Как руководитель вы можете править записи прямо в таблице — изменения сохраняются при смене поля." %}
@@ -681,6 +693,7 @@
+ {{ it_work_catalog|json_script:"it-work-catalog-data" }}
@@ -688,14 +701,22 @@
-
-
+
+
+
+
+
+
+
+
@@ -737,8 +758,8 @@
-
-
+
+
@@ -772,8 +793,8 @@
{% trans "Дата" %}
{% trans "Исполнитель" %}
{% trans "Заявка" %}
- {% trans "Вид работ" %}
- {% trans "Описание" %}
+ {% trans "Типовая работа" %}
+ {% trans "Комментарий" %}
{% trans "Локация" %}
{% trans "Часов" %}
{% if it_can_edit_worklogs %}
@@ -810,17 +831,39 @@
title="{{ log.ticket.title }}">{{ log.ticket.number }}
{% else %}—{% endif %}
-
+
+ {% if log.catalog_work %}
+
+ {{ log.catalog_work.name }}
+ {{ log.catalog_work.parent.name|default:"" }}
+ {% with req=log.catalog_work.extra_data.required_details %}
+ {% if req %}
+
+ {% for key in req %}
+
+ {% endfor %}
+
+ {% endif %}
+ {% endwith %}
+ {% else %}
+ {% trans "без справочника" %}
{% for value, label in worklog_types %}
-
+
{% endfor %}
+ {% endif %}
-
@@ -860,8 +903,27 @@
title="{{ log.ticket.title }}">{{ log.ticket.number }}
{% else %}—{% endif %}
- {{ log.work_type.name }}
- {{ log.description|truncatechars:120 }}
+
+ {% if log.catalog_work %}
+ {{ log.catalog_work.name }}
+ {{ log.catalog_work.parent.name|default:"" }}
+ {% if log.details %}
+
+ {% for k, v in log.details.items %}{% if v %}{{ worklog_detail_labels|get_item:k|default:k }}: {{ v }}{% if not forloop.last %}; {% endif %}{% endif %}{% endfor %}
+
+ {% endif %}
+ {% else %}
+ {% trans "без справочника" %}
+ {% if log.work_type %}{{ log.work_type.name }}{% endif %}
+ {% endif %}
+
+
+ {% if log.catalog_work %}
+ {{ log.description|truncatechars:120|default:"—" }}
+ {% else %}
+ {{ log.description|truncatechars:120 }}
+ {% endif %}
+
{{ log.location|default:"—" }}
{{ log.hours_spent|default:"—" }}