Merge feat/operator-shield into main
This commit is contained in:
@@ -332,6 +332,7 @@ def init_sqlite():
|
||||
("discount_total", "REAL"),
|
||||
("web_status", "TEXT"),
|
||||
("discount_split", "TEXT"),
|
||||
("price_match", "INTEGER"),
|
||||
]:
|
||||
if col not in order_cols:
|
||||
conn.execute(f"ALTER TABLE orders ADD COLUMN {col} {typedef}")
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import asyncio
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime
|
||||
from fastapi import FastAPI
|
||||
@@ -8,6 +9,7 @@ import os
|
||||
|
||||
from .config import settings
|
||||
from .database import init_oracle, close_oracle, init_sqlite
|
||||
from .routers.sync import backfill_price_match
|
||||
|
||||
# Configure logging with both stream and file handlers
|
||||
_log_level = getattr(logging, settings.LOG_LEVEL.upper(), logging.INFO)
|
||||
@@ -56,6 +58,8 @@ async def lifespan(app: FastAPI):
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
asyncio.create_task(backfill_price_match())
|
||||
|
||||
logger.info("GoMag Import Manager started")
|
||||
yield
|
||||
|
||||
|
||||
@@ -146,8 +146,8 @@ async def create_batch_mapping(data: MappingBatchCreate):
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
|
||||
@router.get("/api/mappings/{sku}/prices")
|
||||
async def get_mapping_prices(sku: str):
|
||||
@router.get("/api/mappings/prices")
|
||||
async def get_mapping_prices(sku: str = Query(...)):
|
||||
"""Get component prices from crm_politici_pret_art for a kit SKU."""
|
||||
app_settings = await sqlite_service.get_app_settings()
|
||||
id_pol = int(app_settings.get("id_pol") or 0) or None
|
||||
|
||||
@@ -12,13 +12,81 @@ from pydantic import BaseModel
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from ..services import sync_service, scheduler_service, sqlite_service, invoice_service
|
||||
from ..services import sync_service, scheduler_service, sqlite_service, invoice_service, validation_service
|
||||
from .. import database
|
||||
|
||||
router = APIRouter(tags=["sync"])
|
||||
templates = Jinja2Templates(directory=str(Path(__file__).parent.parent / "templates"))
|
||||
|
||||
|
||||
async def _enrich_items_with_codmat(items: list) -> None:
|
||||
"""Enrich order items with codmat_details from ARTICOLE_TERTI + NOM_ARTICOLE fallback."""
|
||||
skus = {item["sku"] for item in items if item.get("sku")}
|
||||
if not skus:
|
||||
return
|
||||
codmat_map = await asyncio.to_thread(_get_articole_terti_for_skus, skus)
|
||||
for item in items:
|
||||
sku = item.get("sku")
|
||||
if sku and sku in codmat_map:
|
||||
item["codmat_details"] = codmat_map[sku]
|
||||
remaining_skus = {item["sku"] for item in items
|
||||
if item.get("sku") and not item.get("codmat_details")}
|
||||
if remaining_skus:
|
||||
nom_map = await asyncio.to_thread(_get_nom_articole_for_direct_skus, remaining_skus)
|
||||
for item in items:
|
||||
sku = item.get("sku")
|
||||
if sku and sku in nom_map and not item.get("codmat_details"):
|
||||
item["codmat_details"] = [{"codmat": sku, "cantitate_roa": 1,
|
||||
"denumire": nom_map[sku], "direct": True}]
|
||||
|
||||
|
||||
async def backfill_price_match():
|
||||
"""Background task: check prices for all imported orders without cached price_match."""
|
||||
try:
|
||||
from ..database import get_sqlite
|
||||
db = await get_sqlite()
|
||||
try:
|
||||
cursor = await db.execute("""
|
||||
SELECT order_number FROM orders
|
||||
WHERE status IN ('IMPORTED', 'ALREADY_IMPORTED')
|
||||
AND price_match IS NULL
|
||||
ORDER BY order_date DESC
|
||||
""")
|
||||
rows = [r["order_number"] for r in await cursor.fetchall()]
|
||||
finally:
|
||||
await db.close()
|
||||
|
||||
if not rows:
|
||||
logger.info("backfill_price_match: no unchecked orders")
|
||||
return
|
||||
|
||||
logger.info(f"backfill_price_match: checking {len(rows)} orders...")
|
||||
app_settings = await sqlite_service.get_app_settings()
|
||||
checked = 0
|
||||
|
||||
for order_number in rows:
|
||||
try:
|
||||
detail = await sqlite_service.get_order_detail(order_number)
|
||||
if not detail:
|
||||
continue
|
||||
items = detail.get("items", [])
|
||||
await _enrich_items_with_codmat(items)
|
||||
price_data = await asyncio.to_thread(
|
||||
validation_service.get_prices_for_order, items, app_settings
|
||||
)
|
||||
summary = price_data.get("summary", {})
|
||||
if summary.get("oracle_available") is not False:
|
||||
pm = summary.get("mismatches", 0) == 0
|
||||
await sqlite_service.update_order_price_match(order_number, pm)
|
||||
checked += 1
|
||||
except Exception as e:
|
||||
logger.debug(f"backfill_price_match: order {order_number} failed: {e}")
|
||||
|
||||
logger.info(f"backfill_price_match: done, {checked}/{len(rows)} updated")
|
||||
except Exception as e:
|
||||
logger.error(f"backfill_price_match failed: {e}")
|
||||
|
||||
|
||||
class ScheduleConfig(BaseModel):
|
||||
enabled: bool
|
||||
interval_minutes: int = 5
|
||||
@@ -380,33 +448,36 @@ async def order_detail(order_number: str):
|
||||
if not detail:
|
||||
return {"error": "Order not found"}
|
||||
|
||||
# Enrich items with ARTICOLE_TERTI mappings from Oracle
|
||||
items = detail.get("items", [])
|
||||
skus = {item["sku"] for item in items if item.get("sku")}
|
||||
if skus:
|
||||
codmat_map = await asyncio.to_thread(_get_articole_terti_for_skus, skus)
|
||||
for item in items:
|
||||
sku = item.get("sku")
|
||||
if sku and sku in codmat_map:
|
||||
item["codmat_details"] = codmat_map[sku]
|
||||
await _enrich_items_with_codmat(items)
|
||||
|
||||
# Enrich remaining SKUs via NOM_ARTICOLE (fallback for stale mapping_status)
|
||||
remaining_skus = {item["sku"] for item in items
|
||||
if item.get("sku") and not item.get("codmat_details")}
|
||||
if remaining_skus:
|
||||
nom_map = await asyncio.to_thread(_get_nom_articole_for_direct_skus, remaining_skus)
|
||||
for item in items:
|
||||
sku = item.get("sku")
|
||||
if sku and sku in nom_map and not item.get("codmat_details"):
|
||||
item["codmat_details"] = [{
|
||||
"codmat": sku,
|
||||
"cantitate_roa": 1,
|
||||
"denumire": nom_map[sku],
|
||||
"direct": True
|
||||
}]
|
||||
# Price comparison against ROA Oracle
|
||||
app_settings = await sqlite_service.get_app_settings()
|
||||
try:
|
||||
price_data = await asyncio.to_thread(
|
||||
validation_service.get_prices_for_order, items, app_settings
|
||||
)
|
||||
price_items = price_data.get("items", {})
|
||||
for idx, item in enumerate(items):
|
||||
pi = price_items.get(idx)
|
||||
if pi:
|
||||
item["pret_roa"] = pi.get("pret_roa")
|
||||
item["price_match"] = pi.get("match")
|
||||
order_price_check = price_data.get("summary", {})
|
||||
# Cache price_match in SQLite if changed
|
||||
if order_price_check.get("oracle_available") is not False:
|
||||
pm = order_price_check.get("mismatches", 0) == 0
|
||||
cached = detail.get("order", {}).get("price_match")
|
||||
cached_bool = True if cached == 1 else (False if cached == 0 else None)
|
||||
if cached_bool != pm:
|
||||
await sqlite_service.update_order_price_match(order_number, pm)
|
||||
except Exception as e:
|
||||
logger.warning(f"Price comparison failed for order {order_number}: {e}")
|
||||
order_price_check = {"mismatches": 0, "checked": 0, "oracle_available": False}
|
||||
|
||||
# Enrich with invoice data
|
||||
order = detail.get("order", {})
|
||||
order["price_check"] = order_price_check
|
||||
if order.get("factura_numar") and order.get("factura_data"):
|
||||
order["invoice"] = {
|
||||
"facturat": True,
|
||||
@@ -438,6 +509,19 @@ async def order_detail(order_number: str):
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Invoice reconciliation
|
||||
inv = order.get("invoice")
|
||||
if inv and inv.get("facturat") and inv.get("total_cu_tva") is not None:
|
||||
order_total = float(order.get("order_total") or 0)
|
||||
inv_total = float(inv["total_cu_tva"])
|
||||
difference = round(inv_total - order_total, 2)
|
||||
inv["reconciliation"] = {
|
||||
"order_total": order_total,
|
||||
"invoice_total": inv_total,
|
||||
"difference": difference,
|
||||
"match": abs(difference) < 0.01,
|
||||
}
|
||||
|
||||
# Parse discount_split JSON string
|
||||
if order.get("discount_split"):
|
||||
try:
|
||||
@@ -445,8 +529,7 @@ async def order_detail(order_number: str):
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
|
||||
# Add settings for receipt display
|
||||
app_settings = await sqlite_service.get_app_settings()
|
||||
# Add settings for receipt display (app_settings already fetched above)
|
||||
order["transport_vat"] = app_settings.get("transport_vat") or "21"
|
||||
order["transport_codmat"] = app_settings.get("transport_codmat") or ""
|
||||
order["discount_codmat"] = app_settings.get("discount_codmat") or ""
|
||||
@@ -454,6 +537,52 @@ async def order_detail(order_number: str):
|
||||
return detail
|
||||
|
||||
|
||||
@router.post("/api/orders/{order_number}/retry")
|
||||
async def retry_order(order_number: str):
|
||||
"""Retry importing a failed/skipped order."""
|
||||
from ..services import retry_service
|
||||
|
||||
app_settings = await sqlite_service.get_app_settings()
|
||||
result = await retry_service.retry_single_order(order_number, app_settings)
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/api/orders/by-sku/{sku}/pending")
|
||||
async def get_pending_orders_for_sku(sku: str):
|
||||
"""Get SKIPPED orders that contain the given SKU."""
|
||||
order_numbers = await sqlite_service.get_skipped_orders_with_sku(sku)
|
||||
return {"sku": sku, "order_numbers": order_numbers, "count": len(order_numbers)}
|
||||
|
||||
|
||||
@router.post("/api/orders/batch-retry")
|
||||
async def batch_retry_orders(request: Request):
|
||||
"""Batch retry multiple orders."""
|
||||
from ..services import retry_service
|
||||
body = await request.json()
|
||||
order_numbers = body.get("order_numbers", [])
|
||||
if not order_numbers:
|
||||
return {"success": False, "message": "No orders specified"}
|
||||
|
||||
app_settings = await sqlite_service.get_app_settings()
|
||||
results = {"imported": 0, "errors": 0, "messages": []}
|
||||
|
||||
for on in order_numbers[:20]: # Limit to 20 to avoid timeout
|
||||
result = await retry_service.retry_single_order(str(on), app_settings)
|
||||
if result.get("success"):
|
||||
results["imported"] += 1
|
||||
else:
|
||||
results["errors"] += 1
|
||||
results["messages"].append(f"{on}: {result.get('message', 'Error')}")
|
||||
|
||||
return {
|
||||
"success": results["imported"] > 0,
|
||||
"imported": results["imported"],
|
||||
"errors": results["errors"],
|
||||
"message": f"{results['imported']} importate, {results['errors']} erori" if results["errors"] else f"{results['imported']} importate cu succes",
|
||||
"details": results["messages"][:5],
|
||||
}
|
||||
|
||||
|
||||
@router.get("/api/dashboard/orders")
|
||||
async def dashboard_orders(page: int = 1, per_page: int = 50,
|
||||
search: str = "", status: str = "all",
|
||||
@@ -484,6 +613,9 @@ async def dashboard_orders(page: int = 1, per_page: int = 50,
|
||||
# Enrich orders with invoice data — prefer SQLite cache, fallback to Oracle
|
||||
all_orders = result["orders"]
|
||||
for o in all_orders:
|
||||
# price_match: 1=OK, 0=mismatch, NULL=not checked yet
|
||||
pm = o.get("price_match")
|
||||
o["price_match"] = True if pm == 1 else (False if pm == 0 else None)
|
||||
if o.get("factura_numar") and o.get("factura_data"):
|
||||
# Use cached invoice data from SQLite (only if complete)
|
||||
o["invoice"] = {
|
||||
@@ -534,9 +666,8 @@ async def dashboard_orders(page: int = 1, per_page: int = 50,
|
||||
|
||||
# Use counts from sqlite_service (already period-scoped)
|
||||
counts = result.get("counts", {})
|
||||
# Count newly-cached invoices found during this request
|
||||
# Adjust uninvoiced count for invoices discovered via Oracle during this request
|
||||
newly_invoiced = sum(1 for o in uncached_orders if o.get("invoice") and o["invoice"].get("facturat"))
|
||||
# Adjust uninvoiced count: start from SQLite count, subtract newly-found invoices
|
||||
uninvoiced_base = counts.get("uninvoiced_sqlite", sum(
|
||||
1 for o in all_orders
|
||||
if o.get("status") in ("IMPORTED", "ALREADY_IMPORTED") and not o.get("invoice")
|
||||
@@ -546,6 +677,13 @@ async def dashboard_orders(page: int = 1, per_page: int = 50,
|
||||
counts["facturate"] = max(0, imported_total - counts["nefacturate"])
|
||||
counts.setdefault("total", counts.get("imported", 0) + counts.get("skipped", 0) + counts.get("error", 0))
|
||||
|
||||
# Attention metrics: add unresolved SKUs count
|
||||
try:
|
||||
stats = await sqlite_service.get_dashboard_stats()
|
||||
counts["unresolved_skus"] = stats.get("unresolved_skus", 0)
|
||||
except Exception:
|
||||
counts["unresolved_skus"] = 0
|
||||
|
||||
# For UNINVOICED filter: apply server-side filtering + pagination
|
||||
if is_uninvoiced_filter:
|
||||
filtered = [o for o in all_orders if o.get("status") in ("IMPORTED", "ALREADY_IMPORTED") and not o.get("invoice")]
|
||||
|
||||
131
api/app/services/retry_service.py
Normal file
131
api/app/services/retry_service.py
Normal 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"}
|
||||
@@ -739,6 +739,16 @@ async def get_orders(page: int = 1, per_page: int = 50,
|
||||
cursor = await db.execute(f"SELECT COUNT(*) FROM orders {uninv_where}", base_params)
|
||||
uninvoiced_sqlite = (await cursor.fetchone())[0]
|
||||
|
||||
# Uninvoiced > 3 days old
|
||||
uninv_old_clauses = list(base_clauses) + [
|
||||
"UPPER(status) IN ('IMPORTED', 'ALREADY_IMPORTED')",
|
||||
"(factura_numar IS NULL OR factura_numar = '')",
|
||||
"order_date < datetime('now', '-3 days')",
|
||||
]
|
||||
uninv_old_where = "WHERE " + " AND ".join(uninv_old_clauses)
|
||||
cursor = await db.execute(f"SELECT COUNT(*) FROM orders {uninv_old_where}", base_params)
|
||||
uninvoiced_old = (await cursor.fetchone())[0]
|
||||
|
||||
return {
|
||||
"orders": [dict(r) for r in rows],
|
||||
"total": total,
|
||||
@@ -754,6 +764,7 @@ async def get_orders(page: int = 1, per_page: int = 50,
|
||||
"cancelled": status_counts.get("CANCELLED", 0),
|
||||
"total": sum(status_counts.values()),
|
||||
"uninvoiced_sqlite": uninvoiced_sqlite,
|
||||
"uninvoiced_old": uninvoiced_old,
|
||||
}
|
||||
}
|
||||
finally:
|
||||
@@ -820,6 +831,20 @@ async def update_order_invoice(order_number: str, serie: str = None,
|
||||
await db.close()
|
||||
|
||||
|
||||
async def update_order_price_match(order_number: str, match: bool | None):
|
||||
"""Cache price_match result (True=OK, False=mismatch, None=unavailable)."""
|
||||
db = await get_sqlite()
|
||||
try:
|
||||
val = None if match is None else (1 if match else 0)
|
||||
await db.execute(
|
||||
"UPDATE orders SET price_match = ?, updated_at = datetime('now') WHERE order_number = ?",
|
||||
(val, order_number),
|
||||
)
|
||||
await db.commit()
|
||||
finally:
|
||||
await db.close()
|
||||
|
||||
|
||||
async def get_invoiced_imported_orders() -> list:
|
||||
"""Get imported orders that HAVE cached invoice data (for re-verification)."""
|
||||
db = await get_sqlite()
|
||||
@@ -949,6 +974,24 @@ async def set_app_setting(key: str, value: str):
|
||||
await db.close()
|
||||
|
||||
|
||||
# ── SKU-based order lookup ────────────────────────
|
||||
|
||||
async def get_skipped_orders_with_sku(sku: str) -> list[str]:
|
||||
"""Get order_numbers of SKIPPED orders that contain the given SKU."""
|
||||
db = await get_sqlite()
|
||||
try:
|
||||
cursor = await db.execute("""
|
||||
SELECT DISTINCT oi.order_number
|
||||
FROM order_items oi
|
||||
JOIN orders o ON o.order_number = oi.order_number
|
||||
WHERE oi.sku = ? AND o.status = 'SKIPPED'
|
||||
""", (sku,))
|
||||
rows = await cursor.fetchall()
|
||||
return [row[0] for row in rows]
|
||||
finally:
|
||||
await db.close()
|
||||
|
||||
|
||||
# ── Price Sync Runs ───────────────────────────────
|
||||
|
||||
async def get_price_sync_runs(page: int = 1, per_page: int = 20):
|
||||
|
||||
@@ -586,3 +586,189 @@ def sync_prices_from_order(orders, mapped_codmat_data: dict, direct_id_map: dict
|
||||
database.pool.release(conn)
|
||||
|
||||
return updated
|
||||
|
||||
|
||||
def get_prices_for_order(items: list[dict], app_settings: dict, conn=None) -> dict:
|
||||
"""Compare GoMag prices with ROA prices for order items.
|
||||
|
||||
Args:
|
||||
items: list of order items, each with 'sku', 'price', 'quantity', 'codmat_details'
|
||||
(codmat_details = [{"codmat", "cantitate_roa", "id_articol"?, "cont"?, "direct"?}])
|
||||
app_settings: dict with 'id_pol', 'id_pol_productie'
|
||||
conn: Oracle connection (optional, will acquire if None)
|
||||
|
||||
Returns: {
|
||||
"items": {idx: {"pret_roa": float|None, "match": bool|None, "pret_gomag": float}},
|
||||
"summary": {"mismatches": int, "checked": int, "oracle_available": bool}
|
||||
}
|
||||
"""
|
||||
try:
|
||||
id_pol = int(app_settings.get("id_pol", 0) or 0)
|
||||
id_pol_productie = int(app_settings.get("id_pol_productie", 0) or 0)
|
||||
except (ValueError, TypeError):
|
||||
id_pol = 0
|
||||
id_pol_productie = 0
|
||||
|
||||
def _empty_result(oracle_available: bool) -> dict:
|
||||
return {
|
||||
"items": {
|
||||
idx: {"pret_roa": None, "match": None, "pret_gomag": float(item.get("price") or 0)}
|
||||
for idx, item in enumerate(items)
|
||||
},
|
||||
"summary": {"mismatches": 0, "checked": 0, "oracle_available": oracle_available}
|
||||
}
|
||||
|
||||
if not items or not id_pol:
|
||||
return _empty_result(oracle_available=False)
|
||||
|
||||
own_conn = conn is None
|
||||
try:
|
||||
if own_conn:
|
||||
conn = database.get_oracle_connection()
|
||||
|
||||
# Step 1: Collect codmats; use id_articol/cont from codmat_details when already known
|
||||
pre_resolved = {} # {codmat: {"id_articol": int, "cont": str}}
|
||||
all_codmats = set()
|
||||
for item in items:
|
||||
for cd in (item.get("codmat_details") or []):
|
||||
codmat = cd.get("codmat")
|
||||
if not codmat:
|
||||
continue
|
||||
all_codmats.add(codmat)
|
||||
if cd.get("id_articol") and codmat not in pre_resolved:
|
||||
pre_resolved[codmat] = {
|
||||
"id_articol": cd["id_articol"],
|
||||
"cont": cd.get("cont") or "",
|
||||
}
|
||||
|
||||
# Step 2: Resolve missing id_articols via nom_articole
|
||||
need_resolve = all_codmats - set(pre_resolved.keys())
|
||||
if need_resolve:
|
||||
db_resolved = resolve_codmat_ids(need_resolve, conn=conn)
|
||||
pre_resolved.update(db_resolved)
|
||||
|
||||
codmat_info = pre_resolved # {codmat: {"id_articol": int, "cont": str}}
|
||||
|
||||
# Step 3: Get PRETURI_CU_TVA flag once per policy
|
||||
policies = {id_pol}
|
||||
if id_pol_productie and id_pol_productie != id_pol:
|
||||
policies.add(id_pol_productie)
|
||||
|
||||
pol_cu_tva = {} # {id_pol: bool}
|
||||
with conn.cursor() as cur:
|
||||
for pol in policies:
|
||||
cur.execute(
|
||||
"SELECT PRETURI_CU_TVA FROM CRM_POLITICI_PRETURI WHERE ID_POL = :pol",
|
||||
{"pol": pol},
|
||||
)
|
||||
row = cur.fetchone()
|
||||
pol_cu_tva[pol] = (int(row[0] or 0) == 1) if row else False
|
||||
|
||||
# Step 4: Batch query PRET + PROC_TVAV for all id_articols across both policies
|
||||
all_id_articols = list({
|
||||
info["id_articol"]
|
||||
for info in codmat_info.values()
|
||||
if info.get("id_articol")
|
||||
})
|
||||
price_map = {} # {(id_pol, id_articol): (pret, proc_tvav)}
|
||||
|
||||
if all_id_articols:
|
||||
pol_list = list(policies)
|
||||
pol_placeholders = ",".join([f":p{k}" for k in range(len(pol_list))])
|
||||
with conn.cursor() as cur:
|
||||
for i in range(0, len(all_id_articols), 500):
|
||||
batch = all_id_articols[i:i + 500]
|
||||
art_placeholders = ",".join([f":a{j}" for j in range(len(batch))])
|
||||
params = {f"a{j}": aid for j, aid in enumerate(batch)}
|
||||
for k, pol in enumerate(pol_list):
|
||||
params[f"p{k}"] = pol
|
||||
cur.execute(f"""
|
||||
SELECT ID_POL, ID_ARTICOL, PRET, PROC_TVAV
|
||||
FROM CRM_POLITICI_PRET_ART
|
||||
WHERE ID_POL IN ({pol_placeholders}) AND ID_ARTICOL IN ({art_placeholders})
|
||||
""", params)
|
||||
for row in cur:
|
||||
price_map[(row[0], row[1])] = (row[2], row[3])
|
||||
|
||||
# Step 5: Compute pret_roa per item and compare with GoMag price
|
||||
result_items = {}
|
||||
mismatches = 0
|
||||
checked = 0
|
||||
|
||||
for idx, item in enumerate(items):
|
||||
pret_gomag = float(item.get("price") or 0)
|
||||
result_items[idx] = {"pret_gomag": pret_gomag, "pret_roa": None, "match": None}
|
||||
|
||||
codmat_details = item.get("codmat_details") or []
|
||||
if not codmat_details:
|
||||
continue
|
||||
|
||||
is_kit = len(codmat_details) > 1 or (
|
||||
len(codmat_details) == 1
|
||||
and float(codmat_details[0].get("cantitate_roa") or 1) > 1
|
||||
)
|
||||
|
||||
pret_roa_total = 0.0
|
||||
all_resolved = True
|
||||
|
||||
for cd in codmat_details:
|
||||
codmat = cd.get("codmat")
|
||||
if not codmat:
|
||||
all_resolved = False
|
||||
break
|
||||
|
||||
info = codmat_info.get(codmat, {})
|
||||
id_articol = info.get("id_articol")
|
||||
if not id_articol:
|
||||
all_resolved = False
|
||||
break
|
||||
|
||||
# Dual-policy routing: cont 341/345 → production, else → sales
|
||||
cont = str(info.get("cont") or cd.get("cont") or "").strip()
|
||||
if cont in ("341", "345") and id_pol_productie:
|
||||
pol = id_pol_productie
|
||||
else:
|
||||
pol = id_pol
|
||||
|
||||
price_entry = price_map.get((pol, id_articol))
|
||||
if price_entry is None:
|
||||
all_resolved = False
|
||||
break
|
||||
|
||||
pret, proc_tvav = price_entry
|
||||
proc_tvav = float(proc_tvav or 1.19)
|
||||
|
||||
if pol_cu_tva.get(pol):
|
||||
pret_cu_tva = float(pret or 0)
|
||||
else:
|
||||
pret_cu_tva = float(pret or 0) * proc_tvav
|
||||
|
||||
cantitate_roa = float(cd.get("cantitate_roa") or 1)
|
||||
if is_kit:
|
||||
pret_roa_total += pret_cu_tva * cantitate_roa
|
||||
else:
|
||||
pret_roa_total = pret_cu_tva # cantitate_roa==1 for simple items
|
||||
|
||||
if not all_resolved:
|
||||
continue
|
||||
|
||||
pret_roa = round(pret_roa_total, 4)
|
||||
match = abs(pret_gomag - pret_roa) < 0.01
|
||||
result_items[idx]["pret_roa"] = pret_roa
|
||||
result_items[idx]["match"] = match
|
||||
checked += 1
|
||||
if not match:
|
||||
mismatches += 1
|
||||
|
||||
logger.info(f"get_prices_for_order: {checked}/{len(items)} checked, {mismatches} mismatches")
|
||||
return {
|
||||
"items": result_items,
|
||||
"summary": {"mismatches": mismatches, "checked": checked, "oracle_available": True},
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"get_prices_for_order failed: {e}")
|
||||
return _empty_result(oracle_available=False)
|
||||
finally:
|
||||
if own_conn and conn:
|
||||
database.pool.release(conn)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -20,6 +20,7 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||
loadDashOrders();
|
||||
startSyncPolling();
|
||||
wireFilterBar();
|
||||
checkFirstTime();
|
||||
});
|
||||
|
||||
async function initPollInterval() {
|
||||
@@ -119,11 +120,33 @@ function updateSyncPanel(data) {
|
||||
}
|
||||
if (st) {
|
||||
st.textContent = lr.status === 'completed' ? '\u2713' : '\u2715';
|
||||
st.style.color = lr.status === 'completed' ? '#10b981' : '#ef4444';
|
||||
st.style.color = lr.status === 'completed' ? 'var(--success)' : 'var(--error)';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function checkFirstTime() {
|
||||
const welcomeEl = document.getElementById('welcomeCard');
|
||||
if (!welcomeEl) return;
|
||||
try {
|
||||
const data = await fetchJSON('/api/sync/status');
|
||||
if (!data.last_run) {
|
||||
welcomeEl.innerHTML = `<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)
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
document.getElementById('lastSyncRow')?.addEventListener('click', () => {
|
||||
@@ -201,16 +224,21 @@ async function loadSchedulerStatus() {
|
||||
// ── Filter Bar wiring ─────────────────────────────
|
||||
|
||||
function wireFilterBar() {
|
||||
// Period dropdown
|
||||
document.getElementById('periodSelect')?.addEventListener('change', function () {
|
||||
const cr = document.getElementById('customRangeInputs');
|
||||
if (this.value === 'custom') {
|
||||
cr?.classList.add('visible');
|
||||
} else {
|
||||
cr?.classList.remove('visible');
|
||||
dashPage = 1;
|
||||
loadDashOrders();
|
||||
}
|
||||
// Period preset buttons
|
||||
document.querySelectorAll('.preset-btn[data-days]').forEach(btn => {
|
||||
btn.addEventListener('click', function() {
|
||||
document.querySelectorAll('.preset-btn').forEach(b => b.classList.remove('active'));
|
||||
this.classList.add('active');
|
||||
const days = this.dataset.days;
|
||||
const cr = document.getElementById('customRangeInputs');
|
||||
if (days === 'custom') {
|
||||
cr?.classList.add('visible');
|
||||
} else {
|
||||
cr?.classList.remove('visible');
|
||||
dashPage = 1;
|
||||
loadDashOrders();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Custom range inputs
|
||||
@@ -260,7 +288,8 @@ function dashSortBy(col) {
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
if (periodVal === 'custom') {
|
||||
@@ -301,11 +330,29 @@ async function loadDashOrders() {
|
||||
if (el('cntNef')) el('cntNef').textContent = c.nefacturate || c.uninvoiced || 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 orders = data.orders || [];
|
||||
|
||||
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 {
|
||||
tbody.innerHTML = orders.map(o => {
|
||||
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 fw-bold">${orderTotal}</td>
|
||||
<td class="text-center">${invoiceDot(o)}</td>
|
||||
<td class="text-center">${priceDot(o)}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
}
|
||||
@@ -340,11 +388,12 @@ async function loadDashOrders() {
|
||||
}
|
||||
const name = o.customer_name || o.shipping_name || o.billing_name || '\u2014';
|
||||
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">
|
||||
${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="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>`;
|
||||
}).join('');
|
||||
}
|
||||
@@ -432,14 +481,6 @@ function escHtml(s) {
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
// 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) {
|
||||
switch ((status || '').toUpperCase()) {
|
||||
case 'IMPORTED': return 'Importat';
|
||||
@@ -450,16 +491,10 @@ function statusLabelText(status) {
|
||||
}
|
||||
}
|
||||
|
||||
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 priceDot(order) {
|
||||
if (order.price_match === true) return '<span class="dot dot-green" title="Preturi OK"></span>';
|
||||
if (order.price_match === false) return '<span class="dot dot-red" title="Diferenta de pret"></span>';
|
||||
return '<span class="dot dot-gray" title="Neverificat"></span>';
|
||||
}
|
||||
|
||||
function invoiceDot(order) {
|
||||
@@ -468,22 +503,6 @@ function invoiceDot(order) {
|
||||
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 ──────────────────────────────
|
||||
|
||||
async function refreshInvoices() {
|
||||
@@ -509,262 +528,12 @@ async function refreshInvoices() {
|
||||
|
||||
// ── Order Detail Modal ────────────────────────────
|
||||
|
||||
async function openDashOrderDetail(orderNumber) {
|
||||
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="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);
|
||||
function openDashOrderDetail(orderNumber) {
|
||||
_sharedModalQuickMapFn = openDashQuickMap;
|
||||
renderOrderDetailModal(orderNumber, {
|
||||
onQuickMap: openDashQuickMap,
|
||||
onAfterRender: function() { /* nothing extra needed */ }
|
||||
});
|
||||
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) ───
|
||||
|
||||
@@ -8,10 +8,6 @@ let ordersPage = 1;
|
||||
let ordersSortColumn = 'order_date';
|
||||
let ordersSortDirection = 'desc';
|
||||
|
||||
function fmtCost(v) {
|
||||
return v > 0 ? Number(v).toFixed(2) : '–';
|
||||
}
|
||||
|
||||
function fmtDuration(startedAt, finishedAt) {
|
||||
if (!startedAt || !finishedAt) return '-';
|
||||
const diffMs = new Date(finishedAt) - new Date(startedAt);
|
||||
@@ -23,24 +19,13 @@ function fmtDuration(startedAt, finishedAt) {
|
||||
|
||||
function runStatusBadge(status) {
|
||||
switch ((status || '').toLowerCase()) {
|
||||
case 'completed': return '<span style="color:#16a34a;font-weight:600">completed</span>';
|
||||
case 'running': return '<span style="color:#2563eb;font-weight:600">running</span>';
|
||||
case 'failed': return '<span style="color:#dc2626;font-weight:600">failed</span>';
|
||||
case 'completed': return '<span style="color:var(--success);font-weight:600">completed</span>';
|
||||
case 'running': return '<span style="color:var(--info);font-weight:600">running</span>';
|
||||
case 'failed': return '<span style="color:var(--error);font-weight:600">failed</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) {
|
||||
switch ((status || '').toUpperCase()) {
|
||||
case 'IMPORTED': return 'Importat';
|
||||
@@ -156,7 +141,11 @@ async function loadRunOrders(runId, statusFilter, page) {
|
||||
if (orders.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="9" class="text-center text-muted py-3">Nicio comanda</td></tr>';
|
||||
} 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 orderTotal = o.order_total != null ? Number(o.order_total).toFixed(2) : '-';
|
||||
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 fw-bold">${orderTotal}</td>
|
||||
</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
|
||||
@@ -179,7 +192,11 @@ async function loadRunOrders(runId, statusFilter, page) {
|
||||
if (orders.length === 0) {
|
||||
mobileList.innerHTML = '<div class="flat-row text-muted py-3 justify-content-center">Nicio comanda</div>';
|
||||
} 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 || '';
|
||||
let dateFmt = '-';
|
||||
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) : '';
|
||||
return `<div class="flat-row" onclick="openOrderDetail('${esc(o.order_number)}')" style="font-size:0.875rem">
|
||||
${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="text-nowrap">x${o.items_count || 0}${totalStr ? ' · <strong>' + totalStr + '</strong>' : ''}</span>
|
||||
</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) ─────────────────────
|
||||
|
||||
async function openOrderDetail(orderNumber) {
|
||||
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="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;
|
||||
function openOrderDetail(orderNumber) {
|
||||
_sharedModalQuickMapFn = function(sku, productName, orderNum, itemIdx) {
|
||||
openLogsQuickMap(sku, productName, orderNum);
|
||||
};
|
||||
renderOrderDetailModal(orderNumber, {
|
||||
onQuickMap: function(sku, productName, orderNum, itemIdx) {
|
||||
openLogsQuickMap(sku, productName, orderNum);
|
||||
}
|
||||
|
||||
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) ───
|
||||
|
||||
@@ -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>`
|
||||
: '';
|
||||
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'}"
|
||||
${m.sters ? '' : `onclick="event.stopPropagation();toggleActive('${esc(m.sku)}', '${esc(m.codmat)}', ${m.activ})"`}
|
||||
title="${m.activ ? 'Activ' : 'Inactiv'}"></span>
|
||||
@@ -135,7 +135,7 @@ function renderTable(mappings, showDeleted) {
|
||||
// After last CODMAT of a kit, add total row
|
||||
const isLastOfKit = isKitRow && (i === mappings.length - 1 || mappings[i + 1].sku !== m.sku);
|
||||
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;
|
||||
@@ -176,7 +176,7 @@ async function loadKitPrices(sku, container) {
|
||||
if (spinner) spinner.style.display = '';
|
||||
|
||||
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();
|
||||
if (data.error) {
|
||||
if (spinner) spinner.innerHTML = `<small class="text-danger">${esc(data.error)}</small>`;
|
||||
@@ -523,7 +523,7 @@ function showInlineAddRow() {
|
||||
const row = document.createElement('div');
|
||||
row.id = 'inlineAddRow';
|
||||
row.className = 'flat-row';
|
||||
row.style.background = '#eff6ff';
|
||||
row.style.background = 'var(--info-light)';
|
||||
row.style.gap = '0.5rem';
|
||||
row.innerHTML = `
|
||||
<input type="text" class="form-control form-control-sm" id="inlineSku" placeholder="SKU" style="width:140px">
|
||||
|
||||
@@ -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
|
||||
const catChk = document.getElementById('settCatalogSyncEnabled');
|
||||
if (catChk) catChk.addEventListener('change', () => {
|
||||
@@ -191,14 +200,14 @@ async function saveSettings() {
|
||||
const data = await res.json();
|
||||
const resultEl = document.getElementById('settSaveResult');
|
||||
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);
|
||||
} 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) {
|
||||
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)'; }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
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}"` : '';
|
||||
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>`;
|
||||
@@ -344,6 +344,40 @@ async function saveQuickMapping() {
|
||||
if (data.success) {
|
||||
bootstrap.Modal.getInstance(document.getElementById('quickMapModal')).hide();
|
||||
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 {
|
||||
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 ────────────────────────────────────
|
||||
function statusDot(status) {
|
||||
switch ((status || '').toUpperCase()) {
|
||||
|
||||
@@ -1,16 +1,28 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ro" style="color-scheme: light">
|
||||
<html lang="ro">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<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-icons@1.11.2/font/bootstrap-icons.css" rel="stylesheet">
|
||||
{% 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>
|
||||
<body>
|
||||
<!-- Top Navbar -->
|
||||
<!-- Top Navbar (hidden on mobile via CSS) -->
|
||||
<nav class="top-navbar">
|
||||
<div class="navbar-brand">GoMag Import</div>
|
||||
<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 }}/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>
|
||||
<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>
|
||||
|
||||
<!-- Main content -->
|
||||
<main class="main-content">
|
||||
<main class="main-content {% block main_class %}{% endblock %}">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
@@ -39,7 +63,7 @@
|
||||
<div style="margin-bottom:8px; font-size:0.85rem">
|
||||
<small class="text-muted">Produs:</small> <strong id="qmProductName"></strong>
|
||||
</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="width:70px">Cant.</span>
|
||||
<span style="width:30px"></span>
|
||||
@@ -59,9 +83,88 @@
|
||||
</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 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 %}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Dashboard - GoMag Import{% endblock %}
|
||||
{% block nav_dashboard %}active{% endblock %}
|
||||
{% block bnav_dashboard %}active{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h4 class="mb-4">Panou de Comanda</h4>
|
||||
|
||||
<div id="welcomeCard" style="display:none"></div>
|
||||
|
||||
<!-- Sync Card (unified two-row panel) -->
|
||||
<div class="sync-card">
|
||||
<!-- TOP ROW: Status + Controls -->
|
||||
@@ -48,19 +51,17 @@
|
||||
<span>Comenzi</span>
|
||||
</div>
|
||||
<div class="card-body py-2 px-3">
|
||||
<div id="attentionCard"></div>
|
||||
<div class="filter-bar" id="ordersFilterBar">
|
||||
<!-- Period dropdown -->
|
||||
<select id="periodSelect" class="select-compact">
|
||||
<option value="1">1 zi</option>
|
||||
<option value="2">2 zile</option>
|
||||
<option value="3">3 zile</option>
|
||||
<option value="7" selected>7 zile</option>
|
||||
<option value="30">30 zile</option>
|
||||
<option value="90">3 luni</option>
|
||||
<option value="0">Toate</option>
|
||||
<option value="custom">Perioada personalizata...</option>
|
||||
</select>
|
||||
<!-- Custom date range (hidden until 'custom' selected) -->
|
||||
<!-- Period preset buttons -->
|
||||
<div class="period-presets">
|
||||
<button class="preset-btn" data-days="1">Azi</button>
|
||||
<button class="preset-btn active" data-days="3">3 zile</button>
|
||||
<button class="preset-btn" data-days="7">7 zile</button>
|
||||
<button class="preset-btn" data-days="30">30 zile</button>
|
||||
<button class="preset-btn" data-days="custom">Custom</button>
|
||||
</div>
|
||||
<!-- Custom date range (hidden until 'Custom' clicked) -->
|
||||
<div class="period-custom-range" id="customRangeInputs">
|
||||
<input type="date" id="periodStart" class="select-compact">
|
||||
<span>—</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="btn btn-sm btn-outline-secondary d-none d-md-inline-flex" id="btnRefreshInvoices" onclick="refreshInvoices()" title="Actualizeaza status facturi din Oracle">↻</button>
|
||||
</div>
|
||||
<div class="d-md-none mb-2 d-flex align-items-center gap-2">
|
||||
<div class="flex-grow-1" id="dashMobileSeg"></div>
|
||||
<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" 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">↻</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -98,10 +99,11 @@
|
||||
<th class="text-end">Discount</th>
|
||||
<th class="text-end">Total</th>
|
||||
<th style="width:28px" title="Facturat">F</th>
|
||||
<th class="text-center" style="width:30px" title="Preturi ROA">₽</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<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>
|
||||
</table>
|
||||
</div>
|
||||
@@ -109,64 +111,8 @@
|
||||
<div id="dashPagination" class="pag-strip pag-strip-bottom"></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 %}
|
||||
|
||||
{% 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 %}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Jurnale Import - GoMag Import{% endblock %}
|
||||
{% block nav_logs %}active{% endblock %}
|
||||
{% block bnav_logs %}active{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<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="ERROR">Erori <span class="filter-count fc-red" id="countError">0</span></button>
|
||||
</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 -->
|
||||
<div class="card mb-3">
|
||||
@@ -96,65 +97,10 @@
|
||||
</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 -->
|
||||
<input type="hidden" id="preselectedRun" value="{{ selected_run }}">
|
||||
{% endblock %}
|
||||
|
||||
{% 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 %}
|
||||
|
||||
@@ -1,15 +1,24 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Mapari SKU - GoMag Import{% endblock %}
|
||||
{% block nav_mappings %}active{% endblock %}
|
||||
{% block bnav_mappings %}active{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h4 class="mb-0">Mapari SKU</h4>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<!-- Desktop buttons -->
|
||||
<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>
|
||||
<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-primary d-none d-md-inline-flex" data-bs-toggle="modal" data-bs-target="#importModal"><i class="bi bi-upload"></i> Import CSV</button>
|
||||
<!-- Desktop Import/Export dropdown -->
|
||||
<div class="dropdown d-none d-md-inline-block">
|
||||
<button class="btn btn-sm btn-outline-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown">
|
||||
<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-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 -->
|
||||
@@ -150,5 +159,5 @@
|
||||
{% endblock %}
|
||||
|
||||
{% 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 %}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}SKU-uri Lipsa - GoMag Import{% endblock %}
|
||||
{% block nav_missing %}active{% endblock %}
|
||||
{% block bnav_missing %}active{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
|
||||
@@ -1,10 +1,23 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Setari - GoMag Import{% endblock %}
|
||||
{% block nav_settings %}active{% endblock %}
|
||||
{% block bnav_settings %}active{% endblock %}
|
||||
{% block main_class %}constrained{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<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">
|
||||
<!-- GoMag API card -->
|
||||
<div class="col-md-6">
|
||||
@@ -144,82 +157,89 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-md-6">
|
||||
<div class="card h-100">
|
||||
<div class="card-header py-2 px-3 fw-semibold">Dashboard</div>
|
||||
<div class="card-body py-2 px-3">
|
||||
<div class="mb-2">
|
||||
<label class="form-label mb-0 small">Interval polling (secunde)</label>
|
||||
<input type="number" class="form-control form-control-sm" id="settDashPollSeconds" value="5" min="1" max="300">
|
||||
<div class="form-text" style="font-size:0.75rem">Cât de des verifică dashboard-ul starea sync-ului (implicit 5s)</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<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 class="mt-4">
|
||||
<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">
|
||||
<i class="bi bi-gear"></i> Setari avansate
|
||||
</button>
|
||||
<div class="collapse mt-2" id="advancedSettings">
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-md-6">
|
||||
<div class="card h-100">
|
||||
<div class="card-header py-2 px-3 fw-semibold">Dashboard</div>
|
||||
<div class="card-body py-2 px-3">
|
||||
<div class="mb-2">
|
||||
<label class="form-label mb-0 small">Interval polling (secunde)</label>
|
||||
<input type="number" class="form-control form-control-sm" id="settDashPollSeconds" value="5" min="1" max="300">
|
||||
<div class="form-text" style="font-size:0.75rem">Cât de des verifică dashboard-ul starea sync-ului (implicit 5s)</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 class="col-md-6">
|
||||
<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 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 class="row g-3 mb-3">
|
||||
<div class="col-md-6">
|
||||
<div class="card h-100">
|
||||
<div class="card-header py-2 px-3 fw-semibold">Sincronizare Prețuri</div>
|
||||
<div class="card-body py-2 px-3">
|
||||
<div class="form-check mb-2">
|
||||
<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>
|
||||
</div>
|
||||
<div class="form-check mb-2">
|
||||
<input type="checkbox" class="form-check-input" id="settCatalogSyncEnabled">
|
||||
<label class="form-check-label small" for="settCatalogSyncEnabled">Sync prețuri din catalog GoMag</label>
|
||||
</div>
|
||||
<div id="catalogSyncOptions" style="display:none">
|
||||
<div class="mb-2">
|
||||
<label class="form-label mb-0 small">Program</label>
|
||||
<select class="form-select form-select-sm" id="settPriceSyncSchedule">
|
||||
<option value="">Doar manual</option>
|
||||
<option value="daily_03:00">Zilnic la 03:00</option>
|
||||
<option value="daily_06:00">Zilnic la 06:00</option>
|
||||
</select>
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-md-6">
|
||||
<div class="card h-100">
|
||||
<div class="card-header py-2 px-3 fw-semibold">Sincronizare Prețuri</div>
|
||||
<div class="card-body py-2 px-3">
|
||||
<div class="form-check mb-2">
|
||||
<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>
|
||||
</div>
|
||||
<div class="form-check mb-2">
|
||||
<input type="checkbox" class="form-check-input" id="settCatalogSyncEnabled">
|
||||
<label class="form-check-label small" for="settCatalogSyncEnabled">Sync prețuri din catalog GoMag</label>
|
||||
</div>
|
||||
<div id="catalogSyncOptions" style="display:none">
|
||||
<div class="mb-2">
|
||||
<label class="form-label mb-0 small">Program</label>
|
||||
<select class="form-select form-select-sm" id="settPriceSyncSchedule">
|
||||
<option value="">Doar manual</option>
|
||||
<option value="daily_03:00">Zilnic la 03:00</option>
|
||||
<option value="daily_06:00">Zilnic la 06:00</option>
|
||||
</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 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>
|
||||
@@ -233,5 +253,5 @@
|
||||
{% endblock %}
|
||||
|
||||
{% 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 %}
|
||||
|
||||
105
api/tests/e2e/test_design_system_e2e.py
Normal file
105
api/tests/e2e/test_design_system_e2e.py
Normal 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')")
|
||||
@@ -29,7 +29,7 @@ def test_order_detail_items_table_columns(page: Page, app_url: str):
|
||||
texts = headers.all_text_contents()
|
||||
|
||||
# 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:
|
||||
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.wait_for_load_state("networkidle")
|
||||
|
||||
logs_link = page.locator("a[href='/logs']")
|
||||
expect(logs_link).to_be_visible()
|
||||
logs_link = page.locator(".top-navbar a[href='/logs'], .bottom-nav a[href='/logs']")
|
||||
expect(logs_link.first).to_be_visible()
|
||||
|
||||
@@ -89,14 +89,14 @@ def test_responsive_page(
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
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"])
|
||||
page = context.new_page()
|
||||
try:
|
||||
page.goto(base_url, wait_until="networkidle", timeout=15_000)
|
||||
# Custom navbar: .top-navbar with .navbar-brand
|
||||
navbar = page.locator(".top-navbar")
|
||||
expect(navbar).to_be_visible()
|
||||
# On mobile, top-navbar is hidden and bottom-nav is shown
|
||||
bottom_nav = page.locator(".bottom-nav")
|
||||
expect(bottom_nav).to_be_visible()
|
||||
finally:
|
||||
context.close()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user