Merge feat/operator-shield into main

This commit is contained in:
Claude Agent
2026-03-31 13:01:15 +00:00
22 changed files with 1934 additions and 855 deletions

View File

@@ -332,6 +332,7 @@ def init_sqlite():
("discount_total", "REAL"), ("discount_total", "REAL"),
("web_status", "TEXT"), ("web_status", "TEXT"),
("discount_split", "TEXT"), ("discount_split", "TEXT"),
("price_match", "INTEGER"),
]: ]:
if col not in order_cols: if col not in order_cols:
conn.execute(f"ALTER TABLE orders ADD COLUMN {col} {typedef}") conn.execute(f"ALTER TABLE orders ADD COLUMN {col} {typedef}")

View File

@@ -1,3 +1,4 @@
import asyncio
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from datetime import datetime from datetime import datetime
from fastapi import FastAPI from fastapi import FastAPI
@@ -8,6 +9,7 @@ import os
from .config import settings from .config import settings
from .database import init_oracle, close_oracle, init_sqlite from .database import init_oracle, close_oracle, init_sqlite
from .routers.sync import backfill_price_match
# Configure logging with both stream and file handlers # Configure logging with both stream and file handlers
_log_level = getattr(logging, settings.LOG_LEVEL.upper(), logging.INFO) _log_level = getattr(logging, settings.LOG_LEVEL.upper(), logging.INFO)
@@ -56,6 +58,8 @@ async def lifespan(app: FastAPI):
except Exception: except Exception:
pass pass
asyncio.create_task(backfill_price_match())
logger.info("GoMag Import Manager started") logger.info("GoMag Import Manager started")
yield yield

View File

@@ -146,8 +146,8 @@ async def create_batch_mapping(data: MappingBatchCreate):
return {"success": False, "error": str(e)} return {"success": False, "error": str(e)}
@router.get("/api/mappings/{sku}/prices") @router.get("/api/mappings/prices")
async def get_mapping_prices(sku: str): async def get_mapping_prices(sku: str = Query(...)):
"""Get component prices from crm_politici_pret_art for a kit SKU.""" """Get component prices from crm_politici_pret_art for a kit SKU."""
app_settings = await sqlite_service.get_app_settings() app_settings = await sqlite_service.get_app_settings()
id_pol = int(app_settings.get("id_pol") or 0) or None id_pol = int(app_settings.get("id_pol") or 0) or None

View File

