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:
@@ -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 & 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 & 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>
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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>
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
Reference in New Issue
Block a user