feat(phase-7): complete school admin portal UI

Backend (school_portal.py):
- GET /portal/sms-stats: 30/7/90-day chart, delivery rate, credit burn this month
- POST /portal/credits/request: auto-creates billing ticket for credit top-up
- GET /portal/overview: extended with days_left, sms_credit_low flag, 30-day SMS chart, full school details

PortalOverviewPage: suspension/expired banner, low-credit warning, credit meter
  with progress bar, license countdown with expiry badge, 30-day SMS sparkline
PortalBillingPage: subscription plan panel (fee, SMS cost, cycle, next billing),
  invoice table with subscription/SMS breakdown, credit top-up request form
PortalSmsPage: 30/7/90d period picker, 4-stat KPI row (sent, failed, delivery
  rate, credit burn), daily volume chart, credit snapshot with top-up link,
  paginated SMS log with status filter
PortalTicketsPage: status filter tabs (all/open/in-progress/resolved/closed),
  pagination, improved empty states
PortalProfilePage: school details panel (name, city, contact, tier, status)
  alongside existing change-password form
This commit is contained in:
kevin-asprec
2026-03-16 13:21:41 +08:00
parent 34f87476ff
commit e45e63903d
10 changed files with 973 additions and 106 deletions

View File

@@ -1,63 +1,238 @@
"""School admin portal — school-scoped read endpoints."""
from fastapi import APIRouter, Depends, HTTPException
import uuid
from datetime import date, datetime, timedelta, timezone
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 datetime import date, timedelta
from app.auth.dependencies import require_school_admin, get_current_user
from app.database import get_db
from app.models.user import HubUser, UserRole
from app.models.school import School
from app.models.license import License
from app.models.billing import Invoice
from app.models.sms import SmsJob, SmsJobStatus
from app.models.ticket import SupportTicket
from app.models.billing import Invoice, SchoolSubscription
from app.models.sms import SmsJob, SmsJobStatus, SmsCreditLedger, SmsCreditTx
from app.models.ticket import SupportTicket, TicketCategory
router = APIRouter(prefix="/api/portal", tags=["school-portal"])
async def _get_school(current_user: HubUser, db: AsyncSession) -> School:
if not current_user.school_id:
raise HTTPException(400, "No school linked to your account")
school = (await db.execute(select(School).where(School.id == current_user.school_id))).scalar_one_or_none()
school = (await db.execute(
select(School).where(School.id == current_user.school_id)
)).scalar_one_or_none()
if not school:
raise HTTPException(404, "School not found")
return school
@router.get("/overview")
async def portal_overview(
current_user: HubUser = Depends(require_school_admin),
db: AsyncSession = Depends(get_db),
):
school = await _get_school(current_user, db)
lic = (await db.execute(select(License).where(License.school_id == school.id))).scalar_one_or_none()
lic = (await db.execute(
select(License).where(License.school_id == school.id)
)).scalar_one_or_none()
pending_inv = (await db.execute(
select(func.count()).where(
and_(Invoice.school_id == school.id, Invoice.status.in_(["sent", "overdue"]))
)
)).scalar_one()
sms_this_month = (await db.execute(
select(func.count()).where(
and_(SmsJob.school_id == school.id, SmsJob.status == SmsJobStatus.sent,
func.date_trunc("month", SmsJob.sent_at) == func.date_trunc("month", func.current_date()))
and_(
SmsJob.school_id == school.id,
SmsJob.status == SmsJobStatus.sent,
func.date_trunc("month", SmsJob.sent_at) == func.date_trunc("month", func.current_date()),
)
)
)).scalar_one()
open_tickets = (await db.execute(
select(func.count()).where(
and_(SupportTicket.school_id == school.id, SupportTicket.status.in_(["open", "in_progress"]))
)
)).scalar_one()
# Days until license expires
license_days_left = None
if lic and lic.expires_at:
license_days_left = (lic.expires_at - date.today()).days
# 30-day SMS mini-chart (sent only, for the overview sparkline)
today = date.today()
chart = []
for i in range(29, -1, -1):
d = today - timedelta(days=i)
sent_count = (await db.execute(
select(func.count()).where(
and_(
SmsJob.school_id == school.id,
SmsJob.status == SmsJobStatus.sent,
func.date(SmsJob.sent_at) == d,
)
)
)).scalar_one()
chart.append({"date": d.isoformat(), "sent": sent_count})
return {
"school": {"id": school.id, "name": school.name, "status": school.status.value, "tier": school.tier.value},
"school": {
"id": school.id,
"name": school.name,
"status": school.status.value,
"tier": school.tier.value,
"city": school.city,
"contact_name": school.contact_name,
"contact_email": school.contact_email,
"contact_phone": school.contact_phone,
},
"license": {
"key": lic.key if lic else None,
"status": lic.status.value if lic else None,
"expires_at": lic.expires_at.isoformat() if lic and lic.expires_at else None,
"days_left": license_days_left,
"last_seen": lic.last_validated_at.isoformat() if lic and lic.last_validated_at else None,
},
"sms_credits": float(school.sms_credits),
"sms_credit_low_threshold": school.sms_credit_low_threshold,
"sms_credit_low": float(school.sms_credits) <= school.sms_credit_low_threshold,
"sms_this_month": sms_this_month,
"sms_chart": chart,
"pending_invoices": pending_inv,
"open_tickets": open_tickets,
}
@router.get("/sms-stats")
async def portal_sms_stats(
days: int = Query(30, ge=7, le=90),
current_user: HubUser = Depends(require_school_admin),
db: AsyncSession = Depends(get_db),
):
"""
SMS statistics for the school portal.
Returns daily chart, totals, delivery rate, and credit burn this month.
"""
school = await _get_school(current_user, db)
today = date.today()
# Build daily chart
chart = []
for i in range(days - 1, -1, -1):
d = today - timedelta(days=i)
sent_count = (await db.execute(
select(func.count()).where(
and_(SmsJob.school_id == school.id, SmsJob.status == SmsJobStatus.sent,
func.date(SmsJob.sent_at) == d)
)
)).scalar_one()
failed_count = (await db.execute(
select(func.count()).where(
and_(SmsJob.school_id == school.id, SmsJob.status == SmsJobStatus.failed,
func.date(SmsJob.created_at) == d)
)
)).scalar_one()
chart.append({"date": d.isoformat(), "sent": sent_count, "failed": failed_count})
# Period totals
period_start = today - timedelta(days=days - 1)
total = (await db.execute(
select(func.count()).where(
and_(SmsJob.school_id == school.id,
func.date(SmsJob.created_at) >= period_start)
)
)).scalar_one()
sent = (await db.execute(
select(func.count()).where(
and_(SmsJob.school_id == school.id, SmsJob.status == SmsJobStatus.sent,
func.date(SmsJob.created_at) >= period_start)
)
)).scalar_one()
failed = (await db.execute(
select(func.count()).where(
and_(SmsJob.school_id == school.id, SmsJob.status == SmsJobStatus.failed,
func.date(SmsJob.created_at) >= period_start)
)
)).scalar_one()
delivery_rate = round(sent / total * 100, 1) if total > 0 else 0.0
# Credit burn this month
burn_result = (await db.execute(
select(func.sum(SmsCreditLedger.amount)).where(
and_(
SmsCreditLedger.school_id == school.id,
SmsCreditLedger.tx_type == SmsCreditTx.deduct,
func.date_trunc("month", SmsCreditLedger.created_at) ==
func.date_trunc("month", func.current_date()),
)
)
)).scalar_one()
credit_burn_this_month = abs(float(burn_result or 0))
return {
"chart": chart,
"total": total,
"sent": sent,
"failed": failed,
"delivery_rate": delivery_rate,
"credit_burn_this_month": credit_burn_this_month,
"current_credits": float(school.sms_credits),
"period_days": days,
}
class CreditTopUpRequest(BaseModel):
amount_requested: int
notes: str = ""
@router.post("/credits/request", status_code=201)
async def request_credit_topup(
body: CreditTopUpRequest,
current_user: HubUser = Depends(require_school_admin),
db: AsyncSession = Depends(get_db),
):
"""
School admin requests a credit top-up.
Creates a billing support ticket automatically.
"""
school = await _get_school(current_user, db)
if body.amount_requested < 1:
raise HTTPException(400, "Amount must be at least 1")
count = (await db.execute(select(func.count()).select_from(SupportTicket))).scalar_one()
ticket_body = (
f"SMS Credit Top-Up Request\n\n"
f"School: {school.name}\n"
f"Requested amount: {body.amount_requested} credits\n"
f"Current balance: {float(school.sms_credits):.0f} credits\n"
)
if body.notes:
ticket_body += f"\nNotes: {body.notes}"
ticket = SupportTicket(
school_id=school.id,
submitted_by=current_user.id,
ticket_number=f"TKT-{date.today().year}-{count + 1:05d}",
subject=f"Credit Top-Up Request — {body.amount_requested} credits",
body=ticket_body,
category=TicketCategory.billing,
)
db.add(ticket)
await db.commit()
return {
"ticket_id": ticket.id,
"ticket_number": ticket.ticket_number,
"message": f"Top-up request for {body.amount_requested} credits submitted. We'll process it shortly.",
}