Merge feat/operator-shield into main
This commit is contained in:
@@ -332,6 +332,7 @@ def init_sqlite():
|
|||||||
("discount_total", "REAL"),
|
("discount_total", "REAL"),
|
||||||
("web_status", "TEXT"),
|
("web_status", "TEXT"),
|
||||||
("discount_split", "TEXT"),
|
("discount_split", "TEXT"),
|
||||||
|
("price_match", "INTEGER"),
|
||||||
]:
|
]:
|
||||||
if col not in order_cols:
|
if col not in order_cols:
|
||||||
conn.execute(f"ALTER TABLE orders ADD COLUMN {col} {typedef}")
|
conn.execute(f"ALTER TABLE orders ADD COLUMN {col} {typedef}")
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import asyncio
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
@@ -8,6 +9,7 @@ import os
|
|||||||
|
|
||||||
from .config import settings
|
from .config import settings
|
||||||
from .database import init_oracle, close_oracle, init_sqlite
|
from .database import init_oracle, close_oracle, init_sqlite
|
||||||
|
from .routers.sync import backfill_price_match
|
||||||
|
|
||||||
# Configure logging with both stream and file handlers
|
# Configure logging with both stream and file handlers
|
||||||
_log_level = getattr(logging, settings.LOG_LEVEL.upper(), logging.INFO)
|
_log_level = getattr(logging, settings.LOG_LEVEL.upper(), logging.INFO)
|
||||||
@@ -56,6 +58,8 @@ async def lifespan(app: FastAPI):
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
asyncio.create_task(backfill_price_match())
|
||||||
|
|
||||||
logger.info("GoMag Import Manager started")
|
logger.info("GoMag Import Manager started")
|
||||||
yield
|
yield
|
||||||
|
|
||||||
|
|||||||
@@ -146,8 +146,8 @@ async def create_batch_mapping(data: MappingBatchCreate):
|
|||||||
return {"success": False, "error": str(e)}
|
return {"success": False, "error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/mappings/{sku}/prices")
|
@router.get("/api/mappings/prices")
|
||||||
async def get_mapping_prices(sku: str):
|
async def get_mapping_prices(sku: str = Query(...)):
|
||||||
"""Get component prices from crm_politici_pret_art for a kit SKU."""
|
"""Get component prices from crm_politici_pret_art for a kit SKU."""
|
||||||
app_settings = await sqlite_service.get_app_settings()
|
app_settings = await sqlite_service.get_app_settings()
|
||||||
id_pol = int(app_settings.get("id_pol") or 0) or None
|
id_pol = int(app_settings.get("id_pol") or 0) or None
|
||||||
|
|||||||
@@ -12,13 +12,81 @@ from pydantic import BaseModel
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from ..services import sync_service, scheduler_service, sqlite_service, invoice_service
|
from ..services import sync_service, scheduler_service, sqlite_service, invoice_service, validation_service
|
||||||
from .. import database
|
from .. import database
|
||||||
|
|
||||||
router = APIRouter(tags=["sync"])
|
router = APIRouter(tags=["sync"])
|
||||||
templates = Jinja2Templates(directory=str(Path(__file__).parent.parent / "templates"))
|
templates = Jinja2Templates(directory=str(Path(__file__).parent.parent / "templates"))
|
||||||
|
|
||||||
|
|
||||||
|
async def _enrich_items_with_codmat(items: list) -> None:
|
||||||
|
"""Enrich order items with codmat_details from ARTICOLE_TERTI + NOM_ARTICOLE fallback."""
|
||||||
|
skus = {item["sku"] for item in items if item.get("sku")}
|
||||||
|
if not skus:
|
||||||
|
return
|
||||||
|
codmat_map = await asyncio.to_thread(_get_articole_terti_for_skus, skus)
|
||||||
|
for item in items:
|
||||||
|
sku = item.get("sku")
|
||||||
|
if sku and sku in codmat_map:
|
||||||
|
item["codmat_details"] = codmat_map[sku]
|
||||||
|
remaining_skus = {item["sku"] for item in items
|
||||||
|
if item.get("sku") and not item.get("codmat_details")}
|
||||||
|
if remaining_skus:
|
||||||
|
nom_map = await asyncio.to_thread(_get_nom_articole_for_direct_skus, remaining_skus)
|
||||||
|
for item in items:
|
||||||
|
sku = item.get("sku")
|
||||||
|
if sku and sku in nom_map and not item.get("codmat_details"):
|
||||||
|
item["codmat_details"] = [{"codmat": sku, "cantitate_roa": 1,
|
||||||
|
"denumire": nom_map[sku], "direct": True}]
|
||||||
|
|
||||||
|
|
||||||
|
async def backfill_price_match():
|
||||||
|
"""Background task: check prices for all imported orders without cached price_match."""
|
||||||
|
try:
|
||||||
|
from ..database import get_sqlite
|
||||||
|
db = await get_sqlite()
|
||||||
|
try:
|
||||||
|
cursor = await db.execute("""
|
||||||
|
SELECT order_number FROM orders
|
||||||
|
WHERE status IN ('IMPORTED', 'ALREADY_IMPORTED')
|
||||||
|
AND price_match IS NULL
|
||||||
|
ORDER BY order_date DESC
|
||||||
|
""")
|
||||||
|
rows = [r["order_number"] for r in await cursor.fetchall()]
|
||||||
|
finally:
|
||||||
|
await db.close()
|
||||||
|
|
||||||
|
if not rows:
|
||||||
|
logger.info("backfill_price_match: no unchecked orders")
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.info(f"backfill_price_match: checking {len(rows)} orders...")
|
||||||
|
app_settings = await sqlite_service.get_app_settings()
|
||||||
|
checked = 0
|
||||||
|
|
||||||
|
for order_number in rows:
|
||||||
|
try:
|
||||||
|
detail = await sqlite_service.get_order_detail(order_number)
|
||||||
|
if not detail:
|
||||||
|
continue
|
||||||
|
items = detail.get("items", [])
|
||||||
|
await _enrich_items_with_codmat(items)
|
||||||
|
price_data = await asyncio.to_thread(
|
||||||
|
validation_service.get_prices_for_order, items, app_settings
|
||||||
|
)
|
||||||
|
summary = price_data.get("summary", {})
|
||||||
|
if summary.get("oracle_available") is not False:
|
||||||
|
pm = summary.get("mismatches", 0) == 0
|
||||||
|
await sqlite_service.update_order_price_match(order_number, pm)
|
||||||
|
checked += 1
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"backfill_price_match: order {order_number} failed: {e}")
|
||||||
|
|
||||||
|
logger.info(f"backfill_price_match: done, {checked}/{len(rows)} updated")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"backfill_price_match failed: {e}")
|
||||||
|
|
||||||
|
|
||||||
class ScheduleConfig(BaseModel):
|
class ScheduleConfig(BaseModel):
|
||||||
enabled: bool
|
enabled: bool
|
||||||
interval_minutes: int = 5
|
interval_minutes: int = 5
|
||||||
@@ -380,33 +448,36 @@ async def order_detail(order_number: str):
|
|||||||
if not detail:
|
if not detail:
|
||||||
return {"error": "Order not found"}
|
return {"error": "Order not found"}
|
||||||
|
|
||||||
# Enrich items with ARTICOLE_TERTI mappings from Oracle
|
|
||||||
items = detail.get("items", [])
|
items = detail.get("items", [])
|
||||||
skus = {item["sku"] for item in items if item.get("sku")}
|
await _enrich_items_with_codmat(items)
|
||||||
if skus:
|
|
||||||
codmat_map = await asyncio.to_thread(_get_articole_terti_for_skus, skus)
|
|
||||||
for item in items:
|
|
||||||
sku = item.get("sku")
|
|
||||||
if sku and sku in codmat_map:
|
|
||||||
item["codmat_details"] = codmat_map[sku]
|
|
||||||
|
|
||||||
# Enrich remaining SKUs via NOM_ARTICOLE (fallback for stale mapping_status)
|
# Price comparison against ROA Oracle
|
||||||
remaining_skus = {item["sku"] for item in items
|
app_settings = await sqlite_service.get_app_settings()
|
||||||
if item.get("sku") and not item.get("codmat_details")}
|
try:
|
||||||
if remaining_skus:
|
price_data = await asyncio.to_thread(
|
||||||
nom_map = await asyncio.to_thread(_get_nom_articole_for_direct_skus, remaining_skus)
|
validation_service.get_prices_for_order, items, app_settings
|
||||||
for item in items:
|
)
|
||||||
sku = item.get("sku")
|
price_items = price_data.get("items", {})
|
||||||
if sku and sku in nom_map and not item.get("codmat_details"):
|
for idx, item in enumerate(items):
|
||||||
item["codmat_details"] = [{
|
pi = price_items.get(idx)
|
||||||
"codmat": sku,
|
if pi:
|
||||||
"cantitate_roa": 1,
|
item["pret_roa"] = pi.get("pret_roa")
|
||||||
"denumire": nom_map[sku],
|
item["price_match"] = pi.get("match")
|
||||||
"direct": True
|
order_price_check = price_data.get("summary", {})
|
||||||
}]
|
# Cache price_match in SQLite if changed
|
||||||
|
if order_price_check.get("oracle_available") is not False:
|
||||||
|
pm = order_price_check.get("mismatches", 0) == 0
|
||||||
|
cached = detail.get("order", {}).get("price_match")
|
||||||
|
cached_bool = True if cached == 1 else (False if cached == 0 else None)
|
||||||
|
if cached_bool != pm:
|
||||||
|
await sqlite_service.update_order_price_match(order_number, pm)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Price comparison failed for order {order_number}: {e}")
|
||||||
|
order_price_check = {"mismatches": 0, "checked": 0, "oracle_available": False}
|
||||||
|
|
||||||
# Enrich with invoice data
|
# Enrich with invoice data
|
||||||
order = detail.get("order", {})
|
order = detail.get("order", {})
|
||||||
|
order["price_check"] = order_price_check
|
||||||
if order.get("factura_numar") and order.get("factura_data"):
|
if order.get("factura_numar") and order.get("factura_data"):
|
||||||
order["invoice"] = {
|
order["invoice"] = {
|
||||||
"facturat": True,
|
"facturat": True,
|
||||||
@@ -438,6 +509,19 @@ async def order_detail(order_number: str):
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
# Invoice reconciliation
|
||||||
|
inv = order.get("invoice")
|
||||||
|
if inv and inv.get("facturat") and inv.get("total_cu_tva") is not None:
|
||||||
|
order_total = float(order.get("order_total") or 0)
|
||||||
|
inv_total = float(inv["total_cu_tva"])
|
||||||
|
difference = round(inv_total - order_total, 2)
|
||||||
|
inv["reconciliation"] = {
|
||||||
|
"order_total": order_total,
|
||||||
|
"invoice_total": inv_total,
|
||||||
|
"difference": difference,
|
||||||
|
"match": abs(difference) < 0.01,
|
||||||
|
}
|
||||||
|
|
||||||
# Parse discount_split JSON string
|
# Parse discount_split JSON string
|
||||||
if order.get("discount_split"):
|
if order.get("discount_split"):
|
||||||
try:
|
try:
|
||||||
@@ -445,8 +529,7 @@ async def order_detail(order_number: str):
|
|||||||
except (json.JSONDecodeError, TypeError):
|
except (json.JSONDecodeError, TypeError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Add settings for receipt display
|
# Add settings for receipt display (app_settings already fetched above)
|
||||||
app_settings = await sqlite_service.get_app_settings()
|
|
||||||
order["transport_vat"] = app_settings.get("transport_vat") or "21"
|
order["transport_vat"] = app_settings.get("transport_vat") or "21"
|
||||||
order["transport_codmat"] = app_settings.get("transport_codmat") or ""
|
order["transport_codmat"] = app_settings.get("transport_codmat") or ""
|
||||||
order["discount_codmat"] = app_settings.get("discount_codmat") or ""
|
order["discount_codmat"] = app_settings.get("discount_codmat") or ""
|
||||||
@@ -454,6 +537,52 @@ async def order_detail(order_number: str):
|
|||||||
return detail
|
return detail
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/orders/{order_number}/retry")
|
||||||
|
async def retry_order(order_number: str):
|
||||||
|
"""Retry importing a failed/skipped order."""
|
||||||
|
from ..services import retry_service
|
||||||
|
|
||||||
|
app_settings = await sqlite_service.get_app_settings()
|
||||||
|
result = await retry_service.retry_single_order(order_number, app_settings)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/orders/by-sku/{sku}/pending")
|
||||||
|
async def get_pending_orders_for_sku(sku: str):
|
||||||
|
"""Get SKIPPED orders that contain the given SKU."""
|
||||||
|
order_numbers = await sqlite_service.get_skipped_orders_with_sku(sku)
|
||||||
|
return {"sku": sku, "order_numbers": order_numbers, "count": len(order_numbers)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/orders/batch-retry")
|
||||||
|
async def batch_retry_orders(request: Request):
|
||||||
|
"""Batch retry multiple orders."""
|
||||||
|
from ..services import retry_service
|
||||||
|
body = await request.json()
|
||||||
|
order_numbers = body.get("order_numbers", [])
|
||||||
|
if not order_numbers:
|
||||||
|
return {"success": False, "message": "No orders specified"}
|
||||||
|
|
||||||
|
app_settings = await sqlite_service.get_app_settings()
|
||||||
|
results = {"imported": 0, "errors": 0, "messages": []}
|
||||||
|
|
||||||
|
for on in order_numbers[:20]: # Limit to 20 to avoid timeout
|
||||||
|
result = await retry_service.retry_single_order(str(on), app_settings)
|
||||||
|
if result.get("success"):
|
||||||
|
results["imported"] += 1
|
||||||
|
else:
|
||||||
|
results["errors"] += 1
|
||||||
|
results["messages"].append(f"{on}: {result.get('message', 'Error')}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": results["imported"] > 0,
|
||||||
|
"imported": results["imported"],
|
||||||
|
"errors": results["errors"],
|
||||||
|
"message": f"{results['imported']} importate, {results['errors']} erori" if results["errors"] else f"{results['imported']} importate cu succes",
|
||||||
|
"details": results["messages"][:5],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/dashboard/orders")
|
@router.get("/api/dashboard/orders")
|
||||||
async def dashboard_orders(page: int = 1, per_page: int = 50,
|
async def dashboard_orders(page: int = 1, per_page: int = 50,
|
||||||
search: str = "", status: str = "all",
|
search: str = "", status: str = "all",
|
||||||
@@ -484,6 +613,9 @@ async def dashboard_orders(page: int = 1, per_page: int = 50,
|
|||||||
# Enrich orders with invoice data — prefer SQLite cache, fallback to Oracle
|
# Enrich orders with invoice data — prefer SQLite cache, fallback to Oracle
|
||||||
all_orders = result["orders"]
|
all_orders = result["orders"]
|
||||||
for o in all_orders:
|
for o in all_orders:
|
||||||
|
# price_match: 1=OK, 0=mismatch, NULL=not checked yet
|
||||||
|
pm = o.get("price_match")
|
||||||
|
o["price_match"] = True if pm == 1 else (False if pm == 0 else None)
|
||||||
if o.get("factura_numar") and o.get("factura_data"):
|
if o.get("factura_numar") and o.get("factura_data"):
|
||||||
# Use cached invoice data from SQLite (only if complete)
|
# Use cached invoice data from SQLite (only if complete)
|
||||||
o["invoice"] = {
|
o["invoice"] = {
|
||||||
@@ -534,9 +666,8 @@ async def dashboard_orders(page: int = 1, per_page: int = 50,
|
|||||||
|
|
||||||
# Use counts from sqlite_service (already period-scoped)
|
# Use counts from sqlite_service (already period-scoped)
|
||||||
counts = result.get("counts", {})
|
counts = result.get("counts", {})
|
||||||
# Count newly-cached invoices found during this request
|
# Adjust uninvoiced count for invoices discovered via Oracle during this request
|
||||||
newly_invoiced = sum(1 for o in uncached_orders if o.get("invoice") and o["invoice"].get("facturat"))
|
newly_invoiced = sum(1 for o in uncached_orders if o.get("invoice") and o["invoice"].get("facturat"))
|
||||||
# Adjust uninvoiced count: start from SQLite count, subtract newly-found invoices
|
|
||||||
uninvoiced_base = counts.get("uninvoiced_sqlite", sum(
|
uninvoiced_base = counts.get("uninvoiced_sqlite", sum(
|
||||||
1 for o in all_orders
|
1 for o in all_orders
|
||||||
if o.get("status") in ("IMPORTED", "ALREADY_IMPORTED") and not o.get("invoice")
|
if o.get("status") in ("IMPORTED", "ALREADY_IMPORTED") and not o.get("invoice")
|
||||||
@@ -546,6 +677,13 @@ async def dashboard_orders(page: int = 1, per_page: int = 50,
|
|||||||
counts["facturate"] = max(0, imported_total - counts["nefacturate"])
|
counts["facturate"] = max(0, imported_total - counts["nefacturate"])
|
||||||
counts.setdefault("total", counts.get("imported", 0) + counts.get("skipped", 0) + counts.get("error", 0))
|
counts.setdefault("total", counts.get("imported", 0) + counts.get("skipped", 0) + counts.get("error", 0))
|
||||||
|
|
||||||
|
# Attention metrics: add unresolved SKUs count
|
||||||
|
try:
|
||||||
|
stats = await sqlite_service.get_dashboard_stats()
|
||||||
|
counts["unresolved_skus"] = stats.get("unresolved_skus", 0)
|
||||||
|
except Exception:
|
||||||
|
counts["unresolved_skus"] = 0
|
||||||
|
|
||||||
# For UNINVOICED filter: apply server-side filtering + pagination
|
# For UNINVOICED filter: apply server-side filtering + pagination
|
||||||
if is_uninvoiced_filter:
|
if is_uninvoiced_filter:
|
||||||
filtered = [o for o in all_orders if o.get("status") in ("IMPORTED", "ALREADY_IMPORTED") and not o.get("invoice")]
|
filtered = [o for o in all_orders if o.get("status") in ("IMPORTED", "ALREADY_IMPORTED") and not o.get("invoice")]
|
||||||
|
|||||||
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)
|
cursor = await db.execute(f"SELECT COUNT(*) FROM orders {uninv_where}", base_params)
|
||||||
uninvoiced_sqlite = (await cursor.fetchone())[0]
|
uninvoiced_sqlite = (await cursor.fetchone())[0]
|
||||||
|
|
||||||
|
# Uninvoiced > 3 days old
|
||||||
|
uninv_old_clauses = list(base_clauses) + [
|
||||||
|
"UPPER(status) IN ('IMPORTED', 'ALREADY_IMPORTED')",
|
||||||
|
"(factura_numar IS NULL OR factura_numar = '')",
|
||||||
|
"order_date < datetime('now', '-3 days')",
|
||||||
|
]
|
||||||
|
uninv_old_where = "WHERE " + " AND ".join(uninv_old_clauses)
|
||||||
|
cursor = await db.execute(f"SELECT COUNT(*) FROM orders {uninv_old_where}", base_params)
|
||||||
|
uninvoiced_old = (await cursor.fetchone())[0]
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"orders": [dict(r) for r in rows],
|
"orders": [dict(r) for r in rows],
|
||||||
"total": total,
|
"total": total,
|
||||||
@@ -754,6 +764,7 @@ async def get_orders(page: int = 1, per_page: int = 50,
|
|||||||
"cancelled": status_counts.get("CANCELLED", 0),
|
"cancelled": status_counts.get("CANCELLED", 0),
|
||||||
"total": sum(status_counts.values()),
|
"total": sum(status_counts.values()),
|
||||||
"uninvoiced_sqlite": uninvoiced_sqlite,
|
"uninvoiced_sqlite": uninvoiced_sqlite,
|
||||||
|
"uninvoiced_old": uninvoiced_old,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
finally:
|
finally:
|
||||||
@@ -820,6 +831,20 @@ async def update_order_invoice(order_number: str, serie: str = None,
|
|||||||
await db.close()
|
await db.close()
|
||||||
|
|
||||||
|
|
||||||
|
async def update_order_price_match(order_number: str, match: bool | None):
|
||||||
|
"""Cache price_match result (True=OK, False=mismatch, None=unavailable)."""
|
||||||
|
db = await get_sqlite()
|
||||||
|
try:
|
||||||
|
val = None if match is None else (1 if match else 0)
|
||||||
|
await db.execute(
|
||||||
|
"UPDATE orders SET price_match = ?, updated_at = datetime('now') WHERE order_number = ?",
|
||||||
|
(val, order_number),
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
finally:
|
||||||
|
await db.close()
|
||||||
|
|
||||||
|
|
||||||
async def get_invoiced_imported_orders() -> list:
|
async def get_invoiced_imported_orders() -> list:
|
||||||
"""Get imported orders that HAVE cached invoice data (for re-verification)."""
|
"""Get imported orders that HAVE cached invoice data (for re-verification)."""
|
||||||
db = await get_sqlite()
|
db = await get_sqlite()
|
||||||
@@ -949,6 +974,24 @@ async def set_app_setting(key: str, value: str):
|
|||||||
await db.close()
|
await db.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ── SKU-based order lookup ────────────────────────
|
||||||
|
|
||||||
|
async def get_skipped_orders_with_sku(sku: str) -> list[str]:
|
||||||
|
"""Get order_numbers of SKIPPED orders that contain the given SKU."""
|
||||||
|
db = await get_sqlite()
|
||||||
|
try:
|
||||||
|
cursor = await db.execute("""
|
||||||
|
SELECT DISTINCT oi.order_number
|
||||||
|
FROM order_items oi
|
||||||
|
JOIN orders o ON o.order_number = oi.order_number
|
||||||
|
WHERE oi.sku = ? AND o.status = 'SKIPPED'
|
||||||
|
""", (sku,))
|
||||||
|
rows = await cursor.fetchall()
|
||||||
|
return [row[0] for row in rows]
|
||||||
|
finally:
|
||||||
|
await db.close()
|
||||||
|
|
||||||
|
|
||||||
# ── Price Sync Runs ───────────────────────────────
|
# ── Price Sync Runs ───────────────────────────────
|
||||||
|
|
||||||
async def get_price_sync_runs(page: int = 1, per_page: int = 20):
|
async def get_price_sync_runs(page: int = 1, per_page: int = 20):
|
||||||
|
|||||||
@@ -586,3 +586,189 @@ def sync_prices_from_order(orders, mapped_codmat_data: dict, direct_id_map: dict
|
|||||||
database.pool.release(conn)
|
database.pool.release(conn)
|
||||||
|
|
||||||
return updated
|
return updated
|
||||||
|
|
||||||
|
|
||||||
|
def get_prices_for_order(items: list[dict], app_settings: dict, conn=None) -> dict:
|
||||||
|
"""Compare GoMag prices with ROA prices for order items.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
items: list of order items, each with 'sku', 'price', 'quantity', 'codmat_details'
|
||||||
|
(codmat_details = [{"codmat", "cantitate_roa", "id_articol"?, "cont"?, "direct"?}])
|
||||||
|
app_settings: dict with 'id_pol', 'id_pol_productie'
|
||||||
|
conn: Oracle connection (optional, will acquire if None)
|
||||||
|
|
||||||
|
Returns: {
|
||||||
|
"items": {idx: {"pret_roa": float|None, "match": bool|None, "pret_gomag": float}},
|
||||||
|
"summary": {"mismatches": int, "checked": int, "oracle_available": bool}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
id_pol = int(app_settings.get("id_pol", 0) or 0)
|
||||||
|
id_pol_productie = int(app_settings.get("id_pol_productie", 0) or 0)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
id_pol = 0
|
||||||
|
id_pol_productie = 0
|
||||||
|
|
||||||
|
def _empty_result(oracle_available: bool) -> dict:
|
||||||
|
return {
|
||||||
|
"items": {
|
||||||
|
idx: {"pret_roa": None, "match": None, "pret_gomag": float(item.get("price") or 0)}
|
||||||
|
for idx, item in enumerate(items)
|
||||||
|
},
|
||||||
|
"summary": {"mismatches": 0, "checked": 0, "oracle_available": oracle_available}
|
||||||
|
}
|
||||||
|
|
||||||
|
if not items or not id_pol:
|
||||||
|
return _empty_result(oracle_available=False)
|
||||||
|
|
||||||
|
own_conn = conn is None
|
||||||
|
try:
|
||||||
|
if own_conn:
|
||||||
|
conn = database.get_oracle_connection()
|
||||||
|
|
||||||
|
# Step 1: Collect codmats; use id_articol/cont from codmat_details when already known
|
||||||
|
pre_resolved = {} # {codmat: {"id_articol": int, "cont": str}}
|
||||||
|
all_codmats = set()
|
||||||
|
for item in items:
|
||||||
|
for cd in (item.get("codmat_details") or []):
|
||||||
|
codmat = cd.get("codmat")
|
||||||
|
if not codmat:
|
||||||
|
continue
|
||||||
|
all_codmats.add(codmat)
|
||||||
|
if cd.get("id_articol") and codmat not in pre_resolved:
|
||||||
|
pre_resolved[codmat] = {
|
||||||
|
"id_articol": cd["id_articol"],
|
||||||
|
"cont": cd.get("cont") or "",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Step 2: Resolve missing id_articols via nom_articole
|
||||||
|
need_resolve = all_codmats - set(pre_resolved.keys())
|
||||||
|
if need_resolve:
|
||||||
|
db_resolved = resolve_codmat_ids(need_resolve, conn=conn)
|
||||||
|
pre_resolved.update(db_resolved)
|
||||||
|
|
||||||
|
codmat_info = pre_resolved # {codmat: {"id_articol": int, "cont": str}}
|
||||||
|
|
||||||
|
# Step 3: Get PRETURI_CU_TVA flag once per policy
|
||||||
|
policies = {id_pol}
|
||||||
|
if id_pol_productie and id_pol_productie != id_pol:
|
||||||
|
policies.add(id_pol_productie)
|
||||||
|
|
||||||
|
pol_cu_tva = {} # {id_pol: bool}
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
for pol in policies:
|
||||||
|
cur.execute(
|
||||||
|
"SELECT PRETURI_CU_TVA FROM CRM_POLITICI_PRETURI WHERE ID_POL = :pol",
|
||||||
|
{"pol": pol},
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
pol_cu_tva[pol] = (int(row[0] or 0) == 1) if row else False
|
||||||
|
|
||||||
|
# Step 4: Batch query PRET + PROC_TVAV for all id_articols across both policies
|
||||||
|
all_id_articols = list({
|
||||||
|
info["id_articol"]
|
||||||
|
for info in codmat_info.values()
|
||||||
|
if info.get("id_articol")
|
||||||
|
})
|
||||||
|
price_map = {} # {(id_pol, id_articol): (pret, proc_tvav)}
|
||||||
|
|
||||||
|
if all_id_articols:
|
||||||
|
pol_list = list(policies)
|
||||||
|
pol_placeholders = ",".join([f":p{k}" for k in range(len(pol_list))])
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
for i in range(0, len(all_id_articols), 500):
|
||||||
|
batch = all_id_articols[i:i + 500]
|
||||||
|
art_placeholders = ",".join([f":a{j}" for j in range(len(batch))])
|
||||||
|
params = {f"a{j}": aid for j, aid in enumerate(batch)}
|
||||||
|
for k, pol in enumerate(pol_list):
|
||||||
|
params[f"p{k}"] = pol
|
||||||
|
cur.execute(f"""
|
||||||
|
SELECT ID_POL, ID_ARTICOL, PRET, PROC_TVAV
|
||||||
|
FROM CRM_POLITICI_PRET_ART
|
||||||
|
WHERE ID_POL IN ({pol_placeholders}) AND ID_ARTICOL IN ({art_placeholders})
|
||||||
|
""", params)
|
||||||
|
for row in cur:
|
||||||
|
price_map[(row[0], row[1])] = (row[2], row[3])
|
||||||
|
|
||||||
|
# Step 5: Compute pret_roa per item and compare with GoMag price
|
||||||
|
result_items = {}
|
||||||
|
mismatches = 0
|
||||||
|
checked = 0
|
||||||
|
|
||||||
|
for idx, item in enumerate(items):
|
||||||
|
pret_gomag = float(item.get("price") or 0)
|
||||||
|
result_items[idx] = {"pret_gomag": pret_gomag, "pret_roa": None, "match": None}
|
||||||
|
|
||||||
|
codmat_details = item.get("codmat_details") or []
|
||||||
|
if not codmat_details:
|
||||||
|
continue
|
||||||
|
|
||||||
|
is_kit = len(codmat_details) > 1 or (
|
||||||
|
len(codmat_details) == 1
|
||||||
|
and float(codmat_details[0].get("cantitate_roa") or 1) > 1
|
||||||
|
)
|
||||||
|
|
||||||
|
pret_roa_total = 0.0
|
||||||
|
all_resolved = True
|
||||||
|
|
||||||
|
for cd in codmat_details:
|
||||||
|
codmat = cd.get("codmat")
|
||||||
|
if not codmat:
|
||||||
|
all_resolved = False
|
||||||
|
break
|
||||||
|
|
||||||
|
info = codmat_info.get(codmat, {})
|
||||||
|
id_articol = info.get("id_articol")
|
||||||
|
if not id_articol:
|
||||||
|
all_resolved = False
|
||||||
|
break
|
||||||
|
|
||||||
|
# Dual-policy routing: cont 341/345 → production, else → sales
|
||||||
|
cont = str(info.get("cont") or cd.get("cont") or "").strip()
|
||||||
|
if cont in ("341", "345") and id_pol_productie:
|
||||||
|
pol = id_pol_productie
|
||||||
|
else:
|
||||||
|
pol = id_pol
|
||||||
|
|
||||||
|
price_entry = price_map.get((pol, id_articol))
|
||||||
|
if price_entry is None:
|
||||||
|
all_resolved = False
|
||||||
|
break
|
||||||
|
|
||||||
|
pret, proc_tvav = price_entry
|
||||||
|
proc_tvav = float(proc_tvav or 1.19)
|
||||||
|
|
||||||
|
if pol_cu_tva.get(pol):
|
||||||
|
pret_cu_tva = float(pret or 0)
|
||||||
|
else:
|
||||||
|
pret_cu_tva = float(pret or 0) * proc_tvav
|
||||||
|
|
||||||
|
cantitate_roa = float(cd.get("cantitate_roa") or 1)
|
||||||
|
if is_kit:
|
||||||
|
pret_roa_total += pret_cu_tva * cantitate_roa
|
||||||
|
else:
|
||||||
|
pret_roa_total = pret_cu_tva # cantitate_roa==1 for simple items
|
||||||
|
|
||||||
|
if not all_resolved:
|
||||||
|
continue
|
||||||
|
|
||||||
|
pret_roa = round(pret_roa_total, 4)
|
||||||
|
match = abs(pret_gomag - pret_roa) < 0.01
|
||||||
|
result_items[idx]["pret_roa"] = pret_roa
|
||||||
|
result_items[idx]["match"] = match
|
||||||
|
checked += 1
|
||||||
|
if not match:
|
||||||
|
mismatches += 1
|
||||||
|
|
||||||
|
logger.info(f"get_prices_for_order: {checked}/{len(items)} checked, {mismatches} mismatches")
|
||||||
|
return {
|
||||||
|
"items": result_items,
|
||||||
|
"summary": {"mismatches": mismatches, "checked": checked, "oracle_available": True},
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"get_prices_for_order failed: {e}")
|
||||||
|
return _empty_result(oracle_available=False)
|
||||||
|
finally:
|
||||||
|
if own_conn and conn:
|
||||||
|
database.pool.release(conn)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -20,6 +20,7 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||||||
loadDashOrders();
|
loadDashOrders();
|
||||||
startSyncPolling();
|
startSyncPolling();
|
||||||
wireFilterBar();
|
wireFilterBar();
|
||||||
|
checkFirstTime();
|
||||||
});
|
});
|
||||||
|
|
||||||
async function initPollInterval() {
|
async function initPollInterval() {
|
||||||
@@ -119,11 +120,33 @@ function updateSyncPanel(data) {
|
|||||||
}
|
}
|
||||||
if (st) {
|
if (st) {
|
||||||
st.textContent = lr.status === 'completed' ? '\u2713' : '\u2715';
|
st.textContent = lr.status === 'completed' ? '\u2713' : '\u2715';
|
||||||
st.style.color = lr.status === 'completed' ? '#10b981' : '#ef4444';
|
st.style.color = lr.status === 'completed' ? 'var(--success)' : 'var(--error)';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function checkFirstTime() {
|
||||||
|
const welcomeEl = document.getElementById('welcomeCard');
|
||||||
|
if (!welcomeEl) return;
|
||||||
|
try {
|
||||||
|
const data = await fetchJSON('/api/sync/status');
|
||||||
|
if (!data.last_run) {
|
||||||
|
welcomeEl.innerHTML = `<div class="welcome-card">
|
||||||
|
<h5 style="font-family:var(--font-display);margin:0 0 8px">Bine ai venit!</h5>
|
||||||
|
<p class="text-muted mb-2" style="font-size:0.875rem">Configureaza si ruleaza primul sync:</p>
|
||||||
|
<div class="welcome-steps">
|
||||||
|
<span class="welcome-step"><b>1.</b> <a href="${window.ROOT_PATH||''}/settings">Verifica Settings</a></span>
|
||||||
|
<span class="welcome-step"><b>2.</b> Apasa "Start Sync"</span>
|
||||||
|
<span class="welcome-step"><b>3.</b> <a href="${window.ROOT_PATH||''}/missing-skus">Mapeaza SKU-urile lipsa</a></span>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
welcomeEl.style.display = '';
|
||||||
|
} else {
|
||||||
|
welcomeEl.style.display = 'none';
|
||||||
|
}
|
||||||
|
} catch(e) { welcomeEl.style.display = 'none'; }
|
||||||
|
}
|
||||||
|
|
||||||
// Wire last-sync-row click → journal (use current running sync if active)
|
// Wire last-sync-row click → journal (use current running sync if active)
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
document.getElementById('lastSyncRow')?.addEventListener('click', () => {
|
document.getElementById('lastSyncRow')?.addEventListener('click', () => {
|
||||||
@@ -201,10 +224,14 @@ async function loadSchedulerStatus() {
|
|||||||
// ── Filter Bar wiring ─────────────────────────────
|
// ── Filter Bar wiring ─────────────────────────────
|
||||||
|
|
||||||
function wireFilterBar() {
|
function wireFilterBar() {
|
||||||
// Period dropdown
|
// Period preset buttons
|
||||||
document.getElementById('periodSelect')?.addEventListener('change', function () {
|
document.querySelectorAll('.preset-btn[data-days]').forEach(btn => {
|
||||||
|
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');
|
const cr = document.getElementById('customRangeInputs');
|
||||||
if (this.value === 'custom') {
|
if (days === 'custom') {
|
||||||
cr?.classList.add('visible');
|
cr?.classList.add('visible');
|
||||||
} else {
|
} else {
|
||||||
cr?.classList.remove('visible');
|
cr?.classList.remove('visible');
|
||||||
@@ -212,6 +239,7 @@ function wireFilterBar() {
|
|||||||
loadDashOrders();
|
loadDashOrders();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// Custom range inputs
|
// Custom range inputs
|
||||||
['periodStart', 'periodEnd'].forEach(id => {
|
['periodStart', 'periodEnd'].forEach(id => {
|
||||||
@@ -260,7 +288,8 @@ function dashSortBy(col) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadDashOrders() {
|
async function loadDashOrders() {
|
||||||
const periodVal = document.getElementById('periodSelect')?.value || '7';
|
const activePreset = document.querySelector('.preset-btn.active');
|
||||||
|
const periodVal = activePreset?.dataset.days || '3';
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
|
|
||||||
if (periodVal === 'custom') {
|
if (periodVal === 'custom') {
|
||||||
@@ -301,11 +330,29 @@ async function loadDashOrders() {
|
|||||||
if (el('cntNef')) el('cntNef').textContent = c.nefacturate || c.uninvoiced || 0;
|
if (el('cntNef')) el('cntNef').textContent = c.nefacturate || c.uninvoiced || 0;
|
||||||
if (el('cntCanc')) el('cntCanc').textContent = c.cancelled || 0;
|
if (el('cntCanc')) el('cntCanc').textContent = c.cancelled || 0;
|
||||||
|
|
||||||
|
// Attention card
|
||||||
|
const attnEl = document.getElementById('attentionCard');
|
||||||
|
if (attnEl) {
|
||||||
|
const errors = c.error || 0;
|
||||||
|
const unmapped = c.unresolved_skus || 0;
|
||||||
|
const nefact = c.nefacturate || 0;
|
||||||
|
|
||||||
|
if (errors === 0 && unmapped === 0 && nefact === 0) {
|
||||||
|
attnEl.innerHTML = '<div class="attention-card attention-ok"><i class="bi bi-check-circle"></i> Totul in ordine</div>';
|
||||||
|
} else {
|
||||||
|
let items = [];
|
||||||
|
if (errors > 0) items.push(`<span class="attention-item attention-error" onclick="document.querySelector('.filter-pill[data-status=ERROR]')?.click()"><i class="bi bi-exclamation-triangle"></i> ${errors} erori import</span>`);
|
||||||
|
if (unmapped > 0) items.push(`<span class="attention-item attention-warning" onclick="window.location='${window.ROOT_PATH||''}/missing-skus'"><i class="bi bi-puzzle"></i> ${unmapped} SKU-uri nemapate</span>`);
|
||||||
|
if (nefact > 0) items.push(`<span class="attention-item attention-warning" onclick="document.querySelector('.filter-pill[data-status=UNINVOICED]')?.click()"><i class="bi bi-receipt"></i> ${nefact} nefacturate</span>`);
|
||||||
|
attnEl.innerHTML = '<div class="attention-card attention-alert">' + items.join('') + '</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const tbody = document.getElementById('dashOrdersBody');
|
const tbody = document.getElementById('dashOrdersBody');
|
||||||
const orders = data.orders || [];
|
const orders = data.orders || [];
|
||||||
|
|
||||||
if (orders.length === 0) {
|
if (orders.length === 0) {
|
||||||
tbody.innerHTML = '<tr><td colspan="9" class="text-center text-muted py-3">Nicio comanda</td></tr>';
|
tbody.innerHTML = '<tr><td colspan="10" class="text-center text-muted py-3">Nicio comanda</td></tr>';
|
||||||
} else {
|
} else {
|
||||||
tbody.innerHTML = orders.map(o => {
|
tbody.innerHTML = orders.map(o => {
|
||||||
const dateStr = fmtDate(o.order_date);
|
const dateStr = fmtDate(o.order_date);
|
||||||
@@ -321,6 +368,7 @@ async function loadDashOrders() {
|
|||||||
<td class="text-end text-muted">${fmtCost(o.discount_total)}</td>
|
<td class="text-end text-muted">${fmtCost(o.discount_total)}</td>
|
||||||
<td class="text-end fw-bold">${orderTotal}</td>
|
<td class="text-end fw-bold">${orderTotal}</td>
|
||||||
<td class="text-center">${invoiceDot(o)}</td>
|
<td class="text-center">${invoiceDot(o)}</td>
|
||||||
|
<td class="text-center">${priceDot(o)}</td>
|
||||||
</tr>`;
|
</tr>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
}
|
}
|
||||||
@@ -340,11 +388,12 @@ async function loadDashOrders() {
|
|||||||
}
|
}
|
||||||
const name = o.customer_name || o.shipping_name || o.billing_name || '\u2014';
|
const name = o.customer_name || o.shipping_name || o.billing_name || '\u2014';
|
||||||
const totalStr = o.order_total ? Number(o.order_total).toFixed(2) : '';
|
const totalStr = o.order_total ? Number(o.order_total).toFixed(2) : '';
|
||||||
|
const priceMismatch = o.price_match === false ? '<span class="dot dot-red" style="width:6px;height:6px" title="Pret!="></span> ' : '';
|
||||||
return `<div class="flat-row" onclick="openDashOrderDetail('${esc(o.order_number)}')" style="font-size:0.875rem">
|
return `<div class="flat-row" onclick="openDashOrderDetail('${esc(o.order_number)}')" style="font-size:0.875rem">
|
||||||
${statusDot(o.status)}
|
${statusDot(o.status)}
|
||||||
<span style="color:#6b7280" class="text-nowrap">${dateFmt}</span>
|
<span style="color:var(--text-muted)" class="text-nowrap">${dateFmt}</span>
|
||||||
<span class="grow truncate fw-bold">${esc(name)}</span>
|
<span class="grow truncate fw-bold">${esc(name)}</span>
|
||||||
<span class="text-nowrap">x${o.items_count || 0}${totalStr ? ' · <strong>' + totalStr + '</strong>' : ''}</span>
|
<span class="text-nowrap">x${o.items_count || 0}${totalStr ? ' · ' + priceMismatch + '<strong>' + totalStr + '</strong>' : ''}</span>
|
||||||
</div>`;
|
</div>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
}
|
}
|
||||||
@@ -432,14 +481,6 @@ function escHtml(s) {
|
|||||||
.replace(/'/g, ''');
|
.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) {
|
function statusLabelText(status) {
|
||||||
switch ((status || '').toUpperCase()) {
|
switch ((status || '').toUpperCase()) {
|
||||||
case 'IMPORTED': return 'Importat';
|
case 'IMPORTED': return 'Importat';
|
||||||
@@ -450,16 +491,10 @@ function statusLabelText(status) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function orderStatusBadge(status) {
|
function priceDot(order) {
|
||||||
switch ((status || '').toUpperCase()) {
|
if (order.price_match === true) return '<span class="dot dot-green" title="Preturi OK"></span>';
|
||||||
case 'IMPORTED': return '<span class="badge bg-success">Importat</span>';
|
if (order.price_match === false) return '<span class="dot dot-red" title="Diferenta de pret"></span>';
|
||||||
case 'ALREADY_IMPORTED': return '<span class="badge bg-info">Deja importat</span>';
|
return '<span class="dot dot-gray" title="Neverificat"></span>';
|
||||||
case 'SKIPPED': return '<span class="badge bg-warning">Omis</span>';
|
|
||||||
case 'ERROR': return '<span class="badge bg-danger">Eroare</span>';
|
|
||||||
case 'CANCELLED': return '<span class="badge bg-secondary">Anulat</span>';
|
|
||||||
case 'DELETED_IN_ROA': return '<span class="badge bg-dark">Sters din ROA</span>';
|
|
||||||
default: return `<span class="badge bg-secondary">${esc(status)}</span>`;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function invoiceDot(order) {
|
function invoiceDot(order) {
|
||||||
@@ -468,22 +503,6 @@ function invoiceDot(order) {
|
|||||||
return '<span class="dot dot-red" title="Nefacturat"></span>';
|
return '<span class="dot dot-red" title="Nefacturat"></span>';
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderCodmatCell(item) {
|
|
||||||
if (!item.codmat_details || item.codmat_details.length === 0) {
|
|
||||||
return `<code>${esc(item.codmat || '-')}</code>`;
|
|
||||||
}
|
|
||||||
if (item.codmat_details.length === 1) {
|
|
||||||
const d = item.codmat_details[0];
|
|
||||||
if (d.direct) {
|
|
||||||
return `<code>${esc(d.codmat)}</code> <span class="badge bg-secondary" style="font-size:0.6rem;vertical-align:middle">direct</span>`;
|
|
||||||
}
|
|
||||||
return `<code>${esc(d.codmat)}</code>`;
|
|
||||||
}
|
|
||||||
return item.codmat_details.map(d =>
|
|
||||||
`<div class="small"><code>${esc(d.codmat)}</code> <span class="text-muted">\xd7${d.cantitate_roa}</span></div>`
|
|
||||||
).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Refresh Invoices ──────────────────────────────
|
// ── Refresh Invoices ──────────────────────────────
|
||||||
|
|
||||||
async function refreshInvoices() {
|
async function refreshInvoices() {
|
||||||
@@ -509,262 +528,12 @@ async function refreshInvoices() {
|
|||||||
|
|
||||||
// ── Order Detail Modal ────────────────────────────
|
// ── Order Detail Modal ────────────────────────────
|
||||||
|
|
||||||
async function openDashOrderDetail(orderNumber) {
|
function openDashOrderDetail(orderNumber) {
|
||||||
document.getElementById('detailOrderNumber').textContent = '#' + orderNumber;
|
_sharedModalQuickMapFn = openDashQuickMap;
|
||||||
document.getElementById('detailCustomer').textContent = '...';
|
renderOrderDetailModal(orderNumber, {
|
||||||
document.getElementById('detailDate').textContent = '';
|
onQuickMap: openDashQuickMap,
|
||||||
document.getElementById('detailStatus').innerHTML = '';
|
onAfterRender: function() { /* nothing extra needed */ }
|
||||||
document.getElementById('detailIdComanda').textContent = '-';
|
|
||||||
document.getElementById('detailIdPartener').textContent = '-';
|
|
||||||
document.getElementById('detailIdAdresaFact').textContent = '-';
|
|
||||||
document.getElementById('detailIdAdresaLivr').textContent = '-';
|
|
||||||
document.getElementById('detailItemsBody').innerHTML = '<tr><td colspan="7" class="text-center">Se incarca...</td></tr>';
|
|
||||||
document.getElementById('detailError').style.display = 'none';
|
|
||||||
document.getElementById('detailReceipt').innerHTML = '';
|
|
||||||
document.getElementById('detailReceiptMobile').innerHTML = '';
|
|
||||||
const invInfo = document.getElementById('detailInvoiceInfo');
|
|
||||||
if (invInfo) invInfo.style.display = 'none';
|
|
||||||
const mobileContainer = document.getElementById('detailItemsMobile');
|
|
||||||
if (mobileContainer) mobileContainer.innerHTML = '';
|
|
||||||
|
|
||||||
const modalEl = document.getElementById('orderDetailModal');
|
|
||||||
const existing = bootstrap.Modal.getInstance(modalEl);
|
|
||||||
if (existing) { existing.show(); } else { new bootstrap.Modal(modalEl).show(); }
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch(`/api/sync/order/${encodeURIComponent(orderNumber)}`);
|
|
||||||
const data = await res.json();
|
|
||||||
|
|
||||||
if (data.error) {
|
|
||||||
document.getElementById('detailError').textContent = data.error;
|
|
||||||
document.getElementById('detailError').style.display = '';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const order = data.order || {};
|
|
||||||
document.getElementById('detailCustomer').textContent = order.customer_name || '-';
|
|
||||||
document.getElementById('detailDate').textContent = fmtDate(order.order_date);
|
|
||||||
document.getElementById('detailStatus').innerHTML = orderStatusBadge(order.status);
|
|
||||||
document.getElementById('detailIdComanda').textContent = order.id_comanda || '-';
|
|
||||||
document.getElementById('detailIdPartener').textContent = order.id_partener || '-';
|
|
||||||
document.getElementById('detailIdAdresaFact').textContent = order.id_adresa_facturare || '-';
|
|
||||||
document.getElementById('detailIdAdresaLivr').textContent = order.id_adresa_livrare || '-';
|
|
||||||
|
|
||||||
// Invoice info
|
|
||||||
const invInfo = document.getElementById('detailInvoiceInfo');
|
|
||||||
const inv = order.invoice;
|
|
||||||
if (inv && inv.facturat) {
|
|
||||||
const serie = inv.serie_act || '';
|
|
||||||
const numar = inv.numar_act || '';
|
|
||||||
document.getElementById('detailInvoiceNumber').textContent = serie ? `${serie} ${numar}` : numar;
|
|
||||||
document.getElementById('detailInvoiceDate').textContent = inv.data_act ? fmtDate(inv.data_act) : '-';
|
|
||||||
if (invInfo) invInfo.style.display = '';
|
|
||||||
} else {
|
|
||||||
if (invInfo) invInfo.style.display = 'none';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (order.error_message) {
|
|
||||||
document.getElementById('detailError').textContent = order.error_message;
|
|
||||||
document.getElementById('detailError').style.display = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
const items = data.items || [];
|
|
||||||
if (items.length === 0) {
|
|
||||||
document.getElementById('detailItemsBody').innerHTML = '<tr><td colspan="7" class="text-center text-muted">Niciun articol</td></tr>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store items for quick map pre-population
|
|
||||||
window._detailItems = items;
|
|
||||||
|
|
||||||
// Mobile article flat list
|
|
||||||
const mobileContainer = document.getElementById('detailItemsMobile');
|
|
||||||
if (mobileContainer) {
|
|
||||||
let mobileHtml = items.map((item, idx) => {
|
|
||||||
const codmatText = item.codmat_details?.length
|
|
||||||
? item.codmat_details.map(d => `<code>${esc(d.codmat)}</code>${d.direct ? ' <span class="badge bg-secondary" style="font-size:0.55rem">direct</span>' : ''}`).join(' ')
|
|
||||||
: `<code>${esc(item.codmat || '–')}</code>`;
|
|
||||||
const valoare = (Number(item.price || 0) * Number(item.quantity || 0));
|
|
||||||
return `<div class="dif-item">
|
|
||||||
<div class="dif-row">
|
|
||||||
<span class="dif-sku dif-codmat-link" onclick="openDashQuickMap('${esc(item.sku)}','${esc(item.product_name||'')}','${esc(orderNumber)}', ${idx})">${esc(item.sku)}</span>
|
|
||||||
${codmatText}
|
|
||||||
</div>
|
|
||||||
<div class="dif-row">
|
|
||||||
<span class="dif-name">${esc(item.product_name || '–')}</span>
|
|
||||||
<span class="dif-qty">x${item.quantity || 0}</span>
|
|
||||||
<span class="dif-val">${fmtNum(valoare)} lei</span>
|
|
||||||
<span class="dif-vat text-muted" style="font-size:0.75rem">TVA ${item.vat != null ? Number(item.vat) : '?'}</span>
|
|
||||||
</div>
|
|
||||||
</div>`;
|
|
||||||
}).join('');
|
|
||||||
|
|
||||||
// Transport row (mobile)
|
|
||||||
if (order.delivery_cost > 0) {
|
|
||||||
const tVat = order.transport_vat || '21';
|
|
||||||
mobileHtml += `<div class="dif-item" style="opacity:0.7">
|
|
||||||
<div class="dif-row">
|
|
||||||
<span class="dif-name text-muted">Transport</span>
|
|
||||||
<span class="dif-qty">x1</span>
|
|
||||||
<span class="dif-val">${fmtNum(order.delivery_cost)} lei</span>
|
|
||||||
<span class="dif-vat text-muted" style="font-size:0.75rem">TVA ${tVat}</span>
|
|
||||||
</div>
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Discount rows (mobile)
|
|
||||||
if (order.discount_total > 0) {
|
|
||||||
const discSplit = computeDiscountSplit(items, order);
|
|
||||||
if (discSplit) {
|
|
||||||
Object.entries(discSplit)
|
|
||||||
.sort(([a], [b]) => Number(a) - Number(b))
|
|
||||||
.forEach(([rate, amt]) => {
|
|
||||||
if (amt > 0) mobileHtml += `<div class="dif-item" style="opacity:0.7">
|
|
||||||
<div class="dif-row">
|
|
||||||
<span class="dif-name text-muted">Discount</span>
|
|
||||||
<span class="dif-qty">x\u20131</span>
|
|
||||||
<span class="dif-val">${fmtNum(amt)} lei</span>
|
|
||||||
<span class="dif-vat text-muted" style="font-size:0.75rem">TVA ${Number(rate)}</span>
|
|
||||||
</div>
|
|
||||||
</div>`;
|
|
||||||
});
|
});
|
||||||
} else {
|
|
||||||
mobileHtml += `<div class="dif-item" style="opacity:0.7">
|
|
||||||
<div class="dif-row">
|
|
||||||
<span class="dif-name text-muted">Discount</span>
|
|
||||||
<span class="dif-qty">x\u20131</span>
|
|
||||||
<span class="dif-val">${fmtNum(order.discount_total)} lei</span>
|
|
||||||
</div>
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
mobileContainer.innerHTML = '<div class="detail-item-flat">' + mobileHtml + '</div>';
|
|
||||||
}
|
|
||||||
|
|
||||||
let tableHtml = items.map((item, idx) => {
|
|
||||||
const valoare = Number(item.price || 0) * Number(item.quantity || 0);
|
|
||||||
return `<tr>
|
|
||||||
<td><code class="codmat-link" onclick="openDashQuickMap('${esc(item.sku)}', '${esc(item.product_name || '')}', '${esc(orderNumber)}', ${idx})" title="Click pentru mapare">${esc(item.sku)}</code></td>
|
|
||||||
<td>${esc(item.product_name || '-')}</td>
|
|
||||||
<td>${renderCodmatCell(item)}</td>
|
|
||||||
<td class="text-end">${item.quantity || 0}</td>
|
|
||||||
<td class="text-end">${item.price != null ? fmtNum(item.price) : '-'}</td>
|
|
||||||
<td class="text-end">${item.vat != null ? Number(item.vat) : '-'}</td>
|
|
||||||
<td class="text-end">${fmtNum(valoare)}</td>
|
|
||||||
</tr>`;
|
|
||||||
}).join('');
|
|
||||||
|
|
||||||
// Transport row
|
|
||||||
if (order.delivery_cost > 0) {
|
|
||||||
const tVat = order.transport_vat || '21';
|
|
||||||
const tCodmat = order.transport_codmat || '';
|
|
||||||
tableHtml += `<tr class="table-light">
|
|
||||||
<td></td><td class="text-muted">Transport</td>
|
|
||||||
<td>${tCodmat ? '<code>' + esc(tCodmat) + '</code>' : ''}</td>
|
|
||||||
<td class="text-end">1</td><td class="text-end">${fmtNum(order.delivery_cost)}</td>
|
|
||||||
<td class="text-end">${tVat}</td><td class="text-end">${fmtNum(order.delivery_cost)}</td>
|
|
||||||
</tr>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Discount rows (split by VAT rate)
|
|
||||||
if (order.discount_total > 0) {
|
|
||||||
const dCodmat = order.discount_codmat || '';
|
|
||||||
const discSplit = computeDiscountSplit(items, order);
|
|
||||||
if (discSplit) {
|
|
||||||
Object.entries(discSplit)
|
|
||||||
.sort(([a], [b]) => Number(a) - Number(b))
|
|
||||||
.forEach(([rate, amt]) => {
|
|
||||||
if (amt > 0) tableHtml += `<tr class="table-light">
|
|
||||||
<td></td><td class="text-muted">Discount</td>
|
|
||||||
<td>${dCodmat ? '<code>' + esc(dCodmat) + '</code>' : ''}</td>
|
|
||||||
<td class="text-end">\u20131</td><td class="text-end">${fmtNum(amt)}</td>
|
|
||||||
<td class="text-end">${Number(rate)}</td><td class="text-end">\u2013${fmtNum(amt)}</td>
|
|
||||||
</tr>`;
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
tableHtml += `<tr class="table-light">
|
|
||||||
<td></td><td class="text-muted">Discount</td>
|
|
||||||
<td>${dCodmat ? '<code>' + esc(dCodmat) + '</code>' : ''}</td>
|
|
||||||
<td class="text-end">\u20131</td><td class="text-end">${fmtNum(order.discount_total)}</td>
|
|
||||||
<td class="text-end">-</td><td class="text-end">\u2013${fmtNum(order.discount_total)}</td>
|
|
||||||
</tr>`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
document.getElementById('detailItemsBody').innerHTML = tableHtml;
|
|
||||||
|
|
||||||
// Receipt footer (just total)
|
|
||||||
renderReceipt(items, order);
|
|
||||||
} catch (err) {
|
|
||||||
document.getElementById('detailError').textContent = err.message;
|
|
||||||
document.getElementById('detailError').style.display = '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function fmtNum(v) {
|
|
||||||
return Number(v).toLocaleString('ro-RO', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
|
||||||
}
|
|
||||||
|
|
||||||
function computeDiscountSplit(items, order) {
|
|
||||||
if (order.discount_split && typeof order.discount_split === 'object')
|
|
||||||
return order.discount_split;
|
|
||||||
|
|
||||||
// Compute proportionally from items by VAT rate
|
|
||||||
const byRate = {};
|
|
||||||
items.forEach(item => {
|
|
||||||
const rate = item.vat != null ? Number(item.vat) : null;
|
|
||||||
if (rate === null) return;
|
|
||||||
if (!byRate[rate]) byRate[rate] = 0;
|
|
||||||
byRate[rate] += Number(item.price || 0) * Number(item.quantity || 0);
|
|
||||||
});
|
|
||||||
const rates = Object.keys(byRate).sort((a, b) => Number(a) - Number(b));
|
|
||||||
if (rates.length === 0) return null;
|
|
||||||
|
|
||||||
const grandTotal = rates.reduce((s, r) => s + byRate[r], 0);
|
|
||||||
if (grandTotal <= 0) return null;
|
|
||||||
|
|
||||||
const split = {};
|
|
||||||
let remaining = order.discount_total;
|
|
||||||
rates.forEach((rate, i) => {
|
|
||||||
if (i === rates.length - 1) {
|
|
||||||
split[rate] = Math.round(remaining * 100) / 100;
|
|
||||||
} else {
|
|
||||||
const amt = Math.round(order.discount_total * byRate[rate] / grandTotal * 100) / 100;
|
|
||||||
split[rate] = amt;
|
|
||||||
remaining -= amt;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return split;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderReceipt(items, order) {
|
|
||||||
const desktop = document.getElementById('detailReceipt');
|
|
||||||
const mobile = document.getElementById('detailReceiptMobile');
|
|
||||||
if (!items.length) {
|
|
||||||
desktop.innerHTML = '';
|
|
||||||
mobile.innerHTML = '';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const articole = items.reduce((s, i) => s + Number(i.price || 0) * Number(i.quantity || 0), 0);
|
|
||||||
const discount = Number(order.discount_total || 0);
|
|
||||||
const transport = Number(order.delivery_cost || 0);
|
|
||||||
const total = order.order_total != null ? fmtNum(order.order_total) : '-';
|
|
||||||
|
|
||||||
// Desktop: full labels
|
|
||||||
let dHtml = `<span class="text-muted">Articole: <strong class="text-body">${fmtNum(articole)}</strong></span>`;
|
|
||||||
if (discount > 0) dHtml += `<span class="text-muted">Discount: <strong class="text-danger">\u2013${fmtNum(discount)}</strong></span>`;
|
|
||||||
if (transport > 0) dHtml += `<span class="text-muted">Transport: <strong class="text-body">${fmtNum(transport)}</strong></span>`;
|
|
||||||
dHtml += `<span>Total: <strong>${total} lei</strong></span>`;
|
|
||||||
desktop.innerHTML = dHtml;
|
|
||||||
|
|
||||||
// Mobile: shorter labels
|
|
||||||
let mHtml = `<span class="text-muted">Art: <strong class="text-body">${fmtNum(articole)}</strong></span>`;
|
|
||||||
if (discount > 0) mHtml += `<span class="text-muted">Disc: <strong class="text-danger">\u2013${fmtNum(discount)}</strong></span>`;
|
|
||||||
if (transport > 0) mHtml += `<span class="text-muted">Transp: <strong class="text-body">${fmtNum(transport)}</strong></span>`;
|
|
||||||
mHtml += `<span>Total: <strong>${total} lei</strong></span>`;
|
|
||||||
mobile.innerHTML = mHtml;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Quick Map Modal (uses shared openQuickMap) ───
|
// ── Quick Map Modal (uses shared openQuickMap) ───
|
||||||
|
|||||||
@@ -8,10 +8,6 @@ let ordersPage = 1;
|
|||||||
let ordersSortColumn = 'order_date';
|
let ordersSortColumn = 'order_date';
|
||||||
let ordersSortDirection = 'desc';
|
let ordersSortDirection = 'desc';
|
||||||
|
|
||||||
function fmtCost(v) {
|
|
||||||
return v > 0 ? Number(v).toFixed(2) : '–';
|
|
||||||
}
|
|
||||||
|
|
||||||
function fmtDuration(startedAt, finishedAt) {
|
function fmtDuration(startedAt, finishedAt) {
|
||||||
if (!startedAt || !finishedAt) return '-';
|
if (!startedAt || !finishedAt) return '-';
|
||||||
const diffMs = new Date(finishedAt) - new Date(startedAt);
|
const diffMs = new Date(finishedAt) - new Date(startedAt);
|
||||||
@@ -23,24 +19,13 @@ function fmtDuration(startedAt, finishedAt) {
|
|||||||
|
|
||||||
function runStatusBadge(status) {
|
function runStatusBadge(status) {
|
||||||
switch ((status || '').toLowerCase()) {
|
switch ((status || '').toLowerCase()) {
|
||||||
case 'completed': return '<span style="color:#16a34a;font-weight:600">completed</span>';
|
case 'completed': return '<span style="color:var(--success);font-weight:600">completed</span>';
|
||||||
case 'running': return '<span style="color:#2563eb;font-weight:600">running</span>';
|
case 'running': return '<span style="color:var(--info);font-weight:600">running</span>';
|
||||||
case 'failed': return '<span style="color:#dc2626;font-weight:600">failed</span>';
|
case 'failed': return '<span style="color:var(--error);font-weight:600">failed</span>';
|
||||||
default: return `<span style="font-weight:600">${esc(status)}</span>`;
|
default: return `<span style="font-weight:600">${esc(status)}</span>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function orderStatusBadge(status) {
|
|
||||||
switch ((status || '').toUpperCase()) {
|
|
||||||
case 'IMPORTED': return '<span class="badge bg-success">Importat</span>';
|
|
||||||
case 'ALREADY_IMPORTED': return '<span class="badge bg-info">Deja importat</span>';
|
|
||||||
case 'SKIPPED': return '<span class="badge bg-warning">Omis</span>';
|
|
||||||
case 'ERROR': return '<span class="badge bg-danger">Eroare</span>';
|
|
||||||
case 'DELETED_IN_ROA': return '<span class="badge bg-dark">Sters din ROA</span>';
|
|
||||||
default: return `<span class="badge bg-secondary">${esc(status)}</span>`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function logStatusText(status) {
|
function logStatusText(status) {
|
||||||
switch ((status || '').toUpperCase()) {
|
switch ((status || '').toUpperCase()) {
|
||||||
case 'IMPORTED': return 'Importat';
|
case 'IMPORTED': return 'Importat';
|
||||||
@@ -156,7 +141,11 @@ async function loadRunOrders(runId, statusFilter, page) {
|
|||||||
if (orders.length === 0) {
|
if (orders.length === 0) {
|
||||||
tbody.innerHTML = '<tr><td colspan="9" class="text-center text-muted py-3">Nicio comanda</td></tr>';
|
tbody.innerHTML = '<tr><td colspan="9" class="text-center text-muted py-3">Nicio comanda</td></tr>';
|
||||||
} else {
|
} else {
|
||||||
tbody.innerHTML = orders.map((o, i) => {
|
const problemOrders = orders.filter(o => ['ERROR', 'SKIPPED'].includes(o.status));
|
||||||
|
const okOrders = orders.filter(o => ['IMPORTED', 'ALREADY_IMPORTED'].includes(o.status));
|
||||||
|
const otherOrders = orders.filter(o => !['ERROR', 'SKIPPED', 'IMPORTED', 'ALREADY_IMPORTED'].includes(o.status));
|
||||||
|
|
||||||
|
function orderRow(o, i) {
|
||||||
const dateStr = fmtDate(o.order_date);
|
const dateStr = fmtDate(o.order_date);
|
||||||
const orderTotal = o.order_total != null ? Number(o.order_total).toFixed(2) : '-';
|
const orderTotal = o.order_total != null ? Number(o.order_total).toFixed(2) : '-';
|
||||||
return `<tr style="cursor:pointer" onclick="openOrderDetail('${esc(o.order_number)}')">
|
return `<tr style="cursor:pointer" onclick="openOrderDetail('${esc(o.order_number)}')">
|
||||||
@@ -170,7 +159,31 @@ async function loadRunOrders(runId, statusFilter, page) {
|
|||||||
<td class="text-end text-muted">${fmtCost(o.discount_total)}</td>
|
<td class="text-end text-muted">${fmtCost(o.discount_total)}</td>
|
||||||
<td class="text-end fw-bold">${orderTotal}</td>
|
<td class="text-end fw-bold">${orderTotal}</td>
|
||||||
</tr>`;
|
</tr>`;
|
||||||
}).join('');
|
}
|
||||||
|
|
||||||
|
let html = '';
|
||||||
|
// Show problem orders first (always visible)
|
||||||
|
problemOrders.forEach((o, i) => { html += orderRow(o, i); });
|
||||||
|
otherOrders.forEach((o, i) => { html += orderRow(o, problemOrders.length + i); });
|
||||||
|
|
||||||
|
// Collapsible OK orders
|
||||||
|
if (okOrders.length > 0) {
|
||||||
|
const toggleId = 'okOrdersCollapse_' + Date.now();
|
||||||
|
html += `<tr><td colspan="9" class="p-0">
|
||||||
|
<div class="log-ok-toggle" onclick="this.nextElementSibling.classList.toggle('d-none')">
|
||||||
|
▶ ${okOrders.length} comenzi importate cu succes
|
||||||
|
</div>
|
||||||
|
<div class="d-none">
|
||||||
|
<table class="table mb-0">
|
||||||
|
<tbody>
|
||||||
|
${okOrders.map((o, i) => orderRow(o, problemOrders.length + otherOrders.length + i)).join('')}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</td></tr>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody.innerHTML = html;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mobile flat rows
|
// Mobile flat rows
|
||||||
@@ -179,7 +192,11 @@ async function loadRunOrders(runId, statusFilter, page) {
|
|||||||
if (orders.length === 0) {
|
if (orders.length === 0) {
|
||||||
mobileList.innerHTML = '<div class="flat-row text-muted py-3 justify-content-center">Nicio comanda</div>';
|
mobileList.innerHTML = '<div class="flat-row text-muted py-3 justify-content-center">Nicio comanda</div>';
|
||||||
} else {
|
} else {
|
||||||
mobileList.innerHTML = orders.map(o => {
|
const problemOrders = orders.filter(o => ['ERROR', 'SKIPPED'].includes(o.status));
|
||||||
|
const okOrders = orders.filter(o => ['IMPORTED', 'ALREADY_IMPORTED'].includes(o.status));
|
||||||
|
const otherOrders = orders.filter(o => !['ERROR', 'SKIPPED', 'IMPORTED', 'ALREADY_IMPORTED'].includes(o.status));
|
||||||
|
|
||||||
|
function mobileRow(o) {
|
||||||
const d = o.order_date || '';
|
const d = o.order_date || '';
|
||||||
let dateFmt = '-';
|
let dateFmt = '-';
|
||||||
if (d.length >= 10) {
|
if (d.length >= 10) {
|
||||||
@@ -189,11 +206,26 @@ async function loadRunOrders(runId, statusFilter, page) {
|
|||||||
const totalStr = o.order_total ? Number(o.order_total).toFixed(2) : '';
|
const totalStr = o.order_total ? Number(o.order_total).toFixed(2) : '';
|
||||||
return `<div class="flat-row" onclick="openOrderDetail('${esc(o.order_number)}')" style="font-size:0.875rem">
|
return `<div class="flat-row" onclick="openOrderDetail('${esc(o.order_number)}')" style="font-size:0.875rem">
|
||||||
${statusDot(o.status)}
|
${statusDot(o.status)}
|
||||||
<span style="color:#6b7280" class="text-nowrap">${dateFmt}</span>
|
<span style="color:var(--text-muted)" class="text-nowrap">${dateFmt}</span>
|
||||||
<span class="grow truncate fw-bold">${esc(o.customer_name || '—')}</span>
|
<span class="grow truncate fw-bold">${esc(o.customer_name || '—')}</span>
|
||||||
<span class="text-nowrap">x${o.items_count || 0}${totalStr ? ' · <strong>' + totalStr + '</strong>' : ''}</span>
|
<span class="text-nowrap">x${o.items_count || 0}${totalStr ? ' · <strong>' + totalStr + '</strong>' : ''}</span>
|
||||||
</div>`;
|
</div>`;
|
||||||
}).join('');
|
}
|
||||||
|
|
||||||
|
let mobileHtml = '';
|
||||||
|
problemOrders.forEach(o => { mobileHtml += mobileRow(o); });
|
||||||
|
otherOrders.forEach(o => { mobileHtml += mobileRow(o); });
|
||||||
|
|
||||||
|
if (okOrders.length > 0) {
|
||||||
|
mobileHtml += `<div class="log-ok-toggle" onclick="this.nextElementSibling.classList.toggle('d-none')">
|
||||||
|
▶ ${okOrders.length} comenzi importate cu succes
|
||||||
|
</div>
|
||||||
|
<div class="d-none">
|
||||||
|
${okOrders.map(o => mobileRow(o)).join('')}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
mobileList.innerHTML = mobileHtml;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -296,125 +328,17 @@ async function fetchTextLog(runId) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Multi-CODMAT helper (D1) ─────────────────────
|
|
||||||
|
|
||||||
function renderCodmatCell(item) {
|
|
||||||
if (!item.codmat_details || item.codmat_details.length === 0) {
|
|
||||||
return `<code>${esc(item.codmat || '-')}</code>`;
|
|
||||||
}
|
|
||||||
if (item.codmat_details.length === 1) {
|
|
||||||
const d = item.codmat_details[0];
|
|
||||||
return `<code>${esc(d.codmat)}</code>`;
|
|
||||||
}
|
|
||||||
// Multi-CODMAT: compact list
|
|
||||||
return item.codmat_details.map(d =>
|
|
||||||
`<div class="small"><code>${esc(d.codmat)}</code> <span class="text-muted">\xd7${d.cantitate_roa}</span></div>`
|
|
||||||
).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Order Detail Modal (R9) ─────────────────────
|
// ── Order Detail Modal (R9) ─────────────────────
|
||||||
|
|
||||||
async function openOrderDetail(orderNumber) {
|
function openOrderDetail(orderNumber) {
|
||||||
document.getElementById('detailOrderNumber').textContent = '#' + orderNumber;
|
_sharedModalQuickMapFn = function(sku, productName, orderNum, itemIdx) {
|
||||||
document.getElementById('detailCustomer').textContent = '...';
|
openLogsQuickMap(sku, productName, orderNum);
|
||||||
document.getElementById('detailDate').textContent = '';
|
};
|
||||||
document.getElementById('detailStatus').innerHTML = '';
|
renderOrderDetailModal(orderNumber, {
|
||||||
document.getElementById('detailIdComanda').textContent = '-';
|
onQuickMap: function(sku, productName, orderNum, itemIdx) {
|
||||||
document.getElementById('detailIdPartener').textContent = '-';
|
openLogsQuickMap(sku, productName, orderNum);
|
||||||
document.getElementById('detailIdAdresaFact').textContent = '-';
|
|
||||||
document.getElementById('detailIdAdresaLivr').textContent = '-';
|
|
||||||
document.getElementById('detailItemsBody').innerHTML = '<tr><td colspan="6" class="text-center">Se incarca...</td></tr>';
|
|
||||||
document.getElementById('detailError').style.display = 'none';
|
|
||||||
const detailItemsTotal = document.getElementById('detailItemsTotal');
|
|
||||||
if (detailItemsTotal) detailItemsTotal.textContent = '-';
|
|
||||||
const detailOrderTotal = document.getElementById('detailOrderTotal');
|
|
||||||
if (detailOrderTotal) detailOrderTotal.textContent = '-';
|
|
||||||
const mobileContainer = document.getElementById('detailItemsMobile');
|
|
||||||
if (mobileContainer) mobileContainer.innerHTML = '';
|
|
||||||
|
|
||||||
const modalEl = document.getElementById('orderDetailModal');
|
|
||||||
const existing = bootstrap.Modal.getInstance(modalEl);
|
|
||||||
if (existing) { existing.show(); } else { new bootstrap.Modal(modalEl).show(); }
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch(`/api/sync/order/${encodeURIComponent(orderNumber)}`);
|
|
||||||
const data = await res.json();
|
|
||||||
|
|
||||||
if (data.error) {
|
|
||||||
document.getElementById('detailError').textContent = data.error;
|
|
||||||
document.getElementById('detailError').style.display = '';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const order = data.order || {};
|
|
||||||
document.getElementById('detailCustomer').textContent = order.customer_name || '-';
|
|
||||||
document.getElementById('detailDate').textContent = fmtDate(order.order_date);
|
|
||||||
document.getElementById('detailStatus').innerHTML = orderStatusBadge(order.status);
|
|
||||||
document.getElementById('detailIdComanda').textContent = order.id_comanda || '-';
|
|
||||||
document.getElementById('detailIdPartener').textContent = order.id_partener || '-';
|
|
||||||
document.getElementById('detailIdAdresaFact').textContent = order.id_adresa_facturare || '-';
|
|
||||||
document.getElementById('detailIdAdresaLivr').textContent = order.id_adresa_livrare || '-';
|
|
||||||
|
|
||||||
if (order.error_message) {
|
|
||||||
document.getElementById('detailError').textContent = order.error_message;
|
|
||||||
document.getElementById('detailError').style.display = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
const dlvEl = document.getElementById('detailDeliveryCost');
|
|
||||||
if (dlvEl) dlvEl.textContent = order.delivery_cost > 0 ? Number(order.delivery_cost).toFixed(2) + ' lei' : '–';
|
|
||||||
|
|
||||||
const dscEl = document.getElementById('detailDiscount');
|
|
||||||
if (dscEl) dscEl.textContent = order.discount_total > 0 ? '–' + Number(order.discount_total).toFixed(2) + ' lei' : '–';
|
|
||||||
|
|
||||||
const items = data.items || [];
|
|
||||||
if (items.length === 0) {
|
|
||||||
document.getElementById('detailItemsBody').innerHTML = '<tr><td colspan="6" class="text-center text-muted">Niciun articol</td></tr>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update totals row
|
|
||||||
const itemsTotal = items.reduce((sum, item) => sum + (Number(item.price || 0) * Number(item.quantity || 0)), 0);
|
|
||||||
document.getElementById('detailItemsTotal').textContent = itemsTotal.toFixed(2) + ' lei';
|
|
||||||
document.getElementById('detailOrderTotal').textContent = order.order_total != null ? Number(order.order_total).toFixed(2) + ' lei' : '-';
|
|
||||||
|
|
||||||
// Mobile article flat list
|
|
||||||
const mobileContainer = document.getElementById('detailItemsMobile');
|
|
||||||
if (mobileContainer) {
|
|
||||||
mobileContainer.innerHTML = '<div class="detail-item-flat">' + items.map((item, idx) => {
|
|
||||||
const codmatList = item.codmat_details?.length
|
|
||||||
? item.codmat_details.map(d => `<span class="dif-codmat-link" onclick="openLogsQuickMap('${esc(item.sku)}','${esc(item.product_name||'')}','${esc(orderNumber)}')">${esc(d.codmat)}</span>`).join(' ')
|
|
||||||
: `<span class="dif-codmat-link" onclick="openLogsQuickMap('${esc(item.sku)}','${esc(item.product_name||'')}','${esc(orderNumber)}')">${esc(item.codmat || '–')}</span>`;
|
|
||||||
const valoare = (Number(item.price || 0) * Number(item.quantity || 0)).toFixed(2);
|
|
||||||
return `<div class="dif-item">
|
|
||||||
<div class="dif-row">
|
|
||||||
<span class="dif-sku">${esc(item.sku)}</span>
|
|
||||||
${codmatList}
|
|
||||||
</div>
|
|
||||||
<div class="dif-row">
|
|
||||||
<span class="dif-name">${esc(item.product_name || '–')}</span>
|
|
||||||
<span class="dif-qty">x${item.quantity || 0}</span>
|
|
||||||
<span class="dif-val">${valoare} lei</span>
|
|
||||||
</div>
|
|
||||||
</div>`;
|
|
||||||
}).join('') + '</div>';
|
|
||||||
}
|
|
||||||
|
|
||||||
document.getElementById('detailItemsBody').innerHTML = items.map(item => {
|
|
||||||
const valoare = (Number(item.price || 0) * Number(item.quantity || 0)).toFixed(2);
|
|
||||||
const codmatCell = `<span class="codmat-link" onclick="openLogsQuickMap('${esc(item.sku)}', '${esc(item.product_name || '')}', '${esc(orderNumber)}')" title="Click pentru mapare">${renderCodmatCell(item)}</span>`;
|
|
||||||
return `<tr>
|
|
||||||
<td><code>${esc(item.sku)}</code></td>
|
|
||||||
<td>${esc(item.product_name || '-')}</td>
|
|
||||||
<td>${codmatCell}</td>
|
|
||||||
<td>${item.quantity || 0}</td>
|
|
||||||
<td>${item.price != null ? Number(item.price).toFixed(2) : '-'}</td>
|
|
||||||
<td class="text-end">${valoare}</td>
|
|
||||||
</tr>`;
|
|
||||||
}).join('');
|
|
||||||
} catch (err) {
|
|
||||||
document.getElementById('detailError').textContent = err.message;
|
|
||||||
document.getElementById('detailError').style.display = '';
|
|
||||||
}
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Quick Map Modal (uses shared openQuickMap) ───
|
// ── Quick Map Modal (uses shared openQuickMap) ───
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ function renderTable(mappings, showDeleted) {
|
|||||||
? ` <span class="text-muted small">Kit · ${skuCodmatCount[m.sku]}</span><span class="kit-price-loading" data-sku="${esc(m.sku)}" style="display:none"><span class="spinner-border spinner-border-sm ms-1" style="width:0.8rem;height:0.8rem"></span></span>`
|
? ` <span class="text-muted small">Kit · ${skuCodmatCount[m.sku]}</span><span class="kit-price-loading" data-sku="${esc(m.sku)}" style="display:none"><span class="spinner-border spinner-border-sm ms-1" style="width:0.8rem;height:0.8rem"></span></span>`
|
||||||
: '';
|
: '';
|
||||||
const inactiveStyle = !m.activ && !m.sters ? 'opacity:0.6;' : '';
|
const inactiveStyle = !m.activ && !m.sters ? 'opacity:0.6;' : '';
|
||||||
html += `<div class="flat-row" style="background:#f8fafc;font-weight:600;border-top:1px solid #e5e7eb;${inactiveStyle}">
|
html += `<div class="flat-row" style="background:var(--surface-raised);font-weight:600;border-top:1px solid var(--border);${inactiveStyle}">
|
||||||
<span class="${m.activ ? 'dot dot-green' : 'dot dot-yellow'}" style="cursor:${m.sters ? 'default' : 'pointer'}"
|
<span class="${m.activ ? 'dot dot-green' : 'dot dot-yellow'}" style="cursor:${m.sters ? 'default' : 'pointer'}"
|
||||||
${m.sters ? '' : `onclick="event.stopPropagation();toggleActive('${esc(m.sku)}', '${esc(m.codmat)}', ${m.activ})"`}
|
${m.sters ? '' : `onclick="event.stopPropagation();toggleActive('${esc(m.sku)}', '${esc(m.codmat)}', ${m.activ})"`}
|
||||||
title="${m.activ ? 'Activ' : 'Inactiv'}"></span>
|
title="${m.activ ? 'Activ' : 'Inactiv'}"></span>
|
||||||
@@ -135,7 +135,7 @@ function renderTable(mappings, showDeleted) {
|
|||||||
// After last CODMAT of a kit, add total row
|
// After last CODMAT of a kit, add total row
|
||||||
const isLastOfKit = isKitRow && (i === mappings.length - 1 || mappings[i + 1].sku !== m.sku);
|
const isLastOfKit = isKitRow && (i === mappings.length - 1 || mappings[i + 1].sku !== m.sku);
|
||||||
if (isLastOfKit) {
|
if (isLastOfKit) {
|
||||||
html += `<div class="flat-row kit-total-slot text-muted small" data-sku="${esc(m.sku)}" style="padding-left:1.5rem;display:none;border-top:1px dashed #e5e7eb"></div>`;
|
html += `<div class="flat-row kit-total-slot text-muted small" data-sku="${esc(m.sku)}" style="padding-left:1.5rem;display:none;border-top:1px dashed var(--border)"></div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
prevSku = m.sku;
|
prevSku = m.sku;
|
||||||
@@ -176,7 +176,7 @@ async function loadKitPrices(sku, container) {
|
|||||||
if (spinner) spinner.style.display = '';
|
if (spinner) spinner.style.display = '';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/mappings/${encodeURIComponent(sku)}/prices`);
|
const res = await fetch(`/api/mappings/prices?sku=${encodeURIComponent(sku)}`);
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (data.error) {
|
if (data.error) {
|
||||||
if (spinner) spinner.innerHTML = `<small class="text-danger">${esc(data.error)}</small>`;
|
if (spinner) spinner.innerHTML = `<small class="text-danger">${esc(data.error)}</small>`;
|
||||||
@@ -523,7 +523,7 @@ function showInlineAddRow() {
|
|||||||
const row = document.createElement('div');
|
const row = document.createElement('div');
|
||||||
row.id = 'inlineAddRow';
|
row.id = 'inlineAddRow';
|
||||||
row.className = 'flat-row';
|
row.className = 'flat-row';
|
||||||
row.style.background = '#eff6ff';
|
row.style.background = 'var(--info-light)';
|
||||||
row.style.gap = '0.5rem';
|
row.style.gap = '0.5rem';
|
||||||
row.innerHTML = `
|
row.innerHTML = `
|
||||||
<input type="text" class="form-control form-control-sm" id="inlineSku" placeholder="SKU" style="width:140px">
|
<input type="text" class="form-control form-control-sm" id="inlineSku" placeholder="SKU" style="width:140px">
|
||||||
|
|||||||
@@ -15,6 +15,15 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Dark mode toggle
|
||||||
|
const darkToggle = document.getElementById('settDarkMode');
|
||||||
|
if (darkToggle) {
|
||||||
|
darkToggle.checked = document.documentElement.getAttribute('data-theme') === 'dark';
|
||||||
|
darkToggle.addEventListener('change', () => {
|
||||||
|
if (typeof toggleDarkMode === 'function') toggleDarkMode();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Catalog sync toggle
|
// Catalog sync toggle
|
||||||
const catChk = document.getElementById('settCatalogSyncEnabled');
|
const catChk = document.getElementById('settCatalogSyncEnabled');
|
||||||
if (catChk) catChk.addEventListener('change', () => {
|
if (catChk) catChk.addEventListener('change', () => {
|
||||||
@@ -191,14 +200,14 @@ async function saveSettings() {
|
|||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
const resultEl = document.getElementById('settSaveResult');
|
const resultEl = document.getElementById('settSaveResult');
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
if (resultEl) { resultEl.textContent = 'Salvat!'; resultEl.style.color = '#16a34a'; }
|
if (resultEl) { resultEl.textContent = 'Salvat!'; resultEl.style.color = 'var(--success)'; }
|
||||||
setTimeout(() => { if (resultEl) resultEl.textContent = ''; }, 3000);
|
setTimeout(() => { if (resultEl) resultEl.textContent = ''; }, 3000);
|
||||||
} else {
|
} else {
|
||||||
if (resultEl) { resultEl.textContent = 'Eroare: ' + JSON.stringify(data); resultEl.style.color = '#dc2626'; }
|
if (resultEl) { resultEl.textContent = 'Eroare: ' + JSON.stringify(data); resultEl.style.color = 'var(--error)'; }
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const resultEl = document.getElementById('settSaveResult');
|
const resultEl = document.getElementById('settSaveResult');
|
||||||
if (resultEl) { resultEl.textContent = 'Eroare: ' + err.message; resultEl.style.color = '#dc2626'; }
|
if (resultEl) { resultEl.textContent = 'Eroare: ' + err.message; resultEl.style.color = 'var(--error)'; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -194,7 +194,7 @@ function renderMobileSegmented(containerId, pills, onSelect) {
|
|||||||
const btnStyle = 'font-size:0.75rem;height:32px;white-space:nowrap;display:inline-flex;align-items:center;justify-content:center;gap:0.25rem;flex:1;padding:0 0.25rem';
|
const btnStyle = 'font-size:0.75rem;height:32px;white-space:nowrap;display:inline-flex;align-items:center;justify-content:center;gap:0.25rem;flex:1;padding:0 0.25rem';
|
||||||
|
|
||||||
container.innerHTML = `<div class="btn-group btn-group-sm w-100">${pills.map(p => {
|
container.innerHTML = `<div class="btn-group btn-group-sm w-100">${pills.map(p => {
|
||||||
const cls = p.active ? 'btn btn-primary' : 'btn btn-outline-secondary';
|
const cls = p.active ? 'btn seg-active' : 'btn btn-outline-secondary';
|
||||||
const countColor = (!p.active && p.colorClass) ? ` class="${p.colorClass}"` : '';
|
const countColor = (!p.active && p.colorClass) ? ` class="${p.colorClass}"` : '';
|
||||||
return `<button type="button" class="${cls}" style="${btnStyle}" data-seg-value="${esc(p.value)}">${esc(p.label)} <b${countColor}>${p.count}</b></button>`;
|
return `<button type="button" class="${cls}" style="${btnStyle}" data-seg-value="${esc(p.value)}">${esc(p.label)} <b${countColor}>${p.count}</b></button>`;
|
||||||
}).join('')}</div>`;
|
}).join('')}</div>`;
|
||||||
@@ -344,6 +344,40 @@ async function saveQuickMapping() {
|
|||||||
if (data.success) {
|
if (data.success) {
|
||||||
bootstrap.Modal.getInstance(document.getElementById('quickMapModal')).hide();
|
bootstrap.Modal.getInstance(document.getElementById('quickMapModal')).hide();
|
||||||
if (_qmOnSave) _qmOnSave(sku, mappings);
|
if (_qmOnSave) _qmOnSave(sku, mappings);
|
||||||
|
// Check for SKIPPED orders that can now be imported
|
||||||
|
try {
|
||||||
|
const pendingRes = await fetch(`/api/orders/by-sku/${encodeURIComponent(sku)}/pending`);
|
||||||
|
const pendingData = await pendingRes.json();
|
||||||
|
if (pendingData.count > 0) {
|
||||||
|
const banner = document.createElement('div');
|
||||||
|
banner.className = 'alert alert-info d-flex align-items-center gap-2 mt-2';
|
||||||
|
banner.style.cssText = 'position:fixed;bottom:80px;left:50%;transform:translateX(-50%);z-index:1060;min-width:300px;max-width:500px;box-shadow:var(--card-shadow)';
|
||||||
|
banner.innerHTML = `<i class="bi bi-arrow-clockwise"></i> <span>${pendingData.count} comenzi SKIPPED pot fi importate acum</span> <button class="btn btn-sm btn-primary ms-auto" id="batchRetryBtn">Importa</button> <button class="btn btn-sm btn-outline-secondary" onclick="this.parentElement.remove()">✕</button>`;
|
||||||
|
document.body.appendChild(banner);
|
||||||
|
|
||||||
|
document.getElementById('batchRetryBtn').onclick = async function() {
|
||||||
|
this.disabled = true;
|
||||||
|
this.innerHTML = '<span class="spinner-border spinner-border-sm"></span>';
|
||||||
|
try {
|
||||||
|
const retryRes = await fetch('/api/orders/batch-retry', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({order_numbers: pendingData.order_numbers})
|
||||||
|
});
|
||||||
|
const retryData = await retryRes.json();
|
||||||
|
banner.className = retryData.errors > 0 ? 'alert alert-warning d-flex align-items-center gap-2 mt-2' : 'alert alert-success d-flex align-items-center gap-2 mt-2';
|
||||||
|
banner.style.cssText = 'position:fixed;bottom:80px;left:50%;transform:translateX(-50%);z-index:1060;min-width:300px;max-width:500px;box-shadow:var(--card-shadow)';
|
||||||
|
banner.innerHTML = `<i class="bi bi-check-circle"></i> ${esc(retryData.message)} <button class="btn btn-sm btn-outline-secondary ms-auto" onclick="this.parentElement.remove()">✕</button>`;
|
||||||
|
setTimeout(() => banner.remove(), 5000);
|
||||||
|
if (typeof loadDashOrders === 'function') loadDashOrders();
|
||||||
|
} catch(e) {
|
||||||
|
banner.innerHTML = `Eroare: ${esc(e.message)} <button class="btn btn-sm btn-outline-secondary ms-auto" onclick="this.parentElement.remove()">✕</button>`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
setTimeout(() => { if (banner.parentElement) banner.remove(); }, 15000);
|
||||||
|
}
|
||||||
|
} catch(e) { /* ignore */ }
|
||||||
} else {
|
} else {
|
||||||
alert('Eroare: ' + (data.error || 'Unknown'));
|
alert('Eroare: ' + (data.error || 'Unknown'));
|
||||||
}
|
}
|
||||||
@@ -352,6 +386,415 @@ async function saveQuickMapping() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Shared helpers (moved from dashboard.js/logs.js) ─
|
||||||
|
|
||||||
|
function fmtCost(v) {
|
||||||
|
return v > 0 ? Number(v).toFixed(2) : '–';
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtNum(v) {
|
||||||
|
return Number(v).toLocaleString('ro-RO', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||||
|
}
|
||||||
|
|
||||||
|
function orderStatusBadge(status) {
|
||||||
|
switch ((status || '').toUpperCase()) {
|
||||||
|
case 'IMPORTED': return '<span class="badge bg-success">Importat</span>';
|
||||||
|
case 'ALREADY_IMPORTED': return '<span class="badge bg-info">Deja importat</span>';
|
||||||
|
case 'SKIPPED': return '<span class="badge bg-warning">Omis</span>';
|
||||||
|
case 'ERROR': return '<span class="badge bg-danger">Eroare</span>';
|
||||||
|
case 'CANCELLED': return '<span class="badge bg-secondary">Anulat</span>';
|
||||||
|
case 'DELETED_IN_ROA': return '<span class="badge bg-dark">Sters din ROA</span>';
|
||||||
|
default: return `<span class="badge bg-secondary">${esc(status)}</span>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCodmatCell(item) {
|
||||||
|
if (!item.codmat_details || item.codmat_details.length === 0) {
|
||||||
|
return `<code>${esc(item.codmat || '-')}</code>`;
|
||||||
|
}
|
||||||
|
if (item.codmat_details.length === 1) {
|
||||||
|
const d = item.codmat_details[0];
|
||||||
|
if (d.direct) {
|
||||||
|
return `<code>${esc(d.codmat)}</code> <span class="badge bg-secondary" style="font-size:0.6rem;vertical-align:middle">direct</span>`;
|
||||||
|
}
|
||||||
|
return `<code>${esc(d.codmat)}</code>`;
|
||||||
|
}
|
||||||
|
return item.codmat_details.map(d =>
|
||||||
|
`<div class="small"><code>${esc(d.codmat)}</code> <span class="text-muted">\xd7${d.cantitate_roa}</span></div>`
|
||||||
|
).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeDiscountSplit(items, order) {
|
||||||
|
if (order.discount_split && typeof order.discount_split === 'object')
|
||||||
|
return order.discount_split;
|
||||||
|
|
||||||
|
const byRate = {};
|
||||||
|
items.forEach(item => {
|
||||||
|
const rate = item.vat != null ? Number(item.vat) : null;
|
||||||
|
if (rate === null) return;
|
||||||
|
if (!byRate[rate]) byRate[rate] = 0;
|
||||||
|
byRate[rate] += Number(item.price || 0) * Number(item.quantity || 0);
|
||||||
|
});
|
||||||
|
const rates = Object.keys(byRate).sort((a, b) => Number(a) - Number(b));
|
||||||
|
if (rates.length === 0) return null;
|
||||||
|
|
||||||
|
const grandTotal = rates.reduce((s, r) => s + byRate[r], 0);
|
||||||
|
if (grandTotal <= 0) return null;
|
||||||
|
|
||||||
|
const split = {};
|
||||||
|
let remaining = order.discount_total;
|
||||||
|
rates.forEach((rate, i) => {
|
||||||
|
if (i === rates.length - 1) {
|
||||||
|
split[rate] = Math.round(remaining * 100) / 100;
|
||||||
|
} else {
|
||||||
|
const amt = Math.round(order.discount_total * byRate[rate] / grandTotal * 100) / 100;
|
||||||
|
split[rate] = amt;
|
||||||
|
remaining -= amt;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return split;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _renderReceipt(items, order) {
|
||||||
|
const desktop = document.getElementById('detailReceipt');
|
||||||
|
const mobile = document.getElementById('detailReceiptMobile');
|
||||||
|
if (!desktop && !mobile) return;
|
||||||
|
if (!items.length) {
|
||||||
|
if (desktop) desktop.innerHTML = '';
|
||||||
|
if (mobile) mobile.innerHTML = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const articole = items.reduce((s, i) => s + Number(i.price || 0) * Number(i.quantity || 0), 0);
|
||||||
|
const discount = Number(order.discount_total || 0);
|
||||||
|
const transport = Number(order.delivery_cost || 0);
|
||||||
|
const total = order.order_total != null ? fmtNum(order.order_total) : '-';
|
||||||
|
|
||||||
|
let dHtml = `<span class="text-muted">Articole: <strong class="text-body">${fmtNum(articole)}</strong></span>`;
|
||||||
|
if (discount > 0) dHtml += `<span class="text-muted">Discount: <strong class="text-danger">\u2013${fmtNum(discount)}</strong></span>`;
|
||||||
|
if (transport > 0) dHtml += `<span class="text-muted">Transport: <strong class="text-body">${fmtNum(transport)}</strong></span>`;
|
||||||
|
dHtml += `<span>Total: <strong>${total} lei</strong></span>`;
|
||||||
|
if (desktop) desktop.innerHTML = dHtml;
|
||||||
|
|
||||||
|
let mHtml = `<span class="text-muted">Art: <strong class="text-body">${fmtNum(articole)}</strong></span>`;
|
||||||
|
if (discount > 0) mHtml += `<span class="text-muted">Disc: <strong class="text-danger">\u2013${fmtNum(discount)}</strong></span>`;
|
||||||
|
if (transport > 0) mHtml += `<span class="text-muted">Transp: <strong class="text-body">${fmtNum(transport)}</strong></span>`;
|
||||||
|
mHtml += `<span>Total: <strong>${total} lei</strong></span>`;
|
||||||
|
if (mobile) mobile.innerHTML = mHtml;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Order Detail Modal (shared) ──────────────────
|
||||||
|
/**
|
||||||
|
* Render and show the order detail modal.
|
||||||
|
* @param {string} orderNumber
|
||||||
|
* @param {object} opts
|
||||||
|
* @param {function} opts.onQuickMap - (sku, productName, orderNumber, itemIdx) => void
|
||||||
|
* @param {function} [opts.onAfterRender] - (order, items) => void
|
||||||
|
*/
|
||||||
|
async function renderOrderDetailModal(orderNumber, opts) {
|
||||||
|
opts = opts || {};
|
||||||
|
|
||||||
|
// Reset modal state
|
||||||
|
document.getElementById('detailOrderNumber').textContent = '#' + orderNumber;
|
||||||
|
document.getElementById('detailCustomer').textContent = '...';
|
||||||
|
document.getElementById('detailDate').textContent = '';
|
||||||
|
document.getElementById('detailStatus').innerHTML = '';
|
||||||
|
document.getElementById('detailIdComanda').textContent = '-';
|
||||||
|
document.getElementById('detailIdPartener').textContent = '-';
|
||||||
|
document.getElementById('detailIdAdresaFact').textContent = '-';
|
||||||
|
document.getElementById('detailIdAdresaLivr').textContent = '-';
|
||||||
|
document.getElementById('detailItemsBody').innerHTML = '<tr><td colspan="9" class="text-center">Se incarca...</td></tr>';
|
||||||
|
document.getElementById('detailError').style.display = 'none';
|
||||||
|
const retryBtn = document.getElementById('detailRetryBtn');
|
||||||
|
if (retryBtn) { retryBtn.style.display = 'none'; retryBtn.disabled = false; retryBtn.innerHTML = '<i class="bi bi-arrow-clockwise"></i> Reimporta'; retryBtn.className = 'btn btn-sm btn-outline-primary'; }
|
||||||
|
const receiptEl = document.getElementById('detailReceipt');
|
||||||
|
if (receiptEl) receiptEl.innerHTML = '';
|
||||||
|
const receiptMEl = document.getElementById('detailReceiptMobile');
|
||||||
|
if (receiptMEl) receiptMEl.innerHTML = '';
|
||||||
|
const invInfo = document.getElementById('detailInvoiceInfo');
|
||||||
|
if (invInfo) invInfo.style.display = 'none';
|
||||||
|
const mobileContainer = document.getElementById('detailItemsMobile');
|
||||||
|
if (mobileContainer) mobileContainer.innerHTML = '';
|
||||||
|
const priceCheckEl = document.getElementById('detailPriceCheck');
|
||||||
|
if (priceCheckEl) priceCheckEl.innerHTML = '';
|
||||||
|
const reconEl = document.getElementById('detailInvoiceRecon');
|
||||||
|
if (reconEl) { reconEl.innerHTML = ''; reconEl.style.display = 'none'; }
|
||||||
|
|
||||||
|
const modalEl = document.getElementById('orderDetailModal');
|
||||||
|
const existing = bootstrap.Modal.getInstance(modalEl);
|
||||||
|
if (existing) { existing.show(); } else { new bootstrap.Modal(modalEl).show(); }
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/sync/order/${encodeURIComponent(orderNumber)}`);
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (data.error) {
|
||||||
|
document.getElementById('detailError').textContent = data.error;
|
||||||
|
document.getElementById('detailError').style.display = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const order = data.order || {};
|
||||||
|
document.getElementById('detailCustomer').textContent = order.customer_name || '-';
|
||||||
|
document.getElementById('detailDate').textContent = fmtDate(order.order_date);
|
||||||
|
document.getElementById('detailStatus').innerHTML = orderStatusBadge(order.status);
|
||||||
|
|
||||||
|
// Price check badge
|
||||||
|
const priceCheckEl = document.getElementById('detailPriceCheck');
|
||||||
|
if (priceCheckEl) {
|
||||||
|
const pc = order.price_check;
|
||||||
|
if (!pc || pc.oracle_available === false) {
|
||||||
|
priceCheckEl.innerHTML = '<span class="badge" style="background:var(--cancelled-light);color:var(--text-muted)">Preturi ROA indisponibile</span>';
|
||||||
|
} else if (pc.mismatches === 0) {
|
||||||
|
priceCheckEl.innerHTML = '<span class="badge" style="background:var(--success-light);color:var(--success-text)">✓ Preturi OK</span>';
|
||||||
|
} else {
|
||||||
|
priceCheckEl.innerHTML = `<span class="badge" style="background:var(--error-light);color:var(--error-text)">${pc.mismatches} diferente de pret</span>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('detailIdComanda').textContent = order.id_comanda || '-';
|
||||||
|
document.getElementById('detailIdPartener').textContent = order.id_partener || '-';
|
||||||
|
document.getElementById('detailIdAdresaFact').textContent = order.id_adresa_facturare || '-';
|
||||||
|
document.getElementById('detailIdAdresaLivr').textContent = order.id_adresa_livrare || '-';
|
||||||
|
|
||||||
|
// Invoice info
|
||||||
|
const inv = order.invoice;
|
||||||
|
if (inv && inv.facturat) {
|
||||||
|
const serie = inv.serie_act || '';
|
||||||
|
const numar = inv.numar_act || '';
|
||||||
|
document.getElementById('detailInvoiceNumber').textContent = serie ? `${serie} ${numar}` : numar;
|
||||||
|
document.getElementById('detailInvoiceDate').textContent = inv.data_act ? fmtDate(inv.data_act) : '-';
|
||||||
|
if (invInfo) invInfo.style.display = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invoice reconciliation
|
||||||
|
const reconEl = document.getElementById('detailInvoiceRecon');
|
||||||
|
if (reconEl && inv && inv.reconciliation) {
|
||||||
|
const r = inv.reconciliation;
|
||||||
|
if (r.match) {
|
||||||
|
reconEl.innerHTML = `<span class="badge" style="background:var(--success-light);color:var(--success-text)">✓ Total factura OK (${fmtNum(r.invoice_total)} lei)</span>`;
|
||||||
|
} else {
|
||||||
|
const sign = r.difference > 0 ? '+' : '';
|
||||||
|
reconEl.innerHTML = `<span class="badge" style="background:var(--error-light);color:var(--error-text)">Diferenta: ${sign}${fmtNum(r.difference)} lei</span>
|
||||||
|
<small class="text-muted ms-2">Factura: ${fmtNum(r.invoice_total)} | Comanda: ${fmtNum(r.order_total)}</small>`;
|
||||||
|
}
|
||||||
|
reconEl.style.display = '';
|
||||||
|
} else if (reconEl) {
|
||||||
|
reconEl.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (order.error_message) {
|
||||||
|
document.getElementById('detailError').textContent = order.error_message;
|
||||||
|
document.getElementById('detailError').style.display = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = data.items || [];
|
||||||
|
if (items.length === 0) {
|
||||||
|
document.getElementById('detailItemsBody').innerHTML = '<tr><td colspan="9" class="text-center text-muted">Niciun articol</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store items for quick map pre-population
|
||||||
|
window._detailItems = items;
|
||||||
|
|
||||||
|
const qmFn = opts.onQuickMap ? opts.onQuickMap.name || '_sharedQuickMap' : null;
|
||||||
|
|
||||||
|
// Mobile article flat list
|
||||||
|
if (mobileContainer) {
|
||||||
|
let mobileHtml = items.map((item, idx) => {
|
||||||
|
const codmatText = item.codmat_details?.length
|
||||||
|
? item.codmat_details.map(d => `<code>${esc(d.codmat)}</code>${d.direct ? ' <span class="badge bg-secondary" style="font-size:0.55rem">direct</span>' : ''}`).join(' ')
|
||||||
|
: `<code>${esc(item.codmat || '–')}</code>`;
|
||||||
|
const valoare = (Number(item.price || 0) * Number(item.quantity || 0));
|
||||||
|
const clickAttr = opts.onQuickMap ? `onclick="_sharedModalQuickMap('${esc(item.sku)}','${esc(item.product_name||'')}','${esc(orderNumber)}',${idx})"` : '';
|
||||||
|
const priceInfo = { pret_roa: item.pret_roa, match: item.price_match };
|
||||||
|
const priceMismatchHtml = priceInfo.match === false
|
||||||
|
? `<div class="text-danger" style="font-size:0.7rem">ROA: ${fmtNum(priceInfo.pret_roa)} lei</div>`
|
||||||
|
: '';
|
||||||
|
return `<div class="dif-item">
|
||||||
|
<div class="dif-row">
|
||||||
|
<span class="dif-sku${opts.onQuickMap ? ' dif-codmat-link' : ''}" ${clickAttr}>${esc(item.sku)}</span>
|
||||||
|
${codmatText}
|
||||||
|
</div>
|
||||||
|
<div class="dif-row">
|
||||||
|
<span class="dif-name">${esc(item.product_name || '–')}</span>
|
||||||
|
<span class="dif-qty">x${item.quantity || 0}</span>
|
||||||
|
<span class="dif-val">${fmtNum(valoare)} lei</span>
|
||||||
|
<span class="dif-vat text-muted" style="font-size:0.75rem">TVA ${item.vat != null ? Number(item.vat) : '?'}</span>
|
||||||
|
</div>
|
||||||
|
${priceMismatchHtml}
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
// Transport row (mobile)
|
||||||
|
if (order.delivery_cost > 0) {
|
||||||
|
const tVat = order.transport_vat || '21';
|
||||||
|
mobileHtml += `<div class="dif-item" style="opacity:0.7">
|
||||||
|
<div class="dif-row">
|
||||||
|
<span class="dif-name text-muted">Transport</span>
|
||||||
|
<span class="dif-qty">x1</span>
|
||||||
|
<span class="dif-val">${fmtNum(order.delivery_cost)} lei</span>
|
||||||
|
<span class="dif-vat text-muted" style="font-size:0.75rem">TVA ${tVat}</span>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Discount rows (mobile)
|
||||||
|
if (order.discount_total > 0) {
|
||||||
|
const discSplit = computeDiscountSplit(items, order);
|
||||||
|
if (discSplit) {
|
||||||
|
Object.entries(discSplit)
|
||||||
|
.sort(([a], [b]) => Number(a) - Number(b))
|
||||||
|
.forEach(([rate, amt]) => {
|
||||||
|
if (amt > 0) mobileHtml += `<div class="dif-item" style="opacity:0.7">
|
||||||
|
<div class="dif-row">
|
||||||
|
<span class="dif-name text-muted">Discount</span>
|
||||||
|
<span class="dif-qty">x\u20131</span>
|
||||||
|
<span class="dif-val">${fmtNum(amt)} lei</span>
|
||||||
|
<span class="dif-vat text-muted" style="font-size:0.75rem">TVA ${Number(rate)}</span>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
mobileHtml += `<div class="dif-item" style="opacity:0.7">
|
||||||
|
<div class="dif-row">
|
||||||
|
<span class="dif-name text-muted">Discount</span>
|
||||||
|
<span class="dif-qty">x\u20131</span>
|
||||||
|
<span class="dif-val">${fmtNum(order.discount_total)} lei</span>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mobileContainer.innerHTML = '<div class="detail-item-flat">' + mobileHtml + '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Desktop items table
|
||||||
|
const clickAttrFn = (item, idx) => opts.onQuickMap
|
||||||
|
? `onclick="_sharedModalQuickMap('${esc(item.sku)}', '${esc(item.product_name || '')}', '${esc(orderNumber)}', ${idx})" title="Click pentru mapare"`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
let tableHtml = items.map((item, idx) => {
|
||||||
|
const valoare = Number(item.price || 0) * Number(item.quantity || 0);
|
||||||
|
const priceInfo = { pret_roa: item.pret_roa, match: item.price_match };
|
||||||
|
const pretRoaHtml = priceInfo.pret_roa != null ? fmtNum(priceInfo.pret_roa) : '–';
|
||||||
|
let matchDot, rowStyle;
|
||||||
|
if (priceInfo.pret_roa == null && priceInfo.match == null) {
|
||||||
|
matchDot = '<span class="dot dot-gray"></span>';
|
||||||
|
rowStyle = '';
|
||||||
|
} else if (priceInfo.match === false) {
|
||||||
|
matchDot = '<span class="dot dot-red"></span>';
|
||||||
|
rowStyle = ' style="background:var(--error-light)"';
|
||||||
|
} else {
|
||||||
|
matchDot = '<span class="dot dot-green"></span>';
|
||||||
|
rowStyle = '';
|
||||||
|
}
|
||||||
|
return `<tr${rowStyle}>
|
||||||
|
<td><code class="${opts.onQuickMap ? 'codmat-link' : ''}" ${clickAttrFn(item, idx)}>${esc(item.sku)}</code></td>
|
||||||
|
<td>${esc(item.product_name || '-')}</td>
|
||||||
|
<td>${renderCodmatCell(item)}</td>
|
||||||
|
<td class="text-end">${item.quantity || 0}</td>
|
||||||
|
<td class="text-end font-data">${item.price != null ? fmtNum(item.price) : '-'}</td>
|
||||||
|
<td class="text-end font-data">${pretRoaHtml}</td>
|
||||||
|
<td class="text-end">${item.vat != null ? Number(item.vat) : '-'}</td>
|
||||||
|
<td class="text-end font-data">${fmtNum(valoare)}</td>
|
||||||
|
<td class="text-center">${matchDot}</td>
|
||||||
|
</tr>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
// Transport row
|
||||||
|
if (order.delivery_cost > 0) {
|
||||||
|
const tVat = order.transport_vat || '21';
|
||||||
|
const tCodmat = order.transport_codmat || '';
|
||||||
|
tableHtml += `<tr class="table-light">
|
||||||
|
<td></td><td class="text-muted">Transport</td>
|
||||||
|
<td>${tCodmat ? '<code>' + esc(tCodmat) + '</code>' : ''}</td>
|
||||||
|
<td class="text-end">1</td><td class="text-end font-data">${fmtNum(order.delivery_cost)}</td>
|
||||||
|
<td></td>
|
||||||
|
<td class="text-end">${tVat}</td><td class="text-end font-data">${fmtNum(order.delivery_cost)}</td>
|
||||||
|
<td></td>
|
||||||
|
</tr>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Discount rows (split by VAT rate)
|
||||||
|
if (order.discount_total > 0) {
|
||||||
|
const dCodmat = order.discount_codmat || '';
|
||||||
|
const discSplit = computeDiscountSplit(items, order);
|
||||||
|
if (discSplit) {
|
||||||
|
Object.entries(discSplit)
|
||||||
|
.sort(([a], [b]) => Number(a) - Number(b))
|
||||||
|
.forEach(([rate, amt]) => {
|
||||||
|
if (amt > 0) tableHtml += `<tr class="table-light">
|
||||||
|
<td></td><td class="text-muted">Discount</td>
|
||||||
|
<td>${dCodmat ? '<code>' + esc(dCodmat) + '</code>' : ''}</td>
|
||||||
|
<td class="text-end">\u20131</td><td class="text-end font-data">${fmtNum(amt)}</td>
|
||||||
|
<td></td>
|
||||||
|
<td class="text-end">${Number(rate)}</td><td class="text-end font-data">\u2013${fmtNum(amt)}</td>
|
||||||
|
<td></td>
|
||||||
|
</tr>`;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
tableHtml += `<tr class="table-light">
|
||||||
|
<td></td><td class="text-muted">Discount</td>
|
||||||
|
<td>${dCodmat ? '<code>' + esc(dCodmat) + '</code>' : ''}</td>
|
||||||
|
<td class="text-end">\u20131</td><td class="text-end font-data">${fmtNum(order.discount_total)}</td>
|
||||||
|
<td></td>
|
||||||
|
<td class="text-end">-</td><td class="text-end font-data">\u2013${fmtNum(order.discount_total)}</td>
|
||||||
|
<td></td>
|
||||||
|
</tr>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('detailItemsBody').innerHTML = tableHtml;
|
||||||
|
_renderReceipt(items, order);
|
||||||
|
|
||||||
|
// Retry button (only for ERROR/SKIPPED orders)
|
||||||
|
const retryBtn = document.getElementById('detailRetryBtn');
|
||||||
|
if (retryBtn) {
|
||||||
|
const canRetry = ['ERROR', 'SKIPPED'].includes((order.status || '').toUpperCase());
|
||||||
|
retryBtn.style.display = canRetry ? '' : 'none';
|
||||||
|
if (canRetry) {
|
||||||
|
retryBtn.onclick = async () => {
|
||||||
|
retryBtn.disabled = true;
|
||||||
|
retryBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span> Reimportare...';
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/orders/${encodeURIComponent(orderNumber)}/retry`, { method: 'POST' });
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.success) {
|
||||||
|
retryBtn.innerHTML = '<i class="bi bi-check-circle"></i> ' + (data.message || 'Reimportat');
|
||||||
|
retryBtn.className = 'btn btn-sm btn-success';
|
||||||
|
// Refresh modal after short delay
|
||||||
|
setTimeout(() => renderOrderDetailModal(orderNumber, opts), 1500);
|
||||||
|
} else {
|
||||||
|
retryBtn.innerHTML = '<i class="bi bi-exclamation-triangle"></i> ' + (data.message || 'Eroare');
|
||||||
|
retryBtn.className = 'btn btn-sm btn-danger';
|
||||||
|
setTimeout(() => {
|
||||||
|
retryBtn.innerHTML = '<i class="bi bi-arrow-clockwise"></i> Reimporta';
|
||||||
|
retryBtn.className = 'btn btn-sm btn-outline-primary';
|
||||||
|
retryBtn.disabled = false;
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
retryBtn.innerHTML = 'Eroare: ' + err.message;
|
||||||
|
retryBtn.disabled = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (opts.onAfterRender) opts.onAfterRender(order, items);
|
||||||
|
} catch (err) {
|
||||||
|
document.getElementById('detailError').textContent = err.message;
|
||||||
|
document.getElementById('detailError').style.display = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Global quick map dispatcher — set by each page
|
||||||
|
let _sharedModalQuickMapFn = null;
|
||||||
|
function _sharedModalQuickMap(sku, productName, orderNumber, itemIdx) {
|
||||||
|
if (_sharedModalQuickMapFn) _sharedModalQuickMapFn(sku, productName, orderNumber, itemIdx);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Dot helper ────────────────────────────────────
|
// ── Dot helper ────────────────────────────────────
|
||||||
function statusDot(status) {
|
function statusDot(status) {
|
||||||
switch ((status || '').toUpperCase()) {
|
switch ((status || '').toUpperCase()) {
|
||||||
|
|||||||
@@ -1,16 +1,28 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="ro" style="color-scheme: light">
|
<html lang="ro">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>{% block title %}GoMag Import Manager{% endblock %}</title>
|
<title>{% block title %}GoMag Import Manager{% endblock %}</title>
|
||||||
|
<!-- FOUC prevention: apply saved theme before any rendering -->
|
||||||
|
<script>
|
||||||
|
try {
|
||||||
|
var t = localStorage.getItem('theme');
|
||||||
|
if (!t) t = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||||
|
if (t === 'dark') document.documentElement.setAttribute('data-theme', 'dark');
|
||||||
|
} catch(e) {}
|
||||||
|
</script>
|
||||||
|
<!-- Fonts (DESIGN.md) -->
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,300;0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700;1,9..40,400&family=JetBrains+Mono:wght@400;500;600&family=Space+Grotesk:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.2/font/bootstrap-icons.css" rel="stylesheet">
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.2/font/bootstrap-icons.css" rel="stylesheet">
|
||||||
{% set rp = request.scope.get('root_path', '') %}
|
{% set rp = request.scope.get('root_path', '') %}
|
||||||
<link href="{{ rp }}/static/css/style.css?v=17" rel="stylesheet">
|
<link href="{{ rp }}/static/css/style.css?v=25" rel="stylesheet">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<!-- Top Navbar -->
|
<!-- Top Navbar (hidden on mobile via CSS) -->
|
||||||
<nav class="top-navbar">
|
<nav class="top-navbar">
|
||||||
<div class="navbar-brand">GoMag Import</div>
|
<div class="navbar-brand">GoMag Import</div>
|
||||||
<div class="navbar-links">
|
<div class="navbar-links">
|
||||||
@@ -20,10 +32,22 @@
|
|||||||
<a href="{{ rp }}/logs" class="nav-tab {% block nav_logs %}{% endblock %}"><span class="d-none d-md-inline">Jurnale Import</span><span class="d-md-none">Jurnale</span></a>
|
<a href="{{ rp }}/logs" class="nav-tab {% block nav_logs %}{% endblock %}"><span class="d-none d-md-inline">Jurnale Import</span><span class="d-md-none">Jurnale</span></a>
|
||||||
<a href="{{ rp }}/settings" class="nav-tab {% block nav_settings %}{% endblock %}"><span class="d-none d-md-inline">Setari</span><span class="d-md-none">Setari</span></a>
|
<a href="{{ rp }}/settings" class="nav-tab {% block nav_settings %}{% endblock %}"><span class="d-none d-md-inline">Setari</span><span class="d-md-none">Setari</span></a>
|
||||||
</div>
|
</div>
|
||||||
|
<button class="dark-toggle" onclick="toggleDarkMode()" title="Comuta tema" aria-label="Comuta tema intunecata">
|
||||||
|
<i class="bi bi-sun-fill"></i>
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Bottom Nav (mobile only, shown via CSS) -->
|
||||||
|
<nav class="bottom-nav">
|
||||||
|
<a href="{{ rp }}/" class="bottom-nav-item {% block bnav_dashboard %}{% endblock %}"><i class="bi bi-speedometer2"></i><span>Dashboard</span></a>
|
||||||
|
<a href="{{ rp }}/mappings" class="bottom-nav-item {% block bnav_mappings %}{% endblock %}"><i class="bi bi-arrow-left-right"></i><span>Mapari</span></a>
|
||||||
|
<a href="{{ rp }}/missing-skus" class="bottom-nav-item {% block bnav_missing %}{% endblock %}"><i class="bi bi-exclamation-triangle"></i><span>Lipsa</span></a>
|
||||||
|
<a href="{{ rp }}/logs" class="bottom-nav-item {% block bnav_logs %}{% endblock %}"><i class="bi bi-journal-text"></i><span>Jurnale</span></a>
|
||||||
|
<a href="{{ rp }}/settings" class="bottom-nav-item {% block bnav_settings %}{% endblock %}"><i class="bi bi-gear"></i><span>Setari</span></a>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<!-- Main content -->
|
<!-- Main content -->
|
||||||
<main class="main-content">
|
<main class="main-content {% block main_class %}{% endblock %}">
|
||||||
{% block content %}{% endblock %}
|
{% block content %}{% endblock %}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
@@ -39,7 +63,7 @@
|
|||||||
<div style="margin-bottom:8px; font-size:0.85rem">
|
<div style="margin-bottom:8px; font-size:0.85rem">
|
||||||
<small class="text-muted">Produs:</small> <strong id="qmProductName"></strong>
|
<small class="text-muted">Produs:</small> <strong id="qmProductName"></strong>
|
||||||
</div>
|
</div>
|
||||||
<div class="qm-row" style="font-size:0.7rem; color:#9ca3af; padding:0 0 2px">
|
<div class="qm-row" style="font-size:0.7rem; color:var(--text-muted); padding:0 0 2px">
|
||||||
<span style="flex:1">CODMAT</span>
|
<span style="flex:1">CODMAT</span>
|
||||||
<span style="width:70px">Cant.</span>
|
<span style="width:70px">Cant.</span>
|
||||||
<span style="width:30px"></span>
|
<span style="width:30px"></span>
|
||||||
@@ -59,9 +83,88 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Shared Order Detail Modal -->
|
||||||
|
<div class="modal fade" id="orderDetailModal" tabindex="-1">
|
||||||
|
<div class="modal-dialog modal-lg">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">Comanda <code id="detailOrderNumber"></code></h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<small class="text-muted">Client:</small> <strong id="detailCustomer"></strong><br>
|
||||||
|
<small class="text-muted">Data comanda:</small> <span id="detailDate"></span><br>
|
||||||
|
<small class="text-muted">Status:</small> <span id="detailStatus"></span><span id="detailPriceCheck" class="ms-2"></span>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<small class="text-muted">ID Comanda ROA:</small> <span id="detailIdComanda">-</span><br>
|
||||||
|
<small class="text-muted">ID Partener:</small> <span id="detailIdPartener">-</span><br>
|
||||||
|
<small class="text-muted">ID Adr. Facturare:</small> <span id="detailIdAdresaFact">-</span><br>
|
||||||
|
<small class="text-muted">ID Adr. Livrare:</small> <span id="detailIdAdresaLivr">-</span>
|
||||||
|
<div id="detailInvoiceInfo" style="display:none; margin-top:4px;">
|
||||||
|
<small class="text-muted">Factura:</small> <span id="detailInvoiceNumber"></span>
|
||||||
|
<span class="ms-2"><small class="text-muted">din</small> <span id="detailInvoiceDate"></span></span>
|
||||||
|
<div id="detailInvoiceRecon" class="mt-1" style="display:none"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="table-responsive d-none d-md-block">
|
||||||
|
<table class="table table-sm table-bordered mb-0">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>SKU</th>
|
||||||
|
<th>Produs</th>
|
||||||
|
<th>CODMAT</th>
|
||||||
|
<th class="text-end">Cant.</th>
|
||||||
|
<th class="text-end">Pret GoMag</th>
|
||||||
|
<th class="text-end">Pret ROA</th>
|
||||||
|
<th class="text-end">TVA%</th>
|
||||||
|
<th class="text-end">Valoare</th>
|
||||||
|
<th class="text-center">✓</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="detailItemsBody">
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div id="detailReceipt" class="d-flex flex-wrap gap-2 mt-1 justify-content-end"></div>
|
||||||
|
</div>
|
||||||
|
<div class="d-md-none" id="detailItemsMobile"></div>
|
||||||
|
<div id="detailReceiptMobile" class="d-flex flex-wrap gap-2 mt-1 d-md-none justify-content-end"></div>
|
||||||
|
<div id="detailError" class="alert alert-danger mt-3" style="display:none;"></div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" id="detailRetryBtn" class="btn btn-sm btn-outline-primary" style="display:none"><i class="bi bi-arrow-clockwise"></i> Reimporta</button>
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Inchide</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script>window.ROOT_PATH = "{{ rp }}";</script>
|
<script>window.ROOT_PATH = "{{ rp }}";</script>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
<script src="{{ rp }}/static/js/shared.js?v=12"></script>
|
<script src="{{ rp }}/static/js/shared.js?v=20"></script>
|
||||||
|
<script>
|
||||||
|
// Dark mode toggle
|
||||||
|
function toggleDarkMode() {
|
||||||
|
var isDark = document.documentElement.getAttribute('data-theme') === 'dark';
|
||||||
|
var newTheme = isDark ? 'light' : 'dark';
|
||||||
|
document.documentElement.setAttribute('data-theme', newTheme);
|
||||||
|
try { localStorage.setItem('theme', newTheme); } catch(e) {}
|
||||||
|
updateDarkToggleIcon();
|
||||||
|
// Sync settings page toggle if present
|
||||||
|
var settToggle = document.getElementById('settDarkMode');
|
||||||
|
if (settToggle) settToggle.checked = (newTheme === 'dark');
|
||||||
|
}
|
||||||
|
function updateDarkToggleIcon() {
|
||||||
|
var isDark = document.documentElement.getAttribute('data-theme') === 'dark';
|
||||||
|
document.querySelectorAll('.dark-toggle i').forEach(function(el) {
|
||||||
|
el.className = isDark ? 'bi bi-moon-fill' : 'bi bi-sun-fill';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
updateDarkToggleIcon();
|
||||||
|
</script>
|
||||||
{% block scripts %}{% endblock %}
|
{% block scripts %}{% endblock %}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}Dashboard - GoMag Import{% endblock %}
|
{% block title %}Dashboard - GoMag Import{% endblock %}
|
||||||
{% block nav_dashboard %}active{% endblock %}
|
{% block nav_dashboard %}active{% endblock %}
|
||||||
|
{% block bnav_dashboard %}active{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h4 class="mb-4">Panou de Comanda</h4>
|
<h4 class="mb-4">Panou de Comanda</h4>
|
||||||
|
|
||||||
|
<div id="welcomeCard" style="display:none"></div>
|
||||||
|
|
||||||
<!-- Sync Card (unified two-row panel) -->
|
<!-- Sync Card (unified two-row panel) -->
|
||||||
<div class="sync-card">
|
<div class="sync-card">
|
||||||
<!-- TOP ROW: Status + Controls -->
|
<!-- TOP ROW: Status + Controls -->
|
||||||
@@ -48,19 +51,17 @@
|
|||||||
<span>Comenzi</span>
|
<span>Comenzi</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body py-2 px-3">
|
<div class="card-body py-2 px-3">
|
||||||
|
<div id="attentionCard"></div>
|
||||||
<div class="filter-bar" id="ordersFilterBar">
|
<div class="filter-bar" id="ordersFilterBar">
|
||||||
<!-- Period dropdown -->
|
<!-- Period preset buttons -->
|
||||||
<select id="periodSelect" class="select-compact">
|
<div class="period-presets">
|
||||||
<option value="1">1 zi</option>
|
<button class="preset-btn" data-days="1">Azi</button>
|
||||||
<option value="2">2 zile</option>
|
<button class="preset-btn active" data-days="3">3 zile</button>
|
||||||
<option value="3">3 zile</option>
|
<button class="preset-btn" data-days="7">7 zile</button>
|
||||||
<option value="7" selected>7 zile</option>
|
<button class="preset-btn" data-days="30">30 zile</button>
|
||||||
<option value="30">30 zile</option>
|
<button class="preset-btn" data-days="custom">Custom</button>
|
||||||
<option value="90">3 luni</option>
|
</div>
|
||||||
<option value="0">Toate</option>
|
<!-- Custom date range (hidden until 'Custom' clicked) -->
|
||||||
<option value="custom">Perioada personalizata...</option>
|
|
||||||
</select>
|
|
||||||
<!-- Custom date range (hidden until 'custom' selected) -->
|
|
||||||
<div class="period-custom-range" id="customRangeInputs">
|
<div class="period-custom-range" id="customRangeInputs">
|
||||||
<input type="date" id="periodStart" class="select-compact">
|
<input type="date" id="periodStart" class="select-compact">
|
||||||
<span>—</span>
|
<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="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>
|
<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>
|
||||||
<div class="d-md-none mb-2 d-flex align-items-center gap-2">
|
<div class="d-md-none mb-2 d-flex align-items-center gap-2" style="max-width:100%;overflow:hidden">
|
||||||
<div class="flex-grow-1" id="dashMobileSeg"></div>
|
<div class="flex-grow-1" id="dashMobileSeg" style="min-width:0;overflow-x:auto"></div>
|
||||||
<button class="btn btn-sm btn-outline-secondary" id="btnRefreshInvoicesMobile" onclick="refreshInvoices()" title="Actualizeaza facturi" style="padding:4px 8px; font-size:1rem; line-height:1">↻</button>
|
<button class="btn btn-sm btn-outline-secondary" id="btnRefreshInvoicesMobile" onclick="refreshInvoices()" title="Actualizeaza facturi" style="padding:4px 8px; font-size:1rem; line-height:1">↻</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -98,10 +99,11 @@
|
|||||||
<th class="text-end">Discount</th>
|
<th class="text-end">Discount</th>
|
||||||
<th class="text-end">Total</th>
|
<th class="text-end">Total</th>
|
||||||
<th style="width:28px" title="Facturat">F</th>
|
<th style="width:28px" title="Facturat">F</th>
|
||||||
|
<th class="text-center" style="width:30px" title="Preturi ROA">₽</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="dashOrdersBody">
|
<tbody id="dashOrdersBody">
|
||||||
<tr><td colspan="9" class="text-center text-muted py-3">Se incarca...</td></tr>
|
<tr><td colspan="10" class="text-center text-muted py-3">Se incarca...</td></tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -109,64 +111,8 @@
|
|||||||
<div id="dashPagination" class="pag-strip pag-strip-bottom"></div>
|
<div id="dashPagination" class="pag-strip pag-strip-bottom"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Order Detail Modal -->
|
|
||||||
<div class="modal fade" id="orderDetailModal" tabindex="-1">
|
|
||||||
<div class="modal-dialog modal-lg">
|
|
||||||
<div class="modal-content">
|
|
||||||
<div class="modal-header">
|
|
||||||
<h5 class="modal-title">Comanda <code id="detailOrderNumber"></code></h5>
|
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body">
|
|
||||||
<div class="row mb-3">
|
|
||||||
<div class="col-md-6">
|
|
||||||
<small class="text-muted">Client:</small> <strong id="detailCustomer"></strong><br>
|
|
||||||
<small class="text-muted">Data comanda:</small> <span id="detailDate"></span><br>
|
|
||||||
<small class="text-muted">Status:</small> <span id="detailStatus"></span>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-6">
|
|
||||||
<small class="text-muted">ID Comanda ROA:</small> <span id="detailIdComanda">-</span><br>
|
|
||||||
<small class="text-muted">ID Partener:</small> <span id="detailIdPartener">-</span><br>
|
|
||||||
<small class="text-muted">ID Adr. Facturare:</small> <span id="detailIdAdresaFact">-</span><br>
|
|
||||||
<small class="text-muted">ID Adr. Livrare:</small> <span id="detailIdAdresaLivr">-</span>
|
|
||||||
<div id="detailInvoiceInfo" style="display:none; margin-top:4px;">
|
|
||||||
<small class="text-muted">Factura:</small> <span id="detailInvoiceNumber"></span>
|
|
||||||
<span class="ms-2"><small class="text-muted">din</small> <span id="detailInvoiceDate"></span></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="table-responsive d-none d-md-block">
|
|
||||||
<table class="table table-sm table-bordered mb-0">
|
|
||||||
<thead class="table-light">
|
|
||||||
<tr>
|
|
||||||
<th>SKU</th>
|
|
||||||
<th>Produs</th>
|
|
||||||
<th>CODMAT</th>
|
|
||||||
<th class="text-end">Cant.</th>
|
|
||||||
<th class="text-end">Pret</th>
|
|
||||||
<th class="text-end">TVA%</th>
|
|
||||||
<th class="text-end">Valoare</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody id="detailItemsBody">
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
<div id="detailReceipt" class="d-flex flex-wrap gap-2 mt-1 justify-content-end"></div>
|
|
||||||
</div>
|
|
||||||
<div class="d-md-none" id="detailItemsMobile"></div>
|
|
||||||
<div id="detailReceiptMobile" class="d-flex flex-wrap gap-2 mt-1 d-md-none justify-content-end"></div>
|
|
||||||
<div id="detailError" class="alert alert-danger mt-3" style="display:none;"></div>
|
|
||||||
</div>
|
|
||||||
<div class="modal-footer">
|
|
||||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Inchide</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Quick Map Modal (used from order detail) -->
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
<script src="{{ request.scope.get('root_path', '') }}/static/js/dashboard.js?v=25"></script>
|
<script src="{{ request.scope.get('root_path', '') }}/static/js/dashboard.js?v=32"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}Jurnale Import - GoMag Import{% endblock %}
|
{% block title %}Jurnale Import - GoMag Import{% endblock %}
|
||||||
{% block nav_logs %}active{% endblock %}
|
{% block nav_logs %}active{% endblock %}
|
||||||
|
{% block bnav_logs %}active{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h4 class="mb-4">Jurnale Import</h4>
|
<h4 class="mb-4">Jurnale Import</h4>
|
||||||
@@ -56,7 +57,7 @@
|
|||||||
<button class="filter-pill d-none d-md-inline-flex" data-log-status="SKIPPED">Omise <span class="filter-count fc-yellow" id="countSkipped">0</span></button>
|
<button class="filter-pill d-none d-md-inline-flex" data-log-status="SKIPPED">Omise <span class="filter-count fc-yellow" id="countSkipped">0</span></button>
|
||||||
<button class="filter-pill d-none d-md-inline-flex" data-log-status="ERROR">Erori <span class="filter-count fc-red" id="countError">0</span></button>
|
<button class="filter-pill d-none d-md-inline-flex" data-log-status="ERROR">Erori <span class="filter-count fc-red" id="countError">0</span></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="d-md-none mb-2" id="logsMobileSeg"></div>
|
<div class="d-md-none mb-2" id="logsMobileSeg" style="overflow-x:auto"></div>
|
||||||
|
|
||||||
<!-- Orders table -->
|
<!-- Orders table -->
|
||||||
<div class="card mb-3">
|
<div class="card mb-3">
|
||||||
@@ -96,65 +97,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Order Detail Modal -->
|
|
||||||
<div class="modal fade" id="orderDetailModal" tabindex="-1">
|
|
||||||
<div class="modal-dialog modal-lg">
|
|
||||||
<div class="modal-content">
|
|
||||||
<div class="modal-header">
|
|
||||||
<h5 class="modal-title">Comanda <code id="detailOrderNumber"></code></h5>
|
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body">
|
|
||||||
<div class="row mb-3">
|
|
||||||
<div class="col-md-6">
|
|
||||||
<small class="text-muted">Client:</small> <strong id="detailCustomer"></strong><br>
|
|
||||||
<small class="text-muted">Data comanda:</small> <span id="detailDate"></span><br>
|
|
||||||
<small class="text-muted">Status:</small> <span id="detailStatus"></span>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-6">
|
|
||||||
<small class="text-muted">ID Comanda ROA:</small> <span id="detailIdComanda">-</span><br>
|
|
||||||
<small class="text-muted">ID Partener:</small> <span id="detailIdPartener">-</span><br>
|
|
||||||
<small class="text-muted">ID Adr. Facturare:</small> <span id="detailIdAdresaFact">-</span><br>
|
|
||||||
<small class="text-muted">ID Adr. Livrare:</small> <span id="detailIdAdresaLivr">-</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div id="detailTotals" class="d-flex gap-3 mb-2 flex-wrap" style="font-size:0.875rem">
|
|
||||||
<span><small class="text-muted">Valoare:</small> <strong id="detailItemsTotal">-</strong></span>
|
|
||||||
<span id="detailDiscountWrap"><small class="text-muted">Discount:</small> <strong id="detailDiscount">-</strong></span>
|
|
||||||
<span id="detailDeliveryWrap"><small class="text-muted">Transport:</small> <strong id="detailDeliveryCost">-</strong></span>
|
|
||||||
<span><small class="text-muted">Total:</small> <strong id="detailOrderTotal">-</strong></span>
|
|
||||||
</div>
|
|
||||||
<div class="table-responsive d-none d-md-block">
|
|
||||||
<table class="table table-sm table-bordered mb-0">
|
|
||||||
<thead class="table-light">
|
|
||||||
<tr>
|
|
||||||
<th>SKU</th>
|
|
||||||
<th>Produs</th>
|
|
||||||
<th>CODMAT</th>
|
|
||||||
<th>Cant.</th>
|
|
||||||
<th>Pret</th>
|
|
||||||
<th class="text-end">Valoare</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody id="detailItemsBody">
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<div class="d-md-none" id="detailItemsMobile"></div>
|
|
||||||
<div id="detailError" class="alert alert-danger mt-3" style="display:none;"></div>
|
|
||||||
</div>
|
|
||||||
<div class="modal-footer">
|
|
||||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Inchide</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Quick Map Modal (used from order detail) -->
|
|
||||||
<!-- Hidden field for pre-selected run from URL/server -->
|
<!-- Hidden field for pre-selected run from URL/server -->
|
||||||
<input type="hidden" id="preselectedRun" value="{{ selected_run }}">
|
<input type="hidden" id="preselectedRun" value="{{ selected_run }}">
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
<script src="{{ request.scope.get('root_path', '') }}/static/js/logs.js?v=11"></script>
|
<script src="{{ request.scope.get('root_path', '') }}/static/js/logs.js?v=14"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,15 +1,24 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}Mapari SKU - GoMag Import{% endblock %}
|
{% block title %}Mapari SKU - GoMag Import{% endblock %}
|
||||||
{% block nav_mappings %}active{% endblock %}
|
{% block nav_mappings %}active{% endblock %}
|
||||||
|
{% block bnav_mappings %}active{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
<h4 class="mb-0">Mapari SKU</h4>
|
<h4 class="mb-0">Mapari SKU</h4>
|
||||||
<div class="d-flex align-items-center gap-2">
|
<div class="d-flex align-items-center gap-2">
|
||||||
<!-- Desktop buttons -->
|
<!-- Desktop Import/Export dropdown -->
|
||||||
<button class="btn btn-sm btn-outline-secondary d-none d-md-inline-flex" onclick="downloadTemplate()"><i class="bi bi-file-earmark-arrow-down"></i> Template CSV</button>
|
<div class="dropdown d-none d-md-inline-block">
|
||||||
<button class="btn btn-sm btn-outline-secondary d-none d-md-inline-flex" onclick="exportCsv()"><i class="bi bi-download"></i> Export CSV</button>
|
<button class="btn btn-sm btn-outline-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown">
|
||||||
<button class="btn btn-sm btn-outline-primary d-none d-md-inline-flex" data-bs-toggle="modal" data-bs-target="#importModal"><i class="bi bi-upload"></i> Import CSV</button>
|
<i class="bi bi-file-earmark-spreadsheet"></i> Import/Export
|
||||||
|
</button>
|
||||||
|
<ul class="dropdown-menu">
|
||||||
|
<li><a class="dropdown-item" href="#" onclick="downloadTemplate(); return false"><i class="bi bi-file-earmark-arrow-down me-1"></i> Download Template CSV</a></li>
|
||||||
|
<li><a class="dropdown-item" href="#" onclick="exportCsv(); return false"><i class="bi bi-download me-1"></i> Export CSV</a></li>
|
||||||
|
<li><hr class="dropdown-divider"></li>
|
||||||
|
<li><a class="dropdown-item" href="#" data-bs-toggle="modal" data-bs-target="#importModal"><i class="bi bi-upload me-1"></i> Import CSV</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
<button class="btn btn-sm btn-primary" onclick="showInlineAddRow()"><i class="bi bi-plus-lg"></i> <span class="d-none d-md-inline">Adauga Mapare</span><span class="d-md-none">Mapare</span></button>
|
<button class="btn btn-sm btn-primary" onclick="showInlineAddRow()"><i class="bi bi-plus-lg"></i> <span class="d-none d-md-inline">Adauga Mapare</span><span class="d-md-none">Mapare</span></button>
|
||||||
<button class="btn btn-sm btn-outline-secondary d-none d-md-inline-flex" data-bs-toggle="modal" data-bs-target="#addModal"><i class="bi bi-box-arrow-up-right"></i> Formular complet</button>
|
<button class="btn btn-sm btn-outline-secondary d-none d-md-inline-flex" data-bs-toggle="modal" data-bs-target="#addModal"><i class="bi bi-box-arrow-up-right"></i> Formular complet</button>
|
||||||
<!-- Mobile ⋯ dropdown -->
|
<!-- Mobile ⋯ dropdown -->
|
||||||
@@ -150,5 +159,5 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
<script src="{{ request.scope.get('root_path', '') }}/static/js/mappings.js?v=11"></script>
|
<script src="{{ request.scope.get('root_path', '') }}/static/js/mappings.js?v=14"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}SKU-uri Lipsa - GoMag Import{% endblock %}
|
{% block title %}SKU-uri Lipsa - GoMag Import{% endblock %}
|
||||||
{% block nav_missing %}active{% endblock %}
|
{% block nav_missing %}active{% endblock %}
|
||||||
|
{% block bnav_missing %}active{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
|||||||
@@ -1,10 +1,23 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}Setari - GoMag Import{% endblock %}
|
{% block title %}Setari - GoMag Import{% endblock %}
|
||||||
{% block nav_settings %}active{% endblock %}
|
{% block nav_settings %}active{% endblock %}
|
||||||
|
{% block bnav_settings %}active{% endblock %}
|
||||||
|
{% block main_class %}constrained{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h4 class="mb-3">Setari</h4>
|
<h4 class="mb-3">Setari</h4>
|
||||||
|
|
||||||
|
<!-- Dark mode toggle -->
|
||||||
|
<div class="theme-toggle-card">
|
||||||
|
<div>
|
||||||
|
<i class="bi bi-moon-fill me-2"></i>
|
||||||
|
<label for="settDarkMode">Mod intunecat</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-check form-switch mb-0">
|
||||||
|
<input class="form-check-input" type="checkbox" role="switch" id="settDarkMode" style="width:2.5rem;height:1.25rem">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="row g-3 mb-3">
|
<div class="row g-3 mb-3">
|
||||||
<!-- GoMag API card -->
|
<!-- GoMag API card -->
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
@@ -144,6 +157,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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="row g-3 mb-3">
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<div class="card h-100">
|
<div class="card h-100">
|
||||||
@@ -224,6 +242,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<button class="btn btn-primary btn-sm" onclick="saveSettings()">Salvează Setările</button>
|
<button class="btn btn-primary btn-sm" onclick="saveSettings()">Salvează Setările</button>
|
||||||
@@ -233,5 +253,5 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
<script src="{{ request.scope.get('root_path', '') }}/static/js/settings.js?v=7"></script>
|
<script src="{{ request.scope.get('root_path', '') }}/static/js/settings.js?v=9"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
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()
|
texts = headers.all_text_contents()
|
||||||
|
|
||||||
# Current columns (may evolve — check dashboard.html for source of truth)
|
# Current columns (may evolve — check dashboard.html for source of truth)
|
||||||
required_columns = ["SKU", "Produs", "CODMAT", "Cant.", "Pret", "Valoare"]
|
required_columns = ["SKU", "Produs", "CODMAT", "Cant.", "Pret GoMag", "Pret ROA", "Valoare"]
|
||||||
for col in required_columns:
|
for col in required_columns:
|
||||||
assert col in texts, f"Column '{col}' missing from order detail items table. Found: {texts}"
|
assert col in texts, f"Column '{col}' missing from order detail items table. Found: {texts}"
|
||||||
|
|
||||||
@@ -51,5 +51,5 @@ def test_dashboard_navigates_to_logs(page: Page, app_url: str):
|
|||||||
page.goto(f"{app_url}/")
|
page.goto(f"{app_url}/")
|
||||||
page.wait_for_load_state("networkidle")
|
page.wait_for_load_state("networkidle")
|
||||||
|
|
||||||
logs_link = page.locator("a[href='/logs']")
|
logs_link = page.locator(".top-navbar a[href='/logs'], .bottom-nav a[href='/logs']")
|
||||||
expect(logs_link).to_be_visible()
|
expect(logs_link.first).to_be_visible()
|
||||||
|
|||||||
@@ -89,14 +89,14 @@ def test_responsive_page(
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def test_mobile_navbar_visible(pw_browser, base_url: str):
|
def test_mobile_navbar_visible(pw_browser, base_url: str):
|
||||||
"""Mobile viewport: navbar should still be visible and functional."""
|
"""Mobile viewport: bottom nav should be visible (top navbar hidden on mobile)."""
|
||||||
context = pw_browser.new_context(viewport=VIEWPORTS["mobile"])
|
context = pw_browser.new_context(viewport=VIEWPORTS["mobile"])
|
||||||
page = context.new_page()
|
page = context.new_page()
|
||||||
try:
|
try:
|
||||||
page.goto(base_url, wait_until="networkidle", timeout=15_000)
|
page.goto(base_url, wait_until="networkidle", timeout=15_000)
|
||||||
# Custom navbar: .top-navbar with .navbar-brand
|
# On mobile, top-navbar is hidden and bottom-nav is shown
|
||||||
navbar = page.locator(".top-navbar")
|
bottom_nav = page.locator(".bottom-nav")
|
||||||
expect(navbar).to_be_visible()
|
expect(bottom_nav).to_be_visible()
|
||||||
finally:
|
finally:
|
||||||
context.close()
|
context.close()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user