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:
@@ -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 | — |
|
||||
|
||||
@@ -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 2 — School Registry + License Management
|
||||
Resume file: .paul/ROADMAP.md → Phase 2
|
||||
Run: `/paul:plan` for Phase 3 — On-Prem License Validation
|
||||
Resume file: .paul/ROADMAP.md → Phase 3
|
||||
|
||||
## Repo
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user