feat(dashboard): redesign UI with smart polling, unified sync card, filter bar

Replace SSE with smart polling (30s idle / 3s when running). Unify sync
panel into single two-row card with live progress text. Add unified filter
bar (period dropdown, status pills, search) with period-total counts.
Add Client/Cont tooltip for different shipping/billing persons. Add SKU
mappings pct_total badges + complete/incomplete filter + 409 duplicate
check. Add missing SKUs search + rescan progress UX. Migrate SQLite
orders schema (shipping_name, billing_name, payment_method,
delivery_method). Fix JSON_OUTPUT_DIR path for server running from
project root. Fix pagination controls showing top+bottom with per-page
selector (25/50/100/250).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-13 17:55:36 +02:00
parent 82196b9dc0
commit 5f8b9b6003
14 changed files with 1235 additions and 648 deletions

View File

@@ -91,7 +91,11 @@ CREATE TABLE IF NOT EXISTS orders (
times_skipped INTEGER DEFAULT 0, times_skipped INTEGER DEFAULT 0,
first_seen_at TEXT DEFAULT (datetime('now')), first_seen_at TEXT DEFAULT (datetime('now')),
last_sync_run_id TEXT REFERENCES sync_runs(run_id), last_sync_run_id TEXT REFERENCES sync_runs(run_id),
updated_at TEXT DEFAULT (datetime('now')) updated_at TEXT DEFAULT (datetime('now')),
shipping_name TEXT,
billing_name TEXT,
payment_method TEXT,
delivery_method TEXT
); );
CREATE INDEX IF NOT EXISTS idx_orders_status ON orders(status); CREATE INDEX IF NOT EXISTS idx_orders_status ON orders(status);
CREATE INDEX IF NOT EXISTS idx_orders_date ON orders(order_date); CREATE INDEX IF NOT EXISTS idx_orders_date ON orders(order_date);
@@ -195,18 +199,15 @@ def init_sqlite():
); );
""") """)
# Copy latest record per order_number into orders # Copy latest record per order_number into orders
# Note: old import_orders didn't have address columns — those stay NULL
conn.execute(""" conn.execute("""
INSERT INTO orders INSERT INTO orders
(order_number, order_date, customer_name, status, (order_number, order_date, customer_name, status,
id_comanda, id_partener, id_adresa_facturare, id_adresa_livrare, id_comanda, id_partener, error_message, missing_skus,
error_message, missing_skus, items_count, last_sync_run_id) items_count, last_sync_run_id)
SELECT io.order_number, io.order_date, io.customer_name, io.status, SELECT io.order_number, io.order_date, io.customer_name, io.status,
io.id_comanda, io.id_partener, io.id_comanda, io.id_partener, io.error_message, io.missing_skus,
CASE WHEN io.order_number IN (SELECT order_number FROM import_orders WHERE id_adresa_facturare IS NOT NULL) THEN io.items_count, io.sync_run_id
(SELECT id_adresa_facturare FROM import_orders WHERE order_number = io.order_number AND id_adresa_facturare IS NOT NULL LIMIT 1) ELSE NULL END,
CASE WHEN io.order_number IN (SELECT order_number FROM import_orders WHERE id_adresa_livrare IS NOT NULL) THEN
(SELECT id_adresa_livrare FROM import_orders WHERE order_number = io.order_number AND id_adresa_livrare IS NOT NULL LIMIT 1) ELSE NULL END,
io.error_message, io.missing_skus, io.items_count, io.sync_run_id
FROM import_orders io FROM import_orders io
INNER JOIN ( INNER JOIN (
SELECT order_number, MAX(id) as max_id SELECT order_number, MAX(id) as max_id
@@ -272,6 +273,19 @@ def init_sqlite():
conn.execute("ALTER TABLE sync_runs ADD COLUMN error_message TEXT") conn.execute("ALTER TABLE sync_runs ADD COLUMN error_message TEXT")
logger.info("Migrated sync_runs: added column error_message") logger.info("Migrated sync_runs: added column error_message")
# Migrate orders: add shipping/billing/payment/delivery columns
cursor = conn.execute("PRAGMA table_info(orders)")
order_cols = {row[1] for row in cursor.fetchall()}
for col, typedef in [
("shipping_name", "TEXT"),
("billing_name", "TEXT"),
("payment_method", "TEXT"),
("delivery_method", "TEXT"),
]:
if col not in order_cols:
conn.execute(f"ALTER TABLE orders ADD COLUMN {col} {typedef}")
logger.info(f"Migrated orders: added column {col}")
conn.commit() conn.commit()
except Exception as e: except Exception as e:
logger.warning(f"Migration check failed: {e}") logger.warning(f"Migration check failed: {e}")

View File

@@ -1,6 +1,7 @@
from fastapi import APIRouter, Query, Request, UploadFile, File from fastapi import APIRouter, Query, Request, UploadFile, File
from fastapi.responses import StreamingResponse, HTMLResponse from fastapi.responses import StreamingResponse, HTMLResponse, JSONResponse
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from fastapi import HTTPException
from pydantic import BaseModel from pydantic import BaseModel
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
@@ -49,15 +50,19 @@ async def mappings_page(request: Request):
@router.get("/api/mappings") @router.get("/api/mappings")
async def list_mappings(search: str = "", page: int = 1, per_page: int = 50, async def list_mappings(search: str = "", page: int = 1, per_page: int = 50,
sort_by: str = "sku", sort_dir: str = "asc", sort_by: str = "sku", sort_dir: str = "asc",
show_deleted: bool = False): show_deleted: bool = False, pct_filter: str = None):
result = mapping_service.get_mappings(search=search, page=page, per_page=per_page, result = mapping_service.get_mappings(search=search, page=page, per_page=per_page,
sort_by=sort_by, sort_dir=sort_dir, sort_by=sort_by, sort_dir=sort_dir,
show_deleted=show_deleted) show_deleted=show_deleted,
pct_filter=pct_filter)
# Merge product names from web_products (R4) # Merge product names from web_products (R4)
skus = list({m["sku"] for m in result.get("mappings", [])}) skus = list({m["sku"] for m in result.get("mappings", [])})
product_names = await sqlite_service.get_web_products_batch(skus) product_names = await sqlite_service.get_web_products_batch(skus)
for m in result.get("mappings", []): for m in result.get("mappings", []):
m["product_name"] = product_names.get(m["sku"], "") m["product_name"] = product_names.get(m["sku"], "")
# Ensure counts key is always present
if "counts" not in result:
result["counts"] = {"total": 0, "complete": 0, "incomplete": 0}
return result return result
@router.post("/api/mappings") @router.post("/api/mappings")
@@ -67,6 +72,12 @@ async def create_mapping(data: MappingCreate):
# Mark SKU as resolved in missing_skus tracking # Mark SKU as resolved in missing_skus tracking
await sqlite_service.resolve_missing_sku(data.sku) await sqlite_service.resolve_missing_sku(data.sku)
return {"success": True, **result} return {"success": True, **result}
except HTTPException as e:
can_restore = e.headers.get("X-Can-Restore") == "true" if e.headers else False
resp: dict = {"error": e.detail}
if can_restore:
resp["can_restore"] = True
return JSONResponse(status_code=e.status_code, content=resp)
except Exception as e: except Exception as e:
return {"success": False, "error": str(e)} return {"success": False, "error": str(e)}

View File

@@ -5,7 +5,6 @@ from datetime import datetime
from fastapi import APIRouter, Request, BackgroundTasks from fastapi import APIRouter, Request, BackgroundTasks
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse
from starlette.responses import StreamingResponse
from pydantic import BaseModel from pydantic import BaseModel
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
@@ -21,35 +20,6 @@ class ScheduleConfig(BaseModel):
interval_minutes: int = 5 interval_minutes: int = 5
# SSE streaming endpoint
@router.get("/api/sync/stream")
async def sync_stream(request: Request):
"""SSE stream for real-time sync progress."""
q = sync_service.subscribe()
async def event_generator():
try:
while True:
# Check if client disconnected
if await request.is_disconnected():
break
try:
event = await asyncio.wait_for(q.get(), timeout=15.0)
yield f"data: {json.dumps(event)}\n\n"
if event.get("type") in ("completed", "failed"):
break
except asyncio.TimeoutError:
yield f"data: {json.dumps({'type': 'keepalive'})}\n\n"
finally:
sync_service.unsubscribe(q)
return StreamingResponse(
event_generator(),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}
)
# API endpoints # API endpoints
@router.post("/api/sync/start") @router.post("/api/sync/start")
async def start_sync(background_tasks: BackgroundTasks): async def start_sync(background_tasks: BackgroundTasks):
@@ -72,10 +42,68 @@ async def stop_sync():
@router.get("/api/sync/status") @router.get("/api/sync/status")
async def sync_status(): async def sync_status():
"""Get current sync status.""" """Get current sync status with progress details and last_run info."""
status = await sync_service.get_sync_status() status = await sync_service.get_sync_status()
stats = await sqlite_service.get_dashboard_stats()
return {**status, "stats": stats} # Build last_run from most recent completed/failed sync_runs row
current_run_id = status.get("run_id")
last_run = None
try:
from ..database import get_sqlite
db = await get_sqlite()
try:
if current_run_id:
cursor = await db.execute("""
SELECT * FROM sync_runs
WHERE status IN ('completed', 'failed') AND run_id != ?
ORDER BY started_at DESC LIMIT 1
""", (current_run_id,))
else:
cursor = await db.execute("""
SELECT * FROM sync_runs
WHERE status IN ('completed', 'failed')
ORDER BY started_at DESC LIMIT 1
""")
row = await cursor.fetchone()
if row:
row_dict = dict(row)
duration_seconds = None
if row_dict.get("started_at") and row_dict.get("finished_at"):
try:
dt_start = datetime.fromisoformat(row_dict["started_at"])
dt_end = datetime.fromisoformat(row_dict["finished_at"])
duration_seconds = int((dt_end - dt_start).total_seconds())
except (ValueError, TypeError):
pass
last_run = {
"run_id": row_dict.get("run_id"),
"started_at": row_dict.get("started_at"),
"finished_at": row_dict.get("finished_at"),
"duration_seconds": duration_seconds,
"status": row_dict.get("status"),
"imported": row_dict.get("imported", 0),
"skipped": row_dict.get("skipped", 0),
"errors": row_dict.get("errors", 0),
}
finally:
await db.close()
except Exception:
pass
# Ensure all expected keys are present
result = {
"status": status.get("status", "idle"),
"run_id": status.get("run_id"),
"started_at": status.get("started_at"),
"finished_at": status.get("finished_at"),
"phase": status.get("phase"),
"phase_text": status.get("phase_text"),
"progress_current": status.get("progress_current", 0),
"progress_total": status.get("progress_total", 0),
"counts": status.get("counts", {"imported": 0, "skipped": 0, "errors": 0}),
"last_run": last_run,
}
return result
@router.get("/api/sync/history") @router.get("/api/sync/history")
@@ -277,8 +305,13 @@ async def order_detail(order_number: str):
async def dashboard_orders(page: int = 1, per_page: int = 50, async def dashboard_orders(page: int = 1, per_page: int = 50,
search: str = "", status: str = "all", search: str = "", status: str = "all",
sort_by: str = "order_date", sort_dir: str = "desc", sort_by: str = "order_date", sort_dir: str = "desc",
period_days: int = 7): period_days: int = 7,
"""Get orders for dashboard, enriched with invoice data. period_days=0 means all time.""" period_start: str = "", period_end: str = ""):
"""Get orders for dashboard, enriched with invoice data.
period_days=0 with period_start/period_end uses custom date range.
period_days=0 without dates means all time.
"""
is_uninvoiced_filter = (status == "UNINVOICED") is_uninvoiced_filter = (status == "UNINVOICED")
# For UNINVOICED: fetch all IMPORTED orders, then filter post-invoice-check # For UNINVOICED: fetch all IMPORTED orders, then filter post-invoice-check
@@ -289,7 +322,9 @@ async def dashboard_orders(page: int = 1, per_page: int = 50,
result = await sqlite_service.get_orders( result = await sqlite_service.get_orders(
page=fetch_page, per_page=fetch_per_page, search=search, page=fetch_page, per_page=fetch_per_page, search=search,
status_filter=fetch_status, sort_by=sort_by, sort_dir=sort_dir, status_filter=fetch_status, sort_by=sort_by, sort_dir=sort_dir,
period_days=period_days period_days=period_days,
period_start=period_start if period_days == 0 else "",
period_end=period_end if period_days == 0 else "",
) )
# Enrich imported orders with invoice data from Oracle # Enrich imported orders with invoice data from Oracle
@@ -309,12 +344,22 @@ async def dashboard_orders(page: int = 1, per_page: int = 50,
else: else:
o["invoice"] = None o["invoice"] = None
# Count uninvoiced (IMPORTED without invoice) # Add shipping/billing name fields + is_different_person flag
uninvoiced_count = sum( s_name = o.get("shipping_name") or ""
b_name = o.get("billing_name") or ""
o["shipping_name"] = s_name
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 1 for o in all_orders
if o.get("status") == "IMPORTED" and not o.get("invoice") if o.get("status") == "IMPORTED" and not o.get("invoice")
) )
result["counts"]["uninvoiced"] = uninvoiced_count # Use counts from sqlite_service (already period-scoped) and add nefacturate
counts = result.get("counts", {})
counts["nefacturate"] = nefacturate_count
counts.setdefault("total", counts.get("imported", 0) + counts.get("skipped", 0) + counts.get("error", 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:
@@ -327,7 +372,16 @@ async def dashboard_orders(page: int = 1, per_page: int = 50,
result["per_page"] = per_page result["per_page"] = per_page
result["pages"] = (total + per_page - 1) // per_page if total > 0 else 0 result["pages"] = (total + per_page - 1) // per_page if total > 0 else 0
return result # Reshape response
return {
"orders": result["orders"],
"pagination": {
"page": result.get("page", page),
"per_page": result.get("per_page", per_page),
"total_pages": result.get("pages", 0),
},
"counts": counts,
}
@router.put("/api/sync/schedule") @router.put("/api/sync/schedule")

View File

@@ -16,7 +16,10 @@ async def scan_and_validate():
orders, json_count = order_reader.read_json_orders() orders, json_count = order_reader.read_json_orders()
if not orders: if not orders:
return {"orders": 0, "json_files": json_count, "skus": {}, "message": "No orders found"} return {
"orders": 0, "json_files": json_count, "skus": {}, "message": "No orders found",
"total_skus_scanned": 0, "new_missing": 0, "auto_resolved": 0, "unchanged": 0,
}
all_skus = order_reader.get_all_skus(orders) all_skus = order_reader.get_all_skus(orders)
result = validation_service.validate_skus(all_skus) result = validation_service.validate_skus(all_skus)
@@ -37,6 +40,7 @@ async def scan_and_validate():
if customer not in sku_context[sku]["customers"]: if customer not in sku_context[sku]["customers"]:
sku_context[sku]["customers"].append(customer) sku_context[sku]["customers"].append(customer)
new_missing = 0
for sku in result["missing"]: for sku in result["missing"]:
# Find product name from orders # Find product name from orders
product_name = "" product_name = ""
@@ -49,13 +53,19 @@ async def scan_and_validate():
break break
ctx = sku_context.get(sku, {}) ctx = sku_context.get(sku, {})
await sqlite_service.track_missing_sku( tracked = await sqlite_service.track_missing_sku(
sku=sku, sku=sku,
product_name=product_name, product_name=product_name,
order_count=len(ctx.get("order_numbers", [])), order_count=len(ctx.get("order_numbers", [])),
order_numbers=json.dumps(ctx.get("order_numbers", [])), order_numbers=json.dumps(ctx.get("order_numbers", [])),
customers=json.dumps(ctx.get("customers", [])) customers=json.dumps(ctx.get("customers", []))
) )
if tracked:
new_missing += 1
total_skus_scanned = len(all_skus)
new_missing_count = len(result["missing"])
unchanged = total_skus_scanned - new_missing_count
return { return {
"json_files": json_count, "json_files": json_count,
@@ -64,6 +74,11 @@ async def scan_and_validate():
"importable": len(importable), "importable": len(importable),
"skipped": len(skipped), "skipped": len(skipped),
"new_orders": len(new_orders), "new_orders": len(new_orders),
# Fields consumed by the rescan progress banner in missing_skus.html
"total_skus_scanned": total_skus_scanned,
"new_missing": new_missing_count,
"auto_resolved": 0,
"unchanged": unchanged,
"skus": { "skus": {
"mapped": len(result["mapped"]), "mapped": len(result["mapped"]),
"direct": len(result["direct"]), "direct": len(result["direct"]),
@@ -88,20 +103,35 @@ async def scan_and_validate():
async def get_missing_skus( async def get_missing_skus(
page: int = Query(1, ge=1), page: int = Query(1, ge=1),
per_page: int = Query(20, ge=1, le=100), per_page: int = Query(20, ge=1, le=100),
resolved: int = Query(0, ge=-1, le=1) resolved: int = Query(0, ge=-1, le=1),
search: str = Query(None)
): ):
"""Get paginated missing SKUs. resolved=-1 means show all (R10).""" """Get paginated missing SKUs. resolved=-1 means show all (R10).
result = await sqlite_service.get_missing_skus_paginated(page, per_page, resolved) Optional search filters by sku or product_name."""
# Backward compat: also include 'unresolved' count
db = await get_sqlite() db = await get_sqlite()
try: try:
cursor = await db.execute( # Compute counts across ALL records (unfiltered by search)
"SELECT COUNT(*) FROM missing_skus WHERE resolved = 0" cursor = await db.execute("SELECT COUNT(*) FROM missing_skus WHERE resolved = 0")
) unresolved_count = (await cursor.fetchone())[0]
unresolved = (await cursor.fetchone())[0] cursor = await db.execute("SELECT COUNT(*) FROM missing_skus WHERE resolved = 1")
resolved_count = (await cursor.fetchone())[0]
cursor = await db.execute("SELECT COUNT(*) FROM missing_skus")
total_count = (await cursor.fetchone())[0]
finally: finally:
await db.close() await db.close()
result["unresolved"] = unresolved
counts = {
"total": total_count,
"unresolved": unresolved_count,
"resolved": resolved_count,
}
result = await sqlite_service.get_missing_skus_paginated(page, per_page, resolved, search=search)
# Backward compat
result["unresolved"] = unresolved_count
result["counts"] = counts
# rename key for JS consistency
result["skus"] = result.get("missing_skus", [])
return result return result
@router.get("/missing-skus-csv") @router.get("/missing-skus-csv")

View File

@@ -106,7 +106,7 @@ def import_single_order(order, id_pol: int = None, id_sectie: int = None) -> dic
raise RuntimeError("Oracle pool not initialized") raise RuntimeError("Oracle pool not initialized")
with database.pool.acquire() as conn: with database.pool.acquire() as conn:
with conn.cursor() as cur: with conn.cursor() as cur:
# Step 1: Process partner # Step 1: Process partner — use shipping person data for name
id_partener = cur.var(oracledb.DB_TYPE_NUMBER) id_partener = cur.var(oracledb.DB_TYPE_NUMBER)
if order.billing.is_company: if order.billing.is_company:
@@ -114,6 +114,12 @@ def import_single_order(order, id_pol: int = None, id_sectie: int = None) -> dic
cod_fiscal = clean_web_text(order.billing.company_code) or None cod_fiscal = clean_web_text(order.billing.company_code) or None
registru = clean_web_text(order.billing.company_reg) or None registru = clean_web_text(order.billing.company_reg) or None
is_pj = 1 is_pj = 1
else:
# Use shipping person for partner name (person on shipping label)
if order.shipping and (order.shipping.lastname or order.shipping.firstname):
denumire = clean_web_text(
f"{order.shipping.lastname} {order.shipping.firstname}"
).upper()
else: else:
denumire = clean_web_text( denumire = clean_web_text(
f"{order.billing.lastname} {order.billing.firstname}" f"{order.billing.lastname} {order.billing.firstname}"
@@ -133,7 +139,52 @@ def import_single_order(order, id_pol: int = None, id_sectie: int = None) -> dic
result["id_partener"] = int(partner_id) result["id_partener"] = int(partner_id)
# Step 2: Process billing address # Determine if billing and shipping are different persons
billing_name = clean_web_text(
f"{order.billing.lastname} {order.billing.firstname}"
).strip().upper()
shipping_name = ""
if order.shipping:
shipping_name = clean_web_text(
f"{order.shipping.lastname} {order.shipping.firstname}"
).strip().upper()
different_person = bool(
shipping_name and billing_name and shipping_name != billing_name
)
# Step 2: Process shipping address (primary — person on shipping label)
# Use shipping person phone/email for partner contact
shipping_phone = ""
shipping_email = ""
if order.shipping:
shipping_phone = order.shipping.phone or ""
shipping_email = order.shipping.email or ""
if not shipping_phone:
shipping_phone = order.billing.phone or ""
if not shipping_email:
shipping_email = order.billing.email or ""
addr_livr_id = None
if order.shipping:
id_adresa_livr = cur.var(oracledb.DB_TYPE_NUMBER)
shipping_addr = format_address_for_oracle(
order.shipping.address, order.shipping.city,
order.shipping.region
)
cur.callproc("PACK_IMPORT_PARTENERI.cauta_sau_creeaza_adresa", [
partner_id, shipping_addr,
shipping_phone,
shipping_email,
id_adresa_livr
])
addr_livr_id = id_adresa_livr.getvalue()
# Step 3: Process billing address
if different_person:
# Different person: use shipping address for BOTH billing and shipping in ROA
addr_fact_id = addr_livr_id
else:
# Same person: use billing address as-is
id_adresa_fact = cur.var(oracledb.DB_TYPE_NUMBER) id_adresa_fact = cur.var(oracledb.DB_TYPE_NUMBER)
billing_addr = format_address_for_oracle( billing_addr = format_address_for_oracle(
order.billing.address, order.billing.city, order.billing.region order.billing.address, order.billing.city, order.billing.region
@@ -146,22 +197,6 @@ def import_single_order(order, id_pol: int = None, id_sectie: int = None) -> dic
]) ])
addr_fact_id = id_adresa_fact.getvalue() addr_fact_id = id_adresa_fact.getvalue()
# Step 3: Process shipping address (if different)
addr_livr_id = None
if order.shipping:
id_adresa_livr = cur.var(oracledb.DB_TYPE_NUMBER)
shipping_addr = format_address_for_oracle(
order.shipping.address, order.shipping.city,
order.shipping.region
)
cur.callproc("PACK_IMPORT_PARTENERI.cauta_sau_creeaza_adresa", [
partner_id, shipping_addr,
order.shipping.phone or "",
order.shipping.email or "",
id_adresa_livr
])
addr_livr_id = id_adresa_livr.getvalue()
if addr_fact_id is not None: if addr_fact_id is not None:
result["id_adresa_facturare"] = int(addr_fact_id) result["id_adresa_facturare"] = int(addr_fact_id)
if addr_livr_id is not None: if addr_livr_id is not None:

View File

@@ -9,8 +9,14 @@ logger = logging.getLogger(__name__)
def get_mappings(search: str = "", page: int = 1, per_page: int = 50, def get_mappings(search: str = "", page: int = 1, per_page: int = 50,
sort_by: str = "sku", sort_dir: str = "asc", sort_by: str = "sku", sort_dir: str = "asc",
show_deleted: bool = False): show_deleted: bool = False, pct_filter: str = None):
"""Get paginated mappings with optional search and sorting.""" """Get paginated mappings with optional search, sorting, and pct_filter.
pct_filter values:
'complete' only SKU groups where sum(procent_pret for active rows) == 100
'incomplete' only SKU groups where sum < 100
None / 'all' no filter
"""
if database.pool is None: if database.pool is None:
raise HTTPException(status_code=503, detail="Oracle unavailable") raise HTTPException(status_code=503, detail="Oracle unavailable")
@@ -49,16 +55,7 @@ def get_mappings(search: str = "", page: int = 1, per_page: int = 50,
params["search"] = search params["search"] = search
where = "WHERE " + " AND ".join(where_clauses) if where_clauses else "" where = "WHERE " + " AND ".join(where_clauses) if where_clauses else ""
# Count total # Fetch ALL matching rows (no pagination yet — we need to group by SKU first)
count_sql = f"""
SELECT COUNT(*) FROM ARTICOLE_TERTI at
LEFT JOIN nom_articole na ON na.codmat = at.codmat
{where}
"""
cur.execute(count_sql, params)
total = cur.fetchone()[0]
# Get page
data_sql = f""" data_sql = f"""
SELECT at.sku, at.codmat, na.denumire, na.um, at.cantitate_roa, SELECT at.sku, at.codmat, na.denumire, na.um, at.cantitate_roa,
at.procent_pret, at.activ, at.sters, at.procent_pret, at.activ, at.sters,
@@ -67,30 +64,114 @@ def get_mappings(search: str = "", page: int = 1, per_page: int = 50,
LEFT JOIN nom_articole na ON na.codmat = at.codmat LEFT JOIN nom_articole na ON na.codmat = at.codmat
{where} {where}
ORDER BY {order_clause} ORDER BY {order_clause}
OFFSET :offset ROWS FETCH NEXT :per_page ROWS ONLY
""" """
params["offset"] = offset
params["per_page"] = per_page
cur.execute(data_sql, params) cur.execute(data_sql, params)
columns = [col[0].lower() for col in cur.description] columns = [col[0].lower() for col in cur.description]
rows = [dict(zip(columns, row)) for row in cur.fetchall()] all_rows = [dict(zip(columns, row)) for row in cur.fetchall()]
# Group by SKU and compute pct_total for each group
from collections import OrderedDict
groups = OrderedDict()
for row in all_rows:
sku = row["sku"]
if sku not in groups:
groups[sku] = []
groups[sku].append(row)
# Compute counts across ALL groups (before pct_filter)
total_skus = len(groups)
complete_skus = 0
incomplete_skus = 0
for sku, rows in groups.items():
pct_total = sum(
(r["procent_pret"] or 0)
for r in rows
if r.get("activ") == 1
)
if pct_total >= 99.99:
complete_skus += 1
else:
incomplete_skus += 1
counts = {
"total": total_skus,
"complete": complete_skus,
"incomplete": incomplete_skus,
}
# Apply pct_filter
if pct_filter in ("complete", "incomplete"):
filtered_groups = {}
for sku, rows in groups.items():
pct_total = sum(
(r["procent_pret"] or 0)
for r in rows
if r.get("activ") == 1
)
is_complete = pct_total >= 99.99
if pct_filter == "complete" and is_complete:
filtered_groups[sku] = rows
elif pct_filter == "incomplete" and not is_complete:
filtered_groups[sku] = rows
groups = filtered_groups
# Flatten back to rows for pagination (paginate by raw row count)
filtered_rows = [row for rows in groups.values() for row in rows]
total = len(filtered_rows)
page_rows = filtered_rows[offset: offset + per_page]
# Attach pct_total and is_complete to each row for the renderer
# Re-compute per visible group
sku_pct = {}
for sku, rows in groups.items():
pct_total = sum(
(r["procent_pret"] or 0)
for r in rows
if r.get("activ") == 1
)
sku_pct[sku] = {"pct_total": pct_total, "is_complete": pct_total >= 99.99}
for row in page_rows:
meta = sku_pct.get(row["sku"], {"pct_total": 0, "is_complete": False})
row["pct_total"] = meta["pct_total"]
row["is_complete"] = meta["is_complete"]
return { return {
"mappings": rows, "mappings": page_rows,
"total": total, "total": total,
"page": page, "page": page,
"per_page": per_page, "per_page": per_page,
"pages": (total + per_page - 1) // per_page "pages": (total + per_page - 1) // per_page if total > 0 else 0,
"counts": counts,
} }
def create_mapping(sku: str, codmat: str, cantitate_roa: float = 1, procent_pret: float = 100): def create_mapping(sku: str, codmat: str, cantitate_roa: float = 1, procent_pret: float = 100):
"""Create a new mapping.""" """Create a new mapping. Returns dict or raises HTTPException on duplicate."""
if database.pool is None: if database.pool is None:
raise HTTPException(status_code=503, detail="Oracle unavailable") raise HTTPException(status_code=503, detail="Oracle unavailable")
with database.pool.acquire() as conn: with database.pool.acquire() as conn:
with conn.cursor() as cur: with conn.cursor() as cur:
# Check for active duplicate
cur.execute("""
SELECT COUNT(*) FROM ARTICOLE_TERTI
WHERE sku = :sku AND codmat = :codmat AND NVL(sters, 0) = 0
""", {"sku": sku, "codmat": codmat})
if cur.fetchone()[0] > 0:
raise HTTPException(status_code=409, detail="Maparea SKU-CODMAT există deja")
# Check for soft-deleted record that could be restored
cur.execute("""
SELECT COUNT(*) FROM ARTICOLE_TERTI
WHERE sku = :sku AND codmat = :codmat AND sters = 1
""", {"sku": sku, "codmat": codmat})
if cur.fetchone()[0] > 0:
raise HTTPException(
status_code=409,
detail="Maparea a fost ștearsă anterior",
headers={"X-Can-Restore": "true"}
)
cur.execute(""" cur.execute("""
INSERT INTO ARTICOLE_TERTI (sku, codmat, cantitate_roa, procent_pret, activ, sters, data_creare, id_util_creare) INSERT INTO ARTICOLE_TERTI (sku, codmat, cantitate_roa, procent_pret, activ, sters, data_creare, id_util_creare)
VALUES (:sku, :codmat, :cantitate_roa, :procent_pret, 1, 0, SYSDATE, -3) VALUES (:sku, :codmat, :cantitate_roa, :procent_pret, 1, 0, SYSDATE, -3)

View File

@@ -44,7 +44,9 @@ async def update_sync_run(run_id: str, status: str, total_orders: int = 0,
async def upsert_order(sync_run_id: str, order_number: str, order_date: str, async def upsert_order(sync_run_id: str, order_number: str, order_date: str,
customer_name: str, status: str, id_comanda: int = None, customer_name: str, status: str, id_comanda: int = None,
id_partener: int = None, error_message: str = None, id_partener: int = None, error_message: str = None,
missing_skus: list = None, items_count: int = 0): missing_skus: list = None, items_count: int = 0,
shipping_name: str = None, billing_name: str = None,
payment_method: str = None, delivery_method: str = None):
"""Upsert a single order — one row per order_number, status updated in place.""" """Upsert a single order — one row per order_number, status updated in place."""
db = await get_sqlite() db = await get_sqlite()
try: try:
@@ -52,8 +54,9 @@ async def upsert_order(sync_run_id: str, order_number: str, order_date: str,
INSERT INTO orders INSERT INTO orders
(order_number, order_date, customer_name, status, (order_number, order_date, customer_name, status,
id_comanda, id_partener, error_message, missing_skus, items_count, id_comanda, id_partener, error_message, missing_skus, items_count,
last_sync_run_id) last_sync_run_id, shipping_name, billing_name,
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) payment_method, delivery_method)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(order_number) DO UPDATE SET ON CONFLICT(order_number) DO UPDATE SET
status = excluded.status, status = excluded.status,
error_message = excluded.error_message, error_message = excluded.error_message,
@@ -65,11 +68,16 @@ async def upsert_order(sync_run_id: str, order_number: str, order_date: str,
THEN orders.times_skipped + 1 THEN orders.times_skipped + 1
ELSE orders.times_skipped END, ELSE orders.times_skipped END,
last_sync_run_id = excluded.last_sync_run_id, last_sync_run_id = excluded.last_sync_run_id,
shipping_name = COALESCE(excluded.shipping_name, orders.shipping_name),
billing_name = COALESCE(excluded.billing_name, orders.billing_name),
payment_method = COALESCE(excluded.payment_method, orders.payment_method),
delivery_method = COALESCE(excluded.delivery_method, orders.delivery_method),
updated_at = datetime('now') updated_at = datetime('now')
""", (order_number, order_date, customer_name, status, """, (order_number, order_date, customer_name, status,
id_comanda, id_partener, error_message, id_comanda, id_partener, error_message,
json.dumps(missing_skus) if missing_skus else None, json.dumps(missing_skus) if missing_skus else None,
items_count, sync_run_id)) items_count, sync_run_id, shipping_name, billing_name,
payment_method, delivery_method))
await db.commit() await db.commit()
finally: finally:
await db.close() await db.close()
@@ -124,35 +132,52 @@ async def resolve_missing_sku(sku: str):
await db.close() await db.close()
async def get_missing_skus_paginated(page: int = 1, per_page: int = 20, resolved: int = 0): async def get_missing_skus_paginated(page: int = 1, per_page: int = 20,
"""Get paginated missing SKUs. resolved=-1 means show all.""" resolved: int = 0, search: str = None):
"""Get paginated missing SKUs. resolved=-1 means show all.
Optional search filters by sku or product_name (LIKE)."""
db = await get_sqlite() db = await get_sqlite()
try: try:
offset = (page - 1) * per_page offset = (page - 1) * per_page
if resolved == -1: # Build WHERE clause parts
cursor = await db.execute("SELECT COUNT(*) FROM missing_skus") where_parts = []
total = (await cursor.fetchone())[0] params_count = []
cursor = await db.execute(""" params_data = []
SELECT sku, product_name, first_seen, resolved, resolved_at,
order_count, order_numbers, customers if resolved != -1:
FROM missing_skus where_parts.append("resolved = ?")
ORDER BY resolved ASC, order_count DESC, first_seen DESC params_count.append(resolved)
LIMIT ? OFFSET ? params_data.append(resolved)
""", (per_page, offset))
else: if search:
like = f"%{search}%"
where_parts.append("(LOWER(sku) LIKE LOWER(?) OR LOWER(COALESCE(product_name,'')) LIKE LOWER(?))")
params_count.extend([like, like])
params_data.extend([like, like])
where_clause = ("WHERE " + " AND ".join(where_parts)) if where_parts else ""
order_clause = (
"ORDER BY resolved ASC, order_count DESC, first_seen DESC"
if resolved == -1
else "ORDER BY order_count DESC, first_seen DESC"
)
cursor = await db.execute( cursor = await db.execute(
"SELECT COUNT(*) FROM missing_skus WHERE resolved = ?", (resolved,) f"SELECT COUNT(*) FROM missing_skus {where_clause}",
params_count
) )
total = (await cursor.fetchone())[0] total = (await cursor.fetchone())[0]
cursor = await db.execute("""
cursor = await db.execute(f"""
SELECT sku, product_name, first_seen, resolved, resolved_at, SELECT sku, product_name, first_seen, resolved, resolved_at,
order_count, order_numbers, customers order_count, order_numbers, customers
FROM missing_skus FROM missing_skus
WHERE resolved = ? {where_clause}
ORDER BY order_count DESC, first_seen DESC {order_clause}
LIMIT ? OFFSET ? LIMIT ? OFFSET ?
""", (resolved, per_page, offset)) """, params_data + [per_page, offset])
rows = await cursor.fetchall() rows = await cursor.fetchall()
@@ -474,8 +499,13 @@ async def get_run_orders_filtered(run_id: str, status_filter: str = "all",
async def get_orders(page: int = 1, per_page: int = 50, async def get_orders(page: int = 1, per_page: int = 50,
search: str = "", status_filter: str = "all", search: str = "", status_filter: str = "all",
sort_by: str = "order_date", sort_dir: str = "desc", sort_by: str = "order_date", sort_dir: str = "desc",
period_days: int = 7): period_days: int = 7,
"""Get orders with filters, sorting, and period. period_days=0 means all time.""" period_start: str = "", period_end: str = ""):
"""Get orders with filters, sorting, and period.
period_days=0 with period_start/period_end uses custom date range.
period_days=0 without dates means all time.
"""
db = await get_sqlite() db = await get_sqlite()
try: try:
where_clauses = [] where_clauses = []
@@ -484,6 +514,9 @@ async def get_orders(page: int = 1, per_page: int = 50,
if period_days and period_days > 0: if period_days and period_days > 0:
where_clauses.append("order_date >= date('now', ?)") where_clauses.append("order_date >= date('now', ?)")
params.append(f"-{period_days} days") 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])
if search: if search:
where_clauses.append("(order_number LIKE ? OR customer_name LIKE ?)") where_clauses.append("(order_number LIKE ? OR customer_name LIKE ?)")

View File

@@ -13,28 +13,10 @@ logger = logging.getLogger(__name__)
_sync_lock = asyncio.Lock() _sync_lock = asyncio.Lock()
_current_sync = None # dict with run_id, status, progress info _current_sync = None # dict with run_id, status, progress info
# SSE subscriber system
_subscribers: list[asyncio.Queue] = []
# In-memory text log buffer per run # In-memory text log buffer per run
_run_logs: dict[str, list[str]] = {} _run_logs: dict[str, list[str]] = {}
def subscribe() -> asyncio.Queue:
"""Subscribe to sync events. Returns a queue that will receive event dicts."""
q = asyncio.Queue()
_subscribers.append(q)
return q
def unsubscribe(q: asyncio.Queue):
"""Unsubscribe from sync events."""
try:
_subscribers.remove(q)
except ValueError:
pass
def _log_line(run_id: str, message: str): def _log_line(run_id: str, message: str):
"""Append a timestamped line to the in-memory log buffer.""" """Append a timestamped line to the in-memory log buffer."""
if run_id not in _run_logs: if run_id not in _run_logs:
@@ -51,13 +33,17 @@ def get_run_text_log(run_id: str) -> str | None:
return "\n".join(lines) return "\n".join(lines)
async def _emit(event: dict): def _update_progress(phase: str, phase_text: str, current: int = 0, total: int = 0,
"""Push an event to all subscriber queues.""" counts: dict = None):
for q in _subscribers: """Update _current_sync with progress details for polling."""
try: global _current_sync
q.put_nowait(event) if _current_sync is None:
except asyncio.QueueFull: return
pass _current_sync["phase"] = phase
_current_sync["phase_text"] = phase_text
_current_sync["progress_current"] = current
_current_sync["progress_total"] = total
_current_sync["counts"] = counts or {"imported": 0, "skipped": 0, "errors": 0}
async def get_sync_status(): async def get_sync_status():
@@ -80,7 +66,12 @@ async def prepare_sync(id_pol: int = None, id_sectie: int = None) -> dict:
"run_id": run_id, "run_id": run_id,
"status": "running", "status": "running",
"started_at": datetime.now().isoformat(), "started_at": datetime.now().isoformat(),
"progress": "Starting..." "finished_at": None,
"phase": "starting",
"phase_text": "Starting...",
"progress_current": 0,
"progress_total": 0,
"counts": {"imported": 0, "skipped": 0, "errors": 0},
} }
return {"run_id": run_id, "status": "starting"} return {"run_id": run_id, "status": "starting"}
@@ -100,11 +91,15 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None
"run_id": run_id, "run_id": run_id,
"status": "running", "status": "running",
"started_at": datetime.now().isoformat(), "started_at": datetime.now().isoformat(),
"progress": "Reading JSON files..." "finished_at": None,
"phase": "reading",
"phase_text": "Reading JSON files...",
"progress_current": 0,
"progress_total": 0,
"counts": {"imported": 0, "skipped": 0, "errors": 0},
} }
_current_sync["progress"] = "Reading JSON files..." _update_progress("reading", "Reading JSON files...")
await _emit({"type": "phase", "run_id": run_id, "message": "Reading JSON files..."})
started_dt = datetime.now() started_dt = datetime.now()
_run_logs[run_id] = [ _run_logs[run_id] = [
@@ -119,7 +114,7 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None
orders, json_count = order_reader.read_json_orders() orders, json_count = order_reader.read_json_orders()
orders.sort(key=lambda o: o.date or '') orders.sort(key=lambda o: o.date or '')
await sqlite_service.create_sync_run(run_id, json_count) await sqlite_service.create_sync_run(run_id, json_count)
await _emit({"type": "phase", "run_id": run_id, "message": f"Found {len(orders)} orders in {json_count} files"}) _update_progress("reading", f"Found {len(orders)} orders in {json_count} files", 0, len(orders))
_log_line(run_id, f"Gasite {len(orders)} comenzi in {json_count} fisiere") _log_line(run_id, f"Gasite {len(orders)} comenzi in {json_count} fisiere")
# Populate web_products catalog from all orders (R4) # Populate web_products catalog from all orders (R4)
@@ -131,12 +126,11 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None
if not orders: if not orders:
_log_line(run_id, "Nicio comanda gasita.") _log_line(run_id, "Nicio comanda gasita.")
await sqlite_service.update_sync_run(run_id, "completed", 0, 0, 0, 0) await sqlite_service.update_sync_run(run_id, "completed", 0, 0, 0, 0)
_update_progress("completed", "No orders found")
summary = {"run_id": run_id, "status": "completed", "message": "No orders found", "json_files": json_count} summary = {"run_id": run_id, "status": "completed", "message": "No orders found", "json_files": json_count}
await _emit({"type": "completed", "run_id": run_id, "summary": summary})
return summary return summary
_current_sync["progress"] = f"Validating {len(orders)} orders..." _update_progress("validation", f"Validating {len(orders)} orders...", 0, len(orders))
await _emit({"type": "phase", "run_id": run_id, "message": f"Validating {len(orders)} orders..."})
# Step 2a: Find new orders (not yet in Oracle) # Step 2a: Find new orders (not yet in Oracle)
all_order_numbers = [o.number for o in orders] all_order_numbers = [o.number for o in orders]
@@ -149,7 +143,8 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None
validation = await asyncio.to_thread(validation_service.validate_skus, all_skus) validation = await asyncio.to_thread(validation_service.validate_skus, all_skus)
importable, skipped = validation_service.classify_orders(orders, validation) importable, skipped = validation_service.classify_orders(orders, validation)
await _emit({"type": "phase", "run_id": run_id, "message": f"{len(importable)} importable, {len(skipped)} skipped (missing SKUs)"}) _update_progress("validation", f"{len(importable)} importable, {len(skipped)} skipped (missing SKUs)",
0, len(importable))
_log_line(run_id, f"Validare SKU-uri: {len(importable)} importabile, {len(skipped)} nemapate") _log_line(run_id, f"Validare SKU-uri: {len(importable)} importabile, {len(skipped)} nemapate")
# Step 2c: Build SKU context from skipped orders # Step 2c: Build SKU context from skipped orders
@@ -189,8 +184,7 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None
logger.info(f"Sync params: ID_POL={id_pol}, ID_SECTIE={id_sectie}") logger.info(f"Sync params: ID_POL={id_pol}, ID_SECTIE={id_sectie}")
_log_line(run_id, f"Parametri import: ID_POL={id_pol}, ID_SECTIE={id_sectie}") _log_line(run_id, f"Parametri import: ID_POL={id_pol}, ID_SECTIE={id_sectie}")
if id_pol and importable: if id_pol and importable:
_current_sync["progress"] = "Validating prices..." _update_progress("validation", "Validating prices...", 0, len(importable))
await _emit({"type": "phase", "run_id": run_id, "message": "Validating prices..."})
_log_line(run_id, "Validare preturi...") _log_line(run_id, "Validare preturi...")
# Gather all CODMATs from importable orders # Gather all CODMATs from importable orders
all_codmats = set() all_codmats = set()
@@ -216,10 +210,21 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None
price_result["missing_price"], id_pol price_result["missing_price"], id_pol
) )
# Step 3: Record skipped orders + emit events + store items # Step 3: Record skipped orders + store items
skipped_count = 0
for order, missing_skus in skipped: for order, missing_skus in skipped:
customer = order.billing.company_name or \ skipped_count += 1
f"{order.billing.firstname} {order.billing.lastname}" # Derive shipping / billing names
shipping_name = ""
if order.shipping:
shipping_name = f"{getattr(order.shipping, 'firstname', '') or ''} {getattr(order.shipping, 'lastname', '') or ''}".strip()
billing_name = f"{getattr(order.billing, 'firstname', '') or ''} {getattr(order.billing, 'lastname', '') or ''}".strip()
if not shipping_name:
shipping_name = billing_name
customer = shipping_name or order.billing.company_name or billing_name
payment_method = getattr(order, 'payment_name', None) or None
delivery_method = getattr(order, 'delivery_name', None) or None
await sqlite_service.upsert_order( await sqlite_service.upsert_order(
sync_run_id=run_id, sync_run_id=run_id,
order_number=order.number, order_number=order.number,
@@ -227,7 +232,11 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None
customer_name=customer, customer_name=customer,
status="SKIPPED", status="SKIPPED",
missing_skus=missing_skus, missing_skus=missing_skus,
items_count=len(order.items) items_count=len(order.items),
shipping_name=shipping_name,
billing_name=billing_name,
payment_method=payment_method,
delivery_method=delivery_method,
) )
await sqlite_service.add_sync_run_order(run_id, order.number, "SKIPPED") await sqlite_service.add_sync_run_order(run_id, order.number, "SKIPPED")
# Store order items with mapping status (R9) # Store order items with mapping status (R9)
@@ -243,28 +252,35 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None
}) })
await sqlite_service.add_order_items(order.number, order_items_data) await sqlite_service.add_order_items(order.number, order_items_data)
_log_line(run_id, f"#{order.number} [{order.date or '?'}] {customer} → OMIS (lipsa: {', '.join(missing_skus)})") _log_line(run_id, f"#{order.number} [{order.date or '?'}] {customer} → OMIS (lipsa: {', '.join(missing_skus)})")
await _emit({ _update_progress("skipped", f"Skipped {skipped_count}/{len(skipped)}: #{order.number} {customer}",
"type": "order_result", "run_id": run_id, 0, len(importable),
"order_number": order.number, "customer_name": customer, {"imported": 0, "skipped": skipped_count, "errors": 0})
"order_date": order.date,
"status": "SKIPPED", "missing_skus": missing_skus,
"items_count": len(order.items), "progress": f"0/{len(importable)}"
})
# Step 4: Import valid orders # Step 4: Import valid orders
imported_count = 0 imported_count = 0
error_count = 0 error_count = 0
for i, order in enumerate(importable): for i, order in enumerate(importable):
progress_str = f"{i+1}/{len(importable)}" # Derive shipping / billing names
_current_sync["progress"] = f"Importing {progress_str}: #{order.number}" shipping_name = ""
if order.shipping:
shipping_name = f"{getattr(order.shipping, 'firstname', '') or ''} {getattr(order.shipping, 'lastname', '') or ''}".strip()
billing_name = f"{getattr(order.billing, 'firstname', '') or ''} {getattr(order.billing, 'lastname', '') or ''}".strip()
if not shipping_name:
shipping_name = billing_name
customer = shipping_name or order.billing.company_name or billing_name
payment_method = getattr(order, 'payment_name', None) or None
delivery_method = getattr(order, 'delivery_name', None) or None
_update_progress("import",
f"Import {i+1}/{len(importable)}: #{order.number} {customer}",
i + 1, len(importable),
{"imported": imported_count, "skipped": len(skipped), "errors": error_count})
result = await asyncio.to_thread( result = await asyncio.to_thread(
import_service.import_single_order, import_service.import_single_order,
order, id_pol=id_pol, id_sectie=id_sectie order, id_pol=id_pol, id_sectie=id_sectie
) )
customer = order.billing.company_name or \
f"{order.billing.firstname} {order.billing.lastname}"
# Build order items data for storage (R9) # Build order items data for storage (R9)
order_items_data = [] order_items_data = []
@@ -287,7 +303,11 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None
status="IMPORTED", status="IMPORTED",
id_comanda=result["id_comanda"], id_comanda=result["id_comanda"],
id_partener=result["id_partener"], id_partener=result["id_partener"],
items_count=len(order.items) items_count=len(order.items),
shipping_name=shipping_name,
billing_name=billing_name,
payment_method=payment_method,
delivery_method=delivery_method,
) )
await sqlite_service.add_sync_run_order(run_id, order.number, "IMPORTED") await sqlite_service.add_sync_run_order(run_id, order.number, "IMPORTED")
# Store ROA address IDs (R9) # Store ROA address IDs (R9)
@@ -298,13 +318,6 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None
) )
await sqlite_service.add_order_items(order.number, order_items_data) await sqlite_service.add_order_items(order.number, order_items_data)
_log_line(run_id, f"#{order.number} [{order.date or '?'}] {customer} → IMPORTAT (ID: {result['id_comanda']})") _log_line(run_id, f"#{order.number} [{order.date or '?'}] {customer} → IMPORTAT (ID: {result['id_comanda']})")
await _emit({
"type": "order_result", "run_id": run_id,
"order_number": order.number, "customer_name": customer,
"order_date": order.date,
"status": "IMPORTED", "items_count": len(order.items),
"id_comanda": result["id_comanda"], "progress": progress_str
})
else: else:
error_count += 1 error_count += 1
await sqlite_service.upsert_order( await sqlite_service.upsert_order(
@@ -315,18 +328,15 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None
status="ERROR", status="ERROR",
id_partener=result.get("id_partener"), id_partener=result.get("id_partener"),
error_message=result["error"], error_message=result["error"],
items_count=len(order.items) items_count=len(order.items),
shipping_name=shipping_name,
billing_name=billing_name,
payment_method=payment_method,
delivery_method=delivery_method,
) )
await sqlite_service.add_sync_run_order(run_id, order.number, "ERROR") await sqlite_service.add_sync_run_order(run_id, order.number, "ERROR")
await sqlite_service.add_order_items(order.number, order_items_data) await sqlite_service.add_order_items(order.number, order_items_data)
_log_line(run_id, f"#{order.number} [{order.date or '?'}] {customer} → EROARE: {result['error']}") _log_line(run_id, f"#{order.number} [{order.date or '?'}] {customer} → EROARE: {result['error']}")
await _emit({
"type": "order_result", "run_id": run_id,
"order_number": order.number, "customer_name": customer,
"order_date": order.date,
"status": "ERROR", "error_message": result["error"],
"items_count": len(order.items), "progress": progress_str
})
# Safety: stop if too many errors # Safety: stop if too many errors
if error_count > 10: if error_count > 10:
@@ -351,11 +361,18 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None
"missing_skus": len(validation["missing"]) "missing_skus": len(validation["missing"])
} }
_update_progress("completed",
f"Completed: {imported_count} imported, {len(skipped)} skipped, {error_count} errors",
len(importable), len(importable),
{"imported": imported_count, "skipped": len(skipped), "errors": error_count})
if _current_sync:
_current_sync["status"] = status
_current_sync["finished_at"] = datetime.now().isoformat()
logger.info( logger.info(
f"Sync {run_id} completed: {imported_count} imported, " f"Sync {run_id} completed: {imported_count} imported, "
f"{len(skipped)} skipped, {error_count} errors" f"{len(skipped)} skipped, {error_count} errors"
) )
await _emit({"type": "completed", "run_id": run_id, "summary": summary})
duration = (datetime.now() - started_dt).total_seconds() duration = (datetime.now() - started_dt).total_seconds()
_log_line(run_id, "") _log_line(run_id, "")
@@ -367,8 +384,10 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None
logger.error(f"Sync {run_id} failed: {e}") logger.error(f"Sync {run_id} failed: {e}")
_log_line(run_id, f"EROARE FATALA: {e}") _log_line(run_id, f"EROARE FATALA: {e}")
await sqlite_service.update_sync_run(run_id, "failed", 0, 0, 0, 1, error_message=str(e)) await sqlite_service.update_sync_run(run_id, "failed", 0, 0, 0, 1, error_message=str(e))
if _current_sync:
_current_sync["status"] = "failed"
_current_sync["finished_at"] = datetime.now().isoformat()
_current_sync["error"] = str(e) _current_sync["error"] = str(e)
await _emit({"type": "failed", "run_id": run_id, "error": str(e)})
return {"run_id": run_id, "status": "failed", "error": str(e)} return {"run_id": run_id, "status": "failed", "error": str(e)}
finally: finally:
# Keep _current_sync for 10 seconds so status endpoint can show final result # Keep _current_sync for 10 seconds so status endpoint can show final result

