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

@@ -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 1 complete — Phase 2 next Status: Phase 2 complete — Phase 3 next
Phases: 1 of 15 complete Phases: 2 of 15 complete
--- ---
@@ -29,7 +29,7 @@ Phases: 1 of 15 complete
| Phase | Name | Plans | Status | Completed | | Phase | Name | Plans | Status | Completed |
|-------|------------------------------|-------|--------------|------------| |-------|------------------------------|-------|--------------|------------|
| 1 | Project Setup & Infrastructure | 1 | ✅ Complete | 2026-03-15 | | 1 | Project Setup & Infrastructure | 1 | ✅ Complete | 2026-03-15 |
| 2 | School Registry + License Mgmt | TBD | Not started | | | 2 | School Registry + License Mgmt | 2 | ✅ Complete | 2026-03-15 |
| 3 | On-Prem License Validation | TBD | Not started | — | | 3 | On-Prem License Validation | TBD | Not started | — |
| 4 | SMS Gateway (credits + queue) | TBD | Not started | — | | 4 | SMS Gateway (credits + queue) | TBD | Not started | — |
| 5 | On-Prem SMS Polling Agent | TBD | Not started | — | | 5 | On-Prem SMS Polling Agent | 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: 1 of 15 (Project Setup & Infrastructure — complete) Phase: 2 of 15 (School Registry + License Management — complete)
Plan: Phase 1 complete — no active plan Plan: Phase 2 complete — Phase 3 next
Status: **Phase 1 applied — ready to begin Phase 2** Status: **Phase 2 applied — ready to begin Phase 3**
Last activity: 2026-03-15 — Phase 1 complete (full stack scaffold: Docker Compose, all backend models/routers/tasks, Vue 3 frontend skeleton with all page stubs) Last activity: 2026-03-15 — Phase 2 complete (school create/edit modal, license editor, activate/suspend, subscription plan, SMS credit top-up, ledger)
## Loop Position ## Loop Position
@@ -20,7 +20,7 @@ PLAN ──▶ APPLY ──▶ UNIFY
### v1.0 Foundation ### v1.0 Foundation
- Phase 1 (Project Setup & Infrastructure): [██████████] 100% ✓ - Phase 1 (Project Setup & Infrastructure): [██████████] 100% ✓
- Phase 2 (School Registry + License Mgmt): [░░░░░░░░░░] 0% - Phase 2 (School Registry + License Mgmt): [██████████] 100% ✓
- Phase 3 (On-Prem License Validation): [░░░░░░░░░░] 0% - Phase 3 (On-Prem License Validation): [░░░░░░░░░░] 0%
- Phase 4 (SMS Gateway): [░░░░░░░░░░] 0% - Phase 4 (SMS Gateway): [░░░░░░░░░░] 0%
- Phase 5 (On-Prem SMS Polling Agent): [░░░░░░░░░░] 0% - Phase 5 (On-Prem SMS Polling Agent): [░░░░░░░░░░] 0%
@@ -37,8 +37,8 @@ PLAN ──▶ APPLY ──▶ UNIFY
## Next Action ## Next Action
Run: `/paul:plan` for Phase 2School Registry + License Management Run: `/paul:plan` for Phase 3On-Prem License Validation
Resume file: .paul/ROADMAP.md → Phase 2 Resume file: .paul/ROADMAP.md → Phase 3
## Repo ## Repo

View File

@@ -1,20 +1,49 @@
# Phase 2: School Registry + License Management # Phase 2: School Registry + License Management
**Status:** Not started **Status:** ✅ Complete
**Depends on:** Phase 1 **Completed:** 2026-03-15
## Goal ## Goal
Super admin can register schools, issue license keys, set billing plans, add SMS credits, Super admin can register schools, issue license keys, set billing plans, add SMS credits,
and manage the school lifecycle (pending → active → suspended/expired). and manage the school lifecycle (pending → active → suspended/expired).
## Plans ## Plans Completed
- [ ] 2-01: School create/edit modal + license expiry editor in SchoolDetailPage
- [ ] 2-02: Subscription setup + SMS credit top-up form + ledger table
## Key Endpoints (already built, need UI) ### 2-01: School create/edit modal + license editor
**SchoolsPage.vue:**
- "Add School" button opens full create modal (name, city, address, tier, contact info,
billing email, SMS sender name, student limit, notes)
- "Edit" button in each row opens pre-filled edit modal (same fields + status selector)
- Modal validates required fields, shows inline errors, toasts on success
- Auto-issues license key on school creation (handled by backend)
**SchoolDetailPage.vue:**
- Header: Activate / Suspend / Edit quick-action buttons
- School info panel: all fields displayed
- License panel: key display + copy button, status badge, expiry, last seen (time ago)
- License editor: set expiry date, max students, tier → calls PUT /api/licenses/{id}
- Revoke button: calls POST /api/licenses/{id}/revoke → suspends school
**Backend:**
- GET /api/licenses/school/{school_id} — new endpoint to fetch a school's license by school ID
### 2-02: Subscription setup + SMS credit top-up + ledger
**SchoolDetailPage.vue:**
- Subscription panel: monthly fee (PHP), SMS cost per message, billing cycle, next billing date
→ calls PUT /api/billing/subscriptions/{school_id} (upsert)
- SMS Credits panel: current balance with color-coded credit meter (green/amber/red)
→ preset buttons (+100, +500, +1000) + custom amount + note field
→ calls POST /api/schools/{id}/credits
- Recent ledger: shows last 5 transactions (description + signed amount)
→ link to full SMS ledger page
## Key Endpoints Used
- POST /api/schools — create school (auto-issues license) - POST /api/schools — create school (auto-issues license)
- PUT /api/schools/{id} — update school details - PUT /api/schools/{id} — update school details + status
- PUT /api/licenses/{id} — set expiry, tier, max_students - GET /api/licenses/school/{school_id} — get license by school
- POST /api/licenses/{id}/revoke — revoke license - PUT /api/licenses/{id} — update expiry, tier, max_students
- POST /api/licenses/{id}/revoke — revoke and suspend
- POST /api/schools/{id}/credits — add SMS credits - POST /api/schools/{id}/credits — add SMS credits
- PUT /api/billing/subscriptions/{school_id} — set monthly fee - GET /api/sms/credits/{school_id} — credit ledger
- PUT /api/billing/subscriptions/{school_id} — set billing plan
- GET /api/billing/subscriptions/{school_id} — get current plan

