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:
kevin-asprec
2026-03-16 10:37:23 +08:00
parent a5430736d9
commit 5cab1938b1
6 changed files with 512 additions and 38 deletions

View File

@@ -19,8 +19,8 @@ SMS flow: On-prem TapTrack polls Hub every 30s → Hub queues SMS jobs → Hub s
## Current Milestone ## Current Milestone
**v1.0 — Foundation & Core Services** **v1.0 — Foundation & Core Services**
Status: Phase 3 complete — Phase 4 next Status: Phase 4 complete — Phase 5 next
Phases: 3 of 15 complete Phases: 4 of 15 complete
--- ---
@@ -31,7 +31,7 @@ Phases: 3 of 15 complete
| 1 | Project Setup & Infrastructure | 1 | ✅ Complete | 2026-03-15 | | 1 | Project Setup & Infrastructure | 1 | ✅ Complete | 2026-03-15 |
| 2 | School Registry + License Mgmt | 2 | ✅ Complete | 2026-03-15 | | 2 | School Registry + License Mgmt | 2 | ✅ Complete | 2026-03-15 |
| 3 | On-Prem License Validation | 1 | ✅ 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 | — | | 5 | On-Prem SMS Polling Agent | TBD | Not started | — |
| 6 | Super Admin Dashboard UI | TBD | Not started | — | | 6 | Super Admin Dashboard UI | TBD | Not started | — |
| 7 | School Admin Portal UI | TBD | Not started | — | | 7 | School Admin Portal UI | TBD | Not started | — |

View File

@@ -3,10 +3,10 @@
## Current Position ## Current Position
Milestone: v1.0 — Foundation & Core Services Milestone: v1.0 — Foundation & Core Services
Phase: 3 of 15 (On-Prem License Validation — complete) Phase: 4 of 15 (SMS Gateway — complete)
Plan: Phase 3 complete — Phase 4 next Plan: Phase 4 complete — Phase 5 next
Status: **Phase 3 applied — ready to begin Phase 4** Status: **Phase 4 applied — ready to begin Phase 5**
Last activity: 2026-03-15 — Phase 3 complete (TapTrack on-prem: license validation task, startup check, hub router, frontend warning banner) Last activity: 2026-03-15 — Phase 4 complete (SMS stats/health/retry/trigger endpoints + full SmsPage dashboard with chart, KPIs, school breakdown)
## Loop Position ## Loop Position
@@ -22,7 +22,7 @@ PLAN ──▶ APPLY ──▶ UNIFY
- Phase 1 (Project Setup & Infrastructure): [██████████] 100% ✓ - Phase 1 (Project Setup & Infrastructure): [██████████] 100% ✓
- Phase 2 (School Registry + License Mgmt): [██████████] 100% ✓ - Phase 2 (School Registry + License Mgmt): [██████████] 100% ✓
- Phase 3 (On-Prem License Validation): [██████████] 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 5 (On-Prem SMS Polling Agent): [░░░░░░░░░░] 0%
- Phase 6 (Super Admin Dashboard UI): [░░░░░░░░░░] 0% - Phase 6 (Super Admin Dashboard UI): [░░░░░░░░░░] 0%
- Phase 7 (School Admin Portal UI): [░░░░░░░░░░] 0% - Phase 7 (School Admin Portal UI): [░░░░░░░░░░] 0%
@@ -37,8 +37,8 @@ PLAN ──▶ APPLY ──▶ UNIFY
## Next Action ## Next Action
Run: `/paul:plan` for Phase 4SMS Gateway (credits + queue) Run: `/paul:plan` for Phase 5On-Prem SMS Polling Agent
Resume file: .paul/ROADMAP.md → Phase 4 Resume file: .paul/ROADMAP.md → Phase 5
## Repo ## Repo

View File

