feat(safety): needs attention card on dashboard

Add a "Needs Attention" card above the orders table that surfaces:
- Import errors count (click → ERROR filter)
- Unmapped SKUs count (click → Missing SKUs page)
- Uninvoiced orders >3 days (click → UNINVOICED filter)
Shows green "Totul in ordine" when all metrics are zero.

Backend: add uninvoiced_old count to get_orders() and unresolved_skus
from get_dashboard_stats() to dashboard/orders API response.

Cache-bust: style.css?v=21, dashboard.js?v=29

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude Agent
2026-03-27 12:28:18 +00:00
parent 3bd0556f73
commit a10a00aa4d
6 changed files with 77 additions and 2 deletions

View File

@@ -564,6 +564,13 @@ async def dashboard_orders(page: int = 1, per_page: int = 50,
counts["facturate"] = max(0, imported_total - counts["nefacturate"]) counts["facturate"] = max(0, imported_total - counts["nefacturate"])
counts.setdefault("total", counts.get("imported", 0) + counts.get("skipped", 0) + counts.get("error", 0)) counts.setdefault("total", counts.get("imported", 0) + counts.get("skipped", 0) + counts.get("error", 0))
# Attention metrics: add unresolved SKUs count
try:
stats = await sqlite_service.get_dashboard_stats()
counts["unresolved_skus"] = stats.get("unresolved_skus", 0)
except Exception:
counts["unresolved_skus"] = 0
# For UNINVOICED filter: apply server-side filtering + pagination # For UNINVOICED filter: apply server-side filtering + pagination
if is_uninvoiced_filter: if is_uninvoiced_filter:
filtered = [o for o in all_orders if o.get("status") in ("IMPORTED", "ALREADY_IMPORTED") and not o.get("invoice")] filtered = [o for o in all_orders if o.get("status") in ("IMPORTED", "ALREADY_IMPORTED") and not o.get("invoice")]

View File

@@ -739,6 +739,16 @@ async def get_orders(page: int = 1, per_page: int = 50,
cursor = await db.execute(f"SELECT COUNT(*) FROM orders {uninv_where}", base_params) cursor = await db.execute(f"SELECT COUNT(*) FROM orders {uninv_where}", base_params)
uninvoiced_sqlite = (await cursor.fetchone())[0] uninvoiced_sqlite = (await cursor.fetchone())[0]
# Uninvoiced > 3 days old
uninv_old_clauses = list(base_clauses) + [
"UPPER(status) IN ('IMPORTED', 'ALREADY_IMPORTED')",
"(factura_numar IS NULL OR factura_numar = '')",
"order_date < datetime('now', '-3 days')",
]
uninv_old_where = "WHERE " + " AND ".join(uninv_old_clauses)
cursor = await db.execute(f"SELECT COUNT(*) FROM orders {uninv_old_where}", base_params)
uninvoiced_old = (await cursor.fetchone())[0]
return { return {
"orders": [dict(r) for r in rows], "orders": [dict(r) for r in rows],
"total": total, "total": total,
@@ -754,6 +764,7 @@ async def get_orders(page: int = 1, per_page: int = 50,
"cancelled": status_counts.get("CANCELLED", 0), "cancelled": status_counts.get("CANCELLED", 0),
"total": sum(status_counts.values()), "total": sum(status_counts.values()),
"uninvoiced_sqlite": uninvoiced_sqlite, "uninvoiced_sqlite": uninvoiced_sqlite,
"uninvoiced_old": uninvoiced_old,
} }
} }
finally: finally:

View File

@@ -995,3 +995,41 @@ tr.mapping-deleted td {
margin: 0; margin: 0;
cursor: pointer; cursor: pointer;
} }
/* ── Attention card ──────────────────────────── */
.attention-card {
display: flex;
align-items: center;
gap: 16px;
padding: 10px 16px;
border-radius: 8px;
font-size: 0.875rem;
margin-bottom: 8px;
}
.attention-ok {
background: var(--success-light);
color: var(--success-text);
}
.attention-alert {
background: var(--surface);
border: 1px solid var(--border);
flex-wrap: wrap;
}
.attention-item {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
border-radius: 4px;
cursor: pointer;
transition: opacity 0.15s;
}
.attention-item:hover { opacity: 0.8; }
.attention-error {
background: var(--error-light);
color: var(--error-text);
}
.attention-warning {
background: var(--warning-light);
color: var(--warning-text);
}

View File

@@ -301,6 +301,24 @@ async function loadDashOrders() {
if (el('cntNef')) el('cntNef').textContent = c.nefacturate || c.uninvoiced || 0; if (el('cntNef')) el('cntNef').textContent = c.nefacturate || c.uninvoiced || 0;
if (el('cntCanc')) el('cntCanc').textContent = c.cancelled || 0; if (el('cntCanc')) el('cntCanc').textContent = c.cancelled || 0;
// Attention card
const attnEl = document.getElementById('attentionCard');
if (attnEl) {
const errors = c.error || 0;
const unmapped = c.unresolved_skus || 0;
const uninvOld = c.uninvoiced_old || 0;
if (errors === 0 && unmapped === 0 && uninvOld === 0) {
attnEl.innerHTML = '<div class="attention-card attention-ok"><i class="bi bi-check-circle"></i> Totul in ordine</div>';
} else {
let items = [];
if (errors > 0) items.push(`<span class="attention-item attention-error" onclick="document.querySelector('.filter-pill[data-status=ERROR]')?.click()"><i class="bi bi-exclamation-triangle"></i> ${errors} erori import</span>`);
if (unmapped > 0) items.push(`<span class="attention-item attention-warning" onclick="window.location='${window.ROOT_PATH||''}/missing-skus'"><i class="bi bi-puzzle"></i> ${unmapped} SKU-uri nemapate</span>`);
if (uninvOld > 0) items.push(`<span class="attention-item attention-warning" onclick="document.querySelector('.filter-pill[data-status=UNINVOICED]')?.click()"><i class="bi bi-receipt"></i> ${uninvOld} nefacturate &gt;3 zile</span>`);
attnEl.innerHTML = '<div class="attention-card attention-alert">' + items.join('') + '</div>';
}
}
const tbody = document.getElementById('dashOrdersBody'); const tbody = document.getElementById('dashOrdersBody');
const orders = data.orders || []; const orders = data.orders || [];

View File

@@ -19,7 +19,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@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"> <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', '') %} {% set rp = request.scope.get('root_path', '') %}
<link href="{{ rp }}/static/css/style.css?v=20" rel="stylesheet"> <link href="{{ rp }}/static/css/style.css?v=21" rel="stylesheet">
</head> </head>
<body> <body>
<!-- Top Navbar (hidden on mobile via CSS) --> <!-- Top Navbar (hidden on mobile via CSS) -->

View File

@@ -49,6 +49,7 @@
<span>Comenzi</span> <span>Comenzi</span>
</div> </div>
<div class="card-body py-2 px-3"> <div class="card-body py-2 px-3">
<div id="attentionCard"></div>
<div class="filter-bar" id="ordersFilterBar"> <div class="filter-bar" id="ordersFilterBar">
<!-- Period dropdown --> <!-- Period dropdown -->
<select id="periodSelect" class="select-compact"> <select id="periodSelect" class="select-compact">
@@ -114,5 +115,5 @@
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
<script src="{{ request.scope.get('root_path', '') }}/static/js/dashboard.js?v=28"></script> <script src="{{ request.scope.get('root_path', '') }}/static/js/dashboard.js?v=29"></script>
{% endblock %} {% endblock %}