feat(sync): add SSE live feed, unified logs page, fix Oracle connection

- Add SSE event bus in sync_service (subscribe/unsubscribe/_emit)
- Add GET /api/sync/stream SSE endpoint for real-time sync progress
- Rewrite logs.html: unified runs table + live feed + summary + filters
- Rewrite logs.js: SSE EventSource client, run selection, pagination
- Dashboard: clickable runs navigate to /logs?run=, sync started banner
- Remove "Import Comenzi" nav item, delete sync_detail.html
- Add error_message column to sync_runs table with migration
- Fix: export TNS_ADMIN as OS env var so oracledb finds tnsnames.ora
- Fix: use get_oracle_connection() instead of direct pool.acquire()
- Fix: CRM_POLITICI_PRET_ART INSERT to match actual table schema

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-11 18:08:09 +02:00
parent 97699fa0e5
commit 650e98539e
13 changed files with 638 additions and 359 deletions

View File

@@ -19,6 +19,10 @@ def init_oracle():
instantclient_path = settings.INSTANTCLIENTPATH
dsn = settings.ORACLE_DSN
# Ensure TNS_ADMIN is set as OS env var so oracledb can find tnsnames.ora
if settings.TNS_ADMIN:
os.environ['TNS_ADMIN'] = settings.TNS_ADMIN
if force_thin:
logger.info(f"FORCE_THIN_MODE=true: thin mode for {dsn}")
elif instantclient_path:
@@ -68,7 +72,8 @@ CREATE TABLE IF NOT EXISTS sync_runs (
imported INTEGER DEFAULT 0,
skipped INTEGER DEFAULT 0,
errors INTEGER DEFAULT 0,
json_files INTEGER DEFAULT 0
json_files INTEGER DEFAULT 0,
error_message TEXT
);
CREATE TABLE IF NOT EXISTS import_orders (
@@ -129,6 +134,12 @@ def init_sqlite():
if col not in cols:
conn.execute(f"ALTER TABLE missing_skus ADD COLUMN {col} {typedef}")
logger.info(f"Migrated missing_skus: added column {col}")
# Migrate sync_runs: add error_message column
cursor = conn.execute("PRAGMA table_info(sync_runs)")
sync_cols = {row[1] for row in cursor.fetchall()}
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}")

View File

@@ -1,6 +1,10 @@
import asyncio
import json
from fastapi import APIRouter, Request, BackgroundTasks
from fastapi.templating import Jinja2Templates
from fastapi.responses import HTMLResponse
from starlette.responses import StreamingResponse
from pydantic import BaseModel
from pathlib import Path
from typing import Optional
@@ -16,27 +20,46 @@ class ScheduleConfig(BaseModel):
interval_minutes: int = 5
# HTML pages
@router.get("/sync", response_class=HTMLResponse)
async def sync_page(request: Request):
return templates.TemplateResponse("dashboard.html", {"request": request})
# SSE streaming endpoint
@router.get("/api/sync/stream")
async def sync_stream(request: Request):
"""SSE stream for real-time sync progress."""
q = sync_service.subscribe()
async def event_generator():
try:
while True:
# Check if client disconnected
if await request.is_disconnected():
break
try:
event = await asyncio.wait_for(q.get(), timeout=15.0)
yield f"data: {json.dumps(event)}\n\n"
if event.get("type") in ("completed", "failed"):
break
except asyncio.TimeoutError:
yield f"data: {json.dumps({'type': 'keepalive'})}\n\n"
finally:
sync_service.unsubscribe(q)
@router.get("/sync/run/{run_id}", response_class=HTMLResponse)
async def sync_detail_page(request: Request, run_id: str):
return templates.TemplateResponse("sync_detail.html", {"request": request, "run_id": run_id})
return StreamingResponse(
event_generator(),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}
)
# API endpoints
@router.post("/api/sync/start")
async def start_sync(background_tasks: BackgroundTasks):
"""Trigger a sync run in the background."""
status = await sync_service.get_sync_status()
if status.get("status") == "running":
return {"error": "Sync already running", "run_id": status.get("run_id")}
result = await sync_service.prepare_sync()
if result.get("error"):
return {"error": result["error"], "run_id": result.get("run_id")}
background_tasks.add_task(sync_service.run_sync)
return {"message": "Sync started"}
run_id = result["run_id"]
background_tasks.add_task(sync_service.run_sync, run_id=run_id)
return {"message": "Sync started", "run_id": run_id}
@router.post("/api/sync/stop")
@@ -61,8 +84,8 @@ async def sync_history(page: int = 1, per_page: int = 20):
@router.get("/logs", response_class=HTMLResponse)
async def logs_page(request: Request):
return templates.TemplateResponse("logs.html", {"request": request})
async def logs_page(request: Request, run: str = None):
return templates.TemplateResponse("logs.html", {"request": request, "selected_run": run or ""})
@router.get("/api/sync/run/{run_id}")

View File

@@ -91,6 +91,8 @@ def import_single_order(order, id_pol: int = None, id_sectie: int = None) -> dic
order_number = clean_web_text(order.number)
order_date = convert_web_date(order.date)
if database.pool is None:
raise RuntimeError("Oracle pool not initialized")
with database.pool.acquire() as conn:
with conn.cursor() as cur:
# Step 1: Process partner

