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,
first_seen_at TEXT DEFAULT (datetime('now')),
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_date ON orders(order_date);
@@ -195,18 +199,15 @@ def init_sqlite():
);
""")
# Copy latest record per order_number into orders
# Note: old import_orders didn't have address columns — those stay NULL
conn.execute("""
INSERT INTO orders
(order_number, order_date, customer_name, status,
id_comanda, id_partener, id_adresa_facturare, id_adresa_livrare,
error_message, missing_skus, items_count, last_sync_run_id)
id_comanda, id_partener, error_message, missing_skus,
items_count, last_sync_run_id)
SELECT io.order_number, io.order_date, io.customer_name, io.status,
io.id_comanda, io.id_partener,
CASE WHEN io.order_number IN (SELECT order_number FROM import_orders WHERE id_adresa_facturare IS NOT NULL) THEN
(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
io.id_comanda, io.id_partener, io.error_message, io.missing_skus,
io.items_count, io.sync_run_id
FROM import_orders io
INNER JOIN (
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")
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()
except Exception as e:
logger.warning(f"Migration check failed: {e}")

View File

@@ -1,6 +1,7 @@
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 import HTTPException
from pydantic import BaseModel
from pathlib import Path
from typing import Optional
@@ -49,15 +50,19 @@ async def mappings_page(request: Request):
@router.get("/api/mappings")
async def list_mappings(search: str = "", page: int = 1, per_page: int = 50,
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,
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)
skus = list({m["sku"] for m in result.get("mappings", [])})
product_names = await sqlite_service.get_web_products_batch(skus)
for m in result.get("mappings", []):
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
@router.post("/api/mappings")
@@ -67,6 +72,12 @@ async def create_mapping(data: MappingCreate):
# Mark SKU as resolved in missing_skus tracking
await sqlite_service.resolve_missing_sku(data.sku)
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:
return {"success": False, "error": str(e)}

View File

@@ -5,7 +5,6 @@ from datetime import datetime
from fastapi import APIRouter, Request, BackgroundTasks
from fastapi.templating import Jinja2Templates
from fastapi.responses import HTMLResponse
from starlette.responses import StreamingResponse
from pydantic import BaseModel
from pathlib import Path
from typing import Optional
@@ -21,35 +20,6 @@ class ScheduleConfig(BaseModel):
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
@router.post("/api/sync/start")
async def start_sync(background_tasks: BackgroundTasks):
@@ -72,10 +42,68 @@ async def stop_sync():
@router.get("/api/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()
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")
@@ -277,8 +305,13 @@ async def order_detail(order_number: str):
async def dashboard_orders(page: int = 1, per_page: int = 50,
search: str = "", status: str = "all",
sort_by: str = "order_date", sort_dir: str = "desc",
period_days: int = 7):
"""Get orders for dashboard, enriched with invoice data. period_days=0 means all time."""
period_days: int = 7,
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")
# 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(
page=fetch_page, per_page=fetch_per_page, search=search,
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
@@ -309,12 +344,22 @@ async def dashboard_orders(page: int = 1, per_page: int = 50,
else:
o["invoice"] = None
# Count uninvoiced (IMPORTED without invoice)
uninvoiced_count = sum(
# Add shipping/billing name fields + is_different_person flag
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
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
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["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")

View File

@@ -16,7 +16,10 @@ async def scan_and_validate():
orders, json_count = order_reader.read_json_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)
result = validation_service.validate_skus(all_skus)
@@ -37,6 +40,7 @@ async def scan_and_validate():
if customer not in sku_context[sku]["customers"]:
sku_context[sku]["customers"].append(customer)
new_missing = 0
for sku in result["missing"]:
# Find product name from orders
product_name = ""
@@ -49,13 +53,19 @@ async def scan_and_validate():
break
ctx = sku_context.get(sku, {})
await sqlite_service.track_missing_sku(
tracked = await sqlite_service.track_missing_sku(
sku=sku,
product_name=product_name,
order_count=len(ctx.get("order_numbers", [])),
order_numbers=json.dumps(ctx.get("order_numbers", [])),
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 {
"json_files": json_count,
@@ -64,6 +74,11 @@ async def scan_and_validate():
"importable": len(importable),
"skipped": len(skipped),
"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": {
"mapped": len(result["mapped"]),
"direct": len(result["direct"]),
@@ -88,20 +103,35 @@ async def scan_and_validate():
async def get_missing_skus(
page: int = Query(1, ge=1),
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)."""
result = await sqlite_service.get_missing_skus_paginated(page, per_page, resolved)
# Backward compat: also include 'unresolved' count
"""Get paginated missing SKUs. resolved=-1 means show all (R10).
Optional search filters by sku or product_name."""
db = await get_sqlite()
try:
cursor = await db.execute(
"SELECT COUNT(*) FROM missing_skus WHERE resolved = 0"
)
unresolved = (await cursor.fetchone())[0]
# Compute counts across ALL records (unfiltered by search)
cursor = await db.execute("SELECT COUNT(*) FROM missing_skus WHERE resolved = 0")
unresolved_count = (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:
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
@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")
with database.pool.acquire() as conn:
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)
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
registru = clean_web_text(order.billing.company_reg) or None
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:
denumire = clean_web_text(
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)
# 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)
billing_addr = format_address_for_oracle(
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()
# 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:
result["id_adresa_facturare"] = int(addr_fact_id)
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,
sort_by: str = "sku", sort_dir: str = "asc",
show_deleted: bool = False):
"""Get paginated mappings with optional search and sorting."""
show_deleted: bool = False, pct_filter: str = None):
"""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:
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
where = "WHERE " + " AND ".join(where_clauses) if where_clauses else ""
# Count total
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
# Fetch ALL matching rows (no pagination yet — we need to group by SKU first)
data_sql = f"""
SELECT at.sku, at.codmat, na.denumire, na.um, at.cantitate_roa,
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
{where}
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)
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 {
"mappings": rows,
"mappings": page_rows,
"total": total,
"page": 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):
"""Create a new mapping."""
"""Create a new mapping. Returns dict or raises HTTPException on duplicate."""
if database.pool is None:
raise HTTPException(status_code=503, detail="Oracle unavailable")
with database.pool.acquire() as conn:
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("""
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)

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,
customer_name: str, status: str, id_comanda: int = 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."""
db = await get_sqlite()
try:
@@ -52,8 +54,9 @@ async def upsert_order(sync_run_id: str, order_number: str, order_date: str,
INSERT INTO orders
(order_number, order_date, customer_name, status,
id_comanda, id_partener, error_message, missing_skus, items_count,
last_sync_run_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
last_sync_run_id, shipping_name, billing_name,
payment_method, delivery_method)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(order_number) DO UPDATE SET
status = excluded.status,
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
ELSE orders.times_skipped END,
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')
""", (order_number, order_date, customer_name, status,
id_comanda, id_partener, error_message,
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()
finally:
await db.close()
@@ -124,35 +132,52 @@ async def resolve_missing_sku(sku: str):
await db.close()
async def get_missing_skus_paginated(page: int = 1, per_page: int = 20, resolved: int = 0):
"""Get paginated missing SKUs. resolved=-1 means show all."""
async def get_missing_skus_paginated(page: int = 1, per_page: int = 20,
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()
try:
offset = (page - 1) * per_page
if resolved == -1:
cursor = await db.execute("SELECT COUNT(*) FROM missing_skus")
total = (await cursor.fetchone())[0]
cursor = await db.execute("""
SELECT sku, product_name, first_seen, resolved, resolved_at,
order_count, order_numbers, customers
FROM missing_skus
ORDER BY resolved ASC, order_count DESC, first_seen DESC
LIMIT ? OFFSET ?
""", (per_page, offset))
else:
# Build WHERE clause parts
where_parts = []
params_count = []
params_data = []
if resolved != -1:
where_parts.append("resolved = ?")
params_count.append(resolved)
params_data.append(resolved)
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(
"SELECT COUNT(*) FROM missing_skus WHERE resolved = ?", (resolved,)
f"SELECT COUNT(*) FROM missing_skus {where_clause}",
params_count
)
total = (await cursor.fetchone())[0]
cursor = await db.execute("""
cursor = await db.execute(f"""
SELECT sku, product_name, first_seen, resolved, resolved_at,
order_count, order_numbers, customers
FROM missing_skus
WHERE resolved = ?
ORDER BY order_count DESC, first_seen DESC
{where_clause}
{order_clause}
LIMIT ? OFFSET ?
""", (resolved, per_page, offset))
""", params_data + [per_page, offset])
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,
search: str = "", status_filter: str = "all",
sort_by: str = "order_date", sort_dir: str = "desc",
period_days: int = 7):
"""Get orders with filters, sorting, and period. period_days=0 means all time."""
period_days: int = 7,
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()
try:
where_clauses = []
@@ -484,6 +514,9 @@ async def get_orders(page: int = 1, per_page: int = 50,
if period_days and period_days > 0:
where_clauses.append("order_date >= date('now', ?)")
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:
where_clauses.append("(order_number LIKE ? OR customer_name LIKE ?)")

View File

@@ -13,28 +13,10 @@ logger = logging.getLogger(__name__)
_sync_lock = asyncio.Lock()
_current_sync = None # dict with run_id, status, progress info
# SSE subscriber system
_subscribers: list[asyncio.Queue] = []
# In-memory text log buffer per run
_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):
"""Append a timestamped line to the in-memory log buffer."""
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)
async def _emit(event: dict):
"""Push an event to all subscriber queues."""
for q in _subscribers:
try:
q.put_nowait(event)
except asyncio.QueueFull:
pass
def _update_progress(phase: str, phase_text: str, current: int = 0, total: int = 0,
counts: dict = None):
"""Update _current_sync with progress details for polling."""
global _current_sync
if _current_sync is None:
return
_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():
@@ -80,7 +66,12 @@ async def prepare_sync(id_pol: int = None, id_sectie: int = None) -> dict:
"run_id": run_id,
"status": "running",
"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"}
@@ -100,11 +91,15 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None
"run_id": run_id,
"status": "running",
"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..."
await _emit({"type": "phase", "run_id": run_id, "message": "Reading JSON files..."})
_update_progress("reading", "Reading JSON files...")
started_dt = datetime.now()
_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.sort(key=lambda o: o.date or '')
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")
# 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:
_log_line(run_id, "Nicio comanda gasita.")
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}
await _emit({"type": "completed", "run_id": run_id, "summary": summary})
return summary
_current_sync["progress"] = f"Validating {len(orders)} orders..."
await _emit({"type": "phase", "run_id": run_id, "message": f"Validating {len(orders)} orders..."})
_update_progress("validation", f"Validating {len(orders)} orders...", 0, len(orders))
# Step 2a: Find new orders (not yet in Oracle)
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)
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")
# 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}")
_log_line(run_id, f"Parametri import: ID_POL={id_pol}, ID_SECTIE={id_sectie}")
if id_pol and importable:
_current_sync["progress"] = "Validating prices..."
await _emit({"type": "phase", "run_id": run_id, "message": "Validating prices..."})
_update_progress("validation", "Validating prices...", 0, len(importable))
_log_line(run_id, "Validare preturi...")
# Gather all CODMATs from importable orders
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
)
# 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:
customer = order.billing.company_name or \
f"{order.billing.firstname} {order.billing.lastname}"
skipped_count += 1
# 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(
sync_run_id=run_id,
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,
status="SKIPPED",
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")
# 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)
_log_line(run_id, f"#{order.number} [{order.date or '?'}] {customer} → OMIS (lipsa: {', '.join(missing_skus)})")
await _emit({
"type": "order_result", "run_id": run_id,
"order_number": order.number, "customer_name": customer,
"order_date": order.date,
"status": "SKIPPED", "missing_skus": missing_skus,
"items_count": len(order.items), "progress": f"0/{len(importable)}"
})
_update_progress("skipped", f"Skipped {skipped_count}/{len(skipped)}: #{order.number} {customer}",
0, len(importable),
{"imported": 0, "skipped": skipped_count, "errors": 0})
# Step 4: Import valid orders
imported_count = 0
error_count = 0
for i, order in enumerate(importable):
progress_str = f"{i+1}/{len(importable)}"
_current_sync["progress"] = f"Importing {progress_str}: #{order.number}"
# 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
_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(
import_service.import_single_order,
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)
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",
id_comanda=result["id_comanda"],
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")
# 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)
_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:
error_count += 1
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",
id_partener=result.get("id_partener"),
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_order_items(order.number, order_items_data)
_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
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"])
}
_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(
f"Sync {run_id} completed: {imported_count} imported, "
f"{len(skipped)} skipped, {error_count} errors"
)
await _emit({"type": "completed", "run_id": run_id, "summary": summary})
duration = (datetime.now() - started_dt).total_seconds()
_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}")
_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))
if _current_sync:
_current_sync["status"] = "failed"
_current_sync["finished_at"] = datetime.now().isoformat()
_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)}
finally:
# 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;
}
/* ── 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 dashFilter = 'all';
let dashSearch = '';
let dashPerPage = 50;
let dashSortCol = 'order_date';
let dashSortDir = 'desc';
let dashSearchTimeout = null;
let dashPeriodDays = 7;
let currentQmSku = '';
let currentQmOrderNumber = '';
let qmAcTimeout = null;
let syncEventSource = null;
// Sync polling state
let _pollInterval = null;
let _lastSyncStatus = null;
let _lastRunId = null;
// ── Init ──────────────────────────────────────────
document.addEventListener('DOMContentLoaded', () => {
loadSchedulerStatus();
loadSyncStatus();
loadLastSync();
loadDashOrders();
refreshInterval = setInterval(() => {
loadSyncStatus();
}, 10000);
startSyncPolling();
wireFilterBar();
});
// ── 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 {
const res = await fetch('/api/sync/status');
const data = await res.json();
const badge = document.getElementById('syncStatusBadge');
const status = data.status || 'idle';
badge.textContent = status;
badge.className = 'badge ' + (status === 'running' ? 'bg-primary' : status === 'failed' ? 'bg-danger' : 'bg-secondary');
if (status === 'running') {
document.getElementById('btnStartSync').classList.add('d-none');
document.getElementById('btnStopSync').classList.remove('d-none');
document.getElementById('syncProgressText').textContent = data.progress || 'Running...';
} else {
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 = '';
const data = await fetchJSON('/api/sync/status');
updateSyncPanel(data);
const isRunning = data.status === 'running';
const wasRunning = _lastSyncStatus === 'running';
if (isRunning && !wasRunning) {
// Switched to running — speed up polling
clearInterval(_pollInterval);
_pollInterval = setInterval(pollSyncStatus, 3000);
} else if (!isRunning && wasRunning) {
// Sync just completed — slow down and refresh orders
clearInterval(_pollInterval);
_pollInterval = setInterval(pollSyncStatus, 30000);
loadDashOrders();
}
}
} catch (err) {
console.error('loadSyncStatus error:', err);
_lastSyncStatus = data.status;
} catch (e) {
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 {
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 runs = data.runs || [];
if (runs.length === 0) {
document.getElementById('lastSyncDate').textContent = '-';
if (data.error) {
alert(data.error);
return;
}
// Polling will detect the running state — just speed it up immediately
pollSyncStatus();
} catch (err) {
alert('Eroare: ' + err.message);
}
}
const r = runs[0];
document.getElementById('lastSyncDate').textContent = r.started_at
? new Date(r.started_at).toLocaleString('ro-RO', {day:'2-digit',month:'2-digit',hour:'2-digit',minute:'2-digit'})
: '-';
async function stopSync() {
try {
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';
document.getElementById('lastSyncStatus').innerHTML = `<span class="badge ${statusClass}">${esc(r.status)}</span>`;
document.getElementById('lastSyncImported').textContent = r.imported || 0;
document.getElementById('lastSyncSkipped').textContent = r.skipped || 0;
document.getElementById('lastSyncErrors').textContent = r.errors || 0;
async function toggleScheduler() {
const enabled = document.getElementById('schedulerToggle').checked;
const interval = parseInt(document.getElementById('schedulerInterval').value) || 10;
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);
}
}
if (r.started_at && r.finished_at) {
const sec = Math.round((new Date(r.finished_at) - new Date(r.started_at)) / 1000);
document.getElementById('lastSyncDuration').textContent = sec < 60 ? `${sec}s` : `${Math.floor(sec/60)}m ${sec%60}s`;
} else {
document.getElementById('lastSyncDuration').textContent = '-';
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('loadLastSync error:', err);
console.error('loadSchedulerStatus error:', err);
}
}
// ── Dashboard Orders Table ───────────────────────
// ── Filter Bar wiring ─────────────────────────────
function debounceDashSearch() {
clearTimeout(dashSearchTimeout);
dashSearchTimeout = setTimeout(() => {
dashSearch = document.getElementById('dashSearchInput').value;
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();
}, 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}`;
}
});
// 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);
dashSearchTimeout = setTimeout(() => {
dashPage = 1;
loadDashOrders();
}, 300);
});
}
// ── Dashboard Orders Table ────────────────────────
function dashSortBy(col) {
if (dashSortCol === col) {
dashSortDir = dashSortDir === 'asc' ? 'desc' : 'asc';
@@ -140,8 +217,6 @@ function dashSortBy(col) {
dashSortCol = col;
dashSortDir = 'asc';
}
// Update sort icons
document.querySelectorAll('#dashOrdersBody').forEach(() => {}); // noop
document.querySelectorAll('.sort-icon').forEach(span => {
const c = span.dataset.col;
span.textContent = c === dashSortCol ? (dashSortDir === 'asc' ? '\u2191' : '\u2193') : '';
@@ -150,39 +225,45 @@ function dashSortBy(col) {
loadDashOrders();
}
function dashSetPeriod(days) {
dashPeriodDays = days;
dashPage = 1;
document.querySelectorAll('#dashPeriodBtns button').forEach(btn => {
const val = parseInt(btn.dataset.days);
btn.className = val === days
? 'btn btn-sm btn-secondary'
: 'btn btn-sm btn-outline-secondary';
});
loadDashOrders();
}
async function loadDashOrders() {
const params = new URLSearchParams({
page: dashPage,
per_page: 50,
search: dashSearch,
status: dashFilter,
sort_by: dashSortCol,
sort_dir: dashSortDir,
period_days: dashPeriodDays
});
const periodVal = document.getElementById('periodSelect')?.value || '7';
const params = new URLSearchParams();
if (periodVal === 'custom') {
const s = document.getElementById('periodStart')?.value;
const e = document.getElementById('periodEnd')?.value;
if (s && e) {
params.set('period_start', s);
params.set('period_end', e);
params.set('period_days', '0');
}
} else {
params.set('period_days', periodVal);
}
const activeStatus = document.querySelector('.filter-pill.active')?.dataset.status;
if (activeStatus && activeStatus !== 'all') params.set('status', activeStatus);
const search = document.getElementById('orderSearch')?.value?.trim();
if (search) params.set('search', search);
params.set('page', dashPage);
params.set('per_page', dashPerPage);
params.set('sort_by', dashSortCol);
params.set('sort_dir', dashSortDir);
try {
const res = await fetch(`/api/dashboard/orders?${params}`);
const data = await res.json();
const counts = data.counts || {};
document.getElementById('dashCountAll').textContent = counts.total || 0;
document.getElementById('dashCountImported').textContent = counts.imported || 0;
document.getElementById('dashCountSkipped').textContent = counts.skipped || 0;
document.getElementById('dashCountError').textContent = counts.error || 0;
document.getElementById('dashCountUninvoiced').textContent = counts.uninvoiced || 0;
// Update filter-pill badge counts
const c = data.counts || {};
const el = (id) => document.getElementById(id);
if (el('cntAll')) el('cntAll').textContent = c.total || 0;
if (el('cntImp')) el('cntImp').textContent = c.imported || 0;
if (el('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 orders = data.orders || [];
@@ -212,7 +293,7 @@ async function loadDashOrders() {
return `<tr style="cursor:pointer" onclick="openDashOrderDetail('${esc(o.order_number)}')">
<td><code>${esc(o.order_number)}</code></td>
<td>${dateStr}</td>
<td>${esc(o.customer_name)}</td>
${renderClientCell(o)}
<td>${o.items_count || 0}</td>
<td>${statusBadge}</td>
<td>${o.id_comanda || '-'}</td>
@@ -223,19 +304,23 @@ async function loadDashOrders() {
}
// Pagination
const totalPages = data.pages || 1;
document.getElementById('dashPageInfo').textContent = `${data.total || 0} comenzi | Pagina ${dashPage} din ${totalPages}`;
const pag = data.pagination || {};
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');
if (totalPages > 1) {
pagDiv.innerHTML = `
const pagHtml = totalPages > 1 ? `
<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>
<button class="btn btn-sm btn-outline-secondary" ${dashPage >= totalPages ? 'disabled' : ''} onclick="dashGoPage(${dashPage + 1})"><i class="bi bi-chevron-right"></i></button>
`;
} else {
pagDiv.innerHTML = '';
}
` : '';
const pagDiv = document.getElementById('dashPagination');
if (pagDiv) pagDiv.innerHTML = pagHtml;
const pagDivTop = document.getElementById('dashPaginationTop');
if (pagDivTop) pagDivTop.innerHTML = pagHtml;
// Update sort icons
document.querySelectorAll('.sort-icon').forEach(span => {
@@ -253,7 +338,44 @@ function dashGoPage(p) {
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) {
if (!dateStr) return '-';
@@ -289,7 +411,7 @@ function renderCodmatCell(item) {
).join('');
}
// ── Order Detail Modal ───────────────────────────
// ── Order Detail Modal ───────────────────────────
async function openDashOrderDetail(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) {
currentQmSku = sku;
@@ -435,7 +557,7 @@ async function qmAutocomplete(input, dropdown, selectedEl) {
dropdown.innerHTML = data.results.map(r =>
`<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>`
).join('');
dropdown.classList.remove('d-none');
@@ -500,126 +622,3 @@ async function saveQuickMapping() {
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 sortDirection = 'asc';
let editingMapping = null; // {sku, codmat} when editing
let pctFilter = 'all';
// Load on page ready
document.addEventListener('DOMContentLoaded', () => {
loadMappings();
initAddModal();
initDeleteModal();
initPctFilterPills();
});
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 ────────────────────────────────
async function loadMappings() {
@@ -58,6 +84,7 @@ async function loadMappings() {
sort_dir: sortDirection
});
if (showDeleted) params.set('show_deleted', 'true');
if (pctFilter && pctFilter !== 'all') params.set('pct_filter', pctFilter);
try {
const res = await fetch(`/api/mappings?${params}`);
@@ -71,6 +98,7 @@ async function loadMappings() {
mappings = mappings.filter(m => m.activ || m.sters);
}
updatePctCounts(data.counts);
renderTable(mappings, showDeleted);
renderPagination(data);
updateSortIcons();
@@ -111,7 +139,17 @@ function renderTable(mappings, showDeleted) {
let skuCell, productCell;
if (isNewGroup) {
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>`;
} else {
skuCell = '';
@@ -361,6 +399,8 @@ async function saveMapping() {
bootstrap.Modal.getInstance(document.getElementById('addModal')).hide();
editingMapping = null;
loadMappings();
} else if (res.status === 409) {
handleMappingConflict(data);
} else {
alert('Eroare: ' + (data.error || 'Unknown'));
}
@@ -462,6 +502,8 @@ async function saveInlineMapping() {
if (data.success) {
cancelInlineAdd();
loadMappings();
} else if (res.status === 409) {
handleMappingConflict(data);
} else {
alert('Eroare: ' + (data.error || 'Unknown'));
}
@@ -555,12 +597,17 @@ function showUndoToast(message, undoCallback) {
const newBtn = undoBtn.cloneNode(true);
undoBtn.parentNode.replaceChild(newBtn, undoBtn);
newBtn.id = 'toastUndoBtn';
if (undoCallback) {
newBtn.style.display = '';
newBtn.addEventListener('click', () => {
undoCallback();
const toastEl = document.getElementById('undoToast');
const inst = bootstrap.Toast.getInstance(toastEl);
if (inst) inst.hide();
});
} else {
newBtn.style.display = 'none';
}
const toast = new bootstrap.Toast(document.getElementById('undoToast'));
toast.show();
}
@@ -639,6 +686,33 @@ async function importCsv() {
function exportCsv() { window.location.href = '/api/mappings/export-csv'; }
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) {
if (s == null) return '';
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 %}
<h4 class="mb-4">Panou de Comanda</h4>
<!-- Sync Control -->
<div class="card mb-4">
<div class="card-header d-flex justify-content-between align-items-center">
<span>Sync Control</span>
<span class="badge bg-secondary" id="syncStatusBadge">idle</span>
</div>
<div class="card-body">
<div class="row align-items-center">
<div class="col-auto">
<button class="btn btn-success btn-sm" id="btnStartSync" onclick="startSync()">
<i class="bi bi-play-fill"></i> Start Sync
</button>
<button class="btn btn-danger btn-sm d-none" id="btnStopSync" onclick="stopSync()">
<i class="bi bi-stop-fill"></i> Stop
</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>
<!-- Sync Card (unified two-row panel) -->
<div class="sync-card">
<!-- TOP ROW: Status + Controls -->
<div class="sync-card-controls">
<span id="syncStatusDot" class="sync-status-dot idle"></span>
<span id="syncStatusText" style="font-size:0.8125rem;color:#374151;">Inactiv</span>
<div style="display:flex;align-items:center;gap:0.5rem;margin-left:auto;">
<label style="display:flex;align-items:center;gap:0.4rem;font-size:0.8125rem;color:#6b7280;">
Auto:
<input type="checkbox" id="schedulerToggle" style="cursor:pointer;" onchange="toggleScheduler()">
</label>
<select id="schedulerInterval" class="select-compact" onchange="updateSchedulerInterval()">
<option value="5">5 min</option>
<option value="10" selected>10 min</option>
<option value="30">30 min</option>
<option value="60">60 min</option>
</select>
</div>
<div class="col">
<small class="text-muted" id="syncProgressText"></small>
<button id="syncStartBtn" class="btn btn-primary btn-compact" onclick="startSync()">&#9654; Start Sync</button>
</div>
</div>
<div class="mt-2 d-none" id="syncStartedBanner">
<div class="alert alert-info alert-sm py-1 px-2 mb-0 d-inline-block">
<small><i class="bi bi-broadcast"></i> Sync pornit — <a href="#" id="syncRunLink">vezi progresul live</a></small>
</div>
</div>
</div>
</div>
<!-- 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 class="sync-card-divider"></div>
<!-- BOTTOM ROW: Last sync info (clickable → jurnal) -->
<div class="sync-card-info" id="lastSyncRow" role="button" tabindex="0" title="Ver jurnal sync">
<span id="lastSyncDate" style="font-weight:500;">&#8212;</span>
<span id="lastSyncDuration" style="color:#9ca3af;">&#8212;</span>
<span id="lastSyncCounts">&#8212;</span>
<span id="lastSyncStatus">&#8212;</span>
<span style="margin-left:auto;font-size:0.75rem;color:#9ca3af;">&#8599; jurnal</span>
</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>
<!-- Orders Table -->
<div class="card mb-4">
<div class="card-header d-flex justify-content-between align-items-center">
<div class="d-flex align-items-center gap-2">
<div class="card-header">
<span>Comenzi</span>
<div class="btn-group btn-group-sm" role="group" id="dashPeriodBtns">
<button type="button" class="btn btn-sm btn-outline-secondary" data-days="3" onclick="dashSetPeriod(3)">3 zile</button>
<button type="button" class="btn btn-sm btn-secondary" data-days="7" onclick="dashSetPeriod(7)">7 zile</button>
<button type="button" class="btn btn-sm btn-outline-secondary" data-days="30" onclick="dashSetPeriod(30)">30 zile</button>
<button type="button" class="btn btn-sm btn-outline-secondary" data-days="0" onclick="dashSetPeriod(0)">Toate</button>
</div>
<div class="card-body py-2 px-3">
<div class="filter-bar" id="ordersFilterBar">
<!-- Period dropdown -->
<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 class="input-group input-group-sm" style="width:250px">
<span class="input-group-text"><i class="bi bi-search"></i></span>
<input type="text" class="form-control" id="dashSearchInput" placeholder="Cauta..." oninput="debounceDashSearch()">
</div>
</div>
<div class="card-body py-2">
<div class="btn-group" role="group" id="dashFilterBtns">
<button type="button" class="btn btn-sm btn-primary" onclick="dashFilterOrders('all')">
Toate <span class="badge bg-light text-dark ms-1" id="dashCountAll">0</span>
</button>
<button type="button" class="btn btn-sm btn-outline-success" onclick="dashFilterOrders('IMPORTED')">
Importate <span class="badge bg-light text-dark ms-1" id="dashCountImported">0</span>
</button>
<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>
<!-- Pagination top bar -->
<div class="card-body py-1 px-3 border-bottom d-flex justify-content-between align-items-center" style="gap:0.5rem;">
<small class="text-muted" id="dashPageInfoTop"></small>
<div style="display:flex;align-items:center;gap:0.5rem;">
<label style="font-size:0.8125rem;color:#6b7280;white-space:nowrap;">Per pagina:
<select id="perPageSelect" class="select-compact" style="margin-left:0.25rem;" onchange="dashChangePerPage(this.value)">
<option value="25">25</option>
<option value="50" selected>50</option>
<option value="100">100</option>
<option value="250">250</option>
</select>
</label>
<div id="dashPaginationTop" class="d-flex align-items-center gap-2"></div>
</div>
</div>
<div class="card-body p-0">

View File

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

View File

@@ -9,24 +9,29 @@
<button class="btn btn-sm btn-outline-secondary" onclick="exportMissingCsv()">
<i class="bi bi-download"></i> Export CSV
</button>
<button class="btn btn-sm btn-outline-primary" onclick="scanForMissing()">
<i class="bi bi-search"></i> Re-Scan
</button>
</div>
</div>
<!-- Resolved toggle (R10) -->
<div class="btn-group mb-3" role="group">
<button type="button" class="btn btn-sm btn-primary" id="btnUnresolved" onclick="setResolvedFilter(0)">
Nerezolvate
<!-- Unified filter bar -->
<div class="filter-bar" id="skusFilterBar">
<button class="filter-pill active" data-sku-status="unresolved">
Nerezolvate <span class="filter-count" id="cntUnres">0</span>
</button>
<button type="button" class="btn btn-sm btn-outline-success" id="btnResolved" onclick="setResolvedFilter(1)">
Rezolvate
<button class="filter-pill" data-sku-status="resolved">
Rezolvate <span class="filter-count" id="cntRes">0</span>
</button>
<button type="button" class="btn btn-sm btn-outline-secondary" id="btnAll" onclick="setResolvedFilter(-1)">
Toate
<button class="filter-pill" data-sku-status="all">
Toate <span class="filter-count" id="cntAllSkus">0</span>
</button>
<input type="search" id="skuSearch" placeholder="Cauta SKU / produs..." class="search-input">
<button id="rescanBtn" class="btn btn-secondary btn-compact" style="margin-left:0.5rem;">&#8635; Re-scan</button>
<span id="rescanProgress" style="display:none;align-items:center;gap:0.4rem;font-size:0.8125rem;color:#1d4ed8;">
<span class="sync-live-dot"></span>
<span id="rescanProgressText">Scanare...</span>
</span>
</div>
<!-- Result banner -->
<div id="rescanResult" class="result-banner" style="display:none;margin-bottom:0.75rem;"></div>
<div class="card">
<div class="card-body p-0">
@@ -92,39 +97,102 @@
let currentMapSku = '';
let mapAcTimeout = null;
let currentPage = 1;
let currentResolved = 0;
let skuStatusFilter = 'unresolved';
const perPage = 20;
document.addEventListener('DOMContentLoaded', () => {
loadMissing(1);
// ── Filter pills ──────────────────────────────────
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) {
currentResolved = val;
currentPage = 1;
// Update button styles
document.getElementById('btnUnresolved').className = 'btn btn-sm ' + (val === 0 ? 'btn-primary' : 'btn-outline-primary');
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);
// ── Search with debounce ─────────────────────────
let skuSearchTimer = null;
document.getElementById('skuSearch')?.addEventListener('input', function() {
clearTimeout(skuSearchTimer);
skuSearchTimer = setTimeout(() => { currentPage = 1; loadMissingSkus(); }, 300);
});
// ── 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) {
currentPage = page || 1;
try {
const res = await fetch(`/api/validate/missing-skus?page=${currentPage}&per_page=${perPage}&resolved=${currentResolved}`);
const data = await res.json();
const tbody = document.getElementById('missingBody');
function loadMissingSkus(page) {
currentPage = page || currentPage;
const params = new URLSearchParams();
const resolvedVal = resolvedParamFor(skuStatusFilter);
params.set('resolved', resolvedVal);
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 =
`Total: ${data.total || 0} | Pagina: ${data.page || 1} din ${data.pages || 1}`;
}
const skus = data.missing_skus || [];
if (skus.length === 0) {
const msg = currentResolved === 0 ? 'Toate SKU-urile sunt mapate!' :
currentResolved === 1 ? 'Niciun SKU rezolvat' : 'Niciun SKU gasit';
if (!skus || skus.length === 0) {
const msg = skuStatusFilter === 'unresolved' ? 'Toate SKU-urile sunt mapate!' :
skuStatusFilter === 'resolved' ? 'Niciun SKU rezolvat' : 'Niciun SKU gasit';
tbody.innerHTML = `<tr><td colspan="7" class="text-center text-muted py-4">${msg}</td></tr>`;
renderPagination(data);
return;
}
@@ -157,12 +225,6 @@ async function loadMissing(page) {
</td>
</tr>`;
}).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) {
@@ -173,20 +235,20 @@ function renderPagination(data) {
let html = '';
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;
for (let i = 1; i <= total; i++) {
if (i === 1 || i === total || (i >= page - range && i <= page + range)) {
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) {
html += `<li class="page-item disabled"><span class="page-link">…</span></li>`;
}
}
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;
}
@@ -325,7 +387,7 @@ async function saveQuickMap() {
const data = await res.json();
if (data.success) {
bootstrap.Modal.getInstance(document.getElementById('mapModal')).hide();
loadMissing(currentPage);
loadMissingSkus(currentPage);
} else {
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() {
window.location.href = '/api/validate/missing-skus-csv';
}