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
**v1.0 — Foundation & Core Services**
Status: Phase 1 complete — Phase 2 next
Phases: 1 of 15 complete
Status: Phase 2 complete — Phase 3 next
Phases: 2 of 15 complete
---
@@ -29,7 +29,7 @@ Phases: 1 of 15 complete
| Phase | Name | Plans | Status | Completed |
|-------|------------------------------|-------|--------------|------------|
| 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 | — |
| 4 | SMS Gateway (credits + queue) | TBD | Not started | — |
| 5 | On-Prem SMS Polling Agent | TBD | Not started | — |

View File

@@ -3,10 +3,10 @@
## Current Position
Milestone: v1.0 — Foundation & Core Services
Phase: 1 of 15 (Project Setup & Infrastructure — complete)
Plan: Phase 1 complete — no active plan
Status: **Phase 1 applied — ready to begin Phase 2**
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)
Phase: 2 of 15 (School Registry + License Management — complete)
Plan: Phase 2 complete — Phase 3 next
Status: **Phase 2 applied — ready to begin Phase 3**
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
@@ -20,7 +20,7 @@ PLAN ──▶ APPLY ──▶ UNIFY
### v1.0 Foundation
- 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 4 (SMS Gateway): [░░░░░░░░░░] 0%
- Phase 5 (On-Prem SMS Polling Agent): [░░░░░░░░░░] 0%
@@ -37,8 +37,8 @@ PLAN ──▶ APPLY ──▶ UNIFY
## Next Action
Run: `/paul:plan` for Phase 2School Registry + License Management
Resume file: .paul/ROADMAP.md → Phase 2
Run: `/paul:plan` for Phase 3On-Prem License Validation
Resume file: .paul/ROADMAP.md → Phase 3
## Repo

View File

@@ -1,20 +1,49 @@
# Phase 2: School Registry + License Management
**Status:** Not started
**Depends on:** Phase 1
**Status:** ✅ Complete
**Completed:** 2026-03-15
## Goal
Super admin can register schools, issue license keys, set billing plans, add SMS credits,
and manage the school lifecycle (pending → active → suspended/expired).
## Plans
- [ ] 2-01: School create/edit modal + license expiry editor in SchoolDetailPage
- [ ] 2-02: Subscription setup + SMS credit top-up form + ledger table
## Plans Completed
## 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)
- PUT /api/schools/{id} — update school details
- PUT /api/licenses/{id} — set expiry, tier, max_students
- POST /api/licenses/{id}/revoke — revoke license
- PUT /api/schools/{id} — update school details + status
- GET /api/licenses/school/{school_id} — get license by school
- 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
- 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),
}
@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":

View File

@@ -52,6 +52,7 @@ export const addSmsCredits = (id: string, amount: number, description?: string)
// ── Licenses ──────────────────────────────────────────────────────────────────
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 revokeLicense = (id: string) => api.post(`/licenses/${id}/revoke`).then(r => r.data)

View File

