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
+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 %}