View File

@@ -20,7 +20,8 @@ async def create_sync_run(run_id: str, json_files: int = 0):
async def update_sync_run(run_id: str, status: str, total_orders: int = 0,
imported: int = 0, skipped: int = 0, errors: int = 0):
imported: int = 0, skipped: int = 0, errors: int = 0,
error_message: str = None):
"""Update sync run with results."""
db = await get_sqlite()
try:
@@ -31,9 +32,10 @@ async def update_sync_run(run_id: str, status: str, total_orders: int = 0,
total_orders = ?,
imported = ?,
skipped = ?,
errors = ?
errors = ?,
error_message = ?
WHERE run_id = ?
""", (status, total_orders, imported, skipped, errors, run_id))
""", (status, total_orders, imported, skipped, errors, error_message, run_id))
await db.commit()
finally:
await db.close()

View File

@@ -13,6 +13,33 @@ logger = logging.getLogger(__name__)
_sync_lock = asyncio.Lock()
_current_sync = None # dict with run_id, status, progress info
# SSE subscriber system
_subscribers: list[asyncio.Queue] = []
def subscribe() -> asyncio.Queue:
"""Subscribe to sync events. Returns a queue that will receive event dicts."""
q = asyncio.Queue()
_subscribers.append(q)
return q
def unsubscribe(q: asyncio.Queue):
"""Unsubscribe from sync events."""
try:
_subscribers.remove(q)
except ValueError:
pass
async def _emit(event: dict):
"""Push an event to all subscriber queues."""
for q in _subscribers:
try:
q.put_nowait(event)
except asyncio.QueueFull:
pass
async def get_sync_status():
"""Get current sync status."""
@@ -21,7 +48,25 @@ async def get_sync_status():
return {"status": "idle"}
async def run_sync(id_pol: int = None, id_sectie: int = None) -> dict:
async def prepare_sync(id_pol: int = None, id_sectie: int = None) -> dict:
"""Prepare a sync run - creates run_id and sets initial state.
Returns {"run_id": ..., "status": "starting"} or {"error": ...} if already running.
"""
global _current_sync
if _sync_lock.locked():
return {"error": "Sync already running", "run_id": _current_sync.get("run_id") if _current_sync else None}
run_id = datetime.now().strftime("%Y%m%d_%H%M%S") + "_" + uuid.uuid4().hex[:6]
_current_sync = {
"run_id": run_id,
"status": "running",
"started_at": datetime.now().isoformat(),
"progress": "Starting..."
}
return {"run_id": run_id, "status": "starting"}
async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None) -> dict:
"""Run a full sync cycle. Returns summary dict."""
global _current_sync
@@ -29,30 +74,33 @@ async def run_sync(id_pol: int = None, id_sectie: int = None) -> dict:
return {"error": "Sync already running"}
async with _sync_lock:
run_id = datetime.now().strftime("%Y%m%d_%H%M%S") + "_" + uuid.uuid4().hex[:6]
_current_sync = {
"run_id": run_id,
"status": "running",
"started_at": datetime.now().isoformat(),
"progress": "Reading JSON files..."
}
# Use provided run_id or generate one
if not run_id:
run_id = datetime.now().strftime("%Y%m%d_%H%M%S") + "_" + uuid.uuid4().hex[:6]
_current_sync = {
"run_id": run_id,
"status": "running",
"started_at": datetime.now().isoformat(),
"progress": "Reading JSON files..."
}
_current_sync["progress"] = "Reading JSON files..."
await _emit({"type": "phase", "run_id": run_id, "message": "Reading JSON files..."})
try:
# Step 1: Read orders
orders, json_count = order_reader.read_json_orders()
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"})
if not orders:
await sqlite_service.update_sync_run(run_id, "completed", 0, 0, 0, 0)
_current_sync = None
return {
"run_id": run_id,
"status": "completed",
"message": "No orders found",
"json_files": json_count
}
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})
return summary
_current_sync["progress"] = f"Validating {len(orders)} orders..."
await _emit({"type": "phase", "run_id": run_id, "message": f"Validating {len(orders)} orders..."})
# Step 2a: Find new orders (not yet in Oracle)
all_order_numbers = [o.number for o in orders]
@@ -65,6 +113,8 @@ async def run_sync(id_pol: int = None, id_sectie: int = None) -> dict:
validation = await asyncio.to_thread(validation_service.validate_skus, all_skus)
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)"})
# Step 2c: Build SKU context from skipped orders
sku_context = {} # {sku: {"orders": [], "customers": []}}
for order, missing_skus_list in skipped:
@@ -100,6 +150,7 @@ async def run_sync(id_pol: int = None, id_sectie: int = None) -> dict:
id_pol = id_pol or settings.ID_POL
if id_pol and importable:
_current_sync["progress"] = "Validating prices..."
await _emit({"type": "phase", "run_id": run_id, "message": "Validating prices..."})
# Gather all CODMATs from importable orders
all_codmats = set()
for order in importable:
@@ -124,7 +175,7 @@ async def run_sync(id_pol: int = None, id_sectie: int = None) -> dict:
price_result["missing_price"], id_pol
)
# Step 3: Record skipped orders
# Step 3: Record skipped orders + emit events
for order, missing_skus in skipped:
customer = order.billing.company_name or \
f"{order.billing.firstname} {order.billing.lastname}"
@@ -137,13 +188,20 @@ async def run_sync(id_pol: int = None, id_sectie: int = None) -> dict:
missing_skus=missing_skus,
items_count=len(order.items)
)
await _emit({
"type": "order_result", "run_id": run_id,
"order_number": order.number, "customer_name": customer,
"status": "SKIPPED", "missing_skus": missing_skus,
"items_count": len(order.items), "progress": f"0/{len(importable)}"
})
# Step 4: Import valid orders
imported_count = 0
error_count = 0
for i, order in enumerate(importable):
_current_sync["progress"] = f"Importing {i+1}/{len(importable)}: #{order.number}"
progress_str = f"{i+1}/{len(importable)}"
_current_sync["progress"] = f"Importing {progress_str}: #{order.number}"
result = await asyncio.to_thread(
import_service.import_single_order,
@@ -164,6 +222,12 @@ async def run_sync(id_pol: int = None, id_sectie: int = None) -> dict:
id_partener=result["id_partener"],
items_count=len(order.items)
)
await _emit({
"type": "order_result", "run_id": run_id,
"order_number": order.number, "customer_name": customer,
"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(
@@ -176,6 +240,12 @@ async def run_sync(id_pol: int = None, id_sectie: int = None) -> dict:
error_message=result["error"],
items_count=len(order.items)
)
await _emit({
"type": "order_result", "run_id": run_id,
"order_number": order.number, "customer_name": customer,
"status": "ERROR", "error_message": result["error"],
"items_count": len(order.items), "progress": progress_str
})
# Safety: stop if too many errors
if error_count > 10:
@@ -204,14 +274,22 @@ async def run_sync(id_pol: int = None, id_sectie: int = None) -> dict:
f"Sync {run_id} completed: {imported_count} imported, "
f"{len(skipped)} skipped, {error_count} errors"
)
await _emit({"type": "completed", "run_id": run_id, "summary": summary})
return summary
except Exception as e:
logger.error(f"Sync {run_id} failed: {e}")
await sqlite_service.update_sync_run(run_id, "failed", 0, 0, 0, 1)
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)})
return {"run_id": run_id, "status": "failed", "error": str(e)}
finally:
_current_sync = None
# Keep _current_sync for 10 seconds so status endpoint can show final result
async def _clear_current_sync():
await asyncio.sleep(10)
global _current_sync
_current_sync = None
asyncio.ensure_future(_clear_current_sync())
def stop_sync():

