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.it_manager.billing import internet_payment_update_view
from apps.employees.views_password import password_setup_view from apps.employees.views_password import password_setup_view
from apps.mattermost_connector.chat_proxy import chat_api_proxy 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 ( from apps.visit_manager.views_calendar import (
calendar_event_create_view, calendar_event_create_view,
calendar_event_delete_view, calendar_event_delete_view,
@@ -74,6 +79,7 @@ urlpatterns = [
path('dislocation/columns/', views.dislocation_columns_view, name='dislocation_columns'), 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/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/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('dislocation/vendors/<int:pk>/update/', vendor_update_view, name='dislocation_vendor_update'),
path('calendar/', views.calendar_view, name='calendar'), path('calendar/', views.calendar_view, name='calendar'),
path('calendar/events/', calendar_event_create_view, name='calendar_event_create'), path('calendar/events/', calendar_event_create_view, name='calendar_event_create'),
+129 -5
View File
@@ -11,9 +11,21 @@ from barcode.writer import ImageWriter
from PIL import Image from PIL import Image
# Внутренний префикс EAN (2 = локальное использование) + 00 → 200…
_EAN_PREFIX = '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: def equipment_ean13_body(pk: int) -> str:
"""12 цифр без контрольной: 200 + pk (9 знаков).""" """12 цифр без контрольной: 200 + pk (9 знаков)."""
@@ -25,7 +37,6 @@ def equipment_ean13_body(pk: int) -> str:
def equipment_ean13(pk: int) -> str: def equipment_ean13(pk: int) -> str:
"""Полный EAN-13 (13 цифр) для единицы оборудования.""" """Полный EAN-13 (13 цифр) для единицы оборудования."""
EAN13 = get_barcode_class('ean13') EAN13 = get_barcode_class('ean13')
# python-barcode сам считает check digit из 12 цифр
code = EAN13(equipment_ean13_body(pk), writer=ImageWriter()) code = EAN13(equipment_ean13_body(pk), writer=ImageWriter())
return code.get_fullcode() return code.get_fullcode()
@@ -78,18 +89,131 @@ def ean13_png_base64(pk: int) -> tuple[str, str]:
return _png_b64(img), code.get_fullcode() 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) qr_text = equipment_qr_payload(unit)
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) ean_b64, ean_digits = ean13_png_base64(unit.pk)
model = unit.equipment_model model = unit.equipment_model
return { fields = {
'id': unit.pk, 'id': unit.pk,
'model': str(model) if model else '—', 'model': str(model) if model else '—',
'type_name': model.equipment_type.name if model and model.equipment_type_id else '', 'type_name': model.equipment_type.name if model and model.equipment_type_id else '',
'serial': unit.serial_number or '', 'serial': unit.serial_number or '',
'inventory': (unit.inventory_number or '').strip(), '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_payload': qr_text,
'qr_png': qr_png_base64(qr_text), 'qr_png': qr_png,
'ean13': ean_digits, 'ean13': ean_digits,
'ean_png': ean_b64, '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 .providers import InternetProvider, InternetContract, InternetContractPayment
from .worklogs import WorkLog, ServiceCall from .worklogs import WorkLog, ServiceCall
from .consumables import Consumable 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.assertEqual(len(ean), 13)
self.assertTrue(ean.startswith('200')) 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.assertEqual(r.status_code, 200)
self.assertContains(r, 'INV-LBL-1') self.assertContains(r, 'INV-LBL-1')
self.assertContains(r, 'data:image/png;base64,') self.assertContains(r, 'data:image/png;base64,')
self.assertContains(r, ean) 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}) r2 = self.client.get('/it-manager/', {'tab': 'equipment', 'q': ean})
self.assertEqual(r2.status_code, 200) self.assertEqual(r2.status_code, 200)
serials = [e.serial_number for e in r2.context['equipment_page']] serials = [e.serial_number for e in r2.context['equipment_page']]
self.assertIn('LBL-SN-1', serials) 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): def test_assignment_history_visible_in_card(self):
equipment = make_equipment(serial_number='HIST-UI-1') equipment = make_equipment(serial_number='HIST-UI-1')
emp = make_employee(first_name='Иван', last_name='Историков') 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-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/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/', 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/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>/update/', views.ticket_update_view, name='it_ticket_update'),
path('it-manager/tickets/<int:pk>/comment/', views.ticket_comment_view, name='it_ticket_comment'), 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 @login_required
def equipment_labels_view(request): def equipment_labels_view(request):
"""Печатная страница этикеток QR + EAN-13 для выбранного оборудования.""" """Печатная страница этикеток по шаблону; codes=qr|ean|both."""
from django.shortcuts import render 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): if not _is_it_staff(request.user):
return deny_section_access(request) 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(',') raw_ids = request.GET.getlist('ids') or request.GET.get('ids', '').split(',')
ids = [] ids = []
for part in raw_ids: for part in raw_ids:
part = (part or '').strip() part = (part or '').strip()
if part.isdigit(): if part.isdigit():
ids.append(int(part)) ids.append(int(part))
# порядок как в запросе, без дублей
seen, ordered = set(), [] seen, ordered = set(), []
for pk in ids: for pk in ids:
if pk not in seen: if pk not in seen:
seen.add(pk) seen.add(pk)
ordered.append(pk) ordered.append(pk)
template = get_default_template()
units_by_id = { units_by_id = {
u.pk: u u.pk: u
for u in scoped_queryset(request.user, Equipment.objects.all()) for u in scoped_queryset(request.user, Equipment.objects.all())
@@ -785,9 +788,61 @@ def equipment_labels_view(request):
for pk in ordered: for pk in ordered:
unit = units_by_id.get(pk) unit = units_by_id.get(pk)
if unit is not None: 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 @login_required
+6 -2
View File
@@ -118,7 +118,7 @@ class PortalVendorForm(forms.ModelForm):
fields = ( fields = (
'name', 'location_region', 'name', 'location_region',
'street', 'house_number', 'building', 'apartment', 'street', 'house_number', 'building', 'apartment',
'comments', 'is_active', 'location', 'comments', 'is_active',
) )
widgets = { widgets = {
'name': forms.TextInput(attrs={ 'name': forms.TextInput(attrs={
@@ -130,6 +130,10 @@ class PortalVendorForm(forms.ModelForm):
'house_number': forms.TextInput(attrs={'class': 'input'}), 'house_number': forms.TextInput(attrs={'class': 'input'}),
'building': forms.TextInput(attrs={'class': 'input'}), 'building': forms.TextInput(attrs={'class': 'input'}),
'apartment': 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}), 'comments': forms.Textarea(attrs={'class': 'input', 'rows': 2}),
'is_active': forms.CheckboxInput(), 'is_active': forms.CheckboxInput(),
} }
@@ -138,7 +142,7 @@ class PortalVendorForm(forms.ModelForm):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.fields['name'].required = True self.fields['name'].required = True
self.fields['location_region'].widget.attrs['class'] = 'input' 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 self.fields[optional].required = False
def clean_name(self): def clean_name(self):
+31
View File
@@ -802,3 +802,34 @@ class DislocationVendorsTabTests(TestCase):
self.vendor.refresh_from_db() self.vendor.refresh_from_db()
self.assertEqual(self.vendor.name, 'ТестСервис Обновлён') self.assertEqual(self.vendor.name, 'ТестСервис Обновлён')
self.assertEqual(self.vendor.comments, 'ремонт техники') 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) 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 @login_required
@require_POST @require_POST
def vendor_create_view(request): 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') vendor = get_object_or_404(Location, pk=pk, location_type='vendor')
form = PortalVendorForm(request.POST, instance=vendor) form = PortalVendorForm(request.POST, instance=vendor)
# Если пришли с карточки — остаёмся на ней
from_card = request.POST.get('from_card') == '1'
if form.is_valid(): 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='Изменения сохранены.') 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( return _dislocation_vendors_response(
request, error=_vendor_form_errors(form), form=form, request, error=_vendor_form_errors(form), form=form,
) )
+4 -2
View File
@@ -151,11 +151,13 @@ function itBulkClearSelection() {
itBulkUpdate(); itBulkUpdate();
} }
function itEquipmentOpenLabels() { function itEquipmentOpenLabels(codes) {
var checked = Array.prototype.slice.call(document.querySelectorAll('.it-equip-check:checked')); var checked = Array.prototype.slice.call(document.querySelectorAll('.it-equip-check:checked'));
if (!checked.length) return; if (!checked.length) return;
var ids = checked.map(function (cb) { return cb.value; }); 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'); 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> </thead>
<tbody> <tbody>
{% for vendor in vendors %} {% 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-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-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-muted-foreground);">{{ vendor.location_region.name|default:"—" }}</td>
<td style="padding:0.625rem 1rem; color:var(--color-foreground);"> <td style="padding:0.625rem 1rem; color:var(--color-foreground);">
@@ -485,15 +489,16 @@
{% endif %} {% endif %}
</td> </td>
{% if can_manage_vendors %} {% 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;" <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-name="{{ vendor.name|escapejs }}"
data-region="{{ vendor.location_region_id|default:'' }}" data-region="{{ vendor.location_region_id|default:'' }}"
data-street="{{ vendor.street|escapejs }}" data-street="{{ vendor.street|escapejs }}"
data-house="{{ vendor.house_number|escapejs }}" data-house="{{ vendor.house_number|escapejs }}"
data-building="{{ vendor.building|escapejs }}" data-building="{{ vendor.building|escapejs }}"
data-apartment="{{ vendor.apartment|escapejs }}" data-apartment="{{ vendor.apartment|escapejs }}"
data-location="{{ vendor.location|escapejs }}"
data-comments="{{ vendor.comments|escapejs }}" data-comments="{{ vendor.comments|escapejs }}"
data-active="{% if vendor.is_active %}1{% else %}0{% endif %}">{% trans "Изменить" %}</button> data-active="{% if vendor.is_active %}1{% else %}0{% endif %}">{% trans "Изменить" %}</button>
</td> </td>
@@ -551,6 +556,10 @@
{{ vendor_form.apartment }} {{ vendor_form.apartment }}
</div> </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="font-weight:400; color:var(--color-muted-foreground);">lat,lng</span></label>
{{ vendor_form.location }}
</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>
{{ vendor_form.comments }} {{ vendor_form.comments }}
@@ -652,7 +661,7 @@ function disloVendorPanelOpen() {
if (nameEl) nameEl.value = ''; if (nameEl) nameEl.value = '';
const regionEl = form.querySelector('[name="location_region"]'); const regionEl = form.querySelector('[name="location_region"]');
if (regionEl) regionEl.value = ''; 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 + '"]'); const el = form.querySelector('[name="' + n + '"]');
if (el) el.value = ''; if (el) el.value = '';
}); });
@@ -672,7 +681,7 @@ function disloVendorPanelClose() {
function disloVendorEdit(pk) { function disloVendorEdit(pk) {
const form = document.getElementById('dislo-vendor-form'); const form = document.getElementById('dislo-vendor-form');
if (!form) return; if (!form) return;
const btn = document.querySelector('button[onclick="disloVendorEdit(' + pk + ')"]'); const btn = document.querySelector('button[onclick*="disloVendorEdit(' + pk + ')"]');
if (!btn) return; if (!btn) return;
form.setAttribute('hx-post', '/dislocation/vendors/' + pk + '/update/'); form.setAttribute('hx-post', '/dislocation/vendors/' + pk + '/update/');
if (window.htmx) htmx.process(form); if (window.htmx) htmx.process(form);
@@ -688,6 +697,7 @@ function disloVendorEdit(pk) {
set('house_number', btn.dataset.house); set('house_number', btn.dataset.house);
set('building', btn.dataset.building); set('building', btn.dataset.building);
set('apartment', btn.dataset.apartment); set('apartment', btn.dataset.apartment);
set('location', btn.dataset.location);
set('comments', btn.dataset.comments); set('comments', btn.dataset.comments);
const activeEl = form.querySelector('[name="is_active"]'); const activeEl = form.querySelector('[name="is_active"]');
if (activeEl) activeEl.checked = btn.dataset.active === '1'; 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>
+36 -38
View File
@@ -28,44 +28,41 @@
.toolbar .primary { background: #2563eb; color: #fff; border-color: #2563eb; } .toolbar .primary { background: #2563eb; color: #fff; border-color: #2563eb; }
.hint { font-size: 0.8125rem; color: #555; } .hint { font-size: 0.8125rem; color: #555; }
.sheet { .sheet {
display: grid; display: flex;
grid-template-columns: repeat(2, 1fr); flex-wrap: wrap;
gap: 6mm; gap: 5mm;
padding: 8mm; padding: 8mm;
align-content: flex-start;
} }
.label { .label {
position: relative;
width: {{ template_width_mm }}mm;
height: {{ template_height_mm }}mm;
border: 1px solid #222; border: 1px solid #222;
border-radius: 2mm; border-radius: 1mm;
padding: 3mm 3.5mm; overflow: hidden;
display: grid;
grid-template-columns: 22mm 1fr;
grid-template-rows: auto auto;
gap: 2mm 3mm;
break-inside: avoid; break-inside: avoid;
page-break-inside: avoid; page-break-inside: avoid;
min-height: 32mm; background: #fff;
} }
.label .qr { width: 22mm; height: 22mm; object-fit: contain; } .block {
.label .meta { font-size: 9pt; line-height: 1.25; min-width: 0; } position: absolute;
.label .meta .model { overflow: hidden;
font-weight: 600; font-size: 10pt; line-height: 1.15;
overflow: hidden; text-overflow: ellipsis;
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
} }
.label .meta .muted { color: #444; font-size: 8pt; margin-top: 1mm; } .block img {
.label .meta .inv { font-family: ui-monospace, monospace; font-size: 10pt; margin-top: 1.5mm; } width: 100%;
.label .ean { height: 100%;
grid-column: 1 / -1;
text-align: center;
}
.label .ean img {
max-width: 100%;
height: 14mm;
object-fit: contain; object-fit: contain;
display: block;
}
.block.text {
white-space: pre-wrap;
word-break: break-word;
} }
@media print { @media print {
.toolbar { display: none !important; } .toolbar { display: none !important; }
.sheet { padding: 0; gap: 5mm; } .sheet { padding: 0; gap: 4mm; }
.label { border-color: #000; } .label { border-color: #000; }
} }
</style> </style>
@@ -73,26 +70,27 @@
<body> <body>
<div class="toolbar"> <div class="toolbar">
<button type="button" class="primary" onclick="window.print()">{% trans "Печать" %}</button> <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> <a href="/it-manager/?tab=equipment">{% trans "К оборудованию" %}</a>
</div> </div>
<div class="sheet"> <div class="sheet">
{% for label in labels %} {% for label in labels %}
<div class="label"> <div class="label">
<img class="qr" src="data:image/png;base64,{{ label.qr_png }}" alt="QR {{ label.qr_payload }}"> {% for block in label.blocks %}
<div class="meta"> <div class="block{% if block.type != 'qr' and block.type != 'ean13' %} text{% endif %}"
<div class="model">{{ label.model }}</div> 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 label.type_name %}<div class="muted">{{ label.type_name }}</div>{% endif %} {% if block.type == 'qr' or block.type == 'ean13' %}
{% if label.inventory %} {% if block.png %}<img src="data:image/png;base64,{{ block.png }}" alt="{{ block.text }}">{% endif %}
<div class="inv">{{ label.inventory }}</div>
{% else %} {% else %}
<div class="inv">EQ{{ label.id }}</div> {{ block.text }}
{% endif %} {% 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> </div>
{% endfor %}
</div> </div>
{% empty %} {% empty %}
<p style="padding:2rem;">{% trans "Не выбрано оборудование." %}</p> <p style="padding:2rem;">{% trans "Не выбрано оборудование." %}</p>
+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 %} {% 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="submit" class="btn btn-primary">{% trans "Выдать" %}</button>
<button type="button" class="btn btn-secondary" style="border:1px solid var(--color-border);" <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> <button type="button" class="btn btn-ghost" onclick="itBulkClearSelection()">{% trans "Отменить выбор" %}</button>
</form> </form>
</div> </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>