Initial commit: aplicatie de cantarire si etichetare

Aplicatie Flask + SQLite pentru productie alimentara: cantarirea
componentelor unui produs finit si tiparirea etichetei prin QZ Tray.

- produse finite (retete) formate din componente cantarite
- designer vizual de etichete (mm/px, zona sigura, campuri variabile,
  cod QR cu continut din mai multe bucati)
- legare produs finit -> sablon de eticheta
- navigare pe doua taburi: Cantarire / Setari
- administrare: componente, produse finite, sabloane, campuri, istoric
- backup, resetare baza de date, checklist de pregatire, /healthz
- CLI de administrare si serviciu systemd pentru productie
- vendor/syncpulse-scale-bridge (GPL-3.0-or-later) pentru citirea cantarului
This commit is contained in:
2026-10-18 23:38:45 +03:00
commit bff6d70b17
107 changed files with 21428 additions and 0 deletions
+77
View File
@@ -0,0 +1,77 @@
<!doctype html>
<html lang="ro">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}Cântărire și etichetare{% endblock %}</title>
<link rel="stylesheet" href="{{ url_for('static', filename='css/app.css') }}">
{% block head %}{% endblock %}
</head>
<body>
{% set admin_blueprints = ['history', 'recipes', 'components', 'label_templates', 'fields',
'monitor'] %}
{% set in_settings = request.blueprint in admin_blueprints
or request.endpoint == 'main.settings_page' %}
<header class="topbar">
<a class="brand" href="{{ url_for('main.dashboard') }}">
<span class="brand-mark">CE</span>
<span class="brand-text">Cântărire &amp; Etichetare</span>
</a>
<nav class="nav">
<a href="{{ url_for('main.dashboard') }}"
class="{{ 'active' if request.endpoint == 'main.dashboard' }}">Cântărire</a>
<a href="{{ url_for('main.settings_page') }}"
class="{{ 'active' if in_settings }}">Setări</a>
</nav>
<button class="qz-pill" type="button" data-qz-pill data-qz-connect
title="Starea conexiunii QZ Tray">
<span class="qz-dot"></span>
<span data-qz-status>QZ Tray: se verifică…</span>
</button>
</header>
{% if in_settings %}
<nav class="subnav" aria-label="Secțiuni de administrare">
<div class="subnav-inner">
<a href="{{ url_for('main.settings_page') }}"
class="{{ 'active' if request.endpoint == 'main.settings_page' }}">Generale</a>
<a href="{{ url_for('components.index') }}"
class="{{ 'active' if request.blueprint == 'components' }}">Componente</a>
<a href="{{ url_for('recipes.index') }}"
class="{{ 'active' if request.blueprint == 'recipes' }}">Produse finite</a>
<a href="{{ url_for('label_templates.index') }}"
class="{{ 'active' if request.blueprint == 'label_templates' }}">Șabloane</a>
<a href="{{ url_for('fields.index') }}"
class="{{ 'active' if request.blueprint == 'fields' }}">Câmpuri</a>
<a href="{{ url_for('history.index') }}"
class="{{ 'active' if request.blueprint == 'history' }}">Istoric</a>
<a href="{{ url_for('monitor.index') }}"
class="{{ 'active' if request.blueprint == 'monitor' }}">Monitor serial</a>
</div>
</nav>
{% endif %}
<main class="container">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="flash-stack">
{% for category, message in messages %}
<div class="flash flash-{{ category }}">{{ message }}</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
{% block content %}{% endblock %}
</main>
<footer class="footer">
<span>Cântărire &amp; Etichetare v{{ app_version }}</span>
<span>Produse finite · SyncPulse Scale Bridge · QZ Tray</span>
</footer>
{% block scripts %}{% endblock %}
<script src="{{ url_for('static', filename='js/vendor/qz-tray.js') }}"></script>
<script src="{{ url_for('static', filename='js/qz-print.js') }}"></script>
</body>
</html>
+47
View File
@@ -0,0 +1,47 @@
{% extends "base.html" %}
{% block title %}{{ "Component nou" if is_new else "Editare component" }}{% endblock %}
{% block content %}
<section class="page-head">
<div>
<h1>{{ "Component nou" if is_new else "Editare component" }}</h1>
<p class="subtitle">Numele apare pe etichetă, lângă greutate.</p>
</div>
</section>
<section class="card narrow">
<form method="post" class="form">
<div class="field">
<label for="code">Cod <span class="req">*</span></label>
<input id="code" name="code" type="text" maxlength="40" required
value="{{ component.code or '' }}" autofocus>
</div>
<div class="field">
<label for="name">Denumire <span class="req">*</span></label>
<input id="name" name="name" type="text" maxlength="120" required
value="{{ component.name or '' }}" placeholder="ex. Fasole">
</div>
<div class="field">
<label for="unit">Unitate de măsură</label>
<select id="unit" name="unit">
{% for u in ["g", "kg", "mg", "t", "lb", "oz"] %}
<option value="{{ u }}" {{ 'selected' if (component.unit or 'g') == u }}>{{ u }}</option>
{% endfor %}
</select>
</div>
<div class="field">
<label for="description">Descriere</label>
<textarea id="description" name="description" rows="3" maxlength="500">{{ component.description or '' }}</textarea>
</div>
<label class="check">
<input type="checkbox" name="active" {{ 'checked' if component.active in [True, 1, '1', 'on', 'true'] or is_new }}>
Component activ
</label>
<div class="actions">
<button class="btn primary" type="submit">Salvează</button>
<a class="btn ghost" href="{{ url_for('components.index') }}">Renunță</a>
</div>
</form>
</section>
{% endblock %}
+47
View File
@@ -0,0 +1,47 @@
{% extends "base.html" %}
{% block title %}Componente{% endblock %}
{% block content %}
<section class="page-head">
<div>
<h1>Componente</h1>
<p class="subtitle">Ce se cântărește: fasole, carne, garnitură etc.</p>
</div>
<a class="btn primary" href="{{ url_for('components.create') }}">+ Component nou</a>
</section>
<section class="card">
{% if components %}
<div class="table-wrap">
<table class="table">
<thead>
<tr><th>Cod</th><th>Denumire</th><th>UM</th><th>Descriere</th><th>Stare</th><th></th></tr>
</thead>
<tbody>
{% for c in components %}
<tr class="{{ 'row-muted' if not c.active }}">
<td><code>{{ c.code }}</code></td>
<td>{{ c.name }}</td>
<td>{{ c.unit }}</td>
<td class="muted">{{ c.description or "-" }}</td>
<td>
{% if c.active %}<span class="badge ok">activ</span>
{% else %}<span class="badge off">inactiv</span>{% endif %}
</td>
<td class="right nowrap">
<a class="link" href="{{ url_for('components.edit', component_id=c.id) }}">Editează</a>
<form class="inline" method="post" action="{{ url_for('components.remove', component_id=c.id) }}"
onsubmit="return confirm('Ștergi componentul {{ c.code }}? Dacă e folosit într-o rețetă, va fi doar dezactivat.');">
<button class="link danger" type="submit">Șterge</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="muted">Nu există componente. <a href="{{ url_for('components.create') }}">Adaugă primul component</a>.</p>
{% endif %}
</section>
{% endblock %}
+159
View File
@@ -0,0 +1,159 @@
{% extends "base.html" %}
{% block title %}Cântărire produs finit{% endblock %}
{% block content %}
<section class="page-head">
<div>
<h1>Cântărire produs finit</h1>
<p class="subtitle">Alege produsul, cântărește componentele în ordine și tipărește eticheta.</p>
</div>
<div class="stats">
<div class="stat"><span class="stat-value" id="stat-today-count">{{ stats.today_count }}</span><span class="stat-label">pachete azi</span></div>
<div class="stat"><span class="stat-value">{{ stats.today_total }}</span><span class="stat-label">total azi</span></div>
<div class="stat"><span class="stat-value">{{ stats.recipes }}</span><span class="stat-label">produse finite</span></div>
</div>
</section>
{% if not setup.ready %}
<section class="card setup-card">
<div class="setup-head">
<div>
<h2>Pregătire pentru prima cântărire</h2>
<p class="muted small">Completează pe rând; poți cântări după ce lista e goală.</p>
</div>
<span class="badge warn">{{ setup.missing|length }} de completat</span>
</div>
<ul class="setup-list">
{% for item in setup.checks %}
{% if not item.ok and not item.optional %}
<li class="setup-item">
<span class="setup-mark" aria-hidden="true">!</span>
<div class="setup-body">
<strong>{{ item.label }}</strong>
<span class="muted small">{{ item.message }}</span>
</div>
{% if item.url %}
<a class="btn ghost" href="{{ item.url }}">{{ item.action or 'Deschide' }}</a>
{% endif %}
</li>
{% endif %}
{% endfor %}
</ul>
</section>
{% endif %}
<div class="grid weigh-grid">
<section class="card flow-card">
<div class="flow-head">
<h2 id="flow-title">Pachet nou</h2>
<span id="flow-progress" class="chip"></span>
</div>
<div id="flow-body" class="flow-body"></div>
<p id="flow-message" class="message" role="status" aria-live="polite"></p>
</section>
<section class="card scale-card">
<div class="scale-head">
<span id="scale-dot" class="dot off"></span>
<span id="scale-state">{{ "Se conectează…" if scale_ready else "Introducere manuală" }}</span>
<span id="scale-profile" class="chip">{{ active_profile or "fără profil" }}</span>
</div>
<div class="weight-display">
<span id="weight-value">--</span>
<span id="weight-unit" class="weight-unit"></span>
</div>
<div class="weight-meta">
<span id="stability" class="badge">—</span>
<span id="age" class="muted"></span>
<span id="raw" class="raw"></span>
</div>
<p id="scale-error" class="message error">{% if scale_error %}{{ scale_error }}{% endif %}</p>
{% if profiles %}
<div class="scale-hint">
<span>Profil:</span>
<select id="profile-select" class="inline-select">
{% for p in profiles %}
<option value="{{ p.name }}" {{ 'selected' if p.name == active_profile }}>
{{ p.label }} · {{ p.port }} · {{ p.protocol }}
</option>
{% endfor %}
</select>
</div>
{% else %}
<div class="scale-hint">
<span>Cântarul nu este configurat — cântărește manual.</span>
<a class="link" href="{{ url_for('monitor.index') }}">Monitor</a>
</div>
{% endif %}
<div class="qz-hint">
<span>Imprimantă:</span>
<select class="inline-select" data-qz-printer>
<option value="">Implicit (sistem)</option>
</select>
<button class="link" type="button" data-qz-connect>Conectează</button>
<button class="link" type="button" data-qz-test>Test</button>
<span class="muted small" data-qz-version></span>
</div>
</section>
</div>
<section class="card">
<div class="card-head">
<h2>Ultimele pachete</h2>
<a class="link" href="{{ url_for('history.index') }}">Vezi tot istoricul →</a>
</div>
{% if recent %}
<div class="table-wrap">
<table class="table compact">
<thead>
<tr><th>Finalizat</th><th>Produs finit</th><th>Componente</th><th class="num">Total</th><th>Lot</th><th>Tipărit</th><th></th></tr>
</thead>
<tbody>
{% for p in recent %}
<tr>
<td class="nowrap">{{ (p.completed_at or p.created_at)|dt }}</td>
<td>{{ p.recipe_code }} · {{ p.recipe_name }}</td>
<td class="muted">{{ p.item_summary or "-" }}</td>
<td class="num">{{ (p.total_weight or 0)|weight }} {{ p.unit }}</td>
<td>{{ p.lot or "-" }}</td>
<td>{{ "da" if p.printed_at else "nu" }}</td>
<td class="right">
{% if p.status == "done" %}
<a class="link" target="_blank" rel="noopener"
href="{{ url_for('printing.print_label', package_id=p.id, auto='0') }}">Etichetă</a>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="muted">Niciun pachet finalizat încă.</p>
{% endif %}
</section>
<iframe id="print-frame" class="print-frame" title="Tipărire etichetă" aria-hidden="true"></iframe>
<div id="toast" class="toast" role="status" aria-live="polite"></div>
{% endblock %}
{% block scripts %}
<script>
window.CANTARIRE = {
pollMs: {{ poll_ms|int }},
scaleReady: {{ 'true' if scale_ready else 'false' }},
activeProfile: {{ active_profile|tojson }},
defaultOperator: {{ default_operator|tojson }},
defaultTemplateId: {{ default_template_id|tojson }},
autoPrint: {{ 'true' if auto_print else 'false' }},
initial: {{ initial|tojson }},
recipes: {{ recipes|tojson }}
};
</script>
<script src="{{ url_for('static', filename='js/weigh.js') }}"></script>
{% endblock %}
+120
View File
@@ -0,0 +1,120 @@
{% extends "base.html" %}
{% block title %}{{ "Câmp nou" if is_new else "Editare câmp" }}{% endblock %}
{% block content %}
<section class="page-head">
<div>
<h1>{{ "Câmp nou" if is_new else "Editare câmp" }}</h1>
<p class="subtitle">
Scrie textul etichetei; între acolade poți referi un câmp existent.
</p>
</div>
<a class="btn ghost" href="{{ url_for('fields.index') }}">Înapoi la listă</a>
</section>
<section class="card narrow">
<form method="post" class="form">
<div class="field">
<label for="label">Denumire <span class="req">*</span></label>
<input id="label" name="label" type="text" maxlength="{{ max_label }}" required
value="{{ field.label or '' }}" placeholder="ex. Contact firmă" autofocus>
<p class="muted small">Apare în popup-ul din designer.</p>
</div>
<div class="field">
<label for="template">Text <span class="req">*</span></label>
<textarea id="template" name="template" rows="3" maxlength="{{ max_template }}"
placeholder="ex. Tel. {{ '{{company.phone}}' }}">{{ field.template or '' }}</textarea>
<p class="muted small">
Poți folosi orice câmp din tabelul de mai jos, scris între <code>{{ '{{' }}</code> și
<code>{{ '}}' }}</code>.
</p>
</div>
<div class="field">
<label>Previzualizare cu date demo</label>
<div id="field-preview" class="field-preview muted">—</div>
</div>
<label class="check">
<input type="checkbox" name="active"
{{ 'checked' if field.active in [True, 1, '1', 'on', 'true'] or is_new }}>
Câmp activ (apare în designer)
</label>
<div class="actions">
<button class="btn primary" type="submit">Salvează</button>
<a class="btn ghost" href="{{ url_for('fields.index') }}">Renunță</a>
</div>
</form>
</section>
<section class="card narrow">
<h2>Câmpuri disponibile</h2>
<p class="muted small">Click pe un câmp pentru a-l insera în text.</p>
<div class="field-chips">
{% for c in catalog %}
<button class="field-chip" type="button" data-insert="{{ c.key }}"
title="{{ c.label }} · exemplu: {{ c.sample }}">
<span class="chip-label">{{ c.label }}</span>
<span class="chip-sample">{{ c.sample }}</span>
</button>
{% endfor %}
</div>
</section>
{% endblock %}
{% block scripts %}
<script>
(function () {
var textarea = document.getElementById('template');
var preview = document.getElementById('field-preview');
var timer = null;
var url = {{ url_for('fields.preview')|tojson }};
function refresh() {
var text = textarea.value.trim();
if (!text) {
preview.textContent = '—';
preview.className = 'field-preview muted';
return;
}
clearTimeout(timer);
timer = setTimeout(function () {
fetch(url + '?template=' + encodeURIComponent(text), {
headers: { Accept: 'application/json' }
})
.then(function (r) { return r.json(); })
.then(function (data) {
if (!data.ok) {
preview.textContent = data.error;
preview.className = 'field-preview error';
return;
}
preview.textContent = data.value || '(gol)';
preview.className = 'field-preview';
})
.catch(function () {
preview.textContent = 'Previzualizarea nu a putut fi calculată.';
preview.className = 'field-preview error';
});
}, 250);
}
textarea.addEventListener('input', refresh);
refresh();
document.querySelectorAll('[data-insert]').forEach(function (button) {
button.addEventListener('click', function () {
var token = '{{ "{{" }}' + button.dataset.insert + '{{ "}}" }}';
var start = textarea.selectionStart || 0;
var end = textarea.selectionEnd || 0;
textarea.value = textarea.value.slice(0, start) + token + textarea.value.slice(end);
textarea.focus();
textarea.selectionStart = textarea.selectionEnd = start + token.length;
refresh();
});
});
})();
</script>
{% endblock %}
+75
View File
@@ -0,0 +1,75 @@
{% extends "base.html" %}
{% block title %}Câmpuri variabile{% endblock %}
{% block content %}
<section class="page-head">
<div>
<h1>Câmpuri variabile</h1>
<p class="subtitle">
Text gata de pus pe etichetă, care poate prelua date din pachet, produs sau firmă.
</p>
</div>
<a class="btn primary" href="{{ url_for('fields.create') }}">+ Câmp nou</a>
</section>
<section class="card">
{% if fields %}
<div class="table-wrap">
<table class="table">
<thead>
<tr>
<th>Denumire</th>
<th>Text</th>
<th>Rezultat (demo)</th>
<th>Cheie</th>
<th>Șabloane</th>
<th>Stare</th>
<th></th>
</tr>
</thead>
<tbody>
{% for f in fields %}
<tr class="{{ 'row-muted' if not f.active }}">
<td><strong>{{ f.label }}</strong></td>
<td><code class="field-template">{{ f.template }}</code></td>
<td>{{ f.preview or "-" }}</td>
<td><code class="muted">{{ f.key }}</code></td>
<td>
{% if f.usage %}<a class="link" href="{{ url_for('label_templates.index') }}">{{ f.usage }}</a>
{% else %}<span class="muted">-</span>{% endif %}
</td>
<td>
{% if f.active %}<span class="badge ok">activ</span>
{% else %}<span class="badge off">inactiv</span>{% endif %}
</td>
<td class="right nowrap">
<a class="link" href="{{ url_for('fields.edit', field_id=f.id) }}">Editează</a>
<form class="inline" method="post" action="{{ url_for('fields.remove', field_id=f.id) }}"
onsubmit="return confirm('Ștergi câmpul {{ f.label }}?{% if f.usage %} Este folosit în {{ f.usage }} șabloane și va apărea gol.{% endif %}');">
<button class="link danger" type="submit">Șterge</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="muted">
Nu există câmpuri proprii. <a href="{{ url_for('fields.create') }}">Adaugă primul câmp</a>
sau folosește câmpurile fixe din designer.
</p>
{% endif %}
</section>
<section class="card">
<h2>Cum funcționează</h2>
<p class="muted small">
În textul câmpului poți scrie orice, iar între acolade poți pune un câmp existent:
<code>Tel. {{ '{{company.phone}}' }}</code> sau
<code>{{ '{{recipe.name}}' }} · lot {{ '{{package.lot}}' }}</code>.
La tipărire, acestea sunt înlocuite cu datele reale. Câmpurile proprii apar în designer
în grupul <strong>Câmpuri proprii</strong>.
</p>
</section>
{% endblock %}
+100
View File
@@ -0,0 +1,100 @@
{% extends "base.html" %}
{% block title %}Istoric produse finite{% endblock %}
{% block content %}
<section class="page-head">
<div>
<h1>Istoric produse finite</h1>
<p class="subtitle">Filtrează, verifică și exportă pachetele finalizate.</p>
</div>
</section>
<section class="card">
<form method="get" class="filters">
<div class="field">
<label for="date_from">De la</label>
<input id="date_from" name="date_from" type="date" value="{{ filters.date_from }}">
</div>
<div class="field">
<label for="date_to">Până la</label>
<input id="date_to" name="date_to" type="date" value="{{ filters.date_to }}">
</div>
<div class="field">
<label for="recipe_id">Produs finit</label>
<select id="recipe_id" name="recipe_id">
<option value="">— toate —</option>
{% for r in recipes %}
<option value="{{ r.id }}" {{ 'selected' if filters.recipe_id == r.id }}>{{ r.code }} · {{ r.name }}</option>
{% endfor %}
</select>
</div>
<div class="field">
<label for="status">Stare</label>
<select id="status" name="status">
<option value="">— toate —</option>
<option value="done" {{ 'selected' if filters.status == 'done' }}>finalizat</option>
<option value="open" {{ 'selected' if filters.status == 'open' }}>în lucru</option>
<option value="void" {{ 'selected' if filters.status == 'void' }}>anulat</option>
</select>
</div>
<div class="field grow">
<label for="q">Căutare</label>
<input id="q" name="q" type="search" value="{{ filters.query }}" placeholder="produs, lot, operator…">
</div>
<div class="field actions-field">
<button class="btn primary" type="submit">Filtrează</button>
<a class="btn" href="{{ url_for('history.index') }}">Resetează</a>
</div>
</form>
</section>
<section class="card">
<div class="card-head">
<h2>{{ packages|length }} pachete · total {{ total }}</h2>
<a class="btn ghost"
href="{{ url_for('history.export_csv', date_from=filters.date_from, date_to=filters.date_to, recipe_id=filters.recipe_id or '', status=filters.status, q=filters.query) }}">Export CSV</a>
</div>
{% if packages %}
<div class="table-wrap">
<table class="table">
<thead>
<tr>
<th>Data</th><th>Produs finit</th><th>Componente</th><th class="num">Total</th>
<th>Lot</th><th>Operator</th><th>Stare</th><th>Tipărit</th><th></th>
</tr>
</thead>
<tbody>
{% for p in packages %}
<tr class="{{ 'row-muted' if p.status != 'done' }}">
<td class="nowrap">{{ (p.completed_at or p.created_at)|dt }}</td>
<td>{{ p.recipe_code }} · {{ p.recipe_name }}</td>
<td class="muted small">{{ p.item_summary or "-" }}</td>
<td class="num">{{ (p.total_weight or 0)|weight }} {{ p.unit }}</td>
<td>{{ p.lot or "-" }}</td>
<td>{{ p.operator or "-" }}</td>
<td>
{% if p.status == "done" %}<span class="badge ok">finalizat</span>
{% elif p.status == "open" %}<span class="badge warn">în lucru</span>
{% else %}<span class="badge err">anulat</span>{% endif %}
</td>
<td class="nowrap">
{% if p.printed_at %}<span class="badge ok">{{ p.print_count }}×</span>
{% else %}<span class="muted">—</span>{% endif %}
</td>
<td class="right">
{% if p.status == "done" %}
<a class="link" target="_blank" rel="noopener"
href="{{ url_for('printing.print_label', package_id=p.id, auto='0') }}">Etichetă</a>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="muted">Nicio înregistrare pentru filtrele selectate.</p>
{% endif %}
</section>
{% endblock %}
+89
View File
@@ -0,0 +1,89 @@
<!doctype html>
<html lang="ro">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Etichetă {{ payload.fields.recipe_code }} · {{ payload.fields.total_weight }} {{ payload.fields.unit }}</title>
<style>
@page { size: {{ payload.template.width_mm }}mm {{ payload.template.height_mm }}mm; margin: 0; }
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; background: #e9edf1; }
.preview-bar {
position: sticky; top: 0; z-index: 2;
display: flex; align-items: center; gap: 12px;
padding: 10px 16px; background: #0f172a; color: #fff;
font: 14px/1.4 system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
}
.preview-bar button {
margin-left: auto; padding: 8px 16px; border: 0; border-radius: 6px;
background: #14b8a6; color: #04201d; font-weight: 700; cursor: pointer;
}
.preview-stage { padding: 24px; display: flex; justify-content: center; }
.label-sheet {
width: {{ payload.template.width_mm }}mm;
height: {{ payload.template.height_mm }}mm;
background: #fff;
overflow: hidden;
box-shadow: 0 6px 24px rgba(15, 23, 42, .18);
}
{{ payload.css|safe }}
@media print {
html, body { background: #fff; }
.preview-bar, .preview-stage { padding: 0; }
.preview-bar { display: none !important; }
.label-sheet { box-shadow: none; margin: 0; }
}
</style>
</head>
<body>
{% if preview %}
<div class="preview-bar">
<strong>Previzualizare șablon</strong>
<span>{{ payload.template.name }} · {{ payload.template.width_mm|weight }} × {{ payload.template.height_mm|weight }} mm</span>
<button type="button" onclick="window.print()">Tipărește previzualizarea</button>
</div>
{% endif %}
<div class="preview-stage">
<div class="label-sheet" id="label-sheet">
{{ payload.html|safe }}
</div>
</div>
<script id="label-payload" type="application/json">{{ payload|tojson }}</script>
<script src="{{ url_for('static', filename='js/vendor/qz-tray.js') }}"></script>
<script src="{{ url_for('static', filename='js/qz-print.js') }}"></script>
<script>
(function () {
var payload = JSON.parse(document.getElementById('label-payload').textContent);
var auto = {{ 'true' if auto else 'false' }};
var preview = {{ 'true' if preview else 'false' }};
function acknowledge() {
if (preview) return;
try {
fetch(payload.ack_url, { method: 'POST', headers: { 'Accept': 'application/json' } });
} catch (e) { /* tipărirea a reușit; confirmarea este auxiliară */ }
}
function doPrint() {
if (window.CantarirePrint && typeof window.CantarirePrint.print === 'function') {
Promise.resolve(window.CantarirePrint.print(payload)).then(function (result) {
if (result && result.method === 'qz') acknowledge();
}, function (err) {
console.error('Tipărirea QZ Tray a eșuat:', err);
});
return;
}
window.print();
acknowledge();
}
window.addEventListener('load', function () {
if (!auto) return;
setTimeout(doPrint, 120);
});
})();
</script>
</body>
</html>
+170
View File
@@ -0,0 +1,170 @@
{% extends "base.html" %}
{% block title %}Monitor serial{% endblock %}
{% block content %}
<section class="page-head">
<div>
<h1>Monitor serial</h1>
<p class="subtitle">Ascultă portul cântarului și află ce format trimite. Nu se scrie nimic către cântar.</p>
</div>
</section>
<div class="notice">
<strong>Folosire:</strong> conectează cântarul, pornește o cântărire pe el (sau apasă Print) și rulează
monitorul. Dacă nu apar bytes, încearcă <em>Scanează vitezele</em> sau altă setare de linie.
O captură salvată aici este exact ce trebuie ca să adăugăm un protocol nou.
</div>
<section class="card">
<form method="post" class="filters">
<div class="field">
<label for="port">Port</label>
<input id="port" name="port" type="text" list="port-list" required
placeholder="/dev/ttyUSB0 sau COM3" value="{{ values.port }}">
<datalist id="port-list">
{% for p in ports %}
<option value="{{ p.device }}">{{ p.description or p.device }}{% if p.chip %} · {{ p.chip }}{% endif %}</option>
{% endfor %}
</datalist>
</div>
<div class="field">
<label for="baud">Baud</label>
<input id="baud" name="baud" type="number" min="50" max="921600" value="{{ values.baud }}">
</div>
<div class="field">
<label for="framing">Framing</label>
<select id="framing" name="framing">
{% for f in framings %}
<option value="{{ f }}" {{ 'selected' if values.framing == f }}>{{ f }}</option>
{% endfor %}
</select>
</div>
<div class="field">
<label for="seconds">Secunde</label>
<input id="seconds" name="seconds" type="number" min="0.5" max="30" step="0.5" value="{{ values.seconds }}">
</div>
<div class="field">
<label for="name">Nume captură</label>
<input id="name" name="name" type="text" maxlength="60" value="{{ values.name }}">
</div>
<div class="field actions-field">
<button class="btn primary" type="submit">Ascultă</button>
</div>
<div class="monitor-options">
<label class="check"><input type="checkbox" name="scan" value="1" {{ 'checked' if values.scan }}> Scanează vitezele</label>
<label class="check"><input type="checkbox" name="save" value="1" {{ 'checked' if values.save }}> Salvează captura</label>
</div>
</form>
{% if ports %}
<p class="muted small">
Porturi detectate:
{% for p in ports %}<code>{{ p.device }}</code>{% if p.chip %} ({{ p.chip }}){% endif %}{% if not loop.last %}, {% endif %}{% endfor %}
</p>
{% else %}
<p class="muted small">Nu a fost detectat niciun port. Verifică adaptorul USB-RJ11 (driver, cablu).</p>
{% endif %}
</section>
{% if error %}
<div class="notice warning">{{ error }}</div>
{% endif %}
{% if saved_path %}
<div class="notice"><strong>Cauptră salvată:</strong> <code>{{ saved_path }}</code></div>
{% endif %}
{% if scan_hits %}
<section class="card">
<h2>Viteze cu date</h2>
<div class="table-wrap">
<table class="table">
<thead><tr><th>Baud</th><th>Framing</th><th class="num">Bytes</th><th>Protocoale recunoscute</th></tr></thead>
<tbody>
{% for hit in scan_hits %}
<tr>
<td>{{ hit.baud }}</td>
<td>{{ hit.framing }}</td>
<td class="num">{{ hit.bytes }}</td>
<td>
{% if hit.guesses %}
{% for g in hit.guesses %}<span class="badge ok">{{ g.protocol }}</span> {% endfor %}
{% else %}<span class="muted">necunoscut</span>{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</section>
{% endif %}
{% if result %}
<section class="card">
<div class="card-head">
<h2>Rezultat: {{ result.port }} · {{ result.baud }} {{ result.framing }}</h2>
<span class="badge {{ 'ok' if result.ok else 'err' }}">{{ result.bytes }} bytes</span>
</div>
{% if result.guesses %}
<h3>Protocoale recunoscute</h3>
<div class="table-wrap">
<table class="table compact">
<thead><tr><th>ID</th><th>Nume</th><th>Tip</th><th class="num">Valoare</th><th class="num">Cadre</th></tr></thead>
<tbody>
{% for g in result.guesses %}
<tr>
<td><code>{{ g.protocol }}</code></td>
<td>{{ g.name }}</td>
<td>{{ g.kind }}</td>
<td class="num">{{ g.value }} {{ g.unit }}</td>
<td class="num">{{ g.count }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="muted">Niciun protocol cunoscut nu recunoaște datele. Salvează captura și o folosim ca să scriem parserul.</p>
{% endif %}
<h3>Cadre brute ({{ result.frames|length }})</h3>
{% if result.frames %}
<div class="table-wrap">
<table class="table compact">
<thead><tr><th class="num">Acum</th><th class="num">Bytes</th><th>Hex</th><th>Text</th></tr></thead>
<tbody>
{% for f in result.frames %}
<tr>
<td class="num">{{ f.age_ms }} ms</td>
<td class="num">{{ f.size }}</td>
<td><code class="hex">{{ f.hex }}</code></td>
<td><code>{{ f.text }}</code></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="muted">Niciun byte primit. Cântarul poate fi setat să nu trimită nimic pe această interfață.</p>
{% endif %}
</section>
{% endif %}
<section class="card help-card">
<h2>Setări de încercat</h2>
<ul class="var-list">
<li><code>9600 8N1</code> — cel mai comun pentru cântare de magazin.</li>
<li><code>9600 7E1</code> / <code>4800 7E1</code> — frecvent la indicatoarele vechi.</li>
<li><code>9600 7O1</code> — dacă textul apare cu bitul 8 schimbat.</li>
<li>Unele cântare trimit doar la apăsarea <em>Print</em>: ține monitorul pornit și apasă.</li>
</ul>
<h2>Cablaj modular (RJ11 / 10P4C)</h2>
<p class="muted small">
Un adaptor USB–RJ11 funcționează doar dacă cei patru ași ai prizei cântarului sunt
<strong>TX, RX, GND</strong> la nivel RS-232 și adaptorul are aceeași ordine. Verifică pinoutul în manualul
BC800 înainte de a conecta; unele prize modulare de cântar sunt TTL sau current-loop și nu se leagă
direct la un adaptor RS-232.
</p>
</section>
{% endblock %}
+141
View File
@@ -0,0 +1,141 @@
{% extends "base.html" %}
{% block title %}{{ "Produs finit nou" if is_new else "Editare produs finit" }}{% endblock %}
{% block content %}
<section class="page-head">
<div>
<h1>{{ "Produs finit nou" if is_new else "Editare produs finit" }}</h1>
<p class="subtitle">Ordinea componentelor este ordinea în care se cântăresc.</p>
</div>
</section>
<div class="grid template-grid">
<section class="card">
<form method="post" class="form">
<div class="field-row">
<div class="field">
<label for="code">Cod <span class="req">*</span></label>
<input id="code" name="code" type="text" maxlength="40" required value="{{ recipe.code or '' }}">
</div>
<div class="field">
<label for="unit">Unitate</label>
<select id="unit" name="unit">
{% for u in ["g", "kg", "mg", "t", "lb", "oz"] %}
<option value="{{ u }}" {{ 'selected' if (recipe.unit or 'g') == u }}>{{ u }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="field">
<label for="name">Denumire pe etichetă <span class="req">*</span></label>
<input id="name" name="name" type="text" maxlength="120" required
value="{{ recipe.name or '' }}" placeholder="ex. Fasole cu carne">
</div>
<div class="field">
<label for="target_weight">Greutate totală țintă (opțional)</label>
<input id="target_weight" name="target_weight" type="number" step="0.001" min="0"
value="{{ recipe.target_weight|weight if recipe.target_weight else '' }}" placeholder="ex. 450">
<p class="muted small">Doar orientativă; eticheta afișează greutatea reală cântărită.</p>
</div>
<h2>Etichetă</h2>
<div class="field">
<label for="template_id">Șablon de etichetă</label>
<select id="template_id" name="template_id">
<option value="">— șablonul implicit din Setări —</option>
{% for t in templates %}
<option value="{{ t.id }}" {{ 'selected' if recipe.template_id|string == t.id|string }}>
{{ t.name }} · {{ t.width_mm|weight }} × {{ t.height_mm|weight }} mm
</option>
{% endfor %}
</select>
<p class="muted small">
{% if templates %}
Eticheta acestui produs finit se randează cu șablonul ales aici.
<a href="{{ url_for('label_templates.index') }}">Vezi șabloanele</a> ·
<a href="{{ url_for('label_templates.index') }}">creează unul nou</a>
{% else %}
Nu există șabloane. <a href="{{ url_for('label_templates.index') }}">Creează întâi un șablon</a>,
altfel eticheta nu poate fi tipărită.
{% endif %}
</p>
</div>
<h2>Componente</h2>
<p class="muted small">Completează cel puțin primul rând. Ordinea rândurilor = ordinea cântăririi.</p>
{% for row in slot_rows %}
<div class="component-row">
<span class="component-index">{{ row.slot }}</span>
<div class="field grow">
<label for="component_{{ row.slot }}_id">
Component {{ row.slot }}{% if row.slot == 1 %} <span class="req">*</span>{% endif %}
</label>
<select id="component_{{ row.slot }}_id" name="component_{{ row.slot }}_id">
<option value="">— {{ "obligatoriu" if row.slot == 1 else "opțional" }} —</option>
{% for c in components %}
<option value="{{ c.id }}"
{{ 'selected' if row.component_id|string == c.id|string }}>
{{ c.code }} · {{ c.name }} ({{ c.unit }})
</option>
{% endfor %}
</select>
</div>
<div class="field component-target">
<label for="component_{{ row.slot }}_target">Țintă</label>
<input id="component_{{ row.slot }}_target" name="component_{{ row.slot }}_target"
type="number" step="0.001" min="0" value="{{ row.target|weight if row.target else '' }}">
</div>
</div>
{% endfor %}
<div class="field">
<label for="note">Observații interne</label>
<textarea id="note" name="note" rows="2" maxlength="500">{{ recipe.note or '' }}</textarea>
</div>
<label class="check">
<input type="checkbox" name="active" {{ 'checked' if recipe.active in [True, 1, '1', 'on', 'true'] or is_new }}>
Produs finit activ
</label>
<div class="actions">
<button class="btn primary" type="submit">Salvează</button>
<a class="btn ghost" href="{{ url_for('recipes.index') }}">Renunță</a>
</div>
</form>
</section>
<aside class="card help-card">
<h2>Cum funcționează</h2>
<p class="small">
La cântărire alegi produsul finit. Aplicația cere pe rând fiecare componentă, în ordinea de aici.
După ultima componentă eticheta se tipărește automat.
</p>
<h2>Eticheta produsului</h2>
<p class="small">
Șablonul ales aici este folosit automat la tipărirea etichetelor pentru acest produs finit;
dacă lași gol, se folosește șablonul implicit din Setări.
</p>
<h2>Exemplu</h2>
<p class="small">
<strong>Fasole cu carne</strong>, unitate <code>g</code>:
</p>
<ul class="var-list">
<li>1 · Fasole — țintă 250</li>
<li>2 · Carne — țintă 200</li>
</ul>
<p class="small muted">
Eticheta va arăta totalul real cântărit și defalcarea:
<code>250 g Fasole</code>, <code>200 g Carne</code>.
</p>
<h2>Fără componentă nouă?</h2>
<p class="small muted">
Dacă lipsește o componentă din listă, o adaugi întâi în
<a href="{{ url_for('components.create') }}">Componente</a>, apoi revii aici.
</p>
</aside>
</div>
{% endblock %}
+51
View File
@@ -0,0 +1,51 @@
{% extends "base.html" %}
{% block title %}Produse finite{% endblock %}
{% block content %}
<section class="page-head">
<div>
<h1>Produse finite</h1>
<p class="subtitle">Rețetele care se cântăresc și se etichetează.</p>
</div>
<a class="btn primary" href="{{ url_for('recipes.create') }}">+ Produs finit</a>
</section>
<section class="card">
{% if recipes %}
<div class="table-wrap">
<table class="table">
<thead>
<tr><th>Cod</th><th>Denumire</th><th class="num">Componente</th><th class="num">Țintă</th><th>Etichetă</th><th>Stare</th><th></th></tr>
</thead>
<tbody>
{% for r in recipes %}
<tr class="{{ 'row-muted' if not r.active }}">
<td><code>{{ r.code }}</code></td>
<td>{{ r.name }}</td>
<td class="num">{{ r.component_count }}</td>
<td class="num">{{ (r.target_weight|weight ~ ' ' ~ r.unit) if r.target_weight else "—" }}</td>
<td>
{% if r.template_name %}{{ r.template_name }}
{% else %}<span class="muted">implicit</span>{% endif %}
</td>
<td>
{% if r.active %}<span class="badge ok">activ</span>
{% else %}<span class="badge off">inactiv</span>{% endif %}
</td>
<td class="right nowrap">
<a class="link" href="{{ url_for('recipes.edit', recipe_id=r.id) }}">Editează</a>
<form class="inline" method="post" action="{{ url_for('recipes.remove', recipe_id=r.id) }}"
onsubmit="return confirm('Ștergi produsul finit {{ r.code }}? Dacă are pachete în istoric, va fi doar dezactivat.');">
<button class="link danger" type="submit">Șterge</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="muted">Nu există produse finite. <a href="{{ url_for('recipes.create') }}">Creează primul</a>.</p>
{% endif %}
</section>
{% endblock %}
+194
View File
@@ -0,0 +1,194 @@
{% extends "base.html" %}
{% block title %}Setări{% endblock %}
{% block content %}
<section class="page-head">
<div>
<h1>Setări</h1>
<p class="subtitle">
Datele firmei, cântarul și imprimanta; de aici ajungi la evidențele aplicației.
</p>
</div>
</section>
<nav class="hub-grid" aria-label="Administrare">
<a class="hub-card" href="{{ url_for('components.index') }}">
<span class="hub-title">Componente</span>
<span class="hub-meta">{{ components|length }} definite · ce se cântărește</span>
</a>
<a class="hub-card" href="{{ url_for('recipes.index') }}">
<span class="hub-title">Produse finite</span>
<span class="hub-meta">{{ recipes|length }} rețete · combinații de componente</span>
</a>
<a class="hub-card" href="{{ url_for('label_templates.index') }}">
<span class="hub-title">Șabloane</span>
<span class="hub-meta">{{ templates|length }} șabloane · design de etichetă</span>
</a>
<a class="hub-card" href="{{ url_for('fields.index') }}">
<span class="hub-title">Câmpuri</span>
<span class="hub-meta">{{ fields_count }}
{{ 'variabilă' if fields_count == 1 else 'variabile' }} · text pentru etichete</span>
</a>
<a class="hub-card" href="{{ url_for('history.index') }}">
<span class="hub-title">Istoric</span>
<span class="hub-meta">Pachete finalizate, filtre și export CSV</span>
</a>
<a class="hub-card" href="{{ url_for('monitor.index') }}">
<span class="hub-title">Monitor serial</span>
<span class="hub-meta">Identifică un cântar nou</span>
</a>
</nav>
<div class="grid settings-grid">
<section class="card">
<form method="post" class="form">
<h2>Date firmă</h2>
<div class="field">
<label for="company_name">Denumire firmă</label>
<input id="company_name" name="company_name" type="text" maxlength="200"
value="{{ app_settings.company_name }}">
</div>
<div class="field">
<label for="company_address">Adresă</label>
<input id="company_address" name="company_address" type="text" maxlength="200"
value="{{ app_settings.company_address }}">
</div>
<div class="field">
<label for="company_phone">Telefon</label>
<input id="company_phone" name="company_phone" type="text" maxlength="200"
value="{{ app_settings.company_phone }}">
</div>
<h2>Cântar</h2>
<div class="field">
<label for="active_scale_profile">Profil cântar activ</label>
<select id="active_scale_profile" name="active_scale_profile">
<option value="">— introducere manuală —</option>
{% for p in profiles %}
<option value="{{ p.name }}" {{ 'selected' if app_settings.active_scale_profile == p.name }}>
{{ p.label }} · {{ p.port }} · {{ p.protocol }}
</option>
{% endfor %}
</select>
{% if not profiles %}
<p class="muted small">
Nu există profile. Configurează cântarul în SyncPulse Scale Bridge sau folosește
<a href="{{ url_for('monitor.index') }}">monitorul serial</a>.
</p>
{% endif %}
</div>
<h2>Etichete</h2>
<div class="field">
<label for="default_template_id">Șablon implicit</label>
<select id="default_template_id" name="default_template_id">
<option value="">— primul disponibil —</option>
{% for t in templates %}
<option value="{{ t.id }}" {{ 'selected' if app_settings.default_template_id == t.id|string }}>
{{ t.name }}
</option>
{% endfor %}
</select>
</div>
<div class="field">
<label for="default_operator">Operator implicit</label>
<input id="default_operator" name="default_operator" type="text" maxlength="64"
value="{{ app_settings.default_operator }}">
</div>
<label class="check">
<input type="checkbox" name="auto_print" {{ 'checked' if app_settings.auto_print == '1' }}>
Tipărește automat eticheta după ultima componentă
</label>
<div class="actions">
<button class="btn primary" type="submit">Salvează setările</button>
</div>
</form>
</section>
<aside class="card help-card">
<h2>Starea cântarului</h2>
<p>
{% if scale_available %}
<span class="badge ok">bibliotecă încărcată</span>
{% else %}
<span class="badge err">indisponibil</span>
{% endif %}
</p>
{% if scale_error %}<p class="message error">{{ scale_error }}</p>{% endif %}
<h2>Profile detectate</h2>
{% if profiles %}
<ul class="profile-list">
{% for p in profiles %}
<li>
<strong>{{ p.label }}</strong><br>
<span class="muted">{{ p.name }} · {{ p.port }} · {{ p.protocol }}</span>
</li>
{% endfor %}
</ul>
{% else %}
<p class="muted">Niciun profil salvat în <code>instance/bridge.json</code>.</p>
{% endif %}
<h2>Imprimantă (QZ Tray)</h2>
<p class="muted small">
Eticheta este generată de aplicație și tipărită pe imprimanta termică prin QZ Tray, separat de
cântar. Biblioteca din <code>static/js/vendor/qz-tray.js</code> este patch-uită pentru
autentificare cu pairing key.
</p>
<p class="qz-state-line">
<span class="badge" data-qz-status>QZ Tray: se verifică…</span>
<span class="muted small" data-qz-version></span>
</p>
<div class="field">
<label for="qz-printer">Imprimantă implicită</label>
<select id="qz-printer" data-qz-printer>
<option value="">Implicit (sistem)</option>
</select>
</div>
<div class="actions">
<button class="btn" type="button" data-qz-connect>Conectează</button>
<button class="btn" type="button" data-qz-refresh>Reîncarcă lista</button>
<button class="btn ghost" type="button" data-qz-test>Tipărește test</button>
</div>
<h2>Integrare</h2>
<p class="muted small">
Aplicația importă direct pachetul <code>vendor/syncpulse-scale-bridge</code>; profilul serial
este citit din <code>instance/bridge.json</code>.
</p>
<p class="muted small">
Pentru teste fără hardware: <code>CANTARIRE_SIMULATE=mahak_mds</code>.
</p>
<h2>Sistem</h2>
<p class="small">
{% if setup.ready %}<span class="badge ok">gata de cântărit</span>
{% else %}<span class="badge warn">{{ setup.missing|length }} de completat</span>{% endif %}
<span class="badge">schema v{{ db_status.schema_version }}</span>
</p>
<ul class="system-list small">
<li><span class="muted">Bază de date</span> <code>{{ db_status.path }}</code></li>
<li><span class="muted">Mărime</span> {{ db_status.size_human }}</li>
<li><span class="muted">Integritate</span> {{ db_status.integrity }}</li>
<li><span class="muted">Înregistrări</span>
{{ db_status.counts.components }} componente ·
{{ db_status.counts.recipes }} produse finite ·
{{ db_status.counts.packages }} pachete</li>
</ul>
<div class="actions">
<a class="btn" href="{{ url_for('main.settings_backup') }}">Descarcă backup</a>
<a class="btn ghost" href="{{ url_for('main.healthz') }}" target="_blank" rel="noopener">Stare (/healthz)</a>
</div>
{% if backups %}
<p class="muted small">Ultimele backup-uri:</p>
<ul class="system-list small">
{% for b in backups %}
<li>{{ b.created_at.strftime('%d.%m.%Y %H:%M') }} · {{ b.size_human }}</li>
{% endfor %}
</ul>
{% endif %}
</aside>
</div>
{% endblock %}
+154
View File
@@ -0,0 +1,154 @@
{% extends "base.html" %}
{% block title %}Designer etichetă · {{ template.name }}{% endblock %}
{% block head %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/designer.css') }}">
{% endblock %}
{% block content %}
<div class="designer" id="designer"
data-save-url="{{ url_for('label_templates.designer_save', template_id=template.id) }}"
data-qr-url="{{ url_for('label_templates.designer_qr') }}">
<header class="designer-head">
<div class="designer-head-main">
<form method="post" class="rename-form"
action="{{ url_for('label_templates.rename', template_id=template.id) }}">
<input type="hidden" name="next" value="designer">
<input id="template-name" name="name" type="text" maxlength="80" required
value="{{ template.name }}" class="title-input"
aria-label="Nume șablon" title="Numele șablonului">
<button class="btn ghost" type="submit">Redenumește</button>
</form>
<p class="subtitle">
Trage elementele pe etichetă. Previzualizarea folosește date demo; la tipărire se folosesc datele reale.
</p>
</div>
<div class="designer-actions">
<span id="designer-state" class="badge off">nesalvat</span>
<a class="btn ghost" target="_blank" rel="noopener"
href="{{ url_for('label_templates.preview', template_id=template.id) }}">Previzualizare</a>
<button class="btn primary" type="button" id="designer-save">Salvează</button>
</div>
</header>
<div class="designer-toolbar">
<div class="tool-group tool-grow">
<h2>Adaugă element</h2>
<div class="palette">
<button class="palette-btn" type="button" data-add-picker="variable">Câmp variabil</button>
<button class="palette-btn" type="button" data-add="text">Text</button>
<button class="palette-btn" type="button" data-add="qr">Cod QR</button>
<button class="palette-btn" type="button" data-add="line">Linie</button>
<button class="palette-btn" type="button" data-add="image">Imagine</button>
</div>
</div>
<div class="tool-group">
<h2>Etichetă (mm)</h2>
<div class="tool-fields">
<div class="field">
<label for="label-width">Lățime</label>
<input id="label-width" type="number" min="10" max="500" step="0.5">
</div>
<div class="field">
<label for="label-height">Înălțime</label>
<input id="label-height" type="number" min="10" max="500" step="0.5">
</div>
</div>
</div>
<div class="tool-group">
<h2>Zoom</h2>
<input id="designer-zoom" class="zoom-slider" type="range" min="3" max="14" step="0.5">
</div>
<div class="tool-group tool-safe">
<h2>Zonă sigură</h2>
<label class="check"><input id="safe-toggle" type="checkbox" checked>
Contur roșu</label>
<p id="safe-hint" class="safe-hint muted">Zona sigură: {{ safe_mm }} mm de la fiecare margine.</p>
<button id="safe-arrange" class="btn warn hidden" type="button"
title="Mută elementele care ies din zona sigură în interiorul conturului roșu">
Rearanjează în zona sigură
</button>
</div>
</div>
<div class="designer-grid">
<aside class="designer-panel designer-elements">
<h2>Elemente pe etichetă <span id="block-count" class="count">0</span></h2>
<ul id="block-list" class="block-list"></ul>
<form method="post" class="designer-clear"
action="{{ url_for('label_templates.designer_reset', template_id=template.id) }}"
onsubmit="return confirm('Resetezi designul la varianta implicită? Modificările nesalvate se pierd.');">
<button class="link danger" type="submit">Resetează designul</button>
</form>
</aside>
<section class="designer-canvas-wrap">
<div class="canvas-scroll">
<div class="canvas-stage">
<div id="canvas" class="design-canvas">
<div class="canvas-grid"></div>
<div class="canvas-blocks" id="canvas-blocks"></div>
<div class="canvas-safe" id="canvas-safe"><span>zonă sigură</span></div>
</div>
</div>
</div>
<p class="muted small">
Trage de element pentru mutare, de colțul din dreapta-jos pentru redimensionare.
Săgețile mută cu 0,5 mm (Shift: 2 mm). Delete șterge elementul.
</p>
<p class="muted small">
Recomandat: păstrează toate elementele în interiorul conturului roșu — imprimantele
termice nu pot tipări până la marginea hârtiei.
</p>
</section>
<aside class="designer-panel" id="props">
<h2>Proprietăți</h2>
<p class="muted small" id="props-empty">Selectează un element.</p>
<div id="props-body" class="hidden"></div>
</aside>
</div>
<div id="field-picker" class="picker hidden" role="dialog" aria-modal="true"
aria-labelledby="field-picker-title">
<div class="picker-backdrop" data-picker-close></div>
<div class="picker-card">
<header class="picker-head">
<h2 id="field-picker-title">Alege câmpul variabil</h2>
<button class="picker-close" type="button" data-picker-close aria-label="Închide">×</button>
</header>
<input id="field-picker-search" type="search" autocomplete="off"
placeholder="Caută un câmp…">
<ul id="field-picker-list" class="picker-list"></ul>
<footer class="picker-foot">
<span class="muted small" id="field-picker-hint">Click pe un câmp pentru a-l adăuga pe etichetă.</span>
<span class="picker-foot-actions">
<a class="link" href="{{ url_for('fields.index') }}">Gestionează câmpurile</a>
<button class="btn primary hidden" type="button" id="field-picker-confirm">Adaugă</button>
<button class="btn ghost" type="button" data-picker-close>Anulează</button>
</span>
</footer>
</div>
</div>
<div id="toast" class="toast" role="status" aria-live="polite"></div>
</div>
{% endblock %}
{% block scripts %}
<script>
window.DESIGNER = {
design: {{ design|tojson }},
fields: {{ fields|tojson }},
demoFields: {{ demo_fields|tojson }},
demoItems: {{ demo_items|tojson }},
safeMm: {{ safe_mm }}
};
</script>
<script src="{{ url_for('static', filename='js/designer.js') }}"></script>
{% endblock %}
+59
View File
@@ -0,0 +1,59 @@
{% extends "base.html" %}
{% block title %}Șabloane etichete{% endblock %}
{% block content %}
<section class="page-head">
<div>
<h1>Șabloane etichete</h1>
<p class="subtitle">Desenează eticheta în designer; fiecare produs finit poate folosi alt șablon.</p>
</div>
<div class="actions">
<form method="post" action="{{ url_for('label_templates.designer_create') }}"
class="inline new-template-form">
<input type="text" name="name" maxlength="80" required
placeholder="Nume șablon nou" aria-label="Nume șablon nou">
<button class="btn primary" type="submit">+ Șablon nou</button>
</form>
</div>
</section>
<section class="card">
{% if templates %}
<div class="table-wrap">
<table class="table">
<thead>
<tr><th>Nume</th><th>Dimensiune</th><th>Elemente</th><th>Implicit</th><th>Actualizat</th><th></th></tr>
</thead>
<tbody>
{% for t in templates %}
<tr>
<td>{{ t.name }}</td>
<td>{{ t.width_mm|weight }} × {{ t.height_mm|weight }} mm</td>
<td>
{% if t.design_json %}
<span class="badge ok">designer</span>
{% else %}
<span class="badge warn">fără design</span>
{% endif %}
</td>
<td>{% if t.is_default %}<span class="badge ok">implicit</span>{% else %}<span class="muted">—</span>{% endif %}</td>
<td class="nowrap muted">{{ t.updated_at|dt }}</td>
<td class="right nowrap">
<a class="link" href="{{ url_for('label_templates.designer_page', template_id=t.id) }}">Designer</a>
<a class="link" target="_blank" rel="noopener"
href="{{ url_for('label_templates.preview', template_id=t.id) }}">Previzualizare</a>
<form class="inline" method="post" action="{{ url_for('label_templates.remove', template_id=t.id) }}"
onsubmit="return confirm('Ștergi șablonul {{ t.name }}?');">
<button class="link danger" type="submit">Șterge</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="muted">Nu există șabloane. <a href="{{ url_for('label_templates.index') }}">Creează unul</a>.</p>
{% endif %}
</section>
{% endblock %}