feat(phase-2): school registry + license management UI

Plan 2-01: School create/edit modal + license editor
- SchoolsPage: full create/edit modal (name, city, address, tier, contact,
  billing email, SMS sender name, student limit, status, notes); debounced
  search + status filter; Edit button per row opens pre-filled modal
- SchoolDetailPage: Activate / Suspend / Edit quick-action buttons in header;
  license panel with key copy, expiry, last seen (time-ago); license editor
  (expiry date, max students, tier) calls PUT /api/licenses/{id}; Revoke
  button calls POST /api/licenses/{id}/revoke
- Backend: GET /api/licenses/school/{school_id} — fetch license by school
- api.ts: getSchoolLicense added

Plan 2-02: Subscription setup + SMS credit top-up + ledger
- SchoolDetailPage: subscription panel (monthly fee, SMS cost per msg,
  billing cycle, next billing date) upserts via billing API
- SMS Credits panel: color-coded credit meter (green/amber/red based on
  balance), preset +100/+500/+1000 buttons, custom amount + note field,
  recent 5 ledger entries, link to full SMS page
- All operations toast on success/error

PAUL: Phase 2 marked complete, STATE + ROADMAP updated
This commit is contained in:
kevin-asprec
2026-03-16 08:09:59 +08:00
parent 2306094605
commit b435847a39
7 changed files with 784 additions and 79 deletions

View File

@@ -107,6 +107,26 @@ async def validate_license(
"features": _tier_features(lic.tier),
}
@router.get("/school/{school_id}")
async def get_school_license(
school_id: str,
_admin: HubUser = Depends(require_super_admin),
db: AsyncSession = Depends(get_db),
):
"""Get the license for a specific school."""
lic = (await db.execute(select(License).where(License.school_id == school_id))).scalar_one_or_none()
if not lic:
raise HTTPException(404, "No license found for this school")
return {
"id": lic.id, "school_id": lic.school_id, "key": lic.key,
"status": lic.status.value, "tier": lic.tier,
"issued_at": lic.issued_at.isoformat(),
"expires_at": lic.expires_at.isoformat() if lic.expires_at else None,
"last_validated_at": lic.last_validated_at.isoformat() if lic.last_validated_at else None,
"last_seen_ip": lic.last_seen_ip, "max_students": lic.max_students,
}
def _tier_features(tier: str) -> dict:
base = {"sms": True, "reports": True, "websocket": True, "multi_terminal": True}
if tier == "premium":