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,726 @@
|
||||
:root {
|
||||
--bg: #eef2f6;
|
||||
--panel: #ffffff;
|
||||
--panel-soft: #f7f9fb;
|
||||
--ink: #0f172a;
|
||||
--muted: #64748b;
|
||||
--line: #dbe3ec;
|
||||
--brand: #0f766e;
|
||||
--brand-dark: #115e59;
|
||||
--brand-soft: #ccfbf1;
|
||||
--ok: #15803d;
|
||||
--ok-soft: #dcfce7;
|
||||
--warn: #b45309;
|
||||
--warn-soft: #fef3c7;
|
||||
--err: #b91c1c;
|
||||
--err-soft: #fee2e2;
|
||||
--radius: 12px;
|
||||
--shadow: 0 1px 2px rgba(15, 23, 42, .05), 0 8px 24px rgba(15, 23, 42, .06);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
font: 15px/1.5 system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
}
|
||||
|
||||
a { color: var(--brand-dark); text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
|
||||
code, .code, pre, .code-block {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: .9em;
|
||||
}
|
||||
|
||||
/* -- layout -------------------------------------------------------------- */
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
padding: 12px 24px;
|
||||
background: #0b1220;
|
||||
color: #e2e8f0;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
letter-spacing: .2px;
|
||||
}
|
||||
.brand:hover { text-decoration: none; }
|
||||
|
||||
.brand-mark {
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 8px;
|
||||
background: linear-gradient(135deg, #14b8a6, #0f766e);
|
||||
color: #04211d;
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.nav { display: flex; gap: 4px; flex-wrap: wrap; }
|
||||
.nav a {
|
||||
color: #cbd5e1;
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.nav a:hover { background: #1e293b; color: #fff; text-decoration: none; }
|
||||
.nav a.active { background: #14532d; color: #d1fae5; }
|
||||
|
||||
/* -- subnavigatie pentru sectiunea Setari -------------------------------- */
|
||||
.subnav {
|
||||
position: sticky;
|
||||
top: 62px; /* inaltimea topbar-ului */
|
||||
z-index: 19;
|
||||
background: #f8fafc;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.subnav-inner {
|
||||
max-width: 1180px;
|
||||
margin: 0 auto;
|
||||
padding: 8px 24px;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
.subnav a {
|
||||
flex: none;
|
||||
padding: 7px 14px;
|
||||
border-radius: 999px;
|
||||
font-size: 13.5px;
|
||||
font-weight: 600;
|
||||
color: #475569;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.subnav a:hover { background: #e2e8f0; color: var(--ink); text-decoration: none; }
|
||||
.subnav a.active { background: var(--brand); color: #fff; }
|
||||
|
||||
/* -- carduri de administrare (hub in Setari) ----------------------------- */
|
||||
.hub-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
|
||||
gap: 12px;
|
||||
margin: 0 0 18px;
|
||||
}
|
||||
.hub-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 14px 16px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: var(--panel);
|
||||
box-shadow: var(--shadow);
|
||||
color: inherit;
|
||||
}
|
||||
.hub-card:hover { border-color: var(--brand); background: #fff; text-decoration: none; }
|
||||
.hub-title { font-weight: 700; font-size: 15px; }
|
||||
.hub-meta { font-size: 12.5px; color: var(--muted); }
|
||||
|
||||
/* -- QZ Tray status ------------------------------------------------------ */
|
||||
.qz-pill {
|
||||
margin-left: auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 7px 13px;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 999px;
|
||||
background: #111c2e;
|
||||
color: #cbd5e1;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.qz-pill:hover { background: #1e293b; }
|
||||
.qz-pill:disabled { cursor: default; opacity: 1; }
|
||||
.qz-pill[data-qz-state="ready"] { border-color: #15803d; color: #bbf7d0; }
|
||||
.qz-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #94a3b8;
|
||||
flex: none;
|
||||
}
|
||||
.qz-pill[data-qz-state="ready"] .qz-dot { background: #22c55e; box-shadow: 0 0 0 3px rgba(34, 197, 94, .2); }
|
||||
.qz-pill[data-qz-state="connecting"] .qz-dot { background: #f59e0b; }
|
||||
.qz-pill[data-qz-state="offline"] .qz-dot,
|
||||
.qz-pill[data-qz-state="missing"] .qz-dot { background: #ef4444; }
|
||||
|
||||
.qz-hint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
padding-top: 12px;
|
||||
margin-top: 10px;
|
||||
border-top: 1px dashed var(--line);
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
.qz-state-line { display: flex; align-items: center; gap: 10px; margin: 0 0 14px; }
|
||||
|
||||
.container {
|
||||
max-width: 1240px;
|
||||
margin: 0 auto;
|
||||
padding: 24px 24px 56px;
|
||||
}
|
||||
|
||||
.footer {
|
||||
max-width: 1240px;
|
||||
margin: 0 auto;
|
||||
padding: 20px 24px 32px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.page-head {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
margin-bottom: 18px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.page-head h1 { margin: 0 0 4px; font-size: 26px; letter-spacing: -.4px; }
|
||||
.subtitle { margin: 0; color: var(--muted); }
|
||||
|
||||
.stats { display: flex; gap: 10px; }
|
||||
.stat {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
padding: 10px 16px;
|
||||
min-width: 110px;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.stat-value { display: block; font-size: 22px; font-weight: 800; line-height: 1.1; }
|
||||
.stat-label { display: block; color: var(--muted); font-size: 12px; }
|
||||
|
||||
/* -- cards --------------------------------------------------------------- */
|
||||
.card {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
padding: 20px;
|
||||
box-shadow: var(--shadow);
|
||||
min-width: 0;
|
||||
}
|
||||
.card h2 { margin: 0 0 14px; font-size: 17px; }
|
||||
.card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.card-head h2 { margin: 0; }
|
||||
.card.narrow { max-width: 620px; }
|
||||
|
||||
.grid { display: grid; gap: 18px; }
|
||||
.weigh-grid { grid-template-columns: minmax(0, 1.05fr) minmax(0, .95fr); margin-bottom: 18px; }
|
||||
.template-grid { grid-template-columns: minmax(0, 1.4fr) minmax(0, .8fr); align-items: start; }
|
||||
.settings-grid { grid-template-columns: minmax(0, 1.1fr) minmax(0, .9fr); align-items: start; }
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.weigh-grid, .template-grid, .settings-grid { grid-template-columns: 1fr; }
|
||||
/* cele doua taburi principale raman vizibile si pe ecrane mici */
|
||||
.topbar { gap: 12px; padding: 10px 14px; flex-wrap: wrap; }
|
||||
.brand-text { display: none; }
|
||||
.nav a { padding: 7px 10px; font-size: 14px; }
|
||||
.subnav { top: 54px; }
|
||||
.subnav-inner { padding: 6px 14px; }
|
||||
.subnav a { padding: 6px 11px; font-size: 12.5px; }
|
||||
}
|
||||
|
||||
/* -- forms --------------------------------------------------------------- */
|
||||
.form, .filters { display: flex; flex-direction: column; gap: 14px; }
|
||||
.filters { flex-direction: row; flex-wrap: wrap; align-items: flex-end; }
|
||||
|
||||
.field { display: flex; flex-direction: column; gap: 6px; min-width: 0; }
|
||||
.field > label { font-weight: 600; font-size: 13px; color: #334155; }
|
||||
.field-row { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
|
||||
.field.grow { flex: 1 1 220px; }
|
||||
.actions-field { flex-direction: row; gap: 8px; }
|
||||
|
||||
input[type="text"], input[type="search"], input[type="date"], input[type="number"],
|
||||
select, textarea {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #c8d3e0;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
color: var(--ink);
|
||||
font: inherit;
|
||||
}
|
||||
input:focus, select:focus, textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--brand);
|
||||
box-shadow: 0 0 0 3px rgba(15, 118, 110, .16);
|
||||
}
|
||||
textarea { resize: vertical; }
|
||||
textarea.code { font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 13px; line-height: 1.45; }
|
||||
|
||||
.check { display: flex; align-items: center; gap: 8px; font-size: 14px; color: #334155; }
|
||||
.req { color: var(--err); }
|
||||
.inline-select { width: auto; padding: 6px 10px; font-size: 13px; }
|
||||
.inline { display: inline; }
|
||||
|
||||
.actions { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 16px; }
|
||||
|
||||
/* Formularul de creare sablon: nume + buton, pe un singur rand. */
|
||||
.new-template-form { display: flex; align-items: center; gap: 8px; }
|
||||
.new-template-form input[type="text"] { width: 190px; padding: 9px 11px; }
|
||||
.new-template-form .btn { flex: none; }
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 10px 16px;
|
||||
border: 1px solid #c8d3e0;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
color: var(--ink);
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
.btn:hover { background: var(--panel-soft); text-decoration: none; }
|
||||
.btn:disabled { opacity: .5; cursor: not-allowed; }
|
||||
.btn.primary { background: var(--brand); border-color: var(--brand); color: #fff; }
|
||||
.btn.primary:hover { background: var(--brand-dark); }
|
||||
.btn.ghost { background: transparent; }
|
||||
.btn.danger { color: var(--err); }
|
||||
.btn.warn {
|
||||
background: #fffbeb;
|
||||
border-color: #f59e0b;
|
||||
color: #92400e;
|
||||
font-weight: 600;
|
||||
}
|
||||
.btn.warn:hover { background: #fef3c7; border-color: #d97706; }
|
||||
|
||||
.link {
|
||||
background: none;
|
||||
border: 0;
|
||||
padding: 0 6px;
|
||||
color: var(--brand-dark);
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
.link:hover { text-decoration: underline; }
|
||||
.link.danger { color: var(--err); }
|
||||
|
||||
/* -- scale display ------------------------------------------------------- */
|
||||
.scale-card { display: flex; flex-direction: column; }
|
||||
.scale-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.dot { width: 10px; height: 10px; border-radius: 50%; background: #94a3b8; }
|
||||
.dot.ok { background: #22c55e; box-shadow: 0 0 0 4px rgba(34, 197, 94, .18); }
|
||||
.dot.off { background: #ef4444; box-shadow: 0 0 0 4px rgba(239, 68, 68, .15); }
|
||||
.chip {
|
||||
margin-left: auto;
|
||||
background: var(--panel-soft);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
padding: 3px 10px;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.weight-display {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
padding: 26px 12px 18px;
|
||||
background:
|
||||
radial-gradient(circle at 50% 0%, rgba(20, 184, 166, .12), transparent 62%),
|
||||
var(--panel-soft);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
#weight-value {
|
||||
font-size: clamp(48px, 9vw, 88px);
|
||||
font-weight: 800;
|
||||
letter-spacing: -2px;
|
||||
line-height: 1;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
#weight-value.unstable { color: #b45309; }
|
||||
#weight-value.error { color: #b91c1c; }
|
||||
.weight-unit { font-size: 24px; font-weight: 700; color: var(--muted); }
|
||||
|
||||
.weight-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
margin: 12px 0 4px;
|
||||
min-height: 28px;
|
||||
}
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 3px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
background: #e2e8f0;
|
||||
color: #334155;
|
||||
}
|
||||
.badge.ok { background: var(--ok-soft); color: var(--ok); }
|
||||
.badge.warn { background: var(--warn-soft); color: var(--warn); }
|
||||
.badge.err { background: var(--err-soft); color: var(--err); }
|
||||
.badge.off { background: #e2e8f0; color: var(--muted); }
|
||||
.raw { margin-left: auto; color: var(--muted); font-family: ui-monospace, Menlo, monospace; font-size: 11px; }
|
||||
|
||||
.scale-hint {
|
||||
margin-top: auto;
|
||||
padding-top: 14px;
|
||||
border-top: 1px dashed var(--line);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* -- messages ------------------------------------------------------------ */
|
||||
.message { margin: 12px 0 0; min-height: 20px; font-weight: 500; }
|
||||
.message.error { color: var(--err); }
|
||||
.message.success { color: var(--ok); }
|
||||
.message.info { color: var(--muted); }
|
||||
.muted { color: var(--muted); }
|
||||
.small { font-size: 12.5px; }
|
||||
|
||||
.notice {
|
||||
padding: 14px 16px;
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid var(--line);
|
||||
margin-bottom: 16px;
|
||||
background: var(--panel);
|
||||
}
|
||||
.notice.warning { background: var(--warn-soft); border-color: #fde68a; color: #78350f; }
|
||||
.notice a { color: inherit; text-decoration: underline; }
|
||||
|
||||
.flash-stack { display: flex; flex-direction: column; gap: 8px; margin-bottom: 16px; }
|
||||
.flash {
|
||||
padding: 12px 16px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--panel);
|
||||
font-weight: 500;
|
||||
}
|
||||
.flash-success { background: var(--ok-soft); border-color: #bbf7d0; color: #14532d; }
|
||||
.flash-error { background: var(--err-soft); border-color: #fecaca; color: #7f1d1d; }
|
||||
|
||||
/* -- tables -------------------------------------------------------------- */
|
||||
.table-wrap { overflow-x: auto; }
|
||||
.table { width: 100%; border-collapse: collapse; }
|
||||
.table th, .table td {
|
||||
text-align: left;
|
||||
padding: 11px 12px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
vertical-align: middle;
|
||||
}
|
||||
.table th {
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .4px;
|
||||
color: var(--muted);
|
||||
background: var(--panel-soft);
|
||||
}
|
||||
.table tbody tr:hover { background: #f8fafc; }
|
||||
.table.compact th, .table.compact td { padding: 8px 10px; }
|
||||
.table .num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||||
.table .right { text-align: right; }
|
||||
.nowrap { white-space: nowrap; }
|
||||
.row-muted td { color: var(--muted); }
|
||||
|
||||
/* -- help / settings ----------------------------------------------------- */
|
||||
.help-card { background: var(--panel-soft); }
|
||||
.help-card h2:not(:first-child) { margin-top: 22px; }
|
||||
.var-list { margin: 0; padding-left: 18px; display: flex; flex-direction: column; gap: 6px; font-size: 13.5px; }
|
||||
.var-list code { background: #e2e8f0; padding: 1px 5px; border-radius: 4px; }
|
||||
.code-block {
|
||||
margin: 0;
|
||||
padding: 12px;
|
||||
background: #0b1220;
|
||||
color: #d1fae5;
|
||||
border-radius: 8px;
|
||||
overflow-x: auto;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.profile-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 10px; }
|
||||
.profile-list li { padding: 10px 12px; background: #fff; border: 1px solid var(--line); border-radius: 8px; }
|
||||
|
||||
/* -- monitor serial ------------------------------------------------------ */
|
||||
.monitor-options {
|
||||
display: flex;
|
||||
gap: 18px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 14px;
|
||||
padding-top: 14px;
|
||||
border-top: 1px dashed var(--line);
|
||||
}
|
||||
.monitor-options .check { font-size: 13px; }
|
||||
.card h3 { margin: 20px 0 10px; font-size: 15px; color: #1e293b; }
|
||||
code.hex {
|
||||
display: inline-block;
|
||||
max-width: 420px;
|
||||
overflow-wrap: anywhere;
|
||||
font-size: 11.5px;
|
||||
color: #0f766e;
|
||||
}
|
||||
|
||||
/* -- fluxul de cântărire ------------------------------------------------ */
|
||||
.flow-card { display: flex; flex-direction: column; }
|
||||
.flow-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.flow-head h2 { margin: 0; }
|
||||
.flow-body { flex: 1; display: flex; flex-direction: column; gap: 14px; }
|
||||
|
||||
.step {
|
||||
padding: 18px;
|
||||
border-radius: var(--radius);
|
||||
background:
|
||||
radial-gradient(circle at 50% 0%, rgba(20, 184, 166, .14), transparent 65%),
|
||||
var(--panel-soft);
|
||||
border: 1px solid var(--line);
|
||||
text-align: center;
|
||||
}
|
||||
.step-label {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: .6px;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
}
|
||||
.step-component {
|
||||
font-size: clamp(28px, 4.4vw, 44px);
|
||||
font-weight: 800;
|
||||
letter-spacing: -.6px;
|
||||
line-height: 1.1;
|
||||
margin: 4px 0 2px;
|
||||
}
|
||||
.step-target { color: var(--brand-dark); font-weight: 700; }
|
||||
|
||||
.plan {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
font-size: 14px;
|
||||
color: #334155;
|
||||
}
|
||||
.plan li {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 8px;
|
||||
background: var(--panel-soft);
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
.plan .w {
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: 700;
|
||||
min-width: 74px;
|
||||
color: var(--ink);
|
||||
}
|
||||
.plan.accepted li { background: #f0fdf4; border-color: #bbf7d0; }
|
||||
|
||||
.done { text-align: center; padding: 8px 0 2px; }
|
||||
.done-mark {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
margin: 0 auto 8px;
|
||||
border-radius: 50%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: var(--ok-soft);
|
||||
color: var(--ok);
|
||||
font-size: 26px;
|
||||
font-weight: 800;
|
||||
}
|
||||
.done-total { display: flex; align-items: baseline; justify-content: center; gap: 8px; }
|
||||
.done-total .value { font-size: 40px; font-weight: 800; letter-spacing: -1px; }
|
||||
.done-total .unit { font-size: 18px; font-weight: 700; color: var(--muted); }
|
||||
|
||||
.component-row {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
background: var(--panel-soft);
|
||||
}
|
||||
.component-index {
|
||||
flex: none;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 50%;
|
||||
background: var(--brand);
|
||||
color: #fff;
|
||||
font-weight: 800;
|
||||
font-size: 13px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.component-target { flex: none; width: 110px; }
|
||||
|
||||
.hint { margin: 0; }
|
||||
|
||||
/* -- print / toast ------------------------------------------------------- */
|
||||
.print-frame {
|
||||
position: fixed;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
left: -9999px;
|
||||
top: -9999px;
|
||||
border: 0;
|
||||
visibility: hidden;
|
||||
}
|
||||
.hidden { display: none !important; }
|
||||
|
||||
.toast {
|
||||
position: fixed;
|
||||
right: 24px;
|
||||
bottom: 24px;
|
||||
max-width: 420px;
|
||||
padding: 12px 16px;
|
||||
border-radius: 10px;
|
||||
background: #0b1220;
|
||||
color: #e2e8f0;
|
||||
box-shadow: 0 12px 32px rgba(15, 23, 42, .3);
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
transition: opacity .18s ease, transform .18s ease;
|
||||
pointer-events: none;
|
||||
font-weight: 500;
|
||||
z-index: 50;
|
||||
}
|
||||
.toast.show { opacity: 1; transform: translateY(0); }
|
||||
.toast.error { background: #7f1d1d; color: #fee2e2; }
|
||||
.toast.warn { background: #78350f; color: #fef3c7; }
|
||||
.toast.success { background: #14532d; color: #dcfce7; }
|
||||
|
||||
/* -- campuri variabile --------------------------------------------------- */code.field-template {
|
||||
display: inline-block;
|
||||
max-width: 320px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
vertical-align: bottom;
|
||||
padding: 2px 6px;
|
||||
border-radius: 5px;
|
||||
background: var(--panel-soft);
|
||||
font-size: 12px;
|
||||
}
|
||||
.field-preview {
|
||||
padding: 10px 12px;
|
||||
border: 1px dashed #c8d3e0;
|
||||
border-radius: 8px;
|
||||
background: var(--panel-soft);
|
||||
font-size: 15px;
|
||||
min-height: 42px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
.field-preview.error { border-color: #fca5a5; background: #fef2f2; color: #b91c1c; font-size: 13px; }
|
||||
.field-chips { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.field-chip {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 7px 11px;
|
||||
border: 1px solid #c8d3e0;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
.field-chip:hover { border-color: var(--brand); background: var(--brand-soft); }
|
||||
.chip-label { font-size: 12.5px; font-weight: 600; }
|
||||
.chip-sample { font-size: 11px; color: var(--muted); }
|
||||
|
||||
/* -- checklist de pornire ------------------------------------------------ */
|
||||
.setup-card { margin-bottom: 18px; border-left: 4px solid #f59e0b; }
|
||||
.setup-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.setup-head h2 { margin: 0 0 2px; }
|
||||
.setup-head p { margin: 0; }
|
||||
.setup-list { list-style: none; margin: 14px 0 0; padding: 0; display: grid; gap: 8px; }
|
||||
.setup-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
background: var(--panel-soft);
|
||||
}
|
||||
.setup-mark {
|
||||
flex: none;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
background: #fde68a;
|
||||
color: #92400e;
|
||||
font-weight: 800;
|
||||
font-size: 13px;
|
||||
}
|
||||
.setup-body { display: flex; flex-direction: column; gap: 2px; flex: 1 1 auto; min-width: 0; }
|
||||
.setup-item .btn { flex: none; padding: 7px 12px; font-size: 13px; }
|
||||
|
||||
.system-list { list-style: none; margin: 6px 0 12px; padding: 0; display: grid; gap: 4px; }
|
||||
.system-list code { word-break: break-all; font-size: 11.5px; }
|
||||
@@ -0,0 +1,467 @@
|
||||
/* Designer de etichete: layout, canvas, panouri. */
|
||||
|
||||
.designer { display: flex; flex-direction: column; gap: 14px; }
|
||||
|
||||
.designer-head {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.designer-head h1 { margin: 0 0 4px; font-size: 24px; letter-spacing: -.3px; }
|
||||
.designer-actions { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||||
|
||||
/* Numele sablonului se editeaza direct din antet. */
|
||||
.designer-head-main { min-width: 0; flex: 1 1 320px; }
|
||||
.rename-form { display: flex; align-items: center; gap: 8px; margin: 0 0 4px; }
|
||||
.rename-form .btn { padding: 7px 12px; font-size: 13px; flex: none; }
|
||||
.title-input {
|
||||
width: min(420px, 100%);
|
||||
padding: 6px 10px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -.3px;
|
||||
color: var(--ink);
|
||||
}
|
||||
.title-input:hover { border-color: var(--line); background: #fff; }
|
||||
.title-input:focus {
|
||||
border-color: var(--brand);
|
||||
background: #fff;
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px rgba(15, 118, 110, .16);
|
||||
}
|
||||
|
||||
.designer-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 200px minmax(0, 1fr) 260px;
|
||||
gap: 14px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
/* -- bara de sus: adaugare, dimensiuni, zoom, zona sigura ---------------- */
|
||||
.designer-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
gap: 22px;
|
||||
padding: 12px 16px 14px;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.designer-toolbar h2 {
|
||||
margin: 0 0 7px;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .5px;
|
||||
color: var(--muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.tool-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
.tool-grow { flex: 1 1 320px; }
|
||||
.tool-group + .tool-group {
|
||||
padding-left: 22px;
|
||||
border-left: 1px solid var(--line);
|
||||
}
|
||||
.tool-fields { display: flex; gap: 10px; }
|
||||
.tool-fields .field { gap: 4px; }
|
||||
.tool-fields .field > label { font-size: 11.5px; }
|
||||
.tool-fields input { width: 82px; padding: 7px 9px; }
|
||||
.zoom-slider { width: 130px; margin: 6px 0 0; }
|
||||
.tool-safe { min-width: 168px; }
|
||||
.tool-safe .check { font-size: 13px; }
|
||||
.tool-safe .safe-hint { max-width: 210px; margin-top: 6px; }
|
||||
.tool-safe #safe-arrange {
|
||||
margin-top: 8px;
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
padding: 7px 10px;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.tool-group + .tool-group { padding-left: 0; border-left: 0; }
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.designer-grid { grid-template-columns: 1fr; }
|
||||
.designer-canvas-wrap { order: 1; }
|
||||
.designer-elements { order: 2; position: static; }
|
||||
#props { order: 3; position: static; }
|
||||
.designer-elements .block-list { max-height: 260px; }
|
||||
}
|
||||
|
||||
.designer-panel {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
padding: 16px;
|
||||
box-shadow: var(--shadow);
|
||||
position: sticky;
|
||||
top: 72px;
|
||||
}
|
||||
.designer-panel h2 {
|
||||
margin: 0 0 10px;
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .5px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.designer-panel h2 .count {
|
||||
display: inline-block;
|
||||
min-width: 20px;
|
||||
margin-left: 4px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 999px;
|
||||
background: var(--panel-soft);
|
||||
color: #334155;
|
||||
font-size: 11px;
|
||||
text-align: center;
|
||||
}
|
||||
.designer-panel h2:not(:first-child) { margin-top: 18px; }
|
||||
.designer-panel h3 {
|
||||
margin: 16px 0 8px;
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .5px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.designer-panel .field { margin-bottom: 8px; }
|
||||
.designer-panel .field > label { font-size: 12px; }
|
||||
.designer-panel .btn { padding: 8px 12px; font-size: 13px; }
|
||||
|
||||
.palette { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.palette-btn {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #c8d3e0;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.palette-btn:hover { background: var(--brand-soft); border-color: var(--brand); }
|
||||
|
||||
.designer-elements .block-list {
|
||||
max-height: calc(100vh - 330px);
|
||||
overflow-y: auto;
|
||||
padding-right: 2px;
|
||||
}
|
||||
|
||||
.block-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 4px; }
|
||||
.block-item {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 7px 9px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px;
|
||||
background: var(--panel-soft);
|
||||
font: inherit;
|
||||
font-size: 12.5px;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.block-item:hover { background: #eef2f6; }
|
||||
.block-item.active { border-color: var(--brand); background: var(--brand-soft); font-weight: 700; }
|
||||
|
||||
.designer-canvas-wrap { min-width: 0; }
|
||||
.canvas-scroll {
|
||||
overflow: auto;
|
||||
padding: 18px;
|
||||
background:
|
||||
linear-gradient(#f1f5f9, #f1f5f9) padding-box,
|
||||
var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.canvas-stage { display: flex; justify-content: center; }
|
||||
.design-canvas {
|
||||
position: relative;
|
||||
background: #fff;
|
||||
box-shadow: 0 4px 18px rgba(15, 23, 42, .16);
|
||||
outline: 1px solid #cbd5e1;
|
||||
flex: none;
|
||||
touch-action: none;
|
||||
}
|
||||
.canvas-grid {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background-image:
|
||||
linear-gradient(to right, rgba(15, 23, 42, .07) 1px, transparent 1px),
|
||||
linear-gradient(to bottom, rgba(15, 23, 42, .07) 1px, transparent 1px);
|
||||
background-size: var(--grid, 60px) var(--grid, 60px);
|
||||
}
|
||||
.canvas-blocks { position: absolute; inset: 0; }
|
||||
|
||||
/* Conturul zonei sigure: 3 mm de la marginea etichetei. Doar vizual. */
|
||||
.canvas-safe {
|
||||
position: absolute;
|
||||
border: 1px dashed #dc2626;
|
||||
pointer-events: none;
|
||||
z-index: 3;
|
||||
}
|
||||
.canvas-safe span {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 3px;
|
||||
font-size: 8px;
|
||||
line-height: 1;
|
||||
letter-spacing: .3px;
|
||||
text-transform: uppercase;
|
||||
color: #dc2626;
|
||||
opacity: .65;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.safe-hint { margin: 4px 0 0; font-size: 12px; }
|
||||
.safe-hint.ok { color: #15803d; }
|
||||
.safe-hint.warn { color: #b91c1c; font-weight: 600; }
|
||||
|
||||
.c-block {
|
||||
position: absolute;
|
||||
box-sizing: border-box;
|
||||
cursor: move;
|
||||
user-select: none;
|
||||
outline: 1px solid transparent;
|
||||
}
|
||||
.c-block:hover { outline-color: rgba(15, 118, 110, .45); }
|
||||
.c-block.selected { outline: 2px solid var(--brand); }
|
||||
.c-block.outside-safe { outline: 1px dashed #dc2626; }
|
||||
.c-block.outside-safe.selected { outline: 2px solid var(--brand); }
|
||||
.c-block.outside-safe::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(220, 38, 38, .08);
|
||||
pointer-events: none;
|
||||
}
|
||||
.c-block.c-line { min-height: 0; }
|
||||
.c-block.c-media { overflow: hidden; }
|
||||
.c-block.c-media img { width: 100%; height: 100%; object-fit: contain; display: block; }
|
||||
.c-block.c-empty { background: repeating-linear-gradient(45deg, #f8fafc, #f8fafc 4px, #e2e8f0 4px, #e2e8f0 8px); }
|
||||
.c-block .d-item { white-space: nowrap; }
|
||||
.c-block .d-item-w { font-weight: 700; }
|
||||
|
||||
/* Manerul sta in interiorul blocului: blocurile media au overflow:hidden si
|
||||
* ar taia un maner pozitionat in afara (nu ar mai putea fi apucat). */
|
||||
.c-resize {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
border-radius: 2px;
|
||||
background: var(--brand);
|
||||
border: 1px solid #fff;
|
||||
box-shadow: 0 0 0 1px rgba(15, 23, 42, .15);
|
||||
cursor: nwse-resize;
|
||||
display: none;
|
||||
z-index: 2;
|
||||
}
|
||||
.c-block.selected .c-resize { display: block; }
|
||||
|
||||
.props-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
|
||||
.props-row { display: flex; gap: 8px; }
|
||||
.props-checks { display: flex; flex-direction: column; gap: 4px; margin: 8px 0; }
|
||||
.props-checks .check { font-size: 12.5px; }
|
||||
.designer-panel input[type="color"] { width: 100%; height: 34px; padding: 2px; }
|
||||
.designer-clear { margin-top: 14px; padding-top: 12px; border-top: 1px dashed var(--line); }
|
||||
|
||||
/* -- popup: alegerea campului variabil ----------------------------------- */
|
||||
.picker {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 60;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
}
|
||||
.picker-backdrop { position: absolute; inset: 0; background: rgba(15, 23, 42, .45); }
|
||||
.picker-card {
|
||||
position: relative;
|
||||
width: min(520px, 100%);
|
||||
max-height: min(640px, calc(100vh - 40px));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 18px;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 24px 60px rgba(15, 23, 42, .28);
|
||||
}
|
||||
.picker-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
||||
.picker-head h2 { margin: 0; font-size: 16px; }
|
||||
.picker-close {
|
||||
padding: 2px 8px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
font-size: 22px;
|
||||
line-height: 1;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
.picker-close:hover { background: var(--panel-soft); color: var(--ink); }
|
||||
.picker-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
min-height: 120px;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
.picker-group {
|
||||
padding: 10px 4px 3px;
|
||||
font-size: 10.5px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .5px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.picker-item {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 8px;
|
||||
background: var(--panel-soft);
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
.picker-item:hover, .picker-item:focus-visible {
|
||||
border-color: var(--brand);
|
||||
background: var(--brand-soft);
|
||||
outline: none;
|
||||
}
|
||||
.picker-label { font-size: 13.5px; font-weight: 600; }
|
||||
.picker-item.recent .picker-label::after {
|
||||
content: ' · ultimul';
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
color: var(--brand);
|
||||
}
|
||||
.picker-sample { font-size: 11.5px; color: var(--muted); }
|
||||
.picker-empty { padding: 18px 6px; text-align: center; font-size: 13px; color: var(--muted); }
|
||||
.picker-foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
.picker-foot-actions { display: flex; align-items: center; gap: 12px; }
|
||||
.picker-item.custom .picker-label::before {
|
||||
content: '★ ';
|
||||
color: var(--brand);
|
||||
}
|
||||
.picker-item.picked {
|
||||
border-color: var(--brand);
|
||||
background: var(--brand-soft);
|
||||
}
|
||||
.picker-check {
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
margin-right: 6px;
|
||||
border: 1px solid #94a3b8;
|
||||
border-radius: 3px;
|
||||
background: #fff;
|
||||
font-size: 10px;
|
||||
line-height: 1;
|
||||
color: var(--brand);
|
||||
vertical-align: middle;
|
||||
}
|
||||
.picker-item.picked .picker-check { border-color: var(--brand); }
|
||||
|
||||
/* -- continutul codului QR ------------------------------------------------ */
|
||||
.qr-parts { margin-top: 6px; }
|
||||
.qr-parts-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-bottom: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #334155;
|
||||
}
|
||||
.qr-part-list { list-style: none; margin: 0 0 8px; padding: 0; display: grid; gap: 4px; }
|
||||
.qr-part {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 5px 6px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 7px;
|
||||
background: var(--panel-soft);
|
||||
}
|
||||
.qr-part-num {
|
||||
flex: none;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background: #e2e8f0;
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
color: #475569;
|
||||
}
|
||||
.qr-part-body { display: flex; flex-direction: column; gap: 1px; min-width: 0; flex: 1 1 auto; }
|
||||
.qr-part-body strong { font-size: 12px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.qr-part-sample { font-size: 10.5px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.qr-part input[type="text"] { flex: 1 1 auto; min-width: 0; padding: 5px 7px; font-size: 12px; }
|
||||
.qr-part-tools { display: flex; gap: 2px; flex: none; }
|
||||
.qr-part-btn {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 5px;
|
||||
background: #fff;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
color: #475569;
|
||||
cursor: pointer;
|
||||
}
|
||||
.qr-part-btn:hover:not(:disabled) { border-color: var(--brand); color: var(--brand); }
|
||||
.qr-part-btn:disabled { opacity: .35; cursor: default; }
|
||||
.qr-part-btn.danger:hover { border-color: #dc2626; color: #dc2626; }
|
||||
.qr-part-actions { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
.qr-part-actions .btn { padding: 6px 10px; font-size: 12.5px; }
|
||||
.qr-preview-line {
|
||||
margin: 0 0 8px;
|
||||
padding: 5px 7px;
|
||||
border: 1px dashed var(--line);
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
word-break: break-all;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.hidden { display: none !important; }
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,489 @@
|
||||
/**
|
||||
* QZ Tray printing for Cantarire & Etichetare.
|
||||
*
|
||||
* Mirrors the working integration from `quality_app-v2`:
|
||||
* - the patched `qz-tray.js` (2.2.4) that skips the certificate exchange and
|
||||
* talks to a custom QZ Tray service using pairing-key auth;
|
||||
* - `qz.printers.find()` + auto-selection of the thermal/label printer;
|
||||
* - `qz.configs.create(printer, { scaleContent:false, rasterize:.., size, units:'mm' })`;
|
||||
* - `qz.print(config, [{ type:'pdf'|'html', format:'base64'|'plain', data }])`.
|
||||
*
|
||||
* Public API (kept simple so the rest of the app does not care about QZ):
|
||||
* window.CantarirePrint.print(payload) -> Promise<{ok, method, printer?}>
|
||||
* window.CantarireQZ -> status / connect / printers / test
|
||||
*
|
||||
* `payload` is produced by `cantarire/labels.py::print_payload`:
|
||||
* { job_id, weighing_id, template:{width_mm,height_mm}, html, css,
|
||||
* fields, print_url, ack_url, pdf_url? }
|
||||
*
|
||||
* If `pdf_url` is present the label is fetched as PDF and sent with
|
||||
* `type:'pdf'` (the exact flow used by quality_app-v2). Otherwise the HTML
|
||||
* label is rasterized by QZ Tray at the physical size from the template.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var STORAGE_KEY = 'cantarire.qz.printer';
|
||||
|
||||
var config = Object.assign({
|
||||
autoConnect: true,
|
||||
fallbackToBrowser: true,
|
||||
connectTimeoutMs: 6000,
|
||||
storageKey: STORAGE_KEY,
|
||||
thermalHints: ['thermal', 'label', 'zebra', 'godex', 'argox', 'tsc', 'citizen',
|
||||
'xprinter', 'bixolon', 'epson', 'datamax', 'sato'],
|
||||
// Optional: standard QZ Tray (with certificate + signature) instead of the
|
||||
// patched pairing-key service. Set via window.CANTARIRE_QZ before this file.
|
||||
certificateUrl: '',
|
||||
signatureUrl: ''
|
||||
}, window.CANTARIRE_QZ || {});
|
||||
|
||||
var state = {
|
||||
status: 'idle', // idle | connecting | ready | offline | missing
|
||||
connected: false,
|
||||
printers: [],
|
||||
printer: '',
|
||||
version: '',
|
||||
error: null,
|
||||
lastFailureAt: 0
|
||||
};
|
||||
|
||||
var connectPromise = null;
|
||||
var lastRendered = '';
|
||||
|
||||
// -- helpers -------------------------------------------------------------
|
||||
function lib() { return window.qz; }
|
||||
|
||||
function snapshot() {
|
||||
return {
|
||||
status: state.status,
|
||||
connected: state.connected,
|
||||
printers: state.printers.slice(),
|
||||
printer: state.printer,
|
||||
version: state.version,
|
||||
error: state.error
|
||||
};
|
||||
}
|
||||
|
||||
function messageOf(error) {
|
||||
if (!error) return 'Eroare necunoscuta';
|
||||
return error.message || String(error);
|
||||
}
|
||||
|
||||
function setState(patch) {
|
||||
Object.assign(state, patch);
|
||||
emit();
|
||||
}
|
||||
|
||||
function emit() {
|
||||
var detail = snapshot();
|
||||
try {
|
||||
document.dispatchEvent(new CustomEvent('cantarire:qz', { detail: detail }));
|
||||
} catch (error) { /* browsere foarte vechi */ }
|
||||
syncUi();
|
||||
}
|
||||
|
||||
function savedPrinter() {
|
||||
try { return localStorage.getItem(config.storageKey) || ''; } catch (error) { return ''; }
|
||||
}
|
||||
|
||||
function savePrinter(name) {
|
||||
try {
|
||||
if (name) localStorage.setItem(config.storageKey, name);
|
||||
else localStorage.removeItem(config.storageKey);
|
||||
} catch (error) { /* mod privat */ }
|
||||
}
|
||||
|
||||
function choosePrinter(printers) {
|
||||
var saved = savedPrinter();
|
||||
if (saved && printers.indexOf(saved) >= 0) return saved;
|
||||
for (var i = 0; i < printers.length; i++) {
|
||||
var name = printers[i].toLowerCase();
|
||||
for (var j = 0; j < config.thermalHints.length; j++) {
|
||||
if (name.indexOf(config.thermalHints[j]) >= 0) return printers[i];
|
||||
}
|
||||
}
|
||||
return printers.length ? printers[0] : '';
|
||||
}
|
||||
|
||||
function setupSecurity(qz) {
|
||||
if (config.certificateUrl && qz.security && qz.security.setCertificatePromise) {
|
||||
qz.security.setCertificatePromise(function (resolve, reject) {
|
||||
fetch(config.certificateUrl, { cache: 'no-store' }).then(function (response) {
|
||||
if (!response.ok) throw new Error('certificat: HTTP ' + response.status);
|
||||
return response.text();
|
||||
}).then(resolve, reject);
|
||||
}, { rejectOnFailure: true });
|
||||
}
|
||||
if (config.signatureUrl && qz.security && qz.security.setSignaturePromise) {
|
||||
qz.security.setSignaturePromise(function (toSign) {
|
||||
return function (resolve, reject) {
|
||||
fetch(config.signatureUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ request: toSign })
|
||||
}).then(function (response) {
|
||||
if (!response.ok) throw new Error('semnatura: HTTP ' + response.status);
|
||||
return response.text();
|
||||
}).then(resolve, reject);
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// -- connection ----------------------------------------------------------
|
||||
function connect() {
|
||||
if (state.connected) return Promise.resolve(true);
|
||||
if (connectPromise) return connectPromise;
|
||||
|
||||
var qz = lib();
|
||||
if (!qz) {
|
||||
setState({
|
||||
status: 'missing',
|
||||
connected: false,
|
||||
error: 'Biblioteca qz-tray.js nu este incarcata.',
|
||||
version: ''
|
||||
});
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
|
||||
setState({ status: 'connecting', error: null });
|
||||
|
||||
// The QZ client sweeps several hosts/ports before giving up; reflect a
|
||||
// failure in the UI early. A connection that succeeds later still wins.
|
||||
var timeout = Number(config.connectTimeoutMs) || 6000;
|
||||
var watchdog = setTimeout(function () {
|
||||
if (!state.connected && state.status === 'connecting') {
|
||||
setState({
|
||||
status: 'offline',
|
||||
error: 'QZ Tray nu a raspuns in ' + timeout + ' ms (agentul este pornit?).'
|
||||
});
|
||||
}
|
||||
}, timeout);
|
||||
|
||||
connectPromise = Promise.resolve()
|
||||
.then(function () {
|
||||
try { setupSecurity(qz); } catch (error) { console.warn('[QZ] security:', error); }
|
||||
return qz.websocket.isActive() ? null : qz.websocket.connect();
|
||||
})
|
||||
.then(function () { return qz.api.getVersion(); })
|
||||
.then(function (version) {
|
||||
setState({ status: 'ready', connected: true, version: version, error: null });
|
||||
return loadPrinters();
|
||||
})
|
||||
.then(function () { return true; })
|
||||
.catch(function (error) {
|
||||
setState({
|
||||
status: 'offline',
|
||||
connected: false,
|
||||
version: '',
|
||||
error: messageOf(error),
|
||||
lastFailureAt: Date.now()
|
||||
});
|
||||
return false;
|
||||
})
|
||||
.finally(function () {
|
||||
clearTimeout(watchdog);
|
||||
connectPromise = null;
|
||||
});
|
||||
|
||||
return connectPromise;
|
||||
}
|
||||
|
||||
function disconnect() {
|
||||
var qz = lib();
|
||||
var done = qz && qz.websocket.isActive() ? qz.websocket.disconnect() : Promise.resolve();
|
||||
return Promise.resolve(done).catch(function () { /* ignoram */ }).then(function () {
|
||||
setState({ status: 'idle', connected: false, version: '', printers: [] });
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
function loadPrinters() {
|
||||
var qz = lib();
|
||||
if (!qz || !state.connected) return Promise.resolve([]);
|
||||
|
||||
return qz.printers.find().then(function (found) {
|
||||
var list = Array.isArray(found) ? found.map(String) : (found ? [String(found)] : []);
|
||||
var chosen = choosePrinter(list);
|
||||
state.printers = list;
|
||||
state.printer = chosen;
|
||||
if (chosen) savePrinter(chosen);
|
||||
emit();
|
||||
return list;
|
||||
}).catch(function (error) {
|
||||
setState({ error: messageOf(error) });
|
||||
return [];
|
||||
});
|
||||
}
|
||||
|
||||
function setPrinter(name) {
|
||||
state.printer = name || '';
|
||||
savePrinter(state.printer);
|
||||
emit();
|
||||
}
|
||||
|
||||
// -- payload -------------------------------------------------------------
|
||||
function dimensions(payload) {
|
||||
var template = payload.template || {};
|
||||
var width = Number(template.width_mm);
|
||||
var height = Number(template.height_mm);
|
||||
return {
|
||||
width: isFinite(width) && width > 0 ? width : 100,
|
||||
height: isFinite(height) && height > 0 ? height : 60
|
||||
};
|
||||
}
|
||||
|
||||
function labelDocument(payload) {
|
||||
var size = dimensions(payload);
|
||||
return '<!doctype html><html><head><meta charset="utf-8"><style>'
|
||||
+ '@page{size:' + size.width + 'mm ' + size.height + 'mm;margin:0}'
|
||||
+ 'html,body{margin:0;padding:0;width:' + size.width + 'mm;height:' + size.height
|
||||
+ 'mm;overflow:hidden;background:#fff}'
|
||||
+ '*,*::before,*::after{box-sizing:border-box}'
|
||||
+ (payload.css || '')
|
||||
+ '</style></head><body><div class="label-sheet" style="width:' + size.width
|
||||
+ 'mm;height:' + size.height + 'mm;overflow:hidden">'
|
||||
+ (payload.html || '')
|
||||
+ '</div></body></html>';
|
||||
}
|
||||
|
||||
function fetchBase64(url) {
|
||||
return fetch(url, { cache: 'no-store' }).then(function (response) {
|
||||
if (!response.ok) throw new Error('PDF: HTTP ' + response.status);
|
||||
return response.blob();
|
||||
}).then(function (blob) {
|
||||
return new Promise(function (resolve, reject) {
|
||||
var reader = new FileReader();
|
||||
reader.onloadend = function () {
|
||||
resolve(String(reader.result).split(',')[1]);
|
||||
};
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function printPdf(qz, payload) {
|
||||
var size = dimensions(payload);
|
||||
var printerConfig = qz.configs.create(state.printer || '', {
|
||||
scaleContent: false,
|
||||
rasterize: false,
|
||||
size: { width: size.width, height: size.height },
|
||||
units: 'mm',
|
||||
margins: { top: 0, right: 0, bottom: 0, left: 0 }
|
||||
});
|
||||
return fetchBase64(payload.pdf_url).then(function (base64) {
|
||||
return qz.print(printerConfig, [{ type: 'pdf', format: 'base64', data: base64 }]);
|
||||
});
|
||||
}
|
||||
|
||||
function printHtml(qz, payload) {
|
||||
var size = dimensions(payload);
|
||||
var printerConfig = qz.configs.create(state.printer || '', {
|
||||
scaleContent: false,
|
||||
rasterize: true,
|
||||
size: { width: size.width, height: size.height },
|
||||
units: 'mm',
|
||||
margins: { top: 0, right: 0, bottom: 0, left: 0 },
|
||||
interpolation: 'optimizeQuality'
|
||||
});
|
||||
return qz.print(printerConfig, [{
|
||||
type: 'html',
|
||||
format: 'plain',
|
||||
data: labelDocument(payload)
|
||||
}]);
|
||||
}
|
||||
|
||||
function browserPrint(payload) {
|
||||
// On the label page itself, the browser dialog is exactly what we want.
|
||||
if (document.getElementById('label-sheet')) {
|
||||
window.print();
|
||||
return { ok: true, method: 'browser' };
|
||||
}
|
||||
|
||||
// Anywhere else (dashboard), print the label page, not the current page.
|
||||
var url = payload && payload.print_url;
|
||||
if (url) {
|
||||
var frame = document.getElementById('print-frame');
|
||||
if (frame) {
|
||||
frame.src = url + '?auto=1';
|
||||
return { ok: true, method: 'browser', framed: true };
|
||||
}
|
||||
window.open(url + '?auto=1', '_blank', 'noopener');
|
||||
return { ok: true, method: 'browser' };
|
||||
}
|
||||
|
||||
window.print();
|
||||
return { ok: true, method: 'browser' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Do not hold the operator for the full host/port sweep of the QZ client:
|
||||
* wait at most `connectTimeoutMs`, then let the caller fall back.
|
||||
* A connection that succeeds later is still used by the next print.
|
||||
*/
|
||||
function ensureConnected() {
|
||||
if (state.connected) return Promise.resolve(true);
|
||||
|
||||
// QZ was not running a moment ago: do not make every label wait again.
|
||||
if (state.status === 'offline' && state.lastFailureAt &&
|
||||
Date.now() - state.lastFailureAt < 30000) {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
|
||||
var timeout = Number(config.connectTimeoutMs) || 6000;
|
||||
return Promise.race([
|
||||
connect(),
|
||||
new Promise(function (resolve) { setTimeout(function () { resolve(false); }, timeout); })
|
||||
]);
|
||||
}
|
||||
|
||||
// -- public print --------------------------------------------------------
|
||||
function print(payload) {
|
||||
payload = payload || {};
|
||||
|
||||
return ensureConnected().then(function (connected) {
|
||||
if (!connected) {
|
||||
if (!config.fallbackToBrowser) {
|
||||
throw new Error(state.error || 'QZ Tray nu este disponibil.');
|
||||
}
|
||||
console.warn('[QZ] fallback la tiparirea din browser:', state.error);
|
||||
return browserPrint(payload);
|
||||
}
|
||||
|
||||
var qz = lib();
|
||||
var job = payload.pdf_url
|
||||
? printPdf(qz, payload).catch(function (error) {
|
||||
// PDF-ul poate lipsi (endpoint nedisponibil): incercam HTML-ul.
|
||||
console.warn('[QZ] PDF indisponibil, folosesc eticheta HTML:', error);
|
||||
return printHtml(qz, payload);
|
||||
})
|
||||
: printHtml(qz, payload);
|
||||
|
||||
return job.then(function () {
|
||||
if (state.error) setState({ error: null });
|
||||
return { ok: true, method: 'qz', printer: state.printer };
|
||||
}).catch(function (error) {
|
||||
setState({ error: messageOf(error), lastFailureAt: Date.now() });
|
||||
if (!config.fallbackToBrowser) throw error;
|
||||
console.warn('[QZ] tiparirea a esuat, folosesc browserul:', error);
|
||||
return browserPrint(payload);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function printTest() {
|
||||
var stamp = new Date().toLocaleString('ro-RO');
|
||||
var payload = {
|
||||
template: { width_mm: 60, height_mm: 40 },
|
||||
css: '.t{font-family:Arial,sans-serif;text-align:center;padding:4mm;color:#000}'
|
||||
+ '.t h1{font-size:5mm;margin:0 0 2mm}.t p{font-size:3.5mm;margin:1mm 0}',
|
||||
html: '<div class="t"><h1>TEST IMPRIMANTA</h1>'
|
||||
+ '<p>' + (state.printer || 'imprimanta implicita') + '</p>'
|
||||
+ '<p>' + stamp + '</p>'
|
||||
+ '<p>Cantarire & Etichetare</p></div>'
|
||||
};
|
||||
return print(payload);
|
||||
}
|
||||
|
||||
// -- UI binding ----------------------------------------------------------
|
||||
function statusText() {
|
||||
if (state.status === 'ready') return 'QZ Tray: pregatit';
|
||||
if (state.status === 'connecting') return 'QZ Tray: conectare…';
|
||||
if (state.status === 'missing') return 'QZ Tray: biblioteca lipsa';
|
||||
if (state.status === 'offline') return 'QZ Tray: deconectat';
|
||||
return 'QZ Tray: neinitializat';
|
||||
}
|
||||
|
||||
function renderPrinterSelect(select) {
|
||||
var signature = state.printers.join('|') + '#' + state.printer;
|
||||
if (select.getAttribute('data-qz-rendered') === signature) return;
|
||||
select.setAttribute('data-qz-rendered', signature);
|
||||
|
||||
var current = state.printer || '';
|
||||
select.innerHTML = '';
|
||||
var auto = document.createElement('option');
|
||||
auto.value = '';
|
||||
auto.textContent = state.printers.length ? 'Implicit (sistem)' : 'Nicio imprimanta detectata';
|
||||
select.appendChild(auto);
|
||||
|
||||
state.printers.forEach(function (name) {
|
||||
var option = document.createElement('option');
|
||||
option.value = name;
|
||||
option.textContent = name;
|
||||
if (name === current) option.selected = true;
|
||||
select.appendChild(option);
|
||||
});
|
||||
select.value = current;
|
||||
select.disabled = state.printers.length === 0;
|
||||
}
|
||||
|
||||
function syncUi() {
|
||||
if (typeof document === 'undefined') return;
|
||||
|
||||
document.querySelectorAll('[data-qz-status]').forEach(function (node) {
|
||||
node.textContent = statusText();
|
||||
var pill = node.closest('[data-qz-pill]');
|
||||
if (pill) pill.setAttribute('data-qz-state', state.status);
|
||||
if (state.error) node.title = state.error;
|
||||
else node.removeAttribute('title');
|
||||
});
|
||||
|
||||
document.querySelectorAll('[data-qz-printer]').forEach(renderPrinterSelect);
|
||||
|
||||
document.querySelectorAll('[data-qz-version]').forEach(function (node) {
|
||||
node.textContent = state.version ? 'v' + state.version : '';
|
||||
});
|
||||
|
||||
document.querySelectorAll('[data-qz-connect]').forEach(function (node) {
|
||||
node.disabled = state.connected || state.status === 'connecting';
|
||||
});
|
||||
}
|
||||
|
||||
function bindUi() {
|
||||
document.addEventListener('change', function (event) {
|
||||
var select = event.target.closest ? event.target.closest('[data-qz-printer]') : null;
|
||||
if (select) setPrinter(select.value);
|
||||
});
|
||||
|
||||
document.addEventListener('click', function (event) {
|
||||
var target = event.target.closest ? event.target.closest('[data-qz-connect],[data-qz-test],[data-qz-refresh]') : null;
|
||||
if (!target) return;
|
||||
if (target.hasAttribute('data-qz-connect')) {
|
||||
event.preventDefault();
|
||||
connect();
|
||||
} else if (target.hasAttribute('data-qz-test')) {
|
||||
event.preventDefault();
|
||||
printTest().catch(function (error) { alert('Test esuat: ' + messageOf(error)); });
|
||||
} else if (target.hasAttribute('data-qz-refresh')) {
|
||||
event.preventDefault();
|
||||
if (!state.connected) connect();
|
||||
else loadPrinters();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// -- boot ----------------------------------------------------------------
|
||||
window.CantarireQZ = {
|
||||
getState: snapshot,
|
||||
connect: connect,
|
||||
disconnect: disconnect,
|
||||
refreshPrinters: loadPrinters,
|
||||
setPrinter: setPrinter,
|
||||
printTest: printTest
|
||||
};
|
||||
window.CantarirePrint = { print: print };
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
bindUi();
|
||||
syncUi();
|
||||
if (config.autoConnect) connect();
|
||||
});
|
||||
} else {
|
||||
bindUi();
|
||||
syncUi();
|
||||
if (config.autoConnect) connect();
|
||||
}
|
||||
})();
|
||||
Vendored
+16
@@ -0,0 +1,16 @@
|
||||
# QZ Tray connector (`qz-tray.js`)
|
||||
|
||||
- **Version:** 2.2.4
|
||||
- **License:** LGPL-2.1-only (see the header of `qz-tray.js`)
|
||||
- **Source:** copied unmodified from
|
||||
`quality_app-v2/app/static/js/qz-tray.js` (Gitea: `ske087/quality_app-v2`).
|
||||
- **Local patch:** it **skips the certificate exchange** and connects to the
|
||||
custom QZ Tray service that authenticates with a **pairing key**
|
||||
(`PATCHED` markers around the original `_qz.websocket.connection.sendData({ certificate … })`).
|
||||
|
||||
Because the file is a modified LGPL-2.1 library, keep it under
|
||||
`static/js/vendor/` and ship this notice plus the license header with it.
|
||||
If you later use stock QZ Tray (certificate + signature), set
|
||||
`window.CANTARIRE_QZ = { certificateUrl, signatureUrl }` before loading
|
||||
`qz-print.js`; `qz-print.js` wires `qz.security.setCertificatePromise` and
|
||||
`setSignaturePromise` when those URLs are provided.
|
||||
+2871
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,577 @@
|
||||
/* Fluxul de cântărire a unui produs finit din 1..N componente.
|
||||
*
|
||||
* State machine:
|
||||
* fără pachet -> alegi produsul finit și începi
|
||||
* pas curent -> cântărești componenta (cântar sau manual), Enter acceptă
|
||||
* ultimul pas -> serverul finalizează pachetul și întoarce eticheta
|
||||
* finalizat -> eticheta se tipărește; Enter începe următorul pachet
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var cfg = window.CANTARIRE || {};
|
||||
var $ = function (id) { return document.getElementById(id); };
|
||||
|
||||
// Polling: rapid cat timp se cantareste, rar cand pagina doar asteapta.
|
||||
var POLL_ACTIVE_MS = Number(cfg.pollMs) || 700;
|
||||
var POLL_IDLE_MS = 2500;
|
||||
var pollTimer = null;
|
||||
var el = {
|
||||
flow: $('flow-body'),
|
||||
title: $('flow-title'),
|
||||
progress: $('flow-progress'),
|
||||
message: $('flow-message'),
|
||||
weightValue: $('weight-value'),
|
||||
weightUnit: $('weight-unit'),
|
||||
stability: $('stability'),
|
||||
age: $('age'),
|
||||
raw: $('raw'),
|
||||
scaleDot: $('scale-dot'),
|
||||
scaleState: $('scale-state'),
|
||||
scaleProfile: $('scale-profile'),
|
||||
scaleError: $('scale-error'),
|
||||
profileSelect: $('profile-select'),
|
||||
toast: $('toast'),
|
||||
frame: $('print-frame')
|
||||
};
|
||||
|
||||
var state = cfg.initial || null;
|
||||
var recipes = cfg.recipes || [];
|
||||
var activeProfile = cfg.activeProfile || '';
|
||||
var scaleReady = Boolean(cfg.scaleReady);
|
||||
var manualMode = !scaleReady;
|
||||
var busy = false;
|
||||
var polling = false;
|
||||
var reading = null;
|
||||
var forceArmedAt = 0;
|
||||
var lastLabel = null;
|
||||
var lastRecipeId = null;
|
||||
var lastLot = '';
|
||||
var lastOperator = cfg.defaultOperator || '';
|
||||
var toastTimer = null;
|
||||
|
||||
// -- utilitare -----------------------------------------------------------
|
||||
function esc(value) {
|
||||
return String(value === null || value === undefined ? '' : value)
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function fmt(value) {
|
||||
var number = Number(value);
|
||||
if (!isFinite(number)) return '--';
|
||||
var text = number.toFixed(3);
|
||||
return text.replace(/\.?0+$/, '') || '0';
|
||||
}
|
||||
|
||||
function message(text, kind) {
|
||||
if (!el.message) return;
|
||||
el.message.textContent = text || '';
|
||||
el.message.className = 'message ' + (kind || '');
|
||||
}
|
||||
|
||||
function toast(text, kind) {
|
||||
if (!el.toast) return;
|
||||
el.toast.textContent = text;
|
||||
el.toast.className = 'toast show ' + (kind || '');
|
||||
clearTimeout(toastTimer);
|
||||
toastTimer = setTimeout(function () { el.toast.className = 'toast ' + (kind || ''); }, 3200);
|
||||
}
|
||||
|
||||
function setProgress(text) {
|
||||
if (el.progress) el.progress.textContent = text || '';
|
||||
}
|
||||
|
||||
function getJSON(url) {
|
||||
return fetch(url, { headers: { Accept: 'application/json' }, cache: 'no-store' })
|
||||
.then(function (response) { return response.json(); });
|
||||
}
|
||||
|
||||
function postJSON(url, body) {
|
||||
return fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify(body || {})
|
||||
}).then(function (response) {
|
||||
return response.json().then(function (data) {
|
||||
return { status: response.status, data: data };
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function setBusy(value) {
|
||||
busy = value;
|
||||
document.querySelectorAll('#flow-body button, #flow-body input, #flow-body select')
|
||||
.forEach(function (node) { node.disabled = value; });
|
||||
}
|
||||
|
||||
// -- stare ---------------------------------------------------------------
|
||||
function adopt(data) {
|
||||
if (!data) return;
|
||||
if (data.state === null) {
|
||||
state = null;
|
||||
} else if (data.package) {
|
||||
state = {
|
||||
package: data.package,
|
||||
recipe: data.recipe,
|
||||
items: data.items || [],
|
||||
step: data.step,
|
||||
planned_total: data.planned_total,
|
||||
total_weight: data.total_weight,
|
||||
complete: Boolean(data.complete),
|
||||
open: Boolean(data.open)
|
||||
};
|
||||
if (data.recipe) lastRecipeId = data.recipe.id;
|
||||
if (data.package) {
|
||||
lastLot = data.package.lot || lastLot;
|
||||
lastOperator = data.package.operator || lastOperator;
|
||||
}
|
||||
}
|
||||
render();
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
var data = await getJSON('/api/package');
|
||||
adopt(data);
|
||||
} catch (error) {
|
||||
message('Nu pot citi starea pachetului: ' + error, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// -- randare -------------------------------------------------------------
|
||||
function render() {
|
||||
if (!el.flow) return;
|
||||
if (!state) {
|
||||
renderStart();
|
||||
} else if (state.complete) {
|
||||
renderDone();
|
||||
} else {
|
||||
renderStep();
|
||||
}
|
||||
}
|
||||
|
||||
function recipeOptions(selected) {
|
||||
return recipes.map(function (recipe) {
|
||||
var suffix = ' · ' + recipe.components.length + ' comp.';
|
||||
return '<option value="' + recipe.id + '"' + (recipe.id === selected ? ' selected' : '') +
|
||||
'>' + esc(recipe.code + ' · ' + recipe.name) + suffix + '</option>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function breakdown(recipe) {
|
||||
return (recipe.components || []).map(function (part) {
|
||||
return '<li><span class="w">' + (part.target_weight ? fmt(part.target_weight) + ' ' + esc(recipe.unit) : '—')
|
||||
+ '</span> ' + esc(part.name) + '</li>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function renderStart() {
|
||||
if (el.title) el.title.textContent = 'Pachet nou';
|
||||
setProgress('');
|
||||
|
||||
if (!recipes.length) {
|
||||
el.flow.innerHTML = '<p class="muted">Nu există produse finite active. ' +
|
||||
'<a class="link" href="/retete/nou">Creează unul</a>.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
var selected = lastRecipeId || (recipes[0] && recipes[0].id) || null;
|
||||
var recipe = recipes.filter(function (r) { return r.id === selected; })[0] || recipes[0];
|
||||
|
||||
el.flow.innerHTML =
|
||||
'<div class="field">' +
|
||||
' <label for="start-recipe">Produs finit</label>' +
|
||||
' <select id="start-recipe">' + recipeOptions(recipe.id) + '</select>' +
|
||||
'</div>' +
|
||||
'<ul class="plan" id="start-plan">' + breakdown(recipe) + '</ul>' +
|
||||
'<div class="field-row">' +
|
||||
' <div class="field"><label for="start-lot">Lot</label>' +
|
||||
' <input id="start-lot" type="text" maxlength="64" value="' + esc(lastLot) + '" placeholder="ex. LOT-2610-A"></div>' +
|
||||
' <div class="field"><label for="start-operator">Operator</label>' +
|
||||
' <input id="start-operator" type="text" maxlength="64" value="' + esc(lastOperator) + '"></div>' +
|
||||
'</div>' +
|
||||
'<div class="actions">' +
|
||||
' <button class="btn primary" type="button" id="btn-start">Începe pachetul</button>' +
|
||||
'</div>' +
|
||||
'<p class="hint muted small">Enter pornește pachetul. Apoi cântărești fiecare componentă în ordinea de mai sus.</p>';
|
||||
|
||||
var select = $('start-recipe');
|
||||
if (select) {
|
||||
select.addEventListener('change', function () {
|
||||
var chosen = recipes.filter(function (r) { return String(r.id) === select.value; })[0];
|
||||
if (chosen) {
|
||||
lastRecipeId = chosen.id;
|
||||
var plan = $('start-plan');
|
||||
if (plan) plan.innerHTML = breakdown(chosen);
|
||||
}
|
||||
});
|
||||
}
|
||||
var button = $('btn-start');
|
||||
if (button) button.addEventListener('click', startPackage);
|
||||
}
|
||||
|
||||
function renderStep() {
|
||||
var step = state.step;
|
||||
if (!step) { refresh(); return; }
|
||||
if (el.title) el.title.textContent = state.recipe ? state.recipe.name : 'Cântărire';
|
||||
setProgress('Pas ' + step.position + ' din ' + step.total);
|
||||
|
||||
var accepted = (state.items || []).map(function (item) {
|
||||
return '<li><span class="w">' + fmt(item.weight) + ' ' + esc(item.unit) + '</span> ' +
|
||||
esc(item.component_name) + '</li>';
|
||||
}).join('');
|
||||
|
||||
el.flow.innerHTML =
|
||||
'<div class="step">' +
|
||||
' <div class="step-label">Pune pe cântar</div>' +
|
||||
' <div class="step-component">' + esc(step.component_name) + '</div>' +
|
||||
(step.target_weight
|
||||
? '<div class="step-target">Țintă: ' + fmt(step.target_weight) + ' ' + esc(step.unit) + '</div>'
|
||||
: '') +
|
||||
'</div>' +
|
||||
(manualMode
|
||||
? '<div class="field"><label for="manual-weight">Greutate (' + esc(step.unit) + ')</label>' +
|
||||
'<input id="manual-weight" type="number" step="0.001" min="0" inputmode="decimal" placeholder="0">' +
|
||||
'</div>'
|
||||
: '<div class="actions"><button class="btn ghost" type="button" id="btn-manual-toggle">Introducere manuală</button></div>') +
|
||||
'<div class="actions">' +
|
||||
' <button class="btn primary" type="button" id="btn-accept">Acceptă' + (manualMode ? '' : ' (Enter)') + '</button>' +
|
||||
(accepted ? ' <button class="btn" type="button" id="btn-undo">Anulează ultima</button>' : '') +
|
||||
' <button class="btn ghost" type="button" id="btn-void">Renunță la pachet</button>' +
|
||||
'</div>' +
|
||||
(accepted ? '<h3>Acceptate</h3><ul class="plan accepted">' + accepted + '</ul>' : '');
|
||||
|
||||
if (manualMode) {
|
||||
var input = $('manual-weight');
|
||||
if (input) { input.focus(); input.select(); }
|
||||
}
|
||||
var accept = $('btn-accept');
|
||||
if (accept) accept.addEventListener('click', acceptStep);
|
||||
var undo = $('btn-undo');
|
||||
if (undo) undo.addEventListener('click', undoStep);
|
||||
var voidButton = $('btn-void');
|
||||
if (voidButton) voidButton.addEventListener('click', voidPackage);
|
||||
var toggle = $('btn-manual-toggle');
|
||||
if (toggle) {
|
||||
toggle.addEventListener('click', function () {
|
||||
manualMode = true;
|
||||
renderStep();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function renderDone() {
|
||||
var pkg = state.package || {};
|
||||
if (el.title) el.title.textContent = pkg.recipe_name || 'Pachet finalizat';
|
||||
setProgress('Finalizat');
|
||||
|
||||
var accepted = (state.items || []).map(function (item) {
|
||||
return '<li><span class="w">' + fmt(item.weight) + ' ' + esc(item.unit) + '</span> ' +
|
||||
esc(item.component_name) + '</li>';
|
||||
}).join('');
|
||||
|
||||
el.flow.innerHTML =
|
||||
'<div class="done">' +
|
||||
' <div class="done-mark">✓</div>' +
|
||||
' <div class="done-total"><span class="value">' + fmt(pkg.total_weight) + '</span>' +
|
||||
' <span class="unit">' + esc(pkg.unit) + '</span></div>' +
|
||||
' <div class="muted">Pachet #' + pkg.id + (pkg.lot ? ' · Lot ' + esc(pkg.lot) : '') + '</div>' +
|
||||
'</div>' +
|
||||
'<ul class="plan accepted">' + accepted + '</ul>' +
|
||||
'<div class="actions">' +
|
||||
' <button class="btn primary" type="button" id="btn-next">Pachet nou (Enter)</button>' +
|
||||
(lastLabel ? ' <button class="btn" type="button" id="btn-reprint">Tipărește eticheta</button>' : '') +
|
||||
' <a class="btn ghost" target="_blank" rel="noopener" href="/labels/' + pkg.id + '/print?auto=0">Vezi eticheta</a>' +
|
||||
'</div>';
|
||||
|
||||
var next = $('btn-next');
|
||||
if (next) next.addEventListener('click', startPackage);
|
||||
var reprint = $('btn-reprint');
|
||||
if (reprint) {
|
||||
reprint.addEventListener('click', function () {
|
||||
if (lastLabel) printLabel(lastLabel);
|
||||
});
|
||||
}
|
||||
if (next) next.focus();
|
||||
}
|
||||
|
||||
// -- acțiuni -------------------------------------------------------------
|
||||
async function startPackage() {
|
||||
if (busy) return;
|
||||
var select = $('start-recipe');
|
||||
var recipeId = select ? Number(select.value) : lastRecipeId;
|
||||
if (!recipeId) {
|
||||
toast('Alege produsul finit.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
setBusy(true);
|
||||
message('Se pregătește pachetul…', 'info');
|
||||
try {
|
||||
var result = await postJSON('/api/packages', {
|
||||
recipe_id: recipeId,
|
||||
operator: ($('start-operator') ? $('start-operator').value.trim() : '') || lastOperator,
|
||||
lot: ($('start-lot') ? $('start-lot').value.trim() : '') || lastLot,
|
||||
// Serverul alege sablonul: produsul finit -> setarea implicita -> primul sablon.
|
||||
template_id: null
|
||||
});
|
||||
if (!result.data.ok) {
|
||||
message(result.data.error || 'Pachetul nu a putut fi început.', 'error');
|
||||
toast(result.data.error || 'Eroare.', 'error');
|
||||
return;
|
||||
}
|
||||
lastLabel = null;
|
||||
adopt(result.data);
|
||||
message('Pachet început. Cântărește prima componentă.', 'info');
|
||||
} catch (error) {
|
||||
message('Eroare de rețea: ' + error, 'error');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
render();
|
||||
}
|
||||
}
|
||||
|
||||
function manualWeight() {
|
||||
var input = $('manual-weight');
|
||||
if (!input) return null;
|
||||
var raw = String(input.value || '').trim().replace(',', '.');
|
||||
return raw === '' ? null : raw;
|
||||
}
|
||||
|
||||
async function acceptStep() {
|
||||
if (busy || !state || !state.package) return;
|
||||
var force = forceArmedAt && Date.now() - forceArmedAt < 5000;
|
||||
var body = {};
|
||||
if (force) body.force = true;
|
||||
if (manualMode) {
|
||||
var value = manualWeight();
|
||||
if (value === null) {
|
||||
toast('Introdu greutatea sau conectează cântarul.', 'error');
|
||||
return;
|
||||
}
|
||||
body.weight = value;
|
||||
}
|
||||
|
||||
setBusy(true);
|
||||
message('Se înregistrează…', 'info');
|
||||
try {
|
||||
var result = await postJSON('/api/packages/' + state.package.id + '/items', body);
|
||||
var data = result.data;
|
||||
|
||||
if (!result.status || result.status >= 400 || !data.ok) {
|
||||
if (data.reading) applyReading(data.reading);
|
||||
var unstable = /stabil/i.test(data.error || '');
|
||||
if (unstable) {
|
||||
forceArmedAt = Date.now();
|
||||
message((data.error || 'Greutate instabilă.') +
|
||||
' Apasă din nou pe Acceptă pentru a folosi valoarea curentă.', 'error');
|
||||
} else {
|
||||
message(data.error || 'Cântărirea nu a putut fi salvată.', 'error');
|
||||
toast(data.error || 'Eroare la salvare.', 'error');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
forceArmedAt = 0;
|
||||
adopt(data);
|
||||
if (data.label) {
|
||||
lastLabel = data.label;
|
||||
if (cfg.autoPrint !== false) {
|
||||
await printLabel(data.label);
|
||||
message('Etichetă tipărită. Apasă Enter pentru următorul pachet.', 'success');
|
||||
} else {
|
||||
message('Pachet finalizat. Apasă „Tipărește eticheta” sau Enter pentru următorul pachet.', 'success');
|
||||
}
|
||||
updateStats();
|
||||
} else {
|
||||
message('Acceptat: ' + fmt(state.total_weight) + ' ' + (state.package.unit || ''), 'success');
|
||||
}
|
||||
} catch (error) {
|
||||
message('Eroare de rețea la salvare.', 'error');
|
||||
console.error(error);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
render();
|
||||
}
|
||||
}
|
||||
|
||||
async function undoStep() {
|
||||
if (busy || !state || !state.package) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
var result = await postJSON('/api/packages/' + state.package.id + '/undo', {});
|
||||
if (result.data.ok) {
|
||||
adopt(result.data);
|
||||
message('Ultima cântărire a fost anulată.', 'info');
|
||||
} else {
|
||||
message(result.data.error || 'Nu se poate anula.', 'error');
|
||||
}
|
||||
} finally {
|
||||
setBusy(false);
|
||||
render();
|
||||
}
|
||||
}
|
||||
|
||||
async function voidPackage() {
|
||||
if (busy || !state || !state.package) return;
|
||||
if (!confirm('Renunți la pachetul început? Cântăririle lui nu vor apărea în istoric.')) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await postJSON('/api/packages/' + state.package.id + '/void', {});
|
||||
state = null;
|
||||
lastLabel = null;
|
||||
message('Pachet anulat.', 'info');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
render();
|
||||
}
|
||||
}
|
||||
|
||||
// -- tipărire ------------------------------------------------------------
|
||||
function acknowledge(payload) {
|
||||
if (!payload || !payload.ack_url) return;
|
||||
fetch(payload.ack_url, { method: 'POST', headers: { Accept: 'application/json' } })
|
||||
.catch(function () { /* confirmarea este auxiliară */ });
|
||||
}
|
||||
|
||||
function printLabel(payload) {
|
||||
if (!payload) return Promise.resolve();
|
||||
|
||||
if (window.CantarirePrint && typeof window.CantarirePrint.print === 'function') {
|
||||
return Promise.resolve(window.CantarirePrint.print(payload)).then(function (result) {
|
||||
if (result && result.method === 'qz') acknowledge(payload);
|
||||
return result;
|
||||
}).catch(function (error) {
|
||||
console.error('Tipărirea a eșuat:', error);
|
||||
toast('Imprimanta nu a răspuns.', 'error');
|
||||
});
|
||||
}
|
||||
|
||||
if (el.frame) {
|
||||
el.frame.src = payload.print_url + '?auto=1';
|
||||
return Promise.resolve();
|
||||
}
|
||||
window.open(payload.print_url, '_blank', 'noopener');
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
function updateStats() {
|
||||
var node = $('stat-today-count');
|
||||
if (!node) return;
|
||||
var value = Number(node.textContent) || 0;
|
||||
node.textContent = String(value + 1);
|
||||
}
|
||||
|
||||
// -- citirea cântarului --------------------------------------------------
|
||||
function applyReading(data) {
|
||||
reading = data;
|
||||
if (!data) return;
|
||||
var ok = Boolean(data.ok);
|
||||
var stale = Boolean(data.stale);
|
||||
var stable = Boolean(data.stable);
|
||||
var hasValue = data.value !== null && data.value !== undefined;
|
||||
|
||||
if (el.weightValue) {
|
||||
el.weightValue.textContent = hasValue ? fmt(data.value) : '--';
|
||||
el.weightValue.className = !ok ? 'error' : (stable && !stale ? '' : 'unstable');
|
||||
}
|
||||
if (el.weightUnit) el.weightUnit.textContent = data.unit || '';
|
||||
|
||||
if (!ok) {
|
||||
if (el.scaleDot) el.scaleDot.className = 'dot off';
|
||||
if (el.scaleState) el.scaleState.textContent = 'Cântar indisponibil';
|
||||
if (el.stability) { el.stability.textContent = 'fără date'; el.stability.className = 'badge off'; }
|
||||
if (el.scaleError) el.scaleError.textContent = data.error || 'Cântarul nu poate fi citit.';
|
||||
} else if (stale) {
|
||||
if (el.scaleDot) el.scaleDot.className = 'dot off';
|
||||
if (el.scaleState) el.scaleState.textContent = 'Citire veche';
|
||||
if (el.stability) { el.stability.textContent = 'citire veche'; el.stability.className = 'badge warn'; }
|
||||
if (el.scaleError) el.scaleError.textContent = '';
|
||||
} else {
|
||||
if (el.scaleDot) el.scaleDot.className = 'dot ok';
|
||||
if (el.scaleState) el.scaleState.textContent = 'Cântar conectat';
|
||||
if (el.stability) {
|
||||
el.stability.textContent = stable ? 'stabil' : 'instabil';
|
||||
el.stability.className = 'badge ' + (stable ? 'ok' : 'warn');
|
||||
}
|
||||
if (el.scaleError) el.scaleError.textContent = '';
|
||||
}
|
||||
|
||||
if (el.scaleProfile) el.scaleProfile.textContent = (data.profile || activeProfile || 'fără profil') +
|
||||
(data.port ? ' · ' + data.port : '');
|
||||
if (el.age) {
|
||||
el.age.textContent = typeof data.age_ms === 'number'
|
||||
? (data.age_ms < 1000 ? '<1 s' : (data.age_ms / 1000).toFixed(1) + ' s')
|
||||
: '';
|
||||
}
|
||||
if (el.raw) el.raw.textContent = data.raw_hex ? 'raw: ' + data.raw_hex.slice(0, 40) : '';
|
||||
}
|
||||
|
||||
function poll() {
|
||||
if (!scaleReady || polling || document.hidden) return;
|
||||
polling = true;
|
||||
var controller = new AbortController();
|
||||
var timer = setTimeout(function () { controller.abort(); }, 6000);
|
||||
var params = new URLSearchParams();
|
||||
if (activeProfile) params.set('profile', activeProfile);
|
||||
|
||||
fetch('/api/weight?' + params.toString(), {
|
||||
headers: { Accept: 'application/json' }, cache: 'no-store', signal: controller.signal
|
||||
})
|
||||
.then(function (response) { return response.json(); })
|
||||
.then(applyReading)
|
||||
.catch(function (error) {
|
||||
if (error && error.name !== 'AbortError') {
|
||||
if (el.scaleDot) el.scaleDot.className = 'dot off';
|
||||
if (el.scaleState) el.scaleState.textContent = 'Conexiune întreruptă';
|
||||
}
|
||||
})
|
||||
.finally(function () { clearTimeout(timer); polling = false; });
|
||||
}
|
||||
|
||||
// -- tastatură -----------------------------------------------------------
|
||||
function onKeyDown(event) {
|
||||
if (event.key !== 'Enter' || event.ctrlKey || event.metaKey) return;
|
||||
var tag = (event.target && event.target.tagName || '').toLowerCase();
|
||||
if (tag === 'textarea' || tag === 'button' || tag === 'a') return;
|
||||
|
||||
if (!state) {
|
||||
event.preventDefault();
|
||||
startPackage();
|
||||
} else if (state.complete) {
|
||||
event.preventDefault();
|
||||
startPackage();
|
||||
} else if (state.step) {
|
||||
event.preventDefault();
|
||||
acceptStep();
|
||||
}
|
||||
}
|
||||
|
||||
// -- start ---------------------------------------------------------------
|
||||
function schedulePoll() {
|
||||
clearTimeout(pollTimer);
|
||||
var delay = (state && state.open && state.step) ? POLL_ACTIVE_MS : POLL_IDLE_MS;
|
||||
pollTimer = setTimeout(function () {
|
||||
poll();
|
||||
schedulePoll();
|
||||
}, delay);
|
||||
}
|
||||
|
||||
if (el.profileSelect) {
|
||||
el.profileSelect.addEventListener('change', function () {
|
||||
activeProfile = el.profileSelect.value;
|
||||
poll();
|
||||
});
|
||||
}
|
||||
document.addEventListener('keydown', onKeyDown);
|
||||
document.addEventListener('visibilitychange', function () {
|
||||
if (!document.hidden) poll();
|
||||
});
|
||||
|
||||
render();
|
||||
if (scaleReady) {
|
||||
poll();
|
||||
schedulePoll();
|
||||
}
|
||||
})();
|
||||
Reference in New Issue
Block a user