View File

@@ -302,3 +302,191 @@ tr.mapping-deleted td {
.cursor-pointer { .cursor-pointer {
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 {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
padding: 0.625rem 0;
}
.filter-pill {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.25rem 0.625rem;
border: 1px solid #d1d5db;
border-radius: 999px;
background: #fff;
font-size: 0.8125rem;
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;
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-weight: 600;
text-align: center;
line-height: 1.4;
}
/* ── Search input (used in filter bars) ─────────── */
.search-input {
margin-left: auto;
padding: 0.25rem 0.625rem;
border: 1px solid #d1d5db;
border-radius: 6px;
font-size: 0.8125rem;
outline: none;
min-width: 180px;
}
.search-input:focus { border-color: #1d4ed8; }
/* ── Tooltip for Client/Cont ─────────────────────── */
.tooltip-cont {
position: relative;
cursor: default;
}
.tooltip-cont::after {
content: attr(data-tooltip);
position: absolute;
bottom: 125%;
left: 50%;
transform: translateX(-50%);
background: #1f2937;
color: #f9fafb;
font-size: 0.75rem;
padding: 0.3rem 0.6rem;
border-radius: 4px;
white-space: nowrap;
pointer-events: none;
opacity: 0;
transition: opacity 0.15s;
z-index: 10;
}
.tooltip-cont:hover::after { opacity: 1; }
/* ── Sync card ───────────────────────────────────── */
.sync-card {
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 8px;
overflow: hidden;
margin-bottom: 1rem;
}
.sync-card-controls {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
flex-wrap: wrap;
}
.sync-card-divider {
height: 1px;
background: #e5e7eb;
margin: 0;
}
.sync-card-info {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.5rem 1rem;
font-size: 0.8125rem;
color: #6b7280;
cursor: pointer;
transition: background 0.12s;
}
.sync-card-info:hover { background: #f9fafb; }
.sync-card-progress {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.4rem 1rem;
background: #eff6ff;
font-size: 0.8125rem;
color: #1d4ed8;
border-top: 1px solid #dbeafe;
}
/* ── Pulsing live dot ────────────────────────────── */
.sync-live-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
background: #3b82f6;
animation: pulse-dot 1.2s ease-in-out infinite;
flex-shrink: 0;
}
@keyframes pulse-dot {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.4; transform: scale(0.75); }
}
/* ── Status dot (idle/running/completed/failed) ──── */
.sync-status-dot {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
.sync-status-dot.idle { background: #9ca3af; }
.sync-status-dot.running { background: #3b82f6; animation: pulse-dot 1.2s ease-in-out infinite; }
.sync-status-dot.completed { background: #10b981; }
.sync-status-dot.failed { background: #ef4444; }
/* ── Custom period range inputs ──────────────────── */
.period-custom-range {
display: none;
gap: 0.375rem;
align-items: center;
font-size: 0.8125rem;
}
.period-custom-range.visible { display: flex; }
/* ── Compact button ──────────────────────────────── */
.btn-compact {
padding: 0.3rem 0.75rem;
font-size: 0.8125rem;
}
/* ── Compact select ──────────────────────────────── */
.select-compact {
padding: 0.25rem 0.5rem;
font-size: 0.8125rem;
border: 1px solid #d1d5db;
border-radius: 6px;
background: #fff;
cursor: pointer;
}
/* ── Result banner ───────────────────────────────── */
.result-banner {
padding: 0.4rem 0.75rem;
border-radius: 6px;
font-size: 0.8125rem;
background: #d1fae5;
color: #065f46;
border: 1px solid #6ee7b7;
}

View File

@@ -1,138 +1,215 @@
let refreshInterval = null; // ── State ─────────────────────────────────────────
let dashPage = 1; let dashPage = 1;
let dashFilter = 'all'; let dashPerPage = 50;
let dashSearch = '';
let dashSortCol = 'order_date'; let dashSortCol = 'order_date';
let dashSortDir = 'desc'; let dashSortDir = 'desc';
let dashSearchTimeout = null; let dashSearchTimeout = null;
let dashPeriodDays = 7;
let currentQmSku = ''; let currentQmSku = '';
let currentQmOrderNumber = ''; let currentQmOrderNumber = '';
let qmAcTimeout = null; let qmAcTimeout = null;
let syncEventSource = null;
// Sync polling state
let _pollInterval = null;
let _lastSyncStatus = null;
let _lastRunId = null;
// ── Init ──────────────────────────────────────────
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
loadSchedulerStatus(); loadSchedulerStatus();
loadSyncStatus();
loadLastSync();
loadDashOrders(); loadDashOrders();
refreshInterval = setInterval(() => { startSyncPolling();
loadSyncStatus(); wireFilterBar();
}, 10000);
}); });
// ── Sync Status ────────────────────────────────── // ── Smart Sync Polling ────────────────────────────
async function loadSyncStatus() { function startSyncPolling() {
if (_pollInterval) clearInterval(_pollInterval);
_pollInterval = setInterval(pollSyncStatus, 30000);
pollSyncStatus(); // immediate first call
}
async function pollSyncStatus() {
try { try {
const res = await fetch('/api/sync/status'); const data = await fetchJSON('/api/sync/status');
const data = await res.json(); updateSyncPanel(data);
const isRunning = data.status === 'running';
const badge = document.getElementById('syncStatusBadge'); const wasRunning = _lastSyncStatus === 'running';
const status = data.status || 'idle'; if (isRunning && !wasRunning) {
badge.textContent = status; // Switched to running — speed up polling
badge.className = 'badge ' + (status === 'running' ? 'bg-primary' : status === 'failed' ? 'bg-danger' : 'bg-secondary'); clearInterval(_pollInterval);
_pollInterval = setInterval(pollSyncStatus, 3000);
if (status === 'running') { } else if (!isRunning && wasRunning) {
document.getElementById('btnStartSync').classList.add('d-none'); // Sync just completed — slow down and refresh orders
document.getElementById('btnStopSync').classList.remove('d-none'); clearInterval(_pollInterval);
document.getElementById('syncProgressText').textContent = data.progress || 'Running...'; _pollInterval = setInterval(pollSyncStatus, 30000);
} else { loadDashOrders();
document.getElementById('btnStartSync').classList.remove('d-none');
document.getElementById('btnStopSync').classList.add('d-none');
const stats = data.stats || {};
if (stats.last_run) {
const lr = stats.last_run;
const started = lr.started_at ? new Date(lr.started_at).toLocaleString('ro-RO') : '';
document.getElementById('syncProgressText').textContent =
`Ultimul: ${started} | ${lr.imported || 0} ok, ${lr.skipped || 0} nemapate, ${lr.errors || 0} erori`;
} else {
document.getElementById('syncProgressText').textContent = '';
} }
} _lastSyncStatus = data.status;
} catch (err) { } catch (e) {
console.error('loadSyncStatus error:', err); console.warn('Sync status poll failed:', e);
} }
} }
// ── Last Sync Summary Card ─────────────────────── function updateSyncPanel(data) {
const dot = document.getElementById('syncStatusDot');
const txt = document.getElementById('syncStatusText');
const progressArea = document.getElementById('syncProgressArea');
const progressText = document.getElementById('syncProgressText');
const startBtn = document.getElementById('syncStartBtn');
async function loadLastSync() { if (dot) {
dot.className = 'sync-status-dot ' + (data.status || 'idle');
}
const statusLabels = { running: 'A ruleaza...', idle: 'Inactiv', completed: 'Finalizat', failed: 'Eroare' };
if (txt) txt.textContent = statusLabels[data.status] || data.status || 'Inactiv';
if (startBtn) startBtn.disabled = data.status === 'running';
// Live progress area
if (progressArea) {
progressArea.style.display = data.status === 'running' ? 'flex' : 'none';
}
if (progressText && data.phase_text) {
progressText.textContent = data.phase_text;
}
// Last run info
const lr = data.last_run;
if (lr) {
_lastRunId = lr.run_id;
const d = document.getElementById('lastSyncDate');
const dur = document.getElementById('lastSyncDuration');
const cnt = document.getElementById('lastSyncCounts');
const st = document.getElementById('lastSyncStatus');
if (d) d.textContent = lr.started_at ? lr.started_at.replace('T', ' ').slice(0, 16) : '\u2014';
if (dur) dur.textContent = lr.duration_seconds ? Math.round(lr.duration_seconds) + 's' : '\u2014';
if (cnt) cnt.textContent = '\u2191' + (lr.imported || 0) + ' \u2298' + (lr.skipped || 0) + ' \u2715' + (lr.errors || 0);
if (st) {
st.textContent = lr.status === 'completed' ? '\u2713' : '\u2715';
st.style.color = lr.status === 'completed' ? '#10b981' : '#ef4444';
}
}
}
// Wire last-sync-row click → journal
document.addEventListener('DOMContentLoaded', () => {
document.getElementById('lastSyncRow')?.addEventListener('click', () => {
if (_lastRunId) window.location = '/logs?run=' + _lastRunId;
});
document.getElementById('lastSyncRow')?.addEventListener('keydown', (e) => {
if ((e.key === 'Enter' || e.key === ' ') && _lastRunId) {
window.location = '/logs?run=' + _lastRunId;
}
});
});
// ── Sync Controls ─────────────────────────────────
async function startSync() {
try { try {
const res = await fetch('/api/sync/history?per_page=1'); const res = await fetch('/api/sync/start', { method: 'POST' });
const data = await res.json(); const data = await res.json();
const runs = data.runs || []; if (data.error) {
alert(data.error);
if (runs.length === 0) {
document.getElementById('lastSyncDate').textContent = '-';
return; return;
} }
// Polling will detect the running state — just speed it up immediately
pollSyncStatus();
} catch (err) {
alert('Eroare: ' + err.message);
}
}
const r = runs[0]; async function stopSync() {
document.getElementById('lastSyncDate').textContent = r.started_at try {
? new Date(r.started_at).toLocaleString('ro-RO', {day:'2-digit',month:'2-digit',hour:'2-digit',minute:'2-digit'}) await fetch('/api/sync/stop', { method: 'POST' });
: '-'; pollSyncStatus();
} catch (err) {
alert('Eroare: ' + err.message);
}
}
const statusClass = r.status === 'completed' ? 'bg-success' : r.status === 'running' ? 'bg-primary' : 'bg-danger'; async function toggleScheduler() {
document.getElementById('lastSyncStatus').innerHTML = `<span class="badge ${statusClass}">${esc(r.status)}</span>`; const enabled = document.getElementById('schedulerToggle').checked;
document.getElementById('lastSyncImported').textContent = r.imported || 0; const interval = parseInt(document.getElementById('schedulerInterval').value) || 10;
document.getElementById('lastSyncSkipped').textContent = r.skipped || 0; try {
document.getElementById('lastSyncErrors').textContent = r.errors || 0; await fetch('/api/sync/schedule', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled, interval_minutes: interval })
});
} catch (err) {
alert('Eroare scheduler: ' + err.message);
}
}
if (r.started_at && r.finished_at) { async function updateSchedulerInterval() {
const sec = Math.round((new Date(r.finished_at) - new Date(r.started_at)) / 1000); const enabled = document.getElementById('schedulerToggle').checked;
document.getElementById('lastSyncDuration').textContent = sec < 60 ? `${sec}s` : `${Math.floor(sec/60)}m ${sec%60}s`; if (enabled) {
} else { await toggleScheduler();
document.getElementById('lastSyncDuration').textContent = '-'; }
}
async function loadSchedulerStatus() {
try {
const res = await fetch('/api/sync/schedule');
const data = await res.json();
document.getElementById('schedulerToggle').checked = data.enabled || false;
if (data.interval_minutes) {
document.getElementById('schedulerInterval').value = data.interval_minutes;
} }
} catch (err) { } catch (err) {
console.error('loadLastSync error:', err); console.error('loadSchedulerStatus error:', err);
} }
} }
// ── Dashboard Orders Table ─────────────────────── // ── Filter Bar wiring ─────────────────────────────
function debounceDashSearch() { function wireFilterBar() {
// Period dropdown
document.getElementById('periodSelect')?.addEventListener('change', function () {
const cr = document.getElementById('customRangeInputs');
if (this.value === 'custom') {
cr?.classList.add('visible');
} else {
cr?.classList.remove('visible');
dashPage = 1;
loadDashOrders();
}
});
// Custom range inputs
['periodStart', 'periodEnd'].forEach(id => {
document.getElementById(id)?.addEventListener('change', () => {
const s = document.getElementById('periodStart')?.value;
const e = document.getElementById('periodEnd')?.value;
if (s && e) { dashPage = 1; loadDashOrders(); }
});
});
// Status pills
document.querySelectorAll('.filter-pill[data-status]').forEach(btn => {
btn.addEventListener('click', function () {
document.querySelectorAll('.filter-pill[data-status]').forEach(b => b.classList.remove('active'));
this.classList.add('active');
dashPage = 1;
loadDashOrders();
});
});
// Search — 300ms debounce
document.getElementById('orderSearch')?.addEventListener('input', () => {
clearTimeout(dashSearchTimeout); clearTimeout(dashSearchTimeout);
dashSearchTimeout = setTimeout(() => { dashSearchTimeout = setTimeout(() => {
dashSearch = document.getElementById('dashSearchInput').value;
dashPage = 1; dashPage = 1;
loadDashOrders(); loadDashOrders();
}, 300); }, 300);
}
function dashFilterOrders(filter) {
dashFilter = filter;
dashPage = 1;
// Update button styles
const colorMap = {
'all': 'primary',
'IMPORTED': 'success',
'SKIPPED': 'warning',
'ERROR': 'danger',
'UNINVOICED': 'info'
};
document.querySelectorAll('#dashFilterBtns button').forEach(btn => {
const text = btn.textContent.trim().split(' ')[0];
let btnFilter = 'all';
if (text === 'Importate') btnFilter = 'IMPORTED';
else if (text === 'Omise') btnFilter = 'SKIPPED';
else if (text === 'Erori') btnFilter = 'ERROR';
else if (text === 'Nefacturate') btnFilter = 'UNINVOICED';
const color = colorMap[btnFilter] || 'primary';
if (btnFilter === filter) {
btn.className = `btn btn-sm btn-${color}`;
} else {
btn.className = `btn btn-sm btn-outline-${color}`;
}
}); });
loadDashOrders();
} }
// ── Dashboard Orders Table ────────────────────────
function dashSortBy(col) { function dashSortBy(col) {
if (dashSortCol === col) { if (dashSortCol === col) {
dashSortDir = dashSortDir === 'asc' ? 'desc' : 'asc'; dashSortDir = dashSortDir === 'asc' ? 'desc' : 'asc';
@@ -140,8 +217,6 @@ function dashSortBy(col) {
dashSortCol = col; dashSortCol = col;
dashSortDir = 'asc'; dashSortDir = 'asc';
} }
// Update sort icons
document.querySelectorAll('#dashOrdersBody').forEach(() => {}); // noop
document.querySelectorAll('.sort-icon').forEach(span => { document.querySelectorAll('.sort-icon').forEach(span => {
const c = span.dataset.col; const c = span.dataset.col;
span.textContent = c === dashSortCol ? (dashSortDir === 'asc' ? '\u2191' : '\u2193') : ''; span.textContent = c === dashSortCol ? (dashSortDir === 'asc' ? '\u2191' : '\u2193') : '';
@@ -150,39 +225,45 @@ function dashSortBy(col) {
loadDashOrders(); loadDashOrders();
} }
function dashSetPeriod(days) { async function loadDashOrders() {
dashPeriodDays = days; const periodVal = document.getElementById('periodSelect')?.value || '7';
dashPage = 1; const params = new URLSearchParams();
document.querySelectorAll('#dashPeriodBtns button').forEach(btn => {
const val = parseInt(btn.dataset.days); if (periodVal === 'custom') {
btn.className = val === days const s = document.getElementById('periodStart')?.value;
? 'btn btn-sm btn-secondary' const e = document.getElementById('periodEnd')?.value;
: 'btn btn-sm btn-outline-secondary'; if (s && e) {
}); params.set('period_start', s);
loadDashOrders(); params.set('period_end', e);
params.set('period_days', '0');
}
} else {
params.set('period_days', periodVal);
} }
async function loadDashOrders() { const activeStatus = document.querySelector('.filter-pill.active')?.dataset.status;
const params = new URLSearchParams({ if (activeStatus && activeStatus !== 'all') params.set('status', activeStatus);
page: dashPage,
per_page: 50, const search = document.getElementById('orderSearch')?.value?.trim();
search: dashSearch, if (search) params.set('search', search);
status: dashFilter,
sort_by: dashSortCol, params.set('page', dashPage);
sort_dir: dashSortDir, params.set('per_page', dashPerPage);
period_days: dashPeriodDays params.set('sort_by', dashSortCol);
}); params.set('sort_dir', dashSortDir);
try { try {
const res = await fetch(`/api/dashboard/orders?${params}`); const res = await fetch(`/api/dashboard/orders?${params}`);
const data = await res.json(); const data = await res.json();
const counts = data.counts || {}; // Update filter-pill badge counts
document.getElementById('dashCountAll').textContent = counts.total || 0; const c = data.counts || {};
document.getElementById('dashCountImported').textContent = counts.imported || 0; const el = (id) => document.getElementById(id);
document.getElementById('dashCountSkipped').textContent = counts.skipped || 0; if (el('cntAll')) el('cntAll').textContent = c.total || 0;
document.getElementById('dashCountError').textContent = counts.error || 0; if (el('cntImp')) el('cntImp').textContent = c.imported || 0;
document.getElementById('dashCountUninvoiced').textContent = counts.uninvoiced || 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;
const tbody = document.getElementById('dashOrdersBody'); const tbody = document.getElementById('dashOrdersBody');
const orders = data.orders || []; const orders = data.orders || [];
@@ -212,7 +293,7 @@ async function loadDashOrders() {
return `<tr style="cursor:pointer" onclick="openDashOrderDetail('${esc(o.order_number)}')"> return `<tr style="cursor:pointer" onclick="openDashOrderDetail('${esc(o.order_number)}')">
<td><code>${esc(o.order_number)}</code></td> <td><code>${esc(o.order_number)}</code></td>
<td>${dateStr}</td> <td>${dateStr}</td>
<td>${esc(o.customer_name)}</td> ${renderClientCell(o)}
<td>${o.items_count || 0}</td> <td>${o.items_count || 0}</td>
<td>${statusBadge}</td> <td>${statusBadge}</td>
<td>${o.id_comanda || '-'}</td> <td>${o.id_comanda || '-'}</td>
@@ -223,19 +304,23 @@ async function loadDashOrders() {
} }
// Pagination // Pagination
const totalPages = data.pages || 1; const pag = data.pagination || {};
document.getElementById('dashPageInfo').textContent = `${data.total || 0} comenzi | Pagina ${dashPage} din ${totalPages}`; const totalPages = pag.total_pages || data.pages || 1;
const totalOrders = (data.counts || {}).total || data.total || 0;
const pageInfo = `${totalOrders} comenzi | Pagina ${dashPage} din ${totalPages}`;
document.getElementById('dashPageInfo').textContent = pageInfo;
const pagInfoTop = document.getElementById('dashPageInfoTop');
if (pagInfoTop) pagInfoTop.textContent = pageInfo;
const pagDiv = document.getElementById('dashPagination'); const pagHtml = totalPages > 1 ? `
if (totalPages > 1) {
pagDiv.innerHTML = `
<button class="btn btn-sm btn-outline-secondary" ${dashPage <= 1 ? 'disabled' : ''} onclick="dashGoPage(${dashPage - 1})"><i class="bi bi-chevron-left"></i></button> <button class="btn btn-sm btn-outline-secondary" ${dashPage <= 1 ? 'disabled' : ''} onclick="dashGoPage(${dashPage - 1})"><i class="bi bi-chevron-left"></i></button>
<small class="text-muted">${dashPage} / ${totalPages}</small> <small class="text-muted">${dashPage} / ${totalPages}</small>
<button class="btn btn-sm btn-outline-secondary" ${dashPage >= totalPages ? 'disabled' : ''} onclick="dashGoPage(${dashPage + 1})"><i class="bi bi-chevron-right"></i></button> <button class="btn btn-sm btn-outline-secondary" ${dashPage >= totalPages ? 'disabled' : ''} onclick="dashGoPage(${dashPage + 1})"><i class="bi bi-chevron-right"></i></button>
`; ` : '';
} else { const pagDiv = document.getElementById('dashPagination');
pagDiv.innerHTML = ''; if (pagDiv) pagDiv.innerHTML = pagHtml;
} const pagDivTop = document.getElementById('dashPaginationTop');
if (pagDivTop) pagDivTop.innerHTML = pagHtml;
// Update sort icons // Update sort icons
document.querySelectorAll('.sort-icon').forEach(span => { document.querySelectorAll('.sort-icon').forEach(span => {
@@ -253,7 +338,44 @@ function dashGoPage(p) {
loadDashOrders(); loadDashOrders();
} }
// ── Helper functions ───────────────────────────── function dashChangePerPage(val) {
dashPerPage = parseInt(val) || 50;
dashPage = 1;
loadDashOrders();
}
// ── Client cell with Cont tooltip (Task F4) ───────
function renderClientCell(order) {
const shipping = (order.shipping_name || order.customer_name || '').trim();
const billing = (order.billing_name || '').trim();
const isDiff = order.is_different_person && billing && shipping !== billing;
if (isDiff) {
return `<td class="tooltip-cont" data-tooltip="Cont: ${escHtml(billing)}">${escHtml(shipping)}&nbsp;<sup style="color:#6b7280;font-size:0.65rem">&#9650;</sup></td>`;
}
return `<td>${escHtml(shipping || billing || '\u2014')}</td>`;
}
// ── Helper functions ──────────────────────────────
async function fetchJSON(url) {
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
}
function escHtml(s) {
if (s == null) return '';
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
// Alias kept for backward compat with inline handlers in modal
function esc(s) { return escHtml(s); }
function fmtDate(dateStr) { function fmtDate(dateStr) {
if (!dateStr) return '-'; if (!dateStr) return '-';
@@ -289,7 +411,7 @@ function renderCodmatCell(item) {
).join(''); ).join('');
} }
// ── Order Detail Modal ─────────────────────────── // ── Order Detail Modal ───────────────────────────
async function openDashOrderDetail(orderNumber) { async function openDashOrderDetail(orderNumber) {
document.getElementById('detailOrderNumber').textContent = '#' + orderNumber; document.getElementById('detailOrderNumber').textContent = '#' + orderNumber;
@@ -367,7 +489,7 @@ async function openDashOrderDetail(orderNumber) {
} }
} }
// ── Quick Map Modal ────────────────────────────── // ── Quick Map Modal ──────────────────────────────
function openQuickMap(sku, productName, orderNumber) { function openQuickMap(sku, productName, orderNumber) {
currentQmSku = sku; currentQmSku = sku;
@@ -435,7 +557,7 @@ async function qmAutocomplete(input, dropdown, selectedEl) {
dropdown.innerHTML = data.results.map(r => dropdown.innerHTML = data.results.map(r =>
`<div class="autocomplete-item" onmousedown="qmSelectArticle(this, '${esc(r.codmat)}', '${esc(r.denumire)}${r.um ? ' (' + esc(r.um) + ')' : ''}')"> `<div class="autocomplete-item" onmousedown="qmSelectArticle(this, '${esc(r.codmat)}', '${esc(r.denumire)}${r.um ? ' (' + esc(r.um) + ')' : ''}')">
<span class="codmat">${esc(r.codmat)}</span> <span class="denumire">${esc(r.denumire)}</span>${r.um ? ` <small class="text-muted">(${esc(r.um)})</small>` : ''} <span class="codmat">${esc(r.codmat)}</span> &mdash; <span class="denumire">${esc(r.denumire)}</span>${r.um ? ` <small class="text-muted">(${esc(r.um)})</small>` : ''}
</div>` </div>`
).join(''); ).join('');
dropdown.classList.remove('d-none'); dropdown.classList.remove('d-none');
@@ -500,126 +622,3 @@ async function saveQuickMapping() {
alert('Eroare: ' + err.message); alert('Eroare: ' + err.message);
} }
} }
// ── Sync Controls ────────────────────────────────
async function startSync() {
try {
const res = await fetch('/api/sync/start', { method: 'POST' });
const data = await res.json();
if (data.error) {
alert(data.error);
return;
}
if (data.run_id) {
const banner = document.getElementById('syncStartedBanner');
const link = document.getElementById('syncRunLink');
if (banner && link) {
link.href = '/logs?run=' + encodeURIComponent(data.run_id);
banner.classList.remove('d-none');
}
// Subscribe to SSE for live progress + auto-refresh on completion
listenToSyncStream(data.run_id);
}
loadSyncStatus();
} catch (err) {
alert('Eroare: ' + err.message);
}
}
function listenToSyncStream(runId) {
// Close any previous SSE connection
if (syncEventSource) { syncEventSource.close(); syncEventSource = null; }
syncEventSource = new EventSource('/api/sync/stream');
syncEventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data.type === 'phase') {
document.getElementById('syncProgressText').textContent = data.message || '';
}
if (data.type === 'order_result') {
// Update progress text with current order info
const status = data.status === 'IMPORTED' ? 'OK' : data.status === 'SKIPPED' ? 'OMIS' : 'ERR';
document.getElementById('syncProgressText').textContent =
`[${data.progress || ''}] #${data.order_number} ${data.customer_name || ''}${status}`;
}
if (data.type === 'completed' || data.type === 'failed') {
syncEventSource.close();
syncEventSource = null;
// Refresh all dashboard sections
loadLastSync();
loadDashOrders();
loadSyncStatus();
// Hide banner after 5s
setTimeout(() => {
document.getElementById('syncStartedBanner')?.classList.add('d-none');
}, 5000);
}
} catch (e) {
console.error('SSE parse error:', e);
}
};
syncEventSource.onerror = () => {
syncEventSource.close();
syncEventSource = null;
// Refresh anyway — sync may have finished
loadLastSync();
loadDashOrders();
loadSyncStatus();
};
}
async function stopSync() {
try {
await fetch('/api/sync/stop', { method: 'POST' });
loadSyncStatus();
} catch (err) {
alert('Eroare: ' + err.message);
}
}
async function toggleScheduler() {
const enabled = document.getElementById('schedulerToggle').checked;
const interval = parseInt(document.getElementById('schedulerInterval').value) || 5;
try {
await fetch('/api/sync/schedule', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled, interval_minutes: interval })
});
} catch (err) {
alert('Eroare scheduler: ' + err.message);
}
}
async function updateSchedulerInterval() {
const enabled = document.getElementById('schedulerToggle').checked;
if (enabled) {
await toggleScheduler();
}
}
async function loadSchedulerStatus() {
try {
const res = await fetch('/api/sync/schedule');
const data = await res.json();
document.getElementById('schedulerToggle').checked = data.enabled || false;
if (data.interval_minutes) {
document.getElementById('schedulerInterval').value = data.interval_minutes;
}
} catch (err) {
console.error('loadSchedulerStatus error:', err);
}
}
function esc(s) {
if (s == null) return '';
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}

View File

@@ -4,12 +4,14 @@ let searchTimeout = null;
let sortColumn = 'sku'; let sortColumn = 'sku';
let sortDirection = 'asc'; let sortDirection = 'asc';
let editingMapping = null; // {sku, codmat} when editing let editingMapping = null; // {sku, codmat} when editing
let pctFilter = 'all';
// Load on page ready // Load on page ready
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
loadMappings(); loadMappings();
initAddModal(); initAddModal();
initDeleteModal(); initDeleteModal();
initPctFilterPills();
}); });
function debounceSearch() { function debounceSearch() {
@@ -45,6 +47,30 @@ function updateSortIcons() {
}); });
} }
// ── Pct Filter Pills ─────────────────────────────
function initPctFilterPills() {
document.querySelectorAll('.filter-pill[data-pct]').forEach(btn => {
btn.addEventListener('click', function() {
document.querySelectorAll('.filter-pill[data-pct]').forEach(b => b.classList.remove('active'));
this.classList.add('active');
pctFilter = this.dataset.pct;
currentPage = 1;
loadMappings();
});
});
}
function updatePctCounts(counts) {
if (!counts) return;
const elAll = document.getElementById('mCntAll');
const elComplete = document.getElementById('mCntComplete');
const elIncomplete = document.getElementById('mCntIncomplete');
if (elAll) elAll.textContent = counts.total || 0;
if (elComplete) elComplete.textContent = counts.complete || 0;
if (elIncomplete) elIncomplete.textContent = counts.incomplete || 0;
}
// ── Load & Render ──────────────────────────────── // ── Load & Render ────────────────────────────────
async function loadMappings() { async function loadMappings() {
@@ -58,6 +84,7 @@ async function loadMappings() {
sort_dir: sortDirection sort_dir: sortDirection
}); });
if (showDeleted) params.set('show_deleted', 'true'); if (showDeleted) params.set('show_deleted', 'true');
if (pctFilter && pctFilter !== 'all') params.set('pct_filter', pctFilter);
try { try {
const res = await fetch(`/api/mappings?${params}`); const res = await fetch(`/api/mappings?${params}`);
@@ -71,6 +98,7 @@ async function loadMappings() {
mappings = mappings.filter(m => m.activ || m.sters); mappings = mappings.filter(m => m.activ || m.sters);
} }
updatePctCounts(data.counts);
renderTable(mappings, showDeleted); renderTable(mappings, showDeleted);
renderPagination(data); renderPagination(data);
updateSortIcons(); updateSortIcons();
@@ -111,7 +139,17 @@ function renderTable(mappings, showDeleted) {
let skuCell, productCell; let skuCell, productCell;
if (isNewGroup) { if (isNewGroup) {
const badge = isMulti ? ` <span class="badge bg-info">Set (${skuGroupCounts[m.sku]})</span>` : ''; const badge = isMulti ? ` <span class="badge bg-info">Set (${skuGroupCounts[m.sku]})</span>` : '';
skuCell = `<td rowspan="${isMulti ? skuGroupCounts[m.sku] : 1}"><strong>${esc(m.sku)}</strong>${badge}</td>`; // Percentage total badge
let pctBadge = '';
if (m.pct_total !== undefined) {
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;
pctBadge = ` <span class="badge-pct incomplete" title="${pctVal}% alocat">&#9888; ${pctVal}%</span>`;
}
}
skuCell = `<td rowspan="${isMulti ? skuGroupCounts[m.sku] : 1}"><strong>${esc(m.sku)}</strong>${badge}${pctBadge}</td>`;
productCell = `<td rowspan="${isMulti ? skuGroupCounts[m.sku] : 1}">${esc(m.product_name || '-')}</td>`; productCell = `<td rowspan="${isMulti ? skuGroupCounts[m.sku] : 1}">${esc(m.product_name || '-')}</td>`;
} else { } else {
skuCell = ''; skuCell = '';
@@ -361,6 +399,8 @@ async function saveMapping() {
bootstrap.Modal.getInstance(document.getElementById('addModal')).hide(); bootstrap.Modal.getInstance(document.getElementById('addModal')).hide();
editingMapping = null; editingMapping = null;
loadMappings(); loadMappings();
} else if (res.status === 409) {
handleMappingConflict(data);
} else { } else {
alert('Eroare: ' + (data.error || 'Unknown')); alert('Eroare: ' + (data.error || 'Unknown'));
} }
@@ -462,6 +502,8 @@ async function saveInlineMapping() {
if (data.success) { if (data.success) {
cancelInlineAdd(); cancelInlineAdd();
loadMappings(); loadMappings();
} else if (res.status === 409) {
handleMappingConflict(data);
} else { } else {
alert('Eroare: ' + (data.error || 'Unknown')); alert('Eroare: ' + (data.error || 'Unknown'));
} }
@@ -555,12 +597,17 @@ function showUndoToast(message, undoCallback) {
const newBtn = undoBtn.cloneNode(true); const newBtn = undoBtn.cloneNode(true);
undoBtn.parentNode.replaceChild(newBtn, undoBtn); undoBtn.parentNode.replaceChild(newBtn, undoBtn);
newBtn.id = 'toastUndoBtn'; newBtn.id = 'toastUndoBtn';
if (undoCallback) {
newBtn.style.display = '';
newBtn.addEventListener('click', () => { newBtn.addEventListener('click', () => {
undoCallback(); undoCallback();
const toastEl = document.getElementById('undoToast'); const toastEl = document.getElementById('undoToast');
const inst = bootstrap.Toast.getInstance(toastEl); const inst = bootstrap.Toast.getInstance(toastEl);
if (inst) inst.hide(); if (inst) inst.hide();
}); });
} else {
newBtn.style.display = 'none';
}
const toast = new bootstrap.Toast(document.getElementById('undoToast')); const toast = new bootstrap.Toast(document.getElementById('undoToast'));
toast.show(); toast.show();
} }
@@ -639,6 +686,33 @@ async function importCsv() {
function exportCsv() { window.location.href = '/api/mappings/export-csv'; } function exportCsv() { window.location.href = '/api/mappings/export-csv'; }
function downloadTemplate() { window.location.href = '/api/mappings/csv-template'; } function downloadTemplate() { window.location.href = '/api/mappings/csv-template'; }
// ── Duplicate / Conflict handling ────────────────
function handleMappingConflict(data) {
const msg = data.error || 'Conflict la salvare';
if (data.can_restore) {
const restore = confirm(`${msg}\n\nDoriti sa restaurati maparea stearsa?`);
if (restore) {
// Find sku/codmat from the inline row or modal
const sku = (document.getElementById('inlineSku') || document.getElementById('inputSku'))?.value?.trim();
const codmat = (document.getElementById('inlineCodmat') || document.querySelector('.cl-codmat'))?.value?.trim();
if (sku && codmat) {
fetch(`/api/mappings/${encodeURIComponent(sku)}/${encodeURIComponent(codmat)}/restore`, { method: 'POST' })
.then(r => r.json())
.then(d => {
if (d.success) { cancelInlineAdd(); loadMappings(); }
else alert('Eroare la restaurare: ' + (d.error || ''));
});
}
}
} else {
showUndoToast(msg, null);
// Show non-dismissible inline error
const warn = document.getElementById('pctWarning');
if (warn) { warn.textContent = msg; warn.style.display = ''; }
}
}
function esc(s) { function esc(s) {
if (s == null) return ''; if (s == null) return '';
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;'); return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');

View File

@@ -5,102 +5,86 @@
{% block content %} {% block content %}
<h4 class="mb-4">Panou de Comanda</h4> <h4 class="mb-4">Panou de Comanda</h4>
<!-- Sync Control --> <!-- Sync Card (unified two-row panel) -->
<div class="card mb-4"> <div class="sync-card">
<div class="card-header d-flex justify-content-between align-items-center"> <!-- TOP ROW: Status + Controls -->
<span>Sync Control</span> <div class="sync-card-controls">
<span class="badge bg-secondary" id="syncStatusBadge">idle</span> <span id="syncStatusDot" class="sync-status-dot idle"></span>
</div> <span id="syncStatusText" style="font-size:0.8125rem;color:#374151;">Inactiv</span>
<div class="card-body"> <div style="display:flex;align-items:center;gap:0.5rem;margin-left:auto;">
<div class="row align-items-center"> <label style="display:flex;align-items:center;gap:0.4rem;font-size:0.8125rem;color:#6b7280;">
<div class="col-auto"> Auto:
<button class="btn btn-success btn-sm" id="btnStartSync" onclick="startSync()"> <input type="checkbox" id="schedulerToggle" style="cursor:pointer;" onchange="toggleScheduler()">
<i class="bi bi-play-fill"></i> Start Sync </label>
</button> <select id="schedulerInterval" class="select-compact" onchange="updateSchedulerInterval()">
<button class="btn btn-danger btn-sm d-none" id="btnStopSync" onclick="stopSync()"> <option value="5">5 min</option>
<i class="bi bi-stop-fill"></i> Stop <option value="10" selected>10 min</option>
</button>
</div>
<div class="col-auto">
<div class="form-check form-switch d-inline-block me-2">
<input class="form-check-input" type="checkbox" id="schedulerToggle" onchange="toggleScheduler()">
<label class="form-check-label" for="schedulerToggle">Scheduler</label>
</div>
<select class="form-select form-select-sm d-inline-block" style="width:auto" id="schedulerInterval" onchange="updateSchedulerInterval()">
<option value="1">1 min</option>
<option value="5" selected>5 min</option>
<option value="10">10 min</option>
<option value="15">15 min</option>
<option value="30">30 min</option> <option value="30">30 min</option>
<option value="60">60 min</option>
</select> </select>
</div> <button id="syncStartBtn" class="btn btn-primary btn-compact" onclick="startSync()">&#9654; Start Sync</button>
<div class="col">
<small class="text-muted" id="syncProgressText"></small>
</div> </div>
</div> </div>
<div class="mt-2 d-none" id="syncStartedBanner"> <div class="sync-card-divider"></div>
<div class="alert alert-info alert-sm py-1 px-2 mb-0 d-inline-block"> <!-- BOTTOM ROW: Last sync info (clickable → jurnal) -->
<small><i class="bi bi-broadcast"></i> Sync pornit — <a href="#" id="syncRunLink">vezi progresul live</a></small> <div class="sync-card-info" id="lastSyncRow" role="button" tabindex="0" title="Ver jurnal sync">
</div> <span id="lastSyncDate" style="font-weight:500;">&#8212;</span>
</div> <span id="lastSyncDuration" style="color:#9ca3af;">&#8212;</span>
</div> <span id="lastSyncCounts">&#8212;</span>
</div> <span id="lastSyncStatus">&#8212;</span>
<span style="margin-left:auto;font-size:0.75rem;color:#9ca3af;">&#8599; jurnal</span>
<!-- Last Sync Summary Card -->
<div class="card mb-4" id="lastSyncCard">
<div class="card-header d-flex justify-content-between align-items-center cursor-pointer" data-bs-toggle="collapse" data-bs-target="#lastSyncBody">
<span>Ultimul Sync</span>
<i class="bi bi-chevron-down"></i>
</div>
<div class="collapse show" id="lastSyncBody">
<div class="card-body">
<div class="row text-center" id="lastSyncRow">
<div class="col last-sync-col"><small class="text-muted">Data</small><br><strong id="lastSyncDate">-</strong></div>
<div class="col last-sync-col"><small class="text-muted">Status</small><br><span id="lastSyncStatus">-</span></div>
<div class="col last-sync-col"><small class="text-muted">Importate</small><br><strong class="text-success" id="lastSyncImported">0</strong></div>
<div class="col last-sync-col"><small class="text-muted">Omise</small><br><strong class="text-warning" id="lastSyncSkipped">0</strong></div>
<div class="col last-sync-col"><small class="text-muted">Erori</small><br><strong class="text-danger" id="lastSyncErrors">0</strong></div>
<div class="col"><small class="text-muted">Durata</small><br><strong id="lastSyncDuration">-</strong></div>
</div>
</div> </div>
<!-- LIVE PROGRESS (shown only when sync is running) -->
<div class="sync-card-progress" id="syncProgressArea" style="display:none;">
<span class="sync-live-dot"></span>
<span id="syncProgressText">Se proceseaza...</span>
</div> </div>
</div> </div>
<!-- Orders Table --> <!-- Orders Table -->
<div class="card mb-4"> <div class="card mb-4">
<div class="card-header d-flex justify-content-between align-items-center"> <div class="card-header">
<div class="d-flex align-items-center gap-2">
<span>Comenzi</span> <span>Comenzi</span>
<div class="btn-group btn-group-sm" role="group" id="dashPeriodBtns"> </div>
<button type="button" class="btn btn-sm btn-outline-secondary" data-days="3" onclick="dashSetPeriod(3)">3 zile</button> <div class="card-body py-2 px-3">
<button type="button" class="btn btn-sm btn-secondary" data-days="7" onclick="dashSetPeriod(7)">7 zile</button> <div class="filter-bar" id="ordersFilterBar">
<button type="button" class="btn btn-sm btn-outline-secondary" data-days="30" onclick="dashSetPeriod(30)">30 zile</button> <!-- Period dropdown -->
<button type="button" class="btn btn-sm btn-outline-secondary" data-days="0" onclick="dashSetPeriod(0)">Toate</button> <select id="periodSelect" class="select-compact">
<option value="3">3 zile</option>
<option value="7" selected>7 zile</option>
<option value="30">30 zile</option>
<option value="90">3 luni</option>
<option value="0">Toate</option>
<option value="custom">Perioada personalizata...</option>
</select>
<!-- Custom date range (hidden until 'custom' selected) -->
<div class="period-custom-range" id="customRangeInputs">
<input type="date" id="periodStart" class="select-compact">
<span>&#8212;</span>
<input type="date" id="periodEnd" class="select-compact">
</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="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>
<!-- Search (integrated, end of row) -->
<input type="search" id="orderSearch" placeholder="Cauta..." class="search-input">
</div> </div>
</div> </div>
<div class="input-group input-group-sm" style="width:250px"> <!-- Pagination top bar -->
<span class="input-group-text"><i class="bi bi-search"></i></span> <div class="card-body py-1 px-3 border-bottom d-flex justify-content-between align-items-center" style="gap:0.5rem;">
<input type="text" class="form-control" id="dashSearchInput" placeholder="Cauta..." oninput="debounceDashSearch()"> <small class="text-muted" id="dashPageInfoTop"></small>
</div> <div style="display:flex;align-items:center;gap:0.5rem;">
</div> <label style="font-size:0.8125rem;color:#6b7280;white-space:nowrap;">Per pagina:
<div class="card-body py-2"> <select id="perPageSelect" class="select-compact" style="margin-left:0.25rem;" onchange="dashChangePerPage(this.value)">
<div class="btn-group" role="group" id="dashFilterBtns"> <option value="25">25</option>
<button type="button" class="btn btn-sm btn-primary" onclick="dashFilterOrders('all')"> <option value="50" selected>50</option>
Toate <span class="badge bg-light text-dark ms-1" id="dashCountAll">0</span> <option value="100">100</option>
</button> <option value="250">250</option>
<button type="button" class="btn btn-sm btn-outline-success" onclick="dashFilterOrders('IMPORTED')"> </select>
Importate <span class="badge bg-light text-dark ms-1" id="dashCountImported">0</span> </label>
</button> <div id="dashPaginationTop" class="d-flex align-items-center gap-2"></div>
<button type="button" class="btn btn-sm btn-outline-warning" onclick="dashFilterOrders('SKIPPED')">
Omise <span class="badge bg-light text-dark ms-1" id="dashCountSkipped">0</span>
</button>
<button type="button" class="btn btn-sm btn-outline-danger" onclick="dashFilterOrders('ERROR')">
Erori <span class="badge bg-light text-dark ms-1" id="dashCountError">0</span>
</button>
<button type="button" class="btn btn-sm btn-outline-info" onclick="dashFilterOrders('UNINVOICED')">
Nefacturate <span class="badge bg-light text-dark ms-1" id="dashCountUninvoiced">0</span>
</button>
</div> </div>
</div> </div>
<div class="card-body p-0"> <div class="card-body p-0">

View File

@@ -3,6 +3,11 @@
{% block nav_mappings %}active{% endblock %} {% block nav_mappings %}active{% endblock %}
{% block content %} {% 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"> <div class="d-flex justify-content-between align-items-center mb-4">
<h4 class="mb-0">Mapari SKU</h4> <h4 class="mb-0">Mapari SKU</h4>
<div> <div>
@@ -36,6 +41,13 @@
</div> </div>
</div> </div>
<!-- Percentage filter pills -->
<div class="filter-bar" id="mappingsFilterBar">
<button class="filter-pill active" data-pct="all">Toate <span class="filter-count" id="mCntAll">0</span></button>
<button class="filter-pill" data-pct="complete">Complete &#10003; <span class="filter-count" id="mCntComplete">0</span></button>
<button class="filter-pill" data-pct="incomplete">Incomplete &#9888; <span class="filter-count" id="mCntIncomplete">0</span></button>
</div>
<!-- Table --> <!-- Table -->
<div class="card"> <div class="card">
<div class="card-body p-0"> <div class="card-body p-0">

View File

@@ -9,24 +9,29 @@
<button class="btn btn-sm btn-outline-secondary" onclick="exportMissingCsv()"> <button class="btn btn-sm btn-outline-secondary" onclick="exportMissingCsv()">
<i class="bi bi-download"></i> Export CSV <i class="bi bi-download"></i> Export CSV
</button> </button>
<button class="btn btn-sm btn-outline-primary" onclick="scanForMissing()">
<i class="bi bi-search"></i> Re-Scan
</button>
</div> </div>
</div> </div>
<!-- Resolved toggle (R10) --> <!-- Unified filter bar -->
<div class="btn-group mb-3" role="group"> <div class="filter-bar" id="skusFilterBar">
<button type="button" class="btn btn-sm btn-primary" id="btnUnresolved" onclick="setResolvedFilter(0)"> <button class="filter-pill active" data-sku-status="unresolved">
Nerezolvate Nerezolvate <span class="filter-count" id="cntUnres">0</span>
</button> </button>
<button type="button" class="btn btn-sm btn-outline-success" id="btnResolved" onclick="setResolvedFilter(1)"> <button class="filter-pill" data-sku-status="resolved">
Rezolvate Rezolvate <span class="filter-count" id="cntRes">0</span>
</button> </button>
<button type="button" class="btn btn-sm btn-outline-secondary" id="btnAll" onclick="setResolvedFilter(-1)"> <button class="filter-pill" data-sku-status="all">
Toate Toate <span class="filter-count" id="cntAllSkus">0</span>
</button> </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;">
<span class="sync-live-dot"></span>
<span id="rescanProgressText">Scanare...</span>
</span>
</div> </div>
<!-- Result banner -->
<div id="rescanResult" class="result-banner" style="display:none;margin-bottom:0.75rem;"></div>
<div class="card"> <div class="card">
<div class="card-body p-0"> <div class="card-body p-0">
@@ -92,39 +97,102 @@
let currentMapSku = ''; let currentMapSku = '';
let mapAcTimeout = null; let mapAcTimeout = null;
let currentPage = 1; let currentPage = 1;
let currentResolved = 0; let skuStatusFilter = 'unresolved';
const perPage = 20; const perPage = 20;
document.addEventListener('DOMContentLoaded', () => { // ── Filter pills ──────────────────────────────────
loadMissing(1); document.querySelectorAll('.filter-pill[data-sku-status]').forEach(btn => {
btn.addEventListener('click', function() {
document.querySelectorAll('.filter-pill[data-sku-status]').forEach(b => b.classList.remove('active'));
this.classList.add('active');
skuStatusFilter = this.dataset.skuStatus;
currentPage = 1;
loadMissingSkus();
});
}); });
function setResolvedFilter(val) { // ── Search with debounce ─────────────────────────
currentResolved = val; let skuSearchTimer = null;
currentPage = 1; document.getElementById('skuSearch')?.addEventListener('input', function() {
// Update button styles clearTimeout(skuSearchTimer);
document.getElementById('btnUnresolved').className = 'btn btn-sm ' + (val === 0 ? 'btn-primary' : 'btn-outline-primary'); skuSearchTimer = setTimeout(() => { currentPage = 1; loadMissingSkus(); }, 300);
document.getElementById('btnResolved').className = 'btn btn-sm ' + (val === 1 ? 'btn-success' : 'btn-outline-success'); });
document.getElementById('btnAll').className = 'btn btn-sm ' + (val === -1 ? 'btn-secondary' : 'btn-outline-secondary');
loadMissing(1); // ── Rescan ────────────────────────────────────────
document.getElementById('rescanBtn')?.addEventListener('click', async function() {
this.disabled = true;
const prog = document.getElementById('rescanProgress');
const result = document.getElementById('rescanResult');
const progText = document.getElementById('rescanProgressText');
if (prog) { prog.style.display = 'flex'; }
if (result) result.style.display = 'none';
try {
const data = await fetch('/api/validate/scan', { method: 'POST' }).then(r => r.json());
if (progText) progText.textContent = 'Gata.';
if (result) {
result.innerHTML = `&#10003; ${data.total_skus_scanned || 0} scanate &nbsp;|&nbsp; ${data.new_missing || 0} noi lipsa &nbsp;|&nbsp; ${data.auto_resolved || 0} rezolvate`;
result.style.display = 'block';
}
loadMissingSkus();
} catch(e) {
if (progText) progText.textContent = 'Eroare.';
} finally {
this.disabled = false;
setTimeout(() => { if (prog) prog.style.display = 'none'; }, 2500);
}
});
document.addEventListener('DOMContentLoaded', () => {
loadMissingSkus();
});
function resolvedParamFor(statusFilter) {
if (statusFilter === 'resolved') return 1;
if (statusFilter === 'all') return -1;
return 0; // unresolved (default)
} }
async function loadMissing(page) { function loadMissingSkus(page) {
currentPage = page || 1; currentPage = page || currentPage;
try { const params = new URLSearchParams();
const res = await fetch(`/api/validate/missing-skus?page=${currentPage}&per_page=${perPage}&resolved=${currentResolved}`); const resolvedVal = resolvedParamFor(skuStatusFilter);
const data = await res.json(); params.set('resolved', resolvedVal);
const tbody = document.getElementById('missingBody'); params.set('page', currentPage);
params.set('per_page', perPage);
const search = document.getElementById('skuSearch')?.value?.trim();
if (search) params.set('search', search);
fetch('/api/validate/missing-skus?' + params.toString())
.then(r => r.json())
.then(data => {
const c = data.counts || {};
const el = id => document.getElementById(id);
if (el('cntUnres')) el('cntUnres').textContent = c.unresolved || 0;
if (el('cntRes')) el('cntRes').textContent = c.resolved || 0;
if (el('cntAllSkus')) el('cntAllSkus').textContent = c.total || 0;
renderMissingSkusTable(data.skus || data.missing_skus || [], data);
renderPagination(data);
})
.catch(err => {
document.getElementById('missingBody').innerHTML =
`<tr><td colspan="7" class="text-center text-danger">${err.message}</td></tr>`;
});
}
// Keep backward compat alias
function loadMissing(page) { loadMissingSkus(page); }
function renderMissingSkusTable(skus, data) {
const tbody = document.getElementById('missingBody');
if (data) {
document.getElementById('missingInfo').textContent = document.getElementById('missingInfo').textContent =
`Total: ${data.total || 0} | Pagina: ${data.page || 1} din ${data.pages || 1}`; `Total: ${data.total || 0} | Pagina: ${data.page || 1} din ${data.pages || 1}`;
}
const skus = data.missing_skus || []; if (!skus || skus.length === 0) {
if (skus.length === 0) { const msg = skuStatusFilter === 'unresolved' ? 'Toate SKU-urile sunt mapate!' :
const msg = currentResolved === 0 ? 'Toate SKU-urile sunt mapate!' : skuStatusFilter === 'resolved' ? 'Niciun SKU rezolvat' : 'Niciun SKU gasit';
currentResolved === 1 ? 'Niciun SKU rezolvat' : 'Niciun SKU gasit';
tbody.innerHTML = `<tr><td colspan="7" class="text-center text-muted py-4">${msg}</td></tr>`; tbody.innerHTML = `<tr><td colspan="7" class="text-center text-muted py-4">${msg}</td></tr>`;
renderPagination(data);
return; return;
} }
@@ -157,12 +225,6 @@ async function loadMissing(page) {
</td> </td>
</tr>`; </tr>`;
}).join(''); }).join('');
renderPagination(data);
} catch (err) {
document.getElementById('missingBody').innerHTML =
`<tr><td colspan="7" class="text-center text-danger">${err.message}</td></tr>`;
}
} }
function renderPagination(data) { function renderPagination(data) {
@@ -173,20 +235,20 @@ function renderPagination(data) {
let html = ''; let html = '';
html += `<li class="page-item ${page <= 1 ? 'disabled' : ''}"> html += `<li class="page-item ${page <= 1 ? 'disabled' : ''}">
<a class="page-link" href="#" onclick="loadMissing(${page - 1}); return false;">Anterior</a></li>`; <a class="page-link" href="#" onclick="loadMissingSkus(${page - 1}); return false;">Anterior</a></li>`;
const range = 2; const range = 2;
for (let i = 1; i <= total; i++) { for (let i = 1; i <= total; i++) {
if (i === 1 || i === total || (i >= page - range && i <= page + range)) { if (i === 1 || i === total || (i >= page - range && i <= page + range)) {
html += `<li class="page-item ${i === page ? 'active' : ''}"> html += `<li class="page-item ${i === page ? 'active' : ''}">
<a class="page-link" href="#" onclick="loadMissing(${i}); return false;">${i}</a></li>`; <a class="page-link" href="#" onclick="loadMissingSkus(${i}); return false;">${i}</a></li>`;
} else if (i === page - range - 1 || i === page + range + 1) { } else if (i === page - range - 1 || i === page + range + 1) {
html += `<li class="page-item disabled"><span class="page-link">…</span></li>`; html += `<li class="page-item disabled"><span class="page-link">…</span></li>`;
} }
} }
html += `<li class="page-item ${page >= total ? 'disabled' : ''}"> html += `<li class="page-item ${page >= total ? 'disabled' : ''}">
<a class="page-link" href="#" onclick="loadMissing(${page + 1}); return false;">Urmator</a></li>`; <a class="page-link" href="#" onclick="loadMissingSkus(${page + 1}); return false;">Urmator</a></li>`;
ul.innerHTML = html; ul.innerHTML = html;
} }
@@ -325,7 +387,7 @@ async function saveQuickMap() {
const data = await res.json(); const data = await res.json();
if (data.success) { if (data.success) {
bootstrap.Modal.getInstance(document.getElementById('mapModal')).hide(); bootstrap.Modal.getInstance(document.getElementById('mapModal')).hide();
loadMissing(currentPage); loadMissingSkus(currentPage);
} else { } else {
alert('Eroare: ' + (data.error || 'Unknown')); alert('Eroare: ' + (data.error || 'Unknown'));
} }
@@ -334,15 +396,6 @@ async function saveQuickMap() {
} }
} }
async function scanForMissing() {
try {
await fetch('/api/validate/scan', { method: 'POST' });
loadMissing(1);
} catch (err) {
alert('Eroare scan: ' + err.message);
}
}
function exportMissingCsv() { function exportMissingCsv() {
window.location.href = '/api/validate/missing-skus-csv'; window.location.href = '/api/validate/missing-skus-csv';
} }