feat(phase-6): rich super admin dashboard UI
Full DashboardPage rewrite with: - 5-KPI row (total/active schools, expiring licenses, open tickets, SMS today) - 30-day SMS volume bar chart (reuses SmsPage chart pattern) - Revenue snapshot: MRR, paid this month, outstanding + overdue invoices - School health table: status, tier, credit balance with LOW badge, license expiry countdown, last-seen relative time — sortable + search + status filter - Quick actions panel with open-ticket badge counter - Mini stats: SMS queue depth, suspended school count - Refresh button with last-updated timestamp - api.ts: getDashboardSchoolHealth + getDashboardRevenue
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
|
## Current Milestone
|
||||||
|
|
||||||
**v1.0 — Foundation & Core Services**
|
**v1.0 — Foundation & Core Services**
|
||||||
Status: Phase 5 complete — Phase 6 next
|
Status: Phase 6 complete — Phase 7 next
|
||||||
Phases: 5 of 15 complete
|
Phases: 6 of 15 complete
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -33,7 +33,7 @@ Phases: 5 of 15 complete
|
|||||||
| 3 | On-Prem License Validation | 1 | ✅ Complete | 2026-03-15 |
|
| 3 | On-Prem License Validation | 1 | ✅ Complete | 2026-03-15 |
|
||||||
| 4 | SMS Gateway (credits + queue) | 2 | ✅ Complete | 2026-03-15 |
|
| 4 | SMS Gateway (credits + queue) | 2 | ✅ Complete | 2026-03-15 |
|
||||||
| 5 | On-Prem SMS Polling Agent | 1 | ✅ Complete | 2026-03-16 |
|
| 5 | On-Prem SMS Polling Agent | 1 | ✅ Complete | 2026-03-16 |
|
||||||
| 6 | Super Admin Dashboard UI | TBD | Not started | — |
|
| 6 | Super Admin Dashboard UI | 1 | ✅ Complete | 2026-03-16 |
|
||||||
| 7 | School Admin Portal UI | TBD | Not started | — |
|
| 7 | School Admin Portal UI | TBD | Not started | — |
|
||||||
| 8 | Billing Engine + Invoice PDF | TBD | Not started | — |
|
| 8 | Billing Engine + Invoice PDF | TBD | Not started | — |
|
||||||
| 9 | Email Dispatcher | TBD | Not started | — |
|
| 9 | Email Dispatcher | TBD | Not started | — |
|
||||||
|
|||||||
@@ -3,16 +3,16 @@
|
|||||||
## Current Position
|
## Current Position
|
||||||
|
|
||||||
Milestone: v1.0 — Foundation & Core Services
|
Milestone: v1.0 — Foundation & Core Services
|
||||||
Phase: 5 of 15 (On-Prem SMS Polling Agent — complete)
|
Phase: 6 of 15 (Super Admin Dashboard UI — complete)
|
||||||
Plan: Phase 5 complete — Phase 6 next
|
Plan: Phase 6 complete — Phase 7 next
|
||||||
Status: **Phase 5 applied — ready to begin Phase 6**
|
Status: **Phase 6 applied — ready to begin Phase 7**
|
||||||
Last activity: 2026-03-16 — Phase 5 complete (sync/poll hardening: credit deduction, failed job reporting, feature flags, suspension flag, stale job reclaim)
|
Last activity: 2026-03-16 — Phase 6 complete (rich dashboard: KPI row, SMS volume chart, revenue snapshot, school health table with sort/filter, quick actions, refresh button)
|
||||||
|
|
||||||
## Loop Position
|
## Loop Position
|
||||||
|
|
||||||
```
|
```
|
||||||
PLAN ──▶ APPLY ──▶ UNIFY
|
PLAN ──▶ APPLY ──▶ UNIFY
|
||||||
· · · [No active plan — Phase 6 planning next]
|
· · · [No active plan — Phase 7 planning next]
|
||||||
```
|
```
|
||||||
|
|
||||||
## Progress
|
## Progress
|
||||||
@@ -24,7 +24,7 @@ PLAN ──▶ APPLY ──▶ UNIFY
|
|||||||
- Phase 3 (On-Prem License Validation): [██████████] 100% ✓
|
- Phase 3 (On-Prem License Validation): [██████████] 100% ✓
|
||||||
- Phase 4 (SMS Gateway): [██████████] 100% ✓
|
- Phase 4 (SMS Gateway): [██████████] 100% ✓
|
||||||
- Phase 5 (On-Prem SMS Polling Agent): [██████████] 100% ✓
|
- Phase 5 (On-Prem SMS Polling Agent): [██████████] 100% ✓
|
||||||
- Phase 6 (Super Admin Dashboard UI): [░░░░░░░░░░] 0%
|
- Phase 6 (Super Admin Dashboard UI): [██████████] 100% ✓
|
||||||
- Phase 7 (School Admin Portal UI): [░░░░░░░░░░] 0%
|
- Phase 7 (School Admin Portal UI): [░░░░░░░░░░] 0%
|
||||||
- Phase 8 (Billing Engine + Invoice PDF): [░░░░░░░░░░] 0%
|
- Phase 8 (Billing Engine + Invoice PDF): [░░░░░░░░░░] 0%
|
||||||
- Phase 9 (Email Dispatcher): [░░░░░░░░░░] 0%
|
- Phase 9 (Email Dispatcher): [░░░░░░░░░░] 0%
|
||||||
@@ -37,8 +37,8 @@ PLAN ──▶ APPLY ──▶ UNIFY
|
|||||||
|
|
||||||
## Next Action
|
## Next Action
|
||||||
|
|
||||||
Run: `/paul:plan` for Phase 6 — Super Admin Dashboard UI
|
Run: `/paul:plan` for Phase 7 — School Admin Portal UI
|
||||||
Resume file: .paul/ROADMAP.md → Phase 6
|
Resume file: .paul/ROADMAP.md → Phase 7
|
||||||
|
|
||||||
## Repo
|
## Repo
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,29 @@
|
|||||||
# Phase 06: Super Admin Dashboard UI
|
# Phase 06: Super Admin Dashboard UI
|
||||||
|
|
||||||
**Status:** Not started
|
**Status:** Complete
|
||||||
|
**Completed:** 2026-03-16
|
||||||
|
|
||||||
## Goal
|
## Goal
|
||||||
Rich dashboard with KPI cards, school health table, SMS volume chart, revenue snapshot.
|
|
||||||
|
|
||||||
## Plans
|
Replace the minimal DashboardPage skeleton with a rich, data-driven super admin dashboard.
|
||||||
- [ ] TBD — run /paul:plan when Phase 5 is complete
|
|
||||||
|
## What was built
|
||||||
|
|
||||||
|
### Backend (already existed from Phase 1/4 scaffolding)
|
||||||
|
- `GET /api/dashboard/summary` — KPI counts
|
||||||
|
- `GET /api/dashboard/school-health` — school list with license/credit health
|
||||||
|
- `GET /api/dashboard/revenue` — MRR, outstanding, overdue, paid this month
|
||||||
|
|
||||||
|
### Frontend — DashboardPage.vue (full rewrite)
|
||||||
|
|
||||||
|
1. **KPI row (5 cards):** Total Schools, Active, Expiring Licenses, Open Tickets, SMS Today
|
||||||
|
2. **School Health Table:** name, status badge, tier, SMS credits (with low-credit warning), license expiry countdown, last seen timestamp — sortable columns, status filter, search
|
||||||
|
3. **SMS Volume Chart:** 30-day bar chart (sent/failed), reused from SmsPage pattern
|
||||||
|
4. **Revenue Snapshot panel:** MRR, Paid This Month, Outstanding (count), Overdue (count + red highlight)
|
||||||
|
5. **Quick Actions:** Add School (→ /schools), SMS Gateway (→ /sms), Billing (→ /billing)
|
||||||
|
6. **Refresh button** with last-updated timestamp
|
||||||
|
|
||||||
|
### api.ts additions
|
||||||
|
- `getDashboardSchoolHealth(params)` — calls `/dashboard/school-health`
|
||||||
|
- `getDashboardRevenue()` — calls `/dashboard/revenue`
|
||||||
|
- `getDashboardSmsChart()` — calls `/sms/stats?days=30` (reuse existing)
|
||||||
|
|||||||
@@ -31,7 +31,9 @@ export const changePassword = (current_password: string, new_password: string) =
|
|||||||
api.put('/auth/me/password', { current_password, new_password })
|
api.put('/auth/me/password', { current_password, new_password })
|
||||||
|
|
||||||
// ── Dashboard ─────────────────────────────────────────────────────────────────
|
// ── Dashboard ─────────────────────────────────────────────────────────────────
|
||||||
export const getDashboardSummary = () => api.get('/dashboard/summary').then(r => r.data)
|
export const getDashboardSummary = () => api.get('/dashboard/summary').then(r => r.data)
|
||||||
|
export const getDashboardSchoolHealth = (params?: object) => api.get('/dashboard/school-health', { params }).then(r => r.data)
|
||||||
|
export const getDashboardRevenue = () => api.get('/dashboard/revenue').then(r => r.data)
|
||||||
|
|
||||||
// ── Schools ───────────────────────────────────────────────────────────────────
|
// ── Schools ───────────────────────────────────────────────────────────────────
|
||||||
export interface School {
|
export interface School {
|
||||||
|
|||||||
@@ -1,57 +1,395 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<div>
|
|
||||||
<h1 class="text-2xl font-bold text-slate-900">Dashboard</h1>
|
<!-- Header + refresh -->
|
||||||
<p class="text-sm text-slate-500 mt-0.5">{{ today }}</p>
|
<div class="flex items-center justify-between flex-wrap gap-3">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold text-slate-900">Dashboard</h1>
|
||||||
|
<p class="text-sm text-slate-500 mt-0.5">{{ today }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span v-if="lastUpdated" class="text-xs text-slate-400">Updated {{ lastUpdated }}</span>
|
||||||
|
<button @click="refresh" :disabled="loading"
|
||||||
|
class="flex items-center gap-2 px-3 py-1.5 rounded-lg border border-slate-200 bg-white text-sm font-medium text-slate-600 hover:bg-slate-50 disabled:opacity-50 transition-colors">
|
||||||
|
<RefreshCw :size="14" :class="{ 'animate-spin': loading }" />
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- KPI Cards -->
|
<!-- KPI Row -->
|
||||||
<div v-if="loading" class="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-5 gap-4 animate-pulse">
|
<div v-if="loading && !summary" class="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-5 gap-4 animate-pulse">
|
||||||
<div v-for="i in 5" :key="i" class="bg-white rounded-xl p-5 h-24" style="box-shadow:0 2px 8px #0000000A"></div>
|
<div v-for="i in 5" :key="i" class="bg-white rounded-xl p-5 h-24" style="box-shadow:0 2px 8px #0000000A"></div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-5 gap-4">
|
<div v-else class="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-5 gap-4">
|
||||||
<KpiCard label="Total Schools" :value="summary?.schools?.total ?? 0" icon="Building2" color="blue" />
|
<KpiCard label="Total Schools" :value="summary?.schools?.total ?? 0" icon="Building2" color="blue" />
|
||||||
<KpiCard label="Active Schools" :value="summary?.schools?.active ?? 0" icon="CheckCircle" color="green" />
|
<KpiCard label="Active Schools" :value="summary?.schools?.active ?? 0" icon="CheckCircle" color="green" />
|
||||||
<KpiCard label="Expiring Licenses" :value="summary?.licenses?.expiring_soon ?? 0" icon="KeyRound" color="amber" />
|
<KpiCard label="Expiring (30d)" :value="summary?.licenses?.expiring_soon ?? 0" icon="KeyRound" color="amber" />
|
||||||
<KpiCard label="Open Tickets" :value="summary?.tickets?.open ?? 0" icon="Ticket" color="red" />
|
<KpiCard label="Open Tickets" :value="summary?.tickets?.open ?? 0" icon="Ticket" color="red" />
|
||||||
<KpiCard label="SMS Today" :value="summary?.sms?.sent_today ?? 0" icon="MessageSquare" color="purple" />
|
<KpiCard label="SMS Today" :value="summary?.sms?.sent_today ?? 0" icon="MessageSquare" color="purple" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Secondary row -->
|
<!-- Row 2: SMS chart + Revenue snapshot -->
|
||||||
<div class="grid grid-cols-1 xl:grid-cols-3 gap-6">
|
<div class="grid grid-cols-1 xl:grid-cols-3 gap-6">
|
||||||
<!-- Pending invoices -->
|
|
||||||
<div class="bg-white rounded-xl p-6" style="box-shadow:0 2px 8px #0000000A">
|
<!-- SMS Volume chart (30d) -->
|
||||||
<h2 class="text-base font-semibold text-slate-900 mb-1">Billing</h2>
|
<div class="xl:col-span-2 bg-white rounded-xl p-6" style="box-shadow:0 2px 8px #0000000A">
|
||||||
<p class="text-3xl font-bold text-slate-900">{{ summary?.invoices?.pending ?? 0 }}</p>
|
<h2 class="text-base font-semibold text-slate-900 mb-4">SMS Volume — Last 30 Days</h2>
|
||||||
<p class="text-sm text-slate-500 mt-1">Pending invoices</p>
|
<div v-if="smsLoading" class="h-40 animate-pulse bg-slate-50 rounded-xl"></div>
|
||||||
|
<div v-else-if="chart.length" class="relative h-40">
|
||||||
|
<div class="flex items-end gap-0.5 h-full">
|
||||||
|
<div v-for="day in chart" :key="day.date"
|
||||||
|
class="flex-1 flex flex-col items-center group relative"
|
||||||
|
:title="`${day.date}: ${day.sent} sent, ${day.failed} failed`">
|
||||||
|
<div class="w-full flex flex-col-reverse gap-px" style="height:100%">
|
||||||
|
<div v-if="day.failed > 0" class="w-full bg-red-300 rounded-t-sm transition-all"
|
||||||
|
:style="{ height: barHeight(day.failed) + '%' }"></div>
|
||||||
|
<div v-if="day.sent > 0" class="w-full bg-blue-500 rounded-t-sm transition-all"
|
||||||
|
:style="{ height: barHeight(day.sent) + '%' }"></div>
|
||||||
|
</div>
|
||||||
|
<div class="absolute bottom-full mb-1 left-1/2 -translate-x-1/2 hidden group-hover:flex
|
||||||
|
flex-col items-center z-10 pointer-events-none">
|
||||||
|
<div class="bg-slate-900 text-white text-xs rounded px-2 py-1 whitespace-nowrap">
|
||||||
|
{{ fmtDate(day.date) }}: {{ day.sent }} sent
|
||||||
|
<span v-if="day.failed > 0" class="text-red-300">, {{ day.failed }} failed</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between mt-2 text-xs text-slate-400">
|
||||||
|
<span>{{ fmtDate(chart[0]?.date) }}</span>
|
||||||
|
<span>{{ fmtDate(chart[Math.floor(chart.length / 2)]?.date) }}</span>
|
||||||
|
<span>{{ fmtDate(chart[chart.length - 1]?.date) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-4 mt-1">
|
||||||
|
<div class="flex items-center gap-1.5 text-xs text-slate-500">
|
||||||
|
<div class="w-3 h-3 rounded-sm bg-blue-500"></div> Sent
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-1.5 text-xs text-slate-500">
|
||||||
|
<div class="w-3 h-3 rounded-sm bg-red-300"></div> Failed
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="h-40 flex items-center justify-center text-sm text-slate-400">
|
||||||
|
No SMS activity in the last 30 days
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- SMS Queue -->
|
|
||||||
<div class="bg-white rounded-xl p-6" style="box-shadow:0 2px 8px #0000000A">
|
<!-- Revenue snapshot -->
|
||||||
<h2 class="text-base font-semibold text-slate-900 mb-1">SMS Queue</h2>
|
<div class="bg-white rounded-xl p-6 flex flex-col gap-4" style="box-shadow:0 2px 8px #0000000A">
|
||||||
<p class="text-3xl font-bold text-slate-900">{{ summary?.sms?.pending ?? 0 }}</p>
|
<h2 class="text-base font-semibold text-slate-900">Revenue</h2>
|
||||||
<p class="text-sm text-slate-500 mt-1">Jobs awaiting dispatch</p>
|
<div v-if="revenueLoading" class="space-y-3 animate-pulse">
|
||||||
</div>
|
<div v-for="i in 4" :key="i" class="h-10 bg-slate-50 rounded-lg"></div>
|
||||||
<!-- Suspended schools -->
|
</div>
|
||||||
<div class="bg-white rounded-xl p-6" style="box-shadow:0 2px 8px #0000000A">
|
<template v-else>
|
||||||
<h2 class="text-base font-semibold text-slate-900 mb-1">Suspended</h2>
|
<div class="flex items-center justify-between">
|
||||||
<p class="text-3xl font-bold text-red-500">{{ summary?.schools?.suspended ?? 0 }}</p>
|
<div>
|
||||||
<p class="text-sm text-slate-500 mt-1">Schools suspended</p>
|
<p class="text-xs text-slate-500 font-semibold uppercase tracking-wide">MRR</p>
|
||||||
|
<p class="text-2xl font-bold text-slate-900 mt-0.5">₱{{ fmt(revenue?.mrr ?? 0) }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="w-10 h-10 rounded-xl bg-blue-50 flex items-center justify-center">
|
||||||
|
<TrendingUp :size="18" class="text-blue-600" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="h-px bg-slate-100"></div>
|
||||||
|
<div class="grid grid-cols-2 gap-3">
|
||||||
|
<div class="bg-slate-50 rounded-xl p-3">
|
||||||
|
<p class="text-xs text-slate-500 font-semibold">Paid This Month</p>
|
||||||
|
<p class="text-lg font-bold text-emerald-600 mt-0.5">₱{{ fmt(revenue?.paid_this_month ?? 0) }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="bg-slate-50 rounded-xl p-3">
|
||||||
|
<p class="text-xs text-slate-500 font-semibold">Outstanding</p>
|
||||||
|
<p class="text-lg font-bold text-amber-600 mt-0.5">
|
||||||
|
{{ revenue?.outstanding_count ?? 0 }}
|
||||||
|
<span class="text-xs font-normal text-slate-400">inv.</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="col-span-2 rounded-xl p-3" :class="(revenue?.overdue ?? 0) > 0 ? 'bg-red-50' : 'bg-slate-50'">
|
||||||
|
<p class="text-xs font-semibold" :class="(revenue?.overdue ?? 0) > 0 ? 'text-red-500' : 'text-slate-500'">
|
||||||
|
Overdue
|
||||||
|
</p>
|
||||||
|
<p class="text-lg font-bold mt-0.5" :class="(revenue?.overdue ?? 0) > 0 ? 'text-red-600' : 'text-slate-400'">
|
||||||
|
₱{{ fmt(revenue?.overdue ?? 0) }}
|
||||||
|
<span class="text-xs font-normal ml-1">({{ revenue?.overdue_count ?? 0 }} inv.)</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Row 3: School health table + Quick actions -->
|
||||||
|
<div class="grid grid-cols-1 xl:grid-cols-3 gap-6">
|
||||||
|
|
||||||
|
<!-- School health table -->
|
||||||
|
<div class="xl:col-span-2 bg-white rounded-xl overflow-hidden" style="box-shadow:0 2px 8px #0000000A">
|
||||||
|
<div class="flex items-center gap-3 px-5 py-4 border-b border-slate-100 flex-wrap">
|
||||||
|
<h2 class="text-base font-semibold text-slate-900 mr-auto">School Health</h2>
|
||||||
|
<input v-model="healthSearch" type="text" placeholder="Search schools…"
|
||||||
|
class="border border-slate-200 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 w-44" />
|
||||||
|
<select v-model="healthStatus"
|
||||||
|
class="border border-slate-200 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||||
|
<option value="">All Statuses</option>
|
||||||
|
<option value="active">Active</option>
|
||||||
|
<option value="suspended">Suspended</option>
|
||||||
|
<option value="expired">Expired</option>
|
||||||
|
<option value="pending">Pending</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="healthLoading && !healthItems.length" class="p-6 space-y-2 animate-pulse">
|
||||||
|
<div v-for="i in 5" :key="i" class="h-10 bg-slate-50 rounded-lg"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="!healthItems.length"
|
||||||
|
class="flex flex-col items-center justify-center py-12 text-slate-400">
|
||||||
|
<Building2 :size="32" class="mb-2 opacity-30" />
|
||||||
|
<p class="text-sm font-medium">No schools found</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table v-else class="w-full text-sm">
|
||||||
|
<thead class="bg-slate-50 border-b border-slate-100">
|
||||||
|
<tr class="text-left text-xs text-slate-500 font-semibold uppercase tracking-wide">
|
||||||
|
<th class="px-4 py-3 cursor-pointer hover:text-slate-700 select-none" @click="sort('name')">
|
||||||
|
School
|
||||||
|
<span class="ml-0.5" :class="sortBy === 'name' ? 'text-blue-500' : 'opacity-30'">
|
||||||
|
{{ sortBy === 'name' ? (sortDir === 'asc' ? '↑' : '↓') : '↕' }}
|
||||||
|
</span>
|
||||||
|
</th>
|
||||||
|
<th class="px-4 py-3">Status</th>
|
||||||
|
<th class="px-4 py-3 cursor-pointer hover:text-slate-700 select-none" @click="sort('sms_credits')">
|
||||||
|
Credits
|
||||||
|
<span class="ml-0.5" :class="sortBy === 'sms_credits' ? 'text-blue-500' : 'opacity-30'">
|
||||||
|
{{ sortBy === 'sms_credits' ? (sortDir === 'asc' ? '↑' : '↓') : '↕' }}
|
||||||
|
</span>
|
||||||
|
</th>
|
||||||
|
<th class="px-4 py-3">License</th>
|
||||||
|
<th class="px-4 py-3">Last Seen</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-slate-50">
|
||||||
|
<tr v-for="s in healthItems" :key="s.id"
|
||||||
|
class="hover:bg-slate-50 transition-colors cursor-pointer"
|
||||||
|
@click="router.push(`/schools/${s.id}`)">
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<div class="font-medium text-slate-900">{{ s.name }}</div>
|
||||||
|
<div class="text-xs text-slate-400 capitalize">{{ s.tier }}</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3"><StatusBadge :status="s.status" /></td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<span class="font-mono text-sm"
|
||||||
|
:class="s.sms_credit_low ? 'text-red-500 font-semibold' : 'text-slate-700'">
|
||||||
|
{{ s.sms_credits.toLocaleString() }}
|
||||||
|
</span>
|
||||||
|
<span v-if="s.sms_credit_low"
|
||||||
|
class="ml-1.5 text-xs bg-red-100 text-red-600 font-semibold px-1.5 py-0.5 rounded">LOW</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<template v-if="s.license_expires_at">
|
||||||
|
<span class="text-xs font-semibold"
|
||||||
|
:class="s.license_expiring_soon ? 'text-amber-600' :
|
||||||
|
(s.license_days_left != null && s.license_days_left < 0) ? 'text-red-500' : 'text-slate-600'">
|
||||||
|
{{ s.license_days_left != null && s.license_days_left >= 0
|
||||||
|
? `${s.license_days_left}d left`
|
||||||
|
: 'Expired' }}
|
||||||
|
</span>
|
||||||
|
<div class="text-xs text-slate-400">{{ fmtExpiry(s.license_expires_at) }}</div>
|
||||||
|
</template>
|
||||||
|
<span v-else class="text-xs text-slate-400">No expiry</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-xs text-slate-500">
|
||||||
|
<template v-if="s.license_last_seen">{{ fmtRelative(s.license_last_seen) }}</template>
|
||||||
|
<span v-else class="text-slate-300">Never</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div class="px-5 py-3 border-t border-slate-100 flex items-center justify-between text-sm">
|
||||||
|
<span class="text-slate-400 text-xs">{{ healthItems.length }} school{{ healthItems.length !== 1 ? 's' : '' }}</span>
|
||||||
|
<RouterLink to="/schools" class="text-blue-600 hover:underline font-medium">
|
||||||
|
View all schools →
|
||||||
|
</RouterLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Quick actions + mini stats -->
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="bg-white rounded-xl p-5" style="box-shadow:0 2px 8px #0000000A">
|
||||||
|
<h2 class="text-base font-semibold text-slate-900 mb-4">Quick Actions</h2>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<RouterLink to="/schools"
|
||||||
|
class="flex items-center gap-3 px-4 py-3 rounded-xl bg-blue-50 hover:bg-blue-100 text-blue-700 font-medium text-sm transition-colors">
|
||||||
|
<PlusCircle :size="16" />
|
||||||
|
Add / Manage Schools
|
||||||
|
</RouterLink>
|
||||||
|
<RouterLink to="/sms"
|
||||||
|
class="flex items-center gap-3 px-4 py-3 rounded-xl bg-purple-50 hover:bg-purple-100 text-purple-700 font-medium text-sm transition-colors">
|
||||||
|
<MessageSquare :size="16" />
|
||||||
|
SMS Gateway
|
||||||
|
</RouterLink>
|
||||||
|
<RouterLink to="/billing"
|
||||||
|
class="flex items-center gap-3 px-4 py-3 rounded-xl bg-amber-50 hover:bg-amber-100 text-amber-700 font-medium text-sm transition-colors">
|
||||||
|
<Receipt :size="16" />
|
||||||
|
Billing & Invoices
|
||||||
|
</RouterLink>
|
||||||
|
<RouterLink to="/tickets"
|
||||||
|
class="flex items-center gap-3 px-4 py-3 rounded-xl bg-red-50 hover:bg-red-100 text-red-700 font-medium text-sm transition-colors"
|
||||||
|
:class="{ 'ring-2 ring-red-300': (summary?.tickets?.open ?? 0) > 0 }">
|
||||||
|
<Ticket :size="16" />
|
||||||
|
Support Tickets
|
||||||
|
<span v-if="(summary?.tickets?.open ?? 0) > 0"
|
||||||
|
class="ml-auto bg-red-500 text-white text-xs font-bold rounded-full px-2 py-0.5">
|
||||||
|
{{ summary.tickets.open }}
|
||||||
|
</span>
|
||||||
|
</RouterLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Mini stats -->
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div class="bg-white rounded-xl p-4" style="box-shadow:0 2px 8px #0000000A">
|
||||||
|
<p class="text-xs text-slate-500 font-semibold uppercase tracking-wide">SMS Queue</p>
|
||||||
|
<p class="text-2xl font-bold text-slate-900 mt-1">{{ summary?.sms?.pending ?? 0 }}</p>
|
||||||
|
<p class="text-xs text-slate-400 mt-0.5">Pending jobs</p>
|
||||||
|
</div>
|
||||||
|
<div class="bg-white rounded-xl p-4" style="box-shadow:0 2px 8px #0000000A">
|
||||||
|
<p class="text-xs text-slate-500 font-semibold uppercase tracking-wide">Suspended</p>
|
||||||
|
<p class="text-2xl font-bold mt-1"
|
||||||
|
:class="(summary?.schools?.suspended ?? 0) > 0 ? 'text-red-500' : 'text-slate-900'">
|
||||||
|
{{ summary?.schools?.suspended ?? 0 }}
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-slate-400 mt-0.5">Schools</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, computed, watch, onMounted } from 'vue'
|
||||||
import { getDashboardSummary } from '@/lib/api'
|
import { useRouter, RouterLink } from 'vue-router'
|
||||||
|
import { RefreshCw, Building2, TrendingUp, PlusCircle, MessageSquare, Receipt, Ticket } from 'lucide-vue-next'
|
||||||
|
import {
|
||||||
|
getDashboardSummary, getDashboardSchoolHealth,
|
||||||
|
getDashboardRevenue, getSmsStats,
|
||||||
|
} from '@/lib/api'
|
||||||
import KpiCard from '@/components/ui/KpiCard.vue'
|
import KpiCard from '@/components/ui/KpiCard.vue'
|
||||||
|
import StatusBadge from '@/components/ui/StatusBadge.vue'
|
||||||
|
|
||||||
const summary = ref<any>(null)
|
const router = useRouter()
|
||||||
const loading = ref(true)
|
|
||||||
const today = new Date().toLocaleDateString('en-PH', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })
|
|
||||||
|
|
||||||
onMounted(async () => {
|
// ── State ─────────────────────────────────────────────────────────────────────
|
||||||
try { summary.value = await getDashboardSummary() }
|
const summary = ref<any>(null)
|
||||||
finally { loading.value = false }
|
const revenue = ref<any>(null)
|
||||||
|
const chart = ref<any[]>([])
|
||||||
|
const healthItems = ref<any[]>([])
|
||||||
|
const healthSearch = ref('')
|
||||||
|
const healthStatus = ref('')
|
||||||
|
const sortBy = ref('name')
|
||||||
|
const sortDir = ref<'asc' | 'desc'>('asc')
|
||||||
|
const loading = ref(false)
|
||||||
|
const revenueLoading = ref(false)
|
||||||
|
const smsLoading = ref(false)
|
||||||
|
const healthLoading = ref(false)
|
||||||
|
const lastUpdated = ref('')
|
||||||
|
|
||||||
|
const today = new Date().toLocaleDateString('en-PH', {
|
||||||
|
weekday: 'long', year: 'numeric', month: 'long', day: 'numeric',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ── Sort ──────────────────────────────────────────────────────────────────────
|
||||||
|
function sort(field: string) {
|
||||||
|
if (sortBy.value === field) {
|
||||||
|
sortDir.value = sortDir.value === 'asc' ? 'desc' : 'asc'
|
||||||
|
} else {
|
||||||
|
sortBy.value = field
|
||||||
|
sortDir.value = 'asc'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Loaders ───────────────────────────────────────────────────────────────────
|
||||||
|
async function loadSummary() {
|
||||||
|
try { summary.value = await getDashboardSummary() } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadRevenue() {
|
||||||
|
revenueLoading.value = true
|
||||||
|
try { revenue.value = await getDashboardRevenue() } catch { /* ignore */ }
|
||||||
|
finally { revenueLoading.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSmsChart() {
|
||||||
|
smsLoading.value = true
|
||||||
|
try {
|
||||||
|
const data = await getSmsStats({ days: 30 })
|
||||||
|
chart.value = data.chart ?? []
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
finally { smsLoading.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadHealth() {
|
||||||
|
healthLoading.value = true
|
||||||
|
try {
|
||||||
|
const res = await getDashboardSchoolHealth({
|
||||||
|
sort_by: sortBy.value,
|
||||||
|
sort_dir: sortDir.value,
|
||||||
|
status: healthStatus.value || undefined,
|
||||||
|
search: healthSearch.value || undefined,
|
||||||
|
})
|
||||||
|
healthItems.value = res.items
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
finally { healthLoading.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debounced re-fetch when filters/sort change
|
||||||
|
let debounce: ReturnType<typeof setTimeout> | null = null
|
||||||
|
watch([healthSearch, healthStatus, sortBy, sortDir], () => {
|
||||||
|
if (debounce) clearTimeout(debounce)
|
||||||
|
debounce = setTimeout(loadHealth, 250)
|
||||||
|
})
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
loading.value = true
|
||||||
|
await Promise.all([loadSummary(), loadRevenue(), loadSmsChart(), loadHealth()])
|
||||||
|
loading.value = false
|
||||||
|
lastUpdated.value = new Date().toLocaleTimeString('en-PH', { hour: '2-digit', minute: '2-digit' })
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(refresh)
|
||||||
|
|
||||||
|
// ── Chart helpers ─────────────────────────────────────────────────────────────
|
||||||
|
const chartMax = computed(() =>
|
||||||
|
Math.max(...chart.value.map((d: any) => (d.sent ?? 0) + (d.failed ?? 0)), 1)
|
||||||
|
)
|
||||||
|
|
||||||
|
function barHeight(val: number): number {
|
||||||
|
return Math.max((val / chartMax.value) * 100, val > 0 ? 4 : 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDate(iso: string | undefined): string {
|
||||||
|
if (!iso) return ''
|
||||||
|
return new Date(iso).toLocaleDateString('en-PH', { month: 'short', day: 'numeric' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Format helpers ────────────────────────────────────────────────────────────
|
||||||
|
function fmt(val: number): string {
|
||||||
|
return val.toLocaleString('en-PH', { minimumFractionDigits: 0, maximumFractionDigits: 0 })
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtExpiry(iso: string): string {
|
||||||
|
return new Date(iso).toLocaleDateString('en-PH', { year: 'numeric', month: 'short', day: 'numeric' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtRelative(iso: string): string {
|
||||||
|
const diffMs = Date.now() - new Date(iso).getTime()
|
||||||
|
const mins = Math.floor(diffMs / 60_000)
|
||||||
|
if (mins < 2) return 'Just now'
|
||||||
|
if (mins < 60) return `${mins}m ago`
|
||||||
|
const hrs = Math.floor(mins / 60)
|
||||||
|
if (hrs < 24) return `${hrs}h ago`
|
||||||
|
return `${Math.floor(hrs / 24)}d ago`
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
Reference in New Issue
Block a user