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:
kevin-asprec
2026-03-16 07:26:06 +08:00
commit 73a17aaf9a
107 changed files with 4764 additions and 0 deletions

View File

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

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

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

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

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