Files
TapTrack-Hub/frontend/src/pages/portal/PortalSmsPage.vue
kevin-asprec e45e63903d 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
2026-03-16 13:21:41 +08:00

229 lines
10 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="space-y-6">
<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 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">
<th class="px-5 py-3">Recipient</th>
<th class="px-5 py-3">Message</th>
<th class="px-5 py-3">Status</th>
<th class="px-5 py-3">Trigger</th>
<th class="px-5 py-3">Sent At</th>
</tr>
</thead>
<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">
<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('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, 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 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>