it_manager/locations: редактор шаблона этикеток, раздельная печать QR/EAN и карточка поставщика.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-07 16:40:48 +03:00
parent 82ad90c661
commit d54f6fbe42
18 changed files with 1192 additions and 66 deletions
+7 -1
View File
@@ -29,7 +29,12 @@ from core.accounting_portal import accounting_portal_view
from apps.it_manager.billing import internet_payment_update_view
from apps.employees.views_password import password_setup_view
from apps.mattermost_connector.chat_proxy import chat_api_proxy
from apps.locations.views import pharmacy_update_view, vendor_create_view, vendor_update_view
from apps.locations.views import (
pharmacy_update_view,
vendor_create_view,
vendor_detail_view,
vendor_update_view,
)
from apps.visit_manager.views_calendar import (
calendar_event_create_view,
calendar_event_delete_view,
@@ -74,6 +79,7 @@ urlpatterns = [
path('dislocation/columns/', views.dislocation_columns_view, name='dislocation_columns'),
path('dislocation/column-presets/', views.dislocation_column_presets_view, name='dislocation_column_presets'),
path('dislocation/vendors/create/', vendor_create_view, name='dislocation_vendor_create'),
path('dislocation/vendors/<int:pk>/', vendor_detail_view, name='dislocation_vendor_detail'),
path('dislocation/vendors/<int:pk>/update/', vendor_update_view, name='dislocation_vendor_update'),
path('calendar/', views.calendar_view, name='calendar'),
path('calendar/events/', calendar_event_create_view, name='calendar_event_create'),
+130 -6
View File
@@ -11,9 +11,21 @@ from barcode.writer import ImageWriter
from PIL import Image
# Внутренний префикс EAN (2 = локальное использование) + 00 → 200…
_EAN_PREFIX = '200'
TEXT_BLOCK_TYPES = frozenset({'model', 'type', 'inventory', 'serial', 'custom'})
CODE_BLOCK_TYPES = frozenset({'qr', 'ean13'})
def parse_label_codes(raw: str | None) -> str:
"""Нормализация: qr | ean | both."""
value = (raw or 'both').strip().lower()
if value in ('qr', 'ean', 'both'):
return value
if value in ('ean13', 'barcode'):
return 'ean'
return 'both'
def equipment_ean13_body(pk: int) -> str:
"""12 цифр без контрольной: 200 + pk (9 знаков)."""
@@ -25,7 +37,6 @@ def equipment_ean13_body(pk: int) -> str:
def equipment_ean13(pk: int) -> str:
"""Полный EAN-13 (13 цифр) для единицы оборудования."""
EAN13 = get_barcode_class('ean13')
# python-barcode сам считает check digit из 12 цифр
code = EAN13(equipment_ean13_body(pk), writer=ImageWriter())
return code.get_fullcode()
@@ -78,18 +89,131 @@ def ean13_png_base64(pk: int) -> tuple[str, str]:
return _png_b64(img), code.get_fullcode()
def label_payload_for_unit(unit) -> dict[str, Any]:
def get_default_template():
from apps.it_manager.models import EquipmentLabelTemplate
return EquipmentLabelTemplate.get_default()
def _block_visible(block_type: str, codes: str) -> bool:
if block_type in TEXT_BLOCK_TYPES:
return True
if block_type == 'qr':
return codes in ('qr', 'both')
if block_type == 'ean13':
return codes in ('ean', 'both')
return False
def _text_for_block(block_type: str, block: dict, fields: dict[str, str]) -> str:
if block_type == 'custom':
return (block.get('text') or '').strip()
if block_type == 'model':
return fields['model']
if block_type == 'type':
return fields['type_name']
if block_type == 'inventory':
return fields['inventory'] or f"EQ{fields['id']}"
if block_type == 'serial':
serial = fields['serial']
return f'S/N {serial}' if serial else ''
return ''
def label_payload_for_unit(unit, *, codes: str = 'both', template=None) -> dict[str, Any]:
codes = parse_label_codes(codes)
if template is None:
template = get_default_template()
qr_text = equipment_qr_payload(unit)
ean_b64, ean_digits = ean13_png_base64(unit.pk)
need_qr = codes in ('qr', 'both')
need_ean = codes in ('ean', 'both')
qr_png = qr_png_base64(qr_text) if need_qr else ''
ean_b64, ean_digits = ('', '')
if need_ean:
ean_b64, ean_digits = ean13_png_base64(unit.pk)
model = unit.equipment_model
return {
fields = {
'id': unit.pk,
'model': str(model) if model else '—',
'type_name': model.equipment_type.name if model and model.equipment_type_id else '',
'serial': unit.serial_number or '',
'inventory': (unit.inventory_number or '').strip(),
}
blocks_out = []
for raw in (template.layout or []):
if not isinstance(raw, dict):
continue
btype = (raw.get('type') or '').strip()
if not _block_visible(btype, codes):
continue
block = {
'id': raw.get('id') or btype,
'type': btype,
'x_mm': float(raw.get('x_mm') or 0),
'y_mm': float(raw.get('y_mm') or 0),
'w_mm': float(raw.get('w_mm') or 10),
'h_mm': float(raw.get('h_mm') or 10),
'font_pt': float(raw.get('font_pt') or 9),
'bold': bool(raw.get('bold')),
'align': (raw.get('align') or 'left'),
'text': '',
'png': '',
}
if btype == 'qr':
block['png'] = qr_png
block['text'] = qr_text
elif btype == 'ean13':
block['png'] = ean_b64
block['text'] = ean_digits
else:
block['text'] = _text_for_block(btype, raw, fields)
blocks_out.append(block)
return {
'id': unit.pk,
'model': fields['model'],
'type_name': fields['type_name'],
'serial': fields['serial'],
'inventory': fields['inventory'],
'qr_payload': qr_text,
'qr_png': qr_png_base64(qr_text),
'qr_png': qr_png,
'ean13': ean_digits,
'ean_png': ean_b64,
'codes': codes,
'width_mm': float(template.width_mm),
'height_mm': float(template.height_mm),
'blocks': blocks_out,
}
def normalize_layout(raw_blocks) -> list[dict]:
"""Валидация layout из редактора."""
allowed = CODE_BLOCK_TYPES | TEXT_BLOCK_TYPES
out = []
if not isinstance(raw_blocks, list):
return out
for i, raw in enumerate(raw_blocks):
if not isinstance(raw, dict):
continue
btype = (raw.get('type') or '').strip()
if btype not in allowed:
continue
bid = (raw.get('id') or f'{btype}-{i}').strip()[:40]
out.append({
'id': bid,
'type': btype,
'x_mm': max(0.0, float(raw.get('x_mm') or 0)),
'y_mm': max(0.0, float(raw.get('y_mm') or 0)),
'w_mm': max(2.0, float(raw.get('w_mm') or 10)),
'h_mm': max(2.0, float(raw.get('h_mm') or 10)),
'font_pt': max(6.0, min(24.0, float(raw.get('font_pt') or 9))),
'bold': bool(raw.get('bold')),
'align': (raw.get('align') or 'left') if (raw.get('align') or 'left') in (
'left', 'center', 'right',
) else 'left',
'text': (raw.get('text') or '')[:200] if btype == 'custom' else '',
})
return out
@@ -0,0 +1,54 @@
# Generated by Django 6.0.3 on 2026-10-07 13:32
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
def seed_default_template(apps, schema_editor):
from apps.it_manager.models.label_template import DEFAULT_LABEL_LAYOUT
Template = apps.get_model('it_manager', 'EquipmentLabelTemplate')
if Template.objects.filter(code='default').exists():
return
Template.objects.create(
code='default',
name='Этикетка оборудования',
width_mm=70.0,
height_mm=42.0,
layout=list(DEFAULT_LABEL_LAYOUT),
)
def unseed_default_template(apps, schema_editor):
Template = apps.get_model('it_manager', 'EquipmentLabelTemplate')
Template.objects.filter(code='default').delete()
class Migration(migrations.Migration):
dependencies = [
('it_manager', '0022_worklog_catalog_work'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='EquipmentLabelTemplate',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('code', models.SlugField(default='default', max_length=40, unique=True, verbose_name='Код')),
('name', models.CharField(default='Этикетка оборудования', max_length=100, verbose_name='Название')),
('width_mm', models.FloatField(default=70.0, verbose_name='Ширина (мм)')),
('height_mm', models.FloatField(default=42.0, verbose_name='Высота (мм)')),
('layout', models.JSONField(blank=True, default=list, verbose_name='Блоки')),
('updated_at', models.DateTimeField(auto_now=True)),
('updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL, verbose_name='Кто обновил')),
],
options={
'verbose_name': 'Шаблон этикетки',
'verbose_name_plural': 'Шаблоны этикеток',
},
),
migrations.RunPython(seed_default_template, unseed_default_template),
]
+1
View File
@@ -3,3 +3,4 @@ from .tickets import Ticket, TicketComment
from .providers import InternetProvider, InternetContract, InternetContractPayment
from .worklogs import WorkLog, ServiceCall
from .consumables import Consumable
from .label_template import EquipmentLabelTemplate, DEFAULT_LABEL_LAYOUT
+117
View File
@@ -0,0 +1,117 @@
from django.conf import settings
from django.db import models
DEFAULT_LABEL_LAYOUT = [
{
'id': 'qr',
'type': 'qr',
'x_mm': 2.0,
'y_mm': 2.0,
'w_mm': 22.0,
'h_mm': 22.0,
'font_pt': 9,
'bold': False,
'align': 'left',
'text': '',
},
{
'id': 'model',
'type': 'model',
'x_mm': 26.0,
'y_mm': 2.0,
'w_mm': 40.0,
'h_mm': 8.0,
'font_pt': 10,
'bold': True,
'align': 'left',
'text': '',
},
{
'id': 'type',
'type': 'type',
'x_mm': 26.0,
'y_mm': 10.0,
'w_mm': 40.0,
'h_mm': 5.0,
'font_pt': 8,
'bold': False,
'align': 'left',
'text': '',
},
{
'id': 'inventory',
'type': 'inventory',
'x_mm': 26.0,
'y_mm': 15.5,
'w_mm': 40.0,
'h_mm': 5.0,
'font_pt': 10,
'bold': False,
'align': 'left',
'text': '',
},
{
'id': 'serial',
'type': 'serial',
'x_mm': 26.0,
'y_mm': 21.0,
'w_mm': 40.0,
'h_mm': 4.0,
'font_pt': 8,
'bold': False,
'align': 'left',
'text': '',
},
{
'id': 'ean',
'type': 'ean13',
'x_mm': 2.0,
'y_mm': 26.0,
'w_mm': 64.0,
'h_mm': 14.0,
'font_pt': 8,
'bold': False,
'align': 'center',
'text': '',
},
]
class EquipmentLabelTemplate(models.Model):
"""Общий шаблон инвентарной этикетки (singleton code=default)."""
code = models.SlugField('Код', max_length=40, unique=True, default='default')
name = models.CharField('Название', max_length=100, default='Этикетка оборудования')
width_mm = models.FloatField('Ширина (мм)', default=70.0)
height_mm = models.FloatField('Высота (мм)', default=42.0)
layout = models.JSONField('Блоки', default=list, blank=True)
updated_at = models.DateTimeField(auto_now=True)
updated_by = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.SET_NULL,
null=True, blank=True, related_name='+',
verbose_name='Кто обновил',
)
class Meta:
verbose_name = 'Шаблон этикетки'
verbose_name_plural = 'Шаблоны этикеток'
def __str__(self):
return self.name
@classmethod
def get_default(cls):
obj, created = cls.objects.get_or_create(
code='default',
defaults={
'name': 'Этикетка оборудования',
'width_mm': 70.0,
'height_mm': 42.0,
'layout': list(DEFAULT_LABEL_LAYOUT),
},
)
if not created and not obj.layout:
obj.layout = list(DEFAULT_LABEL_LAYOUT)
obj.save(update_fields=['layout'])
return obj
+58 -1
View File
@@ -436,17 +436,74 @@ class ItManagerEquipmentTests(TestCase):
self.assertEqual(len(ean), 13)
self.assertTrue(ean.startswith('200'))
r = self.client.get(f'/it-manager/equipment/labels/?ids={eq.pk}')
r = self.client.get(f'/it-manager/equipment/labels/?ids={eq.pk}&codes=both')
self.assertEqual(r.status_code, 200)
self.assertContains(r, 'INV-LBL-1')
self.assertContains(r, 'data:image/png;base64,')
self.assertContains(r, ean)
r_qr = self.client.get(f'/it-manager/equipment/labels/?ids={eq.pk}&codes=qr')
self.assertEqual(r_qr.status_code, 200)
self.assertContains(r_qr, 'INV-LBL-1')
self.assertNotContains(r_qr, f'alt="{ean}"')
r_ean = self.client.get(f'/it-manager/equipment/labels/?ids={eq.pk}&codes=ean')
self.assertEqual(r_ean.status_code, 200)
self.assertContains(r_ean, ean)
self.assertNotContains(r_ean, f'alt="INV-LBL-1"')
r2 = self.client.get('/it-manager/', {'tab': 'equipment', 'q': ean})
self.assertEqual(r2.status_code, 200)
serials = [e.serial_number for e in r2.context['equipment_page']]
self.assertIn('LBL-SN-1', serials)
def test_label_template_editor(self):
import json
from apps.it_manager.models import EquipmentLabelTemplate
r = self.client.get('/it-manager/equipment/labels/template/')
self.assertEqual(r.status_code, 200)
self.assertContains(r, 'Шаблон этикетки')
tmpl = EquipmentLabelTemplate.get_default()
payload = {
'width_mm': 80,
'height_mm': 50,
'layout': [
{
'id': 'qr1', 'type': 'qr',
'x_mm': 3, 'y_mm': 3, 'w_mm': 20, 'h_mm': 20,
'font_pt': 9, 'bold': False, 'align': 'left', 'text': '',
},
{
'id': 'm1', 'type': 'model',
'x_mm': 26, 'y_mm': 3, 'w_mm': 40, 'h_mm': 8,
'font_pt': 10, 'bold': True, 'align': 'left', 'text': '',
},
],
}
r_save = self.client.post(
'/it-manager/equipment/labels/template/',
data=json.dumps(payload),
content_type='application/json',
)
self.assertEqual(r_save.status_code, 200)
data = r_save.json()
self.assertTrue(data.get('ok'))
tmpl.refresh_from_db()
self.assertEqual(tmpl.width_mm, 80)
self.assertEqual(len(tmpl.layout), 2)
outsider = make_employee(username='no-it-labels')
client2 = Client()
login_2fa(client2, outsider)
forbidden = client2.get('/it-manager/equipment/labels/template/')
self.assertIn(forbidden.status_code, (403, 302, 200))
if forbidden.status_code == 200:
# deny_section_access может вернуть dashboard-фрагмент
self.assertNotContains(forbidden, 'label-canvas')
def test_assignment_history_visible_in_card(self):
equipment = make_equipment(serial_number='HIST-UI-1')
emp = make_employee(first_name='Иван', last_name='Историков')
+5
View File
@@ -31,6 +31,11 @@ urlpatterns = [
path('it-manager/equipment/bulk-create/', views.equipment_bulk_create_view, name='it_equipment_bulk_create'),
path('it-manager/equipment/bulk-assign/', views.equipment_bulk_assign_view, name='it_equipment_bulk_assign'),
path('it-manager/equipment/labels/', views.equipment_labels_view, name='it_equipment_labels'),
path(
'it-manager/equipment/labels/template/',
views.equipment_label_template_view,
name='it_equipment_label_template',
),
path('it-manager/equipment/<int:pk>/update/', views.equipment_update_view, name='it_equipment_update'),
path('it-manager/tickets/<int:pk>/update/', views.ticket_update_view, name='it_ticket_update'),
path('it-manager/tickets/<int:pk>/comment/', views.ticket_comment_view, name='it_ticket_comment'),
+60 -5
View File
@@ -752,27 +752,30 @@ def equipment_bulk_create_view(request):
@login_required
def equipment_labels_view(request):
"""Печатная страница этикеток QR + EAN-13 для выбранного оборудования."""
"""Печатная страница этикеток по шаблону; codes=qr|ean|both."""
from django.shortcuts import render
from apps.it_manager.equipment_labels import label_payload_for_unit
from apps.it_manager.equipment_labels import (
get_default_template, label_payload_for_unit, parse_label_codes,
)
if not _is_it_staff(request.user):
return deny_section_access(request)
codes = parse_label_codes(request.GET.get('codes'))
raw_ids = request.GET.getlist('ids') or request.GET.get('ids', '').split(',')
ids = []
for part in raw_ids:
part = (part or '').strip()
if part.isdigit():
ids.append(int(part))
# порядок как в запросе, без дублей
seen, ordered = set(), []
for pk in ids:
if pk not in seen:
seen.add(pk)
ordered.append(pk)
template = get_default_template()
units_by_id = {
u.pk: u
for u in scoped_queryset(request.user, Equipment.objects.all())
@@ -785,9 +788,61 @@ def equipment_labels_view(request):
for pk in ordered:
unit = units_by_id.get(pk)
if unit is not None:
labels.append(label_payload_for_unit(unit))
labels.append(label_payload_for_unit(unit, codes=codes, template=template))
return render(request, 'pages/it-equipment-labels.html', {'labels': labels})
return render(request, 'pages/it-equipment-labels.html', {
'labels': labels,
'codes': codes,
'template_width_mm': template.width_mm,
'template_height_mm': template.height_mm,
})
@login_required
def equipment_label_template_view(request):
"""Редактор общего шаблона этикетки (GET страница / POST JSON)."""
import json
from django.http import JsonResponse
from django.shortcuts import render
from apps.it_manager.equipment_labels import get_default_template, normalize_layout
if not _is_it_staff(request.user):
return deny_section_access(request)
template = get_default_template()
if request.method == 'POST':
try:
payload = json.loads(request.body.decode('utf-8') or '{}')
except (json.JSONDecodeError, UnicodeDecodeError):
return JsonResponse({'ok': False, 'error': 'Некорректный JSON'}, status=400)
try:
width = float(payload.get('width_mm') or template.width_mm)
height = float(payload.get('height_mm') or template.height_mm)
except (TypeError, ValueError):
return JsonResponse({'ok': False, 'error': 'Некорректный размер'}, status=400)
if width < 20 or width > 200 or height < 15 or height > 200:
return JsonResponse({'ok': False, 'error': 'Размер вне диапазона 20–200 × 15–200 мм'}, status=400)
layout = normalize_layout(payload.get('layout'))
if not layout:
return JsonResponse({'ok': False, 'error': 'Добавьте хотя бы один блок'}, status=400)
template.width_mm = width
template.height_mm = height
template.layout = layout
template.updated_by = request.user
template.save()
return JsonResponse({'ok': True, 'updated_at': template.updated_at.isoformat()})
return render(request, 'pages/it-equipment-label-template.html', {
'template': template,
'template_json': json.dumps({
'width_mm': template.width_mm,
'height_mm': template.height_mm,
'layout': template.layout or [],
}, ensure_ascii=False),
})
@login_required
+6 -2
View File
@@ -118,7 +118,7 @@ class PortalVendorForm(forms.ModelForm):
fields = (
'name', 'location_region',
'street', 'house_number', 'building', 'apartment',
'comments', 'is_active',
'location', 'comments', 'is_active',
)
widgets = {
'name': forms.TextInput(attrs={
@@ -130,6 +130,10 @@ class PortalVendorForm(forms.ModelForm):
'house_number': forms.TextInput(attrs={'class': 'input'}),
'building': forms.TextInput(attrs={'class': 'input'}),
'apartment': forms.TextInput(attrs={'class': 'input'}),
'location': forms.TextInput(attrs={
'class': 'input',
'placeholder': '46.4825,30.7233',
}),
'comments': forms.Textarea(attrs={'class': 'input', 'rows': 2}),
'is_active': forms.CheckboxInput(),
}
@@ -138,7 +142,7 @@ class PortalVendorForm(forms.ModelForm):
super().__init__(*args, **kwargs)
self.fields['name'].required = True
self.fields['location_region'].widget.attrs['class'] = 'input'
for optional in ('street', 'house_number', 'building', 'apartment', 'comments'):
for optional in ('street', 'house_number', 'building', 'apartment', 'comments', 'location'):
self.fields[optional].required = False
def clean_name(self):
+31
View File
@@ -802,3 +802,34 @@ class DislocationVendorsTabTests(TestCase):
self.vendor.refresh_from_db()
self.assertEqual(self.vendor.name, 'ТестСервис Обновлён')
self.assertEqual(self.vendor.comments, 'ремонт техники')
def test_vendor_detail_card_and_map(self):
from django.test import Client
from core.test_utils import login_2fa
client = Client()
login_2fa(client, self.viewer)
r = client.get(f'/dislocation/vendors/{self.vendor.pk}/', HTTP_HX_REQUEST='true')
self.assertEqual(r.status_code, 200)
self.assertContains(r, 'ТестСервис')
self.assertContains(r, 'Координаты не указаны')
login_2fa(client, self.it_user)
r2 = client.post(f'/dislocation/vendors/{self.vendor.pk}/update/', {
'name': 'ТестСервис',
'location_region': str(self.region.pk),
'location': '46.482500,30.723300',
'is_active': 'on',
'from_card': '1',
}, HTTP_HX_REQUEST='true')
self.assertEqual(r2.status_code, 200)
self.vendor.refresh_from_db()
# Дефолт поля location — как раз Одесса; has_coordinates False для дефолта.
# Ставим недефолтные координаты.
self.vendor.location = '46.500000,30.750000'
self.vendor.save(update_fields=['location', 'address'])
r3 = client.get(f'/dislocation/vendors/{self.vendor.pk}/', HTTP_HX_REQUEST='true')
self.assertEqual(r3.status_code, 200)
self.assertContains(r3, 'vendor-map')
self.assertContains(r3, '46')
self.assertContains(r3, '30,75000')
+42 -1
View File
@@ -165,6 +165,34 @@ def pharmacy_update_view(request, pk):
return render_page(request, 'pharmacy-detail.html', context)
def _vendor_detail_context(request, vendor, *, form=None, edit_mode=False,
form_message=None, form_error=None):
can_edit = user_can_manage_vendors(request.user)
if edit_mode and can_edit and form is None:
form = PortalVendorForm(instance=vendor)
return {
'vendor': vendor,
'can_edit_vendor': can_edit,
'vendor_edit_mode': bool(edit_mode and can_edit),
'vendor_edit_form': form if (edit_mode and can_edit) else None,
'vendor_form_message': form_message,
'vendor_form_error': form_error,
}
@login_required
def vendor_detail_view(request, pk):
vendor = get_object_or_404(
Location.objects.select_related('location_region'),
pk=pk, location_type='vendor',
)
edit_mode = request.GET.get('edit') == '1'
if edit_mode and not user_can_manage_vendors(request.user):
return HttpResponseForbidden()
context = _vendor_detail_context(request, vendor, edit_mode=edit_mode)
return render_page(request, 'vendor-detail.html', context)
@login_required
@require_POST
def vendor_create_view(request):
@@ -188,9 +216,22 @@ def vendor_update_view(request, pk):
vendor = get_object_or_404(Location, pk=pk, location_type='vendor')
form = PortalVendorForm(request.POST, instance=vendor)
# Если пришли с карточки — остаёмся на ней
from_card = request.POST.get('from_card') == '1'
if form.is_valid():
form.save()
vendor = form.save()
if from_card:
context = _vendor_detail_context(
request, vendor, form_message='Изменения сохранены.',
)
return render_page(request, 'vendor-detail.html', context)
return _dislocation_vendors_response(request, message='Изменения сохранены.')
if from_card:
context = _vendor_detail_context(
request, vendor, form=form, edit_mode=True,
form_error=_vendor_form_errors(form),
)
return render_page(request, 'vendor-detail.html', context)
return _dislocation_vendors_response(
request, error=_vendor_form_errors(form), form=form,
)
+4 -2
View File
@@ -151,11 +151,13 @@ function itBulkClearSelection() {
itBulkUpdate();
}
function itEquipmentOpenLabels() {
function itEquipmentOpenLabels(codes) {
var checked = Array.prototype.slice.call(document.querySelectorAll('.it-equip-check:checked'));
if (!checked.length) return;
var ids = checked.map(function (cb) { return cb.value; });
var url = '/it-manager/equipment/labels/?ids=' + encodeURIComponent(ids.join(','));
var mode = codes || 'both';
var url = '/it-manager/equipment/labels/?ids=' + encodeURIComponent(ids.join(',')) +
'&codes=' + encodeURIComponent(mode);
window.open(url, '_blank', 'noopener');
}
+290
View File
@@ -0,0 +1,290 @@
(function () {
'use strict';
var TYPE_LABELS = {
qr: 'QR',
ean13: 'EAN-13',
model: 'Модель',
type: 'Тип',
inventory: 'Инв. №',
serial: 'S/N',
custom: 'Текст',
};
var DEMO = {
model: 'Mikrotik RB952',
type: 'Роутер',
inventory: 'INV-DEMO-1',
serial: 'S/N ABC123',
custom: '',
qr: 'QR',
ean13: 'EAN-13',
};
var dataEl = document.getElementById('label-template-data');
var state = { width_mm: 70, height_mm: 42, layout: [] };
try {
state = JSON.parse(dataEl.textContent || '{}');
} catch (e) { /* keep defaults */ }
if (!Array.isArray(state.layout)) state.layout = [];
var canvas = document.getElementById('label-canvas');
var scaleInput = document.getElementById('tmpl-scale');
var widthInput = document.getElementById('tmpl-width');
var heightInput = document.getElementById('tmpl-height');
var statusEl = document.getElementById('status');
var selectedId = null;
var drag = null;
var uid = 1;
state.layout.forEach(function (b) {
if (b && b.id) {
var n = parseInt(String(b.id).replace(/\D/g, ''), 10);
if (n >= uid) uid = n + 1;
}
});
function scale() {
return parseFloat(scaleInput.value) || 4;
}
function setStatus(msg, isErr) {
statusEl.textContent = msg || '';
statusEl.className = isErr ? 'err' : '';
}
function selectedBlock() {
return state.layout.find(function (b) { return b.id === selectedId; }) || null;
}
function previewText(block) {
if (block.type === 'custom') return block.text || 'Текст';
return DEMO[block.type] || block.type;
}
function render() {
var s = scale();
canvas.style.width = (state.width_mm * s) + 'px';
canvas.style.height = (state.height_mm * s) + 'px';
canvas.innerHTML = '';
state.layout.forEach(function (block) {
var el = document.createElement('div');
el.className = 'lbl-block' + (block.id === selectedId ? ' selected' : '');
el.dataset.id = block.id;
el.style.left = (block.x_mm * s) + 'px';
el.style.top = (block.y_mm * s) + 'px';
el.style.width = (block.w_mm * s) + 'px';
el.style.height = (block.h_mm * s) + 'px';
el.style.fontSize = Math.max(8, block.font_pt * (s / 3.5)) + 'px';
el.style.fontWeight = block.bold ? '600' : '400';
el.style.textAlign = block.align || 'left';
el.innerHTML = '<div class="kind">' + (TYPE_LABELS[block.type] || block.type) + '</div>' +
'<div>' + escapeHtml(previewText(block)) + '</div>' +
'<div class="resize" data-resize="1"></div>';
canvas.appendChild(el);
});
syncProps();
}
function escapeHtml(str) {
return String(str || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
function syncProps() {
var block = selectedBlock();
var empty = document.getElementById('props-empty');
var fields = document.getElementById('props-fields');
if (!block) {
empty.style.display = '';
fields.style.display = 'none';
return;
}
empty.style.display = 'none';
fields.style.display = '';
document.getElementById('prop-type-label').textContent = TYPE_LABELS[block.type] || block.type;
document.getElementById('prop-x').value = block.x_mm;
document.getElementById('prop-y').value = block.y_mm;
document.getElementById('prop-w').value = block.w_mm;
document.getElementById('prop-h').value = block.h_mm;
document.getElementById('prop-font').value = block.font_pt;
document.getElementById('prop-align').value = block.align || 'left';
document.getElementById('prop-bold').checked = !!block.bold;
var customWrap = document.getElementById('prop-custom-wrap');
customWrap.style.display = block.type === 'custom' ? '' : 'none';
document.getElementById('prop-text').value = block.text || '';
}
function clampBlock(block) {
block.w_mm = Math.max(2, Number(block.w_mm) || 10);
block.h_mm = Math.max(2, Number(block.h_mm) || 10);
block.x_mm = Math.max(0, Math.min(state.width_mm - 2, Number(block.x_mm) || 0));
block.y_mm = Math.max(0, Math.min(state.height_mm - 2, Number(block.y_mm) || 0));
if (block.x_mm + block.w_mm > state.width_mm) block.w_mm = Math.max(2, state.width_mm - block.x_mm);
if (block.y_mm + block.h_mm > state.height_mm) block.h_mm = Math.max(2, state.height_mm - block.y_mm);
}
function addBlock(type) {
var defaults = {
qr: { w_mm: 22, h_mm: 22 },
ean13: { w_mm: 50, h_mm: 14 },
model: { w_mm: 35, h_mm: 6, bold: true, font_pt: 10 },
type: { w_mm: 35, h_mm: 5, font_pt: 8 },
inventory: { w_mm: 35, h_mm: 5, font_pt: 10 },
serial: { w_mm: 35, h_mm: 4, font_pt: 8 },
custom: { w_mm: 30, h_mm: 5, text: 'Текст' },
};
var d = defaults[type] || { w_mm: 20, h_mm: 6 };
var block = {
id: type + '-' + (uid++),
type: type,
x_mm: 2,
y_mm: 2,
w_mm: d.w_mm,
h_mm: d.h_mm,
font_pt: d.font_pt || 9,
bold: !!d.bold,
align: 'left',
text: d.text || '',
};
clampBlock(block);
state.layout.push(block);
selectedId = block.id;
render();
}
canvas.addEventListener('pointerdown', function (ev) {
var target = ev.target.closest('.lbl-block');
if (!target) {
selectedId = null;
render();
return;
}
selectedId = target.dataset.id;
var block = selectedBlock();
if (!block) return;
var s = scale();
var isResize = !!ev.target.closest('[data-resize]');
drag = {
id: block.id,
resize: isResize,
startX: ev.clientX,
startY: ev.clientY,
origX: block.x_mm,
origY: block.y_mm,
origW: block.w_mm,
origH: block.h_mm,
scale: s,
pointerId: ev.pointerId,
};
canvas.setPointerCapture(ev.pointerId);
render();
ev.preventDefault();
});
canvas.addEventListener('pointermove', function (ev) {
if (!drag) return;
var block = state.layout.find(function (b) { return b.id === drag.id; });
if (!block) return;
var dx = (ev.clientX - drag.startX) / drag.scale;
var dy = (ev.clientY - drag.startY) / drag.scale;
if (drag.resize) {
block.w_mm = drag.origW + dx;
block.h_mm = drag.origH + dy;
} else {
block.x_mm = drag.origX + dx;
block.y_mm = drag.origY + dy;
}
clampBlock(block);
render();
});
function endDrag() {
drag = null;
syncProps();
}
canvas.addEventListener('pointerup', endDrag);
canvas.addEventListener('pointercancel', endDrag);
document.querySelectorAll('.palette [data-add]').forEach(function (btn) {
btn.addEventListener('click', function () {
addBlock(btn.getAttribute('data-add'));
});
});
function bindProp(id, apply) {
var el = document.getElementById(id);
el.addEventListener('change', function () {
var block = selectedBlock();
if (!block) return;
apply(block, el);
clampBlock(block);
render();
});
el.addEventListener('input', function () {
var block = selectedBlock();
if (!block) return;
apply(block, el);
clampBlock(block);
render();
});
}
bindProp('prop-x', function (b, el) { b.x_mm = parseFloat(el.value) || 0; });
bindProp('prop-y', function (b, el) { b.y_mm = parseFloat(el.value) || 0; });
bindProp('prop-w', function (b, el) { b.w_mm = parseFloat(el.value) || 2; });
bindProp('prop-h', function (b, el) { b.h_mm = parseFloat(el.value) || 2; });
bindProp('prop-font', function (b, el) { b.font_pt = parseFloat(el.value) || 9; });
bindProp('prop-align', function (b, el) { b.align = el.value; });
bindProp('prop-bold', function (b, el) { b.bold = el.checked; });
bindProp('prop-text', function (b, el) { b.text = el.value; });
document.getElementById('prop-delete').addEventListener('click', function () {
if (!selectedId) return;
state.layout = state.layout.filter(function (b) { return b.id !== selectedId; });
selectedId = null;
render();
});
function resizeFromInputs() {
state.width_mm = Math.max(20, Math.min(200, parseFloat(widthInput.value) || 70));
state.height_mm = Math.max(15, Math.min(200, parseFloat(heightInput.value) || 42));
widthInput.value = state.width_mm;
heightInput.value = state.height_mm;
state.layout.forEach(clampBlock);
render();
}
widthInput.addEventListener('change', resizeFromInputs);
heightInput.addEventListener('change', resizeFromInputs);
scaleInput.addEventListener('change', render);
document.getElementById('btn-save').addEventListener('click', function () {
var csrf = document.querySelector('meta[name="csrf-token"]');
var payload = {
width_mm: state.width_mm,
height_mm: state.height_mm,
layout: state.layout,
};
setStatus('Сохранение…');
fetch('/it-manager/equipment/labels/template/', {
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrf ? csrf.content : '',
},
body: JSON.stringify(payload),
}).then(function (r) {
return r.json().then(function (data) {
if (!r.ok || !data.ok) throw new Error((data && data.error) || 'Ошибка сохранения');
setStatus('Сохранено');
});
}).catch(function (err) {
setStatus(err.message || 'Ошибка', true);
});
});
render();
})();
+16 -6
View File
@@ -464,9 +464,13 @@
</thead>
<tbody>
{% for vendor in vendors %}
<tr class="tr-hover dislo-vendor-row" style="border-bottom:1px solid var(--color-border);"
<tr class="tr-hover dislo-vendor-row" style="border-bottom:1px solid var(--color-border); cursor:pointer;"
data-name="{{ vendor.name|default:''|escape }}"
data-status="{% if vendor.is_active %}active{% else %}inactive{% endif %}">
data-status="{% if vendor.is_active %}active{% else %}inactive{% endif %}"
hx-get="/dislocation/vendors/{{ vendor.pk }}/"
hx-target="#main-content"
hx-push-url="/dislocation/vendors/{{ vendor.pk }}/"
hx-indicator="#loader">
<td style="padding:0.625rem 1rem; color:var(--color-card-foreground); font-weight:500;">{{ vendor.name|default:"—" }}</td>
<td style="padding:0.625rem 1rem; color:var(--color-muted-foreground);">{{ vendor.location_region.name|default:"—" }}</td>
<td style="padding:0.625rem 1rem; color:var(--color-foreground);">
@@ -485,15 +489,16 @@
{% endif %}
</td>
{% if can_manage_vendors %}
<td style="padding:0.625rem 1rem; text-align:right;">
<td style="padding:0.625rem 1rem; text-align:right;" onclick="event.stopPropagation();">
<button type="button" class="btn btn-ghost" style="font-size:0.75rem; padding:0.25rem 0.5rem;"
onclick="disloVendorEdit({{ vendor.pk }})"
onclick="event.stopPropagation(); disloVendorEdit({{ vendor.pk }})"
data-name="{{ vendor.name|escapejs }}"
data-region="{{ vendor.location_region_id|default:'' }}"
data-street="{{ vendor.street|escapejs }}"
data-house="{{ vendor.house_number|escapejs }}"
data-building="{{ vendor.building|escapejs }}"
data-apartment="{{ vendor.apartment|escapejs }}"
data-location="{{ vendor.location|escapejs }}"
data-comments="{{ vendor.comments|escapejs }}"
data-active="{% if vendor.is_active %}1{% else %}0{% endif %}">{% trans "Изменить" %}</button>
</td>
@@ -551,6 +556,10 @@
{{ vendor_form.apartment }}
</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="font-weight:400; color:var(--color-muted-foreground);">lat,lng</span></label>
{{ vendor_form.location }}
</div>
<div>
<label style="display:block; font-size:0.8125rem; font-weight:500; color:var(--color-foreground); margin-bottom:0.375rem;">{% trans "Комментарий" %}</label>
{{ vendor_form.comments }}
@@ -652,7 +661,7 @@ function disloVendorPanelOpen() {
if (nameEl) nameEl.value = '';
const regionEl = form.querySelector('[name="location_region"]');
if (regionEl) regionEl.value = '';
['street', 'house_number', 'building', 'apartment', 'comments'].forEach(function (n) {
['street', 'house_number', 'building', 'apartment', 'location', 'comments'].forEach(function (n) {
const el = form.querySelector('[name="' + n + '"]');
if (el) el.value = '';
});
@@ -672,7 +681,7 @@ function disloVendorPanelClose() {
function disloVendorEdit(pk) {
const form = document.getElementById('dislo-vendor-form');
if (!form) return;
const btn = document.querySelector('button[onclick="disloVendorEdit(' + pk + ')"]');
const btn = document.querySelector('button[onclick*="disloVendorEdit(' + pk + ')"]');
if (!btn) return;
form.setAttribute('hx-post', '/dislocation/vendors/' + pk + '/update/');
if (window.htmx) htmx.process(form);
@@ -688,6 +697,7 @@ function disloVendorEdit(pk) {
set('house_number', btn.dataset.house);
set('building', btn.dataset.building);
set('apartment', btn.dataset.apartment);
set('location', btn.dataset.location);
set('comments', btn.dataset.comments);
const activeEl = form.querySelector('[name="is_active"]');
if (activeEl) activeEl.checked = btn.dataset.active === '1';
@@ -0,0 +1,163 @@
{% load i18n static %}
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<title>{% trans "Шаблон этикетки" %} — ACORE</title>
<meta name="csrf-token" content="{{ csrf_token }}">
<style>
* { box-sizing: border-box; }
body {
margin: 0;
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
background: #f4f4f5;
color: #111;
}
.topbar {
display: flex; align-items: center; gap: 0.75rem; flex-wrap: wrap;
padding: 0.75rem 1rem; background: #fff; border-bottom: 1px solid #ddd;
}
.topbar h1 { font-size: 1rem; font-weight: 600; margin: 0; margin-right: auto; }
.topbar button, .topbar a {
font-size: 0.8125rem; padding: 0.4rem 0.85rem;
border-radius: 0.375rem; border: 1px solid #ccc;
background: #fff; cursor: pointer; text-decoration: none; color: #111;
}
.topbar .primary { background: #2563eb; color: #fff; border-color: #2563eb; }
.topbar .muted { color: #666; font-size: 0.75rem; }
.layout {
display: grid;
grid-template-columns: 12rem 1fr 16rem;
gap: 1rem;
padding: 1rem;
min-height: calc(100vh - 3.5rem);
}
.panel {
background: #fff; border: 1px solid #ddd; border-radius: 0.5rem; padding: 0.75rem;
}
.panel h2 { font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.04em; color: #666; margin: 0 0 0.5rem; }
.palette button {
display: block; width: 100%; text-align: left;
margin-bottom: 0.35rem; padding: 0.4rem 0.55rem;
border: 1px solid #ddd; border-radius: 0.35rem; background: #fafafa; cursor: pointer; font-size: 0.8125rem;
}
.palette button:hover { background: #eef2ff; border-color: #93c5fd; }
.canvas-wrap {
display: flex; align-items: flex-start; justify-content: center;
padding: 1.5rem; overflow: auto; background: #e5e7eb; border-radius: 0.5rem; border: 1px solid #ddd;
min-height: 28rem;
}
#label-canvas {
position: relative;
background: #fff;
border: 1px dashed #94a3b8;
box-shadow: 0 8px 24px rgba(0,0,0,0.08);
touch-action: none;
}
.lbl-block {
position: absolute;
border: 1px solid #93c5fd;
background: rgba(219, 234, 254, 0.55);
overflow: hidden;
cursor: grab;
user-select: none;
font-size: 10px;
line-height: 1.15;
padding: 1px 2px;
}
.lbl-block.selected { border-color: #2563eb; box-shadow: 0 0 0 2px rgba(37,99,235,0.25); z-index: 5; }
.lbl-block .kind {
font-size: 9px; color: #1e40af; font-weight: 600; text-transform: uppercase;
}
.lbl-block .resize {
position: absolute; right: 0; bottom: 0; width: 10px; height: 10px;
background: #2563eb; cursor: nwse-resize;
}
.props label { display: block; font-size: 0.75rem; color: #555; margin: 0.5rem 0 0.2rem; }
.props input, .props select {
width: 100%; padding: 0.35rem 0.45rem; border: 1px solid #ccc; border-radius: 0.3rem; font-size: 0.8125rem;
}
.props .row { display: grid; grid-template-columns: 1fr 1fr; gap: 0.4rem; }
.props .check { display: flex; align-items: center; gap: 0.4rem; margin-top: 0.6rem; font-size: 0.8125rem; }
.props .danger { margin-top: 0.75rem; width: 100%; color: #b91c1c; border-color: #fca5a5; }
#status { font-size: 0.75rem; color: #16a34a; min-height: 1rem; }
#status.err { color: #b91c1c; }
@media (max-width: 960px) {
.layout { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<div class="topbar">
<h1>{% trans "Шаблон этикетки оборудования" %}</h1>
<span class="muted" id="status"></span>
<a href="/it-manager/?tab=equipment">{% trans "К оборудованию" %}</a>
<button type="button" class="primary" id="btn-save">{% trans "Сохранить" %}</button>
</div>
<div class="layout">
<div class="panel palette">
<h2>{% trans "Блоки" %}</h2>
<button type="button" data-add="qr">QR</button>
<button type="button" data-add="ean13">EAN-13</button>
<button type="button" data-add="model">{% trans "Модель" %}</button>
<button type="button" data-add="type">{% trans "Тип" %}</button>
<button type="button" data-add="inventory">{% trans "Инв. №" %}</button>
<button type="button" data-add="serial">{% trans "S/N" %}</button>
<button type="button" data-add="custom">{% trans "Свой текст" %}</button>
<h2 style="margin-top:1rem;">{% trans "Размер этикетки" %}</h2>
<label style="font-size:0.75rem; color:#555;">{% trans "Ширина, мм" %}</label>
<input type="number" id="tmpl-width" min="20" max="200" step="0.5" value="{{ template.width_mm }}">
<label style="font-size:0.75rem; color:#555; margin-top:0.4rem; display:block;">{% trans "Высота, мм" %}</label>
<input type="number" id="tmpl-height" min="15" max="200" step="0.5" value="{{ template.height_mm }}">
<label style="font-size:0.75rem; color:#555; margin-top:0.4rem; display:block;">{% trans "Масштаб, px/мм" %}</label>
<input type="number" id="tmpl-scale" min="2" max="12" step="0.5" value="4">
</div>
<div class="canvas-wrap">
<div id="label-canvas"></div>
</div>
<div class="panel props" id="props-panel">
<h2>{% trans "Свойства" %}</h2>
<div id="props-empty" style="font-size:0.8125rem; color:#888;">{% trans "Выберите блок на макете" %}</div>
<div id="props-fields" style="display:none;">
<div style="font-size:0.8125rem; font-weight:600;" id="prop-type-label"></div>
<div class="row">
<div>
<label>X мм</label>
<input type="number" id="prop-x" step="0.5">
</div>
<div>
<label>Y мм</label>
<input type="number" id="prop-y" step="0.5">
</div>
</div>
<div class="row">
<div>
<label>W мм</label>
<input type="number" id="prop-w" step="0.5" min="2">
</div>
<div>
<label>H мм</label>
<input type="number" id="prop-h" step="0.5" min="2">
</div>
</div>
<label>{% trans "Шрифт, pt" %}</label>
<input type="number" id="prop-font" step="0.5" min="6" max="24">
<label>{% trans "Выравнивание" %}</label>
<select id="prop-align">
<option value="left">{% trans "Слева" %}</option>
<option value="center">{% trans "По центру" %}</option>
<option value="right">{% trans "Справа" %}</option>
</select>
<label class="check"><input type="checkbox" id="prop-bold"> {% trans "Жирный" %}</label>
<div id="prop-custom-wrap" style="display:none;">
<label>{% trans "Текст" %}</label>
<input type="text" id="prop-text" maxlength="200">
</div>
<button type="button" class="danger" id="prop-delete">{% trans "Удалить блок" %}</button>
</div>
</div>
</div>
{{ template_json|json_script:"label-template-data" }}
<script src="{% static 'assets/js/label-template-editor.js' %}"></script>
</body>
</html>
+39 -41
View File
@@ -28,44 +28,41 @@
.toolbar .primary { background: #2563eb; color: #fff; border-color: #2563eb; }
.hint { font-size: 0.8125rem; color: #555; }
.sheet {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 6mm;
display: flex;
flex-wrap: wrap;
gap: 5mm;
padding: 8mm;
align-content: flex-start;
}
.label {
position: relative;
width: {{ template_width_mm }}mm;
height: {{ template_height_mm }}mm;
border: 1px solid #222;
border-radius: 2mm;
padding: 3mm 3.5mm;
display: grid;
grid-template-columns: 22mm 1fr;
grid-template-rows: auto auto;
gap: 2mm 3mm;
border-radius: 1mm;
overflow: hidden;
break-inside: avoid;
page-break-inside: avoid;
min-height: 32mm;
background: #fff;
}
.label .qr { width: 22mm; height: 22mm; object-fit: contain; }
.label .meta { font-size: 9pt; line-height: 1.25; min-width: 0; }
.label .meta .model {
font-weight: 600; font-size: 10pt;
overflow: hidden; text-overflow: ellipsis;
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
.block {
position: absolute;
overflow: hidden;
line-height: 1.15;
}
.label .meta .muted { color: #444; font-size: 8pt; margin-top: 1mm; }
.label .meta .inv { font-family: ui-monospace, monospace; font-size: 10pt; margin-top: 1.5mm; }
.label .ean {
grid-column: 1 / -1;
text-align: center;
}
.label .ean img {
max-width: 100%;
height: 14mm;
.block img {
width: 100%;
height: 100%;
object-fit: contain;
display: block;
}
.block.text {
white-space: pre-wrap;
word-break: break-word;
}
@media print {
.toolbar { display: none !important; }
.sheet { padding: 0; gap: 5mm; }
.sheet { padding: 0; gap: 4mm; }
.label { border-color: #000; }
}
</style>
@@ -73,30 +70,31 @@
<body>
<div class="toolbar">
<button type="button" class="primary" onclick="window.print()">{% trans "Печать" %}</button>
<span class="hint">{% trans "Этикеток" %}: {{ labels|length }} · QR = {% trans "инв. № или EQ{id}" %} · EAN-13 = 200 + id</span>
<span class="hint">
{% trans "Этикеток" %}: {{ labels|length }} ·
{% if codes == 'qr' %}QR{% elif codes == 'ean' %}EAN-13{% else %}QR + EAN-13{% endif %} ·
{{ template_width_mm }}×{{ template_height_mm }} {% trans "мм" %}
</span>
<a href="/it-manager/equipment/labels/template/">{% trans "Шаблон" %}</a>
<a href="/it-manager/?tab=equipment">{% trans "К оборудованию" %}</a>
</div>
<div class="sheet">
{% for label in labels %}
{% for label in labels %}
<div class="label">
<img class="qr" src="data:image/png;base64,{{ label.qr_png }}" alt="QR {{ label.qr_payload }}">
<div class="meta">
<div class="model">{{ label.model }}</div>
{% if label.type_name %}<div class="muted">{{ label.type_name }}</div>{% endif %}
{% if label.inventory %}
<div class="inv">{{ label.inventory }}</div>
{% for block in label.blocks %}
<div class="block{% if block.type != 'qr' and block.type != 'ean13' %} text{% endif %}"
style="left:{{ block.x_mm }}mm; top:{{ block.y_mm }}mm; width:{{ block.w_mm }}mm; height:{{ block.h_mm }}mm; font-size:{{ block.font_pt }}pt; font-weight:{% if block.bold %}600{% else %}400{% endif %}; text-align:{{ block.align }};">
{% if block.type == 'qr' or block.type == 'ean13' %}
{% if block.png %}<img src="data:image/png;base64,{{ block.png }}" alt="{{ block.text }}">{% endif %}
{% else %}
<div class="inv">EQ{{ label.id }}</div>
{{ block.text }}
{% endif %}
<div class="muted">S/N {{ label.serial }}</div>
</div>
<div class="ean">
<img src="data:image/png;base64,{{ label.ean_png }}" alt="EAN {{ label.ean13 }}">
</div>
{% endfor %}
</div>
{% empty %}
{% empty %}
<p style="padding:2rem;">{% trans "Не выбрано оборудование." %}</p>
{% endfor %}
{% endfor %}
</div>
</body>
</html>
+7 -1
View File
@@ -216,7 +216,13 @@
{% include "partials/it-equipment-assign-fields.html" with widget_id="bulk-assign" selected_target="" selected_location_id=None selected_employee_id=None require_target=True allow_empty=False %}
<button type="submit" class="btn btn-primary">{% trans "Выдать" %}</button>
<button type="button" class="btn btn-secondary" style="border:1px solid var(--color-border);"
onclick="itEquipmentOpenLabels()">{% trans "Этикетки QR / EAN-13" %}</button>
onclick="itEquipmentOpenLabels('qr')">{% trans "QR" %}</button>
<button type="button" class="btn btn-secondary" style="border:1px solid var(--color-border);"
onclick="itEquipmentOpenLabels('ean')">{% trans "EAN-13" %}</button>
<button type="button" class="btn btn-secondary" style="border:1px solid var(--color-border);"
onclick="itEquipmentOpenLabels('both')">{% trans "QR + EAN" %}</button>
<a href="/it-manager/equipment/labels/template/" target="_blank" rel="noopener"
class="btn btn-ghost" style="text-decoration:none; font-size:0.8125rem;">{% trans "Шаблон этикетки" %}</a>
<button type="button" class="btn btn-ghost" onclick="itBulkClearSelection()">{% trans "Отменить выбор" %}</button>
</form>
</div>
+162
View File
@@ -0,0 +1,162 @@
{% load i18n %}
<div class="page-content">
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:1rem; gap:1rem; flex-wrap:wrap;">
<a href="/dislocation/?tab=vendors" hx-get="/dislocation/?tab=vendors" hx-target="#main-content" hx-push-url="/dislocation/?tab=vendors" hx-indicator="#loader"
style="display:inline-flex; align-items:center; gap:0.375rem; font-size:0.8125rem; color:var(--color-muted-foreground); text-decoration:none;">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="m12 19-7-7 7-7"/><path d="M19 12H5"/>
</svg>
{% trans "К дислокации" %}
</a>
{% if can_edit_vendor %}
<div style="display:flex; gap:0.5rem; flex-wrap:wrap;">
{% if vendor_edit_mode %}
<a href="/dislocation/vendors/{{ vendor.pk }}/" hx-get="/dislocation/vendors/{{ vendor.pk }}/" hx-target="#main-content" hx-push-url="true" hx-indicator="#loader"
class="btn btn-secondary" style="font-size:0.8125rem; padding:0.3rem 0.75rem; text-decoration:none;">{% trans "Отмена" %}</a>
{% else %}
<a href="/dislocation/vendors/{{ vendor.pk }}/?edit=1" hx-get="/dislocation/vendors/{{ vendor.pk }}/?edit=1" hx-target="#main-content" hx-push-url="true" hx-indicator="#loader"
class="btn btn-primary" style="font-size:0.8125rem; padding:0.3rem 0.75rem; text-decoration:none;">{% trans "Редактировать" %}</a>
{% endif %}
</div>
{% endif %}
</div>
{% if vendor_form_message %}
<div style="margin-bottom:1rem; padding:0.75rem 1rem; border-radius:0.5rem; border:1px solid hsl(152 40% 70%); background:color-mix(in srgb, var(--color-success) 12%, var(--color-card)); color:var(--color-foreground); font-size:0.875rem;">
{{ vendor_form_message }}
</div>
{% endif %}
{% if vendor_form_error %}
<div style="margin-bottom:1rem; padding:0.75rem 1rem; border-radius:0.5rem; background:hsl(0 70% 96%); border:1px solid hsl(0 70% 85%); color:hsl(0 55% 35%); font-size:0.8125rem;">
{{ vendor_form_error }}
</div>
{% endif %}
<div style="background:var(--color-card); border-radius:0.75rem; border:1px solid var(--color-border); box-shadow:0 1px 2px rgb(0 0 0/0.05); padding:1.5rem; margin-bottom:1.25rem;">
<div style="display:flex; align-items:flex-start; justify-content:space-between; gap:1rem; flex-wrap:wrap;">
<div>
<h1 style="font-size:1.25rem; font-weight:600; color:var(--color-foreground);">{{ vendor.name|default:"—" }}</h1>
<div style="font-size:0.875rem; color:var(--color-muted-foreground); margin-top:0.25rem;">
{% trans "Сервис / поставщик" %}
{% if vendor.location_region %} · {{ vendor.location_region.name }}{% endif %}
</div>
</div>
{% if vendor.is_active %}
<span class="badge badge-success">{% trans "Активен" %}</span>
{% else %}
<span class="badge badge-secondary">{% trans "Неактивен" %}</span>
{% endif %}
</div>
</div>
{% if vendor_edit_mode and vendor_edit_form %}
<form hx-post="/dislocation/vendors/{{ vendor.pk }}/update/" hx-target="#main-content" hx-indicator="#loader" hx-disabled-elt="find button[type=submit]"
style="background:var(--color-card); border:1px solid var(--color-border); border-radius:0.75rem; padding:1.25rem; margin-bottom:1.25rem;">
{% csrf_token %}
<input type="hidden" name="from_card" value="1">
<div style="display:grid; grid-template-columns:repeat(2, minmax(0,1fr)); gap:0.875rem;">
<div style="grid-column:1 / -1;">
<label style="display:block; font-size:0.8125rem; font-weight:500; margin-bottom:0.375rem;">{% trans "Название" %} *</label>
{{ vendor_edit_form.name }}
</div>
<div>
<label style="display:block; font-size:0.8125rem; font-weight:500; margin-bottom:0.375rem;">{% trans "Город/район" %} *</label>
{{ vendor_edit_form.location_region }}
</div>
<div>
<label style="display:block; font-size:0.8125rem; font-weight:500; margin-bottom:0.375rem;">{% trans "Координаты" %} <span style="font-weight:400; color:var(--color-muted-foreground);">lat,lng</span></label>
{{ vendor_edit_form.location }}
</div>
<div>
<label style="display:block; font-size:0.75rem; color:var(--color-muted-foreground); margin-bottom:0.25rem;">{% trans "Улица" %}</label>
{{ vendor_edit_form.street }}
</div>
<div style="display:grid; grid-template-columns:1fr 1fr 1fr; gap:0.5rem;">
<div>
<label style="display:block; font-size:0.75rem; color:var(--color-muted-foreground); margin-bottom:0.25rem;">{% trans "Дом" %}</label>
{{ vendor_edit_form.house_number }}
</div>
<div>
<label style="display:block; font-size:0.75rem; color:var(--color-muted-foreground); margin-bottom:0.25rem;">{% trans "Корпус" %}</label>
{{ vendor_edit_form.building }}
</div>
<div>
<label style="display:block; font-size:0.75rem; color:var(--color-muted-foreground); margin-bottom:0.25rem;">{% trans "Пом." %}</label>
{{ vendor_edit_form.apartment }}
</div>
</div>
<div style="grid-column:1 / -1;">
<label style="display:block; font-size:0.8125rem; font-weight:500; margin-bottom:0.375rem;">{% trans "Комментарий" %}</label>
{{ vendor_edit_form.comments }}
</div>
<label style="display:flex; align-items:center; gap:0.5rem; font-size:0.8125rem;">
{{ vendor_edit_form.is_active }} {% trans "Активен" %}
</label>
</div>
<button type="submit" class="btn btn-primary" style="margin-top:1rem;">{% trans "Сохранить" %}</button>
</form>
{% else %}
<div style="display:grid; grid-template-columns:minmax(0,1.4fr) minmax(0,1fr); gap:1.25rem;">
<div style="background:var(--color-card); border:1px solid var(--color-border); border-radius:0.75rem; padding:1.25rem;">
<h2 style="font-size:1rem; font-weight:600; color:var(--color-foreground); margin-bottom:0.875rem;">{% trans "Данные" %}</h2>
<div style="display:grid; gap:0.75rem; font-size:0.875rem;">
<div>
<div style="font-size:0.75rem; color:var(--color-muted-foreground);">{% trans "Адрес" %}</div>
<div style="color:var(--color-foreground);">
{% if vendor.street or vendor.house_number %}
{% if vendor.street %}ул. {{ vendor.street }}{% endif %}{% if vendor.house_number %} {{ vendor.house_number }}{% endif %}{% if vendor.building %}, корп. {{ vendor.building }}{% endif %}{% if vendor.apartment %}, пом. {{ vendor.apartment }}{% endif %}
{% elif vendor.address %}
{{ vendor.address }}
{% else %}
—
{% endif %}
</div>
</div>
<div>
<div style="font-size:0.75rem; color:var(--color-muted-foreground);">{% trans "Город/район" %}</div>
<div style="color:var(--color-foreground);">{{ vendor.location_region.name|default:"—" }}</div>
</div>
<div>
<div style="font-size:0.75rem; color:var(--color-muted-foreground);">{% trans "Комментарий" %}</div>
<div style="color:var(--color-foreground); white-space:pre-wrap;">{{ vendor.comments|default:"—" }}</div>
</div>
</div>
<div style="margin-top:1.25rem; padding-top:0.875rem; border-top:1px solid var(--color-border); font-size:0.75rem; color:var(--color-muted-foreground);">
<div>{% trans "Создано:" %} {{ vendor.created_at|date:"d.m.Y H:i" }}</div>
<div style="margin-top:0.25rem;">{% trans "Обновлено:" %} {{ vendor.updated_at|date:"d.m.Y H:i" }}</div>
</div>
</div>
<div style="background:var(--color-card); border:1px solid var(--color-border); border-radius:0.75rem; padding:1.25rem;">
<h2 style="font-size:1rem; font-weight:600; color:var(--color-foreground); margin-bottom:0.875rem;">{% trans "Карта" %}</h2>
{% if vendor.has_coordinates %}
<div id="vendor-map" style="height:240px; width:100%; border-radius:0.5rem; overflow:hidden;"></div>
<div style="font-size:0.75rem; color:var(--color-muted-foreground); margin-top:0.5rem;">{{ vendor.latitude|floatformat:5 }} · {{ vendor.longitude|floatformat:5 }}</div>
<script src="https://cdn.jsdelivr.net/npm/leaflet@1.9.4/dist/leaflet.js"></script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/leaflet@1.9.4/dist/leaflet.css">
<script>
(function () {
var lat = parseFloat("{{ vendor.latitude|floatformat:'6' }}".replace(',', '.'));
var lng = parseFloat("{{ vendor.longitude|floatformat:'6' }}".replace(',', '.'));
var map = L.map('vendor-map', { zoomControl: false, dragging: false, scrollWheelZoom: false }).setView([lat, lng], 15);
if (typeof acoreAddMapBaseLayer === 'function') {
acoreAddMapBaseLayer(map);
} else {
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap contributors', maxZoom: 19
}).addTo(map);
}
L.marker([lat, lng]).addTo(map);
})();
</script>
{% else %}
<div style="font-size:0.8125rem; color:var(--color-warning);">{% trans "Координаты не указаны" %}</div>
{% if can_edit_vendor %}
<div style="font-size:0.75rem; color:var(--color-muted-foreground); margin-top:0.35rem;">{% trans "Укажите lat,lng в режиме редактирования." %}</div>
{% endif %}
{% endif %}
</div>
</div>
{% endif %}
</div>