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:
kevin-asprec
2026-03-16 13:21:41 +08:00
parent 34f87476ff
commit e45e63903d
10 changed files with 973 additions and 106 deletions

View File

@@ -94,4 +94,7 @@ export const createAnnouncement = (data: object) => api.post('/announcements', d
export const deleteAnnouncement = (id: string) => api.delete(`/announcements/${id}`)
// ── School Portal ─────────────────────────────────────────────────────────────
export const getPortalOverview = () => api.get('/portal/overview').then(r => r.data)
export const getPortalOverview = () => api.get('/portal/overview').then(r => r.data)
export const getPortalSmsStats = (params?: object) => api.get('/portal/sms-stats', { params }).then(r => r.data)
export const requestCreditTopup = (data: { amount_requested: number; notes?: string }) =>
api.post('/portal/credits/request', data).then(r => r.data)

View File

@@ -1,38 +1,188 @@
<template>
<div class="space-y-6">
<h1 class="text-2xl font-bold text-slate-900">Billing History</h1>
<div class="flex items-center justify-between flex-wrap gap-3">
<h1 class="text-2xl font-bold text-slate-900">Billing</h1>
<button @click="showTopup = !showTopup"
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">
<PlusCircle :size="15" />
Request Credit Top-Up
</button>
</div>
<!-- Credit top-up request form -->
<div v-if="showTopup" 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">Request SMS Credits</h2>
<div class="max-w-sm space-y-4">
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">Credits to Request</label>
<input v-model.number="topupAmount" type="number" min="1" step="50"
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">Notes (optional)</label>
<textarea v-model="topupNotes" rows="2"
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"
placeholder="Any additional information…"></textarea>
</div>
<div class="flex gap-3">
<button @click="showTopup = false"
class="px-4 py-2 border border-slate-200 rounded-lg text-sm text-slate-700 hover:bg-slate-50">
Cancel
</button>
<button @click="submitTopup" :disabled="!topupAmount || topupAmount < 1 || submittingTopup"
class="px-5 py-2 rounded-lg bg-blue-600 text-white text-sm font-medium hover:bg-blue-700 disabled:opacity-50">
{{ submittingTopup ? 'Submitting…' : 'Submit Request' }}
</button>
</div>
</div>
</div>
<!-- Subscription info -->
<div v-if="subscription" class="bg-white rounded-xl p-5" style="box-shadow:0 2px 8px #0000000A">
<h2 class="text-base font-semibold text-slate-900 mb-3">Subscription Plan</h2>
<div class="flex flex-wrap gap-6 text-sm">
<div>
<p class="text-slate-500 text-xs uppercase tracking-wide font-semibold">Monthly Fee</p>
<p class="text-lg font-bold text-slate-900 mt-0.5">{{ Number(subscription.monthly_fee).toLocaleString() }}</p>
</div>
<div>
<p class="text-slate-500 text-xs uppercase tracking-wide font-semibold">SMS Cost / Message</p>
<p class="text-lg font-bold text-slate-900 mt-0.5">{{ subscription.sms_cost_per_message }}</p>
</div>
<div>
<p class="text-slate-500 text-xs uppercase tracking-wide font-semibold">Billing Cycle</p>
<p class="text-lg font-bold text-slate-900 mt-0.5 capitalize">{{ subscription.cycle }}</p>
</div>
<div v-if="subscription.next_billing_date">
<p class="text-slate-500 text-xs uppercase tracking-wide font-semibold">Next Billing</p>
<p class="text-lg font-bold text-slate-900 mt-0.5">
{{ new Date(subscription.next_billing_date).toLocaleDateString('en-PH', { year: 'numeric', month: 'short', day: 'numeric' }) }}
</p>
</div>
</div>
</div>
<!-- Invoice table -->
<div class="bg-white rounded-xl overflow-hidden" style="box-shadow:0 2px 8px #0000000A">
<div class="px-5 py-4 border-b border-slate-100 flex items-center justify-between">
<h2 class="text-base font-semibold text-slate-900">Invoice History</h2>
<span class="text-xs text-slate-400">{{ total }} invoice{{ total !== 1 ? 's' : '' }}</span>
</div>
<div v-if="loading" class="p-8 text-center text-sm text-slate-400 animate-pulse">Loading</div>
<div v-else-if="invoices.length === 0" class="p-12 text-center text-slate-400">No invoices yet</div>
<div v-else-if="invoices.length === 0"
class="flex flex-col items-center justify-center py-14 text-slate-400">
<Receipt :size="36" class="mb-3 opacity-30" />
<p class="font-medium text-sm">No invoices yet</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">Invoice #</th>
<th class="px-5 py-3">Period</th>
<th class="px-5 py-3">Amount</th>
<th class="px-5 py-3">Subscription</th>
<th class="px-5 py-3">SMS</th>
<th class="px-5 py-3">Total</th>
<th class="px-5 py-3">Status</th>
<th class="px-5 py-3">Due Date</th>
<th class="px-5 py-3">Due</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-50">
<tr v-for="inv in invoices" :key="inv.id" class="hover:bg-slate-50">
<td class="px-5 py-3 font-mono text-xs font-semibold">{{ inv.invoice_number }}</td>
<td class="px-5 py-3 text-xs text-slate-500">{{ inv.billing_period_start }} {{ inv.billing_period_end }}</td>
<td class="px-5 py-3 font-semibold">PHP {{ Number(inv.total_amount).toLocaleString() }}</td>
<td class="px-5 py-3 font-mono text-xs font-semibold text-blue-600">{{ inv.invoice_number }}</td>
<td class="px-5 py-3 text-xs text-slate-500">
{{ fmtDate(inv.billing_period_start) }} {{ fmtDate(inv.billing_period_end) }}
</td>
<td class="px-5 py-3 text-xs text-slate-700">{{ Number(inv.subscription_amount).toLocaleString() }}</td>
<td class="px-5 py-3 text-xs text-slate-700">{{ Number(inv.sms_credit_amount).toLocaleString() }}</td>
<td class="px-5 py-3 font-semibold text-slate-900">{{ Number(inv.total_amount).toLocaleString() }}</td>
<td class="px-5 py-3"><StatusBadge :status="inv.status" /></td>
<td class="px-5 py-3 text-xs text-slate-500">{{ inv.due_date || '—' }}</td>
<td class="px-5 py-3 text-xs text-slate-500"
:class="isOverdue(inv) ? 'text-red-500 font-semibold' : ''">
{{ inv.due_date ? new Date(inv.due_date).toLocaleDateString('en-PH', { month: 'short', day: 'numeric', year: 'numeric' }) : '—' }}
</td>
</tr>
</tbody>
</table>
<!-- Pagination -->
<div v-if="total > 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 {{ (page - 1) * perPage + 1 }}{{ Math.min(page * perPage, total) }} of {{ total }}
</span>
<div class="flex gap-2">
<button :disabled="page <= 1" @click="page--; loadInvoices()"
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="page * perPage >= total" @click="page++; loadInvoices()"
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 { getInvoices } from '@/lib/api'
import { PlusCircle, Receipt } from 'lucide-vue-next'
import { getInvoices, getSubscription, requestCreditTopup } from '@/lib/api'
import { useAuthStore } from '@/stores/auth'
import StatusBadge from '@/components/ui/StatusBadge.vue'
const invoices = ref<any[]>([])
const loading = ref(true)
onMounted(async () => { try { const r = await getInvoices(); invoices.value = r.items } finally { loading.value = false } })
import { useToast } from '@/composables/useToast'
const authStore = useAuthStore()
const toast = useToast()
const invoices = ref<any[]>([])
const subscription = ref<any>(null)
const total = ref(0)
const page = ref(1)
const perPage = 10
const loading = ref(true)
const showTopup = ref(false)
const topupAmount = ref(100)
const topupNotes = ref('')
const submittingTopup = ref(false)
async function loadInvoices() {
loading.value = true
try {
const r = await getInvoices({ page: page.value, per_page: perPage })
invoices.value = r.items
total.value = r.total
} finally { loading.value = false }
}
async function loadSubscription() {
const sid = authStore.schoolId
if (!sid) return
try { subscription.value = await getSubscription(sid) } catch { /* no subscription yet */ }
}
async function submitTopup() {
submittingTopup.value = true
try {
const res = await requestCreditTopup({ amount_requested: topupAmount.value, notes: topupNotes.value })
toast.success(`Top-up request submitted — ${res.ticket_number}`)
showTopup.value = false
topupAmount.value = 100
topupNotes.value = ''
} catch (e: any) {
toast.error(e?.response?.data?.detail ?? 'Failed to submit request')
} finally { submittingTopup.value = false }
}
function fmtDate(iso: string | undefined): string {
if (!iso) return '—'
return new Date(iso).toLocaleDateString('en-PH', { year: 'numeric', month: 'short', day: 'numeric' })
}
function isOverdue(inv: any): boolean {
return inv.status === 'overdue' || (inv.status === 'sent' && inv.due_date && new Date(inv.due_date) < new Date())
}
onMounted(() => Promise.all([loadInvoices(), loadSubscription()]))
</script>

View File

@@ -1,49 +1,229 @@
<template>
<div class="space-y-6">
<!-- Suspension / expired banner -->
<div v-if="data && data.school.status !== 'active'"
class="flex items-start gap-3 px-5 py-4 rounded-xl border"
:class="data.school.status === 'suspended'
? 'bg-red-50 border-red-200 text-red-800'
: 'bg-amber-50 border-amber-200 text-amber-800'">
<AlertTriangle :size="18" class="shrink-0 mt-0.5" />
<div>
<p class="font-semibold text-sm">
{{ data.school.status === 'suspended' ? 'Account Suspended' : 'Account Inactive' }}
</p>
<p class="text-sm mt-0.5">
{{ data.school.status === 'suspended'
? 'SMS sending and reports are disabled. Please contact support or settle any outstanding invoices.'
: 'Your account is not yet active. Please contact support.' }}
</p>
</div>
</div>
<!-- Low credit warning -->
<div v-if="data && data.sms_credit_low && data.school.status === 'active'"
class="flex items-start gap-3 px-5 py-4 rounded-xl bg-amber-50 border border-amber-200 text-amber-800">
<AlertTriangle :size="18" class="shrink-0 mt-0.5" />
<div>
<p class="font-semibold text-sm">Low SMS Credits</p>
<p class="text-sm mt-0.5">
You have <strong>{{ data.sms_credits }}</strong> credits remaining
(threshold: {{ data.sms_credit_low_threshold }}).
<RouterLink to="/portal/billing" class="underline font-medium">Request a top-up </RouterLink>
</p>
</div>
</div>
<!-- Header -->
<div>
<h1 class="text-2xl font-bold text-slate-900">Overview</h1>
<p class="text-sm text-slate-500 mt-0.5">{{ data?.school?.name }}</p>
</div>
<!-- KPI Cards -->
<div v-if="loading" 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-24" style="box-shadow:0 2px 8px #0000000A"></div>
</div>
<div v-else class="grid grid-cols-2 md:grid-cols-4 gap-4">
<KpiCard label="SMS Credits" :value="data?.sms_credits ?? 0" icon="MessageSquare" color="blue" />
<KpiCard label="SMS This Month" :value="data?.sms_this_month ?? 0" icon="MessageSquare" color="green" />
<KpiCard label="Open Tickets" :value="data?.open_tickets ?? 0" icon="Ticket" color="amber" />
<KpiCard label="Pending Invoices":value="data?.pending_invoices ?? 0" icon="Receipt" color="red" />
<KpiCard label="SMS Credits" :value="data?.sms_credits ?? 0" icon="MessageSquare" color="blue" />
<KpiCard label="SMS This Month" :value="data?.sms_this_month ?? 0" icon="MessageSquare" color="green" />
<KpiCard label="Open Tickets" :value="data?.open_tickets ?? 0" icon="Ticket" color="amber" />
<KpiCard label="Pending Invoices" :value="data?.pending_invoices ?? 0" icon="Receipt" color="red" />
</div>
<!-- License info -->
<div v-if="data?.license" 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">License Status</h2>
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
<div><p class="text-slate-500">Status</p><StatusBadge :status="data.license.status" /></div>
<div><p class="text-slate-500">Expires</p><p class="font-medium">{{ data.license.expires_at ? new Date(data.license.expires_at).toLocaleDateString() : 'Never' }}</p></div>
<div><p class="text-slate-500">Last Online</p><p class="font-medium">{{ data.license.last_seen ? new Date(data.license.last_seen).toLocaleString() : '—' }}</p></div>
<div><p class="text-slate-500">License Key</p><p class="font-mono text-xs truncate">{{ data.license.key }}</p></div>
<!-- Credit meter + License status -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- Credit meter -->
<div class="bg-white rounded-xl p-6" style="box-shadow:0 2px 8px #0000000A">
<div class="flex items-center justify-between mb-3">
<h2 class="text-base font-semibold text-slate-900">SMS Credits</h2>
<RouterLink to="/portal/billing"
class="text-xs text-blue-600 font-medium hover:underline">Request Top-Up </RouterLink>
</div>
<div v-if="loading" class="animate-pulse space-y-2">
<div class="h-8 bg-slate-100 rounded w-1/3"></div>
<div class="h-2.5 bg-slate-100 rounded-full"></div>
<div class="h-3 bg-slate-50 rounded w-1/2"></div>
</div>
<template v-else>
<div class="flex items-end gap-2 mb-2">
<span class="text-3xl font-bold"
:class="data?.sms_credit_low ? 'text-red-500' : 'text-slate-900'">
{{ (data?.sms_credits ?? 0).toLocaleString() }}
</span>
<span class="text-sm text-slate-400 mb-1">credits</span>
</div>
<div class="h-2.5 bg-slate-100 rounded-full overflow-hidden">
<div class="h-full rounded-full transition-all duration-500"
:class="data?.sms_credit_low ? 'bg-red-400' : 'bg-blue-500'"
:style="{ width: creditMeterPct + '%' }"></div>
</div>
<p class="text-xs text-slate-400 mt-1.5">
Low credit threshold: {{ data?.sms_credit_low_threshold }} credits
</p>
</template>
</div>
<!-- License status -->
<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">License</h2>
<div v-if="loading" class="animate-pulse space-y-3">
<div v-for="i in 4" :key="i" class="h-5 bg-slate-50 rounded"></div>
</div>
<div v-else-if="data?.license" class="space-y-3 text-sm">
<div class="flex items-center justify-between">
<span class="text-slate-500">Status</span>
<StatusBadge :status="data.license.status ?? 'unknown'" />
</div>
<div class="flex items-center justify-between">
<span class="text-slate-500">Expires</span>
<span class="font-medium text-right"
:class="licenseExpiringSoon ? 'text-amber-600' :
(data.license.days_left != null && data.license.days_left < 0) ? 'text-red-500' : 'text-slate-700'">
{{ data.license.expires_at
? new Date(data.license.expires_at).toLocaleDateString('en-PH', { year: 'numeric', month: 'short', day: 'numeric' })
: 'Never' }}
<span v-if="data.license.days_left != null && data.license.days_left >= 0"
class="ml-1.5 text-xs px-1.5 py-0.5 rounded font-semibold"
:class="licenseExpiringSoon ? 'bg-amber-100 text-amber-700' : 'bg-slate-100 text-slate-500'">
{{ data.license.days_left }}d
</span>
<span v-else-if="data.license.days_left != null && data.license.days_left < 0"
class="ml-1.5 text-xs px-1.5 py-0.5 rounded bg-red-100 text-red-600 font-semibold">
Expired
</span>
</span>
</div>
<div class="flex items-center justify-between">
<span class="text-slate-500">Last Online</span>
<span class="text-xs text-slate-700">
{{ data.license.last_seen
? new Date(data.license.last_seen).toLocaleString('en-PH')
: '—' }}
</span>
</div>
<div class="flex items-center justify-between gap-2">
<span class="text-slate-500 shrink-0">License Key</span>
<span class="font-mono text-xs text-slate-500 truncate" :title="data.license.key ?? ''">
{{ data.license.key ?? '—' }}
</span>
</div>
</div>
</div>
</div>
<!-- 30-day SMS activity chart -->
<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 Activity Last 30 Days</h2>
<RouterLink to="/portal/sms" class="text-xs text-blue-600 font-medium hover:underline">
Full report
</RouterLink>
</div>
<div v-if="loading" class="h-24 animate-pulse bg-slate-50 rounded-xl"></div>
<div v-else-if="smsChart.length" class="relative h-24">
<div class="flex items-end gap-0.5 h-full">
<div v-for="day in smsChart" :key="day.date"
class="flex-1 group relative"
:title="`${day.date}: ${day.sent} sent`">
<div class="w-full bg-blue-500 rounded-t-sm transition-all"
:style="{ height: miniBarHeight(day.sent) + '%' }"></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 }}
</div>
</div>
</div>
</div>
<div class="flex justify-between mt-1.5 text-xs text-slate-400">
<span>{{ fmtDate(smsChart[0]?.date) }}</span>
<span>{{ fmtDate(smsChart[smsChart.length - 1]?.date) }}</span>
</div>
</div>
<div v-else class="h-24 flex items-center justify-center text-sm text-slate-400">
No SMS activity in the last 30 days
</div>
</div>
<!-- Announcements -->
<div v-if="announcements.length > 0" class="bg-blue-50 border border-blue-200 rounded-xl p-5">
<h3 class="text-sm font-semibold text-blue-800 mb-2">Announcements</h3>
<div v-for="a in announcements" :key="a.id" class="mb-2">
<p class="text-sm font-medium text-blue-900">{{ a.title }}</p>
<p class="text-xs text-blue-700">{{ a.body }}</p>
<h3 class="text-sm font-semibold text-blue-800 mb-3">Announcements</h3>
<div v-for="a in announcements" :key="a.id" class="mb-3 last:mb-0">
<p class="text-sm font-semibold text-blue-900">{{ a.title }}</p>
<p class="text-sm text-blue-700 mt-0.5">{{ a.body }}</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { RouterLink } from 'vue-router'
import { AlertTriangle } from 'lucide-vue-next'
import { getPortalOverview, getAnnouncements } from '@/lib/api'
import KpiCard from '@/components/ui/KpiCard.vue'
import StatusBadge from '@/components/ui/StatusBadge.vue'
const data = ref<any>(null)
const data = ref<any>(null)
const announcements = ref<any[]>([])
const loading = ref(true)
const loading = ref(true)
onMounted(async () => {
try { [data.value, announcements.value] = await Promise.all([getPortalOverview(), getAnnouncements()]) }
finally { loading.value = false }
try {
[data.value, announcements.value] = await Promise.all([
getPortalOverview(),
getAnnouncements(),
])
} finally {
loading.value = false
}
})
const creditMeterPct = computed(() => {
if (!data.value) return 0
const credits = data.value.sms_credits ?? 0
const threshold = data.value.sms_credit_low_threshold ?? 50
const visual_max = Math.max(credits, threshold * 4)
return Math.min((credits / visual_max) * 100, 100)
})
const licenseExpiringSoon = computed(() => {
const dl = data.value?.license?.days_left
return dl != null && dl >= 0 && dl <= 30
})
const smsChart = computed(() => data.value?.sms_chart ?? [])
const miniMax = computed(() => Math.max(...smsChart.value.map((d: any) => d.sent ?? 0), 1))
function miniBarHeight(val: number): number {
return Math.max((val / miniMax.value) * 100, val > 0 ? 6 : 0)
}
function fmtDate(iso: string | undefined): string {
if (!iso) return ''
return new Date(iso).toLocaleDateString('en-PH', { month: 'short', day: 'numeric' })
}
</script>

