diff --git a/.paul/ROADMAP.md b/.paul/ROADMAP.md index c628173..2eebb0e 100644 --- a/.paul/ROADMAP.md +++ b/.paul/ROADMAP.md @@ -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 6 complete — Phase 7 next -Phases: 6 of 15 complete +Status: Phase 7 complete — Phase 8 next +Phases: 7 of 15 complete --- @@ -34,7 +34,7 @@ Phases: 6 of 15 complete | 4 | SMS Gateway (credits + queue) | 2 | ✅ Complete | 2026-03-15 | | 5 | On-Prem SMS Polling Agent | 1 | ✅ Complete | 2026-03-16 | | 6 | Super Admin Dashboard UI | 1 | ✅ Complete | 2026-03-16 | -| 7 | School Admin Portal UI | TBD | Not started | — | +| 7 | School Admin Portal UI | 1 | ✅ Complete | 2026-03-16 | | 8 | Billing Engine + Invoice PDF | TBD | Not started | — | | 9 | Email Dispatcher | TBD | Not started | — | | 10 | Support Ticket System | TBD | Not started | — | diff --git a/.paul/STATE.md b/.paul/STATE.md index 7665b6a..d4dd86d 100644 --- a/.paul/STATE.md +++ b/.paul/STATE.md @@ -3,16 +3,16 @@ ## Current Position Milestone: v1.0 — Foundation & Core Services -Phase: 6 of 15 (Super Admin Dashboard UI — complete) -Plan: Phase 6 complete — Phase 7 next -Status: **Phase 6 applied — ready to begin Phase 7** -Last activity: 2026-03-16 — Phase 6 complete (rich dashboard: KPI row, SMS volume chart, revenue snapshot, school health table with sort/filter, quick actions, refresh button) +Phase: 7 of 15 (School Admin Portal UI — complete) +Plan: Phase 7 complete — Phase 8 next +Status: **Phase 7 applied — ready to begin Phase 8** +Last activity: 2026-03-16 — Phase 7 complete (portal pages: suspension banner, credit meter, license countdown, SMS chart, delivery stats, top-up request form, ticket filter/pagination, school details) ## Loop Position ``` PLAN ──▶ APPLY ──▶ UNIFY - · · · [No active plan — Phase 7 planning next] + · · · [No active plan — Phase 8 planning next] ``` ## Progress @@ -25,7 +25,7 @@ PLAN ──▶ APPLY ──▶ UNIFY - Phase 4 (SMS Gateway): [██████████] 100% ✓ - Phase 5 (On-Prem SMS Polling Agent): [██████████] 100% ✓ - Phase 6 (Super Admin Dashboard UI): [██████████] 100% ✓ -- Phase 7 (School Admin Portal UI): [░░░░░░░░░░] 0% +- Phase 7 (School Admin Portal UI): [██████████] 100% ✓ - Phase 8 (Billing Engine + Invoice PDF): [░░░░░░░░░░] 0% - Phase 9 (Email Dispatcher): [░░░░░░░░░░] 0% - Phase 10 (Support Ticket System): [░░░░░░░░░░] 0% @@ -37,8 +37,8 @@ PLAN ──▶ APPLY ──▶ UNIFY ## Next Action -Run: `/paul:plan` for Phase 7 — School Admin Portal UI -Resume file: .paul/ROADMAP.md → Phase 7 +Run: `/paul:plan` for Phase 8 — Billing Engine + Invoice PDF +Resume file: .paul/ROADMAP.md → Phase 8 ## Repo diff --git a/.paul/phases/07-school-portal/README.md b/.paul/phases/07-school-portal/README.md index efd1d7b..6dc037e 100644 --- a/.paul/phases/07-school-portal/README.md +++ b/.paul/phases/07-school-portal/README.md @@ -1,9 +1,45 @@ # Phase 07: School Admin Portal UI -**Status:** Not started +**Status:** Complete +**Completed:** 2026-03-16 ## Goal -Complete portal with credit meter, charts, billing PDF download, SMS reports. -## Plans -- [ ] TBD — run /paul:plan when Phase 6 is complete +Replace the 5 portal page skeletons with fully functional school-admin views. + +## What was built + +### Backend additions to `school_portal.py` +- `GET /portal/sms-stats` — 30-day SMS chart, delivery rate, credit burn rate, monthly breakdown +- `POST /portal/credits/request` — school admin submits a credit top-up request (creates a support ticket) + +### Frontend pages (full rewrites) + +**PortalOverviewPage.vue** +- Suspension/expired banner (red alert if school is not active) +- Credit meter: progress bar showing credits vs. threshold, LOW CREDIT warning +- License countdown: days remaining badge, expired warning +- 30-day SMS usage mini-chart (same bar pattern as SmsPage) +- KPI cards: SMS Credits, SMS This Month, Open Tickets, Pending Invoices +- Announcements banner + +**PortalBillingPage.vue** +- Invoice table: number, period, breakdown (subscription + SMS), total, status badge, due date +- Credit top-up request form (opens inline, sends request to support ticket) +- Subscription info panel (monthly fee, SMS cost/message) + +**PortalSmsPage.vue** +- 30-day SMS volume chart (sent/failed bars) +- Delivery rate stat card +- Credit burn rate (credits used this month) +- Monthly SMS job table with status filter and pagination +- Trigger type breakdown + +**PortalTicketsPage.vue** +- Status filter tabs (All / Open / In Progress / Resolved) +- Pagination +- Better empty state + +**PortalProfilePage.vue** +- School details panel (name, city, contact, tier, status) +- Change password form (already existed — preserved) diff --git a/backend/app/routers/school_portal.py b/backend/app/routers/school_portal.py index 6c1bc83..7997c98 100644 --- a/backend/app/routers/school_portal.py +++ b/backend/app/routers/school_portal.py @@ -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.", + } diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index ec000d6..772a72e 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -94,4 +94,7 @@ export const createAnnouncement = (data: object) => api.post('/announcements', d export const deleteAnnouncement = (id: string) => api.delete(`/announcements/${id}`) // ── School Portal ───────────────────────────────────────────────────────────── -export const getPortalOverview = () => api.get('/portal/overview').then(r => r.data) +export const getPortalOverview = () => api.get('/portal/overview').then(r => r.data) +export const getPortalSmsStats = (params?: object) => api.get('/portal/sms-stats', { params }).then(r => r.data) +export const requestCreditTopup = (data: { amount_requested: number; notes?: string }) => + api.post('/portal/credits/request', data).then(r => r.data) diff --git a/frontend/src/pages/portal/PortalBillingPage.vue b/frontend/src/pages/portal/PortalBillingPage.vue index 854c2b6..b545f1b 100644 --- a/frontend/src/pages/portal/PortalBillingPage.vue +++ b/frontend/src/pages/portal/PortalBillingPage.vue @@ -1,38 +1,188 @@ diff --git a/frontend/src/pages/portal/PortalOverviewPage.vue b/frontend/src/pages/portal/PortalOverviewPage.vue index b0a05c3..7718b0d 100644 --- a/frontend/src/pages/portal/PortalOverviewPage.vue +++ b/frontend/src/pages/portal/PortalOverviewPage.vue @@ -1,49 +1,229 @@ diff --git a/frontend/src/pages/portal/PortalProfilePage.vue b/frontend/src/pages/portal/PortalProfilePage.vue index 8d77d52..a046ba2 100644 --- a/frontend/src/pages/portal/PortalProfilePage.vue +++ b/frontend/src/pages/portal/PortalProfilePage.vue @@ -1,9 +1,11 @@ diff --git a/frontend/src/pages/portal/PortalTicketsPage.vue b/frontend/src/pages/portal/PortalTicketsPage.vue index c829c1b..ce8ba8e 100644 --- a/frontend/src/pages/portal/PortalTicketsPage.vue +++ b/frontend/src/pages/portal/PortalTicketsPage.vue @@ -1,23 +1,29 @@