@@ -1,76 +1,524 @@
<template>
<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">
<ArrowLeft :size="20" />
</button>
<div>
<h1 class="text-2xl font-bold text-slate-900">{{ school?.name ?? '…' }}</h1>
<div class="flex-1 min-w-0">
<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>
</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 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">
<!-- Info card -->
<div class="xl:col-span-2 bg-white rounded-xl p-6 space-y-4" style="box-shadow:0 2px 8px #0000000A">
<h2 class="text-base font-semibold text-slate-900">School Information</h2>
<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>
<div><p class="text-slate-500">Email</p><p class="font-medium">{{ school.contact_email || '—' }}</p></div>
<div><p class="text-slate-500">City</p><p class="font-medium">{{ school.city || '—' }}</p></div>
<div><p class="text-slate-500">Billing Email</p><p class="font-medium">{{ school.billing_email || '—' }}</p></div>
<div><p class="text-slate-500">Tier</p><p class="font-medium capitalize">{{ school.tier }}</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">SMS Sender</p><p class="font-medium font-mono">{{ school.sms_sender_name }}</p></div>
<div>
<p class="text-slate-500">SMS Credits</p>
<p class="font-medium" :class="school.sms_credits < 50 ? 'text-red-600' : ''">
{{ school.sms_credits.toLocaleString() }}
</p>
<template v-else-if="school">
<!-- Row 1: Info + License -->
<div class="grid grid-cols-1 xl:grid-cols-3 gap-6">
<!-- School info -->
<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">School Information</h2>
<div class="grid grid-cols-2 gap-x-8 gap-y-3 text-sm">
<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 text-xs">Contact Email</p><p class="font-medium mt-0.5">{{ school.contact_email || '—' }}</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>
<p class="text-slate-500 text-xs">SMS Credits</p>
<p class="font-medium mt-0.5" :class="school.sms_credits < 50 ? 'text-red-600' : ''">
{{ Number(school.sms_credits).toLocaleString() }}
<span v-if="school.sms_credits < 50" class="text-red-500 text-xs ml-1"> Low</span>
</p>
</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>
<!-- License card -->
<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>
<StatusBadge :status="school.license_status || 'pending'" />
</div>
<!-- Key display -->
<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>
</div>
</div>
</div>
</div>
<!-- License card -->
<div class="bg-white rounded-xl p-6 space-y-3" style="box-shadow:0 2px 8px #0000000A">
<h2 class="text-base font-semibold text-slate-900">License</h2>
<div class="text-sm space-y-2">
<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>
<!-- 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>
<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">
<Copy :size="12" /> Copy License Key
</button>
</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>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { ArrowLeft, Copy } from 'lucide-vue-next'
import { getSchool } from '@/lib/api'
import { ArrowLeft, Copy, CheckCircle, Ban, Pencil, X } from 'lucide-vue-next'
import {
getSchool, updateSchool, updateLicense, revokeLicense, getSchoolLicense,
addSmsCredits, getCreditLedger, getSubscription, upsertSubscription,
} from '@/lib/api'
import StatusBadge from '@/components/ui/StatusBadge.vue'
import { useToast } from '@/composables/useToast'
const route = useRoute()
const toast = useToast()
const school = ref<any>(null)
const loading = ref(true)
const schoolId = route.params.id as string
// ── School data ───────────────────────────────────────────────────────────────
const school = ref<any>(null)
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 () => {
try { school.value = await getSchool(route.params.id as string) }
finally { loading.value = false }
await loadSchool()
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() {
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>
<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>
<p class="text-sm text-slate-500 mt-0.5">{{ total }} registered schools</p>
</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">
<Plus :size="16" /> Add School
</button>
@@ -69,9 +69,10 @@
{{ s.license_last_seen ? new Date(s.license_last_seen).toLocaleDateString() : '—' }}
</td>
<td class="px-5 py-3">
<RouterLink :to="`/schools/${s.id}`" class="text-blue-600 hover:underline text-xs font-medium">
View
</RouterLink>
<div class="flex items-center gap-3">
<RouterLink :to="`/schools/${s.id}`" class="text-blue-600 hover:underline text-xs font-medium">View</RouterLink>
<button @click="openEdit(s)" class="text-slate-500 hover:text-slate-800 text-xs font-medium">Edit</button>
</div>
</td>
</tr>
</tbody>
@@ -87,34 +88,240 @@
</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>
<script setup lang="ts">
import { ref, watch, onMounted } from 'vue'
import { Plus, Building2 } from 'lucide-vue-next'
import { getSchools, type School } from '@/lib/api'
import { Plus, Building2, X } from 'lucide-vue-next'
import { getSchools, createSchool, updateSchool, type School } from '@/lib/api'
import StatusBadge from '@/components/ui/StatusBadge.vue'
import { useToast } from '@/composables/useToast'
const schools = ref<School[]>([])
const total = ref(0)
const page = ref(1)
const perPage = 25
const search = ref('')
const toast = useToast()
// ── List ──────────────────────────────────────────────────────────────────────
const schools = ref<School[]>([])
const total = ref(0)
const page = ref(1)
const perPage = 25
const search = ref('')
const statusFilter = ref('')
const loading = ref(false)
const showCreate = ref(false)
let debounce: any = null
const loading = ref(false)
let debounce: any = null
async function fetchSchools() {
loading.value = true
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
total.value = res.total
total.value = res.total
} finally { loading.value = false }
}
watch(search, () => { clearTimeout(debounce); debounce = setTimeout(() => { page.value = 1; fetchSchools() }, 300) })
watch([page, statusFilter], 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>
<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>