View File

@@ -1,9 +1,11 @@
<template>
<div class="space-y-6 max-w-md">
<div class="space-y-6 max-w-2xl">
<h1 class="text-2xl font-bold text-slate-900">Profile</h1>
<!-- Account card -->
<div class="bg-white rounded-xl p-6" style="box-shadow:0 2px 8px #0000000A">
<div class="flex items-center gap-4 mb-6">
<div class="w-14 h-14 rounded-full bg-gradient-to-br from-blue-500 to-indigo-600 flex items-center justify-center text-white text-lg font-bold">
<div class="w-14 h-14 rounded-full bg-gradient-to-br from-blue-500 to-indigo-600 flex items-center justify-center text-white text-lg font-bold shrink-0">
{{ initials }}
</div>
<div>
@@ -11,42 +13,96 @@
<p class="text-sm text-slate-500">School Admin</p>
</div>
</div>
<h2 class="text-sm font-semibold text-slate-900 mb-4">Change Password</h2>
<form @submit.prevent="submitPw" class="space-y-4">
<form @submit.prevent="submitPw" class="space-y-4 max-w-sm">
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">Current Password</label>
<input v-model="pwForm.current" type="password" 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" />
<input v-model="pwForm.current" type="password"
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">New Password</label>
<input v-model="pwForm.newPw" type="password" 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" />
<input v-model="pwForm.newPw" type="password"
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">Confirm New Password</label>
<input v-model="pwForm.confirm" type="password" 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" />
<input v-model="pwForm.confirm" type="password"
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>
<p v-if="pwError" class="text-sm text-red-600">{{ pwError }}</p>
<button type="submit" :disabled="saving || !pwForm.current || !pwForm.newPw || !pwForm.confirm"
class="w-full py-2 rounded-lg bg-blue-600 text-white text-sm font-medium hover:bg-blue-700 disabled:opacity-50">
class="px-5 py-2 rounded-lg bg-blue-600 text-white text-sm font-medium hover:bg-blue-700 disabled:opacity-50">
{{ saving ? 'Saving…' : 'Update Password' }}
</button>
</form>
</div>
<!-- School details card -->
<div v-if="schoolData" 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">School Details</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
<div>
<p class="text-xs text-slate-500 font-semibold uppercase tracking-wide mb-0.5">School Name</p>
<p class="font-medium text-slate-900">{{ schoolData.school.name }}</p>
</div>
<div>
<p class="text-xs text-slate-500 font-semibold uppercase tracking-wide mb-0.5">City</p>
<p class="font-medium text-slate-900">{{ schoolData.school.city || '—' }}</p>
</div>
<div>
<p class="text-xs text-slate-500 font-semibold uppercase tracking-wide mb-0.5">Contact Name</p>
<p class="font-medium text-slate-900">{{ schoolData.school.contact_name || '—' }}</p>
</div>
<div>
<p class="text-xs text-slate-500 font-semibold uppercase tracking-wide mb-0.5">Contact Email</p>
<p class="font-medium text-slate-900">{{ schoolData.school.contact_email || '—' }}</p>
</div>
<div>
<p class="text-xs text-slate-500 font-semibold uppercase tracking-wide mb-0.5">Contact Phone</p>
<p class="font-medium text-slate-900">{{ schoolData.school.contact_phone || '—' }}</p>
</div>
<div>
<p class="text-xs text-slate-500 font-semibold uppercase tracking-wide mb-0.5">Tier</p>
<p class="font-medium text-slate-900 capitalize">{{ schoolData.school.tier }}</p>
</div>
<div>
<p class="text-xs text-slate-500 font-semibold uppercase tracking-wide mb-0.5">Account Status</p>
<StatusBadge :status="schoolData.school.status" />
</div>
</div>
<p class="text-xs text-slate-400 mt-4">
To update school details, please contact TapTrack support.
</p>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { changePassword } from '@/lib/api'
import { ref, computed, onMounted } from 'vue'
import { changePassword, getPortalOverview } from '@/lib/api'
import { useAuthStore } from '@/stores/auth'
import { useToast } from '@/composables/useToast'
import StatusBadge from '@/components/ui/StatusBadge.vue'
const authStore = useAuthStore()
const toast = useToast()
const initials = computed(() => (authStore.fullName ?? '').split(' ').map(n => n[0]).join('').slice(0, 2).toUpperCase())
const pwForm = ref({ current: '', newPw: '', confirm: '' })
const toast = useToast()
const initials = computed(() =>
(authStore.fullName ?? '').split(' ').map(n => n[0]).join('').slice(0, 2).toUpperCase()
)
const pwForm = ref({ current: '', newPw: '', confirm: '' })
const pwError = ref('')
const saving = ref(false)
const saving = ref(false)
const schoolData = ref<any>(null)
onMounted(async () => {
try { schoolData.value = await getPortalOverview() } catch { /* ignore */ }
})
async function submitPw() {
pwError.value = ''

View File

@@ -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>

View File

@@ -1,23 +1,29 @@
<template>
<div class="space-y-6">
<div class="flex items-center justify-between">
<div class="flex items-center justify-between flex-wrap gap-3">
<h1 class="text-2xl font-bold text-slate-900">Support Tickets</h1>
<button @click="showCreate = true"
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">
<Plus :size="16" /> New Ticket
<button @click="showCreate = !showCreate"
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" />
New Ticket
</button>
</div>
<!-- Create form -->
<div v-if="showCreate" 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">Submit a Ticket</h2>
<div class="space-y-4">
<div class="space-y-4 max-w-lg">
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">Subject</label>
<input v-model="form.subject" 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" />
<input v-model="form.subject" 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"
placeholder="Brief description of your issue" />
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">Category</label>
<select v-model="form.category" class="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm">
<select v-model="form.category"
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="general">General</option>
<option value="billing">Billing</option>
<option value="technical">Technical</option>
@@ -27,10 +33,15 @@
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">Message</label>
<textarea v-model="form.body" rows="4" 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>
<textarea v-model="form.body" rows="4"
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"
placeholder="Describe your issue in detail…"></textarea>
</div>
<div class="flex gap-3">
<button @click="showCreate = false" class="px-4 py-2 border border-slate-200 rounded-lg text-sm text-slate-700 hover:bg-slate-50">Cancel</button>
<button @click="showCreate = false"
class="px-4 py-2 border border-slate-200 rounded-lg text-sm text-slate-700 hover:bg-slate-50">
Cancel
</button>
<button @click="submitTicket" :disabled="!form.subject || !form.body || submitting"
class="px-5 py-2 rounded-lg bg-blue-600 text-white text-sm font-medium hover:bg-blue-700 disabled:opacity-50">
{{ submitting ? 'Submitting…' : 'Submit Ticket' }}
@@ -38,10 +49,37 @@
</div>
</div>
</div>
<!-- List -->
<!-- Status filter tabs -->
<div class="flex gap-1 flex-wrap">
<button v-for="tab in statusTabs" :key="tab.value"
@click="statusFilter = tab.value; page = 1; fetchTickets()"
class="px-3 py-1.5 rounded-lg text-sm font-medium transition-colors"
:class="statusFilter === tab.value
? 'bg-blue-600 text-white'
: 'bg-white text-slate-600 hover:bg-slate-50 border border-slate-200'">
{{ tab.label }}
<span v-if="tab.value === '' && total > 0"
class="ml-1.5 text-xs bg-blue-100 text-blue-700 rounded-full px-1.5 py-0.5">{{ total }}</span>
</button>
</div>
<!-- Ticket list -->
<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 animate-pulse text-slate-400">Loading</div>
<div v-else-if="tickets.length === 0" class="p-12 text-center text-slate-400">No tickets yet</div>
<div v-else-if="tickets.length === 0"
class="flex flex-col items-center justify-center py-14 text-slate-400">
<Ticket :size="36" class="mb-3 opacity-30" />
<p class="font-medium text-sm">
{{ statusFilter ? `No ${statusFilter.replace('_', ' ')} tickets` : 'No tickets yet' }}
</p>
<p class="text-xs mt-1">
{{ statusFilter ? 'Try a different filter' : 'Click "New Ticket" to contact support' }}
</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">
@@ -49,50 +87,89 @@
<th class="px-5 py-3">Subject</th>
<th class="px-5 py-3">Category</th>
<th class="px-5 py-3">Status</th>
<th class="px-5 py-3">Date</th>
<th class="px-5 py-3">Opened</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-50">
<tr v-for="t in tickets" :key="t.id" class="hover:bg-slate-50 cursor-pointer" @click="$router.push(`/tickets/${t.id}`)">
<tr v-for="t in tickets" :key="t.id"
class="hover:bg-slate-50 cursor-pointer"
@click="$router.push(`/tickets/${t.id}`)">
<td class="px-5 py-3 font-mono text-xs font-semibold text-blue-600">{{ t.ticket_number }}</td>
<td class="px-5 py-3 font-medium text-slate-900 max-w-xs truncate">{{ t.subject }}</td>
<td class="px-5 py-3 capitalize text-xs text-slate-600">{{ t.category }}</td>
<td class="px-5 py-3"><StatusBadge :status="t.status" /></td>
<td class="px-5 py-3 text-xs text-slate-500">{{ new Date(t.created_at).toLocaleDateString() }}</td>
<td class="px-5 py-3 text-xs text-slate-500">
{{ new Date(t.created_at).toLocaleDateString('en-PH', { year: 'numeric', month: 'short', day: 'numeric' }) }}
</td>
</tr>
</tbody>
</table>
<!-- Pagination -->
<div v-if="total > 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 {{ (page - 1) * perPage + 1 }}{{ Math.min(page * perPage, total) }} of {{ total }}
</span>
<div class="flex gap-2">
<button :disabled="page <= 1" @click="page--; fetchTickets()"
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="page * perPage >= total" @click="page++; fetchTickets()"
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 { Plus } from 'lucide-vue-next'
import { Plus, Ticket } from 'lucide-vue-next'
import { getTickets, createTicket } from '@/lib/api'
import StatusBadge from '@/components/ui/StatusBadge.vue'
import { useToast } from '@/composables/useToast'
const toast = useToast()
const tickets = ref<any[]>([])
const loading = ref(false)
const showCreate = ref(false)
const submitting = ref(false)
const form = ref({ subject: '', body: '', category: 'general' })
const toast = useToast()
const tickets = ref<any[]>([])
const total = ref(0)
const page = ref(1)
const perPage = 20
const loading = ref(false)
const showCreate = ref(false)
const submitting = ref(false)
const statusFilter = ref('')
const form = ref({ subject: '', body: '', category: 'general' })
const statusTabs = [
{ label: 'All', value: '' },
{ label: 'Open', value: 'open' },
{ label: 'In Progress', value: 'in_progress' },
{ label: 'Resolved', value: 'resolved' },
{ label: 'Closed', value: 'closed' },
]
async function fetchTickets() {
loading.value = true
try { const r = await getTickets(); tickets.value = r.items }
finally { loading.value = false }
try {
const r = await getTickets({
page: page.value,
per_page: perPage,
status: statusFilter.value || undefined,
})
tickets.value = r.items
total.value = r.total
} finally { loading.value = false }
}
async function submitTicket() {
submitting.value = true
try {
await createTicket(form.value)
toast.success('Ticket submitted')
toast.success('Ticket submitted successfully')
showCreate.value = false
form.value = { subject: '', body: '', category: 'general' }
page.value = 1
statusFilter.value = ''
await fetchTickets()
} catch { toast.error('Failed to submit ticket') }
finally { submitting.value = false }