Compare commits

..

4 Commits

Author SHA1 Message Date
Claude Agent
5a0ea462e5 fix(validation): remove non-existent find_new_orders call
Replace broken asyncio.to_thread call with len(importable)
which already represents orders ready to process.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-14 21:59:05 +00:00
Claude Agent
452dc9b9f0 feat(mappings): strict validation + silent CSV skip for missing CODMAT
Add Pydantic validators and service-level checks that reject empty SKU/CODMAT
on create/edit (400). CSV import now silently skips rows without CODMAT and
counts them in skipped_no_codmat instead of treating them as errors.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-14 21:46:59 +00:00
Claude Agent
9cacc19d15 fix(ui): fix set pct badge logic and compact CODMAT form layout
- Fix is_complete check: use abs(pct-100)<=0.01 instead of >=99.99
  so sets with >100% total are correctly shown as incomplete
- Show pct badge with 2 decimals (e.g. "⚠️ 200.00%")
- Remove product name pre-fill in missing SKUs map modal CODMAT field
- Compact CODMAT lines to single row with placeholders instead of labels

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-14 21:21:49 +00:00
Claude Agent
15ccbe028a fix(dashboard): fix pill counts and Bootstrap UI cleanup
- IMPORTED pill now includes ALREADY_IMPORTED orders in count
- UNINVOICED filter includes ALREADY_IMPORTED orders
- Pill counts (Toate/Importate/Omise/Erori/Nefacturate) always reflect
  full period+search, independent of active status filter
- Nefacturate count computed from SQLite cache across full period,
  not just current page
- Bootstrap UI: design tokens, soft badge pills, consistent font sizes,
  purge inline styles from templates, move badge-pct to style.css

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-14 21:05:43 +00:00
12 changed files with 354 additions and 292 deletions

View File

@@ -2,7 +2,7 @@ from fastapi import APIRouter, Query, Request, UploadFile, File
from fastapi.responses import StreamingResponse, HTMLResponse, JSONResponse
from fastapi.templating import Jinja2Templates
from fastapi import HTTPException
from pydantic import BaseModel
from pydantic import BaseModel, validator
from pathlib import Path
from typing import Optional
import io
@@ -21,6 +21,12 @@ class MappingCreate(BaseModel):
cantitate_roa: float = 1
procent_pret: float = 100
@validator('sku', 'codmat')
def not_empty(cls, v):
if not v or not v.strip():
raise ValueError('nu poate fi gol')
return v.strip()
class MappingUpdate(BaseModel):
cantitate_roa: Optional[float] = None
procent_pret: Optional[float] = None
@@ -32,6 +38,12 @@ class MappingEdit(BaseModel):
cantitate_roa: float = 1
procent_pret: float = 100
@validator('new_sku', 'new_codmat')
def not_empty(cls, v):
if not v or not v.strip():
raise ValueError('nu poate fi gol')
return v.strip()
class MappingLine(BaseModel):
codmat: str
cantitate_roa: float = 1

View File

@@ -377,19 +377,18 @@ async def dashboard_orders(page: int = 1, per_page: int = 50,
o["billing_name"] = b_name
o["is_different_person"] = bool(s_name and b_name and s_name != b_name)
# Build period-total counts (across all pages, same filters)
nefacturate_count = sum(
1 for o in all_orders
if o.get("status") == "IMPORTED" and not o.get("invoice")
)
# Use counts from sqlite_service (already period-scoped) and add nefacturate
# Use counts from sqlite_service (already period-scoped)
counts = result.get("counts", {})
counts["nefacturate"] = nefacturate_count
# Prefer SQLite-based uninvoiced count (covers full period, not just current page)
counts["nefacturate"] = 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.setdefault("total", counts.get("imported", 0) + counts.get("skipped", 0) + counts.get("error", 0))
# For UNINVOICED filter: apply server-side filtering + pagination
if is_uninvoiced_filter:
filtered = [o for o in all_orders if o.get("status") == "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")]
total = len(filtered)
offset = (page - 1) * per_page
result["orders"] = filtered[offset:offset + per_page]

View File

@@ -1,4 +1,3 @@
import asyncio
import csv
import io
import json
@@ -25,10 +24,6 @@ async def scan_and_validate():
result = validation_service.validate_skus(all_skus)
importable, skipped = validation_service.classify_orders(orders, result)
# Find new orders (not yet in Oracle)
all_order_numbers = [o.number for o in orders]
new_orders = await asyncio.to_thread(validation_service.find_new_orders, all_order_numbers)
# Build SKU context from skipped orders and track missing SKUs
sku_context = {} # sku -> {order_numbers: [], customers: []}
for order, missing_list in skipped:
@@ -73,7 +68,7 @@ async def scan_and_validate():
"total_skus": len(all_skus),
"importable": len(importable),
"skipped": len(skipped),
"new_orders": len(new_orders),
"new_orders": len(importable),
# Fields consumed by the rescan progress banner in missing_skus.html
"total_skus_scanned": total_skus_scanned,
"new_missing": new_missing_count,

View File

