diff --git a/api/app/database.py b/api/app/database.py index 984f0cb..e8c127c 100644 --- a/api/app/database.py +++ b/api/app/database.py @@ -332,6 +332,7 @@ def init_sqlite(): ("discount_total", "REAL"), ("web_status", "TEXT"), ("discount_split", "TEXT"), + ("price_match", "INTEGER"), ]: if col not in order_cols: conn.execute(f"ALTER TABLE orders ADD COLUMN {col} {typedef}") diff --git a/api/app/main.py b/api/app/main.py index 611cad6..89c15a5 100644 --- a/api/app/main.py +++ b/api/app/main.py @@ -1,3 +1,4 @@ +import asyncio from contextlib import asynccontextmanager from datetime import datetime from fastapi import FastAPI @@ -8,6 +9,7 @@ import os from .config import settings from .database import init_oracle, close_oracle, init_sqlite +from .routers.sync import backfill_price_match # Configure logging with both stream and file handlers _log_level = getattr(logging, settings.LOG_LEVEL.upper(), logging.INFO) @@ -56,6 +58,8 @@ async def lifespan(app: FastAPI): except Exception: pass + asyncio.create_task(backfill_price_match()) + logger.info("GoMag Import Manager started") yield diff --git a/api/app/routers/mappings.py b/api/app/routers/mappings.py index 3fba185..be37943 100644 --- a/api/app/routers/mappings.py +++ b/api/app/routers/mappings.py @@ -146,8 +146,8 @@ async def create_batch_mapping(data: MappingBatchCreate): return {"success": False, "error": str(e)} -@router.get("/api/mappings/{sku}/prices") -async def get_mapping_prices(sku: str): +@router.get("/api/mappings/prices") +async def get_mapping_prices(sku: str = Query(...)): """Get component prices from crm_politici_pret_art for a kit SKU.""" app_settings = await sqlite_service.get_app_settings() id_pol = int(app_settings.get("id_pol") or 0) or None diff --git a/api/app/routers/sync.py b/api/app/routers/sync.py index ae18285..c0bea7c 100644 --- a/api/app/routers/sync.py +++ b/api/app/routers/sync.py @@ -12,13 +12,81 @@ from pydantic import BaseModel from pathlib import Path from typing import Optional -from ..services import sync_service, scheduler_service, sqlite_service, invoice_service +from ..services import sync_service, scheduler_service, sqlite_service, invoice_service, validation_service from .. import database router = APIRouter(tags=["sync"]) templates = Jinja2Templates(directory=str(Path(__file__).parent.parent / "templates")) +async def _enrich_items_with_codmat(items: list) -> None: + """Enrich order items with codmat_details from ARTICOLE_TERTI + NOM_ARTICOLE fallback.""" + skus = {item["sku"] for item in items if item.get("sku")} + if not skus: + return + codmat_map = await asyncio.to_thread(_get_articole_terti_for_skus, skus) + for item in items: + sku = item.get("sku") + if sku and sku in codmat_map: + item["codmat_details"] = codmat_map[sku] + remaining_skus = {item["sku"] for item in items + if item.get("sku") and not item.get("codmat_details")} + if remaining_skus: + nom_map = await asyncio.to_thread(_get_nom_articole_for_direct_skus, remaining_skus) + for item in items: + sku = item.get("sku") + if sku and sku in nom_map and not item.get("codmat_details"): + item["codmat_details"] = [{"codmat": sku, "cantitate_roa": 1, + "denumire": nom_map[sku], "direct": True}] + + +async def backfill_price_match(): + """Background task: check prices for all imported orders without cached price_match.""" + try: + from ..database import get_sqlite + db = await get_sqlite() + try: + cursor = await db.execute(""" + SELECT order_number FROM orders + WHERE status IN ('IMPORTED', 'ALREADY_IMPORTED') + AND price_match IS NULL + ORDER BY order_date DESC + """) + rows = [r["order_number"] for r in await cursor.fetchall()] + finally: + await db.close() + + if not rows: + logger.info("backfill_price_match: no unchecked orders") + return + + logger.info(f"backfill_price_match: checking {len(rows)} orders...") + app_settings = await sqlite_service.get_app_settings() + checked = 0 + + for order_number in rows: + try: + detail = await sqlite_service.get_order_detail(order_number) + if not detail: + continue + items = detail.get("items", []) + await _enrich_items_with_codmat(items) + price_data = await asyncio.to_thread( + validation_service.get_prices_for_order, items, app_settings + ) + summary = price_data.get("summary", {}) + if summary.get("oracle_available") is not False: + pm = summary.get("mismatches", 0) == 0 + await sqlite_service.update_order_price_match(order_number, pm) + checked += 1 + except Exception as e: + logger.debug(f"backfill_price_match: order {order_number} failed: {e}") + + logger.info(f"backfill_price_match: done, {checked}/{len(rows)} updated") + except Exception as e: + logger.error(f"backfill_price_match failed: {e}") + + class ScheduleConfig(BaseModel): enabled: bool interval_minutes: int = 5 @@ -380,33 +448,36 @@ async def order_detail(order_number: str): if not detail: return {"error": "Order not found"} - # Enrich items with ARTICOLE_TERTI mappings from Oracle items = detail.get("items", []) - skus = {item["sku"] for item in items if item.get("sku")} - if skus: - codmat_map = await asyncio.to_thread(_get_articole_terti_for_skus, skus) - for item in items: - sku = item.get("sku") - if sku and sku in codmat_map: - item["codmat_details"] = codmat_map[sku] + await _enrich_items_with_codmat(items) - # Enrich remaining SKUs via NOM_ARTICOLE (fallback for stale mapping_status) - remaining_skus = {item["sku"] for item in items - if item.get("sku") and not item.get("codmat_details")} - if remaining_skus: - nom_map = await asyncio.to_thread(_get_nom_articole_for_direct_skus, remaining_skus) - for item in items: - sku = item.get("sku") - if sku and sku in nom_map and not item.get("codmat_details"): - item["codmat_details"] = [{ - "codmat": sku, - "cantitate_roa": 1, - "denumire": nom_map[sku], - "direct": True - }] + # Price comparison against ROA Oracle + app_settings = await sqlite_service.get_app_settings() + try: + price_data = await asyncio.to_thread( + validation_service.get_prices_for_order, items, app_settings + ) + price_items = price_data.get("items", {}) + for idx, item in enumerate(items): + pi = price_items.get(idx) + if pi: + item["pret_roa"] = pi.get("pret_roa") + item["price_match"] = pi.get("match") + order_price_check = price_data.get("summary", {}) + # Cache price_match in SQLite if changed + if order_price_check.get("oracle_available") is not False: + pm = order_price_check.get("mismatches", 0) == 0 + cached = detail.get("order", {}).get("price_match") + cached_bool = True if cached == 1 else (False if cached == 0 else None) + if cached_bool != pm: + await sqlite_service.update_order_price_match(order_number, pm) + except Exception as e: + logger.warning(f"Price comparison failed for order {order_number}: {e}") + order_price_check = {"mismatches": 0, "checked": 0, "oracle_available": False} # Enrich with invoice data order = detail.get("order", {}) + order["price_check"] = order_price_check if order.get("factura_numar") and order.get("factura_data"): order["invoice"] = { "facturat": True, @@ -438,6 +509,19 @@ async def order_detail(order_number: str): except Exception: pass + # Invoice reconciliation + inv = order.get("invoice") + if inv and inv.get("facturat") and inv.get("total_cu_tva") is not None: + order_total = float(order.get("order_total") or 0) + inv_total = float(inv["total_cu_tva"]) + difference = round(inv_total - order_total, 2) + inv["reconciliation"] = { + "order_total": order_total, + "invoice_total": inv_total, + "difference": difference, + "match": abs(difference) < 0.01, + } + # Parse discount_split JSON string if order.get("discount_split"): try: @@ -445,8 +529,7 @@ async def order_detail(order_number: str): except (json.JSONDecodeError, TypeError): pass - # Add settings for receipt display - app_settings = await sqlite_service.get_app_settings() + # Add settings for receipt display (app_settings already fetched above) order["transport_vat"] = app_settings.get("transport_vat") or "21" order["transport_codmat"] = app_settings.get("transport_codmat") or "" order["discount_codmat"] = app_settings.get("discount_codmat") or "" @@ -454,6 +537,52 @@ async def order_detail(order_number: str): return detail +@router.post("/api/orders/{order_number}/retry") +async def retry_order(order_number: str): + """Retry importing a failed/skipped order.""" + from ..services import retry_service + + app_settings = await sqlite_service.get_app_settings() + result = await retry_service.retry_single_order(order_number, app_settings) + return result + + +@router.get("/api/orders/by-sku/{sku}/pending") +async def get_pending_orders_for_sku(sku: str): + """Get SKIPPED orders that contain the given SKU.""" + order_numbers = await sqlite_service.get_skipped_orders_with_sku(sku) + return {"sku": sku, "order_numbers": order_numbers, "count": len(order_numbers)} + + +@router.post("/api/orders/batch-retry") +async def batch_retry_orders(request: Request): + """Batch retry multiple orders.""" + from ..services import retry_service + body = await request.json() + order_numbers = body.get("order_numbers", []) + if not order_numbers: + return {"success": False, "message": "No orders specified"} + + app_settings = await sqlite_service.get_app_settings() + results = {"imported": 0, "errors": 0, "messages": []} + + for on in order_numbers[:20]: # Limit to 20 to avoid timeout + result = await retry_service.retry_single_order(str(on), app_settings) + if result.get("success"): + results["imported"] += 1 + else: + results["errors"] += 1 + results["messages"].append(f"{on}: {result.get('message', 'Error')}") + + return { + "success": results["imported"] > 0, + "imported": results["imported"], + "errors": results["errors"], + "message": f"{results['imported']} importate, {results['errors']} erori" if results["errors"] else f"{results['imported']} importate cu succes", + "details": results["messages"][:5], + } + + @router.get("/api/dashboard/orders") async def dashboard_orders(page: int = 1, per_page: int = 50, search: str = "", status: str = "all", @@ -484,6 +613,9 @@ async def dashboard_orders(page: int = 1, per_page: int = 50, # Enrich orders with invoice data — prefer SQLite cache, fallback to Oracle all_orders = result["orders"] for o in all_orders: + # price_match: 1=OK, 0=mismatch, NULL=not checked yet + pm = o.get("price_match") + o["price_match"] = True if pm == 1 else (False if pm == 0 else None) if o.get("factura_numar") and o.get("factura_data"): # Use cached invoice data from SQLite (only if complete) o["invoice"] = { @@ -534,9 +666,8 @@ async def dashboard_orders(page: int = 1, per_page: int = 50, # Use counts from sqlite_service (already period-scoped) counts = result.get("counts", {}) - # Count newly-cached invoices found during this request + # Adjust uninvoiced count for invoices discovered via Oracle during this request newly_invoiced = sum(1 for o in uncached_orders if o.get("invoice") and o["invoice"].get("facturat")) - # Adjust uninvoiced count: start from SQLite count, subtract newly-found invoices uninvoiced_base = counts.get("uninvoiced_sqlite", sum( 1 for o in all_orders if o.get("status") in ("IMPORTED", "ALREADY_IMPORTED") and not o.get("invoice") @@ -546,6 +677,13 @@ async def dashboard_orders(page: int = 1, per_page: int = 50, counts["facturate"] = max(0, imported_total - counts["nefacturate"]) counts.setdefault("total", counts.get("imported", 0) + counts.get("skipped", 0) + counts.get("error", 0)) + # Attention metrics: add unresolved SKUs count + try: + stats = await sqlite_service.get_dashboard_stats() + counts["unresolved_skus"] = stats.get("unresolved_skus", 0) + except Exception: + counts["unresolved_skus"] = 0 + # For UNINVOICED filter: apply server-side filtering + pagination if is_uninvoiced_filter: filtered = [o for o in all_orders if o.get("status") in ("IMPORTED", "ALREADY_IMPORTED") and not o.get("invoice")] diff --git a/api/app/services/retry_service.py b/api/app/services/retry_service.py new file mode 100644 index 0000000..2f72d14 --- /dev/null +++ b/api/app/services/retry_service.py @@ -0,0 +1,131 @@ +"""Retry service — re-import individual failed/skipped orders.""" +import asyncio +import logging +import tempfile +from datetime import datetime, timedelta + +logger = logging.getLogger(__name__) + + +async def retry_single_order(order_number: str, app_settings: dict) -> dict: + """Re-download and re-import a single order from GoMag. + + Steps: + 1. Read order from SQLite to get order_date / customer_name + 2. Check sync lock (no retry during active sync) + 3. Download narrow date range from GoMag (order_date ± 1 day) + 4. Find the specific order in downloaded data + 5. Run import_single_order() + 6. Update status in SQLite + + Returns: {"success": bool, "message": str, "status": str|None} + """ + from . import sqlite_service, sync_service, gomag_client, import_service, order_reader + + # Check sync lock + if sync_service._sync_lock.locked(): + return {"success": False, "message": "Sync in curs — asteapta finalizarea"} + + # Get order from SQLite + detail = await sqlite_service.get_order_detail(order_number) + if not detail: + return {"success": False, "message": "Comanda nu a fost gasita"} + + order_data = detail["order"] + status = order_data.get("status", "") + if status not in ("ERROR", "SKIPPED"): + return {"success": False, "message": f"Retry permis doar pentru ERROR/SKIPPED (status actual: {status})"} + + order_date_str = order_data.get("order_date", "") + customer_name = order_data.get("customer_name", "") + + # Parse order date for narrow download window + try: + order_date = datetime.fromisoformat(order_date_str.replace("Z", "+00:00")).date() + except (ValueError, AttributeError): + order_date = datetime.now().date() - timedelta(days=1) + + gomag_key = app_settings.get("gomag_api_key") or None + gomag_shop = app_settings.get("gomag_api_shop") or None + + with tempfile.TemporaryDirectory() as tmp_dir: + try: + today = datetime.now().date() + days_back = (today - order_date).days + 1 + if days_back < 2: + days_back = 2 + + await gomag_client.download_orders( + tmp_dir, days_back=days_back, + api_key=gomag_key, api_shop=gomag_shop, + limit=200, + ) + except Exception as e: + logger.error(f"Retry download failed for {order_number}: {e}") + return {"success": False, "message": f"Eroare download GoMag: {e}"} + + # Find the specific order in downloaded data + target_order = None + orders, _ = order_reader.read_json_orders(json_dir=tmp_dir) + for o in orders: + if str(o.number) == str(order_number): + target_order = o + break + + if not target_order: + return {"success": False, "message": f"Comanda {order_number} nu a fost gasita in GoMag API"} + + # Import the order + id_pol = int(app_settings.get("id_pol") or 0) + id_sectie = int(app_settings.get("id_sectie") or 0) + id_gestiune = app_settings.get("id_gestiune", "") + id_gestiuni = [int(g.strip()) for g in id_gestiune.split(",") if g.strip()] if id_gestiune else None + + try: + result = await asyncio.to_thread( + import_service.import_single_order, + target_order, id_pol=id_pol, id_sectie=id_sectie, + app_settings=app_settings, id_gestiuni=id_gestiuni + ) + except Exception as e: + logger.error(f"Retry import failed for {order_number}: {e}") + await sqlite_service.upsert_order( + sync_run_id="retry", + order_number=order_number, + order_date=order_date_str, + customer_name=customer_name, + status="ERROR", + error_message=f"Retry failed: {e}", + ) + return {"success": False, "message": f"Eroare import: {e}"} + + if result.get("success"): + await sqlite_service.upsert_order( + sync_run_id="retry", + order_number=order_number, + order_date=order_date_str, + customer_name=customer_name, + status="IMPORTED", + id_comanda=result.get("id_comanda"), + id_partener=result.get("id_partener"), + error_message=None, + ) + if result.get("id_adresa_facturare") or result.get("id_adresa_livrare"): + await sqlite_service.update_import_order_addresses( + order_number=order_number, + id_adresa_facturare=result.get("id_adresa_facturare"), + id_adresa_livrare=result.get("id_adresa_livrare"), + ) + logger.info(f"Retry successful for order {order_number} → IMPORTED") + return {"success": True, "message": "Comanda reimportata cu succes", "status": "IMPORTED"} + else: + error = result.get("error", "Unknown error") + await sqlite_service.upsert_order( + sync_run_id="retry", + order_number=order_number, + order_date=order_date_str, + customer_name=customer_name, + status="ERROR", + error_message=f"Retry: {error}", + ) + return {"success": False, "message": f"Import esuat: {error}", "status": "ERROR"} diff --git a/api/app/services/sqlite_service.py b/api/app/services/sqlite_service.py index 237db92..df46c29 100644 --- a/api/app/services/sqlite_service.py +++ b/api/app/services/sqlite_service.py @@ -739,6 +739,16 @@ async def get_orders(page: int = 1, per_page: int = 50, cursor = await db.execute(f"SELECT COUNT(*) FROM orders {uninv_where}", base_params) uninvoiced_sqlite = (await cursor.fetchone())[0] + # Uninvoiced > 3 days old + uninv_old_clauses = list(base_clauses) + [ + "UPPER(status) IN ('IMPORTED', 'ALREADY_IMPORTED')", + "(factura_numar IS NULL OR factura_numar = '')", + "order_date < datetime('now', '-3 days')", + ] + uninv_old_where = "WHERE " + " AND ".join(uninv_old_clauses) + cursor = await db.execute(f"SELECT COUNT(*) FROM orders {uninv_old_where}", base_params) + uninvoiced_old = (await cursor.fetchone())[0] + return { "orders": [dict(r) for r in rows], "total": total, @@ -754,6 +764,7 @@ async def get_orders(page: int = 1, per_page: int = 50, "cancelled": status_counts.get("CANCELLED", 0), "total": sum(status_counts.values()), "uninvoiced_sqlite": uninvoiced_sqlite, + "uninvoiced_old": uninvoiced_old, } } finally: @@ -820,6 +831,20 @@ async def update_order_invoice(order_number: str, serie: str = None, await db.close() +async def update_order_price_match(order_number: str, match: bool | None): + """Cache price_match result (True=OK, False=mismatch, None=unavailable).""" + db = await get_sqlite() + try: + val = None if match is None else (1 if match else 0) + await db.execute( + "UPDATE orders SET price_match = ?, updated_at = datetime('now') WHERE order_number = ?", + (val, order_number), + ) + await db.commit() + finally: + await db.close() + + async def get_invoiced_imported_orders() -> list: """Get imported orders that HAVE cached invoice data (for re-verification).""" db = await get_sqlite() @@ -949,6 +974,24 @@ async def set_app_setting(key: str, value: str): await db.close() +# ── SKU-based order lookup ──────────────────────── + +async def get_skipped_orders_with_sku(sku: str) -> list[str]: + """Get order_numbers of SKIPPED orders that contain the given SKU.""" + db = await get_sqlite() + try: + cursor = await db.execute(""" + SELECT DISTINCT oi.order_number + FROM order_items oi + JOIN orders o ON o.order_number = oi.order_number + WHERE oi.sku = ? AND o.status = 'SKIPPED' + """, (sku,)) + rows = await cursor.fetchall() + return [row[0] for row in rows] + finally: + await db.close() + + # ── Price Sync Runs ─────────────────────────────── async def get_price_sync_runs(page: int = 1, per_page: int = 20): diff --git a/api/app/services/validation_service.py b/api/app/services/validation_service.py index 317e45d..6bc3bb8 100644 --- a/api/app/services/validation_service.py +++ b/api/app/services/validation_service.py @@ -586,3 +586,189 @@ def sync_prices_from_order(orders, mapped_codmat_data: dict, direct_id_map: dict database.pool.release(conn) return updated + + +def get_prices_for_order(items: list[dict], app_settings: dict, conn=None) -> dict: + """Compare GoMag prices with ROA prices for order items. + + Args: + items: list of order items, each with 'sku', 'price', 'quantity', 'codmat_details' + (codmat_details = [{"codmat", "cantitate_roa", "id_articol"?, "cont"?, "direct"?}]) + app_settings: dict with 'id_pol', 'id_pol_productie' + conn: Oracle connection (optional, will acquire if None) + + Returns: { + "items": {idx: {"pret_roa": float|None, "match": bool|None, "pret_gomag": float}}, + "summary": {"mismatches": int, "checked": int, "oracle_available": bool} + } + """ + try: + id_pol = int(app_settings.get("id_pol", 0) or 0) + id_pol_productie = int(app_settings.get("id_pol_productie", 0) or 0) + except (ValueError, TypeError): + id_pol = 0 + id_pol_productie = 0 + + def _empty_result(oracle_available: bool) -> dict: + return { + "items": { + idx: {"pret_roa": None, "match": None, "pret_gomag": float(item.get("price") or 0)} + for idx, item in enumerate(items) + }, + "summary": {"mismatches": 0, "checked": 0, "oracle_available": oracle_available} + } + + if not items or not id_pol: + return _empty_result(oracle_available=False) + + own_conn = conn is None + try: + if own_conn: + conn = database.get_oracle_connection() + + # Step 1: Collect codmats; use id_articol/cont from codmat_details when already known + pre_resolved = {} # {codmat: {"id_articol": int, "cont": str}} + all_codmats = set() + for item in items: + for cd in (item.get("codmat_details") or []): + codmat = cd.get("codmat") + if not codmat: + continue + all_codmats.add(codmat) + if cd.get("id_articol") and codmat not in pre_resolved: + pre_resolved[codmat] = { + "id_articol": cd["id_articol"], + "cont": cd.get("cont") or "", + } + + # Step 2: Resolve missing id_articols via nom_articole + need_resolve = all_codmats - set(pre_resolved.keys()) + if need_resolve: + db_resolved = resolve_codmat_ids(need_resolve, conn=conn) + pre_resolved.update(db_resolved) + + codmat_info = pre_resolved # {codmat: {"id_articol": int, "cont": str}} + + # Step 3: Get PRETURI_CU_TVA flag once per policy + policies = {id_pol} + if id_pol_productie and id_pol_productie != id_pol: + policies.add(id_pol_productie) + + pol_cu_tva = {} # {id_pol: bool} + with conn.cursor() as cur: + for pol in policies: + cur.execute( + "SELECT PRETURI_CU_TVA FROM CRM_POLITICI_PRETURI WHERE ID_POL = :pol", + {"pol": pol}, + ) + row = cur.fetchone() + pol_cu_tva[pol] = (int(row[0] or 0) == 1) if row else False + + # Step 4: Batch query PRET + PROC_TVAV for all id_articols across both policies + all_id_articols = list({ + info["id_articol"] + for info in codmat_info.values() + if info.get("id_articol") + }) + price_map = {} # {(id_pol, id_articol): (pret, proc_tvav)} + + if all_id_articols: + pol_list = list(policies) + pol_placeholders = ",".join([f":p{k}" for k in range(len(pol_list))]) + with conn.cursor() as cur: + for i in range(0, len(all_id_articols), 500): + batch = all_id_articols[i:i + 500] + art_placeholders = ",".join([f":a{j}" for j in range(len(batch))]) + params = {f"a{j}": aid for j, aid in enumerate(batch)} + for k, pol in enumerate(pol_list): + params[f"p{k}"] = pol + cur.execute(f""" + SELECT ID_POL, ID_ARTICOL, PRET, PROC_TVAV + FROM CRM_POLITICI_PRET_ART + WHERE ID_POL IN ({pol_placeholders}) AND ID_ARTICOL IN ({art_placeholders}) + """, params) + for row in cur: + price_map[(row[0], row[1])] = (row[2], row[3]) + + # Step 5: Compute pret_roa per item and compare with GoMag price + result_items = {} + mismatches = 0 + checked = 0 + + for idx, item in enumerate(items): + pret_gomag = float(item.get("price") or 0) + result_items[idx] = {"pret_gomag": pret_gomag, "pret_roa": None, "match": None} + + codmat_details = item.get("codmat_details") or [] + if not codmat_details: + continue + + is_kit = len(codmat_details) > 1 or ( + len(codmat_details) == 1 + and float(codmat_details[0].get("cantitate_roa") or 1) > 1 + ) + + pret_roa_total = 0.0 + all_resolved = True + + for cd in codmat_details: + codmat = cd.get("codmat") + if not codmat: + all_resolved = False + break + + info = codmat_info.get(codmat, {}) + id_articol = info.get("id_articol") + if not id_articol: + all_resolved = False + break + + # Dual-policy routing: cont 341/345 → production, else → sales + cont = str(info.get("cont") or cd.get("cont") or "").strip() + if cont in ("341", "345") and id_pol_productie: + pol = id_pol_productie + else: + pol = id_pol + + price_entry = price_map.get((pol, id_articol)) + if price_entry is None: + all_resolved = False + break + + pret, proc_tvav = price_entry + proc_tvav = float(proc_tvav or 1.19) + + if pol_cu_tva.get(pol): + pret_cu_tva = float(pret or 0) + else: + pret_cu_tva = float(pret or 0) * proc_tvav + + cantitate_roa = float(cd.get("cantitate_roa") or 1) + if is_kit: + pret_roa_total += pret_cu_tva * cantitate_roa + else: + pret_roa_total = pret_cu_tva # cantitate_roa==1 for simple items + + if not all_resolved: + continue + + pret_roa = round(pret_roa_total, 4) + match = abs(pret_gomag - pret_roa) < 0.01 + result_items[idx]["pret_roa"] = pret_roa + result_items[idx]["match"] = match + checked += 1 + if not match: + mismatches += 1 + + logger.info(f"get_prices_for_order: {checked}/{len(items)} checked, {mismatches} mismatches") + return { + "items": result_items, + "summary": {"mismatches": mismatches, "checked": checked, "oracle_available": True}, + } + + except Exception as e: + logger.error(f"get_prices_for_order failed: {e}") + return _empty_result(oracle_available=False) + finally: + if own_conn and conn: + database.pool.release(conn) diff --git a/api/app/static/css/style.css b/api/app/static/css/style.css index a0c1e45..d8adcb6 100644 --- a/api/app/static/css/style.css +++ b/api/app/static/css/style.css @@ -1,49 +1,173 @@ -/* ── Design tokens ───────────────────────────────── */ +/* ── Design tokens (DESIGN.md) ───────────────────── */ :root { + /* Fonts */ + --font-display: 'Space Grotesk', sans-serif; + --font-body: 'DM Sans', sans-serif; + --font-data: 'JetBrains Mono', monospace; + /* Surfaces */ - --body-bg: #f9fafb; - --card-bg: #ffffff; - --card-shadow: 0 1px 3px rgba(0,0,0,0.1), 0 1px 2px rgba(0,0,0,0.06); + --bg: #F8F7F5; + --surface: #FFFFFF; + --surface-raised: #F3F2EF; + --card-shadow: 0 1px 3px rgba(28,25,23,0.1), 0 1px 2px rgba(28,25,23,0.06); --card-radius: 0.5rem; - /* Semantic colors */ - --blue-600: #2563eb; - --blue-700: #1d4ed8; - --green-100: #dcfce7; --green-800: #166534; - --yellow-100: #fef9c3; --yellow-800: #854d0e; - --red-100: #fee2e2; --red-800: #991b1b; - --blue-100: #dbeafe; --blue-800: #1e40af; - /* Text */ - --text-primary: #111827; - --text-secondary: #4b5563; - --text-muted: #6b7280; - --border-color: #e5e7eb; + --text-primary: #1C1917; + --text-secondary: #57534E; + --text-muted: #78716C; - /* Dots */ - --dot-green: #22c55e; - --dot-yellow: #eab308; - --dot-red: #ef4444; + /* Borders */ + --border: #E7E5E4; + --border-subtle: #F0EFED; + + /* Accent — amber (state: nav active, filter pills) */ + --accent: #D97706; + --accent-hover: #B45309; + --accent-light: #FEF3C7; + --accent-text: #92400E; + + /* Semantic */ + --success: #16A34A; + --success-light: #DCFCE7; + --success-text: #166534; + + --warning: #CA8A04; + --warning-light: #FEF9C3; + --warning-text: #854D0E; + + --error: #DC2626; + --error-light: #FEE2E2; + --error-text: #991B1B; + + --info: #2563EB; + --info-hover: #1D4ED8; + --info-light: #DBEAFE; + --info-text: #1E40AF; + + --cancelled: #78716C; + --cancelled-light: #F5F5F4; + + /* Border radius */ + --radius-sm: 4px; + --radius-md: 8px; + --radius-lg: 12px; + --radius-full: 9999px; } +/* ── Dark mode ──────────────────────────────────── */ +[data-theme="dark"] { + --bg: #121212; + --surface: #1E1E1E; + --surface-raised: #2A2A2A; + --card-shadow: 0 1px 3px rgba(0,0,0,0.4), 0 1px 2px rgba(0,0,0,0.3); + + --text-primary: #E8E4DD; + --text-secondary: #A8A29E; + --text-muted: #78716C; + + --border: #333333; + --border-subtle: #262626; + + --accent: #F59E0B; + --accent-hover: #D97706; + --accent-light: rgba(245,158,11,0.12); + --accent-text: #FCD34D; + + --success: #16A34A; + --success-light: rgba(22,163,74,0.15); + --success-text: #4ADE80; + + --warning: #CA8A04; + --warning-light: rgba(202,138,4,0.15); + --warning-text: #FACC15; + + --error: #DC2626; + --error-light: rgba(220,38,38,0.15); + --error-text: #FCA5A5; + + --info: #2563EB; + --info-hover: #3B82F6; + --info-light: rgba(37,99,235,0.15); + --info-text: #93C5FD; + + --cancelled: #78716C; + --cancelled-light: rgba(120,113,108,0.15); +} + +/* Dark mode overrides for elements with hardcoded colors */ +[data-theme="dark"] body { color-scheme: dark; } +[data-theme="dark"] .top-navbar { background: var(--surface); border-bottom-color: var(--border); } +[data-theme="dark"] .navbar-brand { color: var(--text-primary); } +[data-theme="dark"] .nav-tab { color: var(--text-muted); } +[data-theme="dark"] .nav-tab:hover { color: var(--text-primary); background: var(--surface-raised); } +[data-theme="dark"] .table { --bs-table-bg: var(--surface); --bs-table-color: var(--text-secondary); --bs-table-border-color: var(--border); --bs-table-striped-bg: var(--surface-raised); --bs-table-hover-bg: rgba(37,99,235,0.1); } +[data-theme="dark"] .table th { background: var(--surface-raised); color: var(--text-muted); } +[data-theme="dark"] .table td { color: var(--text-secondary); background-color: var(--surface); } +[data-theme="dark"] .table tbody tr:nth-child(even) td { background-color: var(--surface-raised); } +[data-theme="dark"] .table-hover tbody tr:hover td { background-color: rgba(37,99,235,0.1) !important; } +[data-theme="dark"] .card { background: var(--surface); color: var(--text-primary); border-color: var(--border); } +[data-theme="dark"] .card-header { background: var(--surface); border-bottom-color: var(--border); } +[data-theme="dark"] .flat-row { border-bottom-color: var(--border-subtle); } +[data-theme="dark"] .flat-row:hover { background: var(--surface-raised); } +[data-theme="dark"] .filter-pill { background: var(--surface); border-color: var(--border); color: var(--text-secondary); } +[data-theme="dark"] .filter-pill:hover { background: var(--surface-raised); } +[data-theme="dark"] .page-btn { background: var(--surface); border-color: var(--border); color: var(--text-secondary); } +[data-theme="dark"] .page-btn:hover:not(:disabled):not(.active) { background: var(--surface-raised); border-color: var(--text-muted); color: var(--text-primary); } +[data-theme="dark"] .page-btn.active { background: var(--info); border-color: var(--info); } +[data-theme="dark"] .form-control, [data-theme="dark"] .form-select { background: var(--surface-raised); border-color: var(--border); color: var(--text-primary); } +[data-theme="dark"] .form-control:focus, [data-theme="dark"] .form-select:focus { border-color: var(--info); } +[data-theme="dark"] .sync-card { background: var(--surface); border-color: var(--border); } +[data-theme="dark"] .sync-card-info:hover { background: var(--surface-raised); } +[data-theme="dark"] .sync-card-progress { background: var(--info-light); color: var(--info-text); border-top-color: var(--border); } +[data-theme="dark"] .autocomplete-dropdown { background: var(--surface); border-color: var(--border); } +[data-theme="dark"] .autocomplete-item:hover, [data-theme="dark"] .autocomplete-item.active { background-color: var(--surface-raised); } +[data-theme="dark"] .autocomplete-item .codmat { color: var(--text-primary); } +[data-theme="dark"] .autocomplete-item .denumire { color: var(--text-muted); } +[data-theme="dark"] .context-menu { background: var(--surface); border-color: var(--border); } +[data-theme="dark"] .context-menu-item { color: var(--text-primary); } +[data-theme="dark"] .context-menu-item:hover { background: var(--surface-raised); } +[data-theme="dark"] .spinner-overlay { background: rgba(18,18,18,0.7); } +[data-theme="dark"] .modal-content { background: var(--surface); color: var(--text-primary); } +[data-theme="dark"] .modal-header { border-bottom-color: var(--border); } +[data-theme="dark"] .modal-footer { border-top-color: var(--border); } +[data-theme="dark"] .detail-item-card { border-color: var(--border); } +[data-theme="dark"] .select-compact { background: var(--surface); border-color: var(--border); color: var(--text-secondary); } +[data-theme="dark"] .search-input { background: var(--surface-raised); border-color: var(--border); color: var(--text-primary); } +[data-theme="dark"] .editable:hover { background-color: var(--surface-raised); } +[data-theme="dark"] .sortable:hover { background-color: var(--surface-raised); } +[data-theme="dark"] .result-banner { background: var(--success-light); color: var(--success-text); border-color: var(--success); } +[data-theme="dark"] .badge-pct.complete { background: var(--success-light); color: var(--success-text); } +[data-theme="dark"] .badge-pct.incomplete { background: var(--warning-light); color: var(--warning-text); } +[data-theme="dark"] .table-light { --bs-table-bg: var(--surface-raised); } +[data-theme="dark"] .bottom-nav { background: var(--surface); border-top-color: var(--border); } +[data-theme="dark"] .qm-line { border-bottom-color: var(--border-subtle); } + /* ── Base ────────────────────────────────────────── */ body { - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; + font-family: var(--font-body); font-size: 1rem; - background-color: var(--body-bg); + background-color: var(--bg); + color: var(--text-primary); margin: 0; padding: 0; } h1, h2, h3, h4, h5, h6 { + font-family: var(--font-display); text-wrap: balance; } +/* Data font — selective: codes, numbers, sums, dates. NOT text names. */ +.font-data, code, .dif-sku, .detail-item-card .card-sku { + font-family: var(--font-data); +} + /* ── Checkboxes — accessible size ────────────────── */ input[type="checkbox"] { width: 1.125rem; height: 1.125rem; - accent-color: var(--blue-600); + accent-color: var(--info); cursor: pointer; } @@ -54,8 +178,8 @@ input[type="checkbox"] { left: 0; right: 0; height: 48px; - background: #fff; - border-bottom: 1px solid var(--border-color); + background: var(--surface); + border-bottom: 1px solid var(--border); display: flex; align-items: center; padding: 0 1.5rem; @@ -65,9 +189,10 @@ input[type="checkbox"] { } .navbar-brand { + font-family: var(--font-display); font-weight: 700; font-size: 1rem; - color: #111827; + color: var(--text-primary); white-space: nowrap; } @@ -78,6 +203,7 @@ input[type="checkbox"] { overflow-x: auto; -webkit-overflow-scrolling: touch; scrollbar-width: none; + flex: 1; } .navbar-links::-webkit-scrollbar { display: none; } @@ -86,7 +212,7 @@ input[type="checkbox"] { align-items: center; padding: 0 1rem; height: 48px; - color: #64748b; + color: var(--text-muted); text-decoration: none; font-size: 0.9375rem; font-weight: 500; @@ -96,15 +222,30 @@ input[type="checkbox"] { transition: color 0.15s, border-color 0.15s; } .nav-tab:hover { - color: #111827; - background: #f9fafb; + color: var(--text-primary); + background: var(--surface-raised); text-decoration: none; } .nav-tab.active { - color: var(--blue-600); - border-bottom-color: var(--blue-600); + color: var(--accent); + border-bottom-color: var(--accent); } +/* Dark toggle button in navbar */ +.dark-toggle { + background: none; + border: none; + color: var(--text-muted); + cursor: pointer; + padding: 0.25rem; + font-size: 1.125rem; + line-height: 1; + border-radius: var(--radius-sm); + transition: color 0.15s; + flex-shrink: 0; +} +.dark-toggle:hover { color: var(--text-primary); } + /* ── Main content ────────────────────────────────── */ .main-content { padding-top: 64px; @@ -112,22 +253,27 @@ input[type="checkbox"] { padding-right: 1.5rem; padding-bottom: 1.5rem; min-height: 100vh; - max-width: 1280px; margin-left: auto; margin-right: auto; } +/* Non-table pages: constrained width */ +.main-content.constrained { + max-width: 1200px; +} + /* ── Cards ───────────────────────────────────────── */ .card { border: none; box-shadow: var(--card-shadow); border-radius: var(--card-radius); - background: var(--card-bg); + background: var(--surface); } .card-header { - background: var(--card-bg); - border-bottom: 1px solid var(--border-color); + background: var(--surface); + border-bottom: 1px solid var(--border); + font-family: var(--font-display); font-weight: 600; font-size: 0.9375rem; padding: 0.75rem 1rem; @@ -139,12 +285,13 @@ input[type="checkbox"] { } .table th { - font-size: 0.8125rem; + font-family: var(--font-display); + font-size: 0.75rem; font-weight: 500; text-transform: uppercase; - letter-spacing: 0.05em; + letter-spacing: 0.04em; color: var(--text-muted); - background: #f9fafb; + background: var(--surface-raised); padding: 0.75rem 1rem; border-top: none; } @@ -157,28 +304,28 @@ input[type="checkbox"] { } /* Zebra striping */ -.table tbody tr:nth-child(even) td { background-color: #f7f8fa; } -.table-hover tbody tr:hover td { background-color: #eef2ff !important; } +.table tbody tr:nth-child(even) td { background-color: var(--surface-raised); } +.table-hover tbody tr:hover td { background-color: rgba(37, 99, 235, 0.08) !important; } /* ── Badges — soft pill style ────────────────────── */ .badge { font-size: 0.8125rem; font-weight: 500; padding: 0.125rem 0.5rem; - border-radius: 9999px; + border-radius: var(--radius-full); } -.badge.bg-success { background: var(--green-100) !important; color: var(--green-800) !important; } -.badge.bg-info { background: var(--blue-100) !important; color: var(--blue-800) !important; } -.badge.bg-warning { background: var(--yellow-100) !important; color: var(--yellow-800) !important; } -.badge.bg-danger { background: var(--red-100) !important; color: var(--red-800) !important; } +.badge.bg-success { background: var(--success-light) !important; color: var(--success-text) !important; } +.badge.bg-info { background: var(--info-light) !important; color: var(--info-text) !important; } +.badge.bg-warning { background: var(--warning-light) !important; color: var(--warning-text) !important; } +.badge.bg-danger { background: var(--error-light) !important; color: var(--error-text) !important; } /* Legacy badge classes */ -.badge-imported { background: var(--green-100); color: var(--green-800); } -.badge-skipped { background: var(--yellow-100); color: var(--yellow-800); } -.badge-error { background: var(--red-100); color: var(--red-800); } -.badge-pending { background: #f3f4f6; color: #374151; } -.badge-ready { background: var(--blue-100); color: var(--blue-800); } +.badge-imported { background: var(--success-light); color: var(--success-text); } +.badge-skipped { background: var(--warning-light); color: var(--warning-text); } +.badge-error { background: var(--error-light); color: var(--error-text); } +.badge-pending { background: var(--surface-raised); color: var(--text-secondary); } +.badge-ready { background: var(--info-light); color: var(--info-text); } /* ── Buttons ─────────────────────────────────────── */ .btn { @@ -192,12 +339,12 @@ input[type="checkbox"] { } .btn-primary { - background: var(--blue-600); - border-color: var(--blue-600); + background: var(--info); + border-color: var(--info); } .btn-primary:hover { - background: var(--blue-700); - border-color: var(--blue-700); + background: var(--info-hover); + border-color: var(--info-hover); } /* ── Forms ───────────────────────────────────────── */ @@ -205,11 +352,11 @@ input[type="checkbox"] { font-size: 0.9375rem; padding: 0.5rem 0.75rem; border-radius: 0.375rem; - border-color: #d1d5db; + border-color: var(--border); } .form-control:focus, .form-select:focus { - border-color: var(--blue-600); + border-color: var(--info); box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.2); } @@ -229,9 +376,9 @@ input[type="checkbox"] { height: 2.75rem; padding: 0 0.5rem; font-size: 0.875rem; - border: 1px solid #d1d5db; + border: 1px solid var(--border); border-radius: 0.375rem; - background: #fff; + background: var(--surface); color: var(--text-secondary); cursor: pointer; transition: background 0.12s, border-color 0.12s; @@ -239,14 +386,14 @@ input[type="checkbox"] { user-select: none; } .page-btn:hover:not(:disabled):not(.active) { - background: #f3f4f6; - border-color: #9ca3af; + background: var(--surface-raised); + border-color: var(--text-muted); color: var(--text-primary); text-decoration: none; } .page-btn.active { - background: var(--blue-600); - border-color: var(--blue-600); + background: var(--info); + border-color: var(--info); color: #fff; font-weight: 600; } @@ -275,11 +422,11 @@ input[type="checkbox"] { border-radius: 50%; flex-shrink: 0; } -.dot-green { background: var(--dot-green); } -.dot-yellow { background: var(--dot-yellow); } -.dot-red { background: var(--dot-red); } -.dot-gray { background: #9ca3af; } -.dot-blue { background: #3b82f6; } +.dot-green { background: var(--success); } +.dot-yellow { background: var(--warning); box-shadow: 0 0 6px 2px rgba(202,138,4,0.3); } +.dot-red { background: var(--error); box-shadow: 0 0 8px 2px rgba(220,38,38,0.35); } +.dot-gray { background: var(--cancelled); } +.dot-blue { background: var(--info); } /* ── Flat row (mobile + desktop) ────────────────── */ .flat-row { @@ -287,26 +434,27 @@ input[type="checkbox"] { align-items: center; gap: 0.5rem; padding: 0.5rem 0.75rem; - border-bottom: 1px solid #f3f4f6; + border-bottom: 1px solid var(--border-subtle); font-size: 1rem; + color: var(--text-primary); } .flat-row:last-child { border-bottom: none; } -.flat-row:hover { background: #f9fafb; cursor: pointer; } +.flat-row:hover { background: var(--surface-raised); cursor: pointer; } .grow { flex: 1; min-width: 0; } .truncate { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } /* ── Colored filter count - text color only ─────── */ -.fc-green { color: #16a34a; } -.fc-yellow { color: #ca8a04; } -.fc-red { color: #dc2626; } -.fc-neutral { color: #6b7280; } -.fc-blue { color: #2563eb; } -.fc-dark { color: #374151; } +.fc-green { color: var(--success); } +.fc-yellow { color: var(--warning); } +.fc-red { color: var(--error); } +.fc-neutral { color: var(--text-muted); } +.fc-blue { color: var(--info); } +.fc-dark { color: var(--text-secondary); } /* ── Log viewer (dark theme — keep as-is) ────────── */ .log-viewer { - font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; + font-family: var(--font-data); font-size: 0.8125rem; line-height: 1.5; max-height: 600px; @@ -325,7 +473,7 @@ input[type="checkbox"] { cursor: pointer; } .table-hover tbody tr[data-href]:hover { - background-color: #f9fafb; + background-color: var(--surface-raised); } /* ── Sortable table headers ──────────────────────── */ @@ -334,22 +482,22 @@ input[type="checkbox"] { user-select: none; } .sortable:hover { - background-color: #f3f4f6; + background-color: var(--surface-raised); } .sort-icon { font-size: 0.75rem; margin-left: 0.25rem; - color: var(--blue-600); + color: var(--info); } /* ── SKU group visual grouping ───────────────────── */ .sku-group-odd { - background-color: #f8fafc; + background-color: var(--surface-raised); } /* ── Editable cells ──────────────────────────────── */ .editable { cursor: pointer; } -.editable:hover { background-color: #f3f4f6; } +.editable:hover { background-color: var(--surface-raised); } /* ── Order detail modal ──────────────────────────── */ .modal-lg .table-sm td, @@ -364,7 +512,7 @@ input[type="checkbox"] { .modal-backdrop ~ .modal-backdrop { z-index: 1055; } /* ── Quick Map compact lines ─────────────────────── */ -.qm-line { border-bottom: 1px solid #e5e7eb; padding: 6px 0; } +.qm-line { border-bottom: 1px solid var(--border); padding: 6px 0; } .qm-line:last-child { border-bottom: none; } .qm-row { display: flex; gap: 6px; align-items: center; } .qm-codmat-wrap { flex: 1; min-width: 0; } @@ -388,17 +536,17 @@ tr.mapping-deleted td { /* ── Map icon button ─────────────────────────────── */ .btn-map-icon { - color: var(--blue-600); + color: var(--info); padding: 0.1rem 0.25rem; cursor: pointer; font-size: 1rem; text-decoration: none; } -.btn-map-icon:hover { color: var(--blue-700); } +.btn-map-icon:hover { color: var(--info-hover); } /* ── Last sync summary card columns ─────────────── */ .last-sync-col { - border-right: 1px solid var(--border-color); + border-right: 1px solid var(--border); } /* ── Cursor pointer utility ──────────────────────── */ @@ -418,18 +566,19 @@ tr.mapping-deleted td { align-items: center; gap: 0.3rem; padding: 0.5rem 0.75rem; - border: 1px solid #d1d5db; + border: 1px solid var(--border); border-radius: 0.375rem; - background: #fff; + background: var(--surface); font-size: 0.9375rem; cursor: pointer; transition: background 0.15s, border-color 0.15s; white-space: nowrap; + color: var(--text-secondary); } -.filter-pill:hover { background: #f3f4f6; } +.filter-pill:hover { background: var(--surface-raised); } .filter-pill.active { - background: var(--blue-700); - border-color: var(--blue-700); + background: var(--accent); + border-color: var(--accent); color: #fff; } .filter-pill.active .filter-count { @@ -444,22 +593,22 @@ tr.mapping-deleted td { /* ── Search input ────────────────────────────────── */ .search-input { padding: 0.375rem 0.75rem; - border: 1px solid #d1d5db; + border: 1px solid var(--border); border-radius: 0.375rem; font-size: 0.9375rem; width: 160px; } .search-input:focus { - border-color: var(--blue-600); + border-color: var(--info); box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.2); } -/* ── Autocomplete dropdown (keep as-is) ──────────── */ +/* ── Autocomplete dropdown ──────────────────────── */ .autocomplete-dropdown { position: absolute; z-index: 1050; - background: #fff; - border: 1px solid #dee2e6; + background: var(--surface); + border: 1px solid var(--border); border-radius: 0.375rem; box-shadow: 0 4px 12px rgba(0,0,0,0.15); max-height: 300px; @@ -470,17 +619,17 @@ tr.mapping-deleted td { padding: 0.5rem 0.75rem; cursor: pointer; font-size: 0.9375rem; - border-bottom: 1px solid #f1f5f9; + border-bottom: 1px solid var(--border-subtle); } .autocomplete-item:hover, .autocomplete-item.active { - background-color: #f1f5f9; + background-color: var(--surface-raised); } .autocomplete-item .codmat { font-weight: 600; - color: #1e293b; + color: var(--text-primary); } .autocomplete-item .denumire { - color: #64748b; + color: var(--text-muted); font-size: 0.875rem; } @@ -499,7 +648,7 @@ tr.mapping-deleted td { color: #f9fafb; font-size: 0.75rem; padding: 0.3rem 0.6rem; - border-radius: 4px; + border-radius: var(--radius-sm); white-space: nowrap; pointer-events: none; opacity: 0; @@ -510,8 +659,8 @@ tr.mapping-deleted td { /* ── Sync card ───────────────────────────────────── */ .sync-card { - background: #fff; - border: 1px solid var(--border-color); + background: var(--surface); + border: 1px solid var(--border); border-radius: var(--card-radius); overflow: hidden; margin-bottom: 1rem; @@ -525,7 +674,7 @@ tr.mapping-deleted td { } .sync-card-divider { height: 1px; - background: var(--border-color); + background: var(--border); margin: 0; } .sync-card-info { @@ -538,34 +687,34 @@ tr.mapping-deleted td { cursor: pointer; transition: background 0.12s; } -.sync-card-info:hover { background: #f9fafb; } +.sync-card-info:hover { background: var(--surface-raised); } .sync-card-progress { display: flex; align-items: center; gap: 0.5rem; padding: 0.4rem 1rem; - background: #eff6ff; + background: var(--info-light); font-size: 1rem; - color: var(--blue-700); - border-top: 1px solid #dbeafe; + color: var(--info-text); + border-top: 1px solid var(--border); } -/* ── Pulsing live dot (keep as-is) ──────────────── */ +/* ── 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; + background: var(--info); + animation: pulse-dot 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); } + 0%, 100% { opacity: 1; } + 50% { opacity: 0.4; } } -/* ── Status dot (keep as-is) ─────────────────────── */ +/* ── Status dot ─────────────────────────────────── */ .sync-status-dot { display: inline-block; width: 10px; @@ -573,10 +722,10 @@ tr.mapping-deleted td { 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; } +.sync-status-dot.idle { background: var(--cancelled); } +.sync-status-dot.running { background: var(--info); animation: pulse-dot 2s ease-in-out infinite; } +.sync-status-dot.completed { background: var(--success); } +.sync-status-dot.failed { background: var(--error); } /* ── Custom period range inputs ──────────────────── */ .period-custom-range { @@ -591,13 +740,13 @@ tr.mapping-deleted td { .select-compact { padding: 0.375rem 0.5rem; font-size: 0.9375rem; - border: 1px solid #d1d5db; + border: 1px solid var(--border); border-radius: 0.375rem; - background: #fff; + background: var(--surface); cursor: pointer; } -/* ── btn-compact (kept for backward compat) ──────── */ +/* ── btn-compact ─────────────────────────────────── */ .btn-compact { padding: 0.375rem 0.75rem; font-size: 0.9375rem; @@ -608,26 +757,26 @@ tr.mapping-deleted td { padding: 0.4rem 0.75rem; border-radius: 0.375rem; font-size: 0.9375rem; - background: #d1fae5; - color: #065f46; - border: 1px solid #6ee7b7; + background: var(--success-light); + color: var(--success-text); + border: 1px solid var(--success); } /* ── Badge-pct (mappings page) ───────────────────── */ .badge-pct { font-size: 0.75rem; padding: 0.1rem 0.35rem; - border-radius: 4px; + border-radius: var(--radius-sm); font-weight: 600; } -.badge-pct.complete { background: #d1fae5; color: #065f46; } -.badge-pct.incomplete { background: #fef3c7; color: #92400e; } +.badge-pct.complete { background: var(--success-light); color: var(--success-text); } +.badge-pct.incomplete { background: var(--warning-light); color: var(--warning-text); } /* ── Context Menu ────────────────────────────────── */ .context-menu-trigger { background: none; border: none; - color: #9ca3af; + color: var(--cancelled); padding: 0.2rem 0.4rem; cursor: pointer; border-radius: 0.25rem; @@ -637,14 +786,14 @@ tr.mapping-deleted td { } .context-menu-trigger:hover { color: var(--text-secondary); - background: #f3f4f6; + background: var(--surface-raised); } .context-menu { position: fixed; - background: #fff; - border: 1px solid #e5e7eb; - border-radius: 0.5rem; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--card-radius); box-shadow: 0 4px 16px rgba(0,0,0,0.12); z-index: 1050; min-width: 150px; @@ -662,9 +811,9 @@ tr.mapping-deleted td { color: var(--text-primary); transition: background 0.1s; } -.context-menu-item:hover { background: #f3f4f6; } -.context-menu-item.text-danger { color: #dc2626; } -.context-menu-item.text-danger:hover { background: #fee2e2; } +.context-menu-item:hover { background: var(--surface-raised); } +.context-menu-item.text-danger { color: var(--error); } +.context-menu-item.text-danger:hover { background: var(--error-light); } /* ── Pagination info strip ───────────────────────── */ .pag-strip { @@ -673,12 +822,12 @@ tr.mapping-deleted td { justify-content: space-between; gap: 1rem; padding: 0.5rem 1rem; - border-bottom: 1px solid var(--border-color); + border-bottom: 1px solid var(--border); flex-wrap: wrap; } .pag-strip-bottom { border-bottom: none; - border-top: 1px solid var(--border-color); + border-top: 1px solid var(--border); } /* ── Per page selector ───────────────────────────── */ @@ -697,29 +846,62 @@ tr.mapping-deleted td { /* ── Mappings flat-rows: always visible ────────────── */ .mappings-flat-list { display: block; } -/* ── Mobile ⋯ dropdown ─────────────────────────── */ +/* ── Mobile more dropdown ─────────────────────────── */ .mobile-more-dropdown { position: relative; display: inline-block; } .mobile-more-dropdown .dropdown-toggle::after { display: none; } /* ── Mobile segmented control (hidden on desktop) ── */ -.mobile-seg { display: none; } +.mobile-seg { display: none; overflow-x: auto; -webkit-overflow-scrolling: touch; } +.mobile-seg .btn-group { flex-wrap: nowrap; min-width: 0; } +.seg-active { + background: var(--accent) !important; + border-color: var(--accent) !important; + color: #fff !important; +} + +/* ── Bottom nav (mobile) ─────────────────────────── */ +.bottom-nav { + position: fixed; + bottom: 0; + left: 0; + right: 0; + height: 56px; + padding-bottom: env(safe-area-inset-bottom); + background: var(--surface); + border-top: 1px solid var(--border); + display: none; /* shown on mobile */ + justify-content: space-around; + align-items: center; + z-index: 1000; +} + +.bottom-nav-item { + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; + text-decoration: none; + color: var(--text-muted); + font-size: 0.625rem; + font-weight: 500; + padding: 4px 0; + min-width: 48px; + min-height: 44px; + justify-content: center; +} +.bottom-nav-item i { font-size: 1.25rem; } +.bottom-nav-item.active { color: var(--accent); } +.bottom-nav-item:hover { color: var(--text-secondary); text-decoration: none; } /* ── Responsive ──────────────────────────────────── */ @media (max-width: 767.98px) { - .top-navbar { - padding: 0 0.5rem; - gap: 0.5rem; - } - .navbar-brand { - font-size: 0.875rem; - } - .nav-tab { - padding: 0 0.625rem; - font-size: 0.8125rem; - } + .top-navbar { display: none; } + .bottom-nav { display: flex; } .main-content { padding-left: 0.75rem; padding-right: 0.75rem; + padding-bottom: 72px; + padding-top: 8px; } .filter-bar { gap: 0.375rem; @@ -751,20 +933,22 @@ tr.mapping-deleted td { /* Hide per-page selector on mobile */ .per-page-label { display: none; } + + /* Hide dark toggle in navbar on mobile (use settings page instead) */ + .dark-toggle { display: none; } } /* Mobile article cards in order detail modal */ .detail-item-card { - border: 1px solid #e5e7eb; + border: 1px solid var(--border); border-radius: 6px; padding: 0.5rem 0.75rem; margin-bottom: 0.5rem; font-size: 0.875rem; } .detail-item-card .card-sku { - font-family: monospace; font-size: 0.8rem; - color: #6b7280; + color: var(--text-muted); } .detail-item-card .card-name { font-weight: 500; @@ -773,24 +957,141 @@ tr.mapping-deleted td { .detail-item-card .card-details { display: flex; gap: 1rem; - color: #374151; + color: var(--text-secondary); } /* Clickable CODMAT link in order detail modal */ -.codmat-link { color: #0d6efd; cursor: pointer; text-decoration: underline; } -.codmat-link:hover { color: #0a58ca; } +.codmat-link { color: var(--info); cursor: pointer; text-decoration: underline; } +.codmat-link:hover { color: var(--info-hover); } /* Mobile article flat list in order detail modal */ .detail-item-flat { font-size: 0.85rem; } .detail-item-flat .dif-item { } -.detail-item-flat .dif-item:nth-child(even) .dif-row { background: #f7f8fa; } +.detail-item-flat .dif-item:nth-child(even) .dif-row { background: var(--surface-raised); } .detail-item-flat .dif-row { display: flex; align-items: baseline; gap: 0.5rem; padding: 0.2rem 0.75rem; flex-wrap: wrap; } -.dif-sku { font-family: monospace; font-size: 0.78rem; color: #6b7280; } +.dif-sku { font-size: 0.78rem; color: var(--text-muted); } .dif-name { font-weight: 500; flex: 1; } -.dif-qty { white-space: nowrap; color: #6b7280; } +.dif-qty { white-space: nowrap; color: var(--text-muted); } .dif-val { white-space: nowrap; font-weight: 600; } -.dif-codmat-link { color: #0d6efd; cursor: pointer; font-size: 0.78rem; font-family: monospace; } -.dif-codmat-link:hover { color: #0a58ca; text-decoration: underline; } +.dif-codmat-link { color: var(--info); cursor: pointer; font-size: 0.78rem; } +.dif-codmat-link:hover { color: var(--info-hover); text-decoration: underline; } + +/* ── Dark mode form toggle ──────────────────────── */ +.theme-toggle-card { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.75rem 1rem; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--card-radius); + margin-bottom: 1rem; +} +.theme-toggle-card label { + font-weight: 500; + margin: 0; + cursor: pointer; +} + +/* ── Attention card ──────────────────────────── */ +.attention-card { + display: flex; + align-items: center; + gap: 16px; + padding: 10px 16px; + border-radius: 8px; + font-size: 0.875rem; + margin-bottom: 8px; +} +.attention-ok { + background: var(--success-light); + color: var(--success-text); +} +.attention-alert { + background: var(--surface); + border: 1px solid var(--border); + flex-wrap: wrap; +} +.attention-item { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 4px 10px; + border-radius: 4px; + cursor: pointer; + transition: opacity 0.15s; +} +.attention-item:hover { opacity: 0.8; } +.attention-error { + background: var(--error-light); + color: var(--error-text); +} +.attention-warning { + background: var(--warning-light); + color: var(--warning-text); +} + +/* ── Period preset buttons ─────────────────────────────────────────── */ +.period-presets { + display: flex; + gap: 2px; + align-items: center; + overflow-x: auto; + flex-shrink: 0; +} +.preset-btn { + background: var(--surface); + border: 1px solid var(--border); + color: var(--text-secondary); + padding: 3px 10px; + font-size: 0.78rem; + cursor: pointer; + transition: all 0.15s; + border-radius: 4px; +} +.preset-btn:hover { + background: var(--surface-raised); +} +.preset-btn.active { + background: var(--accent-light); + color: var(--accent-text); + border-color: var(--accent); + font-weight: 500; +} + +/* ── Logs OK toggle ────────────────────── */ +.log-ok-toggle { + padding: 8px 12px; + color: var(--text-secondary); + cursor: pointer; + font-size: 0.85rem; + border-top: 1px solid var(--border-subtle); +} +.log-ok-toggle:hover { background: var(--surface-raised); } + +/* ── Welcome card ──────────────────────── */ +.welcome-card { + background: var(--accent-light); + border: 1px solid var(--accent); + border-radius: 8px; + padding: 16px 20px; + margin-bottom: 12px; +} +.welcome-steps { + display: flex; + gap: 16px; + flex-wrap: wrap; + font-size: 0.875rem; +} +.welcome-step { + display: inline-flex; + align-items: center; + gap: 4px; +} +.welcome-step a { + color: var(--info); + text-decoration: underline; +} diff --git a/api/app/static/js/dashboard.js b/api/app/static/js/dashboard.js index 6415752..1ebf08e 100644 --- a/api/app/static/js/dashboard.js +++ b/api/app/static/js/dashboard.js @@ -20,6 +20,7 @@ document.addEventListener('DOMContentLoaded', async () => { loadDashOrders(); startSyncPolling(); wireFilterBar(); + checkFirstTime(); }); async function initPollInterval() { @@ -119,11 +120,33 @@ function updateSyncPanel(data) { } if (st) { st.textContent = lr.status === 'completed' ? '\u2713' : '\u2715'; - st.style.color = lr.status === 'completed' ? '#10b981' : '#ef4444'; + st.style.color = lr.status === 'completed' ? 'var(--success)' : 'var(--error)'; } } } +async function checkFirstTime() { + const welcomeEl = document.getElementById('welcomeCard'); + if (!welcomeEl) return; + try { + const data = await fetchJSON('/api/sync/status'); + if (!data.last_run) { + welcomeEl.innerHTML = `
Configureaza si ruleaza primul sync:
+ +${esc(item.codmat || '-')}`;
- }
- if (item.codmat_details.length === 1) {
- const d = item.codmat_details[0];
- if (d.direct) {
- return `${esc(d.codmat)} direct`;
- }
- return `${esc(d.codmat)}`;
- }
- return item.codmat_details.map(d =>
- `${esc(d.codmat)} \xd7${d.cantitate_roa}${esc(d.codmat)}${d.direct ? ' direct' : ''}`).join(' ')
- : `${esc(item.codmat || '–')}`;
- const valoare = (Number(item.price || 0) * Number(item.quantity || 0));
- return `${esc(item.sku)}' + esc(tCodmat) + '' : ''}' + esc(dCodmat) + '' : ''}' + esc(dCodmat) + '' : ''}${esc(item.codmat || '-')}`;
- }
- if (item.codmat_details.length === 1) {
- const d = item.codmat_details[0];
- return `${esc(d.codmat)}`;
- }
- // Multi-CODMAT: compact list
- return item.codmat_details.map(d =>
- `${esc(d.codmat)} \xd7${d.cantitate_roa}${esc(item.sku)}${esc(item.codmat || '-')}`;
+ }
+ if (item.codmat_details.length === 1) {
+ const d = item.codmat_details[0];
+ if (d.direct) {
+ return `${esc(d.codmat)} direct`;
+ }
+ return `${esc(d.codmat)}`;
+ }
+ return item.codmat_details.map(d =>
+ `${esc(d.codmat)} \xd7${d.cantitate_roa}${esc(d.codmat)}${d.direct ? ' direct' : ''}`).join(' ')
+ : `${esc(item.codmat || '–')}`;
+ const valoare = (Number(item.price || 0) * Number(item.quantity || 0));
+ const clickAttr = opts.onQuickMap ? `onclick="_sharedModalQuickMap('${esc(item.sku)}','${esc(item.product_name||'')}','${esc(orderNumber)}',${idx})"` : '';
+ const priceInfo = { pret_roa: item.pret_roa, match: item.price_match };
+ const priceMismatchHtml = priceInfo.match === false
+ ? `${esc(item.sku)}' + esc(tCodmat) + '' : ''}' + esc(dCodmat) + '' : ''}' + esc(dCodmat) + '' : ''}