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)
|
@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='Уточнения'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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
@@ -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-редактирование для руководителя."""
|
||||||
|
|||||||
+67
-10
@@ -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,14 +907,16 @@ 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__':
|
else:
|
||||||
worklog_error = 'Выберите вид работ.'
|
details_err = validate_worklog_details(catalog_work, details)
|
||||||
|
if details_err:
|
||||||
|
worklog_error = details_err
|
||||||
else:
|
else:
|
||||||
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()
|
||||||
@@ -915,7 +937,9 @@ def worklog_create_view(request):
|
|||||||
WorkLog.objects.create(
|
WorkLog.objects.create(
|
||||||
date=log_date,
|
date=log_date,
|
||||||
technician=request.user,
|
technician=request.user,
|
||||||
work_type_id=work_type_id,
|
catalog_work=catalog_work,
|
||||||
|
details=details,
|
||||||
|
work_type_id=None,
|
||||||
location_id=location_id,
|
location_id=location_id,
|
||||||
equipment_id=equipment_id,
|
equipment_id=equipment_id,
|
||||||
ticket_id=ticket_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)
|
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
|
||||||
|
|
||||||
|
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:
|
if not description:
|
||||||
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>',
|
||||||
@@ -991,6 +1043,11 @@ 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
|
||||||
|
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.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()
|
||||||
|
|||||||
@@ -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.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,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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 (тіло поста тощо): знімає теги, розкодовує
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user