feat(phase-7): complete school admin portal UI
Backend (school_portal.py): - GET /portal/sms-stats: 30/7/90-day chart, delivery rate, credit burn this month - POST /portal/credits/request: auto-creates billing ticket for credit top-up - GET /portal/overview: extended with days_left, sms_credit_low flag, 30-day SMS chart, full school details PortalOverviewPage: suspension/expired banner, low-credit warning, credit meter with progress bar, license countdown with expiry badge, 30-day SMS sparkline PortalBillingPage: subscription plan panel (fee, SMS cost, cycle, next billing), invoice table with subscription/SMS breakdown, credit top-up request form PortalSmsPage: 30/7/90d period picker, 4-stat KPI row (sent, failed, delivery rate, credit burn), daily volume chart, credit snapshot with top-up link, paginated SMS log with status filter PortalTicketsPage: status filter tabs (all/open/in-progress/resolved/closed), pagination, improved empty states PortalProfilePage: school details panel (name, city, contact, tier, status) alongside existing change-password form
This commit is contained in:
@@ -1,9 +1,133 @@
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<h1 class="text-2xl font-bold text-slate-900">SMS Reports</h1>
|
||||
|
||||
<div class="flex items-center justify-between flex-wrap gap-3">
|
||||
<h1 class="text-2xl font-bold text-slate-900">SMS Reports</h1>
|
||||
<!-- Period picker -->
|
||||
<div class="flex gap-1 bg-slate-100 rounded-lg p-0.5">
|
||||
<button v-for="d in [7, 30, 90]" :key="d" @click="periodDays = d; loadStats()"
|
||||
class="px-3 py-1.5 rounded-md text-sm font-medium transition-colors"
|
||||
:class="periodDays === d ? 'bg-white text-slate-900 shadow-sm' : 'text-slate-500 hover:text-slate-700'">
|
||||
{{ d }}d
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats row -->
|
||||
<div v-if="statsLoading" class="grid grid-cols-2 md:grid-cols-4 gap-4 animate-pulse">
|
||||
<div v-for="i in 4" :key="i" class="bg-white rounded-xl h-20" style="box-shadow:0 2px 8px #0000000A"></div>
|
||||
</div>
|
||||
<div v-else-if="stats" class="grid grid-cols-2 md:grid-cols-4 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">Sent</p>
|
||||
<p class="text-2xl font-bold text-slate-900 mt-1">{{ stats.sent.toLocaleString() }}</p>
|
||||
</div>
|
||||
<div class="bg-white rounded-xl p-4" style="box-shadow:0 2px 8px #0000000A">
|
||||
<p class="text-xs text-red-500 font-semibold uppercase tracking-wide">Failed</p>
|
||||
<p class="text-2xl font-bold text-red-500 mt-1">{{ stats.failed.toLocaleString() }}</p>
|
||||
</div>
|
||||
<div class="bg-white rounded-xl p-4" style="box-shadow:0 2px 8px #0000000A">
|
||||
<p class="text-xs text-emerald-600 font-semibold uppercase tracking-wide">Delivery Rate</p>
|
||||
<p class="text-2xl font-bold text-slate-900 mt-1">{{ stats.delivery_rate }}%</p>
|
||||
</div>
|
||||
<div class="bg-white rounded-xl p-4" style="box-shadow:0 2px 8px #0000000A">
|
||||
<p class="text-xs text-blue-500 font-semibold uppercase tracking-wide">Credit Burn (month)</p>
|
||||
<p class="text-2xl font-bold text-slate-900 mt-1">{{ stats.credit_burn_this_month.toLocaleString() }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Chart + current credits -->
|
||||
<div class="grid grid-cols-1 xl:grid-cols-3 gap-6">
|
||||
|
||||
<!-- Volume chart -->
|
||||
<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">Daily Volume — Last {{ periodDays }} Days</h2>
|
||||
<div v-if="statsLoading" class="h-36 animate-pulse bg-slate-50 rounded-xl"></div>
|
||||
<div v-else-if="chart.length" class="relative h-36">
|
||||
<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-36 flex items-center justify-center text-sm text-slate-400">
|
||||
No SMS activity in this period
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Credit snapshot -->
|
||||
<div class="bg-white rounded-xl p-6 flex flex-col gap-4" style="box-shadow:0 2px 8px #0000000A">
|
||||
<h2 class="text-base font-semibold text-slate-900">Credits</h2>
|
||||
<div v-if="statsLoading" class="animate-pulse space-y-3">
|
||||
<div v-for="i in 3" :key="i" class="h-8 bg-slate-50 rounded"></div>
|
||||
</div>
|
||||
<template v-else-if="stats">
|
||||
<div>
|
||||
<p class="text-xs text-slate-500 font-semibold uppercase tracking-wide">Current Balance</p>
|
||||
<p class="text-3xl font-bold text-slate-900 mt-1">{{ stats.current_credits.toLocaleString() }}</p>
|
||||
</div>
|
||||
<div class="h-px bg-slate-100"></div>
|
||||
<div>
|
||||
<p class="text-xs text-slate-500 font-semibold uppercase tracking-wide">Burned This Month</p>
|
||||
<p class="text-xl font-bold text-slate-700 mt-1">{{ stats.credit_burn_this_month.toLocaleString() }}</p>
|
||||
</div>
|
||||
<RouterLink to="/portal/billing"
|
||||
class="mt-auto text-center px-4 py-2.5 rounded-xl bg-blue-50 hover:bg-blue-100 text-blue-700 font-medium text-sm transition-colors">
|
||||
Request Top-Up
|
||||
</RouterLink>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SMS jobs table -->
|
||||
<div class="bg-white rounded-xl overflow-hidden" style="box-shadow:0 2px 8px #0000000A">
|
||||
<div v-if="loading" class="p-8 text-center text-sm text-slate-400 animate-pulse">Loading…</div>
|
||||
<div v-else-if="jobs.length === 0" class="p-12 text-center text-slate-400">No SMS records</div>
|
||||
<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">SMS Log</h2>
|
||||
<select v-model="statusFilter"
|
||||
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="sent">Sent</option>
|
||||
<option value="failed">Failed</option>
|
||||
<option value="pending">Pending</option>
|
||||
<option value="cancelled">Cancelled</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div v-if="jobsLoading" class="p-8 text-center text-sm text-slate-400 animate-pulse">Loading…</div>
|
||||
|
||||
<div v-else-if="jobs.length === 0"
|
||||
class="flex flex-col items-center justify-center py-14 text-slate-400">
|
||||
<MessageSquare :size="36" class="mb-3 opacity-30" />
|
||||
<p class="font-medium text-sm">No SMS records 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">
|
||||
@@ -17,22 +141,88 @@
|
||||
<tbody class="divide-y divide-slate-50">
|
||||
<tr v-for="j in jobs" :key="j.id" class="hover:bg-slate-50">
|
||||
<td class="px-5 py-3 font-mono text-xs">{{ j.recipient_phone }}</td>
|
||||
<td class="px-5 py-3 text-slate-600 max-w-xs truncate">{{ j.message }}</td>
|
||||
<td class="px-5 py-3 text-slate-600 max-w-xs">
|
||||
<span class="block truncate" :title="j.message">{{ j.message }}</span>
|
||||
</td>
|
||||
<td class="px-5 py-3"><StatusBadge :status="j.status" /></td>
|
||||
<td class="px-5 py-3 text-xs text-slate-500 capitalize">{{ j.trigger || '—' }}</td>
|
||||
<td class="px-5 py-3 text-xs text-slate-500">{{ j.sent_at ? new Date(j.sent_at).toLocaleString() : '—' }}</td>
|
||||
<td class="px-5 py-3 text-xs text-slate-500">
|
||||
{{ j.sent_at ? new Date(j.sent_at).toLocaleString('en-PH') : '—' }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div v-if="jobsTotal > perPage" class="px-5 py-3 border-t border-slate-100 flex items-center justify-between text-sm text-slate-600">
|
||||
<span class="text-xs text-slate-400">
|
||||
Showing {{ (jobsPage - 1) * perPage + 1 }}–{{ Math.min(jobsPage * perPage, jobsTotal) }} of {{ jobsTotal }}
|
||||
</span>
|
||||
<div class="flex gap-2">
|
||||
<button :disabled="jobsPage <= 1" @click="jobsPage--; loadJobs()"
|
||||
class="px-3 py-1.5 border border-slate-200 rounded-lg text-sm hover:bg-slate-50 disabled:opacity-40">Prev</button>
|
||||
<button :disabled="jobsPage * perPage >= jobsTotal" @click="jobsPage++; loadJobs()"
|
||||
class="px-3 py-1.5 border border-slate-200 rounded-lg text-sm hover:bg-slate-50 disabled:opacity-40">Next</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { getSmsJobs } from '@/lib/api'
|
||||
import { ref, computed, watch, onMounted } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { MessageSquare } from 'lucide-vue-next'
|
||||
import { getPortalSmsStats, getSmsJobs } from '@/lib/api'
|
||||
import StatusBadge from '@/components/ui/StatusBadge.vue'
|
||||
const jobs = ref<any[]>([])
|
||||
const loading = ref(true)
|
||||
onMounted(async () => { try { const r = await getSmsJobs(); jobs.value = r.items } finally { loading.value = false } })
|
||||
|
||||
const stats = ref<any>(null)
|
||||
const statsLoading = ref(true)
|
||||
const periodDays = ref(30)
|
||||
const chart = computed(() => stats.value?.chart ?? [])
|
||||
|
||||
const jobs = ref<any[]>([])
|
||||
const jobsTotal = ref(0)
|
||||
const jobsPage = ref(1)
|
||||
const perPage = 25
|
||||
const jobsLoading = ref(false)
|
||||
const statusFilter = ref('')
|
||||
|
||||
async function loadStats() {
|
||||
statsLoading.value = true
|
||||
try { stats.value = await getPortalSmsStats({ days: periodDays.value }) }
|
||||
catch { /* ignore */ }
|
||||
finally { statsLoading.value = false }
|
||||
}
|
||||
|
||||
async function loadJobs() {
|
||||
jobsLoading.value = true
|
||||
try {
|
||||
const r = await getSmsJobs({
|
||||
page: jobsPage.value,
|
||||
per_page: perPage,
|
||||
status: statusFilter.value || undefined,
|
||||
})
|
||||
jobs.value = r.items
|
||||
jobsTotal.value = r.total
|
||||
} catch { /* ignore */ }
|
||||
finally { jobsLoading.value = false }
|
||||
}
|
||||
|
||||
watch(statusFilter, () => { jobsPage.value = 1; loadJobs() })
|
||||
|
||||
onMounted(() => Promise.all([loadStats(), loadJobs()]))
|
||||
|
||||
// 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' })
|
||||
}
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user