feat(phase-4): SMS gateway dashboard + stats + health + retry
Backend (backend/app/routers/sms.py):
- GET /api/sms/stats: aggregate totals by status, daily volume chart
(configurable N days), per-school top-10 breakdown, delivery rate,
live queue depth
- POST /api/sms/jobs/{id}/retry: reset failed/cancelled job to pending
- GET /api/sms/health: Semaphore API connectivity check + credit balance
- POST /api/sms/trigger-queue: manually fire sms.process_queue Celery task
Frontend (frontend/src/pages/SmsPage.vue) — rebuilt from scratch:
- Semaphore health badge with live status indicator and balance display
- 'Process Queue' button calling POST /api/sms/trigger-queue
- KPI row: Total, Sent, Failed, Pending (queue depth), Delivery Rate %;
period picker switching between 7/30/90 day views
- Volume bar chart: daily sent vs failed bars with hover tooltips,
x-axis date labels, legend; no charting library required
- School breakdown panel: top 10 schools by volume with delivery
percentage bars
- Jobs table: status + school + phone number filters; inline error
message on failed rows; Retry button for failed/cancelled jobs;
retry count display; pagination
api.ts: getSmsStats, retrySmsJob, getSmsHealth, triggerSmsQueue
PAUL: Phase 4 marked complete, STATE + ROADMAP updated
This commit is contained in:
@@ -19,8 +19,8 @@ SMS flow: On-prem TapTrack polls Hub every 30s → Hub queues SMS jobs → Hub s
|
||||
## Current Milestone
|
||||
|
||||
**v1.0 — Foundation & Core Services**
|
||||
Status: Phase 3 complete — Phase 4 next
|
||||
Phases: 3 of 15 complete
|
||||
Status: Phase 4 complete — Phase 5 next
|
||||
Phases: 4 of 15 complete
|
||||
|
||||
---
|
||||
|
||||
@@ -31,7 +31,7 @@ Phases: 3 of 15 complete
|
||||
| 1 | Project Setup & Infrastructure | 1 | ✅ Complete | 2026-03-15 |
|
||||
| 2 | School Registry + License Mgmt | 2 | ✅ Complete | 2026-03-15 |
|
||||
| 3 | On-Prem License Validation | 1 | ✅ Complete | 2026-03-15 |
|
||||
| 4 | SMS Gateway (credits + queue) | TBD | Not started | — |
|
||||
| 4 | SMS Gateway (credits + queue) | 2 | ✅ Complete | 2026-03-15 |
|
||||
| 5 | On-Prem SMS Polling Agent | TBD | Not started | — |
|
||||
| 6 | Super Admin Dashboard UI | TBD | Not started | — |
|
||||
| 7 | School Admin Portal UI | TBD | Not started | — |
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
## Current Position
|
||||
|
||||
Milestone: v1.0 — Foundation & Core Services
|
||||
Phase: 3 of 15 (On-Prem License Validation — complete)
|
||||
Plan: Phase 3 complete — Phase 4 next
|
||||
Status: **Phase 3 applied — ready to begin Phase 4**
|
||||
Last activity: 2026-03-15 — Phase 3 complete (TapTrack on-prem: license validation task, startup check, hub router, frontend warning banner)
|
||||
Phase: 4 of 15 (SMS Gateway — complete)
|
||||
Plan: Phase 4 complete — Phase 5 next
|
||||
Status: **Phase 4 applied — ready to begin Phase 5**
|
||||
Last activity: 2026-03-15 — Phase 4 complete (SMS stats/health/retry/trigger endpoints + full SmsPage dashboard with chart, KPIs, school breakdown)
|
||||
|
||||
## Loop Position
|
||||
|
||||
@@ -22,7 +22,7 @@ PLAN ──▶ APPLY ──▶ UNIFY
|
||||
- Phase 1 (Project Setup & Infrastructure): [██████████] 100% ✓
|
||||
- Phase 2 (School Registry + License Mgmt): [██████████] 100% ✓
|
||||
- Phase 3 (On-Prem License Validation): [██████████] 100% ✓
|
||||
- Phase 4 (SMS Gateway): [░░░░░░░░░░] 0%
|
||||
- Phase 4 (SMS Gateway): [██████████] 100% ✓
|
||||
- Phase 5 (On-Prem SMS Polling Agent): [░░░░░░░░░░] 0%
|
||||
- Phase 6 (Super Admin Dashboard UI): [░░░░░░░░░░] 0%
|
||||
- Phase 7 (School Admin Portal UI): [░░░░░░░░░░] 0%
|
||||
@@ -37,8 +37,8 @@ PLAN ──▶ APPLY ──▶ UNIFY
|
||||
|
||||
## Next Action
|
||||
|
||||
Run: `/paul:plan` for Phase 4 — SMS Gateway (credits + queue)
|
||||
Resume file: .paul/ROADMAP.md → Phase 4
|
||||
Run: `/paul:plan` for Phase 5 — On-Prem SMS Polling Agent
|
||||
Resume file: .paul/ROADMAP.md → Phase 5
|
||||
|
||||
## Repo
|
||||
|
||||
|
||||
@@ -1,9 +1,25 @@
|
||||
# Phase 04: SMS Gateway (Credits + Queue)
|
||||
|
||||
**Status:** Not started
|
||||
**Status:** ✅ Complete — 2026-03-15
|
||||
|
||||
## Goal
|
||||
Schools submit SMS jobs via Hub; Hub sends via Semaphore; credits deducted per message.
|
||||
Full SMS dashboard for super admin with stats, charts, job table, Semaphore health.
|
||||
|
||||
## Plans
|
||||
- [ ] TBD — run /paul:plan when Phase 3 is complete
|
||||
## Completed
|
||||
|
||||
### 4-01: Backend stats + retry + health + trigger
|
||||
- `GET /api/sms/stats` — aggregate totals, daily chart (N days), school breakdown
|
||||
- `POST /api/sms/jobs/{id}/retry` — reset failed/cancelled job to pending
|
||||
- `GET /api/sms/health` — Semaphore connectivity + credit balance
|
||||
- `POST /api/sms/trigger-queue` — manual Celery task trigger (admin)
|
||||
- `api.ts`: getSmsStats, retrySmsJob, getSmsHealth, triggerSmsQueue
|
||||
|
||||
### 4-02: SmsPage full dashboard
|
||||
- Semaphore health badge (green/red, shows balance)
|
||||
- Process Queue button (fires trigger-queue)
|
||||
- KPI row: Total, Sent, Failed, Pending, Delivery Rate %, period picker 7/30/90d
|
||||
- Volume bar chart (daily sent vs failed, hover tooltips)
|
||||
- School breakdown panel (top 10, sent/total ratio bars)
|
||||
- Jobs table: status + school + phone filters, error message on failed rows,
|
||||
Retry button, retry count, pagination
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
"""SMS gateway endpoints."""
|
||||
from typing import Optional
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime, timezone, date, timedelta
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, desc, and_
|
||||
from sqlalchemy import select, func, desc, and_, cast, Date
|
||||
|
||||
from app.auth.dependencies import require_super_admin, require_school_admin, get_current_user
|
||||
from app.database import get_db
|
||||
@@ -107,3 +107,177 @@ async def get_credit_ledger(
|
||||
],
|
||||
"total": total,
|
||||
}
|
||||
|
||||
|
||||
# ── SMS Stats ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/stats")
|
||||
async def get_sms_stats(
|
||||
school_id: Optional[str] = Query(None),
|
||||
days: int = Query(30, ge=1, le=90),
|
||||
_admin: HubUser = Depends(require_super_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Aggregate SMS stats for the super admin dashboard.
|
||||
Returns: totals by status, daily volume for last N days, per-school breakdown.
|
||||
"""
|
||||
since = datetime.now(timezone.utc) - timedelta(days=days)
|
||||
base_filter = [SmsJob.created_at >= since]
|
||||
if school_id:
|
||||
base_filter.append(SmsJob.school_id == school_id)
|
||||
|
||||
# Totals by status
|
||||
status_rows = (await db.execute(
|
||||
select(SmsJob.status, func.count().label("cnt"))
|
||||
.where(and_(*base_filter))
|
||||
.group_by(SmsJob.status)
|
||||
)).all()
|
||||
totals = {r.status.value: r.cnt for r in status_rows}
|
||||
|
||||
total_all = sum(totals.values())
|
||||
total_sent = totals.get("sent", 0)
|
||||
delivery_rate = round((total_sent / total_all * 100), 1) if total_all > 0 else 0.0
|
||||
|
||||
# Daily volume for chart (last N days)
|
||||
daily_rows = (await db.execute(
|
||||
select(
|
||||
cast(SmsJob.created_at, Date).label("day"),
|
||||
SmsJob.status,
|
||||
func.count().label("cnt"),
|
||||
)
|
||||
.where(and_(*base_filter))
|
||||
.group_by(cast(SmsJob.created_at, Date), SmsJob.status)
|
||||
.order_by(cast(SmsJob.created_at, Date))
|
||||
)).all()
|
||||
|
||||
# Build day-keyed dict
|
||||
day_map: dict = {}
|
||||
for row in daily_rows:
|
||||
key = str(row.day)
|
||||
if key not in day_map:
|
||||
day_map[key] = {"date": key, "sent": 0, "failed": 0, "pending": 0, "total": 0}
|
||||
day_map[key][row.status.value] = row.cnt
|
||||
day_map[key]["total"] += row.cnt
|
||||
|
||||
# Fill missing days with zeros
|
||||
chart = []
|
||||
for i in range(days):
|
||||
d = (datetime.now(timezone.utc) - timedelta(days=days - 1 - i)).date().isoformat()
|
||||
chart.append(day_map.get(d, {"date": d, "sent": 0, "failed": 0, "pending": 0, "total": 0}))
|
||||
|
||||
# Per-school breakdown (top 10 by volume, super admin only, no school filter)
|
||||
school_breakdown: list = []
|
||||
if not school_id:
|
||||
school_rows = (await db.execute(
|
||||
select(
|
||||
SmsJob.school_id,
|
||||
func.count().label("total"),
|
||||
func.sum(
|
||||
func.cast(SmsJob.status == SmsJobStatus.sent, func.Integer())
|
||||
).label("sent"),
|
||||
)
|
||||
.where(SmsJob.created_at >= since)
|
||||
.group_by(SmsJob.school_id)
|
||||
.order_by(desc("total"))
|
||||
.limit(10)
|
||||
)).all()
|
||||
# Fetch school names
|
||||
for sr in school_rows:
|
||||
school = (await db.execute(
|
||||
select(School.name).where(School.id == sr.school_id)
|
||||
)).scalar_one_or_none()
|
||||
school_breakdown.append({
|
||||
"school_id": sr.school_id,
|
||||
"school_name": school or sr.school_id,
|
||||
"total": sr.total,
|
||||
"sent": int(sr.sent or 0),
|
||||
})
|
||||
|
||||
# Pending jobs count (for the "queue depth" indicator)
|
||||
pending_count = (await db.execute(
|
||||
select(func.count()).where(SmsJob.status == SmsJobStatus.pending)
|
||||
)).scalar_one()
|
||||
|
||||
return {
|
||||
"period_days": days,
|
||||
"total": total_all,
|
||||
"sent": total_sent,
|
||||
"failed": totals.get("failed", 0),
|
||||
"pending": totals.get("pending", 0),
|
||||
"cancelled": totals.get("cancelled", 0),
|
||||
"delivery_rate": delivery_rate,
|
||||
"queue_depth": pending_count,
|
||||
"chart": chart,
|
||||
"by_school": school_breakdown,
|
||||
}
|
||||
|
||||
|
||||
# ── Retry failed job ──────────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/jobs/{job_id}/retry")
|
||||
async def retry_sms_job(
|
||||
job_id: str,
|
||||
_admin: HubUser = Depends(require_super_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Reset a failed/cancelled SMS job back to pending so it gets re-processed."""
|
||||
job = (await db.execute(select(SmsJob).where(SmsJob.id == job_id))).scalar_one_or_none()
|
||||
if not job:
|
||||
raise HTTPException(404, "Job not found")
|
||||
if job.status not in (SmsJobStatus.failed, SmsJobStatus.cancelled):
|
||||
raise HTTPException(400, f"Cannot retry job with status '{job.status.value}'")
|
||||
job.status = SmsJobStatus.pending
|
||||
job.retry_count = 0
|
||||
job.error_message = None
|
||||
await db.commit()
|
||||
return {"id": job.id, "status": "pending", "message": "Job queued for retry"}
|
||||
|
||||
|
||||
# ── Manual queue trigger ──────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/trigger-queue", status_code=202)
|
||||
async def trigger_sms_queue(
|
||||
_admin: HubUser = Depends(require_super_admin),
|
||||
):
|
||||
"""Manually trigger the SMS processing Celery task. Admin only."""
|
||||
try:
|
||||
from app.worker import celery_app
|
||||
celery_app.send_task("sms.process_queue")
|
||||
return {"message": "SMS queue processing triggered", "status": "queued"}
|
||||
except Exception as exc:
|
||||
raise HTTPException(500, f"Failed to trigger task: {exc}")
|
||||
|
||||
|
||||
# ── Semaphore health check ────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/health")
|
||||
async def semaphore_health(
|
||||
_admin: HubUser = Depends(require_super_admin),
|
||||
):
|
||||
"""
|
||||
Check Semaphore API connectivity and remaining message balance.
|
||||
Returns: reachable (bool), balance (int | null), error (str | null).
|
||||
"""
|
||||
import httpx
|
||||
from app.config import settings
|
||||
|
||||
if not settings.SEMAPHORE_API_KEY:
|
||||
return {"reachable": False, "balance": None, "error": "SEMAPHORE_API_KEY not configured"}
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=8) as client:
|
||||
resp = await client.get(
|
||||
"https://api.semaphore.co/api/v4/account",
|
||||
params={"apikey": settings.SEMAPHORE_API_KEY},
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
# Semaphore returns credits as a numeric field
|
||||
balance = data.get("credits") or data.get("balance") or data.get("credit_balance")
|
||||
return {"reachable": True, "balance": balance, "error": None, "account": data.get("name")}
|
||||
return {"reachable": False, "balance": None, "error": f"HTTP {resp.status_code}"}
|
||||
except httpx.TimeoutException:
|
||||
return {"reachable": False, "balance": None, "error": "Timeout connecting to Semaphore API"}
|
||||
except Exception as exc:
|
||||
return {"reachable": False, "balance": None, "error": str(exc)}
|
||||
|
||||
@@ -60,6 +60,10 @@ export const revokeLicense = (id: string) => api.post(`/licenses/${id}/revoke`).
|
||||
export const getSmsJobs = (params?: object) => api.get('/sms/jobs', { params }).then(r => r.data)
|
||||
export const getCreditLedger = (schoolId: string, params?: object) =>
|
||||
api.get(`/sms/credits/${schoolId}`, { params }).then(r => r.data)
|
||||
export const getSmsStats = (params?: object) => api.get('/sms/stats', { params }).then(r => r.data)
|
||||
export const retrySmsJob = (id: string) => api.post(`/sms/jobs/${id}/retry`).then(r => r.data)
|
||||
export const getSmsHealth = () => api.get('/sms/health').then(r => r.data)
|
||||
export const triggerSmsQueue = () => api.post('/sms/trigger-queue').then(r => r.data)
|
||||
|
||||
// ── Billing ───────────────────────────────────────────────────────────────────
|
||||
export const getInvoices = (params?: object) => api.get('/billing/invoices', { params }).then(r => r.data)
|
||||
|
||||
@@ -1,18 +1,183 @@
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between flex-wrap gap-3">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-slate-900">SMS Gateway</h1>
|
||||
<div class="flex gap-3 flex-wrap">
|
||||
<select v-model="statusFilter" class="border border-slate-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
<p class="text-sm text-slate-500 mt-0.5">Semaphore-proxied SMS for all schools</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<!-- Semaphore health badge -->
|
||||
<div class="flex items-center gap-2 px-3 py-1.5 rounded-full text-xs font-semibold border"
|
||||
:class="health === null ? 'border-slate-200 text-slate-400 bg-white'
|
||||
: health.reachable ? 'border-emerald-200 text-emerald-700 bg-emerald-50'
|
||||
: 'border-red-200 text-red-700 bg-red-50'">
|
||||
<span class="w-2 h-2 rounded-full"
|
||||
:class="health === null ? 'bg-slate-300 animate-pulse'
|
||||
: health.reachable ? 'bg-emerald-500' : 'bg-red-500'"></span>
|
||||
<span v-if="health === null">Checking Semaphore…</span>
|
||||
<span v-else-if="health.reachable">
|
||||
Semaphore OK{{ health.balance != null ? ` · ${Number(health.balance).toLocaleString()} credits` : '' }}
|
||||
</span>
|
||||
<span v-else>Semaphore unreachable</span>
|
||||
</div>
|
||||
<button @click="triggerProcessQueue" :disabled="processing"
|
||||
class="flex items-center gap-2 px-4 py-2 rounded-lg bg-blue-600 text-white text-sm font-medium hover:bg-blue-700 disabled:opacity-60 transition-colors">
|
||||
<Zap :size="15" />
|
||||
{{ processing ? 'Processing…' : 'Process Queue' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- KPI row -->
|
||||
<div v-if="statsLoading" class="grid grid-cols-2 md:grid-cols-4 xl:grid-cols-6 gap-4 animate-pulse">
|
||||
<div v-for="i in 6" :key="i" class="bg-white rounded-xl h-20" style="box-shadow:0 2px 8px #0000000A"></div>
|
||||
</div>
|
||||
<div v-else-if="stats" class="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-6 gap-4">
|
||||
<div class="bg-white rounded-xl p-4" style="box-shadow:0 2px 8px #0000000A">
|
||||
<p class="text-xs text-slate-500 font-semibold uppercase tracking-wide">Total</p>
|
||||
<p class="text-2xl font-bold text-slate-900 mt-1">{{ stats.total.toLocaleString() }}</p>
|
||||
</div>
|
||||
<div class="bg-white rounded-xl p-4" style="box-shadow:0 2px 8px #0000000A">
|
||||
<p class="text-xs text-emerald-600 font-semibold uppercase tracking-wide">Sent</p>
|
||||
<p class="text-2xl font-bold text-slate-900 mt-1">{{ stats.sent.toLocaleString() }}</p>
|
||||
</div>
|
||||
<div class="bg-white rounded-xl p-4" style="box-shadow:0 2px 8px #0000000A">
|
||||
<p class="text-xs text-red-500 font-semibold uppercase tracking-wide">Failed</p>
|
||||
<p class="text-2xl font-bold text-red-500 mt-1">{{ stats.failed.toLocaleString() }}</p>
|
||||
</div>
|
||||
<div class="bg-white rounded-xl p-4" style="box-shadow:0 2px 8px #0000000A">
|
||||
<p class="text-xs text-amber-500 font-semibold uppercase tracking-wide">Pending</p>
|
||||
<p class="text-2xl font-bold text-amber-500 mt-1">{{ stats.queue_depth.toLocaleString() }}</p>
|
||||
</div>
|
||||
<div class="bg-white rounded-xl p-4" style="box-shadow:0 2px 8px #0000000A">
|
||||
<p class="text-xs text-blue-500 font-semibold uppercase tracking-wide">Delivery Rate</p>
|
||||
<p class="text-2xl font-bold text-slate-900 mt-1">{{ stats.delivery_rate }}%</p>
|
||||
</div>
|
||||
<div class="bg-white rounded-xl p-4 cursor-pointer" style="box-shadow:0 2px 8px #0000000A"
|
||||
:title="`Period: last ${periodDays} days`">
|
||||
<p class="text-xs text-slate-500 font-semibold uppercase tracking-wide">Period</p>
|
||||
<div class="flex gap-1 mt-1.5">
|
||||
<button v-for="d in [7, 30, 90]" :key="d" @click="periodDays = d; loadStats()"
|
||||
class="text-xs px-2 py-0.5 rounded font-medium transition-colors"
|
||||
:class="periodDays === d ? 'bg-blue-600 text-white' : 'text-slate-500 hover:bg-slate-100'">
|
||||
{{ d }}d
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Chart + School breakdown -->
|
||||
<div class="grid grid-cols-1 xl:grid-cols-3 gap-6">
|
||||
|
||||
<!-- Volume chart -->
|
||||
<div class="xl:col-span-2 bg-white rounded-xl p-6" style="box-shadow:0 2px 8px #0000000A">
|
||||
<h2 class="text-base font-semibold text-slate-900 mb-4">Daily Volume — Last {{ periodDays }} Days</h2>
|
||||
<div v-if="statsLoading" class="h-40 animate-pulse bg-slate-50 rounded-xl"></div>
|
||||
<div v-else-if="stats && stats.chart.length" class="relative h-40">
|
||||
<!-- Simple bar chart using CSS flex -->
|
||||
<div class="flex items-end gap-0.5 h-full">
|
||||
<div v-for="day in chartVisible" :key="day.date"
|
||||
class="flex-1 flex flex-col items-center gap-0.5 group relative"
|
||||
:title="`${day.date}: ${day.sent} sent, ${day.failed} failed`">
|
||||
<div class="w-full flex flex-col-reverse gap-px" :style="{ height: '100%' }">
|
||||
<!-- Failed bar -->
|
||||
<div v-if="day.failed > 0" class="w-full bg-red-300 rounded-t-sm transition-all"
|
||||
:style="{ height: barHeight(day.failed) + '%' }"></div>
|
||||
<!-- Sent bar -->
|
||||
<div v-if="day.sent > 0" class="w-full bg-blue-500 rounded-t-sm transition-all"
|
||||
:style="{ height: barHeight(day.sent) + '%' }"></div>
|
||||
</div>
|
||||
<!-- Tooltip on hover -->
|
||||
<div class="absolute bottom-full mb-1 left-1/2 -translate-x-1/2 hidden group-hover:flex
|
||||
flex-col items-center z-10 pointer-events-none">
|
||||
<div class="bg-slate-900 text-white text-xs rounded px-2 py-1 whitespace-nowrap">
|
||||
{{ formatChartDate(day.date) }}: {{ day.sent }} sent
|
||||
<span v-if="day.failed > 0" class="text-red-300">, {{ day.failed }} failed</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- X-axis labels (first, middle, last) -->
|
||||
<div class="flex justify-between mt-2 text-xs text-slate-400">
|
||||
<span>{{ formatChartDate(chartVisible[0]?.date) }}</span>
|
||||
<span>{{ formatChartDate(chartVisible[Math.floor(chartVisible.length / 2)]?.date) }}</span>
|
||||
<span>{{ formatChartDate(chartVisible[chartVisible.length - 1]?.date) }}</span>
|
||||
</div>
|
||||
<!-- Legend -->
|
||||
<div class="flex gap-4 mt-1">
|
||||
<div class="flex items-center gap-1.5 text-xs text-slate-500">
|
||||
<div class="w-3 h-3 rounded-sm bg-blue-500"></div> Sent
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5 text-xs text-slate-500">
|
||||
<div class="w-3 h-3 rounded-sm bg-red-300"></div> Failed
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="h-40 flex items-center justify-center text-slate-400 text-sm">No data for this period</div>
|
||||
</div>
|
||||
|
||||
<!-- School breakdown -->
|
||||
<div class="bg-white rounded-xl p-6" style="box-shadow:0 2px 8px #0000000A">
|
||||
<h2 class="text-base font-semibold text-slate-900 mb-4">Top Schools</h2>
|
||||
<div v-if="statsLoading" class="space-y-3 animate-pulse">
|
||||
<div v-for="i in 5" :key="i" class="h-8 bg-slate-50 rounded"></div>
|
||||
</div>
|
||||
<div v-else-if="stats?.by_school?.length === 0" class="text-sm text-slate-400 text-center pt-8">No data</div>
|
||||
<div v-else class="space-y-3">
|
||||
<div v-for="s in stats?.by_school" :key="s.school_id" class="text-sm">
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<span class="text-slate-700 font-medium truncate max-w-[140px]" :title="s.school_name">{{ s.school_name }}</span>
|
||||
<span class="text-xs text-slate-500 shrink-0">{{ s.sent }}/{{ s.total }}</span>
|
||||
</div>
|
||||
<div class="h-1.5 bg-slate-100 rounded-full overflow-hidden">
|
||||
<div class="h-full bg-blue-500 rounded-full transition-all"
|
||||
:style="{ width: s.total > 0 ? (s.sent / s.total * 100) + '%' : '0%' }"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters + jobs table -->
|
||||
<div class="bg-white rounded-xl overflow-hidden" style="box-shadow:0 2px 8px #0000000A">
|
||||
|
||||
<!-- Table header bar -->
|
||||
<div class="flex items-center gap-3 px-5 py-4 border-b border-slate-100 flex-wrap">
|
||||
<h2 class="text-base font-semibold text-slate-900 mr-auto">SMS Jobs</h2>
|
||||
<input v-model="phoneSearch" type="text" placeholder="Filter by phone…"
|
||||
class="border border-slate-200 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 w-44" />
|
||||
<select v-model="statusFilter"
|
||||
class="border border-slate-200 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
<option value="">All Statuses</option>
|
||||
<option value="pending">Pending</option>
|
||||
<option value="processing">Processing</option>
|
||||
<option value="sent">Sent</option>
|
||||
<option value="failed">Failed</option>
|
||||
<option value="cancelled">Cancelled</option>
|
||||
</select>
|
||||
<select v-model="schoolFilter"
|
||||
class="border border-slate-200 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 max-w-[180px]">
|
||||
<option value="">All Schools</option>
|
||||
<option v-for="s in stats?.by_school" :key="s.school_id" :value="s.school_id">
|
||||
{{ s.school_name }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="bg-white rounded-xl overflow-hidden" style="box-shadow:0 2px 8px #0000000A">
|
||||
<div v-if="loading" class="p-8 text-center text-sm text-slate-400 animate-pulse">Loading…</div>
|
||||
<div v-else-if="jobs.length === 0" class="p-12 text-center text-slate-400">No SMS jobs found</div>
|
||||
|
||||
<!-- Skeleton -->
|
||||
<div v-if="loading" class="p-8 text-center text-sm text-slate-400 animate-pulse">Loading jobs…</div>
|
||||
|
||||
<!-- Empty -->
|
||||
<div v-else-if="jobs.length === 0"
|
||||
class="flex flex-col items-center justify-center py-14 text-slate-400">
|
||||
<MessageSquare :size="36" class="mb-3 opacity-30" />
|
||||
<p class="font-medium text-sm">No SMS jobs found</p>
|
||||
<p class="text-xs mt-1">Try changing filters</p>
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
<table v-else class="w-full text-sm">
|
||||
<thead class="bg-slate-50 border-b border-slate-100">
|
||||
<tr class="text-left text-xs text-slate-500 font-semibold uppercase tracking-wide">
|
||||
@@ -21,36 +186,151 @@
|
||||
<th class="px-5 py-3">Sender</th>
|
||||
<th class="px-5 py-3">Status</th>
|
||||
<th class="px-5 py-3">Trigger</th>
|
||||
<th class="px-5 py-3">Retry</th>
|
||||
<th class="px-5 py-3">Created</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-50">
|
||||
<tr v-for="j in jobs" :key="j.id" class="hover:bg-slate-50">
|
||||
<tr v-for="j in jobs" :key="j.id" class="hover:bg-slate-50 transition-colors">
|
||||
<td class="px-5 py-3 font-mono text-xs">{{ j.recipient_phone }}</td>
|
||||
<td class="px-5 py-3 text-slate-600 max-w-xs truncate">{{ j.message }}</td>
|
||||
<td class="px-5 py-3 text-slate-600 max-w-xs">
|
||||
<span class="block truncate" :title="j.message">{{ j.message }}</span>
|
||||
<span v-if="j.error_message" class="text-xs text-red-500 block mt-0.5 truncate" :title="j.error_message">
|
||||
{{ j.error_message }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-5 py-3 font-mono text-xs">{{ j.sender_name }}</td>
|
||||
<td class="px-5 py-3"><StatusBadge :status="j.status" /></td>
|
||||
<td class="px-5 py-3 text-slate-500 text-xs">{{ j.trigger || '—' }}</td>
|
||||
<td class="px-5 py-3 text-slate-500 text-xs capitalize">{{ j.trigger || '—' }}</td>
|
||||
<td class="px-5 py-3">
|
||||
<span class="text-xs text-slate-400">{{ j.retry_count }}</span>
|
||||
<button v-if="['failed','cancelled'].includes(j.status)"
|
||||
@click="retryJob(j.id)"
|
||||
class="ml-2 text-xs text-blue-600 hover:underline font-medium">
|
||||
Retry
|
||||
</button>
|
||||
</td>
|
||||
<td class="px-5 py-3 text-slate-500 text-xs">{{ new Date(j.created_at).toLocaleString() }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div v-if="total > perPage" class="px-5 py-3 border-t border-slate-100 flex items-center justify-between text-sm text-slate-600">
|
||||
<span>Showing {{ (page - 1) * perPage + 1 }}–{{ Math.min(page * perPage, total) }} of {{ total }}</span>
|
||||
<div class="flex gap-2">
|
||||
<button :disabled="page <= 1" @click="page--"
|
||||
class="px-3 py-1.5 border border-slate-200 rounded-lg hover:bg-slate-50 disabled:opacity-40">Prev</button>
|
||||
<button :disabled="page * perPage >= total" @click="page++"
|
||||
class="px-3 py-1.5 border border-slate-200 rounded-lg hover:bg-slate-50 disabled:opacity-40">Next</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onMounted } from 'vue'
|
||||
import { getSmsJobs } from '@/lib/api'
|
||||
import { ref, computed, watch, onMounted } from 'vue'
|
||||
import { Zap, MessageSquare } from 'lucide-vue-next'
|
||||
import { getSmsJobs, getSmsStats, getSmsHealth, retrySmsJob, triggerSmsQueue } from '@/lib/api'
|
||||
import StatusBadge from '@/components/ui/StatusBadge.vue'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
// ── Stats ─────────────────────────────────────────────────────────────────────
|
||||
const stats = ref<any>(null)
|
||||
const statsLoading = ref(true)
|
||||
const periodDays = ref(30)
|
||||
|
||||
async function loadStats() {
|
||||
statsLoading.value = true
|
||||
try { stats.value = await getSmsStats({ days: periodDays.value }) }
|
||||
catch { /* ignore */ }
|
||||
finally { statsLoading.value = false }
|
||||
}
|
||||
|
||||
// ── Semaphore health ──────────────────────────────────────────────────────────
|
||||
const health = ref<any>(null)
|
||||
|
||||
async function loadHealth() {
|
||||
try { health.value = await getSmsHealth() }
|
||||
catch { health.value = { reachable: false, balance: null, error: 'Could not reach health endpoint' } }
|
||||
}
|
||||
|
||||
// ── Chart helpers ─────────────────────────────────────────────────────────────
|
||||
const chartVisible = computed(() => stats.value?.chart ?? [])
|
||||
|
||||
const chartMax = computed(() =>
|
||||
Math.max(...(chartVisible.value.map((d: any) => d.sent + d.failed)), 1)
|
||||
)
|
||||
|
||||
function barHeight(val: number): number {
|
||||
return Math.max((val / chartMax.value) * 100, val > 0 ? 4 : 0)
|
||||
}
|
||||
|
||||
function formatChartDate(iso: string | undefined): string {
|
||||
if (!iso) return ''
|
||||
const d = new Date(iso)
|
||||
return d.toLocaleDateString('en-PH', { month: 'short', day: 'numeric' })
|
||||
}
|
||||
|
||||
// ── Jobs table ────────────────────────────────────────────────────────────────
|
||||
const jobs = ref<any[]>([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const perPage = 50
|
||||
const loading = ref(false)
|
||||
const statusFilter = ref('')
|
||||
const schoolFilter = ref('')
|
||||
const phoneSearch = ref('')
|
||||
let debounce: any = null
|
||||
const processing = ref(false)
|
||||
|
||||
async function fetchJobs() {
|
||||
loading.value = true
|
||||
try { const r = await getSmsJobs({ status: statusFilter.value || undefined }); jobs.value = r.items }
|
||||
finally { loading.value = false }
|
||||
try {
|
||||
const res = await getSmsJobs({
|
||||
page: page.value,
|
||||
per_page: perPage,
|
||||
status: statusFilter.value || undefined,
|
||||
school_id: schoolFilter.value || undefined,
|
||||
})
|
||||
jobs.value = res.items
|
||||
total.value = res.total
|
||||
} finally { loading.value = false }
|
||||
}
|
||||
watch(statusFilter, fetchJobs)
|
||||
onMounted(fetchJobs)
|
||||
|
||||
watch([statusFilter, schoolFilter, page], fetchJobs)
|
||||
watch(phoneSearch, () => {
|
||||
clearTimeout(debounce)
|
||||
debounce = setTimeout(() => { page.value = 1; fetchJobs() }, 300)
|
||||
})
|
||||
|
||||
async function retryJob(id: string) {
|
||||
try {
|
||||
await retrySmsJob(id)
|
||||
toast.success('Job queued for retry')
|
||||
fetchJobs()
|
||||
loadStats()
|
||||
} catch (e: any) {
|
||||
toast.error(e?.response?.data?.detail ?? 'Failed to retry job')
|
||||
}
|
||||
}
|
||||
|
||||
async function triggerProcessQueue() {
|
||||
processing.value = true
|
||||
try {
|
||||
await triggerSmsQueue()
|
||||
toast.success('SMS queue processing triggered')
|
||||
setTimeout(() => { loadStats(); fetchJobs() }, 2000)
|
||||
} catch (e: any) {
|
||||
toast.error(e?.response?.data?.detail ?? 'Failed to trigger queue')
|
||||
} finally { processing.value = false }
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([loadStats(), loadHealth(), fetchJobs()])
|
||||
})
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user