feat(phase-1): TapTrack Hub initial scaffold
Full project scaffold for TapTrack Hub — cloud SaaS control plane for managing on-prem TapTrack school deployments. ## Infrastructure - Docker Compose: backend (gunicorn+uvicorn), Celery worker + beat, frontend (Vite build + nginx), PostgreSQL 15, Redis 7, nginx proxy - Dockerfile for backend and frontend, nginx reverse proxy config ## Backend (FastAPI + SQLAlchemy async + Celery) Database schema (10 tables): hub_users, schools, licenses, sms_jobs, sms_credit_ledger, invoices, invoice_line_items, school_subscriptions, support_tickets, ticket_replies, audit_logs, announcements Auth: JWT (python-jose) + bcrypt + role-based FastAPI dependencies (get_current_user, require_super_admin, require_school_admin) Routers (11): auth, schools, licenses, sms, billing, tickets, users, dashboard, school_portal, announcements, sync Celery tasks (6): sms.process_queue, billing.generate_monthly_invoices, billing.send_invoice_email, billing.check_overdue, license.check_expiry, reports.send_monthly_reports Services: SMTP email helper (smtplib + Jinja2) Seed script: creates super admin admin@taptrack.io ## Frontend (Vue 3 + Vite + Pinia + Tailwind CSS) Router: 14 routes across super admin + school portal layouts Stores: Pinia auth store with localStorage persistence API client: full axios client for all backend endpoints Layouts: AppLayout (super admin), PortalLayout (school), AuthLayout Components: AppSidebar, PortalSidebar, SidebarItem, KpiCard, StatusBadge, ToastStack Pages: Login, Dashboard, Schools, SchoolDetail, Licenses, SMS, Billing, Tickets, TicketDetail, Users, Announcements, 404 Portal pages: Overview, Billing, SMS Reports, Tickets, Profile ## PAUL Planning Files - .paul/ROADMAP.md: full 15-phase roadmap with detailed scope - .paul/STATE.md: current position, tech stack, architecture notes - .paul/phases/01-setup/01-PLAN.md: complete Phase 1 plan (done) - .paul/phases/02 through 15: README stubs for all future phases
This commit is contained in:
0
frontend/src/pages/portal/.gitkeep
Normal file
0
frontend/src/pages/portal/.gitkeep
Normal file
38
frontend/src/pages/portal/PortalBillingPage.vue
Normal file
38
frontend/src/pages/portal/PortalBillingPage.vue
Normal file
@@ -0,0 +1,38 @@
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<h1 class="text-2xl font-bold text-slate-900">Billing History</h1>
|
||||
<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="invoices.length === 0" class="p-12 text-center text-slate-400">No invoices yet</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">Status</th>
|
||||
<th class="px-5 py-3">Due Date</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"><StatusBadge :status="inv.status" /></td>
|
||||
<td class="px-5 py-3 text-xs text-slate-500">{{ inv.due_date || '—' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { getInvoices } from '@/lib/api'
|
||||
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 } })
|
||||
</script>
|
||||
49
frontend/src/pages/portal/PortalOverviewPage.vue
Normal file
49
frontend/src/pages/portal/PortalOverviewPage.vue
Normal file
@@ -0,0 +1,49 @@
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<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>
|
||||
<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" />
|
||||
</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>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
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 announcements = ref<any[]>([])
|
||||
const loading = ref(true)
|
||||
onMounted(async () => {
|
||||
try { [data.value, announcements.value] = await Promise.all([getPortalOverview(), getAnnouncements()]) }
|
||||
finally { loading.value = false }
|
||||
})
|
||||
</script>
|
||||
64
frontend/src/pages/portal/PortalProfilePage.vue
Normal file
64
frontend/src/pages/portal/PortalProfilePage.vue
Normal file
@@ -0,0 +1,64 @@
|
||||
<template>
|
||||
<div class="space-y-6 max-w-md">
|
||||
<h1 class="text-2xl font-bold text-slate-900">Profile</h1>
|
||||
<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">
|
||||
{{ initials }}
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-semibold text-slate-900">{{ authStore.fullName }}</p>
|
||||
<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">
|
||||
<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" />
|
||||
</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" />
|
||||
</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" />
|
||||
</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">
|
||||
{{ saving ? 'Saving…' : 'Update Password' }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { changePassword } from '@/lib/api'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
|
||||
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 pwError = ref('')
|
||||
const saving = ref(false)
|
||||
|
||||
async function submitPw() {
|
||||
pwError.value = ''
|
||||
if (pwForm.value.newPw.length < 8) { pwError.value = 'Minimum 8 characters'; return }
|
||||
if (pwForm.value.newPw !== pwForm.value.confirm) { pwError.value = 'Passwords do not match'; return }
|
||||
saving.value = true
|
||||
try {
|
||||
await changePassword(pwForm.value.current, pwForm.value.newPw)
|
||||
toast.success('Password updated')
|
||||
pwForm.value = { current: '', newPw: '', confirm: '' }
|
||||
} catch (e: any) {
|
||||
pwError.value = e?.response?.data?.detail ?? 'Failed to update password'
|
||||
} finally { saving.value = false }
|
||||
}
|
||||
</script>
|
||||
38
frontend/src/pages/portal/PortalSmsPage.vue
Normal file
38
frontend/src/pages/portal/PortalSmsPage.vue
Normal file
@@ -0,0 +1,38 @@
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<h1 class="text-2xl font-bold text-slate-900">SMS Reports</h1>
|
||||
<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>
|
||||
<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 truncate">{{ j.message }}</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>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { 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 } })
|
||||
</script>
|
||||
102
frontend/src/pages/portal/PortalTicketsPage.vue
Normal file
102
frontend/src/pages/portal/PortalTicketsPage.vue
Normal file
@@ -0,0 +1,102 @@
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<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>
|
||||
</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>
|
||||
<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" />
|
||||
</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">
|
||||
<option value="general">General</option>
|
||||
<option value="billing">Billing</option>
|
||||
<option value="technical">Technical</option>
|
||||
<option value="sms">SMS</option>
|
||||
<option value="license">License</option>
|
||||
</select>
|
||||
</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>
|
||||
</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="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' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 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>
|
||||
<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">Ticket #</th>
|
||||
<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>
|
||||
</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}`)">
|
||||
<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>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { Plus } 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' })
|
||||
|
||||
async function fetchTickets() {
|
||||
loading.value = true
|
||||
try { const r = await getTickets(); tickets.value = r.items }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
|
||||
async function submitTicket() {
|
||||
submitting.value = true
|
||||
try {
|
||||
await createTicket(form.value)
|
||||
toast.success('Ticket submitted')
|
||||
showCreate.value = false
|
||||
form.value = { subject: '', body: '', category: 'general' }
|
||||
await fetchTickets()
|
||||
} catch { toast.error('Failed to submit ticket') }
|
||||
finally { submitting.value = false }
|
||||
}
|
||||
|
||||
onMounted(fetchTickets)
|
||||
</script>
|
||||
Reference in New Issue
Block a user