@@ -88,7 +88,7 @@ def get_mappings(search: str = "", page: int = 1, per_page: int = 50,
for r in rows
if r.get("activ") == 1
)
if pct_total >= 99.99:
if abs(pct_total - 100) <= 0.01:
complete_skus += 1
else:
incomplete_skus += 1
@@ -108,7 +108,7 @@ def get_mappings(search: str = "", page: int = 1, per_page: int = 50,
for r in rows
if r.get("activ") == 1
)
is_complete = pct_total >= 99.99
is_complete = abs(pct_total - 100) <= 0.01
if pct_filter == "complete" and is_complete:
filtered_groups[sku] = rows
elif pct_filter == "incomplete" and not is_complete:
@@ -129,7 +129,7 @@ def get_mappings(search: str = "", page: int = 1, per_page: int = 50,
for r in rows
if r.get("activ") == 1
)
sku_pct[sku] = {"pct_total": pct_total, "is_complete": pct_total >= 99.99}
sku_pct[sku] = {"pct_total": pct_total, "is_complete": abs(pct_total - 100) <= 0.01}
for row in page_rows:
meta = sku_pct.get(row["sku"], {"pct_total": 0, "is_complete": False})
@@ -147,6 +147,10 @@ def get_mappings(search: str = "", page: int = 1, per_page: int = 50,
def create_mapping(sku: str, codmat: str, cantitate_roa: float = 1, procent_pret: float = 100):
"""Create a new mapping. Returns dict or raises HTTPException on duplicate."""
if not sku or not sku.strip():
raise HTTPException(status_code=400, detail="SKU este obligatoriu")
if not codmat or not codmat.strip():
raise HTTPException(status_code=400, detail="CODMAT este obligatoriu")
if database.pool is None:
raise HTTPException(status_code=503, detail="Oracle unavailable")
@@ -229,6 +233,10 @@ def delete_mapping(sku: str, codmat: str):
def edit_mapping(old_sku: str, old_codmat: str, new_sku: str, new_codmat: str,
cantitate_roa: float = 1, procent_pret: float = 100):
"""Edit a mapping. If PK changed, soft-delete old and insert new."""
if not new_sku or not new_sku.strip():
raise HTTPException(status_code=400, detail="SKU este obligatoriu")
if not new_codmat or not new_codmat.strip():
raise HTTPException(status_code=400, detail="CODMAT este obligatoriu")
if database.pool is None:
raise HTTPException(status_code=503, detail="Oracle unavailable")
@@ -283,23 +291,27 @@ def import_csv(file_content: str):
reader = csv.DictReader(io.StringIO(file_content))
created = 0
updated = 0
skipped_no_codmat = 0
errors = []
with database.pool.acquire() as conn:
with conn.cursor() as cur:
for i, row in enumerate(reader, 1):
sku = row.get("sku", "").strip()
codmat = row.get("codmat", "").strip()
if not sku:
errors.append(f"Rând {i}: SKU lipsă")
continue
if not codmat:
skipped_no_codmat += 1
continue
try:
sku = row.get("sku", "").strip()
codmat = row.get("codmat", "").strip()
cantitate = float(row.get("cantitate_roa", "1") or "1")
procent = float(row.get("procent_pret", "100") or "100")
if not sku or not codmat:
errors.append(f"Row {i}: missing sku or codmat")
continue
# Try update first, insert if not exists (MERGE)
cur.execute("""
MERGE INTO ARTICOLE_TERTI t
USING (SELECT :sku AS sku, :codmat AS codmat FROM DUAL) s
@@ -314,16 +326,14 @@ def import_csv(file_content: str):
(sku, codmat, cantitate_roa, procent_pret, activ, sters, data_creare, id_util_creare)
VALUES (:sku, :codmat, :cantitate_roa, :procent_pret, 1, 0, SYSDATE, -3)
""", {"sku": sku, "codmat": codmat, "cantitate_roa": cantitate, "procent_pret": procent})
# Check if it was insert or update by rowcount
created += 1 # We count total processed
created += 1
except Exception as e:
errors.append(f"Row {i}: {str(e)}")
errors.append(f"Rând {i}: {str(e)}")
conn.commit()
return {"processed": created, "errors": errors}
return {"processed": created, "skipped_no_codmat": skipped_no_codmat, "errors": errors}
def export_csv():
"""Export all mappings as CSV string."""

View File

@@ -623,25 +623,34 @@ async def get_orders(page: int = 1, per_page: int = 50,
"""
db = await get_sqlite()
try:
where_clauses = []
params = []
# Period + search clauses (used for counts — never include status filter)
base_clauses = []
base_params = []
if period_days and period_days > 0:
where_clauses.append("order_date >= date('now', ?)")
params.append(f"-{period_days} days")
base_clauses.append("order_date >= date('now', ?)")
base_params.append(f"-{period_days} days")
elif period_days == 0 and period_start and period_end:
where_clauses.append("order_date BETWEEN ? AND ?")
params.extend([period_start, period_end])
base_clauses.append("order_date BETWEEN ? AND ?")
base_params.extend([period_start, period_end])
if search:
where_clauses.append("(order_number LIKE ? OR customer_name LIKE ?)")
params.extend([f"%{search}%", f"%{search}%"])
base_clauses.append("(order_number LIKE ? OR customer_name LIKE ?)")
base_params.extend([f"%{search}%", f"%{search}%"])
# Data query adds status filter on top of base filters
data_clauses = list(base_clauses)
data_params = list(base_params)
if status_filter and status_filter not in ("all", "UNINVOICED"):
where_clauses.append("UPPER(status) = ?")
params.append(status_filter.upper())
if status_filter.upper() == "IMPORTED":
data_clauses.append("UPPER(status) IN ('IMPORTED', 'ALREADY_IMPORTED')")
else:
data_clauses.append("UPPER(status) = ?")
data_params.append(status_filter.upper())
where = ("WHERE " + " AND ".join(where_clauses)) if where_clauses else ""
where = ("WHERE " + " AND ".join(data_clauses)) if data_clauses else ""
counts_where = ("WHERE " + " AND ".join(base_clauses)) if base_clauses else ""
allowed_sort = {"order_date", "order_number", "customer_name", "items_count",
"status", "first_seen_at", "updated_at"}
@@ -650,7 +659,7 @@ async def get_orders(page: int = 1, per_page: int = 50,
if sort_dir.lower() not in ("asc", "desc"):
sort_dir = "desc"
cursor = await db.execute(f"SELECT COUNT(*) FROM orders {where}", params)
cursor = await db.execute(f"SELECT COUNT(*) FROM orders {where}", data_params)
total = (await cursor.fetchone())[0]
offset = (page - 1) * per_page
@@ -659,17 +668,26 @@ async def get_orders(page: int = 1, per_page: int = 50,
{where}
ORDER BY {sort_by} {sort_dir}
LIMIT ? OFFSET ?
""", params + [per_page, offset])
""", data_params + [per_page, offset])
rows = await cursor.fetchall()
# Counts by status (on full period, not just this page)
# Counts by status — always on full period+search, never filtered by status
cursor = await db.execute(f"""
SELECT status, COUNT(*) as cnt FROM orders
{where}
{counts_where}
GROUP BY status
""", params)
""", base_params)
status_counts = {row["status"]: row["cnt"] for row in await cursor.fetchall()}
# Uninvoiced count: IMPORTED/ALREADY_IMPORTED with no cached invoice, same period+search
uninv_clauses = list(base_clauses) + [
"UPPER(status) IN ('IMPORTED', 'ALREADY_IMPORTED')",
"(factura_numar IS NULL OR factura_numar = '')",
]
uninv_where = "WHERE " + " AND ".join(uninv_clauses)
cursor = await db.execute(f"SELECT COUNT(*) FROM orders {uninv_where}", base_params)
uninvoiced_sqlite = (await cursor.fetchone())[0]
return {
"orders": [dict(r) for r in rows],
"total": total,
@@ -678,10 +696,12 @@ async def get_orders(page: int = 1, per_page: int = 50,
"pages": (total + per_page - 1) // per_page if total > 0 else 0,
"counts": {
"imported": status_counts.get("IMPORTED", 0),
"already_imported": status_counts.get("ALREADY_IMPORTED", 0),
"imported_all": status_counts.get("IMPORTED", 0) + status_counts.get("ALREADY_IMPORTED", 0),
"skipped": status_counts.get("SKIPPED", 0),
"error": status_counts.get("ERROR", 0),
"already_imported": status_counts.get("ALREADY_IMPORTED", 0),
"total": sum(status_counts.values())
"total": sum(status_counts.values()),
"uninvoiced_sqlite": uninvoiced_sqlite,
}
}
finally:

View File

@@ -1,21 +1,44 @@
/* ── Design tokens ───────────────────────────────── */
:root {
--sidebar-width: 220px;
--sidebar-bg: #1e293b;
--sidebar-text: #94a3b8;
--sidebar-active: #ffffff;
--sidebar-hover-bg: #334155;
--body-bg: #f1f5f9;
--card-shadow: 0 1px 3px rgba(0,0,0,0.08);
/* Sidebar */
--sidebar-width: 224px;
--sidebar-bg: #111827;
--sidebar-text: #d1d5db;
--sidebar-active-bg: #1f2937;
--sidebar-active-text: #ffffff;
--sidebar-border: #374151;
/* Surfaces */
--body-bg: #f9fafb;
--card-bg: #ffffff;
--card-shadow: 0 1px 3px rgba(0,0,0,0.1), 0 1px 2px rgba(0,0,0,0.06);
--card-radius: 0.5rem;
/* Semantic colors */
--blue-600: #2563eb;
--blue-700: #1d4ed8;
--green-100: #dcfce7; --green-800: #166534;
--yellow-100: #fef9c3; --yellow-800: #854d0e;
--red-100: #fee2e2; --red-800: #991b1b;
--blue-100: #dbeafe; --blue-800: #1e40af;
/* Text */
--text-primary: #111827;
--text-secondary: #4b5563;
--text-muted: #6b7280;
--border-color: #e5e7eb;
}
/* ── Base ────────────────────────────────────────── */
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
font-size: 0.875rem;
background-color: var(--body-bg);
margin: 0;
padding: 0;
}
/* Sidebar */
/* ── Sidebar ─────────────────────────────────────── */
.sidebar {
position: fixed;
top: 0;
@@ -31,7 +54,7 @@ body {
.sidebar-header {
padding: 1.25rem 1rem;
border-bottom: 1px solid #334155;
border-bottom: 1px solid var(--sidebar-border);
}
.sidebar-header h5 {
@@ -43,21 +66,22 @@ body {
.sidebar .nav-link {
color: var(--sidebar-text);
padding: 0.65rem 1rem;
font-size: 0.9rem;
border-left: 3px solid transparent;
transition: all 0.15s ease;
font-size: 0.875rem;
font-weight: 500;
padding: 0.5rem 0.75rem;
border-radius: 0.375rem;
margin: 0.125rem 0.5rem;
transition: background 0.15s, color 0.15s;
}
.sidebar .nav-link:hover {
color: var(--sidebar-active);
background-color: var(--sidebar-hover-bg);
color: var(--sidebar-active-text);
background-color: var(--sidebar-active-bg);
}
.sidebar .nav-link.active {
color: var(--sidebar-active);
background-color: var(--sidebar-hover-bg);
border-left-color: #3b82f6;
color: var(--sidebar-active-text);
background-color: var(--sidebar-active-bg);
}
.sidebar .nav-link i {
@@ -70,18 +94,18 @@ body {
position: absolute;
bottom: 0;
padding: 0.75rem 1rem;
border-top: 1px solid #334155;
border-top: 1px solid var(--sidebar-border);
width: 100%;
}
/* Main content */
/* ── Main content ────────────────────────────────── */
.main-content {
margin-left: var(--sidebar-width);
padding: 1.5rem;
min-height: 100vh;
}
/* Sidebar toggle button for mobile */
/* ── Sidebar toggle (mobile) ─────────────────────── */
.sidebar-toggle {
position: fixed;
top: 0.5rem;
@@ -90,100 +114,102 @@ body {
border-radius: 0.375rem;
}
/* Cards */
/* ── Cards ───────────────────────────────────────── */
.card {
border: none;
box-shadow: var(--card-shadow);
border-radius: 0.5rem;
border-radius: var(--card-radius);
background: var(--card-bg);
}
.card-header {
background-color: #fff;
border-bottom: 1px solid #e2e8f0;
background: var(--card-bg);
border-bottom: 1px solid var(--border-color);
font-weight: 600;
font-size: 0.9rem;
font-size: 0.875rem;
padding: 0.75rem 1rem;
}
/* Status badges */
.badge-imported { background-color: #22c55e; }
.badge-skipped { background-color: #eab308; color: #000; }
.badge-error { background-color: #ef4444; }
.badge-pending { background-color: #94a3b8; }
.badge-ready { background-color: #3b82f6; }
/* Tables */
/* ── Tables ──────────────────────────────────────── */
.table {
font-size: 0.875rem;
}
.table th {
font-weight: 600;
color: #475569;
font-size: 0.75rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-muted);
background: #f9fafb;
padding: 0.75rem 1rem;
border-top: none;
}
/* Forms */
.form-control:focus, .form-select:focus {
border-color: #3b82f6;
box-shadow: 0 0 0 0.2rem rgba(59, 130, 246, 0.15);
.table td {
padding: 0.75rem 1rem;
color: var(--text-secondary);
}
/* Responsive */
@media (max-width: 767.98px) {
.sidebar {
transform: translateX(-100%);
}
.sidebar.show {
transform: translateX(0);
}
.main-content {
margin-left: 0;
}
.sidebar-toggle {
display: block !important;
}
/* ── Badges — soft pill style ────────────────────── */
.badge {
font-size: 0.75rem;
font-weight: 500;
padding: 0.125rem 0.5rem;
border-radius: 9999px;
}
/* Autocomplete dropdown */
.autocomplete-dropdown {
position: absolute;
z-index: 1050;
background: #fff;
border: 1px solid #dee2e6;
border-radius: 0.375rem;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
max-height: 300px;
overflow-y: auto;
width: 100%;
}
.badge.bg-success { background: var(--green-100) !important; color: var(--green-800) !important; }
.badge.bg-info { background: var(--blue-100) !important; color: var(--blue-800) !important; }
.badge.bg-warning { background: var(--yellow-100) !important; color: var(--yellow-800) !important; }
.badge.bg-danger { background: var(--red-100) !important; color: var(--red-800) !important; }
.autocomplete-item {
padding: 0.5rem 0.75rem;
cursor: pointer;
/* Legacy badge classes */
.badge-imported { background: var(--green-100); color: var(--green-800); }
.badge-skipped { background: var(--yellow-100); color: var(--yellow-800); }
.badge-error { background: var(--red-100); color: var(--red-800); }
.badge-pending { background: #f3f4f6; color: #374151; }
.badge-ready { background: var(--blue-100); color: var(--blue-800); }
/* ── Buttons ─────────────────────────────────────── */
.btn {
font-size: 0.875rem;
border-bottom: 1px solid #f1f5f9;
border-radius: 0.375rem;
}
.autocomplete-item:hover, .autocomplete-item.active {
background-color: #f1f5f9;
.btn-sm {
font-size: 0.875rem;
padding: 0.375rem 0.75rem;
}
.autocomplete-item .codmat {
font-weight: 600;
color: #1e293b;
.btn-primary {
background: var(--blue-600);
border-color: var(--blue-600);
}
.btn-primary:hover {
background: var(--blue-700);
border-color: var(--blue-700);
}
.autocomplete-item .denumire {
color: #64748b;
font-size: 0.8rem;
/* ── Forms ───────────────────────────────────────── */
.form-control, .form-select {
font-size: 0.875rem;
padding: 0.5rem 0.75rem;
border-radius: 0.375rem;
border-color: #d1d5db;
}
/* Pagination */
.form-control:focus, .form-select:focus {
border-color: var(--blue-600);
box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.2);
}
/* ── Pagination ──────────────────────────────────── */
.pagination .page-link {
font-size: 0.875rem;
}
/* Loading spinner */
/* ── Loading spinner ─────────────────────────────── */
.spinner-overlay {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
@@ -194,7 +220,7 @@ body {
justify-content: center;
}
/* Log viewer */
/* ── Log viewer (dark theme — keep as-is) ────────── */
.log-viewer {
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
font-size: 0.8125rem;
@@ -210,107 +236,74 @@ body {
border-radius: 0 0 0.5rem 0.5rem;
}
/* Clickable table rows */
/* ── Clickable table rows ────────────────────────── */
.table-hover tbody tr[data-href] {
cursor: pointer;
}
.table-hover tbody tr[data-href]:hover {
background-color: #e2e8f0;
background-color: #f9fafb;
}
/* Sortable table headers (R7) */
/* ── Sortable table headers ──────────────────────── */
.sortable {
cursor: pointer;
user-select: none;
}
.sortable:hover {
background-color: #f1f5f9;
background-color: #f3f4f6;
}
.sort-icon {
font-size: 0.75rem;
margin-left: 0.25rem;
color: #3b82f6;
color: var(--blue-600);
}
/* SKU group visual grouping (R6) */
.sku-group-even {
/* default background */
}
/* ── SKU group visual grouping ───────────────────── */
.sku-group-odd {
background-color: #f8fafc;
}
/* Editable cells */
.editable {
cursor: pointer;
}
.editable:hover {
background-color: #e2e8f0;
}
/* ── Editable cells ──────────────────────────────── */
.editable { cursor: pointer; }
.editable:hover { background-color: #f3f4f6; }
/* Order detail modal items */
/* ── Order detail modal ──────────────────────────── */
.modal-lg .table-sm td,
.modal-lg .table-sm th {
font-size: 0.8125rem;
padding: 0.35rem 0.5rem;
}
/* Filter button badges */
#orderFilterBtns .badge {
font-size: 0.7rem;
}
/* Modal stacking for quickMap over orderDetail */
#quickMapModal {
z-index: 1060;
}
/* ── Modal stacking (quickMap over orderDetail) ───── */
#quickMapModal { z-index: 1060; }
#quickMapModal + .modal-backdrop,
.modal-backdrop ~ .modal-backdrop {
z-index: 1055;
}
.modal-backdrop ~ .modal-backdrop { z-index: 1055; }
/* Deleted mapping rows */
/* ── Deleted mapping rows ────────────────────────── */
tr.mapping-deleted td {
text-decoration: line-through;
opacity: 0.5;
}
/* Map icon button (minimal, no border) */
/* ── Map icon button ─────────────────────────────── */
.btn-map-icon {
color: #3b82f6;
color: var(--blue-600);
padding: 0.1rem 0.25rem;
cursor: pointer;
font-size: 1rem;
text-decoration: none;
}
.btn-map-icon:hover {
color: #1d4ed8;
}
.btn-map-icon:hover { color: var(--blue-700); }
/* Last sync summary card columns */
/* ── Last sync summary card columns ─────────────── */
.last-sync-col {
border-right: 1px solid #e2e8f0;
border-right: 1px solid var(--border-color);
}
/* Dashboard filter badges */
#dashFilterBtns .badge {
font-size: 0.7rem;
}
/* ── Cursor pointer utility ──────────────────────── */
.cursor-pointer { cursor: pointer; }
/* Cursor pointer utility */
.cursor-pointer {
cursor: pointer;
}
/* ── Typography scale ────────────────────────────── */
.text-header { font-size: 1.25rem; font-weight: 600; }
.text-card-head { font-size: 1rem; font-weight: 600; }
.text-body { font-size: 0.8125rem; }
.text-badge { font-size: 0.75rem; }
.text-label { font-size: 0.6875rem; }
/* ── Filter bar — shared across dashboard, mappings, missing_skus pages ── */
/* ── Filter bar ──────────────────────────────────── */
.filter-bar {
display: flex;
align-items: center;
@@ -318,49 +311,84 @@ tr.mapping-deleted td {
flex-wrap: wrap;
padding: 0.625rem 0;
}
.filter-pill {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.25rem 0.625rem;
padding: 0.375rem 0.75rem;
border: 1px solid #d1d5db;
border-radius: 999px;
border-radius: 0.375rem;
background: #fff;
font-size: 0.8125rem;
font-size: 0.875rem;
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
white-space: nowrap;
}
.filter-pill:hover { background: #f3f4f6; }
.filter-pill.active {
background: #1d4ed8;
border-color: #1d4ed8;
background: var(--blue-700);
border-color: var(--blue-700);
color: #fff;
}
.filter-pill.active .filter-count { background: rgba(255,255,255,0.25); color: #fff; }
.filter-pill.active .filter-count {
background: rgba(255,255,255,0.25);
color: #fff;
}
.filter-count {
display: inline-block;
min-width: 1.25rem;
padding: 0 0.3rem;
border-radius: 999px;
background: #e5e7eb;
font-size: 0.7rem;
font-size: 0.75rem;
font-weight: 600;
text-align: center;
line-height: 1.4;
}
/* ── Search input (used in filter bars) ─────────── */
/* ── Search input ────────────────────────────────── */
.search-input {
margin-left: auto;
padding: 0.25rem 0.625rem;
padding: 0.375rem 0.75rem;
border: 1px solid #d1d5db;
border-radius: 6px;
font-size: 0.8125rem;
border-radius: 0.375rem;
font-size: 0.875rem;
outline: none;
min-width: 180px;
}
.search-input:focus { border-color: #1d4ed8; }
.search-input:focus { border-color: var(--blue-600); }
/* ── Autocomplete dropdown (keep as-is) ──────────── */
.autocomplete-dropdown {
position: absolute;
z-index: 1050;
background: #fff;
border: 1px solid #dee2e6;
border-radius: 0.375rem;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
max-height: 300px;
overflow-y: auto;
width: 100%;
}
.autocomplete-item {
padding: 0.5rem 0.75rem;
cursor: pointer;
font-size: 0.875rem;
border-bottom: 1px solid #f1f5f9;
}
.autocomplete-item:hover, .autocomplete-item.active {
background-color: #f1f5f9;
}
.autocomplete-item .codmat {
font-weight: 600;
color: #1e293b;
}
.autocomplete-item .denumire {
color: #64748b;
font-size: 0.8rem;
}
/* ── Tooltip for Client/Cont ─────────────────────── */
.tooltip-cont {
@@ -389,8 +417,8 @@ tr.mapping-deleted td {
/* ── Sync card ───────────────────────────────────── */
.sync-card {
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 8px;
border: 1px solid var(--border-color);
border-radius: var(--card-radius);
overflow: hidden;
margin-bottom: 1rem;
}
@@ -403,7 +431,7 @@ tr.mapping-deleted td {
}
.sync-card-divider {
height: 1px;
background: #e5e7eb;
background: var(--border-color);
margin: 0;
}
.sync-card-info {
@@ -411,8 +439,8 @@ tr.mapping-deleted td {
align-items: center;
gap: 1rem;
padding: 0.5rem 1rem;
font-size: 0.8125rem;
color: #6b7280;
font-size: 0.875rem;
color: var(--text-muted);
cursor: pointer;
transition: background 0.12s;
}
@@ -423,12 +451,12 @@ tr.mapping-deleted td {
gap: 0.5rem;
padding: 0.4rem 1rem;
background: #eff6ff;
font-size: 0.8125rem;
color: #1d4ed8;
font-size: 0.875rem;
color: var(--blue-700);
border-top: 1px solid #dbeafe;
}
/* ── Pulsing live dot ────────────────────────────── */
/* ── Pulsing live dot (keep as-is) ──────────────── */
.sync-live-dot {
display: inline-block;
width: 8px;
@@ -443,7 +471,7 @@ tr.mapping-deleted td {
50% { opacity: 0.4; transform: scale(0.75); }
}
/* ── Status dot (idle/running/completed/failed) ──── */
/* ── Status dot (keep as-is) ─────────────────────── */
.sync-status-dot {
display: inline-block;
width: 10px;
@@ -461,32 +489,50 @@ tr.mapping-deleted td {
display: none;
gap: 0.375rem;
align-items: center;
font-size: 0.8125rem;
font-size: 0.875rem;
}
.period-custom-range.visible { display: flex; }
/* ── Compact button ──────────────────────────────── */
.btn-compact {
padding: 0.3rem 0.75rem;
font-size: 0.8125rem;
}
/* ── Compact select ──────────────────────────────── */
/* ── select-compact (used in filter bars) ─────────── */
.select-compact {
padding: 0.25rem 0.5rem;
font-size: 0.8125rem;
padding: 0.375rem 0.5rem;
font-size: 0.875rem;
border: 1px solid #d1d5db;
border-radius: 6px;
border-radius: 0.375rem;
background: #fff;
cursor: pointer;
}
/* ── btn-compact (kept for backward compat) ──────── */
.btn-compact {
padding: 0.375rem 0.75rem;
font-size: 0.875rem;
}
/* ── Result banner ───────────────────────────────── */
.result-banner {
padding: 0.4rem 0.75rem;
border-radius: 6px;
font-size: 0.8125rem;
border-radius: 0.375rem;
font-size: 0.875rem;
background: #d1fae5;
color: #065f46;
border: 1px solid #6ee7b7;
}
/* ── Badge-pct (mappings page) ───────────────────── */
.badge-pct {
font-size: 0.7rem;
padding: 0.1rem 0.35rem;
border-radius: 4px;
font-weight: 600;
}
.badge-pct.complete { background: #d1fae5; color: #065f46; }
.badge-pct.incomplete { background: #fef3c7; color: #92400e; }
/* ── Responsive ──────────────────────────────────── */
@media (max-width: 767.98px) {
.sidebar { transform: translateX(-100%); }
.sidebar.show { transform: translateX(0); }
.main-content { margin-left: 0; }
.sidebar-toggle { display: block !important; }
}

View File

@@ -279,7 +279,7 @@ async function loadDashOrders() {
const c = data.counts || {};
const el = (id) => document.getElementById(id);
if (el('cntAll')) el('cntAll').textContent = c.total || 0;
if (el('cntImp')) el('cntImp').textContent = c.imported || 0;
if (el('cntImp')) el('cntImp').textContent = c.imported_all || c.imported || 0;
if (el('cntSkip')) el('cntSkip').textContent = c.skipped || 0;
if (el('cntErr')) el('cntErr').textContent = c.error || c.errors || 0;
if (el('cntNef')) el('cntNef').textContent = c.uninvoiced || c.nefacturate || 0;
@@ -297,7 +297,7 @@ async function loadDashOrders() {
// Invoice info
let invoiceBadge = '';
let invoiceTotal = '';
if (o.status !== 'IMPORTED') {
if (o.status !== 'IMPORTED' && o.status !== 'ALREADY_IMPORTED') {
invoiceBadge = '<span class="text-muted">-</span>';
} else if (o.invoice && o.invoice.facturat) {
invoiceBadge = `<span class="badge bg-success">Facturat</span>`;
@@ -412,7 +412,7 @@ function orderStatusBadge(status) {
switch ((status || '').toUpperCase()) {
case 'IMPORTED': return '<span class="badge bg-success">Importat</span>';
case 'ALREADY_IMPORTED': return '<span class="badge bg-info">Deja importat</span>';
case 'SKIPPED': return '<span class="badge bg-warning text-dark">Omis</span>';
case 'SKIPPED': return '<span class="badge bg-warning">Omis</span>';
case 'ERROR': return '<span class="badge bg-danger">Eroare</span>';
default: return `<span class="badge bg-secondary">${esc(status)}</span>`;
}
@@ -484,7 +484,7 @@ async function openDashOrderDetail(orderNumber) {
switch (item.mapping_status) {
case 'mapped': statusBadge = '<span class="badge bg-success">Mapat</span>'; break;
case 'direct': statusBadge = '<span class="badge bg-info">Direct</span>'; break;
case 'missing': statusBadge = '<span class="badge bg-warning text-dark">Lipsa</span>'; break;
case 'missing': statusBadge = '<span class="badge bg-warning">Lipsa</span>'; break;
default: statusBadge = '<span class="badge bg-secondary">?</span>';
}

View File

@@ -52,7 +52,7 @@ function orderStatusBadge(status) {
switch ((status || '').toUpperCase()) {
case 'IMPORTED': return '<span class="badge bg-success">Importat</span>';
case 'ALREADY_IMPORTED': return '<span class="badge bg-info">Deja importat</span>';
case 'SKIPPED': return '<span class="badge bg-warning text-dark">Omis</span>';
case 'SKIPPED': return '<span class="badge bg-warning">Omis</span>';
case 'ERROR': return '<span class="badge bg-danger">Eroare</span>';
default: return `<span class="badge bg-secondary">${esc(status)}</span>`;
}
@@ -345,7 +345,7 @@ async function openOrderDetail(orderNumber) {
switch (item.mapping_status) {
case 'mapped': statusBadge = '<span class="badge bg-success">Mapat</span>'; break;
case 'direct': statusBadge = '<span class="badge bg-info">Direct</span>'; break;
case 'missing': statusBadge = '<span class="badge bg-warning text-dark">Lipsa</span>'; break;
case 'missing': statusBadge = '<span class="badge bg-warning">Lipsa</span>'; break;
default: statusBadge = '<span class="badge bg-secondary">?</span>';
}

View File

@@ -145,7 +145,7 @@ function renderTable(mappings, showDeleted) {
if (m.is_complete) {
pctBadge = ` <span class="badge-pct complete" title="100% alocat">&#10003; 100%</span>`;
} else {
const pctVal = typeof m.pct_total === 'number' ? m.pct_total.toFixed(0) : m.pct_total;
const pctVal = typeof m.pct_total === 'number' ? m.pct_total.toFixed(2) : m.pct_total;
pctBadge = ` <span class="badge-pct incomplete" title="${pctVal}% alocat">&#9888; ${pctVal}%</span>`;
}
}
@@ -276,23 +276,20 @@ function addCodmatLine() {
const div = document.createElement('div');
div.className = 'border rounded p-2 mb-2 codmat-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 cl-codmat" placeholder="Cauta codmat sau denumire..." autocomplete="off" data-idx="${idx}">
<div class="autocomplete-dropdown d-none cl-ac-dropdown"></div>
<small class="text-muted cl-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 cl-cantitate" value="1" step="0.001" min="0.001">
<div class="row g-2 align-items-center">
<div class="col position-relative">
<input type="text" class="form-control form-control-sm cl-codmat" placeholder="Cauta CODMAT..." autocomplete="off" data-idx="${idx}">
<div class="autocomplete-dropdown d-none cl-ac-dropdown"></div>
<small class="text-muted cl-selected"></small>
</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 cl-procent" value="100" step="0.01" min="0" max="100">
<div class="col-auto" style="width:90px">
<input type="number" class="form-control form-control-sm cl-cantitate" value="1" step="0.001" min="0.001" placeholder="Cant." title="Cantitate ROA">
</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('.codmat-line').remove()"><i class="bi bi-x-lg"></i></button>` : ''}
<div class="col-auto" style="width:90px">
<input type="number" class="form-control form-control-sm cl-procent" value="100" step="0.01" min="0" max="100" placeholder="% Pret" title="Procent Pret">
</div>
<div class="col-auto">
${idx > 0 ? `<button type="button" class="btn btn-sm btn-outline-danger" onclick="this.closest('.codmat-line').remove()"><i class="bi bi-x-lg"></i></button>` : '<div style="width:31px"></div>'}
</div>
</div>
`;
@@ -672,9 +669,13 @@ async function importCsv() {
try {
const res = await fetch('/api/mappings/import-csv', { method: 'POST', body: formData });
const data = await res.json();
let html = `<div class="alert alert-success">Procesate: ${data.processed}</div>`;
let msg = `${data.processed} mapări importate`;
if (data.skipped_no_codmat > 0) {
msg += `, ${data.skipped_no_codmat} rânduri fără CODMAT omise`;
}
let html = `<div class="alert alert-success">${msg}</div>`;
if (data.errors && data.errors.length > 0) {
html += `<div class="alert alert-warning">Erori: <ul>${data.errors.map(e => `<li>${esc(e)}</li>`).join('')}</ul></div>`;
html += `<div class="alert alert-warning">Erori (${data.errors.length}): <ul>${data.errors.map(e => `<li>${esc(e)}</li>`).join('')}</ul></div>`;
}
document.getElementById('importResult').innerHTML = html;
loadMappings();

View File

@@ -10,28 +10,28 @@
<!-- TOP ROW: Status + Controls -->
<div class="sync-card-controls">
<span id="syncStatusDot" class="sync-status-dot idle"></span>
<span id="syncStatusText" style="font-size:0.8125rem;color:#374151;">Inactiv</span>
<div style="display:flex;align-items:center;gap:0.5rem;margin-left:auto;">
<label style="display:flex;align-items:center;gap:0.4rem;font-size:0.8125rem;color:#6b7280;">
<span id="syncStatusText" class="text-secondary">Inactiv</span>
<div class="d-flex align-items-center gap-2 ms-auto">
<label class="d-flex align-items-center gap-1 text-muted">
Auto:
<input type="checkbox" id="schedulerToggle" style="cursor:pointer;" onchange="toggleScheduler()">
<input type="checkbox" id="schedulerToggle" class="cursor-pointer" onchange="toggleScheduler()">
</label>
<select id="schedulerInterval" class="select-compact" onchange="updateSchedulerInterval()">
<option value="5">5 min</option>
<option value="10" selected>10 min</option>
<option value="30">30 min</option>
</select>
<button id="syncStartBtn" class="btn btn-primary btn-compact" onclick="startSync()">&#9654; Start Sync</button>
<button id="syncStartBtn" class="btn btn-sm btn-primary" onclick="startSync()">&#9654; Start Sync</button>
</div>
</div>
<div class="sync-card-divider"></div>
<!-- BOTTOM ROW: Last sync info (clickable → jurnal) -->
<div class="sync-card-info" id="lastSyncRow" role="button" tabindex="0" title="Ver jurnal sync">
<span id="lastSyncDate" style="font-weight:500;">&#8212;</span>
<span id="lastSyncDuration" style="color:#9ca3af;">&#8212;</span>
<span id="lastSyncDate" class="fw-medium">&#8212;</span>
<span id="lastSyncDuration" class="text-muted">&#8212;</span>
<span id="lastSyncCounts">&#8212;</span>
<span id="lastSyncStatus">&#8212;</span>
<span style="margin-left:auto;font-size:0.75rem;color:#9ca3af;">&#8599; jurnal</span>
<span class="ms-auto small text-muted">&#8599; jurnal</span>
</div>
<!-- LIVE PROGRESS (shown only when sync is running) -->
<div class="sync-card-progress" id="syncProgressArea" style="display:none;">
@@ -64,7 +64,7 @@
</div>
<!-- Status pills -->
<button class="filter-pill active" data-status="all">Toate <span class="filter-count" id="cntAll">0</span></button>
<button class="filter-pill" data-status="IMPORTED">Imp. <span class="filter-count" id="cntImp">0</span></button>
<button class="filter-pill" data-status="IMPORTED">Importat <span class="filter-count" id="cntImp">0</span></button>
<button class="filter-pill" data-status="SKIPPED">Omise <span class="filter-count" id="cntSkip">0</span></button>
<button class="filter-pill" data-status="ERROR">Erori <span class="filter-count" id="cntErr">0</span></button>
<button class="filter-pill" data-status="UNINVOICED">Nefact. <span class="filter-count" id="cntNef">0</span></button>
@@ -73,11 +73,11 @@
</div>
</div>
<!-- Pagination top bar -->
<div class="card-body py-1 px-3 border-bottom d-flex justify-content-between align-items-center" style="gap:0.5rem;">
<div class="card-body py-1 px-3 border-bottom d-flex justify-content-between align-items-center gap-2">
<small class="text-muted" id="dashPageInfoTop"></small>
<div style="display:flex;align-items:center;gap:0.5rem;">
<label style="font-size:0.8125rem;color:#6b7280;white-space:nowrap;">Per pagina:
<select id="perPageSelect" class="select-compact" style="margin-left:0.25rem;" onchange="dashChangePerPage(this.value)">
<div class="d-flex align-items-center gap-2">
<label class="text-muted text-nowrap">Per pagina:
<select id="perPageSelect" class="select-compact ms-1" onchange="dashChangePerPage(this.value)">
<option value="25">25</option>
<option value="50" selected>50</option>
<option value="100">100</option>

View File

@@ -3,11 +3,6 @@
{% block nav_mappings %}active{% endblock %}
{% block content %}
<style>
.badge-pct { font-size: 0.7rem; padding: 0.1rem 0.35rem; border-radius: 4px; font-weight: 600; }
.badge-pct.complete { background: #d1fae5; color: #065f46; }
.badge-pct.incomplete { background: #fef3c7; color: #92400e; }
</style>
<div class="d-flex justify-content-between align-items-center mb-4">
<h4 class="mb-0">Mapari SKU</h4>
<div>

View File

@@ -24,8 +24,8 @@
Toate <span class="filter-count" id="cntAllSkus">0</span>
</button>
<input type="search" id="skuSearch" placeholder="Cauta SKU / produs..." class="search-input">
<button id="rescanBtn" class="btn btn-secondary btn-compact" style="margin-left:0.5rem;">&#8635; Re-scan</button>
<span id="rescanProgress" style="display:none;align-items:center;gap:0.4rem;font-size:0.8125rem;color:#1d4ed8;">
<button id="rescanBtn" class="btn btn-sm btn-secondary ms-2">&#8635; Re-scan</button>
<span id="rescanProgress" class="align-items-center gap-2 text-primary" style="display:none;">
<span class="sync-live-dot"></span>
<span id="rescanProgressText">Scanare...</span>
</span>
@@ -199,7 +199,7 @@ function renderMissingSkusTable(skus, data) {
tbody.innerHTML = skus.map(s => {
const statusBadge = s.resolved
? '<span class="badge bg-success">Rezolvat</span>'
: '<span class="badge bg-warning text-dark">Nerezolvat</span>';
: '<span class="badge bg-warning">Nerezolvat</span>';
let firstCustomer = '-';
try {
@@ -264,19 +264,6 @@ function openMapModal(sku, productName) {
container.innerHTML = '';
addMapCodmatLine();
// Pre-search with product name
if (productName) {
setTimeout(() => {
const input = container.querySelector('.mc-codmat');
if (input) {
input.value = productName;
mcAutocomplete(input,
container.querySelector('.mc-ac-dropdown'),
container.querySelector('.mc-selected'));
}
}, 100);
}
new bootstrap.Modal(document.getElementById('mapModal')).show();
}
@@ -286,23 +273,20 @@ function addMapCodmatLine() {
const div = document.createElement('div');
div.className = 'border rounded p-2 mb-2 mc-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 mc-codmat" placeholder="Cauta codmat sau denumire..." autocomplete="off">
<div class="autocomplete-dropdown d-none mc-ac-dropdown"></div>
<small class="text-muted mc-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 mc-cantitate" value="1" step="0.001" min="0.001">
<div class="row g-2 align-items-center">
<div class="col position-relative">
<input type="text" class="form-control form-control-sm mc-codmat" placeholder="Cauta CODMAT..." autocomplete="off">
<div class="autocomplete-dropdown d-none mc-ac-dropdown"></div>
<small class="text-muted mc-selected"></small>
</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 mc-procent" value="100" step="0.01" min="0" max="100">
<div class="col-auto" style="width:90px">
<input type="number" class="form-control form-control-sm mc-cantitate" value="1" step="0.001" min="0.001" placeholder="Cant." title="Cantitate ROA">
</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('.mc-line').remove()"><i class="bi bi-x"></i></button>` : ''}
<div class="col-auto" style="width:90px">
<input type="number" class="form-control form-control-sm mc-procent" value="100" step="0.01" min="0" max="100" placeholder="% Pret" title="Procent Pret">
</div>
<div class="col-auto">
${idx > 0 ? `<button type="button" class="btn btn-sm btn-outline-danger" onclick="this.closest('.mc-line').remove()"><i class="bi bi-x"></i></button>` : '<div style="width:31px"></div>'}
</div>
</div>
`;