it_manager: справочник типовых работ в журнале WorkLog.

Категория/работа/required_details вместо свободного work_type; seed Directory, отчёт и тесты.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-07 13:38:07 +03:00
parent be7e5853cb
commit 31bd4c8fa7
11 changed files with 822 additions and 112 deletions
+24 -27
View File
@@ -611,23 +611,27 @@ class ServiceCallForm(forms.ModelForm):
@admin.register(WorkLog) @admin.register(WorkLog)
class WorkLogAdmin(ModelAdmin): class WorkLogAdmin(ModelAdmin):
form = WorkLogForm form = WorkLogForm
list_display = ['date', 'technician', 'work_type_display', 'location', list_display = ['date', 'technician', 'catalog_display', 'location',
'hours_spent', 'ticket', 'created_at'] 'hours_spent', 'ticket', 'created_at']
list_filter = ['work_type', 'technician', 'date'] list_filter = ['catalog_work__parent', 'work_type', 'technician', 'date']
search_fields = ['description', 'result', 'technician__last_name', search_fields = ['description', 'result', 'catalog_work__name',
'technician__first_name'] 'technician__last_name', 'technician__first_name']
list_select_related = ['technician', 'location', 'ticket', 'equipment', 'work_type'] list_select_related = [
'technician', 'location', 'ticket', 'equipment',
'work_type', 'catalog_work', 'catalog_work__parent',
]
ordering = ['-date', '-created_at'] ordering = ['-date', '-created_at']
date_hierarchy = 'date' date_hierarchy = 'date'
raw_id_fields = ['catalog_work']
readonly_fields = ['created_at', 'updated_at'] readonly_fields = ['created_at', 'updated_at']
fieldsets = ( fieldsets = (
(_('Работа'), { (_('Работа'), {
'fields': ('date', 'technician', 'work_type', 'location'), 'fields': ('date', 'technician', 'catalog_work', 'details', 'location'),
}), }),
(_('Описание'), { (_('Устарело / комментарий'), {
'fields': ('description', 'result', 'hours_spent'), 'fields': ('work_type', 'description', 'result', 'hours_spent'),
}), }),
(_('Связи'), { (_('Связи'), {
'fields': ('equipment', 'ticket'), 'fields': ('equipment', 'ticket'),
@@ -638,25 +642,18 @@ class WorkLogAdmin(ModelAdmin):
}), }),
) )
WORK_TYPE_COLORS = { def catalog_display(self, obj):
'maintenance': '#2563eb', if obj.catalog_work_id:
'installation': '#16a34a', cat = obj.catalog_work.parent.name if obj.catalog_work.parent_id else ''
'repair': '#ca8a04', label = obj.catalog_work.name
'network': '#9333ea', if cat:
'software': '#0891b2', return f'{cat}: {label}'
'backup': '#6b7280', return label
'other': '#6b7280', if obj.work_type_id:
} return obj.work_type.name
return (obj.description or '—')[:60]
def work_type_display(self, obj): catalog_display.short_description = _('Типовая работа')
color = self.WORK_TYPE_COLORS.get(obj.work_type.code, '#6b7280') catalog_display.admin_order_field = 'catalog_work__name'
return format_html(
'<span style="color:{};font-weight:bold;">{}</span>',
color, obj.work_type.name
)
work_type_display.short_description = _('Вид работ')
work_type_display.admin_order_field = 'work_type__name'
def save_model(self, request, obj, form, change): def save_model(self, request, obj, form, change):
if not change and not obj.technician_id: if not change and not obj.technician_id:
obj.technician = request.user obj.technician = request.user
@@ -0,0 +1,31 @@
"""Загрузить consolidated-справочник типовых IT-работ в Directory."""
from pathlib import Path
from django.core.management.base import BaseCommand, CommandError
from apps.it_manager.work_directory import DEFAULT_JSON, seed_from_json
class Command(BaseCommand):
help = (
'Идемпотентный seed категорий (it_work_category) и типовых работ (it_work) '
'из work-directory-consolidated JSON.'
)
def add_arguments(self, parser):
parser.add_argument(
'json_path',
nargs='?',
default=str(DEFAULT_JSON),
help=f'Путь к work-directory.json (по умолчанию: {DEFAULT_JSON})',
)
def handle(self, *args, **options):
path = Path(options['json_path'])
if not path.is_file():
raise CommandError(f'Файл не найден: {path}')
stats = seed_from_json(path)
self.stdout.write(self.style.SUCCESS(
f'Справочник загружен: категорий={stats["categories"]}, '
f'работ={stats["works"]} ({path})'
))
@@ -0,0 +1,51 @@
# WorkLog: catalog_work + details; work_type/description optional
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0024_seed_pvms_visit_permissions'),
('it_manager', '0021_seed_gpon_connection_type'),
]
operations = [
migrations.AlterField(
model_name='worklog',
name='work_type',
field=models.ForeignKey(
blank=True,
limit_choices_to={'directory_type__code': 'worklog_type', 'is_active': True},
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name='worklogs_by_type',
to='core.directory',
verbose_name='Вид работ',
),
),
migrations.AlterField(
model_name='worklog',
name='description',
field=models.TextField(blank=True, verbose_name='Комментарий'),
),
migrations.AddField(
model_name='worklog',
name='catalog_work',
field=models.ForeignKey(
blank=True,
limit_choices_to={'directory_type__code': 'it_work', 'is_active': True},
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name='worklogs_by_catalog',
to='core.directory',
verbose_name='Типовая работа',
),
),
migrations.AddField(
model_name='worklog',
name='details',
field=models.JSONField(blank=True, default=dict, verbose_name='Уточнения'),
),
]
+22 -4
View File
@@ -2,8 +2,6 @@ from django.db import models
from django.conf import settings from django.conf import settings
from django.utils import timezone from django.utils import timezone
from core.models.directories import directory_default
class WorkLog(models.Model): class WorkLog(models.Model):
date = models.DateField('Дата', default=timezone.localdate, db_index=True) date = models.DateField('Дата', default=timezone.localdate, db_index=True)
@@ -16,12 +14,21 @@ class WorkLog(models.Model):
null=True, blank=True, null=True, blank=True,
related_name='work_logs', verbose_name='Локация', related_name='work_logs', verbose_name='Локация',
) )
# Устаревший грубый вид (~7 типов); для новых записей не заполняется.
work_type = models.ForeignKey( work_type = models.ForeignKey(
'core.Directory', on_delete=models.PROTECT, 'core.Directory', on_delete=models.PROTECT,
limit_choices_to={'directory_type__code': 'worklog_type', 'is_active': True}, limit_choices_to={'directory_type__code': 'worklog_type', 'is_active': True},
related_name='worklogs_by_type', verbose_name='Вид работ', related_name='worklogs_by_type', verbose_name='Вид работ',
null=True, blank=True,
) )
description = models.TextField('Описание работ') catalog_work = models.ForeignKey(
'core.Directory', on_delete=models.PROTECT,
limit_choices_to={'directory_type__code': 'it_work', 'is_active': True},
related_name='worklogs_by_catalog', verbose_name='Типовая работа',
null=True, blank=True,
)
details = models.JSONField('Уточнения', default=dict, blank=True)
description = models.TextField('Комментарий', blank=True)
result = models.TextField('Результат', blank=True) result = models.TextField('Результат', blank=True)
hours_spent = models.DecimalField('Затрачено часов', max_digits=5, decimal_places=1, hours_spent = models.DecimalField('Затрачено часов', max_digits=5, decimal_places=1,
null=True, blank=True) null=True, blank=True)
@@ -49,7 +56,18 @@ class WorkLog(models.Model):
] ]
def __str__(self): def __str__(self):
return f'{self.work_type.name} — {self.technician} — {self.date:%d.%m.%Y}' if self.catalog_work_id:
title = self.catalog_work.name
elif self.work_type_id:
title = self.work_type.name
else:
title = (self.description or '—')[:40]
return f'{title} — {self.technician} — {self.date:%d.%m.%Y}'
@property
def display_title(self) -> str:
from apps.it_manager.work_directory import worklog_display_title
return worklog_display_title(self)
class ServiceCall(models.Model): class ServiceCall(models.Model):
+139 -7
View File
@@ -1,6 +1,9 @@
"""Тести IT-порталу: доступ через групу IT, інвентаризація обладнання """Тести IT-порталу: доступ через групу IT, інвентаризація обладнання
(зі списанням історії видачі), черга заявок, журнал робіт.""" (зі списанням історії видачі), черга заявок, журнал робіт."""
from datetime import date from datetime import date
import json
import tempfile
from pathlib import Path
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.contrib.auth.models import Group from django.contrib.auth.models import Group
@@ -9,6 +12,7 @@ from django.test import Client, TestCase
from apps.it_manager.models import ( from apps.it_manager.models import (
Equipment, EquipmentAssignment, EquipmentModel, Ticket, WorkLog, Equipment, EquipmentAssignment, EquipmentModel, Ticket, WorkLog,
) )
from apps.it_manager.work_directory import seed_from_json
from core.models.directories import Directory from core.models.directories import Directory
from core.test_utils import login_2fa, grant_all_locations from core.test_utils import login_2fa, grant_all_locations
@@ -58,6 +62,50 @@ def make_equipment(**overrides):
return Equipment.objects.create(**defaults) return Equipment.objects.create(**defaults)
MINI_WORK_CATALOG = {
'schema_version': 1,
'status': 'test',
'detail_fields': {
'device_type': {'label': 'Вид устройства'},
'system': {'label': 'Система'},
},
'categories': [
{
'id': 'helpdesk',
'name': 'Поддержка',
'works': [
{'id': 'helpdesk.test1', 'name': 'Приём обращений'},
],
},
{
'id': 'printing',
'name': 'Печать',
'works': [
{
'id': 'printing.test1',
'name': 'Восстановление печати',
'required_details': ['device_type'],
},
],
},
],
}
def seed_mini_work_catalog():
with tempfile.NamedTemporaryFile('w', suffix='.json', delete=False, encoding='utf-8') as fh:
json.dump(MINI_WORK_CATALOG, fh, ensure_ascii=False)
path = fh.name
try:
return seed_from_json(path)
finally:
Path(path).unlink(missing_ok=True)
def catalog_work_id(code):
return Directory.objects.get(directory_type__code='it_work', code=code).id
class ItManagerAccessTests(TestCase): class ItManagerAccessTests(TestCase):
"""Сторінка й усі мутації доступні лише групі IT (або суперюзеру).""" """Сторінка й усі мутації доступні лише групі IT (або суперюзеру)."""
@@ -432,9 +480,30 @@ class ItManagerTicketTests(TestCase):
self.assertTrue(ticket.comments.first().is_internal) self.assertTrue(ticket.comments.first().is_internal)
class ItWorkDirectorySeedTests(TestCase):
def test_seed_upserts_categories_and_works(self):
stats = seed_mini_work_catalog()
self.assertEqual(stats['categories'], 2)
self.assertEqual(stats['works'], 2)
self.assertEqual(
Directory.objects.filter(directory_type__code='it_work_category', is_active=True).count(),
2,
)
work = Directory.objects.get(directory_type__code='it_work', code='printing.test1')
self.assertEqual(work.extra_data.get('required_details'), ['device_type'])
# idempotent
stats2 = seed_mini_work_catalog()
self.assertEqual(stats2['works'], 2)
self.assertEqual(
Directory.objects.filter(directory_type__code='it_work').exclude(code='_meta').count(),
2,
)
class ItManagerWorkLogTests(TestCase): class ItManagerWorkLogTests(TestCase):
def setUp(self): def setUp(self):
seed_mini_work_catalog()
self.it_user = make_employee(is_staff=False) self.it_user = make_employee(is_staff=False)
self.it_user.groups.add(Group.objects.get(name='IT')) self.it_user.groups.add(Group.objects.get(name='IT'))
grant_all_locations(self.it_user) grant_all_locations(self.it_user)
@@ -443,27 +512,65 @@ class ItManagerWorkLogTests(TestCase):
def test_create_worklog(self): def test_create_worklog(self):
r = self.client.post('/it-manager/worklog/create/', { r = self.client.post('/it-manager/worklog/create/', {
'work_type': 'maintenance', 'description': 'Профілактика серверної', 'catalog_work_id': str(catalog_work_id('helpdesk.test1')),
'description': 'Комментарий к обращению',
'hours_spent': '2.5', 'hours_spent': '2.5',
}) })
self.assertEqual(r.status_code, 200) self.assertEqual(r.status_code, 200)
log = WorkLog.objects.get(description='Профілактика серверної') log = WorkLog.objects.get(description='Комментарий к обращению')
self.assertEqual(log.technician_id, self.it_user.id) self.assertEqual(log.technician_id, self.it_user.id)
self.assertEqual(log.catalog_work.code, 'helpdesk.test1')
self.assertIsNone(log.work_type_id)
self.assertEqual(float(log.hours_spent), 2.5) self.assertEqual(float(log.hours_spent), 2.5)
self.assertContains(r, 'Запись о выполненной работе сохранена') self.assertContains(r, 'Запись о выполненной работе сохранена')
def test_create_worklog_requires_description(self): def test_create_worklog_requires_catalog_and_details(self):
before = WorkLog.objects.count() before = WorkLog.objects.count()
r = self.client.post('/it-manager/worklog/create/', { r = self.client.post('/it-manager/worklog/create/', {
'work_type': 'maintenance', 'description': ' ', 'description': 'без работы',
}) })
self.assertEqual(r.status_code, 200) self.assertEqual(r.status_code, 200)
self.assertEqual(WorkLog.objects.count(), before) self.assertEqual(WorkLog.objects.count(), before)
self.assertContains(r, 'Укажите описание работ') self.assertContains(r, 'типовую работу')
r = self.client.post('/it-manager/worklog/create/', {
'catalog_work_id': str(catalog_work_id('printing.test1')),
'description': '',
})
self.assertEqual(r.status_code, 200)
self.assertEqual(WorkLog.objects.count(), before)
self.assertContains(r, 'обязательные уточнения')
r = self.client.post('/it-manager/worklog/create/', {
'catalog_work_id': str(catalog_work_id('printing.test1')),
'detail_device_type': 'МФУ',
})
self.assertEqual(r.status_code, 200)
self.assertEqual(WorkLog.objects.count(), before + 1)
log = WorkLog.objects.latest('pk')
self.assertEqual(log.details.get('device_type'), 'МФУ')
def test_worklog_list_shows_catalog_title(self):
WorkLog.objects.create(
technician=self.it_user,
catalog_work_id=catalog_work_id('printing.test1'),
details={'device_type': 'Принтер'},
description='на складе',
)
WorkLog.objects.create(
technician=self.it_user,
work_type_id=_dir_id('worklog_type', 'maintenance'),
description='Старая запись без справочника',
)
r = self.client.get('/it-manager/?tab=worklog')
self.assertEqual(r.status_code, 200)
self.assertContains(r, 'Восстановление печати')
self.assertContains(r, 'без справочника')
self.assertContains(r, 'Старая запись без справочника')
def test_create_worklog_linked_to_ticket(self): def test_create_worklog_linked_to_ticket(self):
from apps.it_manager.models import Ticket from apps.it_manager.models import Ticket
from core.models.directories import Directory, DirectoryType from core.models.directories import DirectoryType
ttype, _ = DirectoryType.objects.get_or_create(code='ticket_type', defaults={'name': 'Тип заявки'}) ttype, _ = DirectoryType.objects.get_or_create(code='ticket_type', defaults={'name': 'Тип заявки'})
ptype, _ = DirectoryType.objects.get_or_create(code='ticket_priority', defaults={'name': 'Приоритет'}) ptype, _ = DirectoryType.objects.get_or_create(code='ticket_priority', defaults={'name': 'Приоритет'})
@@ -486,7 +593,7 @@ class ItManagerWorkLogTests(TestCase):
description='Каса не відкривається', description='Каса не відкривається',
) )
r = self.client.post('/it-manager/worklog/create/', { r = self.client.post('/it-manager/worklog/create/', {
'work_type': 'repair', 'catalog_work_id': str(catalog_work_id('helpdesk.test1')),
'description': 'Перезапуск касового ПЗ', 'description': 'Перезапуск касового ПЗ',
'ticket_id': ticket.pk, 'ticket_id': ticket.pk,
'hours_spent': '1', 'hours_spent': '1',
@@ -497,6 +604,31 @@ class ItManagerWorkLogTests(TestCase):
self.assertContains(r, 'Работа привязана к заявке') self.assertContains(r, 'Работа привязана к заявке')
self.assertContains(r, ticket.number) self.assertContains(r, ticket.number)
def test_monthly_builder_mix_catalog_and_legacy(self):
from apps.reports.it_monthly.builder import build_monthly_report
WorkLog.objects.create(
date=date(2026, 5, 10),
technician=self.it_user,
catalog_work_id=catalog_work_id('printing.test1'),
details={'device_type': 'МФУ'},
description='на А3',
)
WorkLog.objects.create(
date=date(2026, 5, 11),
technician=self.it_user,
work_type_id=_dir_id('worklog_type', 'repair'),
description='Старый свободный текст',
)
report = build_monthly_report(2026, 5, company={'staff': [], 'directions': []})
works = [
t['work']
for emp in report['employees']
for t in emp.get('tasks') or []
]
self.assertTrue(any('Восстановление печати' in w and 'МФУ' in w for w in works))
self.assertTrue(any('Старый свободный текст' in w for w in works))
class ItManagerWorkLogFilterEditTests(TestCase): class ItManagerWorkLogFilterEditTests(TestCase):
"""Фильтр по сотруднику и inline-редактирование для руководителя.""" """Фильтр по сотруднику и inline-редактирование для руководителя."""
+106 -49
View File
@@ -15,6 +15,13 @@ from apps.it_manager.models import (
InternetContract, InternetProvider, InternetContract, InternetProvider,
ServiceCall, Ticket, TicketComment, WorkLog, ServiceCall, Ticket, TicketComment, WorkLog,
) )
from apps.it_manager.work_directory import (
catalog_payload_for_ui,
detail_labels_from_db,
get_catalog_work,
parse_details_from_post,
validate_worklog_details,
)
from apps.it_manager.monthly_report import ( from apps.it_manager.monthly_report import (
MONTHS_RU, MONTHS_RU,
user_can_manage_it_monthly_report, user_can_manage_it_monthly_report,
@@ -306,7 +313,9 @@ def _it_manager_context(request):
'comments__author', 'comments__author',
models.Prefetch( models.Prefetch(
'work_logs', 'work_logs',
queryset=scoped_queryset(request.user, WorkLog.objects.all()).select_related('technician', 'work_type').order_by('-date', '-created_at'), queryset=scoped_queryset(request.user, WorkLog.objects.all()).select_related(
'technician', 'work_type', 'catalog_work', 'catalog_work__parent',
).order_by('-date', '-created_at'),
), ),
) )
.annotate(worklogs_count=models.Count('work_logs', distinct=True)) .annotate(worklogs_count=models.Count('work_logs', distinct=True))
@@ -330,6 +339,8 @@ def _it_manager_context(request):
month_start = timezone.localdate().replace(day=1) month_start = timezone.localdate().replace(day=1)
worklog_technician_raw = (request.GET.get('worklog_technician') or '').strip() worklog_technician_raw = (request.GET.get('worklog_technician') or '').strip()
worklog_technician_id = int(worklog_technician_raw) if worklog_technician_raw.isdigit() else None worklog_technician_id = int(worklog_technician_raw) if worklog_technician_raw.isdigit() else None
worklog_category_raw = (request.GET.get('worklog_category') or '').strip()
worklog_category_id = int(worklog_category_raw) if worklog_category_raw.isdigit() else None
worklog_month_raw = (request.GET.get('worklog_month') or '').strip() worklog_month_raw = (request.GET.get('worklog_month') or '').strip()
worklog_month_start = None worklog_month_start = None
@@ -346,6 +357,7 @@ def _it_manager_context(request):
worklogs_qs = ( worklogs_qs = (
scoped_queryset(request.user, WorkLog.objects.all()).select_related( scoped_queryset(request.user, WorkLog.objects.all()).select_related(
'technician', 'location', 'equipment', 'ticket', 'work_type', 'technician', 'location', 'equipment', 'ticket', 'work_type',
'catalog_work', 'catalog_work__parent',
) )
.prefetch_related( .prefetch_related(
models.Prefetch( models.Prefetch(
@@ -357,11 +369,15 @@ def _it_manager_context(request):
) )
if worklog_technician_id: if worklog_technician_id:
worklogs_qs = worklogs_qs.filter(technician_id=worklog_technician_id) worklogs_qs = worklogs_qs.filter(technician_id=worklog_technician_id)
if worklog_category_id:
worklogs_qs = worklogs_qs.filter(catalog_work__parent_id=worklog_category_id)
if worklog_month_start and worklog_month_end: if worklog_month_start and worklog_month_end:
worklogs_qs = worklogs_qs.filter( worklogs_qs = worklogs_qs.filter(
date__gte=worklog_month_start, date__lte=worklog_month_end, date__gte=worklog_month_start, date__lte=worklog_month_end,
) )
worklogs_page = paginate(request, worklogs_qs, page_param='worklogs_page', size_param='worklogs_per_page') worklogs_page = paginate(request, worklogs_qs, page_param='worklogs_page', size_param='worklogs_per_page')
it_work_catalog = catalog_payload_for_ui()
worklog_detail_labels = it_work_catalog.get('detail_labels') or detail_labels_from_db()
# Статистика: выбранный месяц или текущий календарный # Статистика: выбранный месяц или текущий календарный
if worklog_month_start and worklog_month_end: if worklog_month_start and worklog_month_end:
@@ -468,10 +484,13 @@ def _it_manager_context(request):
'worklog_types': _directory_choices('worklog_type'), 'worklog_types': _directory_choices('worklog_type'),
'worklog_technicians': worklog_technicians, 'worklog_technicians': worklog_technicians,
'worklog_technician_id': worklog_technician_id, 'worklog_technician_id': worklog_technician_id,
'worklog_category_id': worklog_category_id,
'worklog_month_id': worklog_month_id, 'worklog_month_id': worklog_month_id,
'worklog_month_choices': worklog_month_choices, 'worklog_month_choices': worklog_month_choices,
'worklog_stats_label': worklog_stats_label, 'worklog_stats_label': worklog_stats_label,
'it_can_edit_worklogs': can_edit_worklogs, 'it_can_edit_worklogs': can_edit_worklogs,
'it_work_catalog': it_work_catalog,
'worklog_detail_labels': worklog_detail_labels,
'month_hours': month_hours, 'month_hours': month_hours,
'month_count': month_count, 'month_count': month_count,
@@ -519,6 +538,7 @@ def it_manager_view(request):
elif ( elif (
'worklog_technician' in request.GET 'worklog_technician' in request.GET
or 'worklog_month' in request.GET or 'worklog_month' in request.GET
or 'worklog_category' in request.GET
or request.GET.get('worklogs_page') or request.GET.get('worklogs_page')
): ):
context['it_active_tab'] = 'worklog' context['it_active_tab'] = 'worklog'
@@ -887,49 +907,53 @@ def worklog_create_view(request):
worklog_error = '' worklog_error = ''
worklog_message = '' worklog_message = ''
if request.method == 'POST': if request.method == 'POST':
catalog_work = get_catalog_work(request.POST.get('catalog_work_id'))
description = request.POST.get('description', '').strip() description = request.POST.get('description', '').strip()
work_type_raw = request.POST.get('work_type', '') details = parse_details_from_post(request.POST, catalog_work)
work_type_id = _resolve_directory('worklog_type', work_type_raw)
if not description: if not catalog_work:
worklog_error = 'Укажите описание работ.' worklog_error = 'Выберите типовую работу из справочника.'
elif not work_type_id or work_type_raw == '__new__':
worklog_error = 'Выберите вид работ.'
else: else:
try: details_err = validate_worklog_details(catalog_work, details)
log_date = datetime.strptime(request.POST.get('date', ''), '%Y-%m-%d').date() if details_err:
except ValueError: worklog_error = details_err
log_date = timezone.localdate()
try:
hours_spent = float(request.POST.get('hours_spent', '').strip() or 0) or None
except ValueError:
hours_spent = None
location_id = request.POST.get('location_id') or None
if not location_id and not has_all_locations(request.user):
return HttpResponseForbidden('Укажите доступную локацию.')
equipment_id = request.POST.get('equipment_id') or None
ticket_id = request.POST.get('ticket_id') or None
try:
WorkLog.objects.create(
date=log_date,
technician=request.user,
work_type_id=work_type_id,
location_id=location_id,
equipment_id=equipment_id,
ticket_id=ticket_id,
description=description,
result=request.POST.get('result', '').strip(),
hours_spent=hours_spent,
)
except Exception:
worklog_error = 'Не удалось сохранить запись. Проверьте локацию, заявку и оборудование.'
else: else:
if ticket_id: try:
worklog_message = 'Работа привязана к заявке и сохранена.' log_date = datetime.strptime(request.POST.get('date', ''), '%Y-%m-%d').date()
except ValueError:
log_date = timezone.localdate()
try:
hours_spent = float(request.POST.get('hours_spent', '').strip() or 0) or None
except ValueError:
hours_spent = None
location_id = request.POST.get('location_id') or None
if not location_id and not has_all_locations(request.user):
return HttpResponseForbidden('Укажите доступную локацию.')
equipment_id = request.POST.get('equipment_id') or None
ticket_id = request.POST.get('ticket_id') or None
try:
WorkLog.objects.create(
date=log_date,
technician=request.user,
catalog_work=catalog_work,
details=details,
work_type_id=None,
location_id=location_id,
equipment_id=equipment_id,
ticket_id=ticket_id,
description=description,
result=request.POST.get('result', '').strip(),
hours_spent=hours_spent,
)
except Exception:
worklog_error = 'Не удалось сохранить запись. Проверьте локацию, заявку и оборудование.'
else: else:
worklog_message = 'Запись о выполненной работе сохранена.' if ticket_id:
worklog_message = 'Работа привязана к заявке и сохранена.'
else:
worklog_message = 'Запись о выполненной работе сохранена.'
context = _it_manager_context(request) context = _it_manager_context(request)
context['it_active_tab'] = 'worklog' context['it_active_tab'] = 'worklog'
@@ -953,19 +977,47 @@ def worklog_update_view(request, pk):
log = get_object_or_404(scoped_queryset(request.user, WorkLog.objects.all()), pk=pk) log = get_object_or_404(scoped_queryset(request.user, WorkLog.objects.all()), pk=pk)
description = request.POST.get('description', '').strip() description = request.POST.get('description', '').strip()
work_type_raw = request.POST.get('work_type', '') catalog_raw = (request.POST.get('catalog_work_id') or '').strip()
work_type_id = _resolve_directory('worklog_type', work_type_raw) catalog_work = get_catalog_work(catalog_raw) if catalog_raw else None
# Если запись уже из справочника — сохраняем FK даже без смены в форме
if not catalog_work and log.catalog_work_id and not catalog_raw:
catalog_work = log.catalog_work
if not description: work_type_raw = request.POST.get('work_type', '')
work_type_id = _resolve_directory('worklog_type', work_type_raw) if work_type_raw else None
if catalog_work:
details = parse_details_from_post(request.POST, catalog_work)
# если detail_* не пришли — оставить прежние значения для ключей без POST
if log.catalog_work_id == catalog_work.pk and log.details:
merged = dict(log.details)
for key in details:
if f'detail_{key}' in request.POST:
merged[key] = details[key]
details = merged
details_err = validate_worklog_details(catalog_work, details)
if details_err:
return HttpResponse(
f'<span style="color:var(--color-destructive); font-size:0.75rem;">{details_err}</span>',
status=400,
)
elif log.catalog_work_id:
return HttpResponse( return HttpResponse(
'<span style="color:var(--color-destructive); font-size:0.75rem;">Нужно описание</span>', '<span style="color:var(--color-destructive); font-size:0.75rem;">Укажите типовую работу</span>',
status=400,
)
if not work_type_id or work_type_raw == '__new__':
return HttpResponse(
'<span style="color:var(--color-destructive); font-size:0.75rem;">Укажите вид работ</span>',
status=400, status=400,
) )
else:
# Историческая запись без справочника
if not description:
return HttpResponse(
'<span style="color:var(--color-destructive); font-size:0.75rem;">Нужно описание</span>',
status=400,
)
if not work_type_id or work_type_raw == '__new__':
return HttpResponse(
'<span style="color:var(--color-destructive); font-size:0.75rem;">Укажите вид работ</span>',
status=400,
)
try: try:
log_date = datetime.strptime(request.POST.get('date', ''), '%Y-%m-%d').date() log_date = datetime.strptime(request.POST.get('date', ''), '%Y-%m-%d').date()
@@ -991,7 +1043,12 @@ def worklog_update_view(request, pk):
return HttpResponseForbidden('Укажите доступную локацию.') return HttpResponseForbidden('Укажите доступную локацию.')
log.date = log_date log.date = log_date
log.technician_id = technician_id log.technician_id = technician_id
log.work_type_id = work_type_id if catalog_work:
log.catalog_work = catalog_work
log.details = details
log.work_type_id = None
else:
log.work_type_id = work_type_id
log.description = description log.description = description
log.result = request.POST.get('result', '').strip() log.result = request.POST.get('result', '').strip()
log.hours_spent = hours_spent log.hours_spent = hours_spent
+258
View File
@@ -0,0 +1,258 @@
"""Справочник типовых IT-работ (Directory it_work_category / it_work)."""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
from django.db import transaction
from core.models.directories import Directory, DirectoryType
CAT_TYPE = 'it_work_category'
WORK_TYPE = 'it_work'
def _default_json_path() -> Path:
acore_root = Path(__file__).resolve().parents[2]
sibling = (
acore_root.parent
/ 'report-tool'
/ 'docs'
/ 'work-directory-consolidated'
/ 'work-directory.json'
)
if sibling.is_file():
return sibling
return Path(
'/home/lokses/Рабочий стол/apms/report-tool/docs/'
'work-directory-consolidated/work-directory.json'
)
DEFAULT_JSON = _default_json_path()
def load_catalog_json(path: str | Path | None = None) -> dict:
p = Path(path) if path else DEFAULT_JSON
return json.loads(p.read_text(encoding='utf-8'))
def detail_field_labels(catalog: dict | None = None) -> dict[str, str]:
"""code → label для UI."""
data = catalog if catalog is not None else {}
fields = data.get('detail_fields') or {}
out: dict[str, str] = {}
for code, meta in fields.items():
if isinstance(meta, dict):
out[code] = meta.get('label') or code
else:
out[code] = str(meta)
return out
@transaction.atomic
def seed_from_json(path: str | Path | None = None) -> dict[str, int]:
"""Идемпотентный upsert категорий и работ. Возвращает счётчики."""
data = load_catalog_json(path)
cat_type, _ = DirectoryType.objects.get_or_create(
code=CAT_TYPE,
defaults={
'name': 'Категория IT-работ',
'hierarchy_type': 'flexible',
'description': 'Категории справочника типовых IT-работ',
},
)
if cat_type.hierarchy_type != 'flexible':
cat_type.hierarchy_type = 'flexible'
cat_type.save(update_fields=['hierarchy_type', 'updated_at'])
work_type, _ = DirectoryType.objects.get_or_create(
code=WORK_TYPE,
defaults={
'name': 'Типовая IT-работа',
'hierarchy_type': 'flexible',
'description': 'Типовые работы IT-отдела',
},
)
if work_type.hierarchy_type != 'flexible':
work_type.hierarchy_type = 'flexible'
work_type.save(update_fields=['hierarchy_type', 'updated_at'])
labels = detail_field_labels(data)
# храним лейблы на типе работ в description JSON-хвосте некрасиво —
# кладём в первую категорию-маркер? Лучше отдельный Directory code=_meta
meta, _ = Directory.objects.update_or_create(
directory_type=work_type,
code='_meta',
defaults={
'name': 'Метаданные справочника IT-работ',
'is_active': False,
'extra_data': {
'detail_fields': data.get('detail_fields') or {},
'labels': labels,
'source_status': data.get('status', ''),
},
},
)
cats_upserted = 0
works_upserted = 0
cat_by_code: dict[str, Directory] = {}
for cat in data.get('categories') or []:
code = cat['id']
obj, _ = Directory.objects.update_or_create(
directory_type=cat_type,
code=code,
defaults={
'name': cat['name'],
'is_active': True,
'parent': None,
'extra_data': {},
},
)
cat_by_code[code] = obj
cats_upserted += 1
for work in cat.get('works') or []:
wid = work['id']
extra: dict[str, Any] = {
'required_details': list(work.get('required_details') or []),
}
if work.get('notes'):
extra['notes'] = work['notes']
if work.get('source_ids'):
extra['source_ids'] = work['source_ids']
Directory.objects.update_or_create(
directory_type=work_type,
code=wid,
defaults={
'name': work['name'],
'is_active': True,
'parent': obj,
'extra_data': extra,
},
)
works_upserted += 1
return {
'categories': cats_upserted,
'works': works_upserted,
'meta_id': meta.pk,
}
def catalog_payload_for_ui() -> dict:
"""JSON для формы: категории, работы, лейблы полей."""
cats = list(
Directory.objects.filter(directory_type__code=CAT_TYPE, is_active=True)
.order_by('name')
)
works = list(
Directory.objects.filter(directory_type__code=WORK_TYPE, is_active=True)
.exclude(code='_meta')
.select_related('parent')
.order_by('parent__name', 'name')
)
meta = Directory.objects.filter(
directory_type__code=WORK_TYPE, code='_meta',
).first()
labels = {}
if meta and isinstance(meta.extra_data, dict):
labels = meta.extra_data.get('labels') or {}
if not labels and meta.extra_data.get('detail_fields'):
labels = detail_field_labels({'detail_fields': meta.extra_data['detail_fields']})
return {
'categories': [{'id': c.pk, 'code': c.code, 'name': c.name} for c in cats],
'works': [
{
'id': w.pk,
'code': w.code,
'name': w.name,
'category_id': w.parent_id,
'required_details': list((w.extra_data or {}).get('required_details') or []),
'notes': (w.extra_data or {}).get('notes') or '',
}
for w in works
],
'detail_labels': labels,
}
def required_details_for(work: Directory | None) -> list[str]:
if not work:
return []
return list((work.extra_data or {}).get('required_details') or [])
def detail_labels_from_db() -> dict[str, str]:
meta = Directory.objects.filter(
directory_type__code=WORK_TYPE, code='_meta',
).first()
if not meta or not isinstance(meta.extra_data, dict):
return {}
labels = meta.extra_data.get('labels') or {}
if labels:
return dict(labels)
if meta.extra_data.get('detail_fields'):
return detail_field_labels({'detail_fields': meta.extra_data['detail_fields']})
return {}
def parse_details_from_post(post, work: Directory | None) -> dict[str, str]:
"""Собрать details из POST (поля detail_<code>)."""
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()
)
+21 -6
View File
@@ -11,6 +11,7 @@ from django.utils import timezone
from apps.it_manager.models import WorkLog 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.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.locations.models.base import pharmacy_object_code
from apps.reports.it_monthly.conclusions import build_conclusions 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) start = date(year, month, 1)
end = date(year, month, calendar.monthrange(year, month)[1]) end = date(year, month, calendar.monthrange(year, month)[1])
detail_labels = detail_labels_from_db()
logs = list( logs = list(
WorkLog.objects.filter(date__gte=start, date__lte=end) 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') .prefetch_related('location__pharmacy__organization')
.order_by('date', 'id') .order_by('date', 'id')
) )
@@ -322,16 +327,20 @@ def build_monthly_report(year: int, month: int, *, company: dict | None = None)
systems = [] systems = []
for log in tech_logs: for log in tech_logs:
obj = location_object_code(log.location) 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 — …» из импорта без локации # префикс «А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: if m:
obj = m.group(1).upper().replace('A', 'А') obj = m.group(1).upper().replace('A', 'А')
work = m.group(2).strip() work = m.group(2).strip()
else: else:
work = log.description work = log.description or ''
else: else:
work = log.description work = log.description or ''
sys_m = SYSTEM_DESC_RE.match(work) sys_m = SYSTEM_DESC_RE.match(work)
if sys_m and (log.result or '').startswith('import:report-tool:'): 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_counts[INFRA_DIRECTION] += 1
direction_people[INFRA_DIRECTION].add(short) direction_people[INFRA_DIRECTION].add(short)
else: 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({ tasks.append({
'date': log.date.strftime('%d.%m.%Y'), 'date': log.date.strftime('%d.%m.%Y'),
'object': obj, 'object': obj,
'work': work, 'work': work,
'work_items': split_work_items(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 '', 'ticket': log.ticket.number if log.ticket_id else '',
'hours': float(log.hours_spent) if log.hours_spent is not None else None, 'hours': float(log.hours_spent) if log.hours_spent is not None else None,
}) })
+11
View File
@@ -19,6 +19,17 @@ def initials(user):
return f'{last}{first}' 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 @register.filter
def clean_excerpt(html_text, length=140): def clean_excerpt(html_text, length=140):
"""Чистий текстовий уривок з HTML (тіло поста тощо): знімає теги, розкодовує """Чистий текстовий уривок з HTML (тіло поста тощо): знімає теги, розкодовує
+80 -2
View File
@@ -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) { function itWorklogFromTicket(ticket) {
if (!ticket || !ticket.id) return; if (!ticket || !ticket.id) return;
itTabSwitch('worklog'); itTabSwitch('worklog');
@@ -187,8 +265,8 @@ function itWorklogFromTicket(ticket) {
hint.style.display = ''; hint.style.display = '';
hint.textContent = 'Заявка ' + ticket.number + (ticket.title ? ' — ' + ticket.title : ''); hint.textContent = 'Заявка ' + ticket.number + (ticket.title ? ' — ' + ticket.title : '');
} }
var typeSelect = document.getElementById('it-worklog-type'); var catSelect = document.getElementById('it-worklog-category');
if (typeSelect) typeSelect.focus(); if (catSelect) catSelect.focus();
} }
function itWorklogTicketChanged() { function itWorklogTicketChanged() {
+79 -17
View File
@@ -574,10 +574,13 @@
{% for log in ticket.work_logs.all %} {% for log in ticket.work_logs.all %}
<div style="font-size:0.8125rem; background:var(--color-card); border:1px solid var(--color-border); border-radius:0.5rem; padding:0.5rem 0.75rem;"> <div style="font-size:0.8125rem; background:var(--color-card); border:1px solid var(--color-border); border-radius:0.5rem; padding:0.5rem 0.75rem;">
<div style="display:flex; justify-content:space-between; gap:0.5rem; color:var(--color-muted-foreground); font-size:0.75rem;"> <div style="display:flex; justify-content:space-between; gap:0.5rem; color:var(--color-muted-foreground); font-size:0.75rem;">
<span>{{ log.date|date:"d.m.Y" }} · {{ log.technician.get_full_name|default:log.technician.username }} · {{ log.work_type.name }}</span> <span>
{{ 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 %}
</span>
<span>{% if log.hours_spent %}{{ log.hours_spent }} {% trans "ч" %}{% endif %}</span> <span>{% if log.hours_spent %}{{ log.hours_spent }} {% trans "ч" %}{% endif %}</span>
</div> </div>
<div style="color:var(--color-foreground); margin-top:0.2rem;">{{ log.description|truncatechars:160 }}</div> <div style="color:var(--color-foreground); margin-top:0.2rem;">{{ log.display_title|truncatechars:160 }}</div>
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
@@ -645,6 +648,15 @@
{% endfor %} {% endfor %}
</select> </select>
</div> </div>
<div>
<label for="it-worklog-filter-cat" style="display:block; font-size:0.75rem; font-weight:500; color:var(--color-muted-foreground); margin-bottom:0.25rem;">{% trans "Категория" %}</label>
<select id="it-worklog-filter-cat" name="worklog_category" class="input" style="min-width:14rem; width:auto;">
<option value="">{% trans "Все категории" %}</option>
{% for cat in it_work_catalog.categories %}
<option value="{{ cat.id }}" {% if worklog_category_id == cat.id %}selected{% endif %}>{{ cat.name }}</option>
{% endfor %}
</select>
</div>
{% if it_can_edit_worklogs %} {% if it_can_edit_worklogs %}
<p style="font-size:0.75rem; color:var(--color-muted-foreground); margin:0 0 0.5rem; max-width:18rem;"> <p style="font-size:0.75rem; color:var(--color-muted-foreground); margin:0 0 0.5rem; max-width:18rem;">
{% trans "Как руководитель вы можете править записи прямо в таблице — изменения сохраняются при смене поля." %} {% trans "Как руководитель вы можете править записи прямо в таблице — изменения сохраняются при смене поля." %}
@@ -681,6 +693,7 @@
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6 6 18M6 6l12 12"/></svg> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6 6 18M6 6l12 12"/></svg>
</button> </button>
</div> </div>
{{ it_work_catalog|json_script:"it-work-catalog-data" }}
<div style="padding:1.25rem; display:flex; flex-direction:column; gap:1rem;"> <div style="padding:1.25rem; display:flex; flex-direction:column; gap:1rem;">
<div id="it-worklog-ticket-hint" style="display:none; padding:0.625rem 0.75rem; border-radius:0.5rem; background:color-mix(in srgb, var(--color-primary) 8%, var(--color-card)); border:1px solid color-mix(in srgb, var(--color-primary) 25%, var(--color-border)); font-size:0.8125rem; color:var(--color-foreground);"></div> <div id="it-worklog-ticket-hint" style="display:none; padding:0.625rem 0.75rem; border-radius:0.5rem; background:color-mix(in srgb, var(--color-primary) 8%, var(--color-card)); border:1px solid color-mix(in srgb, var(--color-primary) 25%, var(--color-border)); font-size:0.8125rem; color:var(--color-foreground);"></div>
<div> <div>
@@ -688,14 +701,22 @@
<input type="date" name="date" id="it-worklog-date" value="{% now 'Y-m-d' %}" class="input" style="width:100%;"> <input type="date" name="date" id="it-worklog-date" value="{% now 'Y-m-d' %}" class="input" style="width:100%;">
</div> </div>
<div> <div>
<label style="display:block; font-size:0.8125rem; font-weight:500; color:var(--color-foreground); margin-bottom:0.375rem;">{% trans "Вид работ" %} <span style="color:var(--color-destructive);">*</span></label> <label style="display:block; font-size:0.8125rem; font-weight:500; color:var(--color-foreground); margin-bottom:0.375rem;">{% trans "Категория" %} <span style="color:var(--color-destructive);">*</span></label>
<select name="work_type" id="it-worklog-type" required class="input" style="width:100%;" onchange="dqaHandleChange(this, 'worklog_type', 'code')"> <select id="it-worklog-category" class="input" style="width:100%;" onchange="itWorklogCategoryChanged()">
{% for value, label in worklog_types %} <option value="">{% trans "— Выберите категорию —" %}</option>
<option value="{{ value }}">{{ label }}</option> {% for cat in it_work_catalog.categories %}
<option value="{{ cat.id }}">{{ cat.name }}</option>
{% endfor %} {% endfor %}
<option value="__new__">{% trans "+ Добавить новый вид работ..." %}</option>
</select> </select>
</div> </div>
<div>
<label style="display:block; font-size:0.8125rem; font-weight:500; color:var(--color-foreground); margin-bottom:0.375rem;">{% trans "Типовая работа" %} <span style="color:var(--color-destructive);">*</span></label>
<select name="catalog_work_id" id="it-worklog-work" required class="input" style="width:100%;" onchange="itWorklogWorkChanged()">
<option value="">{% trans "— Сначала выберите категорию —" %}</option>
</select>
<div id="it-worklog-work-notes" style="display:none; font-size:0.75rem; color:var(--color-muted-foreground); margin-top:0.375rem;"></div>
</div>
<div id="it-worklog-details" style="display:none; flex-direction:column; gap:0.75rem;"></div>
<div> <div>
<label style="display:block; font-size:0.8125rem; font-weight:500; color:var(--color-foreground); margin-bottom:0.375rem;">{% trans "Заявка" %}</label> <label style="display:block; font-size:0.8125rem; font-weight:500; color:var(--color-foreground); margin-bottom:0.375rem;">{% trans "Заявка" %}</label>
<div class="searchable-select" data-placeholder="{% trans 'Поиск заявки по номеру или теме...' %}"> <div class="searchable-select" data-placeholder="{% trans 'Поиск заявки по номеру или теме...' %}">
@@ -737,8 +758,8 @@
</select> </select>
</div> </div>
<div> <div>
<label style="display:block; font-size:0.8125rem; font-weight:500; color:var(--color-foreground); margin-bottom:0.375rem;">{% trans "Описание работ" %} <span style="color:var(--color-destructive);">*</span></label> <label style="display:block; font-size:0.8125rem; font-weight:500; color:var(--color-foreground); margin-bottom:0.375rem;">{% trans "Комментарий" %} <span style="font-weight:400; color:var(--color-muted-foreground);">{% trans "(необязательно)" %}</span></label>
<textarea name="description" id="it-worklog-description" required style="width:100%; min-height:4rem; resize:vertical; background:var(--color-card); border:1px solid var(--color-input); border-radius:var(--radius); padding:0.4375rem 0.75rem; font-size:0.875rem; color:var(--color-foreground); font-family:var(--font-sans); outline:none; box-sizing:border-box;"></textarea> <textarea name="description" id="it-worklog-description" style="width:100%; min-height:3rem; resize:vertical; background:var(--color-card); border:1px solid var(--color-input); border-radius:var(--radius); padding:0.4375rem 0.75rem; font-size:0.875rem; color:var(--color-foreground); font-family:var(--font-sans); outline:none; box-sizing:border-box;"></textarea>
</div> </div>
<div> <div>
<label style="display:block; font-size:0.8125rem; font-weight:500; color:var(--color-foreground); margin-bottom:0.375rem;">{% trans "Результат" %}</label> <label style="display:block; font-size:0.8125rem; font-weight:500; color:var(--color-foreground); margin-bottom:0.375rem;">{% trans "Результат" %}</label>
@@ -772,8 +793,8 @@
<th style="padding:0.75rem 1rem;text-align:left;color:var(--color-muted-foreground);font-weight:600;font-size:0.8125rem;">{% trans "Дата" %}</th> <th style="padding:0.75rem 1rem;text-align:left;color:var(--color-muted-foreground);font-weight:600;font-size:0.8125rem;">{% trans "Дата" %}</th>
<th style="padding:0.75rem 1rem;text-align:left;color:var(--color-muted-foreground);font-weight:600;font-size:0.8125rem;">{% trans "Исполнитель" %}</th> <th style="padding:0.75rem 1rem;text-align:left;color:var(--color-muted-foreground);font-weight:600;font-size:0.8125rem;">{% trans "Исполнитель" %}</th>
<th style="padding:0.75rem 1rem;text-align:left;color:var(--color-muted-foreground);font-weight:600;font-size:0.8125rem;">{% trans "Заявка" %}</th> <th style="padding:0.75rem 1rem;text-align:left;color:var(--color-muted-foreground);font-weight:600;font-size:0.8125rem;">{% trans "Заявка" %}</th>
<th style="padding:0.75rem 1rem;text-align:left;color:var(--color-muted-foreground);font-weight:600;font-size:0.8125rem;">{% trans "Вид работ" %}</th> <th style="padding:0.75rem 1rem;text-align:left;color:var(--color-muted-foreground);font-weight:600;font-size:0.8125rem;">{% trans "Типовая работа" %}</th>
<th style="padding:0.75rem 1rem;text-align:left;color:var(--color-muted-foreground);font-weight:600;font-size:0.8125rem;">{% trans "Описание" %}</th> <th style="padding:0.75rem 1rem;text-align:left;color:var(--color-muted-foreground);font-weight:600;font-size:0.8125rem;">{% trans "Комментарий" %}</th>
<th style="padding:0.75rem 1rem;text-align:left;color:var(--color-muted-foreground);font-weight:600;font-size:0.8125rem;">{% trans "Локация" %}</th> <th style="padding:0.75rem 1rem;text-align:left;color:var(--color-muted-foreground);font-weight:600;font-size:0.8125rem;">{% trans "Локация" %}</th>
<th style="padding:0.75rem 1rem;text-align:left;color:var(--color-muted-foreground);font-weight:600;font-size:0.8125rem;">{% trans "Часов" %}</th> <th style="padding:0.75rem 1rem;text-align:left;color:var(--color-muted-foreground);font-weight:600;font-size:0.8125rem;">{% trans "Часов" %}</th>
{% if it_can_edit_worklogs %} {% if it_can_edit_worklogs %}
@@ -810,17 +831,39 @@
title="{{ log.ticket.title }}">{{ log.ticket.number }}</button> title="{{ log.ticket.title }}">{{ log.ticket.number }}</button>
{% else %}—{% endif %} {% else %}—{% endif %}
</td> </td>
<td style="padding:0.5rem 0.75rem;"> <td style="padding:0.5rem 0.75rem; min-width:12rem; max-width:16rem;">
{% if log.catalog_work %}
<input type="hidden" form="worklog-form-{{ log.pk }}" name="catalog_work_id" value="{{ log.catalog_work_id }}">
<div style="font-size:0.8125rem; color:var(--color-foreground); font-weight:500;">{{ log.catalog_work.name }}</div>
<div style="font-size:0.6875rem; color:var(--color-muted-foreground); margin-top:0.125rem;">{{ log.catalog_work.parent.name|default:"" }}</div>
{% with req=log.catalog_work.extra_data.required_details %}
{% if req %}
<div style="display:flex; flex-direction:column; gap:0.25rem; margin-top:0.375rem;">
{% for key in req %}
<label style="font-size:0.6875rem; color:var(--color-muted-foreground);">
{{ worklog_detail_labels|get_item:key|default:key }}
<input form="worklog-form-{{ log.pk }}" type="text" name="detail_{{ key }}"
value="{{ log.details|get_item:key }}"
class="input" style="width:100%; font-size:0.75rem; padding:0.2rem 0.35rem; margin-top:0.1rem;"
onchange="this.form.requestSubmit()">
</label>
{% endfor %}
</div>
{% endif %}
{% endwith %}
{% else %}
<span class="badge badge-muted" style="margin-bottom:0.25rem;">{% trans "без справочника" %}</span>
<select form="worklog-form-{{ log.pk }}" name="work_type" class="input" <select form="worklog-form-{{ log.pk }}" name="work_type" class="input"
style="min-width:8rem; max-width:11rem; font-size:0.8125rem; padding:0.3rem 0.4rem;" style="min-width:8rem; max-width:11rem; font-size:0.8125rem; padding:0.3rem 0.4rem; display:block;"
onchange="this.form.requestSubmit()"> onchange="this.form.requestSubmit()">
{% for value, label in worklog_types %} {% for value, label in worklog_types %}
<option value="{{ value }}" {% if log.work_type.code == value %}selected{% endif %}>{{ label }}</option> <option value="{{ value }}" {% if log.work_type_id and log.work_type.code == value %}selected{% endif %}>{{ label }}</option>
{% endfor %} {% endfor %}
</select> </select>
{% endif %}
</td> </td>
<td style="padding:0.5rem 0.75rem; min-width:14rem;"> <td style="padding:0.5rem 0.75rem; min-width:14rem;">
<textarea form="worklog-form-{{ log.pk }}" name="description" rows="2" required <textarea form="worklog-form-{{ log.pk }}" name="description" rows="2"
class="input" style="width:100%; min-width:12rem; font-size:0.8125rem; padding:0.3rem 0.4rem; resize:vertical;" class="input" style="width:100%; min-width:12rem; font-size:0.8125rem; padding:0.3rem 0.4rem; resize:vertical;"
onchange="this.form.requestSubmit()">{{ log.description }}</textarea> onchange="this.form.requestSubmit()">{{ log.description }}</textarea>
</td> </td>
@@ -860,8 +903,27 @@
title="{{ log.ticket.title }}">{{ log.ticket.number }}</button> title="{{ log.ticket.title }}">{{ log.ticket.number }}</button>
{% else %}—{% endif %} {% else %}—{% endif %}
</td> </td>
<td style="padding:0.625rem 1rem; color:var(--color-muted-foreground);">{{ log.work_type.name }}</td> <td style="padding:0.625rem 1rem; color:var(--color-foreground); max-width:16rem;">
<td style="padding:0.625rem 1rem; color:var(--color-foreground); max-width:22rem;">{{ log.description|truncatechars:120 }}</td> {% if log.catalog_work %}
<div>{{ log.catalog_work.name }}</div>
<div style="font-size:0.75rem; color:var(--color-muted-foreground);">{{ log.catalog_work.parent.name|default:"" }}</div>
{% if log.details %}
<div style="font-size:0.75rem; color:var(--color-muted-foreground); margin-top:0.2rem;">
{% 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 %}
</div>
{% endif %}
{% else %}
<span class="badge badge-muted">{% trans "без справочника" %}</span>
<div style="font-size:0.8125rem; color:var(--color-muted-foreground); margin-top:0.125rem;">{% if log.work_type %}{{ log.work_type.name }}{% endif %}</div>
{% endif %}
</td>
<td style="padding:0.625rem 1rem; color:var(--color-foreground); max-width:22rem;">
{% if log.catalog_work %}
{{ log.description|truncatechars:120|default:"—" }}
{% else %}
{{ log.description|truncatechars:120 }}
{% endif %}
</td>
<td style="padding:0.625rem 1rem; color:var(--color-muted-foreground); font-size:0.8125rem;">{{ log.location|default:"—" }}</td> <td style="padding:0.625rem 1rem; color:var(--color-muted-foreground); font-size:0.8125rem;">{{ log.location|default:"—" }}</td>
<td style="padding:0.625rem 1rem; color:var(--color-foreground);">{{ log.hours_spent|default:"—" }}</td> <td style="padding:0.625rem 1rem; color:var(--color-foreground);">{{ log.hours_spent|default:"—" }}</td>
</tr> </tr>