View File

@@ -107,6 +107,26 @@ async def validate_license(
"features": _tier_features(lic.tier), "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: def _tier_features(tier: str) -> dict:
base = {"sms": True, "reports": True, "websocket": True, "multi_terminal": True} base = {"sms": True, "reports": True, "websocket": True, "multi_terminal": True}
if tier == "premium": if tier == "premium":

View File

@@ -52,6 +52,7 @@ export const addSmsCredits = (id: string, amount: number, description?: string)
// ── Licenses ────────────────────────────────────────────────────────────────── // ── Licenses ──────────────────────────────────────────────────────────────────
export const getLicenses = () => api.get('/licenses').then(r => r.data) export const getLicenses = () => api.get('/licenses').then(r => r.data)
export const getSchoolLicense = (schoolId: string) => api.get(`/licenses/school/${schoolId}`).then(r => r.data)
export const updateLicense = (id: string, data: object) => api.put(`/licenses/${id}`, data).then(r => r.data) export const updateLicense = (id: string, data: object) => api.put(`/licenses/${id}`, data).then(r => r.data)
export const revokeLicense = (id: string) => api.post(`/licenses/${id}/revoke`).then(r => r.data) export const revokeLicense = (id: string) => api.post(`/licenses/${id}/revoke`).then(r => r.data)

View File

@@ -1,76 +1,524 @@
<template> <template>
<div class="space-y-6"> <div class="space-y-6">
<div class="flex items-center gap-4">
<!-- Page header -->
<div class="flex items-center gap-3 flex-wrap">
<button @click="$router.back()" class="text-slate-400 hover:text-slate-700 transition-colors"> <button @click="$router.back()" class="text-slate-400 hover:text-slate-700 transition-colors">
<ArrowLeft :size="20" /> <ArrowLeft :size="20" />
</button> </button>
<div> <div class="flex-1 min-w-0">
<h1 class="text-2xl font-bold text-slate-900">{{ school?.name ?? '…' }}</h1> <h1 class="text-2xl font-bold text-slate-900 truncate">{{ school?.name ?? '…' }}</h1>
<p class="text-sm text-slate-500 mt-0.5">{{ school?.slug }}</p> <p class="text-sm text-slate-500 mt-0.5">{{ school?.slug }}</p>
</div> </div>
<StatusBadge v-if="school" :status="school.status" class="ml-2" /> <StatusBadge v-if="school" :status="school.status" />
<!-- Quick action buttons -->
<template v-if="school">
<button v-if="school.status !== 'active'" @click="setStatus('active')"
class="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-emerald-600 text-white text-xs font-medium hover:bg-emerald-700 transition-colors">
<CheckCircle :size="14" /> Activate
</button>
<button v-if="school.status === 'active'" @click="setStatus('suspended')"
class="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-amber-500 text-white text-xs font-medium hover:bg-amber-600 transition-colors">
<Ban :size="14" /> Suspend
</button>
<button @click="openEdit"
class="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-slate-200 text-slate-700 text-xs font-medium hover:bg-slate-50 transition-colors">
<Pencil :size="14" /> Edit
</button>
</template>
</div> </div>
<div v-if="loading" class="animate-pulse h-48 bg-white rounded-xl" style="box-shadow:0 2px 8px #0000000A"></div> <!-- Skeleton -->
<div v-if="loading" class="grid grid-cols-1 xl:grid-cols-3 gap-6 animate-pulse">
<div class="xl:col-span-2 bg-white rounded-xl h-56" style="box-shadow:0 2px 8px #0000000A"></div>
<div class="bg-white rounded-xl h-56" style="box-shadow:0 2px 8px #0000000A"></div>
</div>
<div v-else-if="school" class="grid grid-cols-1 xl:grid-cols-3 gap-6"> <template v-else-if="school">
<!-- Info card -->
<div class="xl:col-span-2 bg-white rounded-xl p-6 space-y-4" style="box-shadow:0 2px 8px #0000000A"> <!-- Row 1: Info + License -->
<h2 class="text-base font-semibold text-slate-900">School Information</h2> <div class="grid grid-cols-1 xl:grid-cols-3 gap-6">
<div class="grid grid-cols-2 gap-4 text-sm">
<div><p class="text-slate-500">Contact</p><p class="font-medium">{{ school.contact_name || '—' }}</p></div> <!-- School info -->
<div><p class="text-slate-500">Email</p><p class="font-medium">{{ school.contact_email || '—' }}</p></div> <div class="xl:col-span-2 bg-white rounded-xl p-6" style="box-shadow:0 2px 8px #0000000A">
<div><p class="text-slate-500">City</p><p class="font-medium">{{ school.city || '—' }}</p></div> <h2 class="text-base font-semibold text-slate-900 mb-4">School Information</h2>
<div><p class="text-slate-500">Billing Email</p><p class="font-medium">{{ school.billing_email || '—' }}</p></div> <div class="grid grid-cols-2 gap-x-8 gap-y-3 text-sm">
<div><p class="text-slate-500">Tier</p><p class="font-medium capitalize">{{ school.tier }}</p></div> <div><p class="text-slate-500 text-xs">Contact Name</p><p class="font-medium mt-0.5">{{ school.contact_name || '—' }}</p></div>
<div><p class="text-slate-500">Student Limit</p><p class="font-medium">{{ school.student_limit.toLocaleString() }}</p></div> <div><p class="text-slate-500 text-xs">Contact Email</p><p class="font-medium mt-0.5">{{ school.contact_email || '—' }}</p></div>
<div><p class="text-slate-500">SMS Sender</p><p class="font-medium font-mono">{{ school.sms_sender_name }}</p></div> <div><p class="text-slate-500 text-xs">City</p><p class="font-medium mt-0.5">{{ school.city || '—' }}</p></div>
<div><p class="text-slate-500 text-xs">Billing Email</p><p class="font-medium mt-0.5">{{ school.billing_email || '—' }}</p></div>
<div><p class="text-slate-500 text-xs">Tier</p><p class="font-medium capitalize mt-0.5">{{ school.tier }}</p></div>
<div><p class="text-slate-500 text-xs">Student Limit</p><p class="font-medium mt-0.5">{{ school.student_limit.toLocaleString() }}</p></div>
<div><p class="text-slate-500 text-xs">SMS Sender Name</p><p class="font-medium font-mono mt-0.5">{{ school.sms_sender_name }}</p></div>
<div> <div>
<p class="text-slate-500">SMS Credits</p> <p class="text-slate-500 text-xs">SMS Credits</p>
<p class="font-medium" :class="school.sms_credits < 50 ? 'text-red-600' : ''"> <p class="font-medium mt-0.5" :class="school.sms_credits < 50 ? 'text-red-600' : ''">
{{ school.sms_credits.toLocaleString() }} {{ Number(school.sms_credits).toLocaleString() }}
<span v-if="school.sms_credits < 50" class="text-red-500 text-xs ml-1"> Low</span>
</p> </p>
</div> </div>
<div v-if="school.notes" class="col-span-2">
<p class="text-slate-500 text-xs">Notes</p>
<p class="font-medium mt-0.5 text-slate-600">{{ school.notes }}</p>
</div> </div>
</div> </div>
</div>
<!-- License card --> <!-- License card -->
<div class="bg-white rounded-xl p-6 space-y-3" style="box-shadow:0 2px 8px #0000000A"> <div class="bg-white rounded-xl p-6 flex flex-col gap-4" style="box-shadow:0 2px 8px #0000000A">
<div class="flex items-center justify-between">
<h2 class="text-base font-semibold text-slate-900">License</h2> <h2 class="text-base font-semibold text-slate-900">License</h2>
<div class="text-sm space-y-2"> <StatusBadge :status="school.license_status || 'pending'" />
<div class="flex justify-between"><span class="text-slate-500">Key</span><span class="font-mono text-xs">{{ school.license_key || '—' }}</span></div>
<div class="flex justify-between"><span class="text-slate-500">Status</span><StatusBadge :status="school.license_status || 'pending'" /></div>
<div class="flex justify-between"><span class="text-slate-500">Expires</span><span>{{ school.license_expires_at ? new Date(school.license_expires_at).toLocaleDateString() : 'Never' }}</span></div>
<div class="flex justify-between"><span class="text-slate-500">Last Seen</span><span>{{ school.license_last_seen ? new Date(school.license_last_seen).toLocaleDateString() : '—' }}</span></div>
</div> </div>
<button @click="copyKey" v-if="school.license_key"
class="w-full mt-2 py-1.5 text-xs font-medium border border-slate-200 rounded-lg hover:bg-slate-50 transition-colors flex items-center justify-center gap-1.5"> <!-- Key display -->
<Copy :size="12" /> Copy License Key <div class="bg-slate-50 rounded-lg px-3 py-2.5 flex items-center justify-between gap-2">
<span class="font-mono text-xs text-slate-700 truncate">{{ school.license_key || 'No key issued' }}</span>
<button v-if="school.license_key" @click="copyKey" title="Copy key"
class="text-slate-400 hover:text-blue-600 transition-colors shrink-0">
<Copy :size="14" />
</button>
</div>
<!-- License meta -->
<div class="text-sm space-y-2">
<div class="flex justify-between items-center">
<span class="text-slate-500 text-xs">Expires</span>
<span :class="isExpiringSoon ? 'text-amber-600 font-semibold' : 'text-slate-700'">
{{ school.license_expires_at ? new Date(school.license_expires_at).toLocaleDateString() : 'Never' }}
</span>
</div>
<div class="flex justify-between items-center">
<span class="text-slate-500 text-xs">Last Seen</span>
<span class="text-slate-700">{{ school.license_last_seen ? timeAgo(school.license_last_seen) : '—' }}</span>
</div>
</div>
<!-- License editor -->
<div class="border-t border-slate-100 pt-4 space-y-3">
<p class="text-xs font-semibold text-slate-400 uppercase tracking-wider">Edit License</p>
<div>
<label class="block text-xs font-medium text-slate-600 mb-1">Expiry Date</label>
<input v-model="licenseForm.expires_at" type="date"
class="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" />
</div>
<div>
<label class="block text-xs font-medium text-slate-600 mb-1">Max Students</label>
<input v-model.number="licenseForm.max_students" type="number" min="1"
class="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" />
</div>
<div>
<label class="block text-xs font-medium text-slate-600 mb-1">Tier</label>
<select v-model="licenseForm.tier"
class="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="basic">Basic</option>
<option value="standard">Standard</option>
<option value="premium">Premium</option>
</select>
</div>
<div class="flex gap-2">
<button @click="saveLicense" :disabled="licenseSaving"
class="flex-1 py-1.5 rounded-lg bg-blue-600 text-white text-xs font-medium hover:bg-blue-700 disabled:opacity-50 transition-colors">
{{ licenseSaving ? 'Saving…' : 'Save License' }}
</button>
<button v-if="school.license_status !== 'revoked'" @click="revokeKey"
class="flex-1 py-1.5 rounded-lg border border-red-200 text-red-600 text-xs font-medium hover:bg-red-50 transition-colors">
Revoke
</button> </button>
</div> </div>
</div> </div>
</div> </div>
</div>
<!-- Row 2: Subscription + SMS Credits -->
<div class="grid grid-cols-1 xl:grid-cols-2 gap-6">
<!-- Subscription plan -->
<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">Billing Subscription</h2>
<div v-if="subLoading" class="animate-pulse space-y-3">
<div class="h-8 bg-slate-100 rounded"></div>
<div class="h-8 bg-slate-100 rounded"></div>
</div>
<div v-else class="space-y-4">
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-xs font-medium text-slate-600 mb-1">Monthly Fee (PHP)</label>
<input v-model.number="subForm.monthly_fee" type="number" min="0" step="0.01"
class="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" />
</div>
<div>
<label class="block text-xs font-medium text-slate-600 mb-1">SMS Cost per Message (PHP)</label>
<input v-model.number="subForm.sms_cost_per_message" type="number" min="0" step="0.01"
class="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" />
</div>
<div>
<label class="block text-xs font-medium text-slate-600 mb-1">Billing Cycle</label>
<select v-model="subForm.cycle"
class="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="monthly">Monthly</option>
<option value="annual">Annual</option>
</select>
</div>
<div>
<label class="block text-xs font-medium text-slate-600 mb-1">Next Billing Date</label>
<input v-model="subForm.next_billing_date" type="date"
class="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" />
</div>
</div>
<p v-if="subError" class="text-xs text-red-600">{{ subError }}</p>
<button @click="saveSub" :disabled="subSaving"
class="w-full py-2 rounded-lg bg-blue-600 text-white text-sm font-medium hover:bg-blue-700 disabled:opacity-50 transition-colors">
{{ subSaving ? 'Saving…' : 'Save Subscription' }}
</button>
</div>
</div>
<!-- SMS Credit top-up -->
<div class="bg-white rounded-xl p-6" style="box-shadow:0 2px 8px #0000000A">
<div class="flex items-center justify-between mb-4">
<h2 class="text-base font-semibold text-slate-900">SMS Credits</h2>
<span class="text-2xl font-bold" :class="Number(school.sms_credits) < 50 ? 'text-red-600' : 'text-slate-900'">
{{ Number(school.sms_credits).toLocaleString() }}
</span>
</div>
<!-- Credit meter -->
<div class="mb-5">
<div class="h-2 bg-slate-100 rounded-full overflow-hidden">
<div class="h-full rounded-full transition-all duration-500"
:class="Number(school.sms_credits) < 50 ? 'bg-red-500' : Number(school.sms_credits) < 200 ? 'bg-amber-400' : 'bg-emerald-500'"
:style="{ width: Math.min((Number(school.sms_credits) / 1000) * 100, 100) + '%' }">
</div>
</div>
<p class="text-xs text-slate-400 mt-1">{{ Number(school.sms_credits) < 50 ? '⚠ Low balance' : 'Credits remaining' }}</p>
</div>
<!-- Top-up form -->
<div class="space-y-3">
<p class="text-xs font-semibold text-slate-400 uppercase tracking-wider">Add Credits</p>
<div class="flex gap-2">
<input v-model.number="creditAmount" type="number" min="1" placeholder="Amount"
class="flex-1 border border-slate-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" />
<div class="flex gap-1.5">
<button v-for="preset in [100, 500, 1000]" :key="preset"
@click="creditAmount = preset"
class="px-3 py-2 text-xs font-medium border border-slate-200 rounded-lg hover:bg-blue-50 hover:border-blue-300 hover:text-blue-700 transition-colors">
+{{ preset }}
</button>
</div>
</div>
<input v-model="creditNote" type="text" placeholder="Note (optional)"
class="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" />
<button @click="addCredits" :disabled="!creditAmount || creditSaving"
class="w-full py-2 rounded-lg bg-emerald-600 text-white text-sm font-medium hover:bg-emerald-700 disabled:opacity-50 transition-colors">
{{ creditSaving ? 'Adding…' : `Add ${creditAmount || 0} Credits` }}
</button>
</div>
<!-- Recent ledger -->
<div v-if="ledger.length > 0" class="mt-5 border-t border-slate-100 pt-4">
<p class="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-3">Recent Transactions</p>
<div class="space-y-2">
<div v-for="entry in ledger.slice(0, 5)" :key="entry.id"
class="flex items-center justify-between text-xs">
<span class="text-slate-600 truncate max-w-[160px]">{{ entry.description || entry.tx_type }}</span>
<span :class="entry.amount > 0 ? 'text-emerald-600 font-semibold' : 'text-red-500 font-semibold'">
{{ entry.amount > 0 ? '+' : '' }}{{ Number(entry.amount).toLocaleString() }}
</span>
</div>
</div>
<button @click="$router.push(`/sms?school_id=${school.id}`)"
class="mt-2 text-xs text-blue-600 hover:underline">View full ledger </button>
</div>
</div>
</div>
</template>
</div>
<!-- Edit School Modal (reuse same form) -->
<Teleport to="body">
<Transition name="modal-fade">
<div v-if="editOpen" class="fixed inset-0 z-50 flex items-center justify-center p-4"
style="background:rgba(0,0,0,0.45)" @click.self="editOpen = false">
<div class="bg-white rounded-2xl shadow-2xl w-full max-w-xl overflow-hidden">
<div class="flex items-center justify-between px-6 py-5 border-b border-slate-100">
<h2 class="text-lg font-bold text-slate-900">Edit School</h2>
<button @click="editOpen = false" class="text-slate-400 hover:text-slate-700">
<X :size="20" />
</button>
</div>
<div class="px-6 py-5 space-y-4 max-h-[70vh] overflow-y-auto">
<div class="grid grid-cols-2 gap-4">
<div class="col-span-2">
<label class="block text-sm font-medium text-slate-700 mb-1">School Name</label>
<input v-model="editForm.name" type="text"
class="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" />
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">City</label>
<input v-model="editForm.city" type="text"
class="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" />
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">Tier</label>
<select v-model="editForm.tier"
class="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="basic">Basic</option>
<option value="standard">Standard</option>
<option value="premium">Premium</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">Contact Name</label>
<input v-model="editForm.contact_name" type="text"
class="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" />
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">Billing Email</label>
<input v-model="editForm.billing_email" type="email"
class="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" />
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">SMS Sender Name</label>
<input v-model="editForm.sms_sender_name" type="text" maxlength="11"
class="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-blue-500" />
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">Status</label>
<select v-model="editForm.status"
class="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="pending">Pending</option>
<option value="active">Active</option>
<option value="suspended">Suspended</option>
<option value="expired">Expired</option>
</select>
</div>
</div>
<p v-if="editError" class="text-sm text-red-600 bg-red-50 rounded-lg px-3 py-2">{{ editError }}</p>
</div>
<div class="flex justify-end gap-3 px-6 py-4 border-t border-slate-100 bg-slate-50">
<button @click="editOpen = false"
class="px-4 py-2 border border-slate-200 rounded-lg text-sm text-slate-700 hover:bg-slate-100">Cancel</button>
<button @click="saveEdit" :disabled="editSaving"
class="px-5 py-2 rounded-lg bg-blue-600 text-white text-sm font-medium hover:bg-blue-700 disabled:opacity-50">
{{ editSaving ? 'Saving…' : 'Save Changes' }}
</button>
</div>
</div>
</div>
</Transition>
</Teleport>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { ArrowLeft, Copy } from 'lucide-vue-next' import { ArrowLeft, Copy, CheckCircle, Ban, Pencil, X } from 'lucide-vue-next'
import { getSchool } from '@/lib/api' import {
getSchool, updateSchool, updateLicense, revokeLicense, getSchoolLicense,
addSmsCredits, getCreditLedger, getSubscription, upsertSubscription,
} from '@/lib/api'
import StatusBadge from '@/components/ui/StatusBadge.vue' import StatusBadge from '@/components/ui/StatusBadge.vue'
import { useToast } from '@/composables/useToast' import { useToast } from '@/composables/useToast'
const route = useRoute() const route = useRoute()
const toast = useToast() const toast = useToast()
const schoolId = route.params.id as string
// ── School data ───────────────────────────────────────────────────────────────
const school = ref<any>(null) const school = ref<any>(null)
const loading = ref(true) const loading = ref(true)
const licenseId = ref<string | null>(null)
async function loadSchool() {
try {
school.value = await getSchool(schoolId)
// Pre-fill license form from loaded school
licenseForm.value.expires_at = school.value.license_expires_at?.split('T')[0] ?? ''
licenseForm.value.max_students = school.value.student_limit ?? 500
licenseForm.value.tier = school.value.tier ?? 'standard'
// Load license id for update/revoke operations
try {
const lic = await getSchoolLicense(schoolId)
licenseId.value = lic.id
} catch { /* no license yet */ }
} finally { loading.value = false }
}
onMounted(async () => { onMounted(async () => {
try { school.value = await getSchool(route.params.id as string) } await loadSchool()
finally { loading.value = false } loadSub()
loadLedger()
}) })
// ── Computed ──────────────────────────────────────────────────────────────────
const isExpiringSoon = computed(() => {
if (!school.value?.license_expires_at) return false
const exp = new Date(school.value.license_expires_at)
const daysLeft = (exp.getTime() - Date.now()) / 86400000
return daysLeft <= 30 && daysLeft > 0
})
// ── Quick actions ─────────────────────────────────────────────────────────────
async function setStatus(status: string) {
try {
await updateSchool(schoolId, { status })
school.value.status = status
toast.success(`School ${status}`)
} catch (e: any) {
toast.error(e?.response?.data?.detail ?? 'Failed to update status')
}
}
// ── Copy key ──────────────────────────────────────────────────────────────────
function copyKey() { function copyKey() {
navigator.clipboard.writeText(school.value?.license_key ?? '') navigator.clipboard.writeText(school.value?.license_key ?? '')
toast.success('License key copied') toast.success('License key copied to clipboard')
}
// ── License editor ────────────────────────────────────────────────────────────
const licenseForm = ref({ expires_at: '', max_students: 500, tier: 'standard' })
const licenseSaving = ref(false)
async function saveLicense() {
if (!licenseId.value) { toast.error('No license found for this school'); return }
licenseSaving.value = true
try {
await updateLicense(licenseId.value, {
expires_at: licenseForm.value.expires_at || null,
max_students: licenseForm.value.max_students,
tier: licenseForm.value.tier,
})
// Also sync tier + student_limit on the school record
await updateSchool(schoolId, {
tier: licenseForm.value.tier,
student_limit: licenseForm.value.max_students,
})
toast.success('License updated')
await loadSchool()
} catch (e: any) {
toast.error(e?.response?.data?.detail ?? 'Failed to update license')
} finally { licenseSaving.value = false }
}
async function revokeKey() {
if (!licenseId.value) return
if (!confirm('Revoke this license? The school will lose access to SMS and features immediately.')) return
try {
await revokeLicense(licenseId.value)
toast.success('License revoked — school suspended')
await loadSchool()
} catch (e: any) {
toast.error(e?.response?.data?.detail ?? 'Failed to revoke license')
}
}
// ── Subscription ──────────────────────────────────────────────────────────────
const subForm = ref({ monthly_fee: 0, sms_cost_per_message: 1.0, cycle: 'monthly', next_billing_date: '' })
const subLoading = ref(false)
const subSaving = ref(false)
const subError = ref('')
async function loadSub() {
subLoading.value = true
try {
const sub = await getSubscription(schoolId)
subForm.value.monthly_fee = Number(sub.monthly_fee)
subForm.value.sms_cost_per_message = Number(sub.sms_cost_per_message)
subForm.value.cycle = sub.cycle
subForm.value.next_billing_date = sub.next_billing_date ?? ''
} catch {
// 404 = no subscription yet — defaults are fine
} finally { subLoading.value = false }
}
async function saveSub() {
subSaving.value = true
subError.value = ''
try {
await upsertSubscription(schoolId, subForm.value)
toast.success('Subscription saved')
} catch (e: any) {
subError.value = e?.response?.data?.detail ?? 'Failed to save subscription'
} finally { subSaving.value = false }
}
// ── SMS credits ───────────────────────────────────────────────────────────────
const creditAmount = ref<number | null>(null)
const creditNote = ref('')
const creditSaving = ref(false)
const ledger = ref<any[]>([])
async function loadLedger() {
try {
const res = await getCreditLedger(schoolId, { per_page: 5 })
ledger.value = res.items
} catch { /* ignore */ }
}
async function addCredits() {
if (!creditAmount.value || creditAmount.value <= 0) return
creditSaving.value = true
try {
await addSmsCredits(schoolId, creditAmount.value, creditNote.value || undefined)
toast.success(`${creditAmount.value} SMS credits added`)
creditAmount.value = null
creditNote.value = ''
await loadSchool()
loadLedger()
} catch (e: any) {
toast.error(e?.response?.data?.detail ?? 'Failed to add credits')
} finally { creditSaving.value = false }
}
// ── Edit school modal ─────────────────────────────────────────────────────────
const editOpen = ref(false)
const editSaving = ref(false)
const editError = ref('')
const editForm = ref<any>({})
function openEdit() {
editForm.value = {
name: school.value.name,
city: school.value.city ?? '',
tier: school.value.tier,
contact_name: school.value.contact_name ?? '',
billing_email: school.value.billing_email ?? '',
sms_sender_name: school.value.sms_sender_name,
status: school.value.status,
}
editError.value = ''
editOpen.value = true
}
async function saveEdit() {
editSaving.value = true
editError.value = ''
try {
await updateSchool(schoolId, editForm.value)
toast.success('School updated')
editOpen.value = false
await loadSchool()
} catch (e: any) {
editError.value = e?.response?.data?.detail ?? 'Failed to update school'
} finally { editSaving.value = false }
}
// ── Helpers ───────────────────────────────────────────────────────────────────
function timeAgo(iso: string): string {
const diff = Date.now() - new Date(iso).getTime()
const m = Math.floor(diff / 60000)
if (m < 1) return 'just now'
if (m < 60) return `${m}m ago`
const h = Math.floor(m / 60)
if (h < 24) return `${h}h ago`
return `${Math.floor(h / 24)}d ago`
} }
</script> </script>
<style scoped>
.modal-fade-enter-active, .modal-fade-leave-active { transition: opacity 0.2s ease; }
.modal-fade-enter-from, .modal-fade-leave-to { opacity: 0; }
</style>

View File

@@ -5,7 +5,7 @@
<h1 class="text-2xl font-bold text-slate-900">Schools</h1> <h1 class="text-2xl font-bold text-slate-900">Schools</h1>
<p class="text-sm text-slate-500 mt-0.5">{{ total }} registered schools</p> <p class="text-sm text-slate-500 mt-0.5">{{ total }} registered schools</p>
</div> </div>
<button @click="showCreate = true" <button @click="openCreate"
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 transition-colors"> 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 transition-colors">
<Plus :size="16" /> Add School <Plus :size="16" /> Add School
</button> </button>
@@ -69,9 +69,10 @@
{{ s.license_last_seen ? new Date(s.license_last_seen).toLocaleDateString() : '—' }} {{ s.license_last_seen ? new Date(s.license_last_seen).toLocaleDateString() : '—' }}
</td> </td>
<td class="px-5 py-3"> <td class="px-5 py-3">
<RouterLink :to="`/schools/${s.id}`" class="text-blue-600 hover:underline text-xs font-medium"> <div class="flex items-center gap-3">
View <RouterLink :to="`/schools/${s.id}`" class="text-blue-600 hover:underline text-xs font-medium">View</RouterLink>
</RouterLink> <button @click="openEdit(s)" class="text-slate-500 hover:text-slate-800 text-xs font-medium">Edit</button>
</div>
</td> </td>
</tr> </tr>
</tbody> </tbody>
@@ -87,14 +88,149 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Create / Edit School Modal -->
<Teleport to="body">
<Transition name="modal-fade">
<div v-if="modalOpen" class="fixed inset-0 z-50 flex items-center justify-center p-4"
style="background:rgba(0,0,0,0.45)" @click.self="closeModal">
<div class="bg-white rounded-2xl shadow-2xl w-full max-w-xl overflow-hidden">
<!-- Header -->
<div class="flex items-center justify-between px-6 py-5 border-b border-slate-100">
<h2 class="text-lg font-bold text-slate-900">
{{ editingId ? 'Edit School' : 'Add New School' }}
</h2>
<button @click="closeModal" class="text-slate-400 hover:text-slate-700 transition-colors">
<X :size="20" />
</button>
</div>
<!-- Body -->
<div class="px-6 py-5 space-y-5 max-h-[70vh] overflow-y-auto">
<div class="space-y-4">
<p class="text-xs font-semibold text-slate-400 uppercase tracking-wider">Basic Information</p>
<div class="grid grid-cols-2 gap-4">
<div class="col-span-2">
<label class="block text-sm font-medium text-slate-700 mb-1">School Name <span class="text-red-500">*</span></label>
<input v-model="form.name" type="text" placeholder="e.g. San Jose National High School"
class="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" />
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">City</label>
<input v-model="form.city" type="text" placeholder="e.g. Manila"
class="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" />
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">Tier</label>
<select v-model="form.tier"
class="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="basic">Basic</option>
<option value="standard">Standard</option>
<option value="premium">Premium</option>
</select>
</div>
<div class="col-span-2">
<label class="block text-sm font-medium text-slate-700 mb-1">Address</label>
<input v-model="form.address" type="text"
class="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" />
</div>
</div>
</div>
<div class="space-y-4">
<p class="text-xs font-semibold text-slate-400 uppercase tracking-wider">Contact</p>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">Contact Name</label>
<input v-model="form.contact_name" type="text"
class="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" />
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">Contact Phone</label>
<input v-model="form.contact_phone" type="tel"
class="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" />
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">Contact Email</label>
<input v-model="form.contact_email" type="email"
class="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" />
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">Billing Email</label>
<input v-model="form.billing_email" type="email" placeholder="For invoices"
class="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" />
</div>
</div>
</div>
<div class="space-y-4">
<p class="text-xs font-semibold text-slate-400 uppercase tracking-wider">TapTrack Settings</p>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">
SMS Sender Name <span class="text-slate-400 font-normal">(max 11 chars)</span>
</label>
<input v-model="form.sms_sender_name" type="text" maxlength="11" placeholder="e.g. SJNHS"
class="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-blue-500" />
<p class="text-xs text-slate-400 mt-1">{{ (form.sms_sender_name || '').length }}/11</p>
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">Student Limit</label>
<input v-model.number="form.student_limit" type="number" min="1" max="10000"
class="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" />
</div>
</div>
</div>
<div v-if="editingId" class="space-y-3">
<p class="text-xs font-semibold text-slate-400 uppercase tracking-wider">Status</p>
<select v-model="form.status"
class="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="pending">Pending</option>
<option value="active">Active</option>
<option value="suspended">Suspended</option>
<option value="expired">Expired</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">Internal Notes</label>
<textarea v-model="form.notes" rows="2" placeholder="Visible only to super admins…"
class="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none"></textarea>
</div>
<p v-if="modalError" class="text-sm text-red-600 bg-red-50 rounded-lg px-3 py-2">{{ modalError }}</p>
</div>
<!-- Footer -->
<div class="flex justify-end gap-3 px-6 py-4 border-t border-slate-100 bg-slate-50">
<button @click="closeModal"
class="px-4 py-2 border border-slate-200 rounded-lg text-sm text-slate-700 hover:bg-slate-100 transition-colors">
Cancel
</button>
<button @click="submitModal" :disabled="!form.name || saving"
class="px-5 py-2 rounded-lg bg-blue-600 text-white text-sm font-medium hover:bg-blue-700 disabled:opacity-50 transition-colors">
{{ saving ? 'Saving…' : editingId ? 'Save Changes' : 'Create School' }}
</button>
</div>
</div>
</div>
</Transition>
</Teleport>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch, onMounted } from 'vue' import { ref, watch, onMounted } from 'vue'
import { Plus, Building2 } from 'lucide-vue-next' import { Plus, Building2, X } from 'lucide-vue-next'
import { getSchools, type School } from '@/lib/api' import { getSchools, createSchool, updateSchool, type School } 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()
// ── List ──────────────────────────────────────────────────────────────────────
const schools = ref<School[]>([]) const schools = ref<School[]>([])
const total = ref(0) const total = ref(0)
const page = ref(1) const page = ref(1)
@@ -102,13 +238,16 @@ const perPage = 25
const search = ref('') const search = ref('')
const statusFilter = ref('') const statusFilter = ref('')
const loading = ref(false) const loading = ref(false)
const showCreate = ref(false)
let debounce: any = null let debounce: any = null
async function fetchSchools() { async function fetchSchools() {
loading.value = true loading.value = true
try { try {
const res = await getSchools({ page: page.value, per_page: perPage, search: search.value || undefined, status: statusFilter.value || undefined }) const res = await getSchools({
page: page.value, per_page: perPage,
search: search.value || undefined,
status: statusFilter.value || undefined,
})
schools.value = res.items schools.value = res.items
total.value = res.total total.value = res.total
} finally { loading.value = false } } finally { loading.value = false }
@@ -117,4 +256,72 @@ async function fetchSchools() {
watch(search, () => { clearTimeout(debounce); debounce = setTimeout(() => { page.value = 1; fetchSchools() }, 300) }) watch(search, () => { clearTimeout(debounce); debounce = setTimeout(() => { page.value = 1; fetchSchools() }, 300) })
watch([page, statusFilter], fetchSchools) watch([page, statusFilter], fetchSchools)
onMounted(fetchSchools) onMounted(fetchSchools)
// ── Modal ─────────────────────────────────────────────────────────────────────
const modalOpen = ref(false)
const editingId = ref<string | null>(null)
const saving = ref(false)
const modalError = ref('')
const defaultForm = () => ({
name: '', city: '', address: '', tier: 'standard',
contact_name: '', contact_phone: '', contact_email: '', billing_email: '',
sms_sender_name: 'SCHOOL', student_limit: 500,
status: 'pending', notes: '',
})
const form = ref(defaultForm())
function openCreate() {
editingId.value = null
form.value = defaultForm()
modalError.value = ''
modalOpen.value = true
}
function openEdit(s: School) {
editingId.value = s.id
form.value = {
name: s.name,
city: s.city ?? '',
address: (s as any).address ?? '',
tier: s.tier,
contact_name: s.contact_name ?? '',
contact_phone: (s as any).contact_phone ?? '',
contact_email: s.contact_email ?? '',
billing_email: s.billing_email ?? '',
sms_sender_name: s.sms_sender_name,
student_limit: s.student_limit,
status: s.status,
notes: s.notes ?? '',
}
modalError.value = ''
modalOpen.value = true
}
function closeModal() { modalOpen.value = false }
async function submitModal() {
if (!form.value.name.trim()) return
saving.value = true
modalError.value = ''
try {
if (editingId.value) {
await updateSchool(editingId.value, form.value)
toast.success('School updated')
} else {
await createSchool(form.value)
toast.success('School created — license key issued automatically')
}
closeModal()
fetchSchools()
} catch (e: any) {
modalError.value = e?.response?.data?.detail ?? 'Failed to save school'
} finally { saving.value = false }
}
</script> </script>
<style scoped>
.modal-fade-enter-active, .modal-fade-leave-active { transition: opacity 0.2s ease; }
.modal-fade-enter-from, .modal-fade-leave-to { opacity: 0; }
</style>