@@ -12,13 +12,81 @@ from pydantic import BaseModel
from pathlib import Path from pathlib import Path
from typing import Optional 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 from .. import database
router = APIRouter(tags=["sync"]) router = APIRouter(tags=["sync"])
templates = Jinja2Templates(directory=str(Path(__file__).parent.parent / "templates")) 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): class ScheduleConfig(BaseModel):
enabled: bool enabled: bool
interval_minutes: int = 5 interval_minutes: int = 5
@@ -380,33 +448,36 @@ async def order_detail(order_number: str):
if not detail: if not detail:
return {"error": "Order not found"} return {"error": "Order not found"}
# Enrich items with ARTICOLE_TERTI mappings from Oracle
items = detail.get("items", []) items = detail.get("items", [])
skus = {item["sku"] for item in items if item.get("sku")} await _enrich_items_with_codmat(items)
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]
# Enrich remaining SKUs via NOM_ARTICOLE (fallback for stale mapping_status) # Price comparison against ROA Oracle
remaining_skus = {item["sku"] for item in items app_settings = await sqlite_service.get_app_settings()
if item.get("sku") and not item.get("codmat_details")} try:
if remaining_skus: price_data = await asyncio.to_thread(
nom_map = await asyncio.to_thread(_get_nom_articole_for_direct_skus, remaining_skus) validation_service.get_prices_for_order, items, app_settings
for item in items: )
sku = item.get("sku") price_items = price_data.get("items", {})
if sku and sku in nom_map and not item.get("codmat_details"): for idx, item in enumerate(items):
item["codmat_details"] = [{ pi = price_items.get(idx)
"codmat": sku, if pi:
"cantitate_roa": 1, item["pret_roa"] = pi.get("pret_roa")
"denumire": nom_map[sku], item["price_match"] = pi.get("match")
"direct": True 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 # Enrich with invoice data
order = detail.get("order", {}) order = detail.get("order", {})
order["price_check"] = order_price_check
if order.get("factura_numar") and order.get("factura_data"): if order.get("factura_numar") and order.get("factura_data"):
order["invoice"] = { order["invoice"] = {
"facturat": True, "facturat": True,
@@ -438,6 +509,19 @@ async def order_detail(order_number: str):
except Exception: except Exception:
pass 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 # Parse discount_split JSON string
if order.get("discount_split"): if order.get("discount_split"):
try: try:
@@ -445,8 +529,7 @@ async def order_detail(order_number: str):
except (json.JSONDecodeError, TypeError): except (json.JSONDecodeError, TypeError):
pass pass
# Add settings for receipt display # Add settings for receipt display (app_settings already fetched above)
app_settings = await sqlite_service.get_app_settings()
order["transport_vat"] = app_settings.get("transport_vat") or "21" order["transport_vat"] = app_settings.get("transport_vat") or "21"
order["transport_codmat"] = app_settings.get("transport_codmat") or "" order["transport_codmat"] = app_settings.get("transport_codmat") or ""
order["discount_codmat"] = app_settings.get("discount_codmat") or "" order["discount_codmat"] = app_settings.get("discount_codmat") or ""
@@ -454,6 +537,52 @@ async def order_detail(order_number: str):
return detail 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") @router.get("/api/dashboard/orders")
async def dashboard_orders(page: int = 1, per_page: int = 50, async def dashboard_orders(page: int = 1, per_page: int = 50,
search: str = "", status: str = "all", search: str = "", status: str = "all",
@@ -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 # Enrich orders with invoice data — prefer SQLite cache, fallback to Oracle
all_orders = result["orders"] all_orders = result["orders"]
for o in all_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"): if o.get("factura_numar") and o.get("factura_data"):
# Use cached invoice data from SQLite (only if complete) # Use cached invoice data from SQLite (only if complete)
o["invoice"] = { 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) # Use counts from sqlite_service (already period-scoped)
counts = result.get("counts", {}) 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")) 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( uninvoiced_base = counts.get("uninvoiced_sqlite", sum(
1 for o in all_orders 1 for o in all_orders
if o.get("status") in ("IMPORTED", "ALREADY_IMPORTED") and not o.get("invoice") 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["facturate"] = max(0, imported_total - counts["nefacturate"])
counts.setdefault("total", counts.get("imported", 0) + counts.get("skipped", 0) + counts.get("error", 0)) counts.setdefault("total", counts.get("imported", 0) + counts.get("skipped", 0) + counts.get("error", 0))
# Attention metrics: add unresolved SKUs count
try:
stats = await sqlite_service.get_dashboard_stats()
counts["unresolved_skus"] = stats.get("unresolved_skus", 0)
except Exception:
counts["unresolved_skus"] = 0
# For UNINVOICED filter: apply server-side filtering + pagination # For UNINVOICED filter: apply server-side filtering + pagination
if is_uninvoiced_filter: if is_uninvoiced_filter:
filtered = [o for o in all_orders if o.get("status") in ("IMPORTED", "ALREADY_IMPORTED") and not o.get("invoice")] filtered = [o for o in all_orders if o.get("status") in ("IMPORTED", "ALREADY_IMPORTED") and not o.get("invoice")]

View File

@@ -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"}

View File

@@ -739,6 +739,16 @@ async def get_orders(page: int = 1, per_page: int = 50,
cursor = await db.execute(f"SELECT COUNT(*) FROM orders {uninv_where}", base_params) cursor = await db.execute(f"SELECT COUNT(*) FROM orders {uninv_where}", base_params)
uninvoiced_sqlite = (await cursor.fetchone())[0] uninvoiced_sqlite = (await cursor.fetchone())[0]
# Uninvoiced > 3 days old
uninv_old_clauses = list(base_clauses) + [
"UPPER(status) IN ('IMPORTED', 'ALREADY_IMPORTED')",
"(factura_numar IS NULL OR factura_numar = '')",
"order_date < datetime('now', '-3 days')",
]
uninv_old_where = "WHERE " + " AND ".join(uninv_old_clauses)
cursor = await db.execute(f"SELECT COUNT(*) FROM orders {uninv_old_where}", base_params)
uninvoiced_old = (await cursor.fetchone())[0]
return { return {
"orders": [dict(r) for r in rows], "orders": [dict(r) for r in rows],
"total": total, "total": total,
@@ -754,6 +764,7 @@ async def get_orders(page: int = 1, per_page: int = 50,
"cancelled": status_counts.get("CANCELLED", 0), "cancelled": status_counts.get("CANCELLED", 0),
"total": sum(status_counts.values()), "total": sum(status_counts.values()),
"uninvoiced_sqlite": uninvoiced_sqlite, "uninvoiced_sqlite": uninvoiced_sqlite,
"uninvoiced_old": uninvoiced_old,
} }
} }
finally: finally:
@@ -820,6 +831,20 @@ async def update_order_invoice(order_number: str, serie: str = None,
await db.close() 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: async def get_invoiced_imported_orders() -> list:
"""Get imported orders that HAVE cached invoice data (for re-verification).""" """Get imported orders that HAVE cached invoice data (for re-verification)."""
db = await get_sqlite() db = await get_sqlite()
@@ -949,6 +974,24 @@ async def set_app_setting(key: str, value: str):
await db.close() 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 ─────────────────────────────── # ── Price Sync Runs ───────────────────────────────
async def get_price_sync_runs(page: int = 1, per_page: int = 20): async def get_price_sync_runs(page: int = 1, per_page: int = 20):

View File

@@ -586,3 +586,189 @@ def sync_prices_from_order(orders, mapped_codmat_data: dict, direct_id_map: dict
database.pool.release(conn) database.pool.release(conn)
return updated 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)

File diff suppressed because it is too large Load Diff

View File

@@ -20,6 +20,7 @@ document.addEventListener('DOMContentLoaded', async () => {
loadDashOrders(); loadDashOrders();
startSyncPolling(); startSyncPolling();
wireFilterBar(); wireFilterBar();
checkFirstTime();
}); });
async function initPollInterval() { async function initPollInterval() {
@@ -119,11 +120,33 @@ function updateSyncPanel(data) {
} }
if (st) { if (st) {
st.textContent = lr.status === 'completed' ? '\u2713' : '\u2715'; 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 = `<div class="welcome-card">
<h5 style="font-family:var(--font-display);margin:0 0 8px">Bine ai venit!</h5>
<p class="text-muted mb-2" style="font-size:0.875rem">Configureaza si ruleaza primul sync:</p>
<div class="welcome-steps">
<span class="welcome-step"><b>1.</b> <a href="${window.ROOT_PATH||''}/settings">Verifica Settings</a></span>
<span class="welcome-step"><b>2.</b> Apasa "Start Sync"</span>
<span class="welcome-step"><b>3.</b> <a href="${window.ROOT_PATH||''}/missing-skus">Mapeaza SKU-urile lipsa</a></span>
</div>
</div>`;
welcomeEl.style.display = '';
} else {
welcomeEl.style.display = 'none';
}
} catch(e) { welcomeEl.style.display = 'none'; }
}
// Wire last-sync-row click → journal (use current running sync if active) // Wire last-sync-row click → journal (use current running sync if active)
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
document.getElementById('lastSyncRow')?.addEventListener('click', () => { document.getElementById('lastSyncRow')?.addEventListener('click', () => {
@@ -201,16 +224,21 @@ async function loadSchedulerStatus() {
// ── Filter Bar wiring ───────────────────────────── // ── Filter Bar wiring ─────────────────────────────
function wireFilterBar() { function wireFilterBar() {
// Period dropdown // Period preset buttons
document.getElementById('periodSelect')?.addEventListener('change', function () { document.querySelectorAll('.preset-btn[data-days]').forEach(btn => {
const cr = document.getElementById('customRangeInputs'); btn.addEventListener('click', function() {
if (this.value === 'custom') { document.querySelectorAll('.preset-btn').forEach(b => b.classList.remove('active'));
cr?.classList.add('visible'); this.classList.add('active');
} else { const days = this.dataset.days;
cr?.classList.remove('visible'); const cr = document.getElementById('customRangeInputs');
dashPage = 1; if (days === 'custom') {
loadDashOrders(); cr?.classList.add('visible');
} } else {
cr?.classList.remove('visible');
dashPage = 1;
loadDashOrders();
}
});
}); });
// Custom range inputs // Custom range inputs
@@ -260,7 +288,8 @@ function dashSortBy(col) {
} }
async function loadDashOrders() { async function loadDashOrders() {
const periodVal = document.getElementById('periodSelect')?.value || '7'; const activePreset = document.querySelector('.preset-btn.active');
const periodVal = activePreset?.dataset.days || '3';
const params = new URLSearchParams(); const params = new URLSearchParams();
if (periodVal === 'custom') { if (periodVal === 'custom') {
@@ -301,11 +330,29 @@ async function loadDashOrders() {
if (el('cntNef')) el('cntNef').textContent = c.nefacturate || c.uninvoiced || 0; if (el('cntNef')) el('cntNef').textContent = c.nefacturate || c.uninvoiced || 0;
if (el('cntCanc')) el('cntCanc').textContent = c.cancelled || 0; if (el('cntCanc')) el('cntCanc').textContent = c.cancelled || 0;
// Attention card
const attnEl = document.getElementById('attentionCard');
if (attnEl) {
const errors = c.error || 0;
const unmapped = c.unresolved_skus || 0;
const nefact = c.nefacturate || 0;
if (errors === 0 && unmapped === 0 && nefact === 0) {
attnEl.innerHTML = '<div class="attention-card attention-ok"><i class="bi bi-check-circle"></i> Totul in ordine</div>';
} else {
let items = [];
if (errors > 0) items.push(`<span class="attention-item attention-error" onclick="document.querySelector('.filter-pill[data-status=ERROR]')?.click()"><i class="bi bi-exclamation-triangle"></i> ${errors} erori import</span>`);
if (unmapped > 0) items.push(`<span class="attention-item attention-warning" onclick="window.location='${window.ROOT_PATH||''}/missing-skus'"><i class="bi bi-puzzle"></i> ${unmapped} SKU-uri nemapate</span>`);
if (nefact > 0) items.push(`<span class="attention-item attention-warning" onclick="document.querySelector('.filter-pill[data-status=UNINVOICED]')?.click()"><i class="bi bi-receipt"></i> ${nefact} nefacturate</span>`);
attnEl.innerHTML = '<div class="attention-card attention-alert">' + items.join('') + '</div>';
}
}
const tbody = document.getElementById('dashOrdersBody'); const tbody = document.getElementById('dashOrdersBody');
const orders = data.orders || []; const orders = data.orders || [];
if (orders.length === 0) { if (orders.length === 0) {
tbody.innerHTML = '<tr><td colspan="9" class="text-center text-muted py-3">Nicio comanda</td></tr>'; tbody.innerHTML = '<tr><td colspan="10" class="text-center text-muted py-3">Nicio comanda</td></tr>';
} else { } else {
tbody.innerHTML = orders.map(o => { tbody.innerHTML = orders.map(o => {
const dateStr = fmtDate(o.order_date); const dateStr = fmtDate(o.order_date);
@@ -321,6 +368,7 @@ async function loadDashOrders() {
<td class="text-end text-muted">${fmtCost(o.discount_total)}</td> <td class="text-end text-muted">${fmtCost(o.discount_total)}</td>
<td class="text-end fw-bold">${orderTotal}</td> <td class="text-end fw-bold">${orderTotal}</td>
<td class="text-center">${invoiceDot(o)}</td> <td class="text-center">${invoiceDot(o)}</td>
<td class="text-center">${priceDot(o)}</td>
</tr>`; </tr>`;
}).join(''); }).join('');
} }
@@ -340,11 +388,12 @@ async function loadDashOrders() {
} }
const name = o.customer_name || o.shipping_name || o.billing_name || '\u2014'; const name = o.customer_name || o.shipping_name || o.billing_name || '\u2014';
const totalStr = o.order_total ? Number(o.order_total).toFixed(2) : ''; const totalStr = o.order_total ? Number(o.order_total).toFixed(2) : '';
const priceMismatch = o.price_match === false ? '<span class="dot dot-red" style="width:6px;height:6px" title="Pret!="></span> ' : '';
return `<div class="flat-row" onclick="openDashOrderDetail('${esc(o.order_number)}')" style="font-size:0.875rem"> return `<div class="flat-row" onclick="openDashOrderDetail('${esc(o.order_number)}')" style="font-size:0.875rem">
${statusDot(o.status)} ${statusDot(o.status)}
<span style="color:#6b7280" class="text-nowrap">${dateFmt}</span> <span style="color:var(--text-muted)" class="text-nowrap">${dateFmt}</span>
<span class="grow truncate fw-bold">${esc(name)}</span> <span class="grow truncate fw-bold">${esc(name)}</span>
<span class="text-nowrap">x${o.items_count || 0}${totalStr ? ' · <strong>' + totalStr + '</strong>' : ''}</span> <span class="text-nowrap">x${o.items_count || 0}${totalStr ? ' · ' + priceMismatch + '<strong>' + totalStr + '</strong>' : ''}</span>
</div>`; </div>`;
}).join(''); }).join('');
} }
@@ -432,14 +481,6 @@ function escHtml(s) {
.replace(/'/g, '&#39;'); .replace(/'/g, '&#39;');
} }
// Alias kept for backward compat with inline handlers in modal
function esc(s) { return escHtml(s); }
function fmtCost(v) {
return v > 0 ? Number(v).toFixed(2) : '';
}
function statusLabelText(status) { function statusLabelText(status) {
switch ((status || '').toUpperCase()) { switch ((status || '').toUpperCase()) {
case 'IMPORTED': return 'Importat'; case 'IMPORTED': return 'Importat';
@@ -450,16 +491,10 @@ function statusLabelText(status) {
} }
} }
function orderStatusBadge(status) { function priceDot(order) {
switch ((status || '').toUpperCase()) { if (order.price_match === true) return '<span class="dot dot-green" title="Preturi OK"></span>';
case 'IMPORTED': return '<span class="badge bg-success">Importat</span>'; if (order.price_match === false) return '<span class="dot dot-red" title="Diferenta de pret"></span>';
case 'ALREADY_IMPORTED': return '<span class="badge bg-info">Deja importat</span>'; return '<span class="dot dot-gray" title="Neverificat"></span>';
case 'SKIPPED': return '<span class="badge bg-warning">Omis</span>';
case 'ERROR': return '<span class="badge bg-danger">Eroare</span>';
case 'CANCELLED': return '<span class="badge bg-secondary">Anulat</span>';
case 'DELETED_IN_ROA': return '<span class="badge bg-dark">Sters din ROA</span>';
default: return `<span class="badge bg-secondary">${esc(status)}</span>`;
}
} }
function invoiceDot(order) { function invoiceDot(order) {
@@ -468,22 +503,6 @@ function invoiceDot(order) {
return '<span class="dot dot-red" title="Nefacturat"></span>'; return '<span class="dot dot-red" title="Nefacturat"></span>';
} }
function renderCodmatCell(item) {
if (!item.codmat_details || item.codmat_details.length === 0) {
return `<code>${esc(item.codmat || '-')}</code>`;
}
if (item.codmat_details.length === 1) {
const d = item.codmat_details[0];
if (d.direct) {
return `<code>${esc(d.codmat)}</code> <span class="badge bg-secondary" style="font-size:0.6rem;vertical-align:middle">direct</span>`;
}
return `<code>${esc(d.codmat)}</code>`;
}
return item.codmat_details.map(d =>
`<div class="small"><code>${esc(d.codmat)}</code> <span class="text-muted">\xd7${d.cantitate_roa}</span></div>`
).join('');
}
// ── Refresh Invoices ────────────────────────────── // ── Refresh Invoices ──────────────────────────────
async function refreshInvoices() { async function refreshInvoices() {
@@ -509,262 +528,12 @@ async function refreshInvoices() {
// ── Order Detail Modal ──────────────────────────── // ── Order Detail Modal ────────────────────────────
async function openDashOrderDetail(orderNumber) { function openDashOrderDetail(orderNumber) {
document.getElementById('detailOrderNumber').textContent = '#' + orderNumber; _sharedModalQuickMapFn = openDashQuickMap;
document.getElementById('detailCustomer').textContent = '...'; renderOrderDetailModal(orderNumber, {
document.getElementById('detailDate').textContent = ''; onQuickMap: openDashQuickMap,
document.getElementById('detailStatus').innerHTML = ''; onAfterRender: function() { /* nothing extra needed */ }
document.getElementById('detailIdComanda').textContent = '-';
document.getElementById('detailIdPartener').textContent = '-';
document.getElementById('detailIdAdresaFact').textContent = '-';
document.getElementById('detailIdAdresaLivr').textContent = '-';
document.getElementById('detailItemsBody').innerHTML = '<tr><td colspan="7" class="text-center">Se incarca...</td></tr>';
document.getElementById('detailError').style.display = 'none';
document.getElementById('detailReceipt').innerHTML = '';
document.getElementById('detailReceiptMobile').innerHTML = '';
const invInfo = document.getElementById('detailInvoiceInfo');
if (invInfo) invInfo.style.display = 'none';
const mobileContainer = document.getElementById('detailItemsMobile');
if (mobileContainer) mobileContainer.innerHTML = '';
const modalEl = document.getElementById('orderDetailModal');
const existing = bootstrap.Modal.getInstance(modalEl);
if (existing) { existing.show(); } else { new bootstrap.Modal(modalEl).show(); }
try {
const res = await fetch(`/api/sync/order/${encodeURIComponent(orderNumber)}`);
const data = await res.json();
if (data.error) {
document.getElementById('detailError').textContent = data.error;
document.getElementById('detailError').style.display = '';
return;
}
const order = data.order || {};
document.getElementById('detailCustomer').textContent = order.customer_name || '-';
document.getElementById('detailDate').textContent = fmtDate(order.order_date);
document.getElementById('detailStatus').innerHTML = orderStatusBadge(order.status);
document.getElementById('detailIdComanda').textContent = order.id_comanda || '-';
document.getElementById('detailIdPartener').textContent = order.id_partener || '-';
document.getElementById('detailIdAdresaFact').textContent = order.id_adresa_facturare || '-';
document.getElementById('detailIdAdresaLivr').textContent = order.id_adresa_livrare || '-';
// Invoice info
const invInfo = document.getElementById('detailInvoiceInfo');
const inv = order.invoice;
if (inv && inv.facturat) {
const serie = inv.serie_act || '';
const numar = inv.numar_act || '';
document.getElementById('detailInvoiceNumber').textContent = serie ? `${serie} ${numar}` : numar;
document.getElementById('detailInvoiceDate').textContent = inv.data_act ? fmtDate(inv.data_act) : '-';
if (invInfo) invInfo.style.display = '';
} else {
if (invInfo) invInfo.style.display = 'none';
}
if (order.error_message) {
document.getElementById('detailError').textContent = order.error_message;
document.getElementById('detailError').style.display = '';
}
const items = data.items || [];
if (items.length === 0) {
document.getElementById('detailItemsBody').innerHTML = '<tr><td colspan="7" class="text-center text-muted">Niciun articol</td></tr>';
return;
}
// Store items for quick map pre-population
window._detailItems = items;
// Mobile article flat list
const mobileContainer = document.getElementById('detailItemsMobile');
if (mobileContainer) {
let mobileHtml = items.map((item, idx) => {
const codmatText = item.codmat_details?.length
? item.codmat_details.map(d => `<code>${esc(d.codmat)}</code>${d.direct ? ' <span class="badge bg-secondary" style="font-size:0.55rem">direct</span>' : ''}`).join(' ')
: `<code>${esc(item.codmat || '')}</code>`;
const valoare = (Number(item.price || 0) * Number(item.quantity || 0));
return `<div class="dif-item">
<div class="dif-row">
<span class="dif-sku dif-codmat-link" onclick="openDashQuickMap('${esc(item.sku)}','${esc(item.product_name||'')}','${esc(orderNumber)}', ${idx})">${esc(item.sku)}</span>
${codmatText}
</div>
<div class="dif-row">
<span class="dif-name">${esc(item.product_name || '')}</span>
<span class="dif-qty">x${item.quantity || 0}</span>
<span class="dif-val">${fmtNum(valoare)} lei</span>
<span class="dif-vat text-muted" style="font-size:0.75rem">TVA ${item.vat != null ? Number(item.vat) : '?'}</span>
</div>
</div>`;
}).join('');
// Transport row (mobile)
if (order.delivery_cost > 0) {
const tVat = order.transport_vat || '21';
mobileHtml += `<div class="dif-item" style="opacity:0.7">
<div class="dif-row">
<span class="dif-name text-muted">Transport</span>
<span class="dif-qty">x1</span>
<span class="dif-val">${fmtNum(order.delivery_cost)} lei</span>
<span class="dif-vat text-muted" style="font-size:0.75rem">TVA ${tVat}</span>
</div>
</div>`;
}
// Discount rows (mobile)
if (order.discount_total > 0) {
const discSplit = computeDiscountSplit(items, order);
if (discSplit) {
Object.entries(discSplit)
.sort(([a], [b]) => Number(a) - Number(b))
.forEach(([rate, amt]) => {
if (amt > 0) mobileHtml += `<div class="dif-item" style="opacity:0.7">
<div class="dif-row">
<span class="dif-name text-muted">Discount</span>
<span class="dif-qty">x\u20131</span>
<span class="dif-val">${fmtNum(amt)} lei</span>
<span class="dif-vat text-muted" style="font-size:0.75rem">TVA ${Number(rate)}</span>
</div>
</div>`;
});
} else {
mobileHtml += `<div class="dif-item" style="opacity:0.7">
<div class="dif-row">
<span class="dif-name text-muted">Discount</span>
<span class="dif-qty">x\u20131</span>
<span class="dif-val">${fmtNum(order.discount_total)} lei</span>
</div>
</div>`;
}
}
mobileContainer.innerHTML = '<div class="detail-item-flat">' + mobileHtml + '</div>';
}
let tableHtml = items.map((item, idx) => {
const valoare = Number(item.price || 0) * Number(item.quantity || 0);
return `<tr>
<td><code class="codmat-link" onclick="openDashQuickMap('${esc(item.sku)}', '${esc(item.product_name || '')}', '${esc(orderNumber)}', ${idx})" title="Click pentru mapare">${esc(item.sku)}</code></td>
<td>${esc(item.product_name || '-')}</td>
<td>${renderCodmatCell(item)}</td>
<td class="text-end">${item.quantity || 0}</td>
<td class="text-end">${item.price != null ? fmtNum(item.price) : '-'}</td>
<td class="text-end">${item.vat != null ? Number(item.vat) : '-'}</td>
<td class="text-end">${fmtNum(valoare)}</td>
</tr>`;
}).join('');
// Transport row
if (order.delivery_cost > 0) {
const tVat = order.transport_vat || '21';
const tCodmat = order.transport_codmat || '';
tableHtml += `<tr class="table-light">
<td></td><td class="text-muted">Transport</td>
<td>${tCodmat ? '<code>' + esc(tCodmat) + '</code>' : ''}</td>
<td class="text-end">1</td><td class="text-end">${fmtNum(order.delivery_cost)}</td>
<td class="text-end">${tVat}</td><td class="text-end">${fmtNum(order.delivery_cost)}</td>
</tr>`;
}
// Discount rows (split by VAT rate)
if (order.discount_total > 0) {
const dCodmat = order.discount_codmat || '';
const discSplit = computeDiscountSplit(items, order);
if (discSplit) {
Object.entries(discSplit)
.sort(([a], [b]) => Number(a) - Number(b))
.forEach(([rate, amt]) => {
if (amt > 0) tableHtml += `<tr class="table-light">
<td></td><td class="text-muted">Discount</td>
<td>${dCodmat ? '<code>' + esc(dCodmat) + '</code>' : ''}</td>
<td class="text-end">\u20131</td><td class="text-end">${fmtNum(amt)}</td>
<td class="text-end">${Number(rate)}</td><td class="text-end">\u2013${fmtNum(amt)}</td>
</tr>`;
});
} else {
tableHtml += `<tr class="table-light">
<td></td><td class="text-muted">Discount</td>
<td>${dCodmat ? '<code>' + esc(dCodmat) + '</code>' : ''}</td>
<td class="text-end">\u20131</td><td class="text-end">${fmtNum(order.discount_total)}</td>
<td class="text-end">-</td><td class="text-end">\u2013${fmtNum(order.discount_total)}</td>
</tr>`;
}
}
document.getElementById('detailItemsBody').innerHTML = tableHtml;
// Receipt footer (just total)
renderReceipt(items, order);
} catch (err) {
document.getElementById('detailError').textContent = err.message;
document.getElementById('detailError').style.display = '';
}
}
function fmtNum(v) {
return Number(v).toLocaleString('ro-RO', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
function computeDiscountSplit(items, order) {
if (order.discount_split && typeof order.discount_split === 'object')
return order.discount_split;
// Compute proportionally from items by VAT rate
const byRate = {};
items.forEach(item => {
const rate = item.vat != null ? Number(item.vat) : null;
if (rate === null) return;
if (!byRate[rate]) byRate[rate] = 0;
byRate[rate] += Number(item.price || 0) * Number(item.quantity || 0);
}); });
const rates = Object.keys(byRate).sort((a, b) => Number(a) - Number(b));
if (rates.length === 0) return null;
const grandTotal = rates.reduce((s, r) => s + byRate[r], 0);
if (grandTotal <= 0) return null;
const split = {};
let remaining = order.discount_total;
rates.forEach((rate, i) => {
if (i === rates.length - 1) {
split[rate] = Math.round(remaining * 100) / 100;
} else {
const amt = Math.round(order.discount_total * byRate[rate] / grandTotal * 100) / 100;
split[rate] = amt;
remaining -= amt;
}
});
return split;
}
function renderReceipt(items, order) {
const desktop = document.getElementById('detailReceipt');
const mobile = document.getElementById('detailReceiptMobile');
if (!items.length) {
desktop.innerHTML = '';
mobile.innerHTML = '';
return;
}
const articole = items.reduce((s, i) => s + Number(i.price || 0) * Number(i.quantity || 0), 0);
const discount = Number(order.discount_total || 0);
const transport = Number(order.delivery_cost || 0);
const total = order.order_total != null ? fmtNum(order.order_total) : '-';
// Desktop: full labels
let dHtml = `<span class="text-muted">Articole: <strong class="text-body">${fmtNum(articole)}</strong></span>`;
if (discount > 0) dHtml += `<span class="text-muted">Discount: <strong class="text-danger">\u2013${fmtNum(discount)}</strong></span>`;
if (transport > 0) dHtml += `<span class="text-muted">Transport: <strong class="text-body">${fmtNum(transport)}</strong></span>`;
dHtml += `<span>Total: <strong>${total} lei</strong></span>`;
desktop.innerHTML = dHtml;
// Mobile: shorter labels
let mHtml = `<span class="text-muted">Art: <strong class="text-body">${fmtNum(articole)}</strong></span>`;
if (discount > 0) mHtml += `<span class="text-muted">Disc: <strong class="text-danger">\u2013${fmtNum(discount)}</strong></span>`;
if (transport > 0) mHtml += `<span class="text-muted">Transp: <strong class="text-body">${fmtNum(transport)}</strong></span>`;
mHtml += `<span>Total: <strong>${total} lei</strong></span>`;
mobile.innerHTML = mHtml;
} }
// ── Quick Map Modal (uses shared openQuickMap) ─── // ── Quick Map Modal (uses shared openQuickMap) ───

View File

@@ -8,10 +8,6 @@ let ordersPage = 1;
let ordersSortColumn = 'order_date'; let ordersSortColumn = 'order_date';
let ordersSortDirection = 'desc'; let ordersSortDirection = 'desc';
function fmtCost(v) {
return v > 0 ? Number(v).toFixed(2) : '';
}
function fmtDuration(startedAt, finishedAt) { function fmtDuration(startedAt, finishedAt) {
if (!startedAt || !finishedAt) return '-'; if (!startedAt || !finishedAt) return '-';
const diffMs = new Date(finishedAt) - new Date(startedAt); const diffMs = new Date(finishedAt) - new Date(startedAt);
@@ -23,24 +19,13 @@ function fmtDuration(startedAt, finishedAt) {
function runStatusBadge(status) { function runStatusBadge(status) {
switch ((status || '').toLowerCase()) { switch ((status || '').toLowerCase()) {
case 'completed': return '<span style="color:#16a34a;font-weight:600">completed</span>'; case 'completed': return '<span style="color:var(--success);font-weight:600">completed</span>';
case 'running': return '<span style="color:#2563eb;font-weight:600">running</span>'; case 'running': return '<span style="color:var(--info);font-weight:600">running</span>';
case 'failed': return '<span style="color:#dc2626;font-weight:600">failed</span>'; case 'failed': return '<span style="color:var(--error);font-weight:600">failed</span>';
default: return `<span style="font-weight:600">${esc(status)}</span>`; default: return `<span style="font-weight:600">${esc(status)}</span>`;
} }
} }
function orderStatusBadge(status) {
switch ((status || '').toUpperCase()) {
case 'IMPORTED': return '<span class="badge bg-success">Importat</span>';
case 'ALREADY_IMPORTED': return '<span class="badge bg-info">Deja importat</span>';
case 'SKIPPED': return '<span class="badge bg-warning">Omis</span>';
case 'ERROR': return '<span class="badge bg-danger">Eroare</span>';
case 'DELETED_IN_ROA': return '<span class="badge bg-dark">Sters din ROA</span>';
default: return `<span class="badge bg-secondary">${esc(status)}</span>`;
}
}
function logStatusText(status) { function logStatusText(status) {
switch ((status || '').toUpperCase()) { switch ((status || '').toUpperCase()) {
case 'IMPORTED': return 'Importat'; case 'IMPORTED': return 'Importat';
@@ -156,7 +141,11 @@ async function loadRunOrders(runId, statusFilter, page) {
if (orders.length === 0) { if (orders.length === 0) {
tbody.innerHTML = '<tr><td colspan="9" class="text-center text-muted py-3">Nicio comanda</td></tr>'; tbody.innerHTML = '<tr><td colspan="9" class="text-center text-muted py-3">Nicio comanda</td></tr>';
} else { } else {
tbody.innerHTML = orders.map((o, i) => { const problemOrders = orders.filter(o => ['ERROR', 'SKIPPED'].includes(o.status));
const okOrders = orders.filter(o => ['IMPORTED', 'ALREADY_IMPORTED'].includes(o.status));
const otherOrders = orders.filter(o => !['ERROR', 'SKIPPED', 'IMPORTED', 'ALREADY_IMPORTED'].includes(o.status));
function orderRow(o, i) {
const dateStr = fmtDate(o.order_date); const dateStr = fmtDate(o.order_date);
const orderTotal = o.order_total != null ? Number(o.order_total).toFixed(2) : '-'; const orderTotal = o.order_total != null ? Number(o.order_total).toFixed(2) : '-';
return `<tr style="cursor:pointer" onclick="openOrderDetail('${esc(o.order_number)}')"> return `<tr style="cursor:pointer" onclick="openOrderDetail('${esc(o.order_number)}')">
@@ -170,7 +159,31 @@ async function loadRunOrders(runId, statusFilter, page) {
<td class="text-end text-muted">${fmtCost(o.discount_total)}</td> <td class="text-end text-muted">${fmtCost(o.discount_total)}</td>
<td class="text-end fw-bold">${orderTotal}</td> <td class="text-end fw-bold">${orderTotal}</td>
</tr>`; </tr>`;
}).join(''); }
let html = '';
// Show problem orders first (always visible)
problemOrders.forEach((o, i) => { html += orderRow(o, i); });
otherOrders.forEach((o, i) => { html += orderRow(o, problemOrders.length + i); });
// Collapsible OK orders
if (okOrders.length > 0) {
const toggleId = 'okOrdersCollapse_' + Date.now();
html += `<tr><td colspan="9" class="p-0">
<div class="log-ok-toggle" onclick="this.nextElementSibling.classList.toggle('d-none')">
${okOrders.length} comenzi importate cu succes
</div>
<div class="d-none">
<table class="table mb-0">
<tbody>
${okOrders.map((o, i) => orderRow(o, problemOrders.length + otherOrders.length + i)).join('')}
</tbody>
</table>
</div>
</td></tr>`;
}
tbody.innerHTML = html;
} }
// Mobile flat rows // Mobile flat rows
@@ -179,7 +192,11 @@ async function loadRunOrders(runId, statusFilter, page) {
if (orders.length === 0) { if (orders.length === 0) {
mobileList.innerHTML = '<div class="flat-row text-muted py-3 justify-content-center">Nicio comanda</div>'; mobileList.innerHTML = '<div class="flat-row text-muted py-3 justify-content-center">Nicio comanda</div>';
} else { } else {
mobileList.innerHTML = orders.map(o => { const problemOrders = orders.filter(o => ['ERROR', 'SKIPPED'].includes(o.status));
const okOrders = orders.filter(o => ['IMPORTED', 'ALREADY_IMPORTED'].includes(o.status));
const otherOrders = orders.filter(o => !['ERROR', 'SKIPPED', 'IMPORTED', 'ALREADY_IMPORTED'].includes(o.status));
function mobileRow(o) {
const d = o.order_date || ''; const d = o.order_date || '';
let dateFmt = '-'; let dateFmt = '-';
if (d.length >= 10) { if (d.length >= 10) {
@@ -189,11 +206,26 @@ async function loadRunOrders(runId, statusFilter, page) {
const totalStr = o.order_total ? Number(o.order_total).toFixed(2) : ''; const totalStr = o.order_total ? Number(o.order_total).toFixed(2) : '';
return `<div class="flat-row" onclick="openOrderDetail('${esc(o.order_number)}')" style="font-size:0.875rem"> return `<div class="flat-row" onclick="openOrderDetail('${esc(o.order_number)}')" style="font-size:0.875rem">
${statusDot(o.status)} ${statusDot(o.status)}
<span style="color:#6b7280" class="text-nowrap">${dateFmt}</span> <span style="color:var(--text-muted)" class="text-nowrap">${dateFmt}</span>
<span class="grow truncate fw-bold">${esc(o.customer_name || '—')}</span> <span class="grow truncate fw-bold">${esc(o.customer_name || '—')}</span>
<span class="text-nowrap">x${o.items_count || 0}${totalStr ? ' · <strong>' + totalStr + '</strong>' : ''}</span> <span class="text-nowrap">x${o.items_count || 0}${totalStr ? ' · <strong>' + totalStr + '</strong>' : ''}</span>
</div>`; </div>`;
}).join(''); }
let mobileHtml = '';
problemOrders.forEach(o => { mobileHtml += mobileRow(o); });
otherOrders.forEach(o => { mobileHtml += mobileRow(o); });
if (okOrders.length > 0) {
mobileHtml += `<div class="log-ok-toggle" onclick="this.nextElementSibling.classList.toggle('d-none')">
${okOrders.length} comenzi importate cu succes
</div>
<div class="d-none">
${okOrders.map(o => mobileRow(o)).join('')}
</div>`;
}
mobileList.innerHTML = mobileHtml;
} }
} }
@@ -296,125 +328,17 @@ async function fetchTextLog(runId) {
} }
} }
// ── Multi-CODMAT helper (D1) ─────────────────────
function renderCodmatCell(item) {
if (!item.codmat_details || item.codmat_details.length === 0) {
return `<code>${esc(item.codmat || '-')}</code>`;
}
if (item.codmat_details.length === 1) {
const d = item.codmat_details[0];
return `<code>${esc(d.codmat)}</code>`;
}
// Multi-CODMAT: compact list
return item.codmat_details.map(d =>
`<div class="small"><code>${esc(d.codmat)}</code> <span class="text-muted">\xd7${d.cantitate_roa}</span></div>`
).join('');
}
// ── Order Detail Modal (R9) ───────────────────── // ── Order Detail Modal (R9) ─────────────────────
async function openOrderDetail(orderNumber) { function openOrderDetail(orderNumber) {
document.getElementById('detailOrderNumber').textContent = '#' + orderNumber; _sharedModalQuickMapFn = function(sku, productName, orderNum, itemIdx) {
document.getElementById('detailCustomer').textContent = '...'; openLogsQuickMap(sku, productName, orderNum);
document.getElementById('detailDate').textContent = ''; };
document.getElementById('detailStatus').innerHTML = ''; renderOrderDetailModal(orderNumber, {
document.getElementById('detailIdComanda').textContent = '-'; onQuickMap: function(sku, productName, orderNum, itemIdx) {
document.getElementById('detailIdPartener').textContent = '-'; openLogsQuickMap(sku, productName, orderNum);
document.getElementById('detailIdAdresaFact').textContent = '-';
document.getElementById('detailIdAdresaLivr').textContent = '-';
document.getElementById('detailItemsBody').innerHTML = '<tr><td colspan="6" class="text-center">Se incarca...</td></tr>';
document.getElementById('detailError').style.display = 'none';
const detailItemsTotal = document.getElementById('detailItemsTotal');
if (detailItemsTotal) detailItemsTotal.textContent = '-';
const detailOrderTotal = document.getElementById('detailOrderTotal');
if (detailOrderTotal) detailOrderTotal.textContent = '-';
const mobileContainer = document.getElementById('detailItemsMobile');
if (mobileContainer) mobileContainer.innerHTML = '';
const modalEl = document.getElementById('orderDetailModal');
const existing = bootstrap.Modal.getInstance(modalEl);
if (existing) { existing.show(); } else { new bootstrap.Modal(modalEl).show(); }
try {
const res = await fetch(`/api/sync/order/${encodeURIComponent(orderNumber)}`);
const data = await res.json();
if (data.error) {
document.getElementById('detailError').textContent = data.error;
document.getElementById('detailError').style.display = '';
return;
} }
});
const order = data.order || {};
document.getElementById('detailCustomer').textContent = order.customer_name || '-';
document.getElementById('detailDate').textContent = fmtDate(order.order_date);
document.getElementById('detailStatus').innerHTML = orderStatusBadge(order.status);
document.getElementById('detailIdComanda').textContent = order.id_comanda || '-';
document.getElementById('detailIdPartener').textContent = order.id_partener || '-';
document.getElementById('detailIdAdresaFact').textContent = order.id_adresa_facturare || '-';
document.getElementById('detailIdAdresaLivr').textContent = order.id_adresa_livrare || '-';
if (order.error_message) {
document.getElementById('detailError').textContent = order.error_message;
document.getElementById('detailError').style.display = '';
}
const dlvEl = document.getElementById('detailDeliveryCost');
if (dlvEl) dlvEl.textContent = order.delivery_cost > 0 ? Number(order.delivery_cost).toFixed(2) + ' lei' : '';
const dscEl = document.getElementById('detailDiscount');
if (dscEl) dscEl.textContent = order.discount_total > 0 ? '' + Number(order.discount_total).toFixed(2) + ' lei' : '';
const items = data.items || [];
if (items.length === 0) {
document.getElementById('detailItemsBody').innerHTML = '<tr><td colspan="6" class="text-center text-muted">Niciun articol</td></tr>';
return;
}
// Update totals row
const itemsTotal = items.reduce((sum, item) => sum + (Number(item.price || 0) * Number(item.quantity || 0)), 0);
document.getElementById('detailItemsTotal').textContent = itemsTotal.toFixed(2) + ' lei';
document.getElementById('detailOrderTotal').textContent = order.order_total != null ? Number(order.order_total).toFixed(2) + ' lei' : '-';
// Mobile article flat list
const mobileContainer = document.getElementById('detailItemsMobile');
if (mobileContainer) {
mobileContainer.innerHTML = '<div class="detail-item-flat">' + items.map((item, idx) => {
const codmatList = item.codmat_details?.length
? item.codmat_details.map(d => `<span class="dif-codmat-link" onclick="openLogsQuickMap('${esc(item.sku)}','${esc(item.product_name||'')}','${esc(orderNumber)}')">${esc(d.codmat)}</span>`).join(' ')
: `<span class="dif-codmat-link" onclick="openLogsQuickMap('${esc(item.sku)}','${esc(item.product_name||'')}','${esc(orderNumber)}')">${esc(item.codmat || '')}</span>`;
const valoare = (Number(item.price || 0) * Number(item.quantity || 0)).toFixed(2);
return `<div class="dif-item">
<div class="dif-row">
<span class="dif-sku">${esc(item.sku)}</span>
${codmatList}
</div>
<div class="dif-row">
<span class="dif-name">${esc(item.product_name || '')}</span>
<span class="dif-qty">x${item.quantity || 0}</span>
<span class="dif-val">${valoare} lei</span>
</div>
</div>`;
}).join('') + '</div>';
}
document.getElementById('detailItemsBody').innerHTML = items.map(item => {
const valoare = (Number(item.price || 0) * Number(item.quantity || 0)).toFixed(2);
const codmatCell = `<span class="codmat-link" onclick="openLogsQuickMap('${esc(item.sku)}', '${esc(item.product_name || '')}', '${esc(orderNumber)}')" title="Click pentru mapare">${renderCodmatCell(item)}</span>`;
return `<tr>
<td><code>${esc(item.sku)}</code></td>
<td>${esc(item.product_name || '-')}</td>
<td>${codmatCell}</td>
<td>${item.quantity || 0}</td>
<td>${item.price != null ? Number(item.price).toFixed(2) : '-'}</td>
<td class="text-end">${valoare}</td>
</tr>`;
}).join('');
} catch (err) {
document.getElementById('detailError').textContent = err.message;
document.getElementById('detailError').style.display = '';
}
} }
// ── Quick Map Modal (uses shared openQuickMap) ─── // ── Quick Map Modal (uses shared openQuickMap) ───

View File

@@ -107,7 +107,7 @@ function renderTable(mappings, showDeleted) {
? ` <span class="text-muted small">Kit · ${skuCodmatCount[m.sku]}</span><span class="kit-price-loading" data-sku="${esc(m.sku)}" style="display:none"><span class="spinner-border spinner-border-sm ms-1" style="width:0.8rem;height:0.8rem"></span></span>` ? ` <span class="text-muted small">Kit · ${skuCodmatCount[m.sku]}</span><span class="kit-price-loading" data-sku="${esc(m.sku)}" style="display:none"><span class="spinner-border spinner-border-sm ms-1" style="width:0.8rem;height:0.8rem"></span></span>`
: ''; : '';
const inactiveStyle = !m.activ && !m.sters ? 'opacity:0.6;' : ''; const inactiveStyle = !m.activ && !m.sters ? 'opacity:0.6;' : '';
html += `<div class="flat-row" style="background:#f8fafc;font-weight:600;border-top:1px solid #e5e7eb;${inactiveStyle}"> html += `<div class="flat-row" style="background:var(--surface-raised);font-weight:600;border-top:1px solid var(--border);${inactiveStyle}">
<span class="${m.activ ? 'dot dot-green' : 'dot dot-yellow'}" style="cursor:${m.sters ? 'default' : 'pointer'}" <span class="${m.activ ? 'dot dot-green' : 'dot dot-yellow'}" style="cursor:${m.sters ? 'default' : 'pointer'}"
${m.sters ? '' : `onclick="event.stopPropagation();toggleActive('${esc(m.sku)}', '${esc(m.codmat)}', ${m.activ})"`} ${m.sters ? '' : `onclick="event.stopPropagation();toggleActive('${esc(m.sku)}', '${esc(m.codmat)}', ${m.activ})"`}
title="${m.activ ? 'Activ' : 'Inactiv'}"></span> title="${m.activ ? 'Activ' : 'Inactiv'}"></span>
@@ -135,7 +135,7 @@ function renderTable(mappings, showDeleted) {
// After last CODMAT of a kit, add total row // After last CODMAT of a kit, add total row
const isLastOfKit = isKitRow && (i === mappings.length - 1 || mappings[i + 1].sku !== m.sku); const isLastOfKit = isKitRow && (i === mappings.length - 1 || mappings[i + 1].sku !== m.sku);
if (isLastOfKit) { if (isLastOfKit) {
html += `<div class="flat-row kit-total-slot text-muted small" data-sku="${esc(m.sku)}" style="padding-left:1.5rem;display:none;border-top:1px dashed #e5e7eb"></div>`; html += `<div class="flat-row kit-total-slot text-muted small" data-sku="${esc(m.sku)}" style="padding-left:1.5rem;display:none;border-top:1px dashed var(--border)"></div>`;
} }
prevSku = m.sku; prevSku = m.sku;
@@ -176,7 +176,7 @@ async function loadKitPrices(sku, container) {
if (spinner) spinner.style.display = ''; if (spinner) spinner.style.display = '';
try { try {
const res = await fetch(`/api/mappings/${encodeURIComponent(sku)}/prices`); const res = await fetch(`/api/mappings/prices?sku=${encodeURIComponent(sku)}`);
const data = await res.json(); const data = await res.json();
if (data.error) { if (data.error) {
if (spinner) spinner.innerHTML = `<small class="text-danger">${esc(data.error)}</small>`; if (spinner) spinner.innerHTML = `<small class="text-danger">${esc(data.error)}</small>`;
@@ -523,7 +523,7 @@ function showInlineAddRow() {
const row = document.createElement('div'); const row = document.createElement('div');
row.id = 'inlineAddRow'; row.id = 'inlineAddRow';
row.className = 'flat-row'; row.className = 'flat-row';
row.style.background = '#eff6ff'; row.style.background = 'var(--info-light)';
row.style.gap = '0.5rem'; row.style.gap = '0.5rem';
row.innerHTML = ` row.innerHTML = `
<input type="text" class="form-control form-control-sm" id="inlineSku" placeholder="SKU" style="width:140px"> <input type="text" class="form-control form-control-sm" id="inlineSku" placeholder="SKU" style="width:140px">

View File

@@ -15,6 +15,15 @@ document.addEventListener('DOMContentLoaded', async () => {
}); });
}); });
// Dark mode toggle
const darkToggle = document.getElementById('settDarkMode');
if (darkToggle) {
darkToggle.checked = document.documentElement.getAttribute('data-theme') === 'dark';
darkToggle.addEventListener('change', () => {
if (typeof toggleDarkMode === 'function') toggleDarkMode();
});
}
// Catalog sync toggle // Catalog sync toggle
const catChk = document.getElementById('settCatalogSyncEnabled'); const catChk = document.getElementById('settCatalogSyncEnabled');
if (catChk) catChk.addEventListener('change', () => { if (catChk) catChk.addEventListener('change', () => {
@@ -191,14 +200,14 @@ async function saveSettings() {
const data = await res.json(); const data = await res.json();
const resultEl = document.getElementById('settSaveResult'); const resultEl = document.getElementById('settSaveResult');
if (data.success) { if (data.success) {
if (resultEl) { resultEl.textContent = 'Salvat!'; resultEl.style.color = '#16a34a'; } if (resultEl) { resultEl.textContent = 'Salvat!'; resultEl.style.color = 'var(--success)'; }
setTimeout(() => { if (resultEl) resultEl.textContent = ''; }, 3000); setTimeout(() => { if (resultEl) resultEl.textContent = ''; }, 3000);
} else { } else {
if (resultEl) { resultEl.textContent = 'Eroare: ' + JSON.stringify(data); resultEl.style.color = '#dc2626'; } if (resultEl) { resultEl.textContent = 'Eroare: ' + JSON.stringify(data); resultEl.style.color = 'var(--error)'; }
} }
} catch (err) { } catch (err) {
const resultEl = document.getElementById('settSaveResult'); const resultEl = document.getElementById('settSaveResult');
if (resultEl) { resultEl.textContent = 'Eroare: ' + err.message; resultEl.style.color = '#dc2626'; } if (resultEl) { resultEl.textContent = 'Eroare: ' + err.message; resultEl.style.color = 'var(--error)'; }
} }
} }

View File

@@ -194,7 +194,7 @@ function renderMobileSegmented(containerId, pills, onSelect) {
const btnStyle = 'font-size:0.75rem;height:32px;white-space:nowrap;display:inline-flex;align-items:center;justify-content:center;gap:0.25rem;flex:1;padding:0 0.25rem'; const btnStyle = 'font-size:0.75rem;height:32px;white-space:nowrap;display:inline-flex;align-items:center;justify-content:center;gap:0.25rem;flex:1;padding:0 0.25rem';
container.innerHTML = `<div class="btn-group btn-group-sm w-100">${pills.map(p => { container.innerHTML = `<div class="btn-group btn-group-sm w-100">${pills.map(p => {
const cls = p.active ? 'btn btn-primary' : 'btn btn-outline-secondary'; const cls = p.active ? 'btn seg-active' : 'btn btn-outline-secondary';
const countColor = (!p.active && p.colorClass) ? ` class="${p.colorClass}"` : ''; const countColor = (!p.active && p.colorClass) ? ` class="${p.colorClass}"` : '';
return `<button type="button" class="${cls}" style="${btnStyle}" data-seg-value="${esc(p.value)}">${esc(p.label)} <b${countColor}>${p.count}</b></button>`; return `<button type="button" class="${cls}" style="${btnStyle}" data-seg-value="${esc(p.value)}">${esc(p.label)} <b${countColor}>${p.count}</b></button>`;
}).join('')}</div>`; }).join('')}</div>`;
@@ -344,6 +344,40 @@ async function saveQuickMapping() {
if (data.success) { if (data.success) {
bootstrap.Modal.getInstance(document.getElementById('quickMapModal')).hide(); bootstrap.Modal.getInstance(document.getElementById('quickMapModal')).hide();
if (_qmOnSave) _qmOnSave(sku, mappings); if (_qmOnSave) _qmOnSave(sku, mappings);
// Check for SKIPPED orders that can now be imported
try {
const pendingRes = await fetch(`/api/orders/by-sku/${encodeURIComponent(sku)}/pending`);
const pendingData = await pendingRes.json();
if (pendingData.count > 0) {
const banner = document.createElement('div');
banner.className = 'alert alert-info d-flex align-items-center gap-2 mt-2';
banner.style.cssText = 'position:fixed;bottom:80px;left:50%;transform:translateX(-50%);z-index:1060;min-width:300px;max-width:500px;box-shadow:var(--card-shadow)';
banner.innerHTML = `<i class="bi bi-arrow-clockwise"></i> <span>${pendingData.count} comenzi SKIPPED pot fi importate acum</span> <button class="btn btn-sm btn-primary ms-auto" id="batchRetryBtn">Importa</button> <button class="btn btn-sm btn-outline-secondary" onclick="this.parentElement.remove()">✕</button>`;
document.body.appendChild(banner);
document.getElementById('batchRetryBtn').onclick = async function() {
this.disabled = true;
this.innerHTML = '<span class="spinner-border spinner-border-sm"></span>';
try {
const retryRes = await fetch('/api/orders/batch-retry', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({order_numbers: pendingData.order_numbers})
});
const retryData = await retryRes.json();
banner.className = retryData.errors > 0 ? 'alert alert-warning d-flex align-items-center gap-2 mt-2' : 'alert alert-success d-flex align-items-center gap-2 mt-2';
banner.style.cssText = 'position:fixed;bottom:80px;left:50%;transform:translateX(-50%);z-index:1060;min-width:300px;max-width:500px;box-shadow:var(--card-shadow)';
banner.innerHTML = `<i class="bi bi-check-circle"></i> ${esc(retryData.message)} <button class="btn btn-sm btn-outline-secondary ms-auto" onclick="this.parentElement.remove()">✕</button>`;
setTimeout(() => banner.remove(), 5000);
if (typeof loadDashOrders === 'function') loadDashOrders();
} catch(e) {
banner.innerHTML = `Eroare: ${esc(e.message)} <button class="btn btn-sm btn-outline-secondary ms-auto" onclick="this.parentElement.remove()">✕</button>`;
}
};
setTimeout(() => { if (banner.parentElement) banner.remove(); }, 15000);
}
} catch(e) { /* ignore */ }
} else { } else {
alert('Eroare: ' + (data.error || 'Unknown')); alert('Eroare: ' + (data.error || 'Unknown'));
} }
@@ -352,6 +386,415 @@ async function saveQuickMapping() {
} }
} }
// ── Shared helpers (moved from dashboard.js/logs.js) ─
function fmtCost(v) {
return v > 0 ? Number(v).toFixed(2) : '';
}
function fmtNum(v) {
return Number(v).toLocaleString('ro-RO', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
function orderStatusBadge(status) {
switch ((status || '').toUpperCase()) {
case 'IMPORTED': return '<span class="badge bg-success">Importat</span>';
case 'ALREADY_IMPORTED': return '<span class="badge bg-info">Deja importat</span>';
case 'SKIPPED': return '<span class="badge bg-warning">Omis</span>';
case 'ERROR': return '<span class="badge bg-danger">Eroare</span>';
case 'CANCELLED': return '<span class="badge bg-secondary">Anulat</span>';
case 'DELETED_IN_ROA': return '<span class="badge bg-dark">Sters din ROA</span>';
default: return `<span class="badge bg-secondary">${esc(status)}</span>`;
}
}
function renderCodmatCell(item) {
if (!item.codmat_details || item.codmat_details.length === 0) {
return `<code>${esc(item.codmat || '-')}</code>`;
}
if (item.codmat_details.length === 1) {
const d = item.codmat_details[0];
if (d.direct) {
return `<code>${esc(d.codmat)}</code> <span class="badge bg-secondary" style="font-size:0.6rem;vertical-align:middle">direct</span>`;
}
return `<code>${esc(d.codmat)}</code>`;
}
return item.codmat_details.map(d =>
`<div class="small"><code>${esc(d.codmat)}</code> <span class="text-muted">\xd7${d.cantitate_roa}</span></div>`
).join('');
}
function computeDiscountSplit(items, order) {
if (order.discount_split && typeof order.discount_split === 'object')
return order.discount_split;
const byRate = {};
items.forEach(item => {
const rate = item.vat != null ? Number(item.vat) : null;
if (rate === null) return;
if (!byRate[rate]) byRate[rate] = 0;
byRate[rate] += Number(item.price || 0) * Number(item.quantity || 0);
});
const rates = Object.keys(byRate).sort((a, b) => Number(a) - Number(b));
if (rates.length === 0) return null;
const grandTotal = rates.reduce((s, r) => s + byRate[r], 0);
if (grandTotal <= 0) return null;
const split = {};
let remaining = order.discount_total;
rates.forEach((rate, i) => {
if (i === rates.length - 1) {
split[rate] = Math.round(remaining * 100) / 100;
} else {
const amt = Math.round(order.discount_total * byRate[rate] / grandTotal * 100) / 100;
split[rate] = amt;
remaining -= amt;
}
});
return split;
}
function _renderReceipt(items, order) {
const desktop = document.getElementById('detailReceipt');
const mobile = document.getElementById('detailReceiptMobile');
if (!desktop && !mobile) return;
if (!items.length) {
if (desktop) desktop.innerHTML = '';
if (mobile) mobile.innerHTML = '';
return;
}
const articole = items.reduce((s, i) => s + Number(i.price || 0) * Number(i.quantity || 0), 0);
const discount = Number(order.discount_total || 0);
const transport = Number(order.delivery_cost || 0);
const total = order.order_total != null ? fmtNum(order.order_total) : '-';
let dHtml = `<span class="text-muted">Articole: <strong class="text-body">${fmtNum(articole)}</strong></span>`;
if (discount > 0) dHtml += `<span class="text-muted">Discount: <strong class="text-danger">\u2013${fmtNum(discount)}</strong></span>`;
if (transport > 0) dHtml += `<span class="text-muted">Transport: <strong class="text-body">${fmtNum(transport)}</strong></span>`;
dHtml += `<span>Total: <strong>${total} lei</strong></span>`;
if (desktop) desktop.innerHTML = dHtml;
let mHtml = `<span class="text-muted">Art: <strong class="text-body">${fmtNum(articole)}</strong></span>`;
if (discount > 0) mHtml += `<span class="text-muted">Disc: <strong class="text-danger">\u2013${fmtNum(discount)}</strong></span>`;
if (transport > 0) mHtml += `<span class="text-muted">Transp: <strong class="text-body">${fmtNum(transport)}</strong></span>`;
mHtml += `<span>Total: <strong>${total} lei</strong></span>`;
if (mobile) mobile.innerHTML = mHtml;
}
// ── Order Detail Modal (shared) ──────────────────
/**
* Render and show the order detail modal.
* @param {string} orderNumber
* @param {object} opts
* @param {function} opts.onQuickMap - (sku, productName, orderNumber, itemIdx) => void
* @param {function} [opts.onAfterRender] - (order, items) => void
*/
async function renderOrderDetailModal(orderNumber, opts) {
opts = opts || {};
// Reset modal state
document.getElementById('detailOrderNumber').textContent = '#' + orderNumber;
document.getElementById('detailCustomer').textContent = '...';
document.getElementById('detailDate').textContent = '';
document.getElementById('detailStatus').innerHTML = '';
document.getElementById('detailIdComanda').textContent = '-';
document.getElementById('detailIdPartener').textContent = '-';
document.getElementById('detailIdAdresaFact').textContent = '-';
document.getElementById('detailIdAdresaLivr').textContent = '-';
document.getElementById('detailItemsBody').innerHTML = '<tr><td colspan="9" class="text-center">Se incarca...</td></tr>';
document.getElementById('detailError').style.display = 'none';
const retryBtn = document.getElementById('detailRetryBtn');
if (retryBtn) { retryBtn.style.display = 'none'; retryBtn.disabled = false; retryBtn.innerHTML = '<i class="bi bi-arrow-clockwise"></i> Reimporta'; retryBtn.className = 'btn btn-sm btn-outline-primary'; }
const receiptEl = document.getElementById('detailReceipt');
if (receiptEl) receiptEl.innerHTML = '';
const receiptMEl = document.getElementById('detailReceiptMobile');
if (receiptMEl) receiptMEl.innerHTML = '';
const invInfo = document.getElementById('detailInvoiceInfo');
if (invInfo) invInfo.style.display = 'none';
const mobileContainer = document.getElementById('detailItemsMobile');
if (mobileContainer) mobileContainer.innerHTML = '';
const priceCheckEl = document.getElementById('detailPriceCheck');
if (priceCheckEl) priceCheckEl.innerHTML = '';
const reconEl = document.getElementById('detailInvoiceRecon');
if (reconEl) { reconEl.innerHTML = ''; reconEl.style.display = 'none'; }
const modalEl = document.getElementById('orderDetailModal');
const existing = bootstrap.Modal.getInstance(modalEl);
if (existing) { existing.show(); } else { new bootstrap.Modal(modalEl).show(); }
try {
const res = await fetch(`/api/sync/order/${encodeURIComponent(orderNumber)}`);
const data = await res.json();
if (data.error) {
document.getElementById('detailError').textContent = data.error;
document.getElementById('detailError').style.display = '';
return;
}
const order = data.order || {};
document.getElementById('detailCustomer').textContent = order.customer_name || '-';
document.getElementById('detailDate').textContent = fmtDate(order.order_date);
document.getElementById('detailStatus').innerHTML = orderStatusBadge(order.status);
// Price check badge
const priceCheckEl = document.getElementById('detailPriceCheck');
if (priceCheckEl) {
const pc = order.price_check;
if (!pc || pc.oracle_available === false) {
priceCheckEl.innerHTML = '<span class="badge" style="background:var(--cancelled-light);color:var(--text-muted)">Preturi ROA indisponibile</span>';
} else if (pc.mismatches === 0) {
priceCheckEl.innerHTML = '<span class="badge" style="background:var(--success-light);color:var(--success-text)">✓ Preturi OK</span>';
} else {
priceCheckEl.innerHTML = `<span class="badge" style="background:var(--error-light);color:var(--error-text)">${pc.mismatches} diferente de pret</span>`;
}
}
document.getElementById('detailIdComanda').textContent = order.id_comanda || '-';
document.getElementById('detailIdPartener').textContent = order.id_partener || '-';
document.getElementById('detailIdAdresaFact').textContent = order.id_adresa_facturare || '-';
document.getElementById('detailIdAdresaLivr').textContent = order.id_adresa_livrare || '-';
// Invoice info
const inv = order.invoice;
if (inv && inv.facturat) {
const serie = inv.serie_act || '';
const numar = inv.numar_act || '';
document.getElementById('detailInvoiceNumber').textContent = serie ? `${serie} ${numar}` : numar;
document.getElementById('detailInvoiceDate').textContent = inv.data_act ? fmtDate(inv.data_act) : '-';
if (invInfo) invInfo.style.display = '';
}
// Invoice reconciliation
const reconEl = document.getElementById('detailInvoiceRecon');
if (reconEl && inv && inv.reconciliation) {
const r = inv.reconciliation;
if (r.match) {
reconEl.innerHTML = `<span class="badge" style="background:var(--success-light);color:var(--success-text)">✓ Total factura OK (${fmtNum(r.invoice_total)} lei)</span>`;
} else {
const sign = r.difference > 0 ? '+' : '';
reconEl.innerHTML = `<span class="badge" style="background:var(--error-light);color:var(--error-text)">Diferenta: ${sign}${fmtNum(r.difference)} lei</span>
<small class="text-muted ms-2">Factura: ${fmtNum(r.invoice_total)} | Comanda: ${fmtNum(r.order_total)}</small>`;
}
reconEl.style.display = '';
} else if (reconEl) {
reconEl.style.display = 'none';
}
if (order.error_message) {
document.getElementById('detailError').textContent = order.error_message;
document.getElementById('detailError').style.display = '';
}
const items = data.items || [];
if (items.length === 0) {
document.getElementById('detailItemsBody').innerHTML = '<tr><td colspan="9" class="text-center text-muted">Niciun articol</td></tr>';
return;
}
// Store items for quick map pre-population
window._detailItems = items;
const qmFn = opts.onQuickMap ? opts.onQuickMap.name || '_sharedQuickMap' : null;
// Mobile article flat list
if (mobileContainer) {
let mobileHtml = items.map((item, idx) => {
const codmatText = item.codmat_details?.length
? item.codmat_details.map(d => `<code>${esc(d.codmat)}</code>${d.direct ? ' <span class="badge bg-secondary" style="font-size:0.55rem">direct</span>' : ''}`).join(' ')
: `<code>${esc(item.codmat || '')}</code>`;
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
? `<div class="text-danger" style="font-size:0.7rem">ROA: ${fmtNum(priceInfo.pret_roa)} lei</div>`
: '';
return `<div class="dif-item">
<div class="dif-row">
<span class="dif-sku${opts.onQuickMap ? ' dif-codmat-link' : ''}" ${clickAttr}>${esc(item.sku)}</span>
${codmatText}
</div>
<div class="dif-row">
<span class="dif-name">${esc(item.product_name || '')}</span>
<span class="dif-qty">x${item.quantity || 0}</span>
<span class="dif-val">${fmtNum(valoare)} lei</span>
<span class="dif-vat text-muted" style="font-size:0.75rem">TVA ${item.vat != null ? Number(item.vat) : '?'}</span>
</div>
${priceMismatchHtml}
</div>`;
}).join('');
// Transport row (mobile)
if (order.delivery_cost > 0) {
const tVat = order.transport_vat || '21';
mobileHtml += `<div class="dif-item" style="opacity:0.7">
<div class="dif-row">
<span class="dif-name text-muted">Transport</span>
<span class="dif-qty">x1</span>
<span class="dif-val">${fmtNum(order.delivery_cost)} lei</span>
<span class="dif-vat text-muted" style="font-size:0.75rem">TVA ${tVat}</span>
</div>
</div>`;
}
// Discount rows (mobile)
if (order.discount_total > 0) {
const discSplit = computeDiscountSplit(items, order);
if (discSplit) {
Object.entries(discSplit)
.sort(([a], [b]) => Number(a) - Number(b))
.forEach(([rate, amt]) => {
if (amt > 0) mobileHtml += `<div class="dif-item" style="opacity:0.7">
<div class="dif-row">
<span class="dif-name text-muted">Discount</span>
<span class="dif-qty">x\u20131</span>
<span class="dif-val">${fmtNum(amt)} lei</span>
<span class="dif-vat text-muted" style="font-size:0.75rem">TVA ${Number(rate)}</span>
</div>
</div>`;
});
} else {
mobileHtml += `<div class="dif-item" style="opacity:0.7">
<div class="dif-row">
<span class="dif-name text-muted">Discount</span>
<span class="dif-qty">x\u20131</span>
<span class="dif-val">${fmtNum(order.discount_total)} lei</span>
</div>
</div>`;
}
}
mobileContainer.innerHTML = '<div class="detail-item-flat">' + mobileHtml + '</div>';
}
// Desktop items table
const clickAttrFn = (item, idx) => opts.onQuickMap
? `onclick="_sharedModalQuickMap('${esc(item.sku)}', '${esc(item.product_name || '')}', '${esc(orderNumber)}', ${idx})" title="Click pentru mapare"`
: '';
let tableHtml = items.map((item, idx) => {
const valoare = Number(item.price || 0) * Number(item.quantity || 0);
const priceInfo = { pret_roa: item.pret_roa, match: item.price_match };
const pretRoaHtml = priceInfo.pret_roa != null ? fmtNum(priceInfo.pret_roa) : '';
let matchDot, rowStyle;
if (priceInfo.pret_roa == null && priceInfo.match == null) {
matchDot = '<span class="dot dot-gray"></span>';
rowStyle = '';
} else if (priceInfo.match === false) {
matchDot = '<span class="dot dot-red"></span>';
rowStyle = ' style="background:var(--error-light)"';
} else {
matchDot = '<span class="dot dot-green"></span>';
rowStyle = '';
}
return `<tr${rowStyle}>
<td><code class="${opts.onQuickMap ? 'codmat-link' : ''}" ${clickAttrFn(item, idx)}>${esc(item.sku)}</code></td>
<td>${esc(item.product_name || '-')}</td>
<td>${renderCodmatCell(item)}</td>
<td class="text-end">${item.quantity || 0}</td>
<td class="text-end font-data">${item.price != null ? fmtNum(item.price) : '-'}</td>
<td class="text-end font-data">${pretRoaHtml}</td>
<td class="text-end">${item.vat != null ? Number(item.vat) : '-'}</td>
<td class="text-end font-data">${fmtNum(valoare)}</td>
<td class="text-center">${matchDot}</td>
</tr>`;
}).join('');
// Transport row
if (order.delivery_cost > 0) {
const tVat = order.transport_vat || '21';
const tCodmat = order.transport_codmat || '';
tableHtml += `<tr class="table-light">
<td></td><td class="text-muted">Transport</td>
<td>${tCodmat ? '<code>' + esc(tCodmat) + '</code>' : ''}</td>
<td class="text-end">1</td><td class="text-end font-data">${fmtNum(order.delivery_cost)}</td>
<td></td>
<td class="text-end">${tVat}</td><td class="text-end font-data">${fmtNum(order.delivery_cost)}</td>
<td></td>
</tr>`;
}
// Discount rows (split by VAT rate)
if (order.discount_total > 0) {
const dCodmat = order.discount_codmat || '';
const discSplit = computeDiscountSplit(items, order);
if (discSplit) {
Object.entries(discSplit)
.sort(([a], [b]) => Number(a) - Number(b))
.forEach(([rate, amt]) => {
if (amt > 0) tableHtml += `<tr class="table-light">
<td></td><td class="text-muted">Discount</td>
<td>${dCodmat ? '<code>' + esc(dCodmat) + '</code>' : ''}</td>
<td class="text-end">\u20131</td><td class="text-end font-data">${fmtNum(amt)}</td>
<td></td>
<td class="text-end">${Number(rate)}</td><td class="text-end font-data">\u2013${fmtNum(amt)}</td>
<td></td>
</tr>`;
});
} else {
tableHtml += `<tr class="table-light">
<td></td><td class="text-muted">Discount</td>
<td>${dCodmat ? '<code>' + esc(dCodmat) + '</code>' : ''}</td>
<td class="text-end">\u20131</td><td class="text-end font-data">${fmtNum(order.discount_total)}</td>
<td></td>
<td class="text-end">-</td><td class="text-end font-data">\u2013${fmtNum(order.discount_total)}</td>
<td></td>
</tr>`;
}
}
document.getElementById('detailItemsBody').innerHTML = tableHtml;
_renderReceipt(items, order);
// Retry button (only for ERROR/SKIPPED orders)
const retryBtn = document.getElementById('detailRetryBtn');
if (retryBtn) {
const canRetry = ['ERROR', 'SKIPPED'].includes((order.status || '').toUpperCase());
retryBtn.style.display = canRetry ? '' : 'none';
if (canRetry) {
retryBtn.onclick = async () => {
retryBtn.disabled = true;
retryBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span> Reimportare...';
try {
const res = await fetch(`/api/orders/${encodeURIComponent(orderNumber)}/retry`, { method: 'POST' });
const data = await res.json();
if (data.success) {
retryBtn.innerHTML = '<i class="bi bi-check-circle"></i> ' + (data.message || 'Reimportat');
retryBtn.className = 'btn btn-sm btn-success';
// Refresh modal after short delay
setTimeout(() => renderOrderDetailModal(orderNumber, opts), 1500);
} else {
retryBtn.innerHTML = '<i class="bi bi-exclamation-triangle"></i> ' + (data.message || 'Eroare');
retryBtn.className = 'btn btn-sm btn-danger';
setTimeout(() => {
retryBtn.innerHTML = '<i class="bi bi-arrow-clockwise"></i> Reimporta';
retryBtn.className = 'btn btn-sm btn-outline-primary';
retryBtn.disabled = false;
}, 3000);
}
} catch (err) {
retryBtn.innerHTML = 'Eroare: ' + err.message;
retryBtn.disabled = false;
}
};
}
}
if (opts.onAfterRender) opts.onAfterRender(order, items);
} catch (err) {
document.getElementById('detailError').textContent = err.message;
document.getElementById('detailError').style.display = '';
}
}
// Global quick map dispatcher — set by each page
let _sharedModalQuickMapFn = null;
function _sharedModalQuickMap(sku, productName, orderNumber, itemIdx) {
if (_sharedModalQuickMapFn) _sharedModalQuickMapFn(sku, productName, orderNumber, itemIdx);
}
// ── Dot helper ──────────────────────────────────── // ── Dot helper ────────────────────────────────────
function statusDot(status) { function statusDot(status) {
switch ((status || '').toUpperCase()) { switch ((status || '').toUpperCase()) {

View File

@@ -1,16 +1,28 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="ro" style="color-scheme: light"> <html lang="ro">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}GoMag Import Manager{% endblock %}</title> <title>{% block title %}GoMag Import Manager{% endblock %}</title>
<!-- FOUC prevention: apply saved theme before any rendering -->
<script>
try {
var t = localStorage.getItem('theme');
if (!t) t = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
if (t === 'dark') document.documentElement.setAttribute('data-theme', 'dark');
} catch(e) {}
</script>
<!-- Fonts (DESIGN.md) -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,300;0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700;1,9..40,400&family=JetBrains+Mono:wght@400;500;600&family=Space+Grotesk:wght@400;500;600;700&display=swap" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet"> <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.2/font/bootstrap-icons.css" rel="stylesheet"> <link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.2/font/bootstrap-icons.css" rel="stylesheet">
{% set rp = request.scope.get('root_path', '') %} {% set rp = request.scope.get('root_path', '') %}
<link href="{{ rp }}/static/css/style.css?v=17" rel="stylesheet"> <link href="{{ rp }}/static/css/style.css?v=25" rel="stylesheet">
</head> </head>
<body> <body>
<!-- Top Navbar --> <!-- Top Navbar (hidden on mobile via CSS) -->
<nav class="top-navbar"> <nav class="top-navbar">
<div class="navbar-brand">GoMag Import</div> <div class="navbar-brand">GoMag Import</div>
<div class="navbar-links"> <div class="navbar-links">
@@ -20,10 +32,22 @@
<a href="{{ rp }}/logs" class="nav-tab {% block nav_logs %}{% endblock %}"><span class="d-none d-md-inline">Jurnale Import</span><span class="d-md-none">Jurnale</span></a> <a href="{{ rp }}/logs" class="nav-tab {% block nav_logs %}{% endblock %}"><span class="d-none d-md-inline">Jurnale Import</span><span class="d-md-none">Jurnale</span></a>
<a href="{{ rp }}/settings" class="nav-tab {% block nav_settings %}{% endblock %}"><span class="d-none d-md-inline">Setari</span><span class="d-md-none">Setari</span></a> <a href="{{ rp }}/settings" class="nav-tab {% block nav_settings %}{% endblock %}"><span class="d-none d-md-inline">Setari</span><span class="d-md-none">Setari</span></a>
</div> </div>
<button class="dark-toggle" onclick="toggleDarkMode()" title="Comuta tema" aria-label="Comuta tema intunecata">
<i class="bi bi-sun-fill"></i>
</button>
</nav>
<!-- Bottom Nav (mobile only, shown via CSS) -->
<nav class="bottom-nav">
<a href="{{ rp }}/" class="bottom-nav-item {% block bnav_dashboard %}{% endblock %}"><i class="bi bi-speedometer2"></i><span>Dashboard</span></a>
<a href="{{ rp }}/mappings" class="bottom-nav-item {% block bnav_mappings %}{% endblock %}"><i class="bi bi-arrow-left-right"></i><span>Mapari</span></a>
<a href="{{ rp }}/missing-skus" class="bottom-nav-item {% block bnav_missing %}{% endblock %}"><i class="bi bi-exclamation-triangle"></i><span>Lipsa</span></a>
<a href="{{ rp }}/logs" class="bottom-nav-item {% block bnav_logs %}{% endblock %}"><i class="bi bi-journal-text"></i><span>Jurnale</span></a>
<a href="{{ rp }}/settings" class="bottom-nav-item {% block bnav_settings %}{% endblock %}"><i class="bi bi-gear"></i><span>Setari</span></a>
</nav> </nav>
<!-- Main content --> <!-- Main content -->
<main class="main-content"> <main class="main-content {% block main_class %}{% endblock %}">
{% block content %}{% endblock %} {% block content %}{% endblock %}
</main> </main>
@@ -39,7 +63,7 @@
<div style="margin-bottom:8px; font-size:0.85rem"> <div style="margin-bottom:8px; font-size:0.85rem">
<small class="text-muted">Produs:</small> <strong id="qmProductName"></strong> <small class="text-muted">Produs:</small> <strong id="qmProductName"></strong>
</div> </div>
<div class="qm-row" style="font-size:0.7rem; color:#9ca3af; padding:0 0 2px"> <div class="qm-row" style="font-size:0.7rem; color:var(--text-muted); padding:0 0 2px">
<span style="flex:1">CODMAT</span> <span style="flex:1">CODMAT</span>
<span style="width:70px">Cant.</span> <span style="width:70px">Cant.</span>
<span style="width:30px"></span> <span style="width:30px"></span>
@@ -59,9 +83,88 @@
</div> </div>
</div> </div>
<!-- Shared Order Detail Modal -->
<div class="modal fade" id="orderDetailModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Comanda <code id="detailOrderNumber"></code></h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="row mb-3">
<div class="col-md-6">
<small class="text-muted">Client:</small> <strong id="detailCustomer"></strong><br>
<small class="text-muted">Data comanda:</small> <span id="detailDate"></span><br>
<small class="text-muted">Status:</small> <span id="detailStatus"></span><span id="detailPriceCheck" class="ms-2"></span>
</div>
<div class="col-md-6">
<small class="text-muted">ID Comanda ROA:</small> <span id="detailIdComanda">-</span><br>
<small class="text-muted">ID Partener:</small> <span id="detailIdPartener">-</span><br>
<small class="text-muted">ID Adr. Facturare:</small> <span id="detailIdAdresaFact">-</span><br>
<small class="text-muted">ID Adr. Livrare:</small> <span id="detailIdAdresaLivr">-</span>
<div id="detailInvoiceInfo" style="display:none; margin-top:4px;">
<small class="text-muted">Factura:</small> <span id="detailInvoiceNumber"></span>
<span class="ms-2"><small class="text-muted">din</small> <span id="detailInvoiceDate"></span></span>
<div id="detailInvoiceRecon" class="mt-1" style="display:none"></div>
</div>
</div>
</div>
<div class="table-responsive d-none d-md-block">
<table class="table table-sm table-bordered mb-0">
<thead class="table-light">
<tr>
<th>SKU</th>
<th>Produs</th>
<th>CODMAT</th>
<th class="text-end">Cant.</th>
<th class="text-end">Pret GoMag</th>
<th class="text-end">Pret ROA</th>
<th class="text-end">TVA%</th>
<th class="text-end">Valoare</th>
<th class="text-center"></th>
</tr>
</thead>
<tbody id="detailItemsBody">
</tbody>
</table>
<div id="detailReceipt" class="d-flex flex-wrap gap-2 mt-1 justify-content-end"></div>
</div>
<div class="d-md-none" id="detailItemsMobile"></div>
<div id="detailReceiptMobile" class="d-flex flex-wrap gap-2 mt-1 d-md-none justify-content-end"></div>
<div id="detailError" class="alert alert-danger mt-3" style="display:none;"></div>
</div>
<div class="modal-footer">
<button type="button" id="detailRetryBtn" class="btn btn-sm btn-outline-primary" style="display:none"><i class="bi bi-arrow-clockwise"></i> Reimporta</button>
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Inchide</button>
</div>
</div>
</div>
</div>
<script>window.ROOT_PATH = "{{ rp }}";</script> <script>window.ROOT_PATH = "{{ rp }}";</script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
<script src="{{ rp }}/static/js/shared.js?v=12"></script> <script src="{{ rp }}/static/js/shared.js?v=20"></script>
<script>
// Dark mode toggle
function toggleDarkMode() {
var isDark = document.documentElement.getAttribute('data-theme') === 'dark';
var newTheme = isDark ? 'light' : 'dark';
document.documentElement.setAttribute('data-theme', newTheme);
try { localStorage.setItem('theme', newTheme); } catch(e) {}
updateDarkToggleIcon();
// Sync settings page toggle if present
var settToggle = document.getElementById('settDarkMode');
if (settToggle) settToggle.checked = (newTheme === 'dark');
}
function updateDarkToggleIcon() {
var isDark = document.documentElement.getAttribute('data-theme') === 'dark';
document.querySelectorAll('.dark-toggle i').forEach(function(el) {
el.className = isDark ? 'bi bi-moon-fill' : 'bi bi-sun-fill';
});
}
updateDarkToggleIcon();
</script>
{% block scripts %}{% endblock %} {% block scripts %}{% endblock %}
</body> </body>
</html> </html>

View File

@@ -1,10 +1,13 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Dashboard - GoMag Import{% endblock %} {% block title %}Dashboard - GoMag Import{% endblock %}
{% block nav_dashboard %}active{% endblock %} {% block nav_dashboard %}active{% endblock %}
{% block bnav_dashboard %}active{% endblock %}
{% block content %} {% block content %}
<h4 class="mb-4">Panou de Comanda</h4> <h4 class="mb-4">Panou de Comanda</h4>
<div id="welcomeCard" style="display:none"></div>
<!-- Sync Card (unified two-row panel) --> <!-- Sync Card (unified two-row panel) -->
<div class="sync-card"> <div class="sync-card">
<!-- TOP ROW: Status + Controls --> <!-- TOP ROW: Status + Controls -->
@@ -48,19 +51,17 @@
<span>Comenzi</span> <span>Comenzi</span>
</div> </div>
<div class="card-body py-2 px-3"> <div class="card-body py-2 px-3">
<div id="attentionCard"></div>
<div class="filter-bar" id="ordersFilterBar"> <div class="filter-bar" id="ordersFilterBar">
<!-- Period dropdown --> <!-- Period preset buttons -->
<select id="periodSelect" class="select-compact"> <div class="period-presets">
<option value="1">1 zi</option> <button class="preset-btn" data-days="1">Azi</button>
<option value="2">2 zile</option> <button class="preset-btn active" data-days="3">3 zile</button>
<option value="3">3 zile</option> <button class="preset-btn" data-days="7">7 zile</button>
<option value="7" selected>7 zile</option> <button class="preset-btn" data-days="30">30 zile</button>
<option value="30">30 zile</option> <button class="preset-btn" data-days="custom">Custom</button>
<option value="90">3 luni</option> </div>
<option value="0">Toate</option> <!-- Custom date range (hidden until 'Custom' clicked) -->
<option value="custom">Perioada personalizata...</option>
</select>
<!-- Custom date range (hidden until 'custom' selected) -->
<div class="period-custom-range" id="customRangeInputs"> <div class="period-custom-range" id="customRangeInputs">
<input type="date" id="periodStart" class="select-compact"> <input type="date" id="periodStart" class="select-compact">
<span>&#8212;</span> <span>&#8212;</span>
@@ -77,8 +78,8 @@
<button class="filter-pill d-none d-md-inline-flex" data-status="CANCELLED">Anulate <span class="filter-count fc-dark" id="cntCanc">0</span></button> <button class="filter-pill d-none d-md-inline-flex" data-status="CANCELLED">Anulate <span class="filter-count fc-dark" id="cntCanc">0</span></button>
<button class="btn btn-sm btn-outline-secondary d-none d-md-inline-flex" id="btnRefreshInvoices" onclick="refreshInvoices()" title="Actualizeaza status facturi din Oracle">&#8635;</button> <button class="btn btn-sm btn-outline-secondary d-none d-md-inline-flex" id="btnRefreshInvoices" onclick="refreshInvoices()" title="Actualizeaza status facturi din Oracle">&#8635;</button>
</div> </div>
<div class="d-md-none mb-2 d-flex align-items-center gap-2"> <div class="d-md-none mb-2 d-flex align-items-center gap-2" style="max-width:100%;overflow:hidden">
<div class="flex-grow-1" id="dashMobileSeg"></div> <div class="flex-grow-1" id="dashMobileSeg" style="min-width:0;overflow-x:auto"></div>
<button class="btn btn-sm btn-outline-secondary" id="btnRefreshInvoicesMobile" onclick="refreshInvoices()" title="Actualizeaza facturi" style="padding:4px 8px; font-size:1rem; line-height:1">&#8635;</button> <button class="btn btn-sm btn-outline-secondary" id="btnRefreshInvoicesMobile" onclick="refreshInvoices()" title="Actualizeaza facturi" style="padding:4px 8px; font-size:1rem; line-height:1">&#8635;</button>
</div> </div>
</div> </div>
@@ -98,10 +99,11 @@
<th class="text-end">Discount</th> <th class="text-end">Discount</th>
<th class="text-end">Total</th> <th class="text-end">Total</th>
<th style="width:28px" title="Facturat">F</th> <th style="width:28px" title="Facturat">F</th>
<th class="text-center" style="width:30px" title="Preturi ROA"></th>
</tr> </tr>
</thead> </thead>
<tbody id="dashOrdersBody"> <tbody id="dashOrdersBody">
<tr><td colspan="9" class="text-center text-muted py-3">Se incarca...</td></tr> <tr><td colspan="10" class="text-center text-muted py-3">Se incarca...</td></tr>
</tbody> </tbody>
</table> </table>
</div> </div>
@@ -109,64 +111,8 @@
<div id="dashPagination" class="pag-strip pag-strip-bottom"></div> <div id="dashPagination" class="pag-strip pag-strip-bottom"></div>
</div> </div>
<!-- Order Detail Modal -->
<div class="modal fade" id="orderDetailModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Comanda <code id="detailOrderNumber"></code></h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="row mb-3">
<div class="col-md-6">
<small class="text-muted">Client:</small> <strong id="detailCustomer"></strong><br>
<small class="text-muted">Data comanda:</small> <span id="detailDate"></span><br>
<small class="text-muted">Status:</small> <span id="detailStatus"></span>
</div>
<div class="col-md-6">
<small class="text-muted">ID Comanda ROA:</small> <span id="detailIdComanda">-</span><br>
<small class="text-muted">ID Partener:</small> <span id="detailIdPartener">-</span><br>
<small class="text-muted">ID Adr. Facturare:</small> <span id="detailIdAdresaFact">-</span><br>
<small class="text-muted">ID Adr. Livrare:</small> <span id="detailIdAdresaLivr">-</span>
<div id="detailInvoiceInfo" style="display:none; margin-top:4px;">
<small class="text-muted">Factura:</small> <span id="detailInvoiceNumber"></span>
<span class="ms-2"><small class="text-muted">din</small> <span id="detailInvoiceDate"></span></span>
</div>
</div>
</div>
<div class="table-responsive d-none d-md-block">
<table class="table table-sm table-bordered mb-0">
<thead class="table-light">
<tr>
<th>SKU</th>
<th>Produs</th>
<th>CODMAT</th>
<th class="text-end">Cant.</th>
<th class="text-end">Pret</th>
<th class="text-end">TVA%</th>
<th class="text-end">Valoare</th>
</tr>
</thead>
<tbody id="detailItemsBody">
</tbody>
</table>
<div id="detailReceipt" class="d-flex flex-wrap gap-2 mt-1 justify-content-end"></div>
</div>
<div class="d-md-none" id="detailItemsMobile"></div>
<div id="detailReceiptMobile" class="d-flex flex-wrap gap-2 mt-1 d-md-none justify-content-end"></div>
<div id="detailError" class="alert alert-danger mt-3" style="display:none;"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Inchide</button>
</div>
</div>
</div>
</div>
<!-- Quick Map Modal (used from order detail) -->
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
<script src="{{ request.scope.get('root_path', '') }}/static/js/dashboard.js?v=25"></script> <script src="{{ request.scope.get('root_path', '') }}/static/js/dashboard.js?v=32"></script>
{% endblock %} {% endblock %}

View File

@@ -1,6 +1,7 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Jurnale Import - GoMag Import{% endblock %} {% block title %}Jurnale Import - GoMag Import{% endblock %}
{% block nav_logs %}active{% endblock %} {% block nav_logs %}active{% endblock %}
{% block bnav_logs %}active{% endblock %}
{% block content %} {% block content %}
<h4 class="mb-4">Jurnale Import</h4> <h4 class="mb-4">Jurnale Import</h4>
@@ -56,7 +57,7 @@
<button class="filter-pill d-none d-md-inline-flex" data-log-status="SKIPPED">Omise <span class="filter-count fc-yellow" id="countSkipped">0</span></button> <button class="filter-pill d-none d-md-inline-flex" data-log-status="SKIPPED">Omise <span class="filter-count fc-yellow" id="countSkipped">0</span></button>
<button class="filter-pill d-none d-md-inline-flex" data-log-status="ERROR">Erori <span class="filter-count fc-red" id="countError">0</span></button> <button class="filter-pill d-none d-md-inline-flex" data-log-status="ERROR">Erori <span class="filter-count fc-red" id="countError">0</span></button>
</div> </div>
<div class="d-md-none mb-2" id="logsMobileSeg"></div> <div class="d-md-none mb-2" id="logsMobileSeg" style="overflow-x:auto"></div>
<!-- Orders table --> <!-- Orders table -->
<div class="card mb-3"> <div class="card mb-3">
@@ -96,65 +97,10 @@
</div> </div>
</div> </div>
<!-- Order Detail Modal -->
<div class="modal fade" id="orderDetailModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Comanda <code id="detailOrderNumber"></code></h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="row mb-3">
<div class="col-md-6">
<small class="text-muted">Client:</small> <strong id="detailCustomer"></strong><br>
<small class="text-muted">Data comanda:</small> <span id="detailDate"></span><br>
<small class="text-muted">Status:</small> <span id="detailStatus"></span>
</div>
<div class="col-md-6">
<small class="text-muted">ID Comanda ROA:</small> <span id="detailIdComanda">-</span><br>
<small class="text-muted">ID Partener:</small> <span id="detailIdPartener">-</span><br>
<small class="text-muted">ID Adr. Facturare:</small> <span id="detailIdAdresaFact">-</span><br>
<small class="text-muted">ID Adr. Livrare:</small> <span id="detailIdAdresaLivr">-</span>
</div>
</div>
<div id="detailTotals" class="d-flex gap-3 mb-2 flex-wrap" style="font-size:0.875rem">
<span><small class="text-muted">Valoare:</small> <strong id="detailItemsTotal">-</strong></span>
<span id="detailDiscountWrap"><small class="text-muted">Discount:</small> <strong id="detailDiscount">-</strong></span>
<span id="detailDeliveryWrap"><small class="text-muted">Transport:</small> <strong id="detailDeliveryCost">-</strong></span>
<span><small class="text-muted">Total:</small> <strong id="detailOrderTotal">-</strong></span>
</div>
<div class="table-responsive d-none d-md-block">
<table class="table table-sm table-bordered mb-0">
<thead class="table-light">
<tr>
<th>SKU</th>
<th>Produs</th>
<th>CODMAT</th>
<th>Cant.</th>
<th>Pret</th>
<th class="text-end">Valoare</th>
</tr>
</thead>
<tbody id="detailItemsBody">
</tbody>
</table>
</div>
<div class="d-md-none" id="detailItemsMobile"></div>
<div id="detailError" class="alert alert-danger mt-3" style="display:none;"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Inchide</button>
</div>
</div>
</div>
</div>
<!-- Quick Map Modal (used from order detail) -->
<!-- Hidden field for pre-selected run from URL/server --> <!-- Hidden field for pre-selected run from URL/server -->
<input type="hidden" id="preselectedRun" value="{{ selected_run }}"> <input type="hidden" id="preselectedRun" value="{{ selected_run }}">
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
<script src="{{ request.scope.get('root_path', '') }}/static/js/logs.js?v=11"></script> <script src="{{ request.scope.get('root_path', '') }}/static/js/logs.js?v=14"></script>
{% endblock %} {% endblock %}

View File

@@ -1,15 +1,24 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Mapari SKU - GoMag Import{% endblock %} {% block title %}Mapari SKU - GoMag Import{% endblock %}
{% block nav_mappings %}active{% endblock %} {% block nav_mappings %}active{% endblock %}
{% block bnav_mappings %}active{% endblock %}
{% block content %} {% block content %}
<div class="d-flex justify-content-between align-items-center mb-4"> <div class="d-flex justify-content-between align-items-center mb-4">
<h4 class="mb-0">Mapari SKU</h4> <h4 class="mb-0">Mapari SKU</h4>
<div class="d-flex align-items-center gap-2"> <div class="d-flex align-items-center gap-2">
<!-- Desktop buttons --> <!-- Desktop Import/Export dropdown -->
<button class="btn btn-sm btn-outline-secondary d-none d-md-inline-flex" onclick="downloadTemplate()"><i class="bi bi-file-earmark-arrow-down"></i> Template CSV</button> <div class="dropdown d-none d-md-inline-block">
<button class="btn btn-sm btn-outline-secondary d-none d-md-inline-flex" onclick="exportCsv()"><i class="bi bi-download"></i> Export CSV</button> <button class="btn btn-sm btn-outline-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown">
<button class="btn btn-sm btn-outline-primary d-none d-md-inline-flex" data-bs-toggle="modal" data-bs-target="#importModal"><i class="bi bi-upload"></i> Import CSV</button> <i class="bi bi-file-earmark-spreadsheet"></i> Import/Export
</button>
<ul class="dropdown-menu">
<li><a class="dropdown-item" href="#" onclick="downloadTemplate(); return false"><i class="bi bi-file-earmark-arrow-down me-1"></i> Download Template CSV</a></li>
<li><a class="dropdown-item" href="#" onclick="exportCsv(); return false"><i class="bi bi-download me-1"></i> Export CSV</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="#" data-bs-toggle="modal" data-bs-target="#importModal"><i class="bi bi-upload me-1"></i> Import CSV</a></li>
</ul>
</div>
<button class="btn btn-sm btn-primary" onclick="showInlineAddRow()"><i class="bi bi-plus-lg"></i> <span class="d-none d-md-inline">Adauga Mapare</span><span class="d-md-none">Mapare</span></button> <button class="btn btn-sm btn-primary" onclick="showInlineAddRow()"><i class="bi bi-plus-lg"></i> <span class="d-none d-md-inline">Adauga Mapare</span><span class="d-md-none">Mapare</span></button>
<button class="btn btn-sm btn-outline-secondary d-none d-md-inline-flex" data-bs-toggle="modal" data-bs-target="#addModal"><i class="bi bi-box-arrow-up-right"></i> Formular complet</button> <button class="btn btn-sm btn-outline-secondary d-none d-md-inline-flex" data-bs-toggle="modal" data-bs-target="#addModal"><i class="bi bi-box-arrow-up-right"></i> Formular complet</button>
<!-- Mobile ⋯ dropdown --> <!-- Mobile ⋯ dropdown -->
@@ -150,5 +159,5 @@
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
<script src="{{ request.scope.get('root_path', '') }}/static/js/mappings.js?v=11"></script> <script src="{{ request.scope.get('root_path', '') }}/static/js/mappings.js?v=14"></script>
{% endblock %} {% endblock %}

View File

@@ -1,6 +1,7 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}SKU-uri Lipsa - GoMag Import{% endblock %} {% block title %}SKU-uri Lipsa - GoMag Import{% endblock %}
{% block nav_missing %}active{% endblock %} {% block nav_missing %}active{% endblock %}
{% block bnav_missing %}active{% endblock %}
{% block content %} {% block content %}
<div class="d-flex justify-content-between align-items-center mb-4"> <div class="d-flex justify-content-between align-items-center mb-4">

View File

@@ -1,10 +1,23 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Setari - GoMag Import{% endblock %} {% block title %}Setari - GoMag Import{% endblock %}
{% block nav_settings %}active{% endblock %} {% block nav_settings %}active{% endblock %}
{% block bnav_settings %}active{% endblock %}
{% block main_class %}constrained{% endblock %}
{% block content %} {% block content %}
<h4 class="mb-3">Setari</h4> <h4 class="mb-3">Setari</h4>
<!-- Dark mode toggle -->
<div class="theme-toggle-card">
<div>
<i class="bi bi-moon-fill me-2"></i>
<label for="settDarkMode">Mod intunecat</label>
</div>
<div class="form-check form-switch mb-0">
<input class="form-check-input" type="checkbox" role="switch" id="settDarkMode" style="width:2.5rem;height:1.25rem">
</div>
</div>
<div class="row g-3 mb-3"> <div class="row g-3 mb-3">
<!-- GoMag API card --> <!-- GoMag API card -->
<div class="col-md-6"> <div class="col-md-6">
@@ -144,82 +157,89 @@
</div> </div>
</div> </div>
<div class="row g-3 mb-3"> <div class="mt-4">
<div class="col-md-6"> <button class="btn btn-sm btn-outline-secondary" type="button" data-bs-toggle="collapse" data-bs-target="#advancedSettings" aria-expanded="false" title="Modificati doar la indicatia echipei tehnice">
<div class="card h-100"> <i class="bi bi-gear"></i> Setari avansate
<div class="card-header py-2 px-3 fw-semibold">Dashboard</div> </button>
<div class="card-body py-2 px-3"> <div class="collapse mt-2" id="advancedSettings">
<div class="mb-2"> <div class="row g-3 mb-3">
<label class="form-label mb-0 small">Interval polling (secunde)</label> <div class="col-md-6">
<input type="number" class="form-control form-control-sm" id="settDashPollSeconds" value="5" min="1" max="300"> <div class="card h-100">
<div class="form-text" style="font-size:0.75rem">Cât de des verifică dashboard-ul starea sync-ului (implicit 5s)</div> <div class="card-header py-2 px-3 fw-semibold">Dashboard</div>
</div> <div class="card-body py-2 px-3">
</div> <div class="mb-2">
</div> <label class="form-label mb-0 small">Interval polling (secunde)</label>
</div> <input type="number" class="form-control form-control-sm" id="settDashPollSeconds" value="5" min="1" max="300">
<div class="col-md-6"> <div class="form-text" style="font-size:0.75rem">Cât de des verifică dashboard-ul starea sync-ului (implicit 5s)</div>
<div class="card h-100">
<div class="card-header py-2 px-3 fw-semibold">Pricing Kituri / Pachete</div>
<div class="card-body py-2 px-3">
<div class="mb-2">
<div class="form-check">
<input class="form-check-input" type="radio" name="kitPricingMode" id="kitModeOff" value="" checked>
<label class="form-check-label small" for="kitModeOff">Dezactivat</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="kitPricingMode" id="kitModeDistributed" value="distributed">
<label class="form-check-label small" for="kitModeDistributed">Distribuire discount în preț</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="kitPricingMode" id="kitModeSeparate" value="separate_line">
<label class="form-check-label small" for="kitModeSeparate">Linie discount separată</label>
</div>
</div>
<div id="kitModeBFields" style="display:none">
<div class="mb-2">
<label class="form-label mb-0 small">Kit Discount CODMAT</label>
<div class="position-relative">
<input type="text" class="form-control form-control-sm" id="settKitDiscountCodmat" placeholder="ex: DISCOUNT_KIT" autocomplete="off">
<div class="autocomplete-dropdown d-none" id="settKitDiscountAc"></div>
</div> </div>
</div> </div>
<div class="mb-2"> </div>
<label class="form-label mb-0 small">Kit Discount Politică</label> </div>
<select class="form-select form-select-sm" id="settKitDiscountIdPol"> <div class="col-md-6">
<option value="">— implicită —</option> <div class="card h-100">
</select> <div class="card-header py-2 px-3 fw-semibold">Pricing Kituri / Pachete</div>
<div class="card-body py-2 px-3">
<div class="mb-2">
<div class="form-check">
<input class="form-check-input" type="radio" name="kitPricingMode" id="kitModeOff" value="" checked>
<label class="form-check-label small" for="kitModeOff">Dezactivat</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="kitPricingMode" id="kitModeDistributed" value="distributed">
<label class="form-check-label small" for="kitModeDistributed">Distribuire discount în preț</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="kitPricingMode" id="kitModeSeparate" value="separate_line">
<label class="form-check-label small" for="kitModeSeparate">Linie discount separată</label>
</div>
</div>
<div id="kitModeBFields" style="display:none">
<div class="mb-2">
<label class="form-label mb-0 small">Kit Discount CODMAT</label>
<div class="position-relative">
<input type="text" class="form-control form-control-sm" id="settKitDiscountCodmat" placeholder="ex: DISCOUNT_KIT" autocomplete="off">
<div class="autocomplete-dropdown d-none" id="settKitDiscountAc"></div>
</div>
</div>
<div class="mb-2">
<label class="form-label mb-0 small">Kit Discount Politică</label>
<select class="form-select form-select-sm" id="settKitDiscountIdPol">
<option value="">— implicită —</option>
</select>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div>
</div>
<div class="row g-3 mb-3"> <div class="row g-3 mb-3">
<div class="col-md-6"> <div class="col-md-6">
<div class="card h-100"> <div class="card h-100">
<div class="card-header py-2 px-3 fw-semibold">Sincronizare Prețuri</div> <div class="card-header py-2 px-3 fw-semibold">Sincronizare Prețuri</div>
<div class="card-body py-2 px-3"> <div class="card-body py-2 px-3">
<div class="form-check mb-2"> <div class="form-check mb-2">
<input type="checkbox" class="form-check-input" id="settPriceSyncEnabled" checked> <input type="checkbox" class="form-check-input" id="settPriceSyncEnabled" checked>
<label class="form-check-label small" for="settPriceSyncEnabled">Sync automat prețuri din comenzi</label> <label class="form-check-label small" for="settPriceSyncEnabled">Sync automat prețuri din comenzi</label>
</div> </div>
<div class="form-check mb-2"> <div class="form-check mb-2">
<input type="checkbox" class="form-check-input" id="settCatalogSyncEnabled"> <input type="checkbox" class="form-check-input" id="settCatalogSyncEnabled">
<label class="form-check-label small" for="settCatalogSyncEnabled">Sync prețuri din catalog GoMag</label> <label class="form-check-label small" for="settCatalogSyncEnabled">Sync prețuri din catalog GoMag</label>
</div> </div>
<div id="catalogSyncOptions" style="display:none"> <div id="catalogSyncOptions" style="display:none">
<div class="mb-2"> <div class="mb-2">
<label class="form-label mb-0 small">Program</label> <label class="form-label mb-0 small">Program</label>
<select class="form-select form-select-sm" id="settPriceSyncSchedule"> <select class="form-select form-select-sm" id="settPriceSyncSchedule">
<option value="">Doar manual</option> <option value="">Doar manual</option>
<option value="daily_03:00">Zilnic la 03:00</option> <option value="daily_03:00">Zilnic la 03:00</option>
<option value="daily_06:00">Zilnic la 06:00</option> <option value="daily_06:00">Zilnic la 06:00</option>
</select> </select>
</div>
</div>
<div id="settPriceSyncStatus" class="text-muted small mt-2"></div>
<button class="btn btn-sm btn-outline-primary mt-2" id="btnCatalogSync" onclick="startCatalogSync()">Sincronizează acum</button>
</div> </div>
</div> </div>
<div id="settPriceSyncStatus" class="text-muted small mt-2"></div>
<button class="btn btn-sm btn-outline-primary mt-2" id="btnCatalogSync" onclick="startCatalogSync()">Sincronizează acum</button>
</div> </div>
</div> </div>
</div> </div>
@@ -233,5 +253,5 @@
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
<script src="{{ request.scope.get('root_path', '') }}/static/js/settings.js?v=7"></script> <script src="{{ request.scope.get('root_path', '') }}/static/js/settings.js?v=9"></script>
{% endblock %} {% endblock %}

View File

@@ -0,0 +1,105 @@
"""
E2E tests for DESIGN.md migration (Commit 0.5).
Tests: dark toggle, FOUC prevention, bottom nav, active tab amber, dark contrast.
"""
import pytest
pytestmark = [pytest.mark.e2e]
def test_dark_mode_toggle(page, app_url):
"""Dark toggle switches theme and persists in localStorage."""
page.goto(f"{app_url}/settings")
page.wait_for_load_state("networkidle")
# Settings page has the dark mode toggle
toggle = page.locator("#settDarkMode")
assert toggle.is_visible()
# Start in light mode
theme = page.evaluate("document.documentElement.getAttribute('data-theme')")
if theme == "dark":
toggle.click()
page.wait_for_timeout(200)
# Toggle to dark
toggle.click()
page.wait_for_timeout(200)
assert page.evaluate("document.documentElement.getAttribute('data-theme')") == "dark"
assert page.evaluate("localStorage.getItem('theme')") == "dark"
# Toggle back to light
toggle.click()
page.wait_for_timeout(200)
assert page.evaluate("document.documentElement.getAttribute('data-theme')") != "dark"
assert page.evaluate("localStorage.getItem('theme')") == "light"
def test_fouc_prevention(page, app_url):
"""Theme is applied before CSS loads (inline script in <head>)."""
# Set dark theme in localStorage before navigation
page.goto(f"{app_url}/")
page.evaluate("localStorage.setItem('theme', 'dark')")
# Navigate fresh — the inline script should apply dark before paint
page.goto(f"{app_url}/")
# Check immediately (before networkidle) that data-theme is set
theme = page.evaluate("document.documentElement.getAttribute('data-theme')")
assert theme == "dark", "FOUC: dark theme not applied before first paint"
# Cleanup
page.evaluate("localStorage.removeItem('theme')")
def test_bottom_nav_visible_on_mobile(page, app_url):
"""Bottom nav is visible on mobile viewport, top navbar is hidden."""
page.set_viewport_size({"width": 375, "height": 812})
page.goto(f"{app_url}/")
page.wait_for_load_state("networkidle")
bottom_nav = page.locator(".bottom-nav")
top_navbar = page.locator(".top-navbar")
assert bottom_nav.is_visible(), "Bottom nav should be visible on mobile"
assert not top_navbar.is_visible(), "Top navbar should be hidden on mobile"
# Check 5 tabs exist
tabs = page.locator(".bottom-nav-item")
assert tabs.count() == 5
def test_active_tab_amber_accent(page, app_url):
"""Active nav tab uses amber accent color, not blue."""
page.goto(f"{app_url}/")
page.wait_for_load_state("networkidle")
active_tab = page.locator(".nav-tab.active")
assert active_tab.count() >= 1
# Get computed color of active tab
color = page.evaluate("""
() => getComputedStyle(document.querySelector('.nav-tab.active')).color
""")
# Amber #D97706 = rgb(217, 119, 6)
assert "217" in color and "119" in color, f"Active tab color should be amber, got: {color}"
def test_dark_mode_contrast(page, app_url):
"""Dark mode has proper contrast — bg is dark, text is light."""
page.goto(f"{app_url}/")
page.wait_for_load_state("networkidle")
# Enable dark mode
page.evaluate("document.documentElement.setAttribute('data-theme', 'dark')")
page.wait_for_timeout(100)
bg = page.evaluate("getComputedStyle(document.body).backgroundColor")
color = page.evaluate("getComputedStyle(document.body).color")
# bg should be dark (#121212 = rgb(18, 18, 18))
assert "18" in bg, f"Dark mode bg should be dark, got: {bg}"
# text should be light (#E8E4DD = rgb(232, 228, 221))
assert "232" in color or "228" in color, f"Dark mode text should be light, got: {color}"
# Cleanup
page.evaluate("document.documentElement.removeAttribute('data-theme')")

View File

@@ -29,7 +29,7 @@ def test_order_detail_items_table_columns(page: Page, app_url: str):
texts = headers.all_text_contents() texts = headers.all_text_contents()
# Current columns (may evolve — check dashboard.html for source of truth) # Current columns (may evolve — check dashboard.html for source of truth)
required_columns = ["SKU", "Produs", "CODMAT", "Cant.", "Pret", "Valoare"] required_columns = ["SKU", "Produs", "CODMAT", "Cant.", "Pret GoMag", "Pret ROA", "Valoare"]
for col in required_columns: for col in required_columns:
assert col in texts, f"Column '{col}' missing from order detail items table. Found: {texts}" assert col in texts, f"Column '{col}' missing from order detail items table. Found: {texts}"
@@ -51,5 +51,5 @@ def test_dashboard_navigates_to_logs(page: Page, app_url: str):
page.goto(f"{app_url}/") page.goto(f"{app_url}/")
page.wait_for_load_state("networkidle") page.wait_for_load_state("networkidle")
logs_link = page.locator("a[href='/logs']") logs_link = page.locator(".top-navbar a[href='/logs'], .bottom-nav a[href='/logs']")
expect(logs_link).to_be_visible() expect(logs_link.first).to_be_visible()

View File

@@ -89,14 +89,14 @@ def test_responsive_page(
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def test_mobile_navbar_visible(pw_browser, base_url: str): def test_mobile_navbar_visible(pw_browser, base_url: str):
"""Mobile viewport: navbar should still be visible and functional.""" """Mobile viewport: bottom nav should be visible (top navbar hidden on mobile)."""
context = pw_browser.new_context(viewport=VIEWPORTS["mobile"]) context = pw_browser.new_context(viewport=VIEWPORTS["mobile"])
page = context.new_page() page = context.new_page()
try: try:
page.goto(base_url, wait_until="networkidle", timeout=15_000) page.goto(base_url, wait_until="networkidle", timeout=15_000)
# Custom navbar: .top-navbar with .navbar-brand # On mobile, top-navbar is hidden and bottom-nav is shown
navbar = page.locator(".top-navbar") bottom_nav = page.locator(".bottom-nav")
expect(navbar).to_be_visible() expect(bottom_nav).to_be_visible()
finally: finally:
context.close() context.close()