feat(invoice+import): refresh facturi, detalii factura, fix duplicate CODMAT + rollback

- PL/SQL: handle duplicate CODMAT in nom_articole with MAX(id_articol)
- import_service: add explicit conn.rollback() on Oracle errors
- sync_service: auto-fix stale ERROR orders that exist in Oracle
- invoice_service: add data_act (invoice date) from vanzari table
- sync router: new POST /api/dashboard/refresh-invoices endpoint
- order detail: enrich with invoice data (serie, numar, data factura)
- dashboard: refresh invoices button (desktop + mobile icon)
- quick map modal: compact single-row layout, pre-populate existing mappings
- quick map: link on SKU column instead of CODMAT

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude Agent
2026-03-16 17:30:23 +00:00
parent 43327c4a70
commit 84b24b1434
11 changed files with 263 additions and 48 deletions

View File

@@ -105,6 +105,7 @@ CREATE TABLE IF NOT EXISTS orders (
factura_total_fara_tva REAL,
factura_total_tva REAL,
factura_total_cu_tva REAL,
factura_data TEXT,
invoice_checked_at TEXT,
order_total REAL,
delivery_cost REAL,
@@ -310,6 +311,7 @@ def init_sqlite():
("factura_total_fara_tva", "REAL"),
("factura_total_tva", "REAL"),
("factura_total_cu_tva", "REAL"),
("factura_data", "TEXT"),
("invoice_checked_at", "TEXT"),
("order_total", "REAL"),
("delivery_cost", "REAL"),

View File

@@ -330,6 +330,39 @@ async def order_detail(order_number: str):
if sku and sku in codmat_map:
item["codmat_details"] = codmat_map[sku]
# Enrich with invoice data
order = detail.get("order", {})
if order.get("factura_numar"):
order["invoice"] = {
"facturat": True,
"serie_act": order.get("factura_serie"),
"numar_act": order.get("factura_numar"),
"data_act": order.get("factura_data"),
"total_fara_tva": order.get("factura_total_fara_tva"),
"total_tva": order.get("factura_total_tva"),
"total_cu_tva": order.get("factura_total_cu_tva"),
}
elif order.get("id_comanda"):
# Check Oracle live
try:
inv_data = await asyncio.to_thread(
invoice_service.check_invoices_for_orders, [order["id_comanda"]]
)
inv = inv_data.get(order["id_comanda"])
if inv and inv.get("facturat"):
order["invoice"] = inv
await sqlite_service.update_order_invoice(
order_number,
serie=inv.get("serie_act"),
numar=str(inv.get("numar_act", "")),
total_fara_tva=inv.get("total_fara_tva"),
total_tva=inv.get("total_tva"),
total_cu_tva=inv.get("total_cu_tva"),
data_act=inv.get("data_act"),
)
except Exception:
pass
return detail
@@ -372,6 +405,7 @@ async def dashboard_orders(page: int = 1, per_page: int = 50,
"total_fara_tva": o.get("factura_total_fara_tva"),
"total_tva": o.get("factura_total_tva"),
"total_cu_tva": o.get("factura_total_cu_tva"),
"data_act": o.get("factura_data"),
}
else:
o["invoice"] = None
@@ -388,6 +422,18 @@ async def dashboard_orders(page: int = 1, per_page: int = 50,
idc = o.get("id_comanda")
if idc and idc in invoice_data:
o["invoice"] = invoice_data[idc]
# Update SQLite cache so counts stay accurate
inv = invoice_data[idc]
if inv.get("facturat"):
await sqlite_service.update_order_invoice(
o["order_number"],
serie=inv.get("serie_act"),
numar=str(inv.get("numar_act", "")),
total_fara_tva=inv.get("total_fara_tva"),
total_tva=inv.get("total_tva"),
total_cu_tva=inv.get("total_cu_tva"),
data_act=inv.get("data_act"),
)
except Exception:
pass
@@ -400,11 +446,14 @@ async def dashboard_orders(page: int = 1, per_page: int = 50,
# Use counts from sqlite_service (already period-scoped)
counts = result.get("counts", {})
# Prefer SQLite-based uninvoiced count (covers full period, not just current page)
counts["nefacturate"] = counts.get("uninvoiced_sqlite", sum(
# Count newly-cached invoices found during this request
newly_invoiced = sum(1 for o in uncached_orders if o.get("invoice") and o["invoice"].get("facturat"))
# Adjust uninvoiced count: start from SQLite count, subtract newly-found invoices
uninvoiced_base = counts.get("uninvoiced_sqlite", sum(
1 for o in all_orders
if o.get("status") in ("IMPORTED", "ALREADY_IMPORTED") and not o.get("invoice")
))
counts["nefacturate"] = max(0, uninvoiced_base - newly_invoiced)
imported_total = counts.get("imported_all") or counts.get("imported", 0)
counts["facturate"] = max(0, imported_total - counts["nefacturate"])
counts.setdefault("total", counts.get("imported", 0) + counts.get("skipped", 0) + counts.get("error", 0))
@@ -441,6 +490,38 @@ async def dashboard_orders(page: int = 1, per_page: int = 50,
}
@router.post("/api/dashboard/refresh-invoices")
async def refresh_invoices():
"""Force-refresh invoice status from Oracle for all uninvoiced imported orders."""
try:
uninvoiced = await sqlite_service.get_uninvoiced_imported_orders()
if not uninvoiced:
return {"updated": 0, "message": "Nicio comanda de verificat"}
id_comanda_list = [o["id_comanda"] for o in uninvoiced]
invoice_data = await asyncio.to_thread(
invoice_service.check_invoices_for_orders, id_comanda_list
)
id_to_order = {o["id_comanda"]: o["order_number"] for o in uninvoiced}
updated = 0
for idc, inv in invoice_data.items():
order_num = id_to_order.get(idc)
if order_num and inv.get("facturat"):
await sqlite_service.update_order_invoice(
order_num,
serie=inv.get("serie_act"),
numar=str(inv.get("numar_act", "")),
total_fara_tva=inv.get("total_fara_tva"),
total_tva=inv.get("total_tva"),
total_cu_tva=inv.get("total_cu_tva"),
data_act=inv.get("data_act"),
)
updated += 1
return {"updated": updated, "checked": len(uninvoiced)}
except Exception as e:
return {"error": str(e), "updated": 0}
@router.put("/api/sync/schedule")
async def update_schedule(config: ScheduleConfig):
"""Update scheduler configuration."""

View File

@@ -128,6 +128,7 @@ def import_single_order(order, id_pol: int = None, id_sectie: int = None, app_se
"error": None
}
conn = None
try:
order_number = clean_web_text(order.number)
order_date = convert_web_date(order.date)
@@ -138,8 +139,8 @@ def import_single_order(order, id_pol: int = None, id_sectie: int = None, app_se
if database.pool is None:
raise RuntimeError("Oracle pool not initialized")
with database.pool.acquire() as conn:
with conn.cursor() as cur:
conn = database.pool.acquire()
with conn.cursor() as cur:
# Step 1: Process partner — use shipping person data for name
id_partener = cur.var(oracledb.DB_TYPE_NUMBER)
@@ -272,8 +273,24 @@ def import_single_order(order, id_pol: int = None, id_sectie: int = None, app_se
error_msg = str(e)
result["error"] = error_msg
logger.error(f"Oracle error importing order {order.number}: {error_msg}")
if conn:
try:
conn.rollback()
except Exception:
pass
except Exception as e:
result["error"] = str(e)
logger.error(f"Error importing order {order.number}: {e}")
if conn:
try:
conn.rollback()
except Exception:
pass
finally:
if conn:
try:
database.pool.release(conn)
except Exception:
pass
return result

View File

@@ -22,7 +22,8 @@ def check_invoices_for_orders(id_comanda_list: list) -> dict:
cur.execute(f"""
SELECT id_comanda, numar_act, serie_act,
total_fara_tva, total_tva, total_cu_tva
total_fara_tva, total_tva, total_cu_tva,
TO_CHAR(data_act, 'YYYY-MM-DD') AS data_act
FROM vanzari
WHERE id_comanda IN ({placeholders}) AND sters = 0
""", params)
@@ -34,6 +35,7 @@ def check_invoices_for_orders(id_comanda_list: list) -> dict:
"total_fara_tva": float(row[3]) if row[3] else 0,
"total_tva": float(row[4]) if row[4] else 0,
"total_cu_tva": float(row[5]) if row[5] else 0,
"data_act": row[6],
}
except Exception as e:
logger.warning(f"Invoice check failed (table may not exist): {e}")

View File

@@ -759,7 +759,8 @@ async def get_uninvoiced_imported_orders() -> list:
async def update_order_invoice(order_number: str, serie: str = None,
numar: str = None, total_fara_tva: float = None,
total_tva: float = None, total_cu_tva: float = None):
total_tva: float = None, total_cu_tva: float = None,
data_act: str = None):
"""Cache invoice data from Oracle onto the order record."""
db = await get_sqlite()
try:
@@ -770,10 +771,11 @@ async def update_order_invoice(order_number: str, serie: str = None,
factura_total_fara_tva = ?,
factura_total_tva = ?,
factura_total_cu_tva = ?,
factura_data = ?,
invoice_checked_at = datetime('now'),
updated_at = datetime('now')
WHERE order_number = ?
""", (serie, numar, total_fara_tva, total_tva, total_cu_tva, order_number))
""", (serie, numar, total_fara_tva, total_tva, total_cu_tva, data_act, order_number))
await db.commit()
finally:
await db.close()

View File

@@ -91,6 +91,40 @@ def _derive_customer_info(order):
return shipping_name, billing_name, customer, payment_method, delivery_method
async def _fix_stale_error_orders(existing_map: dict, run_id: str):
"""Fix orders stuck in ERROR status that are actually in Oracle.
This can happen when a previous import committed partially (no rollback on error).
If the order exists in Oracle COMENZI, update SQLite status to ALREADY_IMPORTED.
"""
from ..database import get_sqlite
db = await get_sqlite()
try:
cursor = await db.execute(
"SELECT order_number FROM orders WHERE status = 'ERROR'"
)
error_orders = [row["order_number"] for row in await cursor.fetchall()]
fixed = 0
for order_number in error_orders:
if order_number in existing_map:
id_comanda = existing_map[order_number]
await db.execute("""
UPDATE orders SET
status = 'ALREADY_IMPORTED',
id_comanda = ?,
error_message = NULL,
updated_at = datetime('now')
WHERE order_number = ? AND status = 'ERROR'
""", (id_comanda, order_number))
fixed += 1
_log_line(run_id, f"#{order_number} → status corectat ERROR → ALREADY_IMPORTED (ID: {id_comanda})")
if fixed:
await db.commit()
logger.info(f"Fixed {fixed} stale ERROR orders that exist in Oracle")
finally:
await db.close()
async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None) -> dict:
"""Run a full sync cycle. Returns summary dict."""
global _current_sync
@@ -191,6 +225,10 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None
validation_service.check_orders_in_roa, min_date, conn
)
# Step 2a-fix: Fix ERROR orders that are actually in Oracle
# (can happen if previous import committed partially without rollback)
await _fix_stale_error_orders(existing_map, run_id)
# Step 2b: Validate SKUs (reuse same connection)
all_skus = order_reader.get_all_skus(orders)
validation = await asyncio.to_thread(validation_service.validate_skus, all_skus, conn)
@@ -447,6 +485,7 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None
total_fara_tva=inv.get("total_fara_tva"),
total_tva=inv.get("total_tva"),
total_cu_tva=inv.get("total_cu_tva"),
data_act=inv.get("data_act"),
)
invoices_updated += 1
if invoices_updated:

View File

@@ -349,6 +349,18 @@ body {
#quickMapModal + .modal-backdrop,
.modal-backdrop ~ .modal-backdrop { z-index: 1055; }
/* ── Quick Map compact lines ─────────────────────── */
.qm-line { border-bottom: 1px solid #e5e7eb; padding: 6px 0; }
.qm-line:last-child { border-bottom: none; }
.qm-row { display: flex; gap: 6px; align-items: center; }
.qm-codmat-wrap { flex: 1; min-width: 0; }
.qm-rm-btn { padding: 2px 6px; line-height: 1; }
#qmCodmatLines .qm-selected:empty { display: none; }
#quickMapModal .modal-body { padding-top: 12px; padding-bottom: 8px; }
#quickMapModal .modal-header { padding: 10px 16px; }
#quickMapModal .modal-header h5 { font-size: 0.95rem; margin: 0; }
#quickMapModal .modal-footer { padding: 8px 16px; }
/* ── Deleted mapping rows ────────────────────────── */
tr.mapping-deleted td {
text-decoration: line-through;

View File

@@ -460,6 +460,29 @@ function renderCodmatCell(item) {
).join('');
}
// ── Refresh Invoices ──────────────────────────────
async function refreshInvoices() {
const btn = document.getElementById('btnRefreshInvoices');
const btnM = document.getElementById('btnRefreshInvoicesMobile');
if (btn) { btn.disabled = true; btn.textContent = '⟳ Se verifica...'; }
if (btnM) { btnM.disabled = true; }
try {
const res = await fetch('/api/dashboard/refresh-invoices', { method: 'POST' });
const data = await res.json();
if (data.error) {
alert('Eroare: ' + data.error);
} else {
loadDashOrders();
}
} catch (err) {
alert('Eroare: ' + err.message);
} finally {
if (btn) { btn.disabled = false; btn.textContent = '↻ Facturi'; }
if (btnM) { btnM.disabled = false; }
}
}
// ── Order Detail Modal ────────────────────────────
async function openDashOrderDetail(orderNumber) {
@@ -473,6 +496,8 @@ async function openDashOrderDetail(orderNumber) {
document.getElementById('detailIdAdresaLivr').textContent = '-';
document.getElementById('detailItemsBody').innerHTML = '<tr><td colspan="6" class="text-center">Se incarca...</td></tr>';
document.getElementById('detailError').style.display = 'none';
const invInfo = document.getElementById('detailInvoiceInfo');
if (invInfo) invInfo.style.display = 'none';
const detailItemsTotal = document.getElementById('detailItemsTotal');
if (detailItemsTotal) detailItemsTotal.textContent = '-';
const detailOrderTotal = document.getElementById('detailOrderTotal');
@@ -503,6 +528,19 @@ async function openDashOrderDetail(orderNumber) {
document.getElementById('detailIdAdresaFact').textContent = order.id_adresa_facturare || '-';
document.getElementById('detailIdAdresaLivr').textContent = order.id_adresa_livrare || '-';
// Invoice info
const invInfo = document.getElementById('detailInvoiceInfo');
const inv = order.invoice;
if (inv && inv.facturat) {
const serie = inv.serie_act || '';
const numar = inv.numar_act || '';
document.getElementById('detailInvoiceNumber').textContent = serie ? `${serie} ${numar}` : numar;
document.getElementById('detailInvoiceDate').textContent = inv.data_act ? fmtDate(inv.data_act) : '-';
if (invInfo) invInfo.style.display = '';
} else {
if (invInfo) invInfo.style.display = 'none';
}
if (order.error_message) {
document.getElementById('detailError').textContent = order.error_message;
document.getElementById('detailError').style.display = '';
@@ -525,18 +563,21 @@ async function openDashOrderDetail(orderNumber) {
document.getElementById('detailItemsTotal').textContent = itemsTotal.toFixed(2) + ' lei';
document.getElementById('detailOrderTotal').textContent = order.order_total != null ? Number(order.order_total).toFixed(2) + ' lei' : '-';
// Store items for quick map pre-population
window._detailItems = items;
// Mobile article flat list
const mobileContainer = document.getElementById('detailItemsMobile');
if (mobileContainer) {
mobileContainer.innerHTML = '<div class="detail-item-flat">' + items.map((item, idx) => {
const codmatList = item.codmat_details?.length
? item.codmat_details.map(d => `<span class="dif-codmat-link" onclick="openQuickMap('${esc(item.sku)}','${esc(item.product_name||'')}','${esc(orderNumber)}')">${esc(d.codmat)}</span>`).join(' ')
: `<span class="dif-codmat-link" onclick="openQuickMap('${esc(item.sku)}','${esc(item.product_name||'')}','${esc(orderNumber)}')">${esc(item.codmat || '')}</span>`;
const codmatText = item.codmat_details?.length
? item.codmat_details.map(d => `<code>${esc(d.codmat)}</code>`).join(' ')
: `<code>${esc(item.codmat || '')}</code>`;
const valoare = (Number(item.price || 0) * Number(item.quantity || 0)).toFixed(2);
return `<div class="dif-item">
<div class="dif-row">
<span class="dif-sku">${esc(item.sku)}</span>
${codmatList}
<span class="dif-sku dif-codmat-link" onclick="openQuickMap('${esc(item.sku)}','${esc(item.product_name||'')}','${esc(orderNumber)}', ${idx})">${esc(item.sku)}</span>
${codmatText}
</div>
<div class="dif-row">
<span class="dif-name">${esc(item.product_name || '')}</span>
@@ -547,13 +588,12 @@ async function openDashOrderDetail(orderNumber) {
}).join('') + '</div>';
}
document.getElementById('detailItemsBody').innerHTML = items.map(item => {
document.getElementById('detailItemsBody').innerHTML = items.map((item, idx) => {
const valoare = (Number(item.price || 0) * Number(item.quantity || 0)).toFixed(2);
const codmatCell = `<span class="codmat-link" onclick="openQuickMap('${esc(item.sku)}', '${esc(item.product_name || '')}', '${esc(orderNumber)}')" title="Click pentru mapare">${renderCodmatCell(item)}</span>`;
return `<tr>
<td><code>${esc(item.sku)}</code></td>
<td><code class="codmat-link" onclick="openQuickMap('${esc(item.sku)}', '${esc(item.product_name || '')}', '${esc(orderNumber)}', ${idx})" title="Click pentru mapare">${esc(item.sku)}</code></td>
<td>${esc(item.product_name || '-')}</td>
<td>${codmatCell}</td>
<td>${renderCodmatCell(item)}</td>
<td>${item.quantity || 0}</td>
<td>${item.price != null ? Number(item.price).toFixed(2) : '-'}</td>
<td class="text-end">${valoare}</td>
@@ -567,7 +607,7 @@ async function openDashOrderDetail(orderNumber) {
// ── Quick Map Modal ───────────────────────────────
function openQuickMap(sku, productName, orderNumber) {
function openQuickMap(sku, productName, orderNumber, itemIdx) {
currentQmSku = sku;
currentQmOrderNumber = orderNumber;
document.getElementById('qmSku').textContent = sku;
@@ -576,36 +616,41 @@ function openQuickMap(sku, productName, orderNumber) {
const container = document.getElementById('qmCodmatLines');
container.innerHTML = '';
addQmCodmatLine();
// Pre-populate with existing codmat_details if available
const item = (window._detailItems || [])[itemIdx];
const details = item?.codmat_details;
if (details && details.length > 0) {
details.forEach(d => {
addQmCodmatLine({ codmat: d.codmat, cantitate: d.cantitate_roa, procent: d.procent_pret, denumire: d.denumire });
});
} else {
addQmCodmatLine();
}
new bootstrap.Modal(document.getElementById('quickMapModal')).show();
}
function addQmCodmatLine() {
function addQmCodmatLine(prefill) {
const container = document.getElementById('qmCodmatLines');
const idx = container.children.length;
const codmatVal = prefill?.codmat || '';
const cantVal = prefill?.cantitate || 1;
const pctVal = prefill?.procent || 100;
const denumireVal = prefill?.denumire || '';
const div = document.createElement('div');
div.className = 'border rounded p-2 mb-2 qm-line';
div.className = 'qm-line';
div.innerHTML = `
<div class="mb-2 position-relative">
<label class="form-label form-label-sm mb-1">CODMAT (Articol ROA)</label>
<input type="text" class="form-control form-control-sm qm-codmat" placeholder="Cauta codmat sau denumire..." autocomplete="off">
<div class="autocomplete-dropdown d-none qm-ac-dropdown"></div>
<small class="text-muted qm-selected"></small>
</div>
<div class="row">
<div class="col-5">
<label class="form-label form-label-sm mb-1">Cantitate ROA</label>
<input type="number" class="form-control form-control-sm qm-cantitate" value="1" step="0.001" min="0.001">
</div>
<div class="col-5">
<label class="form-label form-label-sm mb-1">Procent Pret (%)</label>
<input type="number" class="form-control form-control-sm qm-procent" value="100" step="0.01" min="0" max="100">
</div>
<div class="col-2 d-flex align-items-end">
${idx > 0 ? `<button type="button" class="btn btn-sm btn-outline-danger" onclick="this.closest('.qm-line').remove()"><i class="bi bi-x"></i></button>` : ''}
</div>
<div class="qm-row">
<div class="qm-codmat-wrap position-relative">
<input type="text" class="form-control form-control-sm qm-codmat" placeholder="CODMAT..." autocomplete="off" value="${escHtml(codmatVal)}">
<div class="autocomplete-dropdown d-none qm-ac-dropdown"></div>
</div>
<input type="number" class="form-control form-control-sm qm-cantitate" value="${cantVal}" step="0.001" min="0.001" title="Cantitate ROA" style="width:70px">
<input type="number" class="form-control form-control-sm qm-procent" value="${pctVal}" step="0.01" min="0" max="100" title="Procent %" style="width:70px">
${idx > 0 ? `<button type="button" class="btn btn-sm btn-outline-danger qm-rm-btn" onclick="this.closest('.qm-line').remove()"><i class="bi bi-x"></i></button>` : '<span style="width:30px"></span>'}
</div>
<div class="qm-selected text-muted" style="font-size:0.75rem;padding-left:2px">${escHtml(denumireVal)}</div>
`;
container.appendChild(div);

View File

@@ -7,7 +7,7 @@
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.2/font/bootstrap-icons.css" rel="stylesheet">
{% set rp = request.scope.get('root_path', '') %}
<link href="{{ rp }}/static/css/style.css?v=9" rel="stylesheet">
<link href="{{ rp }}/static/css/style.css?v=10" rel="stylesheet">
</head>
<body>
<!-- Top Navbar -->

View File

@@ -69,10 +69,14 @@
<button class="filter-pill d-none d-md-inline-flex" data-status="ERROR">Erori <span class="filter-count fc-red" id="cntErr">0</span></button>
<button class="filter-pill d-none d-md-inline-flex" data-status="INVOICED">Facturate <span class="filter-count fc-green" id="cntFact">0</span></button>
<button class="filter-pill d-none d-md-inline-flex" data-status="UNINVOICED">Nefacturate <span class="filter-count fc-red" id="cntNef">0</span></button>
<button class="btn btn-sm btn-outline-secondary d-none d-md-inline-flex align-items-center gap-1" id="btnRefreshInvoices" onclick="refreshInvoices()" title="Actualizeaza status facturi din Oracle">&#8635; Facturi</button>
<!-- Search (integrated, end of row) -->
<input type="search" id="orderSearch" placeholder="Cauta..." class="search-input">
</div>
<div class="d-md-none mb-2" id="dashMobileSeg"></div>
<div class="d-md-none mb-2 d-flex align-items-center gap-2">
<div class="flex-grow-1" id="dashMobileSeg"></div>
<button class="btn btn-sm btn-outline-secondary" id="btnRefreshInvoicesMobile" onclick="refreshInvoices()" title="Actualizeaza facturi" style="padding:4px 8px; font-size:1rem; line-height:1">&#8635;</button>
</div>
</div>
<div id="dashPaginationTop" class="pag-strip"></div>
<div class="card-body p-0">
@@ -115,6 +119,10 @@
<small class="text-muted">Client:</small> <strong id="detailCustomer"></strong><br>
<small class="text-muted">Data comanda:</small> <span id="detailDate"></span><br>
<small class="text-muted">Status:</small> <span id="detailStatus"></span>
<div id="detailInvoiceInfo" style="display:none; margin-top:4px;">
<small class="text-muted">Factura:</small> <strong id="detailInvoiceNumber"></strong>
<br><small class="text-muted">Data factura:</small> <span id="detailInvoiceDate"></span>
</div>
</div>
<div class="col-md-6">
<small class="text-muted">ID Comanda ROA:</small> <span id="detailIdComanda">-</span><br>
@@ -164,14 +172,20 @@
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-2">
<small class="text-muted">Produs web:</small> <strong id="qmProductName"></strong>
<div style="margin-bottom:8px; font-size:0.85rem">
<small class="text-muted">Produs:</small> <strong id="qmProductName"></strong>
</div>
<div class="qm-row" style="font-size:0.7rem; color:#9ca3af; padding:0 0 2px">
<span style="flex:1">CODMAT</span>
<span style="width:70px">Cant.</span>
<span style="width:70px">%</span>
<span style="width:30px"></span>
</div>
<div id="qmCodmatLines">
<!-- Dynamic CODMAT lines -->
</div>
<button type="button" class="btn btn-sm btn-outline-secondary mt-2" onclick="addQmCodmatLine()">
<i class="bi bi-plus"></i> Adauga CODMAT
<button type="button" class="btn btn-sm btn-outline-secondary mt-1" onclick="addQmCodmatLine()" style="font-size:0.8rem; padding:2px 10px">
+ CODMAT
</button>
<div id="qmPctWarning" class="text-danger mt-2" style="display:none;"></div>
</div>
@@ -185,5 +199,5 @@
{% endblock %}
{% block scripts %}
<script src="{{ request.scope.get('root_path', '') }}/static/js/dashboard.js?v=9"></script>
<script src="{{ request.scope.get('root_path', '') }}/static/js/dashboard.js?v=11"></script>
{% endblock %}