@@ -1,9 +1,25 @@
# Phase 04: SMS Gateway (Credits + Queue) # Phase 04: SMS Gateway (Credits + Queue)
**Status:** Not started **Status:** ✅ Complete — 2026-03-15
## Goal ## Goal
Schools submit SMS jobs via Hub; Hub sends via Semaphore; credits deducted per message. 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 ## Completed
- [ ] TBD — run /paul:plan when Phase 3 is complete
### 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

View File

@@ -1,10 +1,10 @@
"""SMS gateway endpoints.""" """SMS gateway endpoints."""
from typing import Optional from typing import Optional
from datetime import datetime, timezone from datetime import datetime, timezone, date, timedelta
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession 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.auth.dependencies import require_super_admin, require_school_admin, get_current_user
from app.database import get_db from app.database import get_db
@@ -107,3 +107,177 @@ async def get_credit_ledger(
], ],
"total": total, "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)}

View File

@@ -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 getSmsJobs = (params?: object) => api.get('/sms/jobs', { params }).then(r => r.data)
export const getCreditLedger = (schoolId: string, params?: object) => export const getCreditLedger = (schoolId: string, params?: object) =>
api.get(`/sms/credits/${schoolId}`, { params }).then(r => r.data) 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 ─────────────────────────────────────────────────────────────────── // ── Billing ───────────────────────────────────────────────────────────────────
export const getInvoices = (params?: object) => api.get('/billing/invoices', { params }).then(r => r.data) export const getInvoices = (params?: object) => api.get('/billing/invoices', { params }).then(r => r.data)

View File

@@ -1,18 +1,183 @@
<template> <template>
<div class="space-y-6"> <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> <h1 class="text-2xl font-bold text-slate-900">SMS Gateway</h1>
<div class="flex gap-3 flex-wrap"> <p class="text-sm text-slate-500 mt-0.5">Semaphore-proxied SMS for all schools</p>
<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"> </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="">All Statuses</option>
<option value="pending">Pending</option> <option value="pending">Pending</option>
<option value="processing">Processing</option>
<option value="sent">Sent</option> <option value="sent">Sent</option>
<option value="failed">Failed</option> <option value="failed">Failed</option>
<option value="cancelled">Cancelled</option> <option value="cancelled">Cancelled</option>
</select> </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>
<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> <!-- Skeleton -->
<div v-else-if="jobs.length === 0" class="p-12 text-center text-slate-400">No SMS jobs found</div> <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"> <table v-else class="w-full text-sm">
<thead class="bg-slate-50 border-b border-slate-100"> <thead class="bg-slate-50 border-b border-slate-100">
<tr class="text-left text-xs text-slate-500 font-semibold uppercase tracking-wide"> <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">Sender</th>
<th class="px-5 py-3">Status</th> <th class="px-5 py-3">Status</th>
<th class="px-5 py-3">Trigger</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> <th class="px-5 py-3">Created</th>
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-slate-50"> <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 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 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"><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> <td class="px-5 py-3 text-slate-500 text-xs">{{ new Date(j.created_at).toLocaleString() }}</td>
</tr> </tr>
</tbody> </tbody>
</table> </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>
</div>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch, onMounted } from 'vue' import { ref, computed, watch, onMounted } from 'vue'
import { getSmsJobs } from '@/lib/api' import { Zap, MessageSquare } from 'lucide-vue-next'
import { getSmsJobs, getSmsStats, getSmsHealth, retrySmsJob, triggerSmsQueue } from '@/lib/api'
import StatusBadge from '@/components/ui/StatusBadge.vue' 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 jobs = ref<any[]>([])
const total = ref(0)
const page = ref(1)
const perPage = 50
const loading = ref(false) const loading = ref(false)
const statusFilter = ref('') const statusFilter = ref('')
const schoolFilter = ref('')
const phoneSearch = ref('')
let debounce: any = null
const processing = ref(false)
async function fetchJobs() { async function fetchJobs() {
loading.value = true loading.value = true
try { const r = await getSmsJobs({ status: statusFilter.value || undefined }); jobs.value = r.items } try {
finally { loading.value = false } 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> </script>