View File

@@ -17,7 +17,8 @@ def validate_skus(skus: set[str]) -> dict:
direct = set()
sku_list = list(skus)
with database.pool.acquire() as conn:
conn = database.get_oracle_connection()
try:
with conn.cursor() as cur:
# Check in batches of 500
for i in range(0, len(sku_list), 500):
@@ -44,6 +45,8 @@ def validate_skus(skus: set[str]) -> dict:
""", params2)
for row in cur:
direct.add(row[0])
finally:
database.pool.release(conn)
missing = skus - mapped - direct
@@ -80,7 +83,8 @@ def find_new_orders(order_numbers: list[str]) -> set[str]:
existing = set()
num_list = list(order_numbers)
with database.pool.acquire() as conn:
conn = database.get_oracle_connection()
try:
with conn.cursor() as cur:
for i in range(0, len(num_list), 500):
batch = num_list[i:i+500]
@@ -93,6 +97,8 @@ def find_new_orders(order_numbers: list[str]) -> set[str]:
""", params)
for row in cur:
existing.add(row[0])
finally:
database.pool.release(conn)
new_orders = set(order_numbers) - existing
logger.info(f"Order check: {len(new_orders)} new, {len(existing)} already exist out of {len(order_numbers)} total")
@@ -109,7 +115,8 @@ def validate_prices(codmats: set[str], id_pol: int) -> dict:
ids_with_price = set()
codmat_list = list(codmats)
with database.pool.acquire() as conn:
conn = database.get_oracle_connection()
try:
with conn.cursor() as cur:
# Step 1: Get ID_ARTICOL for each CODMAT
for i in range(0, len(codmat_list), 500):
@@ -138,6 +145,8 @@ def validate_prices(codmats: set[str], id_pol: int) -> dict:
""", params)
for row in cur:
ids_with_price.add(row[0])
finally:
database.pool.release(conn)
# Map back to CODMATs
has_price = {cm for cm, aid in codmat_to_id.items() if aid in ids_with_price}
@@ -151,7 +160,8 @@ def ensure_prices(codmats: set[str], id_pol: int):
if not codmats:
return
with database.pool.acquire() as conn:
conn = database.get_oracle_connection()
try:
with conn.cursor() as cur:
# Get ID_VALUTA for this policy
cur.execute("""
@@ -176,16 +186,18 @@ def ensure_prices(codmats: set[str], id_pol: int):
cur.execute("""
INSERT INTO CRM_POLITICI_PRET_ART
(ID_POL_ART, ID_POL, ID_ARTICOL, PRET, ID_COMANDA, ID_VALUTA,
ID_UTIL, DATAORA, PROC_TVAV, ID_PARTR, ID_PARTZ,
PRETFTVA, PRETCTVA, CANTITATE, ID_UM, PRET_MIN, PRET_MIN_TVA)
(ID_POL_ART, ID_POL, ID_ARTICOL, PRET, ID_VALUTA,
ID_UTIL, DATAORA, PROC_TVAV,
PRETFTVA, PRETCTVA)
VALUES
(SEQ_CRM_POLITICI_PRET_ART.NEXTVAL, :id_pol, :id_articol, 0, NULL, :id_valuta,
-3, SYSDATE, 1.19, NULL, NULL,
0, 0, 0, NULL, 0, 0)
(SEQ_CRM_POLITICI_PRET_ART.NEXTVAL, :id_pol, :id_articol, 0, :id_valuta,
-3, SYSDATE, 1.19,
0, 0)
""", {"id_pol": id_pol, "id_articol": id_articol, "id_valuta": id_valuta})
logger.info(f"Pret 0 adaugat pentru CODMAT {codmat} in politica {id_pol}")
conn.commit()
finally:
database.pool.release(conn)
logger.info(f"Ensure prices done: {len(codmats)} CODMATs processed for policy {id_pol}")

