feat(dashboard): redesign UI with smart polling, unified sync card, filter bar
Replace SSE with smart polling (30s idle / 3s when running). Unify sync panel into single two-row card with live progress text. Add unified filter bar (period dropdown, status pills, search) with period-total counts. Add Client/Cont tooltip for different shipping/billing persons. Add SKU mappings pct_total badges + complete/incomplete filter + 409 duplicate check. Add missing SKUs search + rescan progress UX. Migrate SQLite orders schema (shipping_name, billing_name, payment_method, delivery_method). Fix JSON_OUTPUT_DIR path for server running from project root. Fix pagination controls showing top+bottom with per-page selector (25/50/100/250). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -5,7 +5,6 @@ from datetime import datetime
|
||||
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
|
||||
@@ -21,35 +20,6 @@ class ScheduleConfig(BaseModel):
|
||||
interval_minutes: int = 5
|
||||
|
||||
|
||||
# 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)
|
||||
|
||||
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):
|
||||
@@ -72,10 +42,68 @@ async def stop_sync():
|
||||
|
||||
@router.get("/api/sync/status")
|
||||
async def sync_status():
|
||||
"""Get current sync status."""
|
||||
"""Get current sync status with progress details and last_run info."""
|
||||
status = await sync_service.get_sync_status()
|
||||
stats = await sqlite_service.get_dashboard_stats()
|
||||
return {**status, "stats": stats}
|
||||
|
||||
# Build last_run from most recent completed/failed sync_runs row
|
||||
current_run_id = status.get("run_id")
|
||||
last_run = None
|
||||
try:
|
||||
from ..database import get_sqlite
|
||||
db = await get_sqlite()
|
||||
try:
|
||||
if current_run_id:
|
||||
cursor = await db.execute("""
|
||||
SELECT * FROM sync_runs
|
||||
WHERE status IN ('completed', 'failed') AND run_id != ?
|
||||
ORDER BY started_at DESC LIMIT 1
|
||||
""", (current_run_id,))
|
||||
else:
|
||||
cursor = await db.execute("""
|
||||
SELECT * FROM sync_runs
|
||||
WHERE status IN ('completed', 'failed')
|
||||
ORDER BY started_at DESC LIMIT 1
|
||||
""")
|
||||
row = await cursor.fetchone()
|
||||
if row:
|
||||
row_dict = dict(row)
|
||||
duration_seconds = None
|
||||
if row_dict.get("started_at") and row_dict.get("finished_at"):
|
||||
try:
|
||||
dt_start = datetime.fromisoformat(row_dict["started_at"])
|
||||
dt_end = datetime.fromisoformat(row_dict["finished_at"])
|
||||
duration_seconds = int((dt_end - dt_start).total_seconds())
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
last_run = {
|
||||
"run_id": row_dict.get("run_id"),
|
||||
"started_at": row_dict.get("started_at"),
|
||||
"finished_at": row_dict.get("finished_at"),
|
||||
"duration_seconds": duration_seconds,
|
||||
"status": row_dict.get("status"),
|
||||
"imported": row_dict.get("imported", 0),
|
||||
"skipped": row_dict.get("skipped", 0),
|
||||
"errors": row_dict.get("errors", 0),
|
||||
}
|
||||
finally:
|
||||
await db.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Ensure all expected keys are present
|
||||
result = {
|
||||
"status": status.get("status", "idle"),
|
||||
"run_id": status.get("run_id"),
|
||||
"started_at": status.get("started_at"),
|
||||
"finished_at": status.get("finished_at"),
|
||||
"phase": status.get("phase"),
|
||||
"phase_text": status.get("phase_text"),
|
||||
"progress_current": status.get("progress_current", 0),
|
||||
"progress_total": status.get("progress_total", 0),
|
||||
"counts": status.get("counts", {"imported": 0, "skipped": 0, "errors": 0}),
|
||||
"last_run": last_run,
|
||||
}
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/api/sync/history")
|
||||
@@ -277,8 +305,13 @@ async def order_detail(order_number: str):
|
||||
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."""
|
||||
period_days: int = 7,
|
||||
period_start: str = "", period_end: str = ""):
|
||||
"""Get orders for dashboard, enriched with invoice data.
|
||||
|
||||
period_days=0 with period_start/period_end uses custom date range.
|
||||
period_days=0 without dates means all time.
|
||||
"""
|
||||
is_uninvoiced_filter = (status == "UNINVOICED")
|
||||
|
||||
# For UNINVOICED: fetch all IMPORTED orders, then filter post-invoice-check
|
||||
@@ -289,7 +322,9 @@ async def dashboard_orders(page: int = 1, per_page: int = 50,
|
||||
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
|
||||
period_days=period_days,
|
||||
period_start=period_start if period_days == 0 else "",
|
||||
period_end=period_end if period_days == 0 else "",
|
||||
)
|
||||
|
||||
# Enrich imported orders with invoice data from Oracle
|
||||
@@ -309,12 +344,22 @@ async def dashboard_orders(page: int = 1, per_page: int = 50,
|
||||
else:
|
||||
o["invoice"] = None
|
||||
|
||||
# Count uninvoiced (IMPORTED without invoice)
|
||||
uninvoiced_count = sum(
|
||||
# Add shipping/billing name fields + is_different_person flag
|
||||
s_name = o.get("shipping_name") or ""
|
||||
b_name = o.get("billing_name") or ""
|
||||
o["shipping_name"] = s_name
|
||||
o["billing_name"] = b_name
|
||||
o["is_different_person"] = bool(s_name and b_name and s_name != b_name)
|
||||
|
||||
# Build period-total counts (across all pages, same filters)
|
||||
nefacturate_count = sum(
|
||||
1 for o in all_orders
|
||||
if o.get("status") == "IMPORTED" and not o.get("invoice")
|
||||
)
|
||||
result["counts"]["uninvoiced"] = uninvoiced_count
|
||||
# Use counts from sqlite_service (already period-scoped) and add nefacturate
|
||||
counts = result.get("counts", {})
|
||||
counts["nefacturate"] = nefacturate_count
|
||||
counts.setdefault("total", counts.get("imported", 0) + counts.get("skipped", 0) + counts.get("error", 0))
|
||||
|
||||
# For UNINVOICED filter: apply server-side filtering + pagination
|
||||
if is_uninvoiced_filter:
|
||||
@@ -327,7 +372,16 @@ async def dashboard_orders(page: int = 1, per_page: int = 50,
|
||||
result["per_page"] = per_page
|
||||
result["pages"] = (total + per_page - 1) // per_page if total > 0 else 0
|
||||
|
||||
return result
|
||||
# Reshape response
|
||||
return {
|
||||
"orders": result["orders"],
|
||||
"pagination": {
|
||||
"page": result.get("page", page),
|
||||
"per_page": result.get("per_page", per_page),
|
||||
"total_pages": result.get("pages", 0),
|
||||
},
|
||||
"counts": counts,
|
||||
}
|
||||
|
||||
|
||||
@router.put("/api/sync/schedule")
|
||||
|
||||
Reference in New Issue
Block a user