it_manager: справочник типовых работ в журнале WorkLog.
Категория/работа/required_details вместо свободного work_type; seed Directory, отчёт и тесты. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+24
-27
@@ -611,23 +611,27 @@ class ServiceCallForm(forms.ModelForm):
|
||||
@admin.register(WorkLog)
|
||||
class WorkLogAdmin(ModelAdmin):
|
||||
form = WorkLogForm
|
||||
list_display = ['date', 'technician', 'work_type_display', 'location',
|
||||
list_display = ['date', 'technician', 'catalog_display', 'location',
|
||||
'hours_spent', 'ticket', 'created_at']
|
||||
list_filter = ['work_type', 'technician', 'date']
|
||||
search_fields = ['description', 'result', 'technician__last_name',
|
||||
'technician__first_name']
|
||||
list_select_related = ['technician', 'location', 'ticket', 'equipment', 'work_type']
|
||||
list_filter = ['catalog_work__parent', 'work_type', 'technician', 'date']
|
||||
search_fields = ['description', 'result', 'catalog_work__name',
|
||||
'technician__last_name', 'technician__first_name']
|
||||
list_select_related = [
|
||||
'technician', 'location', 'ticket', 'equipment',
|
||||
'work_type', 'catalog_work', 'catalog_work__parent',
|
||||
]
|
||||
ordering = ['-date', '-created_at']
|
||||
date_hierarchy = 'date'
|
||||
raw_id_fields = ['catalog_work']
|
||||
|
||||
readonly_fields = ['created_at', 'updated_at']
|
||||
|
||||
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'),
|
||||
@@ -638,25 +642,18 @@ class WorkLogAdmin(ModelAdmin):
|
||||
}),
|
||||
)
|
||||
|
||||
WORK_TYPE_COLORS = {
|
||||
'maintenance': '#2563eb',
|
||||
'installation': '#16a34a',
|
||||
'repair': '#ca8a04',
|
||||
'network': '#9333ea',
|
||||
'software': '#0891b2',
|
||||
'backup': '#6b7280',
|
||||
'other': '#6b7280',
|
||||
}
|
||||
|
||||
def work_type_display(self, obj):
|
||||
color = self.WORK_TYPE_COLORS.get(obj.work_type.code, '#6b7280')
|
||||
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 catalog_display(self, obj):
|
||||
if obj.catalog_work_id:
|
||||
cat = obj.catalog_work.parent.name if obj.catalog_work.parent_id else ''
|
||||
label = obj.catalog_work.name
|
||||
if cat:
|
||||
return f'{cat}: {label}'
|
||||
return label
|
||||
if obj.work_type_id:
|
||||
return obj.work_type.name
|
||||
return (obj.description or '—')[:60]
|
||||
catalog_display.short_description = _('Типовая работа')
|
||||
catalog_display.admin_order_field = 'catalog_work__name'
|
||||
def save_model(self, request, obj, form, change):
|
||||
if not change and not obj.technician_id:
|
||||
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='Уточнения'),
|
||||
),
|
||||
]
|
||||
@@ -2,8 +2,6 @@ from django.db import models
|
||||
from django.conf import settings
|
||||
from django.utils import timezone
|
||||
|
||||
from core.models.directories import directory_default
|
||||
|
||||
|
||||
class WorkLog(models.Model):
|
||||
date = models.DateField('Дата', default=timezone.localdate, db_index=True)
|
||||
@@ -16,12 +14,21 @@ class WorkLog(models.Model):
|
||||
null=True, blank=True,
|
||||
related_name='work_logs', verbose_name='Локация',
|
||||
)
|
||||
# Устаревший грубый вид (~7 типов); для новых записей не заполняется.
|
||||
work_type = models.ForeignKey(
|
||||
'core.Directory', on_delete=models.PROTECT,
|
||||
limit_choices_to={'directory_type__code': 'worklog_type', 'is_active': True},
|
||||
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)
|
||||
hours_spent = models.DecimalField('Затрачено часов', max_digits=5, decimal_places=1,
|
||||
null=True, blank=True)
|
||||
@@ -49,7 +56,18 @@ class WorkLog(models.Model):
|
||||
]
|
||||
|
||||
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):
|
||||
|
||||
+139
-7
@@ -1,6 +1,9 @@
|
||||
"""Тести IT-порталу: доступ через групу IT, інвентаризація обладнання
|
||||
(зі списанням історії видачі), черга заявок, журнал робіт."""
|
||||
from datetime import date
|
||||
import json
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.models import Group
|
||||
@@ -9,6 +12,7 @@ from django.test import Client, TestCase
|
||||
from apps.it_manager.models import (
|
||||
Equipment, EquipmentAssignment, EquipmentModel, Ticket, WorkLog,
|
||||
)
|
||||
from apps.it_manager.work_directory import seed_from_json
|
||||
from core.models.directories import Directory
|
||||
from core.test_utils import login_2fa, grant_all_locations
|
||||
|
||||
@@ -58,6 +62,50 @@ def make_equipment(**overrides):
|
||||
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):
|
||||
"""Сторінка й усі мутації доступні лише групі IT (або суперюзеру)."""
|
||||
|
||||
@@ -432,9 +480,30 @@ class ItManagerTicketTests(TestCase):
|
||||
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):
|
||||
|
||||
def setUp(self):
|
||||
seed_mini_work_catalog()
|
||||
self.it_user = make_employee(is_staff=False)
|
||||
self.it_user.groups.add(Group.objects.get(name='IT'))
|
||||
grant_all_locations(self.it_user)
|
||||
@@ -443,27 +512,65 @@ class ItManagerWorkLogTests(TestCase):
|
||||
|
||||
def test_create_worklog(self):
|
||||
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',
|
||||
})
|
||||
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.catalog_work.code, 'helpdesk.test1')
|
||||
self.assertIsNone(log.work_type_id)
|
||||
self.assertEqual(float(log.hours_spent), 2.5)
|
||||
self.assertContains(r, 'Запись о выполненной работе сохранена')
|
||||
|
||||
def test_create_worklog_requires_description(self):
|
||||
def test_create_worklog_requires_catalog_and_details(self):
|
||||
before = WorkLog.objects.count()
|
||||
r = self.client.post('/it-manager/worklog/create/', {
|
||||
'work_type': 'maintenance', 'description': ' ',
|
||||
'description': 'без работы',
|
||||
})
|
||||
self.assertEqual(r.status_code, 200)
|
||||
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):
|
||||
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': 'Тип заявки'})
|
||||
ptype, _ = DirectoryType.objects.get_or_create(code='ticket_priority', defaults={'name': 'Приоритет'})
|
||||
@@ -486,7 +593,7 @@ class ItManagerWorkLogTests(TestCase):
|
||||
description='Каса не відкривається',
|
||||
)
|
||||
r = self.client.post('/it-manager/worklog/create/', {
|
||||
'work_type': 'repair',
|
||||
'catalog_work_id': str(catalog_work_id('helpdesk.test1')),
|
||||
'description': 'Перезапуск касового ПЗ',
|
||||
'ticket_id': ticket.pk,
|
||||
'hours_spent': '1',
|
||||
@@ -497,6 +604,31 @@ class ItManagerWorkLogTests(TestCase):
|
||||
self.assertContains(r, 'Работа привязана к заявке')
|
||||
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):
|
||||
"""Фильтр по сотруднику и inline-редактирование для руководителя."""
|
||||
|
||||
+67
-10
@@ -15,6 +15,13 @@ from apps.it_manager.models import (
|
||||
InternetContract, InternetProvider,
|
||||
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 (
|
||||
MONTHS_RU,
|
||||
user_can_manage_it_monthly_report,
|
||||
@@ -306,7 +313,9 @@ def _it_manager_context(request):
|
||||
'comments__author',
|
||||
models.Prefetch(
|
||||
'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))
|
||||
@@ -330,6 +339,8 @@ def _it_manager_context(request):
|
||||
month_start = timezone.localdate().replace(day=1)
|
||||
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_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_start = None
|
||||
@@ -346,6 +357,7 @@ def _it_manager_context(request):
|
||||
worklogs_qs = (
|
||||
scoped_queryset(request.user, WorkLog.objects.all()).select_related(
|
||||
'technician', 'location', 'equipment', 'ticket', 'work_type',
|
||||
'catalog_work', 'catalog_work__parent',
|
||||
)
|
||||
.prefetch_related(
|
||||
models.Prefetch(
|
||||
@@ -357,11 +369,15 @@ def _it_manager_context(request):
|
||||
)
|
||||
if 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:
|
||||
worklogs_qs = worklogs_qs.filter(
|
||||
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')
|
||||
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:
|
||||
@@ -468,10 +484,13 @@ def _it_manager_context(request):
|
||||
'worklog_types': _directory_choices('worklog_type'),
|
||||
'worklog_technicians': worklog_technicians,
|
||||
'worklog_technician_id': worklog_technician_id,
|
||||
'worklog_category_id': worklog_category_id,
|
||||
'worklog_month_id': worklog_month_id,
|
||||
'worklog_month_choices': worklog_month_choices,
|
||||
'worklog_stats_label': worklog_stats_label,
|
||||
'it_can_edit_worklogs': can_edit_worklogs,
|
||||
'it_work_catalog': it_work_catalog,
|
||||
'worklog_detail_labels': worklog_detail_labels,
|
||||
'month_hours': month_hours,
|
||||
'month_count': month_count,
|
||||
|
||||
@@ -519,6 +538,7 @@ def it_manager_view(request):
|
||||
elif (
|
||||
'worklog_technician' in request.GET
|
||||
or 'worklog_month' in request.GET
|
||||
or 'worklog_category' in request.GET
|
||||
or request.GET.get('worklogs_page')
|
||||
):
|
||||
context['it_active_tab'] = 'worklog'
|
||||
@@ -887,14 +907,16 @@ def worklog_create_view(request):
|
||||
worklog_error = ''
|
||||
worklog_message = ''
|
||||
if request.method == 'POST':
|
||||
catalog_work = get_catalog_work(request.POST.get('catalog_work_id'))
|
||||
description = request.POST.get('description', '').strip()
|
||||
work_type_raw = request.POST.get('work_type', '')
|
||||
work_type_id = _resolve_directory('worklog_type', work_type_raw)
|
||||
details = parse_details_from_post(request.POST, catalog_work)
|
||||
|
||||
if not description:
|
||||
worklog_error = 'Укажите описание работ.'
|
||||
elif not work_type_id or work_type_raw == '__new__':
|
||||
worklog_error = 'Выберите вид работ.'
|
||||
if not catalog_work:
|
||||
worklog_error = 'Выберите типовую работу из справочника.'
|
||||
else:
|
||||
details_err = validate_worklog_details(catalog_work, details)
|
||||
if details_err:
|
||||
worklog_error = details_err
|
||||
else:
|
||||
try:
|
||||
log_date = datetime.strptime(request.POST.get('date', ''), '%Y-%m-%d').date()
|
||||
@@ -915,7 +937,9 @@ def worklog_create_view(request):
|
||||
WorkLog.objects.create(
|
||||
date=log_date,
|
||||
technician=request.user,
|
||||
work_type_id=work_type_id,
|
||||
catalog_work=catalog_work,
|
||||
details=details,
|
||||
work_type_id=None,
|
||||
location_id=location_id,
|
||||
equipment_id=equipment_id,
|
||||
ticket_id=ticket_id,
|
||||
@@ -953,9 +977,37 @@ def worklog_update_view(request, pk):
|
||||
|
||||
log = get_object_or_404(scoped_queryset(request.user, WorkLog.objects.all()), pk=pk)
|
||||
description = request.POST.get('description', '').strip()
|
||||
work_type_raw = request.POST.get('work_type', '')
|
||||
work_type_id = _resolve_directory('worklog_type', work_type_raw)
|
||||
catalog_raw = (request.POST.get('catalog_work_id') or '').strip()
|
||||
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
|
||||
|
||||
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(
|
||||
'<span style="color:var(--color-destructive); font-size:0.75rem;">Укажите типовую работу</span>',
|
||||
status=400,
|
||||
)
|
||||
else:
|
||||
# Историческая запись без справочника
|
||||
if not description:
|
||||
return HttpResponse(
|
||||
'<span style="color:var(--color-destructive); font-size:0.75rem;">Нужно описание</span>',
|
||||
@@ -991,6 +1043,11 @@ def worklog_update_view(request, pk):
|
||||
return HttpResponseForbidden('Укажите доступную локацию.')
|
||||
log.date = log_date
|
||||
log.technician_id = technician_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.result = request.POST.get('result', '').strip()
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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 (тіло поста тощо): знімає теги, розкодовує
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -574,10 +574,13 @@
|
||||
{% 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="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>
|
||||
</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>
|
||||
{% endfor %}
|
||||
</div>
|
||||
@@ -645,6 +648,15 @@
|
||||
{% endfor %}
|
||||
</select>
|
||||
</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 %}
|
||||
<p style="font-size:0.75rem; color:var(--color-muted-foreground); margin:0 0 0.5rem; max-width:18rem;">
|
||||
{% 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>
|
||||
</button>
|
||||
</div>
|
||||
{{ it_work_catalog|json_script:"it-work-catalog-data" }}
|
||||
<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>
|
||||
@@ -688,14 +701,22 @@
|
||||
<input type="date" name="date" id="it-worklog-date" value="{% now 'Y-m-d' %}" class="input" style="width:100%;">
|
||||
</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="work_type" id="it-worklog-type" required class="input" style="width:100%;" onchange="dqaHandleChange(this, 'worklog_type', 'code')">
|
||||
{% for value, label in worklog_types %}
|
||||
<option value="{{ value }}">{{ label }}</option>
|
||||
<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 id="it-worklog-category" class="input" style="width:100%;" onchange="itWorklogCategoryChanged()">
|
||||
<option value="">{% trans "— Выберите категорию —" %}</option>
|
||||
{% for cat in it_work_catalog.categories %}
|
||||
<option value="{{ cat.id }}">{{ cat.name }}</option>
|
||||
{% endfor %}
|
||||
<option value="__new__">{% trans "+ Добавить новый вид работ..." %}</option>
|
||||
</select>
|
||||
</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>
|
||||
<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 'Поиск заявки по номеру или теме...' %}">
|
||||
@@ -737,8 +758,8 @@
|
||||
</select>
|
||||
</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>
|
||||
<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>
|
||||
<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" 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>
|
||||
<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>
|
||||
{% if it_can_edit_worklogs %}
|
||||
@@ -810,17 +831,39 @@
|
||||
title="{{ log.ticket.title }}">{{ log.ticket.number }}</button>
|
||||
{% else %}—{% endif %}
|
||||
</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"
|
||||
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()">
|
||||
{% 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 %}
|
||||
</select>
|
||||
{% endif %}
|
||||
</td>
|
||||
<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;"
|
||||
onchange="this.form.requestSubmit()">{{ log.description }}</textarea>
|
||||
</td>
|
||||
@@ -860,8 +903,27 @@
|
||||
title="{{ log.ticket.title }}">{{ log.ticket.number }}</button>
|
||||
{% else %}—{% endif %}
|
||||
</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:22rem;">{{ log.description|truncatechars:120 }}</td>
|
||||
<td style="padding:0.625rem 1rem; color:var(--color-foreground); max-width:16rem;">
|
||||
{% 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-foreground);">{{ log.hours_spent|default:"—" }}</td>
|
||||
</tr>
|
||||
|
||||
Reference in New Issue
Block a user