View File

@@ -212,3 +212,73 @@ body {
align-items: center;
justify-content: center;
}
/* Live Feed */
.live-feed {
max-height: 300px;
overflow-y: auto;
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; }
}
/* Clickable table rows */
.table-hover tbody tr[data-href] {
cursor: pointer;
}
.table-hover tbody tr[data-href]:hover {
background-color: #e2e8f0;
}

View File

@@ -114,7 +114,7 @@ async function loadSyncHistory() {
}
const statusClass = r.status === 'completed' ? 'bg-success' : r.status === 'running' ? 'bg-primary' : 'bg-danger';
return `<tr style="cursor:pointer" onclick="window.location='/sync/run/${esc(r.run_id)}'">
return `<tr style="cursor:pointer" onclick="window.location='/logs?run=${esc(r.run_id)}'">
<td>${started}</td>
<td><span class="badge ${statusClass}">${esc(r.status)}</span></td>
<td>${r.total_orders || 0}</td>
@@ -192,6 +192,16 @@ async function startSync() {
const data = await res.json();
if (data.error) {
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');
if (banner && link) {
link.href = '/logs?run=' + encodeURIComponent(data.run_id);
banner.classList.remove('d-none');
}
}
loadDashboard();
} catch (err) {

View File

@@ -1,15 +1,25 @@
// logs.js - Jurnale Import page logic
// logs.js - Unified Logs page with SSE live feed
let currentRunId = null;
let eventSource = null;
let runsPage = 1;
let liveCounts = { imported: 0, skipped: 0, errors: 0, total: 0 };
function esc(s) {
if (s == null) return '';
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/&/g, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
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 {
@@ -17,9 +27,7 @@ function fmtDatetime(iso) {
day: '2-digit', month: '2-digit', year: 'numeric',
hour: '2-digit', minute: '2-digit'
});
} catch (e) {
return iso;
}
} catch (e) { return iso; }
}
function fmtDuration(startedAt, finishedAt) {
@@ -41,50 +49,252 @@ function statusBadge(status) {
}
function runStatusBadge(status) {
switch ((status || '').toUpperCase()) {
case 'SUCCESS': return '<span class="badge bg-success ms-1">SUCCESS</span>';
case 'ERROR': return '<span class="badge bg-danger ms-1">ERROR</span>';
case 'RUNNING': return '<span class="badge bg-primary ms-1">RUNNING</span>';
case 'PARTIAL': return '<span class="badge bg-warning text-dark ms-1">PARTIAL</span>';
default: return `<span class="badge bg-secondary ms-1">${esc(status || '')}</span>`;
switch ((status || '').toLowerCase()) {
case 'completed': return '<span class="badge bg-success">completed</span>';
case 'running': return '<span class="badge bg-primary">running</span>';
case 'failed': return '<span class="badge bg-danger">failed</span>';
default: return `<span class="badge bg-secondary">${esc(status)}</span>`;
}
}
async function loadRuns() {
const sel = document.getElementById('runSelector');
sel.innerHTML = '<option value="">Se incarca...</option>';
// ── Runs Table ──────────────────────────────────
async function loadRuns(page) {
if (page != null) runsPage = page;
const perPage = 20;
try {
const res = await fetch('/api/sync/history?per_page=20');
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 || [];
if (runs.length === 0) {
sel.innerHTML = '<option value="">Nu exista sync runs</option>';
return;
}
const total = data.total || runs.length;
// Populate dropdown
const sel = document.getElementById('runSelector');
sel.innerHTML = '<option value="">-- Selecteaza un sync run --</option>' +
runs.map(r => {
const date = fmtDatetime(r.started_at);
const stats = `${r.total_orders || 0} total / ${r.imported || 0} ok / ${r.errors || 0} err`;
const statusText = (r.status || '').toUpperCase();
return `<option value="${esc(r.run_id)}">[${statusText}] ${date}${stats}</option>`;
return `<option value="${esc(r.run_id)}"${r.run_id === currentRunId ? ' selected' : ''}>[${(r.status||'').toUpperCase()}] ${date}${stats}</option>`;
}).join('');
// Populate table
const tbody = document.getElementById('runsTableBody');
if (runs.length === 0) {
tbody.innerHTML = '<tr><td colspan="7" class="text-center text-muted py-3">Niciun sync run</td></tr>';
} 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 `<tr class="${activeClass}" data-href="/logs?run=${esc(r.run_id)}" onclick="selectRun('${esc(r.run_id)}')">
<td>${started}</td>
<td><span class="badge ${statusClass}">${esc(r.status)}</span></td>
<td>${r.total_orders || 0}</td>
<td class="text-success">${r.imported || 0}</td>
<td class="text-warning">${r.skipped || 0}</td>
<td class="text-danger">${r.errors || 0}</td>
<td>${duration}</td>
</tr>`;
}).join('');
}
// Pagination
const pagDiv = document.getElementById('runsTablePagination');
const totalPages = Math.ceil(total / perPage);
if (totalPages > 1) {
pagDiv.innerHTML = `
<button class="btn btn-sm btn-outline-secondary" ${runsPage <= 1 ? 'disabled' : ''} onclick="loadRuns(${runsPage - 1})"><i class="bi bi-chevron-left"></i></button>
<small class="text-muted">${runsPage} / ${totalPages}</small>
<button class="btn btn-sm btn-outline-secondary" ${runsPage >= totalPages ? 'disabled' : ''} onclick="loadRuns(${runsPage + 1})"><i class="bi bi-chevron-right"></i></button>
`;
} else {
pagDiv.innerHTML = '';
}
} catch (err) {
sel.innerHTML = '<option value="">Eroare la incarcare: ' + esc(err.message) + '</option>';
document.getElementById('runsTableBody').innerHTML = `<tr><td colspan="7" class="text-center text-danger py-3">${esc(err.message)}</td></tr>`;
}
}
// ── Run Selection ───────────────────────────────
async function selectRun(runId) {
if (eventSource) { eventSource.close(); eventSource = null; }
currentRunId = runId;
// 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 || '';
if (!runId) {
document.getElementById('runDetailSection').style.display = 'none';
return;
}
document.getElementById('runDetailSection').style.display = '';
// 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 */ }
// Load historical data
document.getElementById('liveFeedCard').style.display = 'none';
await loadRunLog(runId);
}
// ── Live SSE Feed ───────────────────────────────
function startLiveFeed(runId) {
liveCounts = { imported: 0, skipped: 0, errors: 0, total: 0 };
// 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 = `<span class="feed-time">${now}</span><span class="feed-icon">${icon}</span><span class="feed-msg">${esc(message)}</span>`;
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 = `<span class="text-danger">${esc(event.error_message)}</span>`;
}
if (event.missing_skus && Array.isArray(event.missing_skus) && event.missing_skus.length > 0) {
details += `<div class="mt-1">${event.missing_skus.map(s => `<code class="me-1 text-warning">${esc(s)}</code>`).join('')}</div>`;
}
if (event.id_comanda) {
details += `<small class="text-success">ID: ${event.id_comanda}</small>`;
}
if (!details) details = '<span class="text-muted">-</span>';
const tr = document.createElement('tr');
tr.setAttribute('data-status', status);
tr.innerHTML = `
<td><code>${esc(event.order_number || '-')}</code></td>
<td>${esc(event.customer_name || '-')}</td>
<td class="text-center">${event.items_count ?? '-'}</td>
<td>${statusBadge(status)}</td>
<td>${details}</td>
`;
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');
const filterRow = document.getElementById('filterRow');
const runSummary = document.getElementById('runSummary');
tbody.innerHTML = '<tr><td colspan="5" class="text-center text-muted py-4"><div class="spinner-border spinner-border-sm me-2"></div>Se incarca...</td></tr>';
filterRow.style.display = 'none';
runSummary.style.display = 'none';
try {
const res = await fetch(`/api/sync/run/${encodeURIComponent(runId)}/log`);
@@ -95,40 +305,33 @@ async function loadRunLog(runId) {
const orders = data.orders || [];
// Populate summary bar
document.getElementById('sum-total').textContent = run.total_orders ?? '-';
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-skipped').textContent = run.skipped ?? '-';
document.getElementById('sum-errors').textContent = run.errors ?? '-';
document.getElementById('sum-duration').textContent = fmtDuration(run.started_at, run.finished_at);
runSummary.style.display = '';
if (orders.length === 0) {
tbody.innerHTML = '<tr><td colspan="5" class="text-center text-muted py-4">Nicio comanda in acest sync run</td></tr>';
filterRow.style.display = 'none';
const runError = run.error_message
? `<tr><td colspan="5" class="text-center py-4"><span class="text-danger"><i class="bi bi-exclamation-triangle me-1"></i>${esc(run.error_message)}</span></td></tr>`
: '<tr><td colspan="5" class="text-center text-muted py-4">Nicio comanda in acest sync run</td></tr>';
tbody.innerHTML = runError;
updateFilterCount();
return;
}
tbody.innerHTML = orders.map(order => {
const status = (order.status || '').toUpperCase();
// Parse missing_skus — API returns JSON string or null
let missingSkuTags = '';
if (order.missing_skus) {
try {
const skus = typeof order.missing_skus === 'string'
? JSON.parse(order.missing_skus)
: order.missing_skus;
const skus = typeof order.missing_skus === 'string' ? JSON.parse(order.missing_skus) : order.missing_skus;
if (Array.isArray(skus) && skus.length > 0) {
missingSkuTags = '<div class="mt-1">' +
skus.map(s => `<code class="me-1 text-warning">${esc(s)}</code>`).join('') +
'</div>';
skus.map(s => `<code class="me-1 text-warning">${esc(s)}</code>`).join('') + '</div>';
}
} catch (e) {
// malformed JSON — skip
}
} catch (e) { /* skip */ }
}
const details = order.error_message
? `<span class="text-danger">${esc(order.error_message)}</span>${missingSkuTags}`
: missingSkuTags || '<span class="text-muted">-</span>';
@@ -142,75 +345,61 @@ async function loadRunLog(runId) {
</tr>`;
}).join('');
filterRow.style.display = '';
// Reset filter to "Toate"
// Reset filter
document.querySelectorAll('[data-filter]').forEach(btn => {
btn.classList.toggle('active', btn.dataset.filter === 'all');
});
applyFilter('all');
} catch (err) {
tbody.innerHTML = `<tr><td colspan="5" class="text-center text-danger py-3">
<i class="bi bi-exclamation-triangle me-1"></i>${esc(err.message)}
</td></tr>`;
filterRow.style.display = 'none';
runSummary.style.display = 'none';
tbody.innerHTML = `<tr><td colspan="5" class="text-center text-danger py-3"><i class="bi bi-exclamation-triangle me-1"></i>${esc(err.message)}</td></tr>`;
}
}
// ── 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 updateFilterCount(visible, total, filter) {
const el = document.getElementById('filterCount');
if (!el) return;
if (visible == null) {
el.textContent = '';
return;
}
if (filter === 'all') {
el.textContent = `${total} comenzi`;
} else {
el.textContent = `${visible} din ${total} comenzi`;
}
if (visible == null) { el.textContent = ''; return; }
el.textContent = filter === 'all' ? `${total} comenzi` : `${visible} din ${total} comenzi`;
}
// ── Init ────────────────────────────────────────
document.addEventListener('DOMContentLoaded', () => {
loadRuns();
// Dropdown change
document.getElementById('runSelector').addEventListener('change', function () {
const runId = this.value;
if (!runId) {
document.getElementById('logsBody').innerHTML = `<tr id="emptyState">
<td colspan="5" class="text-center text-muted py-5">
<i class="bi bi-journal-text fs-2 d-block mb-2 text-muted opacity-50"></i>
Selecteaza un sync run din lista de sus
</td>
</tr>`;
document.getElementById('filterRow').style.display = 'none';
document.getElementById('runSummary').style.display = 'none';
return;
}
loadRunLog(runId);
document.getElementById('runSelector').addEventListener('change', function() {
selectRun(this.value);
});
// Filter buttons
document.querySelectorAll('[data-filter]').forEach(btn => {
btn.addEventListener('click', function () {
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);
}
});

View File

@@ -20,11 +20,6 @@
<i class="bi bi-speedometer2"></i> Dashboard
</a>
</li>
<li class="nav-item">
<a class="nav-link {% block nav_sync %}{% endblock %}" href="/sync">
<i class="bi bi-arrow-repeat"></i> Import Comenzi
</a>
</li>
<li class="nav-item">
<a class="nav-link {% block nav_mappings %}{% endblock %}" href="/mappings">
<i class="bi bi-link-45deg"></i> Mapari SKU

View File

@@ -105,6 +105,11 @@
<small class="text-muted" id="syncProgressText"></small>
</div>
</div>
<div class="mt-2 d-none" id="syncStartedBanner">
<div class="alert alert-info alert-sm py-1 px-2 mb-0 d-inline-block">
<small><i class="bi bi-broadcast"></i> Sync pornit — <a href="#" id="syncRunLink">vezi progresul live</a></small>
</div>
</div>
</div>
</div>

View File

@@ -15,85 +15,125 @@
</div>
</div>
<!-- Filter buttons -->
<div class="mb-3" id="filterRow" style="display:none;">
<div class="btn-group btn-group-sm" role="group">
<button type="button" class="btn btn-outline-secondary active" data-filter="all">
<i class="bi bi-list-ul"></i> Toate
</button>
<button type="button" class="btn btn-outline-success" data-filter="IMPORTED">
<i class="bi bi-check-circle"></i> Importate
</button>
<button type="button" class="btn btn-outline-warning" data-filter="SKIPPED">
<i class="bi bi-skip-forward"></i> Fara Mapare
</button>
<button type="button" class="btn btn-outline-danger" data-filter="ERROR">
<i class="bi bi-x-circle"></i> Erori
</button>
<!-- Sync Runs Table (always visible) -->
<div class="card mb-4">
<div class="card-header d-flex justify-content-between align-items-center">
<span>Sync Runs</span>
<div id="runsTablePagination" class="d-flex align-items-center gap-2"></div>
</div>
<small class="text-muted ms-3" id="filterCount"></small>
</div>
<!-- Run summary bar -->
<div class="row g-3 mb-3" id="runSummary" style="display:none;">
<div class="col-auto">
<div class="card stat-card px-3 py-2">
<div class="stat-value text-primary" id="sum-total" style="font-size:1.25rem;">-</div>
<div class="stat-label">Total</div>
</div>
</div>
<div class="col-auto">
<div class="card stat-card px-3 py-2">
<div class="stat-value text-success" id="sum-imported" style="font-size:1.25rem;">-</div>
<div class="stat-label">Importate</div>
</div>
</div>
<div class="col-auto">
<div class="card stat-card px-3 py-2">
<div class="stat-value text-warning" id="sum-skipped" style="font-size:1.25rem;">-</div>
<div class="stat-label">Omise</div>
</div>
</div>
<div class="col-auto">
<div class="card stat-card px-3 py-2">
<div class="stat-value text-danger" id="sum-errors" style="font-size:1.25rem;">-</div>
<div class="stat-label">Erori</div>
</div>
</div>
<div class="col-auto">
<div class="card stat-card px-3 py-2">
<div class="stat-value text-secondary" id="sum-duration" style="font-size:1.25rem;">-</div>
<div class="stat-label">Durata</div>
</div>
</div>
</div>
<!-- Orders table -->
<div class="card">
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover mb-0" id="logsTable">
<table class="table table-hover mb-0">
<thead>
<tr>
<th style="width:140px;">Nr. Comanda</th>
<th>Client</th>
<th style="width:100px;" class="text-center">Nr. Articole</th>
<th style="width:120px;">Status</th>
<th>Eroare / Detalii</th>
<th>Data</th>
<th>Status</th>
<th>Total</th>
<th>OK</th>
<th>Fara mapare</th>
<th>Erori</th>
<th>Durata</th>
</tr>
</thead>
<tbody id="logsBody">
<tr id="emptyState">
<td colspan="5" class="text-center text-muted py-5">
<i class="bi bi-journal-text fs-2 d-block mb-2 text-muted opacity-50"></i>
Selecteaza un sync run din lista de sus
</td>
</tr>
<tbody id="runsTableBody">
<tr><td colspan="7" class="text-center text-muted py-3">Se incarca...</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- Run Detail Section (shown when run selected or live sync) -->
<div id="runDetailSection" style="display:none;">
<!-- Run Summary Bar -->
<div class="row g-3 mb-3" id="runSummary">
<div class="col-auto">
<div class="card stat-card px-3 py-2">
<div class="stat-value text-primary" id="sum-total" style="font-size:1.25rem;">-</div>
<div class="stat-label">Total</div>
</div>
</div>
<div class="col-auto">
<div class="card stat-card px-3 py-2">
<div class="stat-value text-success" id="sum-imported" style="font-size:1.25rem;">-</div>
<div class="stat-label">Importate</div>
</div>
</div>
<div class="col-auto">
<div class="card stat-card px-3 py-2">
<div class="stat-value text-warning" id="sum-skipped" style="font-size:1.25rem;">-</div>
<div class="stat-label">Omise</div>
</div>
</div>
<div class="col-auto">
<div class="card stat-card px-3 py-2">
<div class="stat-value text-danger" id="sum-errors" style="font-size:1.25rem;">-</div>
<div class="stat-label">Erori</div>
</div>
</div>
<div class="col-auto">
<div class="card stat-card px-3 py-2">
<div class="stat-value text-secondary" id="sum-duration" style="font-size:1.25rem;">-</div>
<div class="stat-label">Durata</div>
</div>
</div>
</div>
<!-- Live Feed (visible only during active sync) -->
<div class="card mb-3" id="liveFeedCard" style="display:none;">
<div class="card-header">
<i class="bi bi-broadcast"></i> Live Feed
<span class="badge bg-danger ms-2 live-pulse">LIVE</span>
</div>
<div class="card-body p-0">
<div class="live-feed" id="liveFeed"></div>
</div>
</div>
<!-- Filter buttons -->
<div class="mb-3" id="filterRow">
<div class="btn-group btn-group-sm" role="group">
<button type="button" class="btn btn-outline-secondary active" data-filter="all">
<i class="bi bi-list-ul"></i> Toate
</button>
<button type="button" class="btn btn-outline-success" data-filter="IMPORTED">
<i class="bi bi-check-circle"></i> Importate
</button>
<button type="button" class="btn btn-outline-warning" data-filter="SKIPPED">
<i class="bi bi-skip-forward"></i> Fara Mapare
</button>
<button type="button" class="btn btn-outline-danger" data-filter="ERROR">
<i class="bi bi-x-circle"></i> Erori
</button>
</div>
<small class="text-muted ms-3" id="filterCount"></small>
</div>
<!-- Orders table -->
<div class="card">
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover mb-0" id="logsTable">
<thead>
<tr>
<th style="width:140px;">Nr. Comanda</th>
<th>Client</th>
<th style="width:100px;" class="text-center">Nr. Articole</th>
<th style="width:120px;">Status</th>
<th>Eroare / Detalii</th>
</tr>
</thead>
<tbody id="logsBody">
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- Hidden field for pre-selected run from URL/server -->
<input type="hidden" id="preselectedRun" value="{{ selected_run }}">
{% endblock %}
{% block scripts %}

View File

@@ -1,158 +0,0 @@
{% extends "base.html" %}
{% block title %}Sync Run - GoMag Import{% endblock %}
{% block nav_sync %}active{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<a href="/" class="text-decoration-none text-muted"><i class="bi bi-arrow-left"></i> Dashboard</a>
<h4 class="mb-0 mt-1">Sync Run <small class="text-muted" id="runId">{{ run_id }}</small></h4>
</div>
<span class="badge bg-secondary fs-6" id="runStatusBadge">-</span>
</div>
<!-- Run summary -->
<div class="row g-3 mb-4">
<div class="col-md-3">
<div class="card stat-card">
<div class="stat-value" id="runTotal">-</div>
<div class="stat-label">Total Comenzi</div>
</div>
</div>
<div class="col-md-3">
<div class="card stat-card">
<div class="stat-value text-success" id="runImported">-</div>
<div class="stat-label">Imported</div>
</div>
</div>
<div class="col-md-3">
<div class="card stat-card">
<div class="stat-value text-warning" id="runSkipped">-</div>
<div class="stat-label">Skipped</div>
</div>
</div>
<div class="col-md-3">
<div class="card stat-card">
<div class="stat-value text-danger" id="runErrors">-</div>
<div class="stat-label">Errors</div>
</div>
</div>
</div>
<div class="mb-3">
<small class="text-muted" id="runTiming"></small>
</div>
<!-- Orders table -->
<div class="card">
<div class="card-header">Comenzi</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead>
<tr>
<th>#</th>
<th>Nr Comanda</th>
<th>Data</th>
<th>Client</th>
<th>Articole</th>
<th>Status</th>
<th>Detalii</th>
</tr>
</thead>
<tbody id="ordersBody">
<tr><td colspan="7" class="text-center text-muted py-4">Se incarca...</td></tr>
</tbody>
</table>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
const RUN_ID = '{{ run_id }}';
document.addEventListener('DOMContentLoaded', loadRunDetail);
async function loadRunDetail() {
try {
const res = await fetch(`/api/sync/run/${RUN_ID}`);
const data = await res.json();
if (data.error) {
document.getElementById('ordersBody').innerHTML =
`<tr><td colspan="7" class="text-center text-danger">${data.error}</td></tr>`;
return;
}
const run = data.run;
// Update summary
document.getElementById('runTotal').textContent = run.total_orders || 0;
document.getElementById('runImported').textContent = run.imported || 0;
document.getElementById('runSkipped').textContent = run.skipped || 0;
document.getElementById('runErrors').textContent = run.errors || 0;
const badge = document.getElementById('runStatusBadge');
badge.textContent = run.status;
badge.className = 'badge fs-6 ' + (run.status === 'completed' ? 'bg-success' : run.status === 'running' ? 'bg-primary' : 'bg-danger');
// Timing
if (run.started_at) {
let timing = 'Start: ' + new Date(run.started_at).toLocaleString('ro-RO');
if (run.finished_at) {
const sec = Math.round((new Date(run.finished_at) - new Date(run.started_at)) / 1000);
timing += ` | Durata: ${sec < 60 ? sec + 's' : Math.floor(sec/60) + 'm ' + (sec%60) + 's'}`;
}
document.getElementById('runTiming').textContent = timing;
}
// Orders table
const orders = data.orders || [];
if (orders.length === 0) {
document.getElementById('ordersBody').innerHTML =
'<tr><td colspan="7" class="text-center text-muted py-4">Nicio comanda</td></tr>';
return;
}
document.getElementById('ordersBody').innerHTML = orders.map((o, i) => {
const statusClass = o.status === 'IMPORTED' ? 'badge-imported' : o.status === 'SKIPPED' ? 'badge-skipped' : 'badge-error';
let details = '';
if (o.status === 'IMPORTED' && o.id_comanda) {
details = `<small class="text-success">ID: ${o.id_comanda}</small>`;
} else if (o.status === 'SKIPPED' && o.missing_skus) {
try {
const skus = JSON.parse(o.missing_skus);
details = `<small class="text-warning">SKU lipsa: ${skus.map(s => '<code>' + esc(s) + '</code>').join(', ')}</small>`;
} catch(e) {
details = `<small class="text-warning">${esc(o.missing_skus)}</small>`;
}
} else if (o.status === 'ERROR' && o.error_message) {
details = `<small class="text-danger">${esc(o.error_message).substring(0, 100)}</small>`;
}
return `<tr>
<td>${i + 1}</td>
<td><strong>${esc(o.order_number)}</strong></td>
<td><small>${o.order_date ? o.order_date.substring(0, 10) : '-'}</small></td>
<td>${esc(o.customer_name)}</td>
<td>${o.items_count || '-'}</td>
<td><span class="badge ${statusClass}">${o.status}</span></td>
<td>${details}</td>
</tr>`;
}).join('');
} catch (err) {
document.getElementById('ordersBody').innerHTML =
`<tr><td colspan="7" class="text-center text-danger">Eroare: ${err.message}</td></tr>`;
}
}
function esc(s) {
if (s == null) return '';
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
</script>
{% endblock %}