From 82196b9dc03d2cfb6cd824f8083e796b37876206 Mon Sep 17 00:00:00 2001 From: Marius Mutu Date: Fri, 13 Mar 2026 16:18:57 +0200 Subject: [PATCH] feat(sqlite): refactor orders schema + dashboard period filter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace import_orders (insert-per-run) with orders table (one row per order, upsert on conflict). Eliminates dedup CTE on every dashboard query and prevents unbounded row growth at 4-500 orders/sync. Key changes: - orders table: PK order_number, upsert via ON CONFLICT DO UPDATE; COALESCE preserves id_comanda once set; times_skipped auto-increments - sync_run_orders: lightweight junction (sync_run_id, order_number) replaces sync_run_id column on orders - order_items: PK changed to (order_number, sku), INSERT OR IGNORE - Auto-migration in init_sqlite(): import_orders → orders on first boot, old table renamed to import_orders_bak - /api/dashboard/orders: period_days param (3/7/30/0=all, default 7) - Dashboard: period selector buttons in orders card header - start.sh: stop existing process on port 5003 before restart; remove --reload (broken on WSL2 /mnt/e/) - Add invoice_service, E2E Playwright tests, Oracle package updates Co-Authored-By: Claude Sonnet 4.6 --- api/app/config.py | 5 +- api/app/database.py | 158 +++- api/app/routers/mappings.py | 73 +- api/app/routers/sync.py | 211 ++++- api/app/routers/validation.py | 10 +- api/app/services/article_service.py | 3 +- api/app/services/import_service.py | 26 +- api/app/services/invoice_service.py | 43 + api/app/services/mapping_service.py | 115 ++- api/app/services/sqlite_service.py | 379 ++++++++- api/app/services/sync_service.py | 102 ++- api/app/services/validation_service.py | 4 +- api/app/static/css/style.css | 172 ++-- api/app/static/js/dashboard.js | 752 +++++++++++------ api/app/static/js/logs.js | 766 ++++++++++-------- api/app/static/js/mappings.js | 564 ++++++++++--- api/app/templates/dashboard.html | 251 +++--- api/app/templates/logs.html | 239 +++--- api/app/templates/mappings.html | 88 +- api/app/templates/missing_skus.html | 240 ++++-- .../05_pack_import_parteneri.pck | 6 +- .../06_pack_import_comenzi.pck | 1 + .../07_alter_articole_terti_sters.sql | 12 + api/requirements.txt | 2 + api/tests/e2e/conftest.py | 82 ++ api/tests/e2e/test_dashboard_live.py | 171 ++++ api/tests/e2e/test_logs_filtering.py | 57 ++ api/tests/e2e/test_mappings.py | 81 ++ api/tests/e2e/test_missing_skus.py | 68 ++ api/tests/e2e/test_order_detail.py | 52 ++ api/tests/test_requirements.py | 613 ++++++++++++++ start.sh | 10 +- 32 files changed, 4164 insertions(+), 1192 deletions(-) create mode 100644 api/app/services/invoice_service.py create mode 100644 api/database-scripts/07_alter_articole_terti_sters.sql create mode 100644 api/tests/e2e/conftest.py create mode 100644 api/tests/e2e/test_dashboard_live.py create mode 100644 api/tests/e2e/test_logs_filtering.py create mode 100644 api/tests/e2e/test_mappings.py create mode 100644 api/tests/e2e/test_missing_skus.py create mode 100644 api/tests/e2e/test_order_detail.py create mode 100644 api/tests/test_requirements.py diff --git a/api/app/config.py b/api/app/config.py index 0323fb6..4ace5c4 100644 --- a/api/app/config.py +++ b/api/app/config.py @@ -2,6 +2,9 @@ from pydantic_settings import BaseSettings from pathlib import Path import os +# Resolve .env relative to this file (api/app/config.py → api/.env) +_env_path = Path(__file__).resolve().parent.parent / ".env" + class Settings(BaseSettings): # Oracle ORACLE_USER: str = "MARIUSM_AUTO" @@ -35,6 +38,6 @@ class Settings(BaseSettings): ID_GESTIUNE: int = 0 ID_SECTIE: int = 0 - model_config = {"env_file": ".env", "env_file_encoding": "utf-8", "extra": "ignore"} + model_config = {"env_file": str(_env_path), "env_file_encoding": "utf-8", "extra": "ignore"} settings = Settings() diff --git a/api/app/database.py b/api/app/database.py index c5bca76..20655e3 100644 --- a/api/app/database.py +++ b/api/app/database.py @@ -76,19 +76,31 @@ CREATE TABLE IF NOT EXISTS sync_runs ( error_message TEXT ); -CREATE TABLE IF NOT EXISTS import_orders ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - sync_run_id TEXT REFERENCES sync_runs(run_id), - order_number TEXT, - order_date TEXT, - customer_name TEXT, - status TEXT, - id_comanda INTEGER, - id_partener INTEGER, - error_message TEXT, - missing_skus TEXT, - items_count INTEGER, - created_at TEXT DEFAULT (datetime('now')) +CREATE TABLE IF NOT EXISTS orders ( + order_number TEXT PRIMARY KEY, + order_date TEXT, + customer_name TEXT, + status TEXT, + id_comanda INTEGER, + id_partener INTEGER, + id_adresa_facturare INTEGER, + id_adresa_livrare INTEGER, + error_message TEXT, + missing_skus TEXT, + items_count INTEGER, + times_skipped INTEGER DEFAULT 0, + first_seen_at TEXT DEFAULT (datetime('now')), + last_sync_run_id TEXT REFERENCES sync_runs(run_id), + updated_at TEXT DEFAULT (datetime('now')) +); +CREATE INDEX IF NOT EXISTS idx_orders_status ON orders(status); +CREATE INDEX IF NOT EXISTS idx_orders_date ON orders(order_date); + +CREATE TABLE IF NOT EXISTS sync_run_orders ( + sync_run_id TEXT REFERENCES sync_runs(run_id), + order_number TEXT REFERENCES orders(order_number), + status_at_run TEXT, + PRIMARY KEY (sync_run_id, order_number) ); CREATE TABLE IF NOT EXISTS missing_skus ( @@ -106,6 +118,30 @@ CREATE TABLE IF NOT EXISTS scheduler_config ( key TEXT PRIMARY KEY, value TEXT ); + +CREATE TABLE IF NOT EXISTS web_products ( + sku TEXT PRIMARY KEY, + product_name TEXT, + first_seen TEXT DEFAULT (datetime('now')), + last_seen TEXT DEFAULT (datetime('now')), + order_count INTEGER DEFAULT 0 +); + +CREATE TABLE IF NOT EXISTS order_items ( + order_number TEXT, + sku TEXT, + product_name TEXT, + quantity REAL, + price REAL, + vat REAL, + mapping_status TEXT, + codmat TEXT, + id_articol INTEGER, + cantitate_roa REAL, + created_at TEXT DEFAULT (datetime('now')), + PRIMARY KEY (order_number, sku) +); +CREATE INDEX IF NOT EXISTS idx_order_items_order ON order_items(order_number); """ _sqlite_db_path = None @@ -122,6 +158,101 @@ def init_sqlite(): # Create tables synchronously conn = sqlite3.connect(_sqlite_db_path) + + # Check existing tables before running schema + cursor = conn.execute("SELECT name FROM sqlite_master WHERE type='table'") + existing_tables = {row[0] for row in cursor.fetchall()} + + # Migration: import_orders → orders (one row per order) + if 'import_orders' in existing_tables and 'orders' not in existing_tables: + logger.info("Migrating import_orders → orders schema...") + conn.executescript(""" + CREATE TABLE orders ( + order_number TEXT PRIMARY KEY, + order_date TEXT, + customer_name TEXT, + status TEXT, + id_comanda INTEGER, + id_partener INTEGER, + id_adresa_facturare INTEGER, + id_adresa_livrare INTEGER, + error_message TEXT, + missing_skus TEXT, + items_count INTEGER, + times_skipped INTEGER DEFAULT 0, + first_seen_at TEXT DEFAULT (datetime('now')), + last_sync_run_id TEXT, + updated_at TEXT DEFAULT (datetime('now')) + ); + CREATE INDEX IF NOT EXISTS idx_orders_status ON orders(status); + CREATE INDEX IF NOT EXISTS idx_orders_date ON orders(order_date); + + CREATE TABLE sync_run_orders ( + sync_run_id TEXT, + order_number TEXT, + status_at_run TEXT, + PRIMARY KEY (sync_run_id, order_number) + ); + """) + # Copy latest record per order_number into orders + conn.execute(""" + INSERT INTO orders + (order_number, order_date, customer_name, status, + id_comanda, id_partener, id_adresa_facturare, id_adresa_livrare, + error_message, missing_skus, items_count, last_sync_run_id) + SELECT io.order_number, io.order_date, io.customer_name, io.status, + io.id_comanda, io.id_partener, + CASE WHEN io.order_number IN (SELECT order_number FROM import_orders WHERE id_adresa_facturare IS NOT NULL) THEN + (SELECT id_adresa_facturare FROM import_orders WHERE order_number = io.order_number AND id_adresa_facturare IS NOT NULL LIMIT 1) ELSE NULL END, + CASE WHEN io.order_number IN (SELECT order_number FROM import_orders WHERE id_adresa_livrare IS NOT NULL) THEN + (SELECT id_adresa_livrare FROM import_orders WHERE order_number = io.order_number AND id_adresa_livrare IS NOT NULL LIMIT 1) ELSE NULL END, + io.error_message, io.missing_skus, io.items_count, io.sync_run_id + FROM import_orders io + INNER JOIN ( + SELECT order_number, MAX(id) as max_id + FROM import_orders + GROUP BY order_number + ) latest ON io.id = latest.max_id + """) + # Populate sync_run_orders from all import_orders rows + conn.execute(""" + INSERT OR IGNORE INTO sync_run_orders (sync_run_id, order_number, status_at_run) + SELECT sync_run_id, order_number, status + FROM import_orders + WHERE sync_run_id IS NOT NULL + """) + # Migrate order_items: drop sync_run_id, change PK to (order_number, sku) + if 'order_items' in existing_tables: + conn.executescript(""" + CREATE TABLE order_items_new ( + order_number TEXT, + sku TEXT, + product_name TEXT, + quantity REAL, + price REAL, + vat REAL, + mapping_status TEXT, + codmat TEXT, + id_articol INTEGER, + cantitate_roa REAL, + created_at TEXT DEFAULT (datetime('now')), + PRIMARY KEY (order_number, sku) + ); + INSERT OR IGNORE INTO order_items_new + (order_number, sku, product_name, quantity, price, vat, + mapping_status, codmat, id_articol, cantitate_roa, created_at) + SELECT order_number, sku, product_name, quantity, price, vat, + mapping_status, codmat, id_articol, cantitate_roa, created_at + FROM order_items; + DROP TABLE order_items; + ALTER TABLE order_items_new RENAME TO order_items; + CREATE INDEX IF NOT EXISTS idx_order_items_order ON order_items(order_number); + """) + # Rename old table instead of dropping (safety backup) + conn.execute("ALTER TABLE import_orders RENAME TO import_orders_bak") + conn.commit() + logger.info("Migration complete: import_orders → orders") + conn.executescript(SQLITE_SCHEMA) # Migrate: add columns if missing (for existing databases) @@ -140,6 +271,7 @@ def init_sqlite(): if "error_message" not in sync_cols: conn.execute("ALTER TABLE sync_runs ADD COLUMN error_message TEXT") logger.info("Migrated sync_runs: added column error_message") + conn.commit() except Exception as e: logger.warning(f"Migration check failed: {e}") diff --git a/api/app/routers/mappings.py b/api/app/routers/mappings.py index 13be8aa..0f5981a 100644 --- a/api/app/routers/mappings.py +++ b/api/app/routers/mappings.py @@ -8,6 +8,9 @@ import io from ..services import mapping_service, sqlite_service +import logging +logger = logging.getLogger(__name__) + router = APIRouter(tags=["mappings"]) templates = Jinja2Templates(directory=str(Path(__file__).parent.parent / "templates")) @@ -22,6 +25,21 @@ class MappingUpdate(BaseModel): procent_pret: Optional[float] = None activ: Optional[int] = None +class MappingEdit(BaseModel): + new_sku: str + new_codmat: str + cantitate_roa: float = 1 + procent_pret: float = 100 + +class MappingLine(BaseModel): + codmat: str + cantitate_roa: float = 1 + procent_pret: float = 100 + +class MappingBatchCreate(BaseModel): + sku: str + mappings: list[MappingLine] + # HTML page @router.get("/mappings", response_class=HTMLResponse) async def mappings_page(request: Request): @@ -29,8 +47,18 @@ async def mappings_page(request: Request): # API endpoints @router.get("/api/mappings") -def list_mappings(search: str = "", page: int = 1, per_page: int = 50): - return mapping_service.get_mappings(search=search, page=page, per_page=per_page) +async def list_mappings(search: str = "", page: int = 1, per_page: int = 50, + sort_by: str = "sku", sort_dir: str = "asc", + show_deleted: bool = False): + result = mapping_service.get_mappings(search=search, page=page, per_page=per_page, + sort_by=sort_by, sort_dir=sort_dir, + show_deleted=show_deleted) + # Merge product names from web_products (R4) + skus = list({m["sku"] for m in result.get("mappings", [])}) + product_names = await sqlite_service.get_web_products_batch(skus) + for m in result.get("mappings", []): + m["product_name"] = product_names.get(m["sku"], "") + return result @router.post("/api/mappings") async def create_mapping(data: MappingCreate): @@ -50,6 +78,15 @@ def update_mapping(sku: str, codmat: str, data: MappingUpdate): except Exception as e: return {"success": False, "error": str(e)} +@router.put("/api/mappings/{sku}/{codmat}/edit") +def edit_mapping(sku: str, codmat: str, data: MappingEdit): + try: + result = mapping_service.edit_mapping(sku, codmat, data.new_sku, data.new_codmat, + data.cantitate_roa, data.procent_pret) + return {"success": result} + except Exception as e: + return {"success": False, "error": str(e)} + @router.delete("/api/mappings/{sku}/{codmat}") def delete_mapping(sku: str, codmat: str): try: @@ -58,6 +95,38 @@ def delete_mapping(sku: str, codmat: str): except Exception as e: return {"success": False, "error": str(e)} +@router.post("/api/mappings/{sku}/{codmat}/restore") +def restore_mapping(sku: str, codmat: str): + try: + restored = mapping_service.restore_mapping(sku, codmat) + return {"success": restored} + except Exception as e: + return {"success": False, "error": str(e)} + +@router.post("/api/mappings/batch") +async def create_batch_mapping(data: MappingBatchCreate): + """Create multiple (sku, codmat) rows for complex sets (R11).""" + if not data.mappings: + return {"success": False, "error": "No mappings provided"} + + # Validate procent_pret sums to 100 for multi-line sets + if len(data.mappings) > 1: + total_pct = sum(m.procent_pret for m in data.mappings) + if abs(total_pct - 100) > 0.01: + return {"success": False, "error": f"Procent pret trebuie sa fie 100% (actual: {total_pct}%)"} + + try: + results = [] + for m in data.mappings: + r = mapping_service.create_mapping(data.sku, m.codmat, m.cantitate_roa, m.procent_pret) + results.append(r) + # Mark SKU as resolved in missing_skus tracking + await sqlite_service.resolve_missing_sku(data.sku) + return {"success": True, "created": len(results)} + except Exception as e: + return {"success": False, "error": str(e)} + + @router.post("/api/mappings/import-csv") async def import_csv(file: UploadFile = File(...)): content = await file.read() diff --git a/api/app/routers/sync.py b/api/app/routers/sync.py index c8d7418..600da85 100644 --- a/api/app/routers/sync.py +++ b/api/app/routers/sync.py @@ -1,5 +1,6 @@ import asyncio import json +from datetime import datetime from fastapi import APIRouter, Request, BackgroundTasks from fastapi.templating import Jinja2Templates @@ -9,7 +10,7 @@ from pydantic import BaseModel from pathlib import Path from typing import Optional -from ..services import sync_service, scheduler_service, sqlite_service +from ..services import sync_service, scheduler_service, sqlite_service, invoice_service router = APIRouter(tags=["sync"]) templates = Jinja2Templates(directory=str(Path(__file__).parent.parent / "templates")) @@ -110,9 +111,12 @@ async def sync_run_log(run_id: str): "orders": [ { "order_number": o.get("order_number"), + "order_date": o.get("order_date"), "customer_name": o.get("customer_name"), "items_count": o.get("items_count"), "status": o.get("status"), + "id_comanda": o.get("id_comanda"), + "id_partener": o.get("id_partener"), "error_message": o.get("error_message"), "missing_skus": o.get("missing_skus"), } @@ -121,6 +125,211 @@ async def sync_run_log(run_id: str): } +def _format_text_log_from_detail(detail: dict) -> str: + """Build a text log from SQLite stored data for completed runs.""" + run = detail.get("run", {}) + orders = detail.get("orders", []) + + run_id = run.get("run_id", "?") + started = run.get("started_at", "") + + lines = [f"=== Sync Run {run_id} ==="] + if started: + try: + dt = datetime.fromisoformat(started) + lines.append(f"Inceput: {dt.strftime('%d.%m.%Y %H:%M:%S')}") + except (ValueError, TypeError): + lines.append(f"Inceput: {started}") + lines.append("") + + for o in orders: + status = (o.get("status") or "").upper() + number = o.get("order_number", "?") + customer = o.get("customer_name", "?") + order_date = o.get("order_date") or "?" + + if status == "IMPORTED": + id_cmd = o.get("id_comanda", "?") + lines.append(f"#{number} [{order_date}] {customer} → IMPORTAT (ID: {id_cmd})") + elif status == "SKIPPED": + missing = o.get("missing_skus", "") + if isinstance(missing, str): + try: + missing = json.loads(missing) + except (json.JSONDecodeError, TypeError): + missing = [missing] if missing else [] + skus_str = ", ".join(missing) if isinstance(missing, list) else str(missing) + lines.append(f"#{number} [{order_date}] {customer} → OMIS (lipsa: {skus_str})") + elif status == "ERROR": + err = o.get("error_message", "necunoscuta") + lines.append(f"#{number} [{order_date}] {customer} → EROARE: {err}") + + # Summary line + lines.append("") + total = run.get("total_orders", 0) + imported = run.get("imported", 0) + skipped = run.get("skipped", 0) + errors = run.get("errors", 0) + + duration_str = "" + finished = run.get("finished_at", "") + if started and finished: + try: + dt_start = datetime.fromisoformat(started) + dt_end = datetime.fromisoformat(finished) + secs = int((dt_end - dt_start).total_seconds()) + duration_str = f" | Durata: {secs}s" + except (ValueError, TypeError): + pass + + lines.append(f"Finalizat: {imported} importate, {skipped} nemapate, {errors} erori din {total} comenzi{duration_str}") + + return "\n".join(lines) + + +@router.get("/api/sync/run/{run_id}/text-log") +async def sync_run_text_log(run_id: str): + """Get text log for a sync run - live from memory or reconstructed from SQLite.""" + # Check in-memory first (active/recent runs) + live_log = sync_service.get_run_text_log(run_id) + if live_log is not None: + status = "running" + current = await sync_service.get_sync_status() + if current.get("run_id") != run_id or current.get("status") != "running": + status = "completed" + return {"text": live_log, "status": status, "finished": status != "running"} + + # Fall back to SQLite for historical runs + detail = await sqlite_service.get_sync_run_detail(run_id) + if not detail: + return {"error": "Run not found", "text": "", "status": "unknown", "finished": True} + + run = detail.get("run", {}) + text = _format_text_log_from_detail(detail) + status = run.get("status", "completed") + return {"text": text, "status": status, "finished": True} + + +@router.get("/api/sync/run/{run_id}/orders") +async def sync_run_orders(run_id: str, status: str = "all", page: int = 1, per_page: int = 50, + sort_by: str = "created_at", sort_dir: str = "asc"): + """Get filtered, paginated orders for a sync run (R1).""" + return await sqlite_service.get_run_orders_filtered(run_id, status, page, per_page, + sort_by=sort_by, sort_dir=sort_dir) + + +def _get_articole_terti_for_skus(skus: set) -> dict: + """Query ARTICOLE_TERTI for all active codmat/cantitate/procent per SKU.""" + from .. import database + result = {} + sku_list = list(skus) + conn = database.get_oracle_connection() + try: + with conn.cursor() as cur: + for i in range(0, len(sku_list), 500): + batch = sku_list[i:i+500] + placeholders = ",".join([f":s{j}" for j in range(len(batch))]) + params = {f"s{j}": sku for j, sku in enumerate(batch)} + cur.execute(f""" + SELECT at.sku, at.codmat, at.cantitate_roa, at.procent_pret, + na.denumire + FROM ARTICOLE_TERTI at + LEFT JOIN NOM_ARTICOLE na ON na.codmat = at.codmat AND na.sters = 0 AND na.inactiv = 0 + WHERE at.sku IN ({placeholders}) AND at.activ = 1 AND at.sters = 0 + ORDER BY at.sku, at.codmat + """, params) + for row in cur: + sku = row[0] + if sku not in result: + result[sku] = [] + result[sku].append({ + "codmat": row[1], + "cantitate_roa": float(row[2]) if row[2] else 1, + "procent_pret": float(row[3]) if row[3] else 100, + "denumire": row[4] or "" + }) + finally: + database.pool.release(conn) + return result + + +@router.get("/api/sync/order/{order_number}") +async def order_detail(order_number: str): + """Get order detail with line items (R9), enriched with ARTICOLE_TERTI data.""" + detail = await sqlite_service.get_order_detail(order_number) + if not detail: + return {"error": "Order not found"} + + # Enrich items with ARTICOLE_TERTI mappings from Oracle + items = detail.get("items", []) + skus = {item["sku"] for item in items if item.get("sku")} + if skus: + codmat_map = await asyncio.to_thread(_get_articole_terti_for_skus, skus) + for item in items: + sku = item.get("sku") + if sku and sku in codmat_map: + item["codmat_details"] = codmat_map[sku] + + return detail + + +@router.get("/api/dashboard/orders") +async def dashboard_orders(page: int = 1, per_page: int = 50, + search: str = "", status: str = "all", + sort_by: str = "order_date", sort_dir: str = "desc", + period_days: int = 7): + """Get orders for dashboard, enriched with invoice data. period_days=0 means all time.""" + is_uninvoiced_filter = (status == "UNINVOICED") + + # For UNINVOICED: fetch all IMPORTED orders, then filter post-invoice-check + fetch_status = "IMPORTED" if is_uninvoiced_filter else status + fetch_per_page = 10000 if is_uninvoiced_filter else per_page + fetch_page = 1 if is_uninvoiced_filter else page + + result = await sqlite_service.get_orders( + page=fetch_page, per_page=fetch_per_page, search=search, + status_filter=fetch_status, sort_by=sort_by, sort_dir=sort_dir, + period_days=period_days + ) + + # Enrich imported orders with invoice data from Oracle + all_orders = result["orders"] + imported_orders = [o for o in all_orders if o.get("id_comanda")] + invoice_data = {} + if imported_orders: + id_comanda_list = [o["id_comanda"] for o in imported_orders] + invoice_data = await asyncio.to_thread( + invoice_service.check_invoices_for_orders, id_comanda_list + ) + + for o in all_orders: + idc = o.get("id_comanda") + if idc and idc in invoice_data: + o["invoice"] = invoice_data[idc] + else: + o["invoice"] = None + + # Count uninvoiced (IMPORTED without invoice) + uninvoiced_count = sum( + 1 for o in all_orders + if o.get("status") == "IMPORTED" and not o.get("invoice") + ) + result["counts"]["uninvoiced"] = uninvoiced_count + + # For UNINVOICED filter: apply server-side filtering + pagination + if is_uninvoiced_filter: + filtered = [o for o in all_orders if o.get("status") == "IMPORTED" and not o.get("invoice")] + total = len(filtered) + offset = (page - 1) * per_page + result["orders"] = filtered[offset:offset + per_page] + result["total"] = total + result["page"] = page + result["per_page"] = per_page + result["pages"] = (total + per_page - 1) // per_page if total > 0 else 0 + + return result + + @router.put("/api/sync/schedule") async def update_schedule(config: ScheduleConfig): """Update scheduler configuration.""" diff --git a/api/app/routers/validation.py b/api/app/routers/validation.py index 5d491f8..f1f7c94 100644 --- a/api/app/routers/validation.py +++ b/api/app/routers/validation.py @@ -88,9 +88,9 @@ async def scan_and_validate(): async def get_missing_skus( page: int = Query(1, ge=1), per_page: int = Query(20, ge=1, le=100), - resolved: int = Query(0, ge=0, le=1) + resolved: int = Query(0, ge=-1, le=1) ): - """Get paginated missing SKUs.""" + """Get paginated missing SKUs. resolved=-1 means show all (R10).""" result = await sqlite_service.get_missing_skus_paginated(page, per_page, resolved) # Backward compat: also include 'unresolved' count db = await get_sqlite() @@ -106,7 +106,7 @@ async def get_missing_skus( @router.get("/missing-skus-csv") async def export_missing_skus_csv(): - """Export missing SKUs as CSV.""" + """Export missing SKUs as CSV compatible with mapping import (R8).""" db = await get_sqlite() try: cursor = await db.execute(""" @@ -120,9 +120,9 @@ async def export_missing_skus_csv(): output = io.StringIO() writer = csv.writer(output) - writer.writerow(["sku", "product_name", "first_seen"]) + writer.writerow(["sku", "codmat", "cantitate_roa", "procent_pret", "product_name"]) for row in rows: - writer.writerow([row["sku"], row["product_name"], row["first_seen"]]) + writer.writerow([row["sku"], "", "", "", row["product_name"] or ""]) return StreamingResponse( io.BytesIO(output.getvalue().encode("utf-8-sig")), diff --git a/api/app/services/article_service.py b/api/app/services/article_service.py index f6dc8d9..2a4f1e0 100644 --- a/api/app/services/article_service.py +++ b/api/app/services/article_service.py @@ -15,10 +15,11 @@ def search_articles(query: str, limit: int = 20): with database.pool.acquire() as conn: with conn.cursor() as cur: cur.execute(""" - SELECT id_articol, codmat, denumire + SELECT id_articol, codmat, denumire, um FROM nom_articole WHERE (UPPER(codmat) LIKE UPPER(:q) || '%' OR UPPER(denumire) LIKE '%' || UPPER(:q) || '%') + AND sters = 0 AND inactiv = 0 AND ROWNUM <= :lim ORDER BY CASE WHEN UPPER(codmat) LIKE UPPER(:q) || '%' THEN 0 ELSE 1 END, codmat """, {"q": query, "lim": limit}) diff --git a/api/app/services/import_service.py b/api/app/services/import_service.py index 5278032..c77e5ab 100644 --- a/api/app/services/import_service.py +++ b/api/app/services/import_service.py @@ -44,9 +44,12 @@ def convert_web_date(date_str: str) -> datetime: if not date_str: return datetime.now() try: - return datetime.strptime(date_str[:10], '%Y-%m-%d') + return datetime.strptime(date_str.strip(), '%Y-%m-%d %H:%M:%S') except ValueError: - return datetime.now() + try: + return datetime.strptime(date_str.strip()[:10], '%Y-%m-%d') + except ValueError: + return datetime.now() def format_address_for_oracle(address: str, city: str, region: str) -> str: @@ -78,18 +81,26 @@ def import_single_order(order, id_pol: int = None, id_sectie: int = None) -> dic success: bool id_comanda: int or None id_partener: int or None + id_adresa_facturare: int or None + id_adresa_livrare: int or None error: str or None """ result = { "success": False, "id_comanda": None, "id_partener": None, + "id_adresa_facturare": None, + "id_adresa_livrare": None, "error": None } try: order_number = clean_web_text(order.number) order_date = convert_web_date(order.date) + logger.info( + f"Order {order.number}: raw date={order.date!r} → " + f"parsed={order_date.strftime('%Y-%m-%d %H:%M:%S')}" + ) if database.pool is None: raise RuntimeError("Oracle pool not initialized") @@ -99,14 +110,14 @@ def import_single_order(order, id_pol: int = None, id_sectie: int = None) -> dic id_partener = cur.var(oracledb.DB_TYPE_NUMBER) if order.billing.is_company: - denumire = clean_web_text(order.billing.company_name) + denumire = clean_web_text(order.billing.company_name).upper() cod_fiscal = clean_web_text(order.billing.company_code) or None registru = clean_web_text(order.billing.company_reg) or None is_pj = 1 else: denumire = clean_web_text( - f"{order.billing.firstname} {order.billing.lastname}" - ) + f"{order.billing.lastname} {order.billing.firstname}" + ).upper() cod_fiscal = None registru = None is_pj = 0 @@ -151,6 +162,11 @@ def import_single_order(order, id_pol: int = None, id_sectie: int = None) -> dic ]) addr_livr_id = id_adresa_livr.getvalue() + if addr_fact_id is not None: + result["id_adresa_facturare"] = int(addr_fact_id) + if addr_livr_id is not None: + result["id_adresa_livrare"] = int(addr_livr_id) + # Step 4: Build articles JSON and import order articles_json = build_articles_json(order.items) diff --git a/api/app/services/invoice_service.py b/api/app/services/invoice_service.py new file mode 100644 index 0000000..149f88d --- /dev/null +++ b/api/app/services/invoice_service.py @@ -0,0 +1,43 @@ +import logging +from .. import database + +logger = logging.getLogger(__name__) + + +def check_invoices_for_orders(id_comanda_list: list) -> dict: + """Check which orders have been invoiced in Oracle (vanzari table). + Returns {id_comanda: {facturat, numar_act, serie_act, total_fara_tva, total_tva, total_cu_tva}} + """ + if not id_comanda_list or database.pool is None: + return {} + + result = {} + conn = database.get_oracle_connection() + try: + with conn.cursor() as cur: + for i in range(0, len(id_comanda_list), 500): + batch = id_comanda_list[i:i+500] + placeholders = ",".join([f":c{j}" for j in range(len(batch))]) + params = {f"c{j}": cid for j, cid in enumerate(batch)} + + cur.execute(f""" + SELECT id_comanda, numar_act, serie_act, + total_fara_tva, total_tva, total_cu_tva + FROM vanzari + WHERE id_comanda IN ({placeholders}) AND sters = 0 + """, params) + for row in cur: + result[row[0]] = { + "facturat": True, + "numar_act": row[1], + "serie_act": row[2], + "total_fara_tva": float(row[3]) if row[3] else 0, + "total_tva": float(row[4]) if row[4] else 0, + "total_cu_tva": float(row[5]) if row[5] else 0, + } + except Exception as e: + logger.warning(f"Invoice check failed (table may not exist): {e}") + finally: + database.pool.release(conn) + + return result diff --git a/api/app/services/mapping_service.py b/api/app/services/mapping_service.py index ebae0d7..382e01a 100644 --- a/api/app/services/mapping_service.py +++ b/api/app/services/mapping_service.py @@ -7,23 +7,47 @@ from .. import database logger = logging.getLogger(__name__) -def get_mappings(search: str = "", page: int = 1, per_page: int = 50): - """Get paginated mappings with optional search.""" +def get_mappings(search: str = "", page: int = 1, per_page: int = 50, + sort_by: str = "sku", sort_dir: str = "asc", + show_deleted: bool = False): + """Get paginated mappings with optional search and sorting.""" if database.pool is None: raise HTTPException(status_code=503, detail="Oracle unavailable") offset = (page - 1) * per_page + # Validate and resolve sort parameters + allowed_sort = { + "sku": "at.sku", + "codmat": "at.codmat", + "denumire": "na.denumire", + "um": "na.um", + "cantitate_roa": "at.cantitate_roa", + "procent_pret": "at.procent_pret", + "activ": "at.activ", + } + sort_col = allowed_sort.get(sort_by, "at.sku") + if sort_dir.lower() not in ("asc", "desc"): + sort_dir = "asc" + order_clause = f"{sort_col} {sort_dir}" + # Always add secondary sort to keep groups together + if sort_col not in ("at.sku",): + order_clause += ", at.sku" + order_clause += ", at.codmat" + with database.pool.acquire() as conn: with conn.cursor() as cur: # Build WHERE clause - where = "" + where_clauses = [] params = {} + if not show_deleted: + where_clauses.append("at.sters = 0") if search: - where = """WHERE (UPPER(at.sku) LIKE '%' || UPPER(:search) || '%' + where_clauses.append("""(UPPER(at.sku) LIKE '%' || UPPER(:search) || '%' OR UPPER(at.codmat) LIKE '%' || UPPER(:search) || '%' - OR UPPER(na.denumire) LIKE '%' || UPPER(:search) || '%')""" + OR UPPER(na.denumire) LIKE '%' || UPPER(:search) || '%')""") params["search"] = search + where = "WHERE " + " AND ".join(where_clauses) if where_clauses else "" # Count total count_sql = f""" @@ -36,13 +60,13 @@ def get_mappings(search: str = "", page: int = 1, per_page: int = 50): # Get page data_sql = f""" - SELECT at.sku, at.codmat, na.denumire, at.cantitate_roa, - at.procent_pret, at.activ, + SELECT at.sku, at.codmat, na.denumire, na.um, at.cantitate_roa, + at.procent_pret, at.activ, at.sters, TO_CHAR(at.data_creare, 'YYYY-MM-DD HH24:MI') as data_creare FROM ARTICOLE_TERTI at LEFT JOIN nom_articole na ON na.codmat = at.codmat {where} - ORDER BY at.sku, at.codmat + ORDER BY {order_clause} OFFSET :offset ROWS FETCH NEXT :per_page ROWS ONLY """ params["offset"] = offset @@ -68,8 +92,8 @@ def create_mapping(sku: str, codmat: str, cantitate_roa: float = 1, procent_pret with database.pool.acquire() as conn: with conn.cursor() as cur: cur.execute(""" - INSERT INTO ARTICOLE_TERTI (sku, codmat, cantitate_roa, procent_pret, activ, data_creare, id_util_creare) - VALUES (:sku, :codmat, :cantitate_roa, :procent_pret, 1, SYSDATE, -3) + INSERT INTO ARTICOLE_TERTI (sku, codmat, cantitate_roa, procent_pret, activ, sters, data_creare, id_util_creare) + VALUES (:sku, :codmat, :cantitate_roa, :procent_pret, 1, 0, SYSDATE, -3) """, {"sku": sku, "codmat": codmat, "cantitate_roa": cantitate_roa, "procent_pret": procent_pret}) conn.commit() return {"sku": sku, "codmat": codmat} @@ -108,8 +132,68 @@ def update_mapping(sku: str, codmat: str, cantitate_roa: float = None, procent_p return cur.rowcount > 0 def delete_mapping(sku: str, codmat: str): - """Soft delete (set activ=0).""" - return update_mapping(sku, codmat, activ=0) + """Soft delete (set sters=1).""" + if database.pool is None: + raise HTTPException(status_code=503, detail="Oracle unavailable") + + with database.pool.acquire() as conn: + with conn.cursor() as cur: + cur.execute(""" + UPDATE ARTICOLE_TERTI SET sters = 1, data_modif = SYSDATE + WHERE sku = :sku AND codmat = :codmat + """, {"sku": sku, "codmat": codmat}) + conn.commit() + return cur.rowcount > 0 + +def edit_mapping(old_sku: str, old_codmat: str, new_sku: str, new_codmat: str, + cantitate_roa: float = 1, procent_pret: float = 100): + """Edit a mapping. If PK changed, soft-delete old and insert new.""" + if database.pool is None: + raise HTTPException(status_code=503, detail="Oracle unavailable") + + if old_sku == new_sku and old_codmat == new_codmat: + # Simple update - only cantitate/procent changed + return update_mapping(new_sku, new_codmat, cantitate_roa, procent_pret) + else: + # PK changed: soft-delete old, upsert new (MERGE handles existing soft-deleted target) + with database.pool.acquire() as conn: + with conn.cursor() as cur: + # Mark old record as deleted + cur.execute(""" + UPDATE ARTICOLE_TERTI SET sters = 1, data_modif = SYSDATE + WHERE sku = :sku AND codmat = :codmat + """, {"sku": old_sku, "codmat": old_codmat}) + # Upsert new record (MERGE in case target PK exists as soft-deleted) + cur.execute(""" + MERGE INTO ARTICOLE_TERTI t + USING (SELECT :sku AS sku, :codmat AS codmat FROM DUAL) s + ON (t.sku = s.sku AND t.codmat = s.codmat) + WHEN MATCHED THEN UPDATE SET + cantitate_roa = :cantitate_roa, + procent_pret = :procent_pret, + activ = 1, sters = 0, + data_modif = SYSDATE + WHEN NOT MATCHED THEN INSERT + (sku, codmat, cantitate_roa, procent_pret, activ, sters, data_creare, id_util_creare) + VALUES (:sku, :codmat, :cantitate_roa, :procent_pret, 1, 0, SYSDATE, -3) + """, {"sku": new_sku, "codmat": new_codmat, + "cantitate_roa": cantitate_roa, "procent_pret": procent_pret}) + conn.commit() + return True + +def restore_mapping(sku: str, codmat: str): + """Restore a soft-deleted mapping (set sters=0).""" + if database.pool is None: + raise HTTPException(status_code=503, detail="Oracle unavailable") + + with database.pool.acquire() as conn: + with conn.cursor() as cur: + cur.execute(""" + UPDATE ARTICOLE_TERTI SET sters = 0, data_modif = SYSDATE + WHERE sku = :sku AND codmat = :codmat + """, {"sku": sku, "codmat": codmat}) + conn.commit() + return cur.rowcount > 0 def import_csv(file_content: str): """Import mappings from CSV content. Returns summary.""" @@ -143,10 +227,11 @@ def import_csv(file_content: str): cantitate_roa = :cantitate_roa, procent_pret = :procent_pret, activ = 1, + sters = 0, data_modif = SYSDATE WHEN NOT MATCHED THEN INSERT - (sku, codmat, cantitate_roa, procent_pret, activ, data_creare, id_util_creare) - VALUES (:sku, :codmat, :cantitate_roa, :procent_pret, 1, SYSDATE, -3) + (sku, codmat, cantitate_roa, procent_pret, activ, sters, data_creare, id_util_creare) + VALUES (:sku, :codmat, :cantitate_roa, :procent_pret, 1, 0, SYSDATE, -3) """, {"sku": sku, "codmat": codmat, "cantitate_roa": cantitate, "procent_pret": procent}) # Check if it was insert or update by rowcount @@ -172,7 +257,7 @@ def export_csv(): with conn.cursor() as cur: cur.execute(""" SELECT sku, codmat, cantitate_roa, procent_pret, activ - FROM ARTICOLE_TERTI ORDER BY sku, codmat + FROM ARTICOLE_TERTI WHERE sters = 0 ORDER BY sku, codmat """) for row in cur: writer.writerow(row) diff --git a/api/app/services/sqlite_service.py b/api/app/services/sqlite_service.py index a8bf136..fe70ef3 100644 --- a/api/app/services/sqlite_service.py +++ b/api/app/services/sqlite_service.py @@ -41,21 +41,48 @@ async def update_sync_run(run_id: str, status: str, total_orders: int = 0, await db.close() -async def add_import_order(sync_run_id: str, order_number: str, order_date: str, - customer_name: str, status: str, id_comanda: int = None, - id_partener: int = None, error_message: str = None, - missing_skus: list = None, items_count: int = 0): - """Record an individual order import result.""" +async def upsert_order(sync_run_id: str, order_number: str, order_date: str, + customer_name: str, status: str, id_comanda: int = None, + id_partener: int = None, error_message: str = None, + missing_skus: list = None, items_count: int = 0): + """Upsert a single order — one row per order_number, status updated in place.""" db = await get_sqlite() try: await db.execute(""" - INSERT INTO import_orders - (sync_run_id, order_number, order_date, customer_name, status, - id_comanda, id_partener, error_message, missing_skus, items_count) + INSERT INTO orders + (order_number, order_date, customer_name, status, + id_comanda, id_partener, error_message, missing_skus, items_count, + last_sync_run_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - """, (sync_run_id, order_number, order_date, customer_name, status, + ON CONFLICT(order_number) DO UPDATE SET + status = excluded.status, + error_message = excluded.error_message, + missing_skus = excluded.missing_skus, + items_count = excluded.items_count, + id_comanda = COALESCE(excluded.id_comanda, orders.id_comanda), + id_partener = COALESCE(excluded.id_partener, orders.id_partener), + times_skipped = CASE WHEN excluded.status = 'SKIPPED' + THEN orders.times_skipped + 1 + ELSE orders.times_skipped END, + last_sync_run_id = excluded.last_sync_run_id, + updated_at = datetime('now') + """, (order_number, order_date, customer_name, status, id_comanda, id_partener, error_message, - json.dumps(missing_skus) if missing_skus else None, items_count)) + json.dumps(missing_skus) if missing_skus else None, + items_count, sync_run_id)) + await db.commit() + finally: + await db.close() + + +async def add_sync_run_order(sync_run_id: str, order_number: str, status_at_run: str): + """Record that this run processed this order (junction table).""" + db = await get_sqlite() + try: + await db.execute(""" + INSERT OR IGNORE INTO sync_run_orders (sync_run_id, order_number, status_at_run) + VALUES (?, ?, ?) + """, (sync_run_id, order_number, status_at_run)) await db.commit() finally: await db.close() @@ -71,7 +98,6 @@ async def track_missing_sku(sku: str, product_name: str = "", INSERT OR IGNORE INTO missing_skus (sku, product_name) VALUES (?, ?) """, (sku, product_name)) - # Update context columns (always update with latest data) if order_count or order_numbers or customers: await db.execute(""" UPDATE missing_skus SET @@ -99,24 +125,35 @@ async def resolve_missing_sku(sku: str): async def get_missing_skus_paginated(page: int = 1, per_page: int = 20, resolved: int = 0): - """Get paginated missing SKUs.""" + """Get paginated missing SKUs. resolved=-1 means show all.""" db = await get_sqlite() try: offset = (page - 1) * per_page - cursor = await db.execute( - "SELECT COUNT(*) FROM missing_skus WHERE resolved = ?", (resolved,) - ) - total = (await cursor.fetchone())[0] + if resolved == -1: + cursor = await db.execute("SELECT COUNT(*) FROM missing_skus") + total = (await cursor.fetchone())[0] + cursor = await db.execute(""" + SELECT sku, product_name, first_seen, resolved, resolved_at, + order_count, order_numbers, customers + FROM missing_skus + ORDER BY resolved ASC, order_count DESC, first_seen DESC + LIMIT ? OFFSET ? + """, (per_page, offset)) + else: + cursor = await db.execute( + "SELECT COUNT(*) FROM missing_skus WHERE resolved = ?", (resolved,) + ) + total = (await cursor.fetchone())[0] + cursor = await db.execute(""" + SELECT sku, product_name, first_seen, resolved, resolved_at, + order_count, order_numbers, customers + FROM missing_skus + WHERE resolved = ? + ORDER BY order_count DESC, first_seen DESC + LIMIT ? OFFSET ? + """, (resolved, per_page, offset)) - cursor = await db.execute(""" - SELECT sku, product_name, first_seen, resolved, resolved_at, - order_count, order_numbers, customers - FROM missing_skus - WHERE resolved = ? - ORDER BY order_count DESC, first_seen DESC - LIMIT ? OFFSET ? - """, (resolved, per_page, offset)) rows = await cursor.fetchall() return { @@ -157,7 +194,7 @@ async def get_sync_runs(page: int = 1, per_page: int = 20): async def get_sync_run_detail(run_id: str): - """Get details for a specific sync run including its orders.""" + """Get details for a specific sync run including its orders via sync_run_orders.""" db = await get_sqlite() try: cursor = await db.execute( @@ -168,9 +205,10 @@ async def get_sync_run_detail(run_id: str): return None cursor = await db.execute(""" - SELECT * FROM import_orders - WHERE sync_run_id = ? - ORDER BY created_at + SELECT o.* FROM orders o + INNER JOIN sync_run_orders sro ON sro.order_number = o.order_number + WHERE sro.sync_run_id = ? + ORDER BY o.order_date """, (run_id,)) orders = await cursor.fetchall() @@ -186,42 +224,34 @@ async def get_dashboard_stats(): """Get stats for the dashboard.""" db = await get_sqlite() try: - # Total imported cursor = await db.execute( - "SELECT COUNT(*) FROM import_orders WHERE status = 'IMPORTED'" + "SELECT COUNT(*) FROM orders WHERE status = 'IMPORTED'" ) imported = (await cursor.fetchone())[0] - # Total skipped cursor = await db.execute( - "SELECT COUNT(*) FROM import_orders WHERE status = 'SKIPPED'" + "SELECT COUNT(*) FROM orders WHERE status = 'SKIPPED'" ) skipped = (await cursor.fetchone())[0] - # Total errors cursor = await db.execute( - "SELECT COUNT(*) FROM import_orders WHERE status = 'ERROR'" + "SELECT COUNT(*) FROM orders WHERE status = 'ERROR'" ) errors = (await cursor.fetchone())[0] - # Missing SKUs (unresolved) cursor = await db.execute( "SELECT COUNT(*) FROM missing_skus WHERE resolved = 0" ) missing = (await cursor.fetchone())[0] - # Article stats from last sync - cursor = await db.execute(""" - SELECT COUNT(DISTINCT sku) FROM missing_skus - """) + cursor = await db.execute("SELECT COUNT(DISTINCT sku) FROM missing_skus") total_missing_skus = (await cursor.fetchone())[0] - cursor = await db.execute(""" - SELECT COUNT(DISTINCT sku) FROM missing_skus WHERE resolved = 0 - """) + cursor = await db.execute( + "SELECT COUNT(DISTINCT sku) FROM missing_skus WHERE resolved = 0" + ) unresolved_skus = (await cursor.fetchone())[0] - # Last sync run cursor = await db.execute(""" SELECT * FROM sync_runs ORDER BY started_at DESC LIMIT 1 """) @@ -262,3 +292,266 @@ async def set_scheduler_config(key: str, value: str): await db.commit() finally: await db.close() + + +# ── web_products ───────────────────────────────── + +async def upsert_web_product(sku: str, product_name: str): + """Insert or update a web product, incrementing order_count.""" + db = await get_sqlite() + try: + await db.execute(""" + INSERT INTO web_products (sku, product_name, order_count) + VALUES (?, ?, 1) + ON CONFLICT(sku) DO UPDATE SET + product_name = COALESCE(NULLIF(excluded.product_name, ''), web_products.product_name), + last_seen = datetime('now'), + order_count = web_products.order_count + 1 + """, (sku, product_name)) + await db.commit() + finally: + await db.close() + + +async def get_web_product_name(sku: str) -> str: + """Lookup product name by SKU.""" + db = await get_sqlite() + try: + cursor = await db.execute( + "SELECT product_name FROM web_products WHERE sku = ?", (sku,) + ) + row = await cursor.fetchone() + return row["product_name"] if row else "" + finally: + await db.close() + + +async def get_web_products_batch(skus: list) -> dict: + """Batch lookup product names by SKU list. Returns {sku: product_name}.""" + if not skus: + return {} + db = await get_sqlite() + try: + placeholders = ",".join("?" for _ in skus) + cursor = await db.execute( + f"SELECT sku, product_name FROM web_products WHERE sku IN ({placeholders})", + list(skus) + ) + rows = await cursor.fetchall() + return {row["sku"]: row["product_name"] for row in rows} + finally: + await db.close() + + +# ── order_items ────────────────────────────────── + +async def add_order_items(order_number: str, items: list): + """Bulk insert order items. Uses INSERT OR IGNORE — PK is (order_number, sku).""" + if not items: + return + db = await get_sqlite() + try: + await db.executemany(""" + INSERT OR IGNORE INTO order_items + (order_number, sku, product_name, quantity, price, vat, + mapping_status, codmat, id_articol, cantitate_roa) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, [ + (order_number, + item.get("sku"), item.get("product_name"), + item.get("quantity"), item.get("price"), item.get("vat"), + item.get("mapping_status"), item.get("codmat"), + item.get("id_articol"), item.get("cantitate_roa")) + for item in items + ]) + await db.commit() + finally: + await db.close() + + +async def get_order_items(order_number: str) -> list: + """Fetch items for one order.""" + db = await get_sqlite() + try: + cursor = await db.execute(""" + SELECT * FROM order_items + WHERE order_number = ? + ORDER BY sku + """, (order_number,)) + rows = await cursor.fetchall() + return [dict(row) for row in rows] + finally: + await db.close() + + +async def get_order_detail(order_number: str) -> dict: + """Get full order detail: order metadata + items.""" + db = await get_sqlite() + try: + cursor = await db.execute(""" + SELECT * FROM orders WHERE order_number = ? + """, (order_number,)) + order = await cursor.fetchone() + if not order: + return None + + cursor = await db.execute(""" + SELECT * FROM order_items WHERE order_number = ? + ORDER BY sku + """, (order_number,)) + items = await cursor.fetchall() + + return { + "order": dict(order), + "items": [dict(i) for i in items] + } + finally: + await db.close() + + +async def get_run_orders_filtered(run_id: str, status_filter: str = "all", + page: int = 1, per_page: int = 50, + sort_by: str = "order_date", sort_dir: str = "asc"): + """Get paginated orders for a run via sync_run_orders junction table.""" + db = await get_sqlite() + try: + where = "WHERE sro.sync_run_id = ?" + params = [run_id] + + if status_filter and status_filter != "all": + where += " AND UPPER(o.status) = ?" + params.append(status_filter.upper()) + + allowed_sort = {"order_date", "order_number", "customer_name", "items_count", + "status", "first_seen_at", "updated_at"} + if sort_by not in allowed_sort: + sort_by = "order_date" + if sort_dir.lower() not in ("asc", "desc"): + sort_dir = "asc" + + cursor = await db.execute( + f"SELECT COUNT(*) FROM orders o INNER JOIN sync_run_orders sro " + f"ON sro.order_number = o.order_number {where}", params + ) + total = (await cursor.fetchone())[0] + + offset = (page - 1) * per_page + cursor = await db.execute(f""" + SELECT o.* FROM orders o + INNER JOIN sync_run_orders sro ON sro.order_number = o.order_number + {where} + ORDER BY o.{sort_by} {sort_dir} + LIMIT ? OFFSET ? + """, params + [per_page, offset]) + rows = await cursor.fetchall() + + cursor = await db.execute(""" + SELECT o.status, COUNT(*) as cnt + FROM orders o + INNER JOIN sync_run_orders sro ON sro.order_number = o.order_number + WHERE sro.sync_run_id = ? + GROUP BY o.status + """, (run_id,)) + status_counts = {row["status"]: row["cnt"] for row in await cursor.fetchall()} + + return { + "orders": [dict(r) for r in rows], + "total": total, + "page": page, + "per_page": per_page, + "pages": (total + per_page - 1) // per_page if total > 0 else 0, + "counts": { + "imported": status_counts.get("IMPORTED", 0), + "skipped": status_counts.get("SKIPPED", 0), + "error": status_counts.get("ERROR", 0), + "total": sum(status_counts.values()) + } + } + finally: + await db.close() + + +async def get_orders(page: int = 1, per_page: int = 50, + search: str = "", status_filter: str = "all", + sort_by: str = "order_date", sort_dir: str = "desc", + period_days: int = 7): + """Get orders with filters, sorting, and period. period_days=0 means all time.""" + db = await get_sqlite() + try: + where_clauses = [] + params = [] + + if period_days and period_days > 0: + where_clauses.append("order_date >= date('now', ?)") + params.append(f"-{period_days} days") + + if search: + where_clauses.append("(order_number LIKE ? OR customer_name LIKE ?)") + params.extend([f"%{search}%", f"%{search}%"]) + + if status_filter and status_filter not in ("all", "UNINVOICED"): + where_clauses.append("UPPER(status) = ?") + params.append(status_filter.upper()) + + where = ("WHERE " + " AND ".join(where_clauses)) if where_clauses else "" + + allowed_sort = {"order_date", "order_number", "customer_name", "items_count", + "status", "first_seen_at", "updated_at"} + if sort_by not in allowed_sort: + sort_by = "order_date" + if sort_dir.lower() not in ("asc", "desc"): + sort_dir = "desc" + + cursor = await db.execute(f"SELECT COUNT(*) FROM orders {where}", params) + total = (await cursor.fetchone())[0] + + offset = (page - 1) * per_page + cursor = await db.execute(f""" + SELECT * FROM orders + {where} + ORDER BY {sort_by} {sort_dir} + LIMIT ? OFFSET ? + """, params + [per_page, offset]) + rows = await cursor.fetchall() + + # Counts by status (on full period, not just this page) + cursor = await db.execute(f""" + SELECT status, COUNT(*) as cnt FROM orders + {where} + GROUP BY status + """, params) + status_counts = {row["status"]: row["cnt"] for row in await cursor.fetchall()} + + return { + "orders": [dict(r) for r in rows], + "total": total, + "page": page, + "per_page": per_page, + "pages": (total + per_page - 1) // per_page if total > 0 else 0, + "counts": { + "imported": status_counts.get("IMPORTED", 0), + "skipped": status_counts.get("SKIPPED", 0), + "error": status_counts.get("ERROR", 0), + "total": sum(status_counts.values()) + } + } + finally: + await db.close() + + +async def update_import_order_addresses(order_number: str, + id_adresa_facturare: int = None, + id_adresa_livrare: int = None): + """Update ROA address IDs on an order record.""" + db = await get_sqlite() + try: + await db.execute(""" + UPDATE orders SET + id_adresa_facturare = ?, + id_adresa_livrare = ?, + updated_at = datetime('now') + WHERE order_number = ? + """, (id_adresa_facturare, id_adresa_livrare, order_number)) + await db.commit() + finally: + await db.close() diff --git a/api/app/services/sync_service.py b/api/app/services/sync_service.py index 58061d0..e5e1c76 100644 --- a/api/app/services/sync_service.py +++ b/api/app/services/sync_service.py @@ -16,6 +16,9 @@ _current_sync = None # dict with run_id, status, progress info # SSE subscriber system _subscribers: list[asyncio.Queue] = [] +# In-memory text log buffer per run +_run_logs: dict[str, list[str]] = {} + def subscribe() -> asyncio.Queue: """Subscribe to sync events. Returns a queue that will receive event dicts.""" @@ -32,6 +35,22 @@ def unsubscribe(q: asyncio.Queue): pass +def _log_line(run_id: str, message: str): + """Append a timestamped line to the in-memory log buffer.""" + if run_id not in _run_logs: + _run_logs[run_id] = [] + ts = datetime.now().strftime("%H:%M:%S") + _run_logs[run_id].append(f"[{ts}] {message}") + + +def get_run_text_log(run_id: str) -> str | None: + """Return the accumulated text log for a run, or None if not found.""" + lines = _run_logs.get(run_id) + if lines is None: + return None + return "\n".join(lines) + + async def _emit(event: dict): """Push an event to all subscriber queues.""" for q in _subscribers: @@ -87,13 +106,30 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None _current_sync["progress"] = "Reading JSON files..." await _emit({"type": "phase", "run_id": run_id, "message": "Reading JSON files..."}) + started_dt = datetime.now() + _run_logs[run_id] = [ + f"=== Sync Run {run_id} ===", + f"Inceput: {started_dt.strftime('%d.%m.%Y %H:%M:%S')}", + "" + ] + _log_line(run_id, "Citire fisiere JSON...") + try: - # Step 1: Read orders + # Step 1: Read orders and sort chronologically (oldest first - R3) orders, json_count = order_reader.read_json_orders() + orders.sort(key=lambda o: o.date or '') await sqlite_service.create_sync_run(run_id, json_count) await _emit({"type": "phase", "run_id": run_id, "message": f"Found {len(orders)} orders in {json_count} files"}) + _log_line(run_id, f"Gasite {len(orders)} comenzi in {json_count} fisiere") + + # Populate web_products catalog from all orders (R4) + for order in orders: + for item in order.items: + if item.sku and item.name: + await sqlite_service.upsert_web_product(item.sku, item.name) if not orders: + _log_line(run_id, "Nicio comanda gasita.") await sqlite_service.update_sync_run(run_id, "completed", 0, 0, 0, 0) summary = {"run_id": run_id, "status": "completed", "message": "No orders found", "json_files": json_count} await _emit({"type": "completed", "run_id": run_id, "summary": summary}) @@ -114,6 +150,7 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None importable, skipped = validation_service.classify_orders(orders, validation) await _emit({"type": "phase", "run_id": run_id, "message": f"{len(importable)} importable, {len(skipped)} skipped (missing SKUs)"}) + _log_line(run_id, f"Validare SKU-uri: {len(importable)} importabile, {len(skipped)} nemapate") # Step 2c: Build SKU context from skipped orders sku_context = {} # {sku: {"orders": [], "customers": []}} @@ -148,9 +185,13 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None # Step 2d: Pre-validate prices for importable articles id_pol = id_pol or settings.ID_POL + id_sectie = id_sectie or settings.ID_SECTIE + logger.info(f"Sync params: ID_POL={id_pol}, ID_SECTIE={id_sectie}") + _log_line(run_id, f"Parametri import: ID_POL={id_pol}, ID_SECTIE={id_sectie}") if id_pol and importable: _current_sync["progress"] = "Validating prices..." await _emit({"type": "phase", "run_id": run_id, "message": "Validating prices..."}) + _log_line(run_id, "Validare preturi...") # Gather all CODMATs from importable orders all_codmats = set() for order in importable: @@ -175,11 +216,11 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None price_result["missing_price"], id_pol ) - # Step 3: Record skipped orders + emit events + # Step 3: Record skipped orders + emit events + store items for order, missing_skus in skipped: customer = order.billing.company_name or \ f"{order.billing.firstname} {order.billing.lastname}" - await sqlite_service.add_import_order( + await sqlite_service.upsert_order( sync_run_id=run_id, order_number=order.number, order_date=order.date, @@ -188,9 +229,24 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None missing_skus=missing_skus, items_count=len(order.items) ) + await sqlite_service.add_sync_run_order(run_id, order.number, "SKIPPED") + # Store order items with mapping status (R9) + order_items_data = [] + for item in order.items: + ms = "missing" if item.sku in validation["missing"] else \ + "mapped" if item.sku in validation["mapped"] else "direct" + order_items_data.append({ + "sku": item.sku, "product_name": item.name, + "quantity": item.quantity, "price": item.price, "vat": item.vat, + "mapping_status": ms, "codmat": None, "id_articol": None, + "cantitate_roa": None + }) + await sqlite_service.add_order_items(order.number, order_items_data) + _log_line(run_id, f"#{order.number} [{order.date or '?'}] {customer} → OMIS (lipsa: {', '.join(missing_skus)})") await _emit({ "type": "order_result", "run_id": run_id, "order_number": order.number, "customer_name": customer, + "order_date": order.date, "status": "SKIPPED", "missing_skus": missing_skus, "items_count": len(order.items), "progress": f"0/{len(importable)}" }) @@ -210,9 +266,20 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None customer = order.billing.company_name or \ f"{order.billing.firstname} {order.billing.lastname}" + # Build order items data for storage (R9) + order_items_data = [] + for item in order.items: + ms = "mapped" if item.sku in validation["mapped"] else "direct" + order_items_data.append({ + "sku": item.sku, "product_name": item.name, + "quantity": item.quantity, "price": item.price, "vat": item.vat, + "mapping_status": ms, "codmat": None, "id_articol": None, + "cantitate_roa": None + }) + if result["success"]: imported_count += 1 - await sqlite_service.add_import_order( + await sqlite_service.upsert_order( sync_run_id=run_id, order_number=order.number, order_date=order.date, @@ -222,15 +289,25 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None id_partener=result["id_partener"], items_count=len(order.items) ) + await sqlite_service.add_sync_run_order(run_id, order.number, "IMPORTED") + # Store ROA address IDs (R9) + await sqlite_service.update_import_order_addresses( + order.number, + id_adresa_facturare=result.get("id_adresa_facturare"), + id_adresa_livrare=result.get("id_adresa_livrare") + ) + await sqlite_service.add_order_items(order.number, order_items_data) + _log_line(run_id, f"#{order.number} [{order.date or '?'}] {customer} → IMPORTAT (ID: {result['id_comanda']})") await _emit({ "type": "order_result", "run_id": run_id, "order_number": order.number, "customer_name": customer, + "order_date": order.date, "status": "IMPORTED", "items_count": len(order.items), "id_comanda": result["id_comanda"], "progress": progress_str }) else: error_count += 1 - await sqlite_service.add_import_order( + await sqlite_service.upsert_order( sync_run_id=run_id, order_number=order.number, order_date=order.date, @@ -240,9 +317,13 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None error_message=result["error"], items_count=len(order.items) ) + await sqlite_service.add_sync_run_order(run_id, order.number, "ERROR") + await sqlite_service.add_order_items(order.number, order_items_data) + _log_line(run_id, f"#{order.number} [{order.date or '?'}] {customer} → EROARE: {result['error']}") await _emit({ "type": "order_result", "run_id": run_id, "order_number": order.number, "customer_name": customer, + "order_date": order.date, "status": "ERROR", "error_message": result["error"], "items_count": len(order.items), "progress": progress_str }) @@ -275,10 +356,16 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None f"{len(skipped)} skipped, {error_count} errors" ) await _emit({"type": "completed", "run_id": run_id, "summary": summary}) + + duration = (datetime.now() - started_dt).total_seconds() + _log_line(run_id, "") + _run_logs[run_id].append(f"Finalizat: {imported_count} importate, {len(skipped)} nemapate, {error_count} erori din {len(orders)} comenzi | Durata: {int(duration)}s") + return summary except Exception as e: logger.error(f"Sync {run_id} failed: {e}") + _log_line(run_id, f"EROARE FATALA: {e}") await sqlite_service.update_sync_run(run_id, "failed", 0, 0, 0, 1, error_message=str(e)) _current_sync["error"] = str(e) await _emit({"type": "failed", "run_id": run_id, "error": str(e)}) @@ -291,6 +378,11 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None _current_sync = None asyncio.ensure_future(_clear_current_sync()) + async def _clear_run_logs(): + await asyncio.sleep(300) # 5 minutes + _run_logs.pop(run_id, None) + asyncio.ensure_future(_clear_run_logs()) + def stop_sync(): """Signal sync to stop. Currently sync runs to completion.""" diff --git a/api/app/services/validation_service.py b/api/app/services/validation_service.py index 7798bac..e014e0b 100644 --- a/api/app/services/validation_service.py +++ b/api/app/services/validation_service.py @@ -29,7 +29,7 @@ def validate_skus(skus: set[str]) -> dict: # Check ARTICOLE_TERTI cur.execute(f""" SELECT DISTINCT sku FROM ARTICOLE_TERTI - WHERE sku IN ({placeholders}) AND activ = 1 + WHERE sku IN ({placeholders}) AND activ = 1 AND sters = 0 """, params) for row in cur: mapped.add(row[0]) @@ -41,7 +41,7 @@ def validate_skus(skus: set[str]) -> dict: params2 = {f"n{j}": sku for j, sku in enumerate(remaining)} cur.execute(f""" SELECT DISTINCT codmat FROM NOM_ARTICOLE - WHERE codmat IN ({placeholders2}) + WHERE codmat IN ({placeholders2}) AND sters = 0 AND inactiv = 0 """, params2) for row in cur: direct.add(row[0]) diff --git a/api/app/static/css/style.css b/api/app/static/css/style.css index 9f8c226..31789c2 100644 --- a/api/app/static/css/style.css +++ b/api/app/static/css/style.css @@ -111,25 +111,6 @@ body { .badge-pending { background-color: #94a3b8; } .badge-ready { background-color: #3b82f6; } -/* Stat cards */ -.stat-card { - text-align: center; - padding: 1rem; -} - -.stat-card .stat-value { - font-size: 1.75rem; - font-weight: 700; - line-height: 1.2; -} - -.stat-card .stat-label { - font-size: 0.8rem; - color: #64748b; - text-transform: uppercase; - letter-spacing: 0.05em; -} - /* Tables */ .table { font-size: 0.875rem; @@ -213,65 +194,20 @@ body { justify-content: center; } -/* Live Feed */ -.live-feed { - max-height: 300px; - overflow-y: auto; +/* Log viewer */ +.log-viewer { font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; font-size: 0.8125rem; - scroll-behavior: smooth; -} - -.feed-entry { - padding: 0.35rem 0.75rem; - border-bottom: 1px solid #f1f5f9; - display: flex; - align-items: baseline; - gap: 0.5rem; -} - -.feed-entry:last-child { - border-bottom: none; -} - -.feed-entry.phase { - background-color: #eff6ff; - color: #1e40af; -} - -.feed-entry.error { - background-color: #fef2f2; - color: #991b1b; -} - -.feed-entry.success { - color: #166534; -} - -.feed-entry .feed-time { - color: #94a3b8; - white-space: nowrap; - min-width: 5rem; -} - -.feed-entry .feed-icon { - min-width: 1.25rem; - text-align: center; -} - -.feed-entry .feed-msg { - flex: 1; - word-break: break-word; -} - -/* Live pulse animation */ -.live-pulse { - animation: pulse 1.5s ease-in-out infinite; -} - -@keyframes pulse { - 0%, 100% { opacity: 1; } - 50% { opacity: 0.5; } + line-height: 1.5; + max-height: 600px; + overflow-y: auto; + padding: 1rem; + margin: 0; + background-color: #1e293b; + color: #e2e8f0; + white-space: pre-wrap; + word-wrap: break-word; + border-radius: 0 0 0.5rem 0.5rem; } /* Clickable table rows */ @@ -282,3 +218,87 @@ body { .table-hover tbody tr[data-href]:hover { background-color: #e2e8f0; } + +/* Sortable table headers (R7) */ +.sortable { + cursor: pointer; + user-select: none; +} +.sortable:hover { + background-color: #f1f5f9; +} +.sort-icon { + font-size: 0.75rem; + margin-left: 0.25rem; + color: #3b82f6; +} + +/* SKU group visual grouping (R6) */ +.sku-group-even { + /* default background */ +} +.sku-group-odd { + background-color: #f8fafc; +} + +/* Editable cells */ +.editable { + cursor: pointer; +} +.editable:hover { + background-color: #e2e8f0; +} + +/* Order detail modal items */ +.modal-lg .table-sm td, +.modal-lg .table-sm th { + font-size: 0.8125rem; + padding: 0.35rem 0.5rem; +} + +/* Filter button badges */ +#orderFilterBtns .badge { + font-size: 0.7rem; +} + +/* Modal stacking for quickMap over orderDetail */ +#quickMapModal { + z-index: 1060; +} +#quickMapModal + .modal-backdrop, +.modal-backdrop ~ .modal-backdrop { + z-index: 1055; +} + +/* Deleted mapping rows */ +tr.mapping-deleted td { + text-decoration: line-through; + opacity: 0.5; +} + +/* Map icon button (minimal, no border) */ +.btn-map-icon { + color: #3b82f6; + padding: 0.1rem 0.25rem; + cursor: pointer; + font-size: 1rem; + text-decoration: none; +} +.btn-map-icon:hover { + color: #1d4ed8; +} + +/* Last sync summary card columns */ +.last-sync-col { + border-right: 1px solid #e2e8f0; +} + +/* Dashboard filter badges */ +#dashFilterBtns .badge { + font-size: 0.7rem; +} + +/* Cursor pointer utility */ +.cursor-pointer { + cursor: pointer; +} diff --git a/api/app/static/js/dashboard.js b/api/app/static/js/dashboard.js index 1a4a8ee..2ab12f1 100644 --- a/api/app/static/js/dashboard.js +++ b/api/app/static/js/dashboard.js @@ -1,76 +1,38 @@ let refreshInterval = null; -let currentMapSku = ''; -let acTimeout = null; +let dashPage = 1; +let dashFilter = 'all'; +let dashSearch = ''; +let dashSortCol = 'order_date'; +let dashSortDir = 'desc'; +let dashSearchTimeout = null; +let dashPeriodDays = 7; +let currentQmSku = ''; +let currentQmOrderNumber = ''; +let qmAcTimeout = null; +let syncEventSource = null; document.addEventListener('DOMContentLoaded', () => { - loadDashboard(); - // Auto-refresh every 10 seconds - refreshInterval = setInterval(loadDashboard, 10000); - - const input = document.getElementById('mapCodmat'); - if (input) { - input.addEventListener('input', () => { - clearTimeout(acTimeout); - acTimeout = setTimeout(() => autocompleteMap(input.value), 250); - }); - input.addEventListener('blur', () => { - setTimeout(() => document.getElementById('mapAutocomplete').classList.add('d-none'), 200); - }); - } + loadSchedulerStatus(); + loadSyncStatus(); + loadLastSync(); + loadDashOrders(); + refreshInterval = setInterval(() => { + loadSyncStatus(); + }, 10000); }); -async function loadDashboard() { - await Promise.all([ - loadSyncStatus(), - loadSyncHistory(), - loadMissingSkus(), - loadSchedulerStatus() - ]); -} +// ── Sync Status ────────────────────────────────── async function loadSyncStatus() { try { const res = await fetch('/api/sync/status'); const data = await res.json(); - const stats = data.stats || {}; - - // Order-level stat cards from sync status - document.getElementById('stat-imported').textContent = stats.imported != null ? stats.imported : 0; - document.getElementById('stat-skipped').textContent = stats.skipped != null ? stats.skipped : 0; - document.getElementById('stat-errors').textContent = stats.errors != null ? stats.errors : 0; - - // Article-level stats from sync status - if (stats.total_tracked_skus != null) { - document.getElementById('stat-total-skus').textContent = stats.total_tracked_skus; - } - if (stats.unresolved_skus != null) { - document.getElementById('stat-missing-skus').textContent = stats.unresolved_skus; - const total = stats.total_tracked_skus || 0; - const unresolved = stats.unresolved_skus || 0; - document.getElementById('stat-mapped-skus').textContent = total - unresolved; - } - - // Restore scan-derived stats from sessionStorage (preserved across auto-refresh) - const scanData = getScanData(); - if (scanData) { - document.getElementById('stat-new').textContent = scanData.new_orders != null ? scanData.new_orders : (scanData.total_orders || '-'); - document.getElementById('stat-ready').textContent = scanData.importable != null ? scanData.importable : '-'; - if (scanData.skus) { - document.getElementById('stat-total-skus').textContent = scanData.skus.total_skus || stats.total_tracked_skus || '-'; - document.getElementById('stat-missing-skus').textContent = scanData.skus.missing || stats.unresolved_skus || 0; - const mapped = (scanData.skus.total_skus || 0) - (scanData.skus.missing || 0); - document.getElementById('stat-mapped-skus').textContent = mapped >= 0 ? mapped : '-'; - } - } - - // Update sync status badge const badge = document.getElementById('syncStatusBadge'); const status = data.status || 'idle'; badge.textContent = status; badge.className = 'badge ' + (status === 'running' ? 'bg-primary' : status === 'failed' ? 'bg-danger' : 'bg-secondary'); - // Show/hide start/stop buttons if (status === 'running') { document.getElementById('btnStartSync').classList.add('d-none'); document.getElementById('btnStopSync').classList.remove('d-none'); @@ -79,12 +41,12 @@ async function loadSyncStatus() { document.getElementById('btnStartSync').classList.remove('d-none'); document.getElementById('btnStopSync').classList.add('d-none'); - // Show last run info + const stats = data.stats || {}; if (stats.last_run) { const lr = stats.last_run; const started = lr.started_at ? new Date(lr.started_at).toLocaleString('ro-RO') : ''; document.getElementById('syncProgressText').textContent = - `Ultimul: ${started} | ${lr.imported || 0} ok, ${lr.skipped || 0} fara mapare, ${lr.errors || 0} erori`; + `Ultimul: ${started} | ${lr.imported || 0} ok, ${lr.skipped || 0} nemapate, ${lr.errors || 0} erori`; } else { document.getElementById('syncProgressText').textContent = ''; } @@ -94,98 +56,453 @@ async function loadSyncStatus() { } } -async function loadSyncHistory() { - try { - const res = await fetch('/api/sync/history?per_page=10'); - const data = await res.json(); - const tbody = document.getElementById('syncRunsBody'); +// ── Last Sync Summary Card ─────────────────────── - if (!data.runs || data.runs.length === 0) { - tbody.innerHTML = 'Niciun sync run'; +async function loadLastSync() { + try { + const res = await fetch('/api/sync/history?per_page=1'); + const data = await res.json(); + const runs = data.runs || []; + + if (runs.length === 0) { + document.getElementById('lastSyncDate').textContent = '-'; return; } - tbody.innerHTML = data.runs.map(r => { - const started = r.started_at ? new Date(r.started_at).toLocaleString('ro-RO', {day:'2-digit',month:'2-digit',hour:'2-digit',minute:'2-digit'}) : '-'; - let duration = '-'; - if (r.started_at && r.finished_at) { - const sec = Math.round((new Date(r.finished_at) - new Date(r.started_at)) / 1000); - duration = sec < 60 ? `${sec}s` : `${Math.floor(sec/60)}m ${sec%60}s`; - } - const statusClass = r.status === 'completed' ? 'bg-success' : r.status === 'running' ? 'bg-primary' : 'bg-danger'; + const r = runs[0]; + document.getElementById('lastSyncDate').textContent = r.started_at + ? new Date(r.started_at).toLocaleString('ro-RO', {day:'2-digit',month:'2-digit',hour:'2-digit',minute:'2-digit'}) + : '-'; - return ` - ${started} - ${esc(r.status)} - ${r.total_orders || 0} - ${r.imported || 0} - ${r.skipped || 0} - ${r.errors || 0} - ${duration} - `; - }).join(''); + const statusClass = r.status === 'completed' ? 'bg-success' : r.status === 'running' ? 'bg-primary' : 'bg-danger'; + document.getElementById('lastSyncStatus').innerHTML = `${esc(r.status)}`; + document.getElementById('lastSyncImported').textContent = r.imported || 0; + document.getElementById('lastSyncSkipped').textContent = r.skipped || 0; + document.getElementById('lastSyncErrors').textContent = r.errors || 0; + + if (r.started_at && r.finished_at) { + const sec = Math.round((new Date(r.finished_at) - new Date(r.started_at)) / 1000); + document.getElementById('lastSyncDuration').textContent = sec < 60 ? `${sec}s` : `${Math.floor(sec/60)}m ${sec%60}s`; + } else { + document.getElementById('lastSyncDuration').textContent = '-'; + } } catch (err) { - console.error('loadSyncHistory error:', err); + console.error('loadLastSync error:', err); } } -async function loadMissingSkus() { - try { - const res = await fetch('/api/validate/missing-skus?page=1&per_page=10'); - const data = await res.json(); - const tbody = document.getElementById('missingSkusBody'); +// ── Dashboard Orders Table ─────────────────────── - // Update article-level stat card (unresolved count) - if (data.total != null) { - document.getElementById('stat-missing-skus').textContent = data.total; +function debounceDashSearch() { + clearTimeout(dashSearchTimeout); + dashSearchTimeout = setTimeout(() => { + dashSearch = document.getElementById('dashSearchInput').value; + dashPage = 1; + loadDashOrders(); + }, 300); +} + +function dashFilterOrders(filter) { + dashFilter = filter; + dashPage = 1; + + // Update button styles + const colorMap = { + 'all': 'primary', + 'IMPORTED': 'success', + 'SKIPPED': 'warning', + 'ERROR': 'danger', + 'UNINVOICED': 'info' + }; + document.querySelectorAll('#dashFilterBtns button').forEach(btn => { + const text = btn.textContent.trim().split(' ')[0]; + let btnFilter = 'all'; + if (text === 'Importate') btnFilter = 'IMPORTED'; + else if (text === 'Omise') btnFilter = 'SKIPPED'; + else if (text === 'Erori') btnFilter = 'ERROR'; + else if (text === 'Nefacturate') btnFilter = 'UNINVOICED'; + + const color = colorMap[btnFilter] || 'primary'; + if (btnFilter === filter) { + btn.className = `btn btn-sm btn-${color}`; + } else { + btn.className = `btn btn-sm btn-outline-${color}`; + } + }); + + loadDashOrders(); +} + +function dashSortBy(col) { + if (dashSortCol === col) { + dashSortDir = dashSortDir === 'asc' ? 'desc' : 'asc'; + } else { + dashSortCol = col; + dashSortDir = 'asc'; + } + // Update sort icons + document.querySelectorAll('#dashOrdersBody').forEach(() => {}); // noop + document.querySelectorAll('.sort-icon').forEach(span => { + const c = span.dataset.col; + span.textContent = c === dashSortCol ? (dashSortDir === 'asc' ? '\u2191' : '\u2193') : ''; + }); + dashPage = 1; + loadDashOrders(); +} + +function dashSetPeriod(days) { + dashPeriodDays = days; + dashPage = 1; + document.querySelectorAll('#dashPeriodBtns button').forEach(btn => { + const val = parseInt(btn.dataset.days); + btn.className = val === days + ? 'btn btn-sm btn-secondary' + : 'btn btn-sm btn-outline-secondary'; + }); + loadDashOrders(); +} + +async function loadDashOrders() { + const params = new URLSearchParams({ + page: dashPage, + per_page: 50, + search: dashSearch, + status: dashFilter, + sort_by: dashSortCol, + sort_dir: dashSortDir, + period_days: dashPeriodDays + }); + + try { + const res = await fetch(`/api/dashboard/orders?${params}`); + const data = await res.json(); + + const counts = data.counts || {}; + document.getElementById('dashCountAll').textContent = counts.total || 0; + document.getElementById('dashCountImported').textContent = counts.imported || 0; + document.getElementById('dashCountSkipped').textContent = counts.skipped || 0; + document.getElementById('dashCountError').textContent = counts.error || 0; + document.getElementById('dashCountUninvoiced').textContent = counts.uninvoiced || 0; + + const tbody = document.getElementById('dashOrdersBody'); + const orders = data.orders || []; + + if (orders.length === 0) { + tbody.innerHTML = 'Nicio comanda'; + } else { + tbody.innerHTML = orders.map(o => { + const dateStr = fmtDate(o.order_date); + const statusBadge = orderStatusBadge(o.status); + + // Invoice info + let invoiceBadge = ''; + let invoiceTotal = ''; + if (o.status !== 'IMPORTED') { + invoiceBadge = '-'; + } else if (o.invoice && o.invoice.facturat) { + invoiceBadge = `Facturat`; + if (o.invoice.serie_act || o.invoice.numar_act) { + invoiceBadge += `
${esc(o.invoice.serie_act || '')} ${esc(String(o.invoice.numar_act || ''))}`; + } + invoiceTotal = o.invoice.total_cu_tva ? Number(o.invoice.total_cu_tva).toFixed(2) : '-'; + } else { + invoiceBadge = 'Nefacturat'; + } + + return ` + ${esc(o.order_number)} + ${dateStr} + ${esc(o.customer_name)} + ${o.items_count || 0} + ${statusBadge} + ${o.id_comanda || '-'} + ${invoiceBadge} + ${invoiceTotal} + `; + }).join(''); } - const unresolved = (data.missing_skus || []).filter(s => !s.resolved); + // Pagination + const totalPages = data.pages || 1; + document.getElementById('dashPageInfo').textContent = `${data.total || 0} comenzi | Pagina ${dashPage} din ${totalPages}`; - if (unresolved.length === 0) { - tbody.innerHTML = 'Toate SKU-urile sunt mapate'; + const pagDiv = document.getElementById('dashPagination'); + if (totalPages > 1) { + pagDiv.innerHTML = ` + + ${dashPage} / ${totalPages} + + `; + } else { + pagDiv.innerHTML = ''; + } + + // Update sort icons + document.querySelectorAll('.sort-icon').forEach(span => { + const c = span.dataset.col; + span.textContent = c === dashSortCol ? (dashSortDir === 'asc' ? '\u2191' : '\u2193') : ''; + }); + } catch (err) { + document.getElementById('dashOrdersBody').innerHTML = + `${esc(err.message)}`; + } +} + +function dashGoPage(p) { + dashPage = p; + loadDashOrders(); +} + +// ── Helper functions ───────────────────────────── + +function fmtDate(dateStr) { + if (!dateStr) return '-'; + try { + const d = new Date(dateStr); + const hasTime = dateStr.includes(':'); + if (hasTime) { + return d.toLocaleString('ro-RO', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' }); + } + return d.toLocaleDateString('ro-RO', { day: '2-digit', month: '2-digit', year: 'numeric' }); + } catch { return dateStr; } +} + +function orderStatusBadge(status) { + switch ((status || '').toUpperCase()) { + case 'IMPORTED': return 'Importat'; + case 'SKIPPED': return 'Omis'; + case 'ERROR': return 'Eroare'; + default: return `${esc(status)}`; + } +} + +function renderCodmatCell(item) { + if (!item.codmat_details || item.codmat_details.length === 0) { + return `${esc(item.codmat || '-')}`; + } + if (item.codmat_details.length === 1) { + const d = item.codmat_details[0]; + return `${esc(d.codmat)}`; + } + return item.codmat_details.map(d => + `
${esc(d.codmat)} \xd7${d.cantitate_roa} (${d.procent_pret}%)
` + ).join(''); +} + +// ── Order Detail Modal ─────────────────────────── + +async function openDashOrderDetail(orderNumber) { + document.getElementById('detailOrderNumber').textContent = '#' + orderNumber; + document.getElementById('detailCustomer').textContent = '...'; + document.getElementById('detailDate').textContent = ''; + document.getElementById('detailStatus').innerHTML = ''; + document.getElementById('detailIdComanda').textContent = '-'; + document.getElementById('detailIdPartener').textContent = '-'; + document.getElementById('detailIdAdresaFact').textContent = '-'; + document.getElementById('detailIdAdresaLivr').textContent = '-'; + document.getElementById('detailItemsBody').innerHTML = 'Se incarca...'; + document.getElementById('detailError').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; } - tbody.innerHTML = unresolved.slice(0, 10).map(s => { - let firstCustomer = '-'; - try { - const customers = JSON.parse(s.customers || '[]'); - if (customers.length > 0) firstCustomer = customers[0]; - } catch (e) { /* ignore */ } + 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 items = data.items || []; + if (items.length === 0) { + document.getElementById('detailItemsBody').innerHTML = 'Niciun articol'; + return; + } + + document.getElementById('detailItemsBody').innerHTML = items.map(item => { + let statusBadge; + switch (item.mapping_status) { + case 'mapped': statusBadge = 'Mapat'; break; + case 'direct': statusBadge = 'Direct'; break; + case 'missing': statusBadge = 'Lipsa'; break; + default: statusBadge = '?'; + } + + const action = item.mapping_status === 'missing' + ? `` + : ''; return ` - ${esc(s.sku)} - ${esc(s.product_name || '-')} - ${s.order_count != null ? s.order_count : '-'} - ${esc(firstCustomer)} - - - + ${esc(item.sku)} + ${esc(item.product_name || '-')} + ${item.quantity || 0} + ${item.price != null ? Number(item.price).toFixed(2) : '-'} + ${item.vat != null ? Number(item.vat).toFixed(2) : '-'} + ${renderCodmatCell(item)} + ${statusBadge} + ${action} `; }).join(''); } catch (err) { - console.error('loadMissingSkus error:', err); + document.getElementById('detailError').textContent = err.message; + document.getElementById('detailError').style.display = ''; } } -async function loadSchedulerStatus() { - try { - const res = await fetch('/api/sync/schedule'); - const data = await res.json(); +// ── Quick Map Modal ────────────────────────────── - document.getElementById('schedulerToggle').checked = data.enabled || false; - if (data.interval_minutes) { - document.getElementById('schedulerInterval').value = data.interval_minutes; +function openQuickMap(sku, productName, orderNumber) { + currentQmSku = sku; + currentQmOrderNumber = orderNumber; + document.getElementById('qmSku').textContent = sku; + document.getElementById('qmProductName').textContent = productName || '-'; + document.getElementById('qmPctWarning').style.display = 'none'; + + const container = document.getElementById('qmCodmatLines'); + container.innerHTML = ''; + addQmCodmatLine(); + + new bootstrap.Modal(document.getElementById('quickMapModal')).show(); +} + +function addQmCodmatLine() { + const container = document.getElementById('qmCodmatLines'); + const idx = container.children.length; + const div = document.createElement('div'); + div.className = 'border rounded p-2 mb-2 qm-line'; + div.innerHTML = ` +
+ + +
+ +
+
+
+ + +
+
+ + +
+
+ ${idx > 0 ? `` : ''} +
+
+ `; + container.appendChild(div); + + const input = div.querySelector('.qm-codmat'); + const dropdown = div.querySelector('.qm-ac-dropdown'); + const selected = div.querySelector('.qm-selected'); + + input.addEventListener('input', () => { + clearTimeout(qmAcTimeout); + qmAcTimeout = setTimeout(() => qmAutocomplete(input, dropdown, selected), 250); + }); + input.addEventListener('blur', () => { + setTimeout(() => dropdown.classList.add('d-none'), 200); + }); +} + +async function qmAutocomplete(input, dropdown, selectedEl) { + const q = input.value; + if (q.length < 2) { dropdown.classList.add('d-none'); return; } + + try { + const res = await fetch(`/api/articles/search?q=${encodeURIComponent(q)}`); + const data = await res.json(); + if (!data.results || data.results.length === 0) { dropdown.classList.add('d-none'); return; } + + dropdown.innerHTML = data.results.map(r => + `
+ ${esc(r.codmat)}${esc(r.denumire)}${r.um ? ` (${esc(r.um)})` : ''} +
` + ).join(''); + dropdown.classList.remove('d-none'); + } catch { dropdown.classList.add('d-none'); } +} + +function qmSelectArticle(el, codmat, label) { + const line = el.closest('.qm-line'); + line.querySelector('.qm-codmat').value = codmat; + line.querySelector('.qm-selected').textContent = label; + line.querySelector('.qm-ac-dropdown').classList.add('d-none'); +} + +async function saveQuickMapping() { + const lines = document.querySelectorAll('.qm-line'); + const mappings = []; + + for (const line of lines) { + const codmat = line.querySelector('.qm-codmat').value.trim(); + const cantitate = parseFloat(line.querySelector('.qm-cantitate').value) || 1; + const procent = parseFloat(line.querySelector('.qm-procent').value) || 100; + if (!codmat) continue; + mappings.push({ codmat, cantitate_roa: cantitate, procent_pret: procent }); + } + + if (mappings.length === 0) { alert('Selecteaza cel putin un CODMAT'); return; } + + if (mappings.length > 1) { + const totalPct = mappings.reduce((s, m) => s + m.procent_pret, 0); + if (Math.abs(totalPct - 100) > 0.01) { + document.getElementById('qmPctWarning').textContent = `Suma procentelor trebuie sa fie 100% (actual: ${totalPct.toFixed(2)}%)`; + document.getElementById('qmPctWarning').style.display = ''; + return; + } + } + document.getElementById('qmPctWarning').style.display = 'none'; + + try { + let res; + if (mappings.length === 1) { + res = await fetch('/api/mappings', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ sku: currentQmSku, codmat: mappings[0].codmat, cantitate_roa: mappings[0].cantitate_roa, procent_pret: mappings[0].procent_pret }) + }); + } else { + res = await fetch('/api/mappings/batch', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ sku: currentQmSku, mappings }) + }); + } + const data = await res.json(); + if (data.success) { + bootstrap.Modal.getInstance(document.getElementById('quickMapModal')).hide(); + if (currentQmOrderNumber) openDashOrderDetail(currentQmOrderNumber); + loadDashOrders(); + } else { + alert('Eroare: ' + (data.error || 'Unknown')); } } catch (err) { - console.error('loadSchedulerStatus error:', err); + alert('Eroare: ' + err.message); } } +// ── Sync Controls ──────────────────────────────── + async function startSync() { try { const res = await fetch('/api/sync/start', { method: 'POST' }); @@ -194,7 +511,6 @@ async function startSync() { alert(data.error); return; } - // Show banner with link to live logs if (data.run_id) { const banner = document.getElementById('syncStartedBanner'); const link = document.getElementById('syncRunLink'); @@ -202,61 +518,72 @@ async function startSync() { link.href = '/logs?run=' + encodeURIComponent(data.run_id); banner.classList.remove('d-none'); } + // Subscribe to SSE for live progress + auto-refresh on completion + listenToSyncStream(data.run_id); } - loadDashboard(); + loadSyncStatus(); } catch (err) { alert('Eroare: ' + err.message); } } +function listenToSyncStream(runId) { + // Close any previous SSE connection + if (syncEventSource) { syncEventSource.close(); syncEventSource = null; } + + syncEventSource = new EventSource('/api/sync/stream'); + + syncEventSource.onmessage = (event) => { + try { + const data = JSON.parse(event.data); + + if (data.type === 'phase') { + document.getElementById('syncProgressText').textContent = data.message || ''; + } + + if (data.type === 'order_result') { + // Update progress text with current order info + const status = data.status === 'IMPORTED' ? 'OK' : data.status === 'SKIPPED' ? 'OMIS' : 'ERR'; + document.getElementById('syncProgressText').textContent = + `[${data.progress || ''}] #${data.order_number} ${data.customer_name || ''} → ${status}`; + } + + if (data.type === 'completed' || data.type === 'failed') { + syncEventSource.close(); + syncEventSource = null; + // Refresh all dashboard sections + loadLastSync(); + loadDashOrders(); + loadSyncStatus(); + // Hide banner after 5s + setTimeout(() => { + document.getElementById('syncStartedBanner')?.classList.add('d-none'); + }, 5000); + } + } catch (e) { + console.error('SSE parse error:', e); + } + }; + + syncEventSource.onerror = () => { + syncEventSource.close(); + syncEventSource = null; + // Refresh anyway — sync may have finished + loadLastSync(); + loadDashOrders(); + loadSyncStatus(); + }; +} + async function stopSync() { try { await fetch('/api/sync/stop', { method: 'POST' }); - loadDashboard(); + loadSyncStatus(); } catch (err) { alert('Eroare: ' + err.message); } } -async function scanOrders() { - const btn = document.getElementById('btnScan'); - btn.disabled = true; - btn.innerHTML = ' Scanning...'; - - try { - const res = await fetch('/api/validate/scan', { method: 'POST' }); - const data = await res.json(); - - // Persist scan results so auto-refresh doesn't overwrite them - saveScanData(data); - - // Update stat cards immediately from scan response - document.getElementById('stat-new').textContent = data.new_orders != null ? data.new_orders : (data.total_orders || 0); - document.getElementById('stat-ready').textContent = data.importable != null ? data.importable : 0; - - if (data.skus) { - document.getElementById('stat-total-skus').textContent = data.skus.total_skus || 0; - document.getElementById('stat-missing-skus').textContent = data.skus.missing || 0; - const mapped = (data.skus.total_skus || 0) - (data.skus.missing || 0); - document.getElementById('stat-mapped-skus').textContent = mapped >= 0 ? mapped : 0; - } - - let msg = `Scan complet: ${data.total_orders || 0} comenzi`; - if (data.new_orders != null) msg += `, ${data.new_orders} noi`; - msg += `, ${data.importable || 0} ready`; - if (data.skus && data.skus.missing > 0) { - msg += `, ${data.skus.missing} SKU-uri lipsa`; - } - alert(msg); - loadDashboard(); - } catch (err) { - alert('Eroare scan: ' + err.message); - } finally { - btn.disabled = false; - btn.innerHTML = ' Scan'; - } -} - async function toggleScheduler() { const enabled = document.getElementById('schedulerToggle').checked; const interval = parseInt(document.getElementById('schedulerInterval').value) || 5; @@ -279,106 +606,19 @@ async function updateSchedulerInterval() { } } -// --- Map Modal --- - -function openMapModal(sku, productName) { - currentMapSku = sku; - document.getElementById('mapSku').textContent = sku; - document.getElementById('mapCodmat').value = productName || ''; - document.getElementById('mapCantitate').value = '1'; - document.getElementById('mapProcent').value = '100'; - document.getElementById('mapSelectedArticle').textContent = ''; - document.getElementById('mapAutocomplete').classList.add('d-none'); - - if (productName) { - autocompleteMap(productName); - } - - new bootstrap.Modal(document.getElementById('mapModal')).show(); -} - -async function autocompleteMap(q) { - const dropdown = document.getElementById('mapAutocomplete'); - if (!dropdown) return; - if (q.length < 2) { dropdown.classList.add('d-none'); return; } - +async function loadSchedulerStatus() { try { - const res = await fetch(`/api/articles/search?q=${encodeURIComponent(q)}`); + const res = await fetch('/api/sync/schedule'); const data = await res.json(); - - if (!data.results || data.results.length === 0) { - dropdown.classList.add('d-none'); - return; - } - - dropdown.innerHTML = data.results.map(r => ` -
- ${esc(r.codmat)} -
${esc(r.denumire)} -
- `).join(''); - dropdown.classList.remove('d-none'); - } catch (err) { - dropdown.classList.add('d-none'); - } -} - -function selectMapArticle(codmat, denumire) { - document.getElementById('mapCodmat').value = codmat; - document.getElementById('mapSelectedArticle').textContent = denumire; - document.getElementById('mapAutocomplete').classList.add('d-none'); -} - -async function saveQuickMap() { - const codmat = document.getElementById('mapCodmat').value.trim(); - const cantitate = parseFloat(document.getElementById('mapCantitate').value) || 1; - const procent = parseFloat(document.getElementById('mapProcent').value) || 100; - - if (!codmat) { alert('Selecteaza un CODMAT'); return; } - - try { - const res = await fetch('/api/mappings', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - sku: currentMapSku, - codmat: codmat, - cantitate_roa: cantitate, - procent_pret: procent - }) - }); - const data = await res.json(); - - if (data.success) { - bootstrap.Modal.getInstance(document.getElementById('mapModal')).hide(); - loadMissingSkus(); - } else { - alert('Eroare: ' + (data.error || 'Unknown')); + document.getElementById('schedulerToggle').checked = data.enabled || false; + if (data.interval_minutes) { + document.getElementById('schedulerInterval').value = data.interval_minutes; } } catch (err) { - alert('Eroare: ' + err.message); + console.error('loadSchedulerStatus error:', err); } } -// --- sessionStorage helpers for scan data --- - -function saveScanData(data) { - try { - sessionStorage.setItem('lastScanData', JSON.stringify(data)); - sessionStorage.setItem('lastScanTime', Date.now().toString()); - } catch (e) { /* ignore */ } -} - -function getScanData() { - try { - const t = parseInt(sessionStorage.getItem('lastScanTime') || '0'); - // Expire scan data after 5 minutes - if (Date.now() - t > 5 * 60 * 1000) return null; - const raw = sessionStorage.getItem('lastScanData'); - return raw ? JSON.parse(raw) : null; - } catch (e) { return null; } -} - function esc(s) { if (s == null) return ''; return String(s).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, '''); diff --git a/api/app/static/js/logs.js b/api/app/static/js/logs.js index 1d1fda3..6bdac6a 100644 --- a/api/app/static/js/logs.js +++ b/api/app/static/js/logs.js @@ -1,9 +1,14 @@ -// logs.js - Unified Logs page with SSE live feed +// logs.js - Structured order viewer with text log fallback let currentRunId = null; -let eventSource = null; let runsPage = 1; -let liveCounts = { imported: 0, skipped: 0, errors: 0, total: 0 }; +let logPollTimer = null; +let currentFilter = 'all'; +let ordersPage = 1; +let currentQmSku = ''; +let currentQmOrderNumber = ''; +let ordersSortColumn = 'created_at'; +let ordersSortDirection = 'asc'; function esc(s) { if (s == null) return ''; @@ -13,23 +18,6 @@ function esc(s) { .replace(/'/g, '''); } -function fmtTime(iso) { - if (!iso) return ''; - try { - return new Date(iso).toLocaleTimeString('ro-RO', { hour: '2-digit', minute: '2-digit', second: '2-digit' }); - } catch (e) { return ''; } -} - -function fmtDatetime(iso) { - if (!iso) return '-'; - try { - return new Date(iso).toLocaleString('ro-RO', { - day: '2-digit', month: '2-digit', year: 'numeric', - hour: '2-digit', minute: '2-digit' - }); - } catch (e) { return iso; } -} - function fmtDuration(startedAt, finishedAt) { if (!startedAt || !finishedAt) return '-'; const diffMs = new Date(finishedAt) - new Date(startedAt); @@ -39,13 +27,16 @@ function fmtDuration(startedAt, finishedAt) { return Math.floor(secs / 60) + 'm ' + (secs % 60) + 's'; } -function statusBadge(status) { - switch ((status || '').toUpperCase()) { - case 'IMPORTED': return 'IMPORTED'; - case 'SKIPPED': return 'SKIPPED'; - case 'ERROR': return 'ERROR'; - default: return `${esc(status || '-')}`; - } +function fmtDate(dateStr) { + if (!dateStr) return '-'; + try { + const d = new Date(dateStr); + const hasTime = dateStr.includes(':'); + if (hasTime) { + return d.toLocaleString('ro-RO', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit', second: '2-digit' }); + } + return d.toLocaleDateString('ro-RO', { day: '2-digit', month: '2-digit', year: 'numeric' }); + } catch { return dateStr; } } function runStatusBadge(status) { @@ -57,323 +48,461 @@ function runStatusBadge(status) { } } -// ── Runs Table ────────────────────────────────── - -async function loadRuns(page) { - if (page != null) runsPage = page; - const perPage = 20; - - try { - const res = await fetch(`/api/sync/history?page=${runsPage}&per_page=${perPage}`); - if (!res.ok) throw new Error('HTTP ' + res.status); - const data = await res.json(); - const runs = data.runs || []; - const total = data.total || runs.length; - - // Populate dropdown - const sel = document.getElementById('runSelector'); - sel.innerHTML = '' + - runs.map(r => { - const date = fmtDatetime(r.started_at); - const stats = `${r.total_orders || 0} total / ${r.imported || 0} ok / ${r.errors || 0} err`; - return ``; - }).join(''); - - // Populate table - const tbody = document.getElementById('runsTableBody'); - if (runs.length === 0) { - tbody.innerHTML = 'Niciun sync run'; - } else { - tbody.innerHTML = runs.map(r => { - const started = r.started_at ? new Date(r.started_at).toLocaleString('ro-RO', {day:'2-digit',month:'2-digit',hour:'2-digit',minute:'2-digit'}) : '-'; - const duration = fmtDuration(r.started_at, r.finished_at); - const statusClass = r.status === 'completed' ? 'bg-success' : r.status === 'running' ? 'bg-primary' : 'bg-danger'; - const activeClass = r.run_id === currentRunId ? 'table-active' : ''; - return ` - ${started} - ${esc(r.status)} - ${r.total_orders || 0} - ${r.imported || 0} - ${r.skipped || 0} - ${r.errors || 0} - ${duration} - `; - }).join(''); - } - - // Pagination - const pagDiv = document.getElementById('runsTablePagination'); - const totalPages = Math.ceil(total / perPage); - if (totalPages > 1) { - pagDiv.innerHTML = ` - - ${runsPage} / ${totalPages} - - `; - } else { - pagDiv.innerHTML = ''; - } - } catch (err) { - document.getElementById('runsTableBody').innerHTML = `${esc(err.message)}`; +function orderStatusBadge(status) { + switch ((status || '').toUpperCase()) { + case 'IMPORTED': return 'Importat'; + case 'SKIPPED': return 'Omis'; + case 'ERROR': return 'Eroare'; + default: return `${esc(status)}`; } } -// ── Run Selection ─────────────────────────────── +// ── Runs Dropdown ──────────────────────────────── + +async function loadRuns() { + // Load all recent runs for dropdown + try { + const res = await fetch(`/api/sync/history?page=1&per_page=100`); + if (!res.ok) throw new Error('HTTP ' + res.status); + const data = await res.json(); + const runs = data.runs || []; + + const dd = document.getElementById('runsDropdown'); + if (runs.length === 0) { + dd.innerHTML = ''; + } else { + dd.innerHTML = '' + + runs.map(r => { + const started = r.started_at ? new Date(r.started_at).toLocaleString('ro-RO', {day:'2-digit',month:'2-digit',year:'numeric',hour:'2-digit',minute:'2-digit'}) : '?'; + const st = (r.status || '').toUpperCase(); + const statusEmoji = st === 'COMPLETED' ? '✓' : st === 'RUNNING' ? '⟳' : '✗'; + const imp = r.imported || 0; + const skip = r.skipped || 0; + const err = r.errors || 0; + const label = `${started} — ${statusEmoji} ${r.status} (${imp} imp, ${skip} skip, ${err} err)`; + const selected = r.run_id === currentRunId ? 'selected' : ''; + return ``; + }).join(''); + } + } catch (err) { + const dd = document.getElementById('runsDropdown'); + dd.innerHTML = ``; + } +} + +// ── Run Selection ──────────────────────────────── async function selectRun(runId) { - if (eventSource) { eventSource.close(); eventSource = null; } - currentRunId = runId; + if (logPollTimer) { clearInterval(logPollTimer); logPollTimer = null; } + + currentRunId = runId; + currentFilter = 'all'; + ordersPage = 1; - // Update URL without reload const url = new URL(window.location); if (runId) { url.searchParams.set('run', runId); } else { url.searchParams.delete('run'); } history.replaceState(null, '', url); - // Highlight active row in table - document.querySelectorAll('#runsTableBody tr').forEach(tr => { - tr.classList.toggle('table-active', tr.getAttribute('data-href') === `/logs?run=${runId}`); - }); - - // Update dropdown - document.getElementById('runSelector').value = runId || ''; + // Sync dropdown selection + const dd = document.getElementById('runsDropdown'); + if (dd && dd.value !== runId) dd.value = runId; if (!runId) { - document.getElementById('runDetailSection').style.display = 'none'; + document.getElementById('logViewerSection').style.display = 'none'; return; } - document.getElementById('runDetailSection').style.display = ''; + document.getElementById('logViewerSection').style.display = ''; + document.getElementById('logRunId').textContent = runId; + document.getElementById('logStatusBadge').innerHTML = '...'; + document.getElementById('textLogSection').style.display = 'none'; - // Check if this run is currently active - try { - const statusRes = await fetch('/api/sync/status'); - const statusData = await statusRes.json(); - if (statusData.status === 'running' && statusData.run_id === runId) { - startLiveFeed(runId); - return; - } - } catch (e) { /* fall through to historical load */ } + await loadRunOrders(runId, 'all', 1); - // Load historical data - document.getElementById('liveFeedCard').style.display = 'none'; - await loadRunLog(runId); + // Also load text log in background + fetchTextLog(runId); } -// ── Live SSE Feed ─────────────────────────────── +// ── Per-Order Filtering (R1) ───────────────────── -function startLiveFeed(runId) { - liveCounts = { imported: 0, skipped: 0, errors: 0, total: 0 }; +async function loadRunOrders(runId, statusFilter, page) { + if (statusFilter != null) currentFilter = statusFilter; + if (page != null) ordersPage = page; - // Show live feed card, clear it - const feedCard = document.getElementById('liveFeedCard'); - feedCard.style.display = ''; - document.getElementById('liveFeed').innerHTML = ''; - document.getElementById('logsBody').innerHTML = ''; - - // Reset summary - document.getElementById('sum-total').textContent = '-'; - document.getElementById('sum-imported').textContent = '0'; - document.getElementById('sum-skipped').textContent = '0'; - document.getElementById('sum-errors').textContent = '0'; - document.getElementById('sum-duration').textContent = 'live...'; - - connectSSE(); -} - -function connectSSE() { - if (eventSource) eventSource.close(); - eventSource = new EventSource('/api/sync/stream'); - - eventSource.onmessage = function(e) { - let event; - try { event = JSON.parse(e.data); } catch (err) { return; } - - if (event.type === 'keepalive') return; - - if (event.type === 'phase') { - appendFeedEntry('phase', event.message); - } - else if (event.type === 'order_result') { - const icon = event.status === 'IMPORTED' ? '✅' : event.status === 'SKIPPED' ? '⏭️' : '❌'; - const progressText = event.progress ? `[${event.progress}]` : ''; - appendFeedEntry( - event.status === 'ERROR' ? 'error' : event.status === 'IMPORTED' ? 'success' : '', - `${progressText} #${event.order_number} ${event.customer_name || ''} → ${icon} ${event.status}${event.error_message ? ' — ' + event.error_message : ''}` - ); - addOrderRow(event); - updateLiveSummary(event); - } - else if (event.type === 'completed') { - appendFeedEntry('phase', '🏁 Sync completed'); - eventSource.close(); - eventSource = null; - document.querySelector('.live-pulse')?.remove(); - // Reload full data from REST after short delay - setTimeout(() => { - loadRunLog(currentRunId); - loadRuns(); - }, 500); - } - else if (event.type === 'failed') { - appendFeedEntry('error', '💥 Sync failed: ' + (event.error || 'Unknown error')); - eventSource.close(); - eventSource = null; - document.querySelector('.live-pulse')?.remove(); - setTimeout(() => { - loadRunLog(currentRunId); - loadRuns(); - }, 500); - } - }; - - eventSource.onerror = function() { - // SSE disconnected — try to load historical data - eventSource.close(); - eventSource = null; - setTimeout(() => loadRunLog(currentRunId), 1000); - }; -} - -function appendFeedEntry(type, message) { - const feed = document.getElementById('liveFeed'); - const now = new Date().toLocaleTimeString('ro-RO', { hour: '2-digit', minute: '2-digit', second: '2-digit' }); - const typeClass = type ? ` ${type}` : ''; - const iconMap = { phase: 'ℹ️', error: '❌', success: '✅' }; - const icon = iconMap[type] || '▶'; - - const entry = document.createElement('div'); - entry.className = `feed-entry${typeClass}`; - entry.innerHTML = `${now}${icon}${esc(message)}`; - feed.appendChild(entry); - - // Auto-scroll to bottom - feed.scrollTop = feed.scrollHeight; -} - -function addOrderRow(event) { - const tbody = document.getElementById('logsBody'); - const status = (event.status || '').toUpperCase(); - - let details = ''; - if (event.error_message) { - details = `${esc(event.error_message)}`; - } - if (event.missing_skus && Array.isArray(event.missing_skus) && event.missing_skus.length > 0) { - details += `
${event.missing_skus.map(s => `${esc(s)}`).join('')}
`; - } - if (event.id_comanda) { - details += `ID: ${event.id_comanda}`; - } - if (!details) details = '-'; - - const tr = document.createElement('tr'); - tr.setAttribute('data-status', status); - tr.innerHTML = ` - ${esc(event.order_number || '-')} - ${esc(event.customer_name || '-')} - ${event.items_count ?? '-'} - ${statusBadge(status)} - ${details} - `; - tbody.appendChild(tr); -} - -function updateLiveSummary(event) { - liveCounts.total++; - if (event.status === 'IMPORTED') liveCounts.imported++; - else if (event.status === 'SKIPPED') liveCounts.skipped++; - else if (event.status === 'ERROR') liveCounts.errors++; - - document.getElementById('sum-total').textContent = liveCounts.total; - document.getElementById('sum-imported').textContent = liveCounts.imported; - document.getElementById('sum-skipped').textContent = liveCounts.skipped; - document.getElementById('sum-errors').textContent = liveCounts.errors; -} - -// ── Historical Run Log ────────────────────────── - -async function loadRunLog(runId) { - const tbody = document.getElementById('logsBody'); - tbody.innerHTML = '
Se incarca...'; + // Update filter button styles + document.querySelectorAll('#orderFilterBtns button').forEach(btn => { + btn.className = btn.className.replace(' btn-primary', ' btn-outline-primary') + .replace(' btn-success', ' btn-outline-success') + .replace(' btn-warning', ' btn-outline-warning') + .replace(' btn-danger', ' btn-outline-danger'); + }); try { - const res = await fetch(`/api/sync/run/${encodeURIComponent(runId)}/log`); + const res = await fetch(`/api/sync/run/${encodeURIComponent(runId)}/orders?status=${currentFilter}&page=${ordersPage}&per_page=50&sort_by=${ordersSortColumn}&sort_dir=${ordersSortDirection}`); if (!res.ok) throw new Error('HTTP ' + res.status); const data = await res.json(); - const run = data.run || {}; - const orders = data.orders || []; + const counts = data.counts || {}; + document.getElementById('countAll').textContent = counts.total || 0; + document.getElementById('countImported').textContent = counts.imported || 0; + document.getElementById('countSkipped').textContent = counts.skipped || 0; + document.getElementById('countError').textContent = counts.error || 0; - // Populate summary bar - document.getElementById('sum-total').textContent = run.total_orders ?? '-'; - document.getElementById('sum-imported').textContent = run.imported ?? '-'; - document.getElementById('sum-skipped').textContent = run.skipped ?? '-'; - document.getElementById('sum-errors').textContent = run.errors ?? '-'; - document.getElementById('sum-duration').textContent = fmtDuration(run.started_at, run.finished_at); - - if (orders.length === 0) { - const runError = run.error_message - ? `${esc(run.error_message)}` - : 'Nicio comanda in acest sync run'; - tbody.innerHTML = runError; - updateFilterCount(); - return; + // Highlight active filter + const filterMap = { 'all': 0, 'IMPORTED': 1, 'SKIPPED': 2, 'ERROR': 3 }; + const btns = document.querySelectorAll('#orderFilterBtns button'); + const idx = filterMap[currentFilter] || 0; + if (btns[idx]) { + const colorMap = ['primary', 'success', 'warning', 'danger']; + btns[idx].className = btns[idx].className.replace(`btn-outline-${colorMap[idx]}`, `btn-${colorMap[idx]}`); } - tbody.innerHTML = orders.map(order => { - const status = (order.status || '').toUpperCase(); - let missingSkuTags = ''; - if (order.missing_skus) { - try { - const skus = typeof order.missing_skus === 'string' ? JSON.parse(order.missing_skus) : order.missing_skus; - if (Array.isArray(skus) && skus.length > 0) { - missingSkuTags = '
' + - skus.map(s => `${esc(s)}`).join('') + '
'; - } - } catch (e) { /* skip */ } - } - const details = order.error_message - ? `${esc(order.error_message)}${missingSkuTags}` - : missingSkuTags || '-'; + const tbody = document.getElementById('runOrdersBody'); + const orders = data.orders || []; - return ` - ${esc(order.order_number || '-')} - ${esc(order.customer_name || '-')} - ${order.items_count ?? '-'} - ${statusBadge(status)} - ${details} - `; - }).join(''); + if (orders.length === 0) { + tbody.innerHTML = 'Nicio comanda'; + } else { + tbody.innerHTML = orders.map((o, i) => { + const dateStr = fmtDate(o.order_date); + return ` + ${(ordersPage - 1) * 50 + i + 1} + ${dateStr} + ${esc(o.order_number)} + ${esc(o.customer_name)} + ${o.items_count || 0} + ${orderStatusBadge(o.status)} + `; + }).join(''); + } - // Reset filter - document.querySelectorAll('[data-filter]').forEach(btn => { - btn.classList.toggle('active', btn.dataset.filter === 'all'); - }); - applyFilter('all'); + // Orders pagination + const totalPages = data.pages || 1; + const infoEl = document.getElementById('ordersPageInfo'); + infoEl.textContent = `${data.total || 0} comenzi | Pagina ${ordersPage} din ${totalPages}`; + const pagDiv = document.getElementById('ordersPagination'); + if (totalPages > 1) { + pagDiv.innerHTML = ` + + ${ordersPage} / ${totalPages} + + `; + } else { + pagDiv.innerHTML = ''; + } + + // Update run status badge + const runRes = await fetch(`/api/sync/run/${encodeURIComponent(runId)}`); + const runData = await runRes.json(); + if (runData.run) { + document.getElementById('logStatusBadge').innerHTML = runStatusBadge(runData.run.status); + } } catch (err) { - tbody.innerHTML = `${esc(err.message)}`; + document.getElementById('runOrdersBody').innerHTML = + `${esc(err.message)}`; } } -// ── Filters ───────────────────────────────────── - -function applyFilter(filter) { - const rows = document.querySelectorAll('#logsBody tr[data-status]'); - let visible = 0; - rows.forEach(row => { - const show = filter === 'all' || row.dataset.status === filter; - row.style.display = show ? '' : 'none'; - if (show) visible++; - }); - updateFilterCount(visible, rows.length, filter); +function filterOrders(status) { + loadRunOrders(currentRunId, status, 1); } -function updateFilterCount(visible, total, filter) { - const el = document.getElementById('filterCount'); - if (!el) return; - if (visible == null) { el.textContent = ''; return; } - el.textContent = filter === 'all' ? `${total} comenzi` : `${visible} din ${total} comenzi`; +function sortOrdersBy(col) { + if (ordersSortColumn === col) { + ordersSortDirection = ordersSortDirection === 'asc' ? 'desc' : 'asc'; + } else { + ordersSortColumn = col; + ordersSortDirection = 'asc'; + } + // Update sort icons + document.querySelectorAll('#logViewerSection .sort-icon').forEach(span => { + const c = span.dataset.col; + span.textContent = c === ordersSortColumn ? (ordersSortDirection === 'asc' ? '\u2191' : '\u2193') : ''; + }); + loadRunOrders(currentRunId, null, 1); +} + +// ── Text Log (collapsible) ────────────────────── + +function toggleTextLog() { + const section = document.getElementById('textLogSection'); + section.style.display = section.style.display === 'none' ? '' : 'none'; + if (section.style.display !== 'none' && currentRunId) { + fetchTextLog(currentRunId); + } +} + +async function fetchTextLog(runId) { + // Clear any existing poll timer to prevent accumulation + if (logPollTimer) { clearInterval(logPollTimer); logPollTimer = null; } + + try { + const res = await fetch(`/api/sync/run/${encodeURIComponent(runId)}/text-log`); + if (!res.ok) throw new Error('HTTP ' + res.status); + const data = await res.json(); + + document.getElementById('logContent').textContent = data.text || '(log gol)'; + + if (!data.finished) { + if (document.getElementById('autoRefreshToggle')?.checked) { + logPollTimer = setInterval(async () => { + try { + const r = await fetch(`/api/sync/run/${encodeURIComponent(runId)}/text-log`); + const d = await r.json(); + if (currentRunId !== runId) { clearInterval(logPollTimer); return; } + document.getElementById('logContent').textContent = d.text || '(log gol)'; + const el = document.getElementById('logContent'); + el.scrollTop = el.scrollHeight; + if (d.finished) { + clearInterval(logPollTimer); + logPollTimer = null; + loadRuns(); + loadRunOrders(runId, currentFilter, ordersPage); + } + } catch (e) { console.error('Poll error:', e); } + }, 2500); + } + } + } catch (err) { + document.getElementById('logContent').textContent = 'Eroare: ' + err.message; + } +} + +// ── Multi-CODMAT helper (D1) ───────────────────── + +function renderCodmatCell(item) { + if (!item.codmat_details || item.codmat_details.length === 0) { + return `${esc(item.codmat || '-')}`; + } + if (item.codmat_details.length === 1) { + const d = item.codmat_details[0]; + return `${esc(d.codmat)}`; + } + // Multi-CODMAT: compact list + return item.codmat_details.map(d => + `
${esc(d.codmat)} \xd7${d.cantitate_roa} (${d.procent_pret}%)
` + ).join(''); +} + +// ── Order Detail Modal (R9) ───────────────────── + +async function openOrderDetail(orderNumber) { + document.getElementById('detailOrderNumber').textContent = '#' + orderNumber; + document.getElementById('detailCustomer').textContent = '...'; + document.getElementById('detailDate').textContent = ''; + document.getElementById('detailStatus').innerHTML = ''; + document.getElementById('detailIdComanda').textContent = '-'; + document.getElementById('detailIdPartener').textContent = '-'; + document.getElementById('detailIdAdresaFact').textContent = '-'; + document.getElementById('detailIdAdresaLivr').textContent = '-'; + document.getElementById('detailItemsBody').innerHTML = 'Se incarca...'; + document.getElementById('detailError').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); + 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 items = data.items || []; + if (items.length === 0) { + document.getElementById('detailItemsBody').innerHTML = 'Niciun articol'; + return; + } + + document.getElementById('detailItemsBody').innerHTML = items.map(item => { + let statusBadge; + switch (item.mapping_status) { + case 'mapped': statusBadge = 'Mapat'; break; + case 'direct': statusBadge = 'Direct'; break; + case 'missing': statusBadge = 'Lipsa'; break; + default: statusBadge = '?'; + } + + const action = item.mapping_status === 'missing' + ? `` + : ''; + + return ` + ${esc(item.sku)} + ${esc(item.product_name || '-')} + ${item.quantity || 0} + ${item.price != null ? Number(item.price).toFixed(2) : '-'} + ${item.vat != null ? Number(item.vat).toFixed(2) : '-'} + ${renderCodmatCell(item)} + ${statusBadge} + ${action} + `; + }).join(''); + } catch (err) { + document.getElementById('detailError').textContent = err.message; + document.getElementById('detailError').style.display = ''; + } +} + +// ── Quick Map Modal (from order detail) ────────── + +let qmAcTimeout = null; + +function openQuickMap(sku, productName, orderNumber) { + currentQmSku = sku; + currentQmOrderNumber = orderNumber; + document.getElementById('qmSku').textContent = sku; + document.getElementById('qmProductName').textContent = productName || '-'; + document.getElementById('qmPctWarning').style.display = 'none'; + + // Reset CODMAT lines + const container = document.getElementById('qmCodmatLines'); + container.innerHTML = ''; + addQmCodmatLine(); + + // Show quick map on top of order detail (modal stacking) + new bootstrap.Modal(document.getElementById('quickMapModal')).show(); +} + +function addQmCodmatLine() { + const container = document.getElementById('qmCodmatLines'); + const idx = container.children.length; + const div = document.createElement('div'); + div.className = 'border rounded p-2 mb-2 qm-line'; + div.innerHTML = ` +
+ + +
+ +
+
+
+ + +
+
+ + +
+
+ ${idx > 0 ? `` : ''} +
+
+ `; + container.appendChild(div); + + // Setup autocomplete on the new input + const input = div.querySelector('.qm-codmat'); + const dropdown = div.querySelector('.qm-ac-dropdown'); + const selected = div.querySelector('.qm-selected'); + + input.addEventListener('input', () => { + clearTimeout(qmAcTimeout); + qmAcTimeout = setTimeout(() => qmAutocomplete(input, dropdown, selected), 250); + }); + input.addEventListener('blur', () => { + setTimeout(() => dropdown.classList.add('d-none'), 200); + }); +} + +async function qmAutocomplete(input, dropdown, selectedEl) { + const q = input.value; + if (q.length < 2) { dropdown.classList.add('d-none'); return; } + + try { + const res = await fetch(`/api/articles/search?q=${encodeURIComponent(q)}`); + const data = await res.json(); + if (!data.results || data.results.length === 0) { dropdown.classList.add('d-none'); return; } + + dropdown.innerHTML = data.results.map(r => + `
+ ${esc(r.codmat)}${esc(r.denumire)}${r.um ? ` (${esc(r.um)})` : ''} +
` + ).join(''); + dropdown.classList.remove('d-none'); + } catch { dropdown.classList.add('d-none'); } +} + +function qmSelectArticle(el, codmat, label) { + const line = el.closest('.qm-line'); + line.querySelector('.qm-codmat').value = codmat; + line.querySelector('.qm-selected').textContent = label; + line.querySelector('.qm-ac-dropdown').classList.add('d-none'); +} + +async function saveQuickMapping() { + const lines = document.querySelectorAll('.qm-line'); + const mappings = []; + + for (const line of lines) { + const codmat = line.querySelector('.qm-codmat').value.trim(); + const cantitate = parseFloat(line.querySelector('.qm-cantitate').value) || 1; + const procent = parseFloat(line.querySelector('.qm-procent').value) || 100; + if (!codmat) continue; + mappings.push({ codmat, cantitate_roa: cantitate, procent_pret: procent }); + } + + if (mappings.length === 0) { alert('Selecteaza cel putin un CODMAT'); return; } + + // Validate percentage sum for multi-line + if (mappings.length > 1) { + const totalPct = mappings.reduce((s, m) => s + m.procent_pret, 0); + if (Math.abs(totalPct - 100) > 0.01) { + document.getElementById('qmPctWarning').textContent = `Suma procentelor trebuie sa fie 100% (actual: ${totalPct.toFixed(2)}%)`; + document.getElementById('qmPctWarning').style.display = ''; + return; + } + } + document.getElementById('qmPctWarning').style.display = 'none'; + + try { + let res; + if (mappings.length === 1) { + res = await fetch('/api/mappings', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ sku: currentQmSku, codmat: mappings[0].codmat, cantitate_roa: mappings[0].cantitate_roa, procent_pret: mappings[0].procent_pret }) + }); + } else { + res = await fetch('/api/mappings/batch', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ sku: currentQmSku, mappings }) + }); + } + const data = await res.json(); + if (data.success) { + bootstrap.Modal.getInstance(document.getElementById('quickMapModal')).hide(); + // Refresh order detail items in the still-open modal + if (currentQmOrderNumber) openOrderDetail(currentQmOrderNumber); + // Refresh orders view + loadRunOrders(currentRunId, currentFilter, ordersPage); + } else { + alert('Eroare: ' + (data.error || 'Unknown')); + } + } catch (err) { + alert('Eroare: ' + err.message); + } } // ── Init ──────────────────────────────────────── @@ -381,25 +510,20 @@ function updateFilterCount(visible, total, filter) { document.addEventListener('DOMContentLoaded', () => { loadRuns(); - // Dropdown change - document.getElementById('runSelector').addEventListener('change', function() { - selectRun(this.value); - }); - - // Filter buttons - document.querySelectorAll('[data-filter]').forEach(btn => { - btn.addEventListener('click', function() { - document.querySelectorAll('[data-filter]').forEach(b => b.classList.remove('active')); - this.classList.add('active'); - applyFilter(this.dataset.filter); - }); - }); - - // Auto-select run from URL or server const preselected = document.getElementById('preselectedRun'); const urlParams = new URLSearchParams(window.location.search); const runFromUrl = urlParams.get('run') || (preselected ? preselected.value : ''); if (runFromUrl) { selectRun(runFromUrl); } + + document.getElementById('autoRefreshToggle')?.addEventListener('change', (e) => { + if (e.target.checked) { + // Resume polling if we have an active run + if (currentRunId) fetchTextLog(currentRunId); + } else { + // Pause polling + if (logPollTimer) { clearInterval(logPollTimer); logPollTimer = null; } + } + }); }); diff --git a/api/app/static/js/mappings.js b/api/app/static/js/mappings.js index 71a147c..55672e3 100644 --- a/api/app/static/js/mappings.js +++ b/api/app/static/js/mappings.js @@ -1,9 +1,16 @@ let currentPage = 1; let currentSearch = ''; let searchTimeout = null; +let sortColumn = 'sku'; +let sortDirection = 'asc'; +let editingMapping = null; // {sku, codmat} when editing // Load on page ready -document.addEventListener('DOMContentLoaded', loadMappings); +document.addEventListener('DOMContentLoaded', () => { + loadMappings(); + initAddModal(); + initDeleteModal(); +}); function debounceSearch() { clearTimeout(searchTimeout); @@ -14,52 +21,132 @@ function debounceSearch() { }, 300); } +// ── Sorting (R7) ───────────────────────────────── + +function sortBy(col) { + if (sortColumn === col) { + sortDirection = sortDirection === 'asc' ? 'desc' : 'asc'; + } else { + sortColumn = col; + sortDirection = 'asc'; + } + currentPage = 1; + loadMappings(); +} + +function updateSortIcons() { + document.querySelectorAll('.sort-icon').forEach(span => { + const col = span.dataset.col; + if (col === sortColumn) { + span.textContent = sortDirection === 'asc' ? '\u2191' : '\u2193'; + } else { + span.textContent = ''; + } + }); +} + +// ── Load & Render ──────────────────────────────── + async function loadMappings() { + const showInactive = document.getElementById('showInactive')?.checked; + const showDeleted = document.getElementById('showDeleted')?.checked; const params = new URLSearchParams({ search: currentSearch, page: currentPage, - per_page: 50 + per_page: 50, + sort_by: sortColumn, + sort_dir: sortDirection }); + if (showDeleted) params.set('show_deleted', 'true'); try { const res = await fetch(`/api/mappings?${params}`); const data = await res.json(); - renderTable(data.mappings); + + let mappings = data.mappings || []; + + // Client-side filter for inactive unless toggle is on + // (keep deleted rows visible when showDeleted is on, even if inactive) + if (!showInactive) { + mappings = mappings.filter(m => m.activ || m.sters); + } + + renderTable(mappings, showDeleted); renderPagination(data); + updateSortIcons(); } catch (err) { document.getElementById('mappingsBody').innerHTML = - `Eroare: ${err.message}`; + `Eroare: ${err.message}`; } } -function renderTable(mappings) { +function renderTable(mappings, showDeleted) { const tbody = document.getElementById('mappingsBody'); if (!mappings || mappings.length === 0) { - tbody.innerHTML = 'Nu exista mapari'; + tbody.innerHTML = 'Nu exista mapari'; return; } - tbody.innerHTML = mappings.map(m => ` - - ${esc(m.sku)} + // Group by SKU for visual grouping (R6) + let html = ''; + let prevSku = null; + let groupIdx = 0; + let skuGroupCounts = {}; + + // Count items per SKU + mappings.forEach(m => { + skuGroupCounts[m.sku] = (skuGroupCounts[m.sku] || 0) + 1; + }); + + mappings.forEach((m, i) => { + const isNewGroup = m.sku !== prevSku; + if (isNewGroup) groupIdx++; + const groupClass = groupIdx % 2 === 0 ? 'sku-group-even' : 'sku-group-odd'; + const isMulti = skuGroupCounts[m.sku] > 1; + const inactiveClass = !m.activ && !m.sters ? 'table-secondary opacity-75' : ''; + const deletedClass = m.sters ? 'mapping-deleted' : ''; + + // SKU cell: show only on first row of group + let skuCell, productCell; + if (isNewGroup) { + const badge = isMulti ? ` Set (${skuGroupCounts[m.sku]})` : ''; + skuCell = `${esc(m.sku)}${badge}`; + productCell = `${esc(m.product_name || '-')}`; + } else { + skuCell = ''; + productCell = ''; + } + + html += ` + ${skuCell} + ${productCell} ${esc(m.codmat)} ${esc(m.denumire || '-')} - ${m.cantitate_roa} - ${m.procent_pret}% + ${esc(m.um || '-')} + ${m.cantitate_roa} + ${m.procent_pret}% - + ${m.activ ? 'Activ' : 'Inactiv'} - ` : ` + + `} - - `).join(''); + `; + + prevSku = m.sku; + }); + + tbody.innerHTML = html; } function renderPagination(data) { @@ -70,11 +157,9 @@ function renderPagination(data) { if (data.pages <= 1) { ul.innerHTML = ''; return; } let html = ''; - // Previous html += `
  • «
  • `; - // Pages (show max 7) let start = Math.max(1, data.page - 3); let end = Math.min(data.pages, start + 6); start = Math.max(1, end - 6); @@ -84,7 +169,6 @@ function renderPagination(data) { ${i}`; } - // Next html += `
  • »
  • `; @@ -96,73 +180,186 @@ function goPage(p) { loadMappings(); } -// Autocomplete for CODMAT -let acTimeout = null; -document.addEventListener('DOMContentLoaded', () => { - const input = document.getElementById('inputCodmat'); - if (!input) return; +// ── Multi-CODMAT Add Modal (R11) ───────────────── + +let acTimeouts = {}; + +function initAddModal() { + const modal = document.getElementById('addModal'); + if (!modal) return; + + modal.addEventListener('show.bs.modal', () => { + if (!editingMapping) { + clearAddForm(); + } + }); + modal.addEventListener('hidden.bs.modal', () => { + editingMapping = null; + document.getElementById('addModalTitle').textContent = 'Adauga Mapare'; + }); +} + +function clearAddForm() { + document.getElementById('inputSku').value = ''; + document.getElementById('inputSku').readOnly = false; + document.getElementById('addModalProductName').style.display = 'none'; + document.getElementById('pctWarning').style.display = 'none'; + document.getElementById('addModalTitle').textContent = 'Adauga Mapare'; + const container = document.getElementById('codmatLines'); + container.innerHTML = ''; + addCodmatLine(); +} + +function openEditModal(sku, codmat, cantitate, procent) { + editingMapping = { sku, codmat }; + document.getElementById('addModalTitle').textContent = 'Editare Mapare'; + document.getElementById('inputSku').value = sku; + document.getElementById('inputSku').readOnly = false; + document.getElementById('pctWarning').style.display = 'none'; + + const container = document.getElementById('codmatLines'); + container.innerHTML = ''; + addCodmatLine(); + + // Pre-fill the CODMAT line + const line = container.querySelector('.codmat-line'); + if (line) { + line.querySelector('.cl-codmat').value = codmat; + line.querySelector('.cl-cantitate').value = cantitate; + line.querySelector('.cl-procent').value = procent; + } + + new bootstrap.Modal(document.getElementById('addModal')).show(); +} + +function addCodmatLine() { + const container = document.getElementById('codmatLines'); + const idx = container.children.length; + const div = document.createElement('div'); + div.className = 'border rounded p-2 mb-2 codmat-line'; + div.innerHTML = ` +
    + + +
    + +
    +
    +
    + + +
    +
    + + +
    +
    + ${idx > 0 ? `` : ''} +
    +
    + `; + container.appendChild(div); + + // Setup autocomplete + const input = div.querySelector('.cl-codmat'); + const dropdown = div.querySelector('.cl-ac-dropdown'); + const selected = div.querySelector('.cl-selected'); input.addEventListener('input', () => { - clearTimeout(acTimeout); - acTimeout = setTimeout(() => autocomplete(input.value), 250); + const key = 'cl_' + idx; + clearTimeout(acTimeouts[key]); + acTimeouts[key] = setTimeout(() => clAutocomplete(input, dropdown, selected), 250); }); - input.addEventListener('blur', () => { - setTimeout(() => document.getElementById('autocompleteDropdown').classList.add('d-none'), 200); + setTimeout(() => dropdown.classList.add('d-none'), 200); }); -}); +} -async function autocomplete(q) { - const dropdown = document.getElementById('autocompleteDropdown'); +async function clAutocomplete(input, dropdown, selectedEl) { + const q = input.value; if (q.length < 2) { dropdown.classList.add('d-none'); return; } try { const res = await fetch(`/api/articles/search?q=${encodeURIComponent(q)}`); const data = await res.json(); + if (!data.results || data.results.length === 0) { dropdown.classList.add('d-none'); return; } - if (!data.results || data.results.length === 0) { - dropdown.classList.add('d-none'); - return; - } - - dropdown.innerHTML = data.results.map(r => ` -
    - ${esc(r.codmat)} -
    ${esc(r.denumire)} -
    - `).join(''); + dropdown.innerHTML = data.results.map(r => + `
    + ${esc(r.codmat)}${esc(r.denumire)}${r.um ? ` (${esc(r.um)})` : ''} +
    ` + ).join(''); dropdown.classList.remove('d-none'); - } catch (err) { - dropdown.classList.add('d-none'); - } + } catch { dropdown.classList.add('d-none'); } } -function selectArticle(codmat, denumire) { - document.getElementById('inputCodmat').value = codmat; - document.getElementById('selectedArticle').textContent = denumire; - document.getElementById('autocompleteDropdown').classList.add('d-none'); +function clSelectArticle(el, codmat, label) { + const line = el.closest('.codmat-line'); + line.querySelector('.cl-codmat').value = codmat; + line.querySelector('.cl-selected').textContent = label; + line.querySelector('.cl-ac-dropdown').classList.add('d-none'); } -// Save mapping (create) async function saveMapping() { const sku = document.getElementById('inputSku').value.trim(); - const codmat = document.getElementById('inputCodmat').value.trim(); - const cantitate = parseFloat(document.getElementById('inputCantitate').value) || 1; - const procent = parseFloat(document.getElementById('inputProcent').value) || 100; + if (!sku) { alert('SKU este obligatoriu'); return; } - if (!sku || !codmat) { alert('SKU si CODMAT sunt obligatorii'); return; } + const lines = document.querySelectorAll('.codmat-line'); + const mappings = []; + + for (const line of lines) { + const codmat = line.querySelector('.cl-codmat').value.trim(); + const cantitate = parseFloat(line.querySelector('.cl-cantitate').value) || 1; + const procent = parseFloat(line.querySelector('.cl-procent').value) || 100; + if (!codmat) continue; + mappings.push({ codmat, cantitate_roa: cantitate, procent_pret: procent }); + } + + if (mappings.length === 0) { alert('Adauga cel putin un CODMAT'); return; } + + // Validate percentage for multi-line + if (mappings.length > 1) { + const totalPct = mappings.reduce((s, m) => s + m.procent_pret, 0); + if (Math.abs(totalPct - 100) > 0.01) { + document.getElementById('pctWarning').textContent = `Suma procentelor trebuie sa fie 100% (actual: ${totalPct.toFixed(2)}%)`; + document.getElementById('pctWarning').style.display = ''; + return; + } + } + document.getElementById('pctWarning').style.display = 'none'; try { - const res = await fetch('/api/mappings', { - method: 'POST', - headers: {'Content-Type': 'application/json'}, - body: JSON.stringify({ sku, codmat, cantitate_roa: cantitate, procent_pret: procent }) - }); - const data = await res.json(); + let res; + if (editingMapping) { + // Edit mode: use PUT /api/mappings/{old_sku}/{old_codmat}/edit + res = await fetch(`/api/mappings/${encodeURIComponent(editingMapping.sku)}/${encodeURIComponent(editingMapping.codmat)}/edit`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + new_sku: sku, + new_codmat: mappings[0].codmat, + cantitate_roa: mappings[0].cantitate_roa, + procent_pret: mappings[0].procent_pret + }) + }); + } else if (mappings.length === 1) { + res = await fetch('/api/mappings', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ sku, codmat: mappings[0].codmat, cantitate_roa: mappings[0].cantitate_roa, procent_pret: mappings[0].procent_pret }) + }); + } else { + res = await fetch('/api/mappings/batch', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ sku, mappings }) + }); + } + const data = await res.json(); if (data.success) { bootstrap.Modal.getInstance(document.getElementById('addModal')).hide(); - clearForm(); + editingMapping = null; loadMappings(); } else { alert('Eroare: ' + (data.error || 'Unknown')); @@ -172,17 +369,117 @@ async function saveMapping() { } } -function clearForm() { - document.getElementById('inputSku').value = ''; - document.getElementById('inputCodmat').value = ''; - document.getElementById('inputCantitate').value = '1'; - document.getElementById('inputProcent').value = '100'; - document.getElementById('selectedArticle').textContent = ''; +// ── Inline Add Row ────────────────────────────── + +let inlineAddVisible = false; + +function showInlineAddRow() { + if (inlineAddVisible) return; + inlineAddVisible = true; + + const tbody = document.getElementById('mappingsBody'); + const row = document.createElement('tr'); + row.id = 'inlineAddRow'; + row.className = 'table-info'; + row.innerHTML = ` + + + + + +
    + + + - + + + + + + + - + + + + + `; + tbody.insertBefore(row, tbody.firstChild); + document.getElementById('inlineSku').focus(); + + // Setup autocomplete for inline CODMAT + const input = document.getElementById('inlineCodmat'); + const dropdown = document.getElementById('inlineAcDropdown'); + const selected = document.getElementById('inlineSelected'); + let inlineAcTimeout = null; + + input.addEventListener('input', () => { + clearTimeout(inlineAcTimeout); + inlineAcTimeout = setTimeout(() => inlineAutocomplete(input, dropdown, selected), 250); + }); + input.addEventListener('blur', () => { + setTimeout(() => dropdown.classList.add('d-none'), 200); + }); } -// Inline edit +async function inlineAutocomplete(input, dropdown, selectedEl) { + const q = input.value; + if (q.length < 2) { dropdown.classList.add('d-none'); return; } + try { + const res = await fetch(`/api/articles/search?q=${encodeURIComponent(q)}`); + const data = await res.json(); + if (!data.results || data.results.length === 0) { dropdown.classList.add('d-none'); return; } + dropdown.innerHTML = data.results.map(r => + `
    + ${esc(r.codmat)}${esc(r.denumire)}${r.um ? ` (${esc(r.um)})` : ''} +
    ` + ).join(''); + dropdown.classList.remove('d-none'); + } catch { dropdown.classList.add('d-none'); } +} + +function inlineSelectArticle(codmat, label) { + document.getElementById('inlineCodmat').value = codmat; + document.getElementById('inlineSelected').textContent = label; + document.getElementById('inlineAcDropdown').classList.add('d-none'); +} + +async function saveInlineMapping() { + const sku = document.getElementById('inlineSku').value.trim(); + const codmat = document.getElementById('inlineCodmat').value.trim(); + const cantitate = parseFloat(document.getElementById('inlineCantitate').value) || 1; + const procent = parseFloat(document.getElementById('inlineProcent').value) || 100; + + if (!sku) { alert('SKU este obligatoriu'); return; } + if (!codmat) { alert('CODMAT este obligatoriu'); return; } + + try { + const res = await fetch('/api/mappings', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ sku, codmat, cantitate_roa: cantitate, procent_pret: procent }) + }); + const data = await res.json(); + if (data.success) { + cancelInlineAdd(); + loadMappings(); + } else { + alert('Eroare: ' + (data.error || 'Unknown')); + } + } catch (err) { + alert('Eroare: ' + err.message); + } +} + +function cancelInlineAdd() { + const row = document.getElementById('inlineAddRow'); + if (row) row.remove(); + inlineAddVisible = false; +} + +// ── Inline Edit ────────────────────────────────── + function editCell(td, sku, codmat, field, currentValue) { - if (td.querySelector('input')) return; // Already editing + if (td.querySelector('input')) return; const input = document.createElement('input'); input.type = 'number'; @@ -203,25 +500,18 @@ function editCell(td, sku, codmat, field, currentValue) { td.textContent = originalText; return; } - try { const body = {}; body[field] = newValue; const res = await fetch(`/api/mappings/${encodeURIComponent(sku)}/${encodeURIComponent(codmat)}`, { method: 'PUT', - headers: {'Content-Type': 'application/json'}, + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); const data = await res.json(); - if (data.success) { - loadMappings(); - } else { - td.textContent = originalText; - alert('Eroare: ' + (data.error || 'Update failed')); - } - } catch (err) { - td.textContent = originalText; - } + if (data.success) { loadMappings(); } + else { td.textContent = originalText; alert('Eroare: ' + (data.error || 'Update failed')); } + } catch (err) { td.textContent = originalText; } }; input.addEventListener('blur', save); @@ -231,34 +521,100 @@ function editCell(td, sku, codmat, field, currentValue) { }); } -// Toggle active +// ── Toggle Active with Toast Undo ──────────────── + async function toggleActive(sku, codmat, currentActive) { + const newActive = currentActive ? 0 : 1; try { const res = await fetch(`/api/mappings/${encodeURIComponent(sku)}/${encodeURIComponent(codmat)}`, { method: 'PUT', - headers: {'Content-Type': 'application/json'}, - body: JSON.stringify({ activ: currentActive ? 0 : 1 }) + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ activ: newActive }) + }); + const data = await res.json(); + if (!data.success) return; + + loadMappings(); + + // Show toast with undo + const action = newActive ? 'activata' : 'dezactivata'; + showUndoToast(`Mapare ${sku} \u2192 ${codmat} ${action}.`, () => { + fetch(`/api/mappings/${encodeURIComponent(sku)}/${encodeURIComponent(codmat)}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ activ: currentActive }) + }).then(() => loadMappings()); + }); + } catch (err) { alert('Eroare: ' + err.message); } +} + +function showUndoToast(message, undoCallback) { + document.getElementById('toastMessage').textContent = message; + const undoBtn = document.getElementById('toastUndoBtn'); + // Clone to remove old listeners + const newBtn = undoBtn.cloneNode(true); + undoBtn.parentNode.replaceChild(newBtn, undoBtn); + newBtn.id = 'toastUndoBtn'; + newBtn.addEventListener('click', () => { + undoCallback(); + const toastEl = document.getElementById('undoToast'); + const inst = bootstrap.Toast.getInstance(toastEl); + if (inst) inst.hide(); + }); + const toast = new bootstrap.Toast(document.getElementById('undoToast')); + toast.show(); +} + +// ── Delete with Modal Confirmation ────────────── + +let pendingDelete = null; + +function initDeleteModal() { + const btn = document.getElementById('confirmDeleteBtn'); + if (!btn) return; + btn.addEventListener('click', async () => { + if (!pendingDelete) return; + const { sku, codmat } = pendingDelete; + try { + const res = await fetch(`/api/mappings/${encodeURIComponent(sku)}/${encodeURIComponent(codmat)}`, { + method: 'DELETE' + }); + const data = await res.json(); + bootstrap.Modal.getInstance(document.getElementById('deleteConfirmModal')).hide(); + if (data.success) loadMappings(); + else alert('Eroare: ' + (data.error || 'Delete failed')); + } catch (err) { + bootstrap.Modal.getInstance(document.getElementById('deleteConfirmModal')).hide(); + alert('Eroare: ' + err.message); + } + pendingDelete = null; + }); +} + +function deleteMappingConfirm(sku, codmat) { + pendingDelete = { sku, codmat }; + document.getElementById('deleteSkuText').textContent = sku; + document.getElementById('deleteCodmatText').textContent = codmat; + new bootstrap.Modal(document.getElementById('deleteConfirmModal')).show(); +} + +// ── Restore Deleted ────────────────────────────── + +async function restoreMapping(sku, codmat) { + try { + const res = await fetch(`/api/mappings/${encodeURIComponent(sku)}/${encodeURIComponent(codmat)}/restore`, { + method: 'POST' }); const data = await res.json(); if (data.success) loadMappings(); + else alert('Eroare: ' + (data.error || 'Restore failed')); } catch (err) { alert('Eroare: ' + err.message); } } -// Delete (soft) -function deleteMappingConfirm(sku, codmat) { - if (confirm(`Dezactivezi maparea ${sku} -> ${codmat}?`)) { - fetch(`/api/mappings/${encodeURIComponent(sku)}/${encodeURIComponent(codmat)}`, { - method: 'DELETE' - }).then(r => r.json()).then(d => { - if (d.success) loadMappings(); - else alert('Eroare: ' + (d.error || 'Delete failed')); - }); - } -} +// ── CSV ────────────────────────────────────────── -// CSV import async function importCsv() { const fileInput = document.getElementById('csvFile'); if (!fileInput.files.length) { alert('Selecteaza un fisier CSV'); return; } @@ -267,12 +623,8 @@ async function importCsv() { formData.append('file', fileInput.files[0]); try { - const res = await fetch('/api/mappings/import-csv', { - method: 'POST', - body: formData - }); + const res = await fetch('/api/mappings/import-csv', { method: 'POST', body: formData }); const data = await res.json(); - let html = `
    Procesate: ${data.processed}
    `; if (data.errors && data.errors.length > 0) { html += `
    Erori:
      ${data.errors.map(e => `
    • ${esc(e)}
    • `).join('')}
    `; @@ -284,15 +636,9 @@ async function importCsv() { } } -function exportCsv() { - window.location.href = '/api/mappings/export-csv'; -} +function exportCsv() { window.location.href = '/api/mappings/export-csv'; } +function downloadTemplate() { window.location.href = '/api/mappings/csv-template'; } -function downloadTemplate() { - window.location.href = '/api/mappings/csv-template'; -} - -// Escape HTML function esc(s) { if (s == null) return ''; return String(s).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, '''); diff --git a/api/app/templates/dashboard.html b/api/app/templates/dashboard.html index 022b9ed..96b2932 100644 --- a/api/app/templates/dashboard.html +++ b/api/app/templates/dashboard.html @@ -3,76 +3,13 @@ {% block nav_dashboard %}active{% endblock %} {% block content %} -

    Dashboard

    - - -
    -
    -
    -
    -
    -
    Comenzi Noi
    -
    -
    -
    -
    -
    -
    -
    Ready
    -
    -
    -
    -
    -
    -
    -
    Importate
    -
    -
    -
    -
    -
    -
    -
    Fără Mapare
    -
    -
    -
    -
    -
    -
    -
    Erori Import
    -
    -
    -
    - - -
    -
    -
    -
    -
    -
    Total SKU Scanate
    -
    -
    -
    -
    -
    -
    -
    Cu Mapare
    -
    -
    -
    -
    -
    -
    -
    Fără Mapare
    -
    -
    -
    -
    -
    +

    Panou de Comanda

    Sync Control - + idle
    @@ -80,9 +17,6 @@ - @@ -113,86 +47,161 @@
    - + +
    +
    + Ultimul Sync + +
    +
    +
    +
    +
    Data
    -
    +
    Status
    -
    +
    Importate
    0
    +
    Omise
    0
    +
    Erori
    0
    +
    Durata
    -
    +
    +
    +
    +
    + +
    -
    Ultimele Sync Runs
    -
    -
    - - - - - - - - - - - - - - - -
    DataStatusTotalOKFără mapareEroriDurata
    Se incarca...
    -
    -
    -
    - - -
    - SKU-uri Lipsa - Vezi toate +
    + Comenzi +
    + + + + +
    +
    +
    + + +
    +
    +
    +
    + + + + + +
    - - - - - + + + + + + + + - - + +
    SKUProdusNr. ComenziPrimul ClientAcțiuneNr Comanda Data Client Art. Status Import ID ROAFacturaTotal
    Se incarca...
    Se incarca...
    +
    - -