it_manager/locations: редактор шаблона этикеток, раздельная печать QR/EAN и карточка поставщика.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+7
-1
@@ -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'),
|
||||||
|
|||||||
@@ -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)
|
||||||
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
|
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),
|
||||||
|
]
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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='Историков')
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>');
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
})();
|
||||||
@@ -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>
|
||||||
@@ -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,30 +70,31 @@
|
|||||||
<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>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
Reference in New Issue
Block a user