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

12
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,12 @@
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

14
frontend/index.html Normal file
View File

@@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>TapTrack Hub</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

10
frontend/nginx.conf Normal file
View File

@@ -0,0 +1,10 @@
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
location / { try_files $uri $uri/ /index.html; }
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}

26
frontend/package.json Normal file
View File

@@ -0,0 +1,26 @@
{
"name": "taptrack-hub-frontend",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
"preview": "vite preview"
},
"dependencies": {
"axios": "^1.7.9",
"lucide-vue-next": "^0.469.0",
"pinia": "^2.3.0",
"vue": "^3.5.13",
"vue-router": "^4.5.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.1",
"autoprefixer": "^10.4.20",
"postcss": "^8.5.1",
"tailwindcss": "^3.4.17",
"typescript": "^5.7.2",
"vite": "^6.0.7",
"vue-tsc": "^2.2.0"
}
}

View File

@@ -0,0 +1,3 @@
export default {
plugins: { tailwindcss: {}, autoprefixer: {} },
}

26
frontend/src/App.vue Normal file
View File

@@ -0,0 +1,26 @@
<template>
<component :is="layout">
<RouterView />
</component>
<ToastStack />
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import ToastStack from '@/components/ui/ToastStack.vue'
import AppLayout from '@/layouts/AppLayout.vue'
import PortalLayout from '@/layouts/PortalLayout.vue'
import AuthLayout from '@/layouts/AuthLayout.vue'
import { useAuthStore } from '@/stores/auth'
const authStore = useAuthStore()
authStore.loadFromStorage()
const route = useRoute()
const layout = computed(() => {
if (route.meta.layout === 'portal') return PortalLayout
if (route.meta.layout === 'app') return AppLayout
return AuthLayout
})
</script>

View File

@@ -0,0 +1,10 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
* { box-sizing: border-box; }
body { font-family: 'Inter', system-ui, sans-serif; margin: 0; background: #F8FAFC; color: #111827; }
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: #CBD5E1; border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: #94A3B8; }

View File

@@ -0,0 +1,37 @@
<template>
<aside class="w-60 bg-sidebar flex flex-col h-full shrink-0">
<div class="px-5 py-4 border-b border-slate-700">
<div class="flex items-center gap-2.5">
<div class="w-8 h-8 rounded-lg bg-blue-500 flex items-center justify-center">
<Layers :size="16" class="text-white" />
</div>
<div>
<p class="text-white text-sm font-bold leading-tight">TapTrack Hub</p>
<p class="text-slate-400 text-xs">Control Plane</p>
</div>
</div>
</div>
<nav class="flex-1 px-3 py-4 space-y-0.5 overflow-y-auto">
<SidebarItem v-for="item in navItems" :key="item.to" v-bind="item" />
</nav>
<div class="px-3 py-3 border-t border-slate-700">
<div class="px-3 py-2 text-xs text-slate-500">v1.0.0 Super Admin</div>
</div>
</aside>
</template>
<script setup lang="ts">
import { LayoutDashboard, Building2, KeyRound, MessageSquare, Receipt, Ticket, Users, Megaphone, Layers } from 'lucide-vue-next'
import SidebarItem from './SidebarItem.vue'
const navItems = [
{ label: 'Dashboard', to: '/dashboard', icon: LayoutDashboard },
{ label: 'Schools', to: '/schools', icon: Building2 },
{ label: 'Licenses', to: '/licenses', icon: KeyRound },
{ label: 'SMS Gateway', to: '/sms', icon: MessageSquare },
{ label: 'Billing', to: '/billing', icon: Receipt },
{ label: 'Support', to: '/tickets', icon: Ticket },
{ label: 'Users', to: '/users', icon: Users },
{ label: 'Announcements', to: '/announcements', icon: Megaphone },
]
</script>

View File

@@ -0,0 +1,31 @@
<template>
<aside class="w-60 bg-sidebar flex flex-col h-full shrink-0">
<div class="px-5 py-4 border-b border-slate-700">
<div class="flex items-center gap-2.5">
<div class="w-8 h-8 rounded-lg bg-emerald-500 flex items-center justify-center">
<School :size="16" class="text-white" />
</div>
<div>
<p class="text-white text-sm font-bold leading-tight">School Portal</p>
<p class="text-slate-400 text-xs">TapTrack Hub</p>
</div>
</div>
</div>
<nav class="flex-1 px-3 py-4 space-y-0.5 overflow-y-auto">
<SidebarItem v-for="item in navItems" :key="item.to" v-bind="item" />
</nav>
</aside>
</template>
<script setup lang="ts">
import { LayoutDashboard, Receipt, MessageSquare, Ticket, UserCircle, School } from 'lucide-vue-next'
import SidebarItem from './SidebarItem.vue'
const navItems = [
{ label: 'Overview', to: '/portal', icon: LayoutDashboard },
{ label: 'Billing', to: '/portal/billing', icon: Receipt },
{ label: 'SMS Reports', to: '/portal/sms', icon: MessageSquare },
{ label: 'Support', to: '/portal/tickets', icon: Ticket },
{ label: 'Profile', to: '/portal/profile', icon: UserCircle },
]
</script>

View File

@@ -0,0 +1,21 @@
<template>
<RouterLink
:to="to"
class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors duration-150"
:class="isActive
? 'bg-blue-600 text-white'
: 'text-slate-400 hover:bg-slate-700 hover:text-white'"
>
<component :is="icon" :size="16" class="shrink-0" />
<span>{{ label }}</span>
</RouterLink>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute } from 'vue-router'
const props = defineProps<{ label: string; to: string; icon: any }>()
const route = useRoute()
const isActive = computed(() => route.path === props.to || route.path.startsWith(props.to + '/'))
</script>

View File

@@ -0,0 +1,31 @@
<template>
<div class="bg-white rounded-xl p-5 flex flex-col gap-3" style="box-shadow:0 2px 8px #0000000A">
<div class="flex items-center justify-between">
<span class="text-xs font-semibold text-slate-500 uppercase tracking-wide">{{ label }}</span>
<div class="w-8 h-8 rounded-lg flex items-center justify-center" :class="iconBg">
<component :is="iconComponent" :size="16" :class="iconColor" />
</div>
</div>
<p class="text-2xl font-bold text-slate-900">{{ value.toLocaleString() }}</p>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { Building2, CheckCircle, KeyRound, Ticket, MessageSquare, Receipt, Users } from 'lucide-vue-next'
const props = defineProps<{ label: string; value: number; icon: string; color: string }>()
const iconMap: Record<string, any> = { Building2, CheckCircle, KeyRound, Ticket, MessageSquare, Receipt, Users }
const iconComponent = computed(() => iconMap[props.icon] ?? Building2)
const colorMap: Record<string, [string, string]> = {
blue: ['bg-blue-100', 'text-blue-600'],
green: ['bg-emerald-100','text-emerald-600'],
amber: ['bg-amber-100', 'text-amber-600'],
red: ['bg-red-100', 'text-red-500'],
purple: ['bg-purple-100', 'text-purple-600'],
}
const iconBg = computed(() => colorMap[props.color]?.[0] ?? 'bg-slate-100')
const iconColor = computed(() => colorMap[props.color]?.[1] ?? 'text-slate-600')
</script>

View File

@@ -0,0 +1,26 @@
<template>
<span class="inline-block text-xs font-semibold px-2.5 py-0.5 rounded-full" :class="classes">
{{ status }}
</span>
</template>
<script setup lang="ts">
import { computed } from 'vue'
const props = defineProps<{ status: string }>()
const classes = computed(() => ({
active: 'bg-emerald-100 text-emerald-700',
pending: 'bg-amber-100 text-amber-700',
suspended: 'bg-red-100 text-red-700',
expired: 'bg-slate-100 text-slate-500',
open: 'bg-blue-100 text-blue-700',
in_progress: 'bg-purple-100 text-purple-700',
resolved: 'bg-emerald-100 text-emerald-700',
closed: 'bg-slate-100 text-slate-500',
paid: 'bg-emerald-100 text-emerald-700',
sent: 'bg-blue-100 text-blue-700',
draft: 'bg-slate-100 text-slate-600',
overdue: 'bg-red-100 text-red-700',
trial: 'bg-purple-100 text-purple-700',
revoked: 'bg-red-100 text-red-700',
}[props.status] ?? 'bg-slate-100 text-slate-600'))
</script>

View File

@@ -0,0 +1,36 @@
<template>
<Teleport to="body">
<div class="fixed bottom-5 right-5 z-[9999] flex flex-col gap-2 pointer-events-none" style="min-width:280px;max-width:380px">
<TransitionGroup name="toast">
<div
v-for="t in toasts"
:key="t.id"
class="flex items-start gap-3 px-4 py-3 rounded-xl shadow-xl text-sm font-medium pointer-events-auto"
:class="{
'bg-emerald-600 text-white': t.type === 'success',
'bg-red-600 text-white': t.type === 'error',
'bg-blue-600 text-white': t.type === 'info',
'bg-amber-500 text-white': t.type === 'warning',
}"
>
<CheckCircle v-if="t.type === 'success'" :size="16" class="shrink-0 mt-0.5" />
<XCircle v-else-if="t.type === 'error'" :size="16" class="shrink-0 mt-0.5" />
<Info v-else :size="16" class="shrink-0 mt-0.5" />
<span>{{ t.message }}</span>
</div>
</TransitionGroup>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { CheckCircle, XCircle, Info } from 'lucide-vue-next'
import { toasts } from '@/composables/useToast'
</script>
<style scoped>
.toast-enter-active { transition: all 0.25s ease; }
.toast-leave-active { transition: all 0.2s ease; }
.toast-enter-from { opacity: 0; transform: translateX(100%); }
.toast-leave-to { opacity: 0; transform: translateX(100%); }
</style>

View File

@@ -0,0 +1,22 @@
import { ref } from 'vue'
interface Toast { id: number; type: 'success' | 'error' | 'info' | 'warning'; message: string }
const toasts = ref<Toast[]>([])
let nextId = 1
export function useToast() {
const show = (type: Toast['type'], message: string, duration = 4000) => {
const id = nextId++
toasts.value.push({ id, type, message })
setTimeout(() => { toasts.value = toasts.value.filter(t => t.id !== id) }, duration)
}
return {
success: (msg: string) => show('success', msg),
error: (msg: string) => show('error', msg),
info: (msg: string) => show('info', msg),
warning: (msg: string) => show('warning', msg),
}
}
export { toasts }

View File

@@ -0,0 +1,50 @@
<template>
<div class="flex h-screen overflow-hidden bg-slate-50">
<AppSidebar />
<div class="flex-1 flex flex-col min-w-0 overflow-hidden">
<!-- Top bar -->
<header class="h-14 bg-white border-b border-slate-200 flex items-center justify-between px-6 shrink-0">
<div class="text-sm text-slate-500">
TapTrack Hub
<span class="mx-1.5 text-slate-300">·</span>
<span class="text-slate-800 font-medium">{{ pageTitle }}</span>
</div>
<div class="flex items-center gap-3">
<span class="text-sm font-medium text-slate-700">{{ authStore.fullName }}</span>
<button @click="logout" class="text-slate-400 hover:text-slate-700 transition-colors" title="Logout">
<LogOut :size="18" />
</button>
</div>
</header>
<!-- Page content -->
<main class="flex-1 overflow-y-auto p-6">
<RouterView />
</main>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { LogOut } from 'lucide-vue-next'
import AppSidebar from '@/components/sidebar/AppSidebar.vue'
import { useAuthStore } from '@/stores/auth'
const route = useRoute()
const router = useRouter()
const authStore = useAuthStore()
const pageTitles: Record<string, string> = {
dashboard: 'Dashboard', schools: 'Schools', 'school-detail': 'School Detail',
licenses: 'Licenses', sms: 'SMS Gateway', billing: 'Billing',
tickets: 'Support Tickets', 'ticket-detail': 'Ticket Detail',
users: 'Users', announcements: 'Announcements',
}
const pageTitle = computed(() => pageTitles[route.name as string] ?? '')
function logout() {
authStore.logout()
router.push('/login')
}
</script>

View File

@@ -0,0 +1,5 @@
<template>
<div class="min-h-screen bg-gradient-to-br from-slate-900 to-slate-800 flex items-center justify-center p-4">
<RouterView />
</div>
</template>

View File

@@ -0,0 +1,43 @@
<template>
<div class="flex h-screen overflow-hidden bg-slate-50">
<PortalSidebar />
<div class="flex-1 flex flex-col min-w-0 overflow-hidden">
<header class="h-14 bg-white border-b border-slate-200 flex items-center justify-between px-6 shrink-0">
<div class="text-sm text-slate-500">
School Portal
<span class="mx-1.5 text-slate-300">·</span>
<span class="text-slate-800 font-medium">{{ pageTitle }}</span>
</div>
<div class="flex items-center gap-3">
<span class="text-sm font-medium text-slate-700">{{ authStore.fullName }}</span>
<button @click="logout" class="text-slate-400 hover:text-slate-700 transition-colors">
<LogOut :size="18" />
</button>
</div>
</header>
<main class="flex-1 overflow-y-auto p-6">
<RouterView />
</main>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { LogOut } from 'lucide-vue-next'
import PortalSidebar from '@/components/sidebar/PortalSidebar.vue'
import { useAuthStore } from '@/stores/auth'
const route = useRoute()
const router = useRouter()
const authStore = useAuthStore()
const titles: Record<string, string> = {
portal: 'Overview', 'portal-billing': 'Billing',
'portal-sms': 'SMS Reports', 'portal-tickets': 'Support', 'portal-profile': 'Profile',
}
const pageTitle = computed(() => titles[route.name as string] ?? '')
function logout() { authStore.logout(); router.push('/login') }
</script>

90
frontend/src/lib/api.ts Normal file
View File

@@ -0,0 +1,90 @@
import axios from 'axios'
const api = axios.create({ baseURL: '/api' })
api.interceptors.request.use((config) => {
const token = localStorage.getItem('hub_token')
if (token) config.headers.Authorization = `Bearer ${token}`
return config
})
api.interceptors.response.use(
(r) => r,
(error) => {
if (error.response?.status === 401) {
localStorage.removeItem('hub_token')
window.location.href = '/login'
}
return Promise.reject(error)
},
)
export default api
// ── Auth ──────────────────────────────────────────────────────────────────────
export const login = (email: string, password: string) =>
api.post('/auth/login', { email, password }).then(r => r.data)
export const getMe = () => api.get('/auth/me').then(r => r.data)
export const changePassword = (current_password: string, new_password: string) =>
api.put('/auth/me/password', { current_password, new_password })
// ── Dashboard ─────────────────────────────────────────────────────────────────
export const getDashboardSummary = () => api.get('/dashboard/summary').then(r => r.data)
// ── Schools ───────────────────────────────────────────────────────────────────
export interface School {
id: string; name: string; slug: string; status: string; tier: string
contact_email: string | null; contact_name: string | null; city: string | null
sms_credits: number; sms_sender_name: string; student_limit: number
license_key: string | null; license_status: string | null
license_expires_at: string | null; license_last_seen: string | null
created_at: string; billing_email: string | null; notes: string | null
}
export const getSchools = (params?: object) => api.get('/schools', { params }).then(r => r.data)
export const getSchool = (id: string) => api.get(`/schools/${id}`).then(r => r.data)
export const createSchool = (data: object) => api.post('/schools', data).then(r => r.data)
export const updateSchool = (id: string, data: object) => api.put(`/schools/${id}`, data).then(r => r.data)
export const addSmsCredits = (id: string, amount: number, description?: string) =>
api.post(`/schools/${id}/credits`, null, { params: { amount, description } }).then(r => r.data)
// ── Licenses ──────────────────────────────────────────────────────────────────
export const getLicenses = () => api.get('/licenses').then(r => r.data)
export const updateLicense = (id: string, data: object) => api.put(`/licenses/${id}`, data).then(r => r.data)
export const revokeLicense = (id: string) => api.post(`/licenses/${id}/revoke`).then(r => r.data)
// ── SMS ───────────────────────────────────────────────────────────────────────
export const getSmsJobs = (params?: object) => api.get('/sms/jobs', { params }).then(r => r.data)
export const getCreditLedger = (schoolId: string, params?: object) =>
api.get(`/sms/credits/${schoolId}`, { params }).then(r => r.data)
// ── Billing ───────────────────────────────────────────────────────────────────
export const getInvoices = (params?: object) => api.get('/billing/invoices', { params }).then(r => r.data)
export const createInvoice = (data: object) => api.post('/billing/invoices', data).then(r => r.data)
export const updateInvoice = (id: string, data: object) => api.put(`/billing/invoices/${id}`, data).then(r => r.data)
export const sendInvoiceEmail = (id: string) => api.post(`/billing/invoices/${id}/send-email`).then(r => r.data)
export const getSubscription = (schoolId: string) => api.get(`/billing/subscriptions/${schoolId}`).then(r => r.data)
export const upsertSubscription = (schoolId: string, data: object) =>
api.put(`/billing/subscriptions/${schoolId}`, data).then(r => r.data)
// ── Tickets ───────────────────────────────────────────────────────────────────
export const getTickets = (params?: object) => api.get('/tickets', { params }).then(r => r.data)
export const getTicket = (id: string) => api.get(`/tickets/${id}`).then(r => r.data)
export const createTicket = (data: object) => api.post('/tickets', data).then(r => r.data)
export const updateTicket = (id: string, data: object) => api.put(`/tickets/${id}`, data).then(r => r.data)
export const addTicketReply = (id: string, data: object) => api.post(`/tickets/${id}/replies`, data).then(r => r.data)
// ── Users ─────────────────────────────────────────────────────────────────────
export const getUsers = (params?: object) => api.get('/users', { params }).then(r => r.data)
export const createUser = (data: object) => api.post('/users', data).then(r => r.data)
export const updateUser = (id: string, data: object) => api.put(`/users/${id}`, data).then(r => r.data)
// ── Announcements ─────────────────────────────────────────────────────────────
export const getAnnouncements = () => api.get('/announcements').then(r => r.data)
export const createAnnouncement = (data: object) => api.post('/announcements', data).then(r => r.data)
export const deleteAnnouncement = (id: string) => api.delete(`/announcements/${id}`)
// ── School Portal ─────────────────────────────────────────────────────────────
export const getPortalOverview = () => api.get('/portal/overview').then(r => r.data)

10
frontend/src/main.ts Normal file
View File

@@ -0,0 +1,10 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import router from './router'
import App from './App.vue'
import './assets/main.css'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.mount('#app')

View File

@@ -0,0 +1,46 @@
<template>
<div class="space-y-6">
<div class="flex items-center justify-between">
<h1 class="text-2xl font-bold text-slate-900">Announcements</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 Announcement
</button>
</div>
<div class="space-y-4">
<div v-if="loading" class="animate-pulse h-24 bg-white rounded-xl"></div>
<div v-for="a in announcements" :key="a.id" class="bg-white rounded-xl p-5" style="box-shadow:0 2px 8px #0000000A">
<div class="flex items-start justify-between">
<div>
<h3 class="font-semibold text-slate-900">{{ a.title }}</h3>
<p class="text-sm text-slate-600 mt-1">{{ a.body }}</p>
<p class="text-xs text-slate-400 mt-2">{{ new Date(a.created_at).toLocaleString() }}</p>
</div>
<button @click="remove(a.id)" class="text-slate-400 hover:text-red-500 transition-colors ml-4">
<Trash2 :size="16" />
</button>
</div>
</div>
<div v-if="!loading && announcements.length === 0" class="p-12 text-center text-slate-400 bg-white rounded-xl">No announcements</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { Plus, Trash2 } from 'lucide-vue-next'
import { getAnnouncements, deleteAnnouncement } from '@/lib/api'
import { useToast } from '@/composables/useToast'
const toast = useToast()
const announcements = ref<any[]>([])
const loading = ref(true)
const showCreate = ref(false)
onMounted(async () => { try { announcements.value = await getAnnouncements() } finally { loading.value = false } })
async function remove(id: string) {
try { await deleteAnnouncement(id); announcements.value = announcements.value.filter(a => a.id !== id); toast.success('Removed') }
catch { toast.error('Failed to remove') }
}
</script>

View File

@@ -0,0 +1,78 @@
<template>
<div class="space-y-6">
<div class="flex items-center justify-between">
<h1 class="text-2xl font-bold text-slate-900">Billing</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 Invoice
</button>
</div>
<div class="flex gap-3">
<select v-model="statusFilter" class="border border-slate-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="">All Statuses</option>
<option value="draft">Draft</option>
<option value="sent">Sent</option>
<option value="paid">Paid</option>
<option value="overdue">Overdue</option>
</select>
</div>
<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 found</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">School</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</th>
<th class="px-5 py-3">Actions</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-600">{{ inv.school_id }}</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>
<td class="px-5 py-3">
<button @click="sendEmail(inv.id)" class="text-xs text-blue-600 hover:underline">Send Email</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch, onMounted } from 'vue'
import { Plus } from 'lucide-vue-next'
import { getInvoices, sendInvoiceEmail } from '@/lib/api'
import StatusBadge from '@/components/ui/StatusBadge.vue'
import { useToast } from '@/composables/useToast'
const toast = useToast()
const invoices = ref<any[]>([])
const loading = ref(false)
const statusFilter = ref('')
const showCreate = ref(false)
async function fetchInvoices() {
loading.value = true
try { const r = await getInvoices({ status: statusFilter.value || undefined }); invoices.value = r.items }
finally { loading.value = false }
}
async function sendEmail(id: string) {
try { await sendInvoiceEmail(id); toast.success('Invoice email queued') }
catch { toast.error('Failed to send email') }
}
watch(statusFilter, fetchInvoices)
onMounted(fetchInvoices)
</script>

View File

@@ -0,0 +1,57 @@
<template>
<div class="space-y-6">
<div>
<h1 class="text-2xl font-bold text-slate-900">Dashboard</h1>
<p class="text-sm text-slate-500 mt-0.5">{{ today }}</p>
</div>
<!-- KPI Cards -->
<div v-if="loading" class="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-5 gap-4 animate-pulse">
<div v-for="i in 5" :key="i" class="bg-white rounded-xl p-5 h-24" style="box-shadow:0 2px 8px #0000000A"></div>
</div>
<div v-else class="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-5 gap-4">
<KpiCard label="Total Schools" :value="summary?.schools?.total ?? 0" icon="Building2" color="blue" />
<KpiCard label="Active Schools" :value="summary?.schools?.active ?? 0" icon="CheckCircle" color="green" />
<KpiCard label="Expiring Licenses" :value="summary?.licenses?.expiring_soon ?? 0" icon="KeyRound" color="amber" />
<KpiCard label="Open Tickets" :value="summary?.tickets?.open ?? 0" icon="Ticket" color="red" />
<KpiCard label="SMS Today" :value="summary?.sms?.sent_today ?? 0" icon="MessageSquare" color="purple" />
</div>
<!-- Secondary row -->
<div class="grid grid-cols-1 xl:grid-cols-3 gap-6">
<!-- Pending invoices -->
<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-1">Billing</h2>
<p class="text-3xl font-bold text-slate-900">{{ summary?.invoices?.pending ?? 0 }}</p>
<p class="text-sm text-slate-500 mt-1">Pending invoices</p>
</div>
<!-- SMS Queue -->
<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-1">SMS Queue</h2>
<p class="text-3xl font-bold text-slate-900">{{ summary?.sms?.pending ?? 0 }}</p>
<p class="text-sm text-slate-500 mt-1">Jobs awaiting dispatch</p>
</div>
<!-- Suspended schools -->
<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-1">Suspended</h2>
<p class="text-3xl font-bold text-red-500">{{ summary?.schools?.suspended ?? 0 }}</p>
<p class="text-sm text-slate-500 mt-1">Schools suspended</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { getDashboardSummary } from '@/lib/api'
import KpiCard from '@/components/ui/KpiCard.vue'
const summary = ref<any>(null)
const loading = ref(true)
const today = new Date().toLocaleDateString('en-PH', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })
onMounted(async () => {
try { summary.value = await getDashboardSummary() }
finally { loading.value = false }
})
</script>

View File

@@ -0,0 +1,41 @@
<template>
<div class="space-y-6">
<h1 class="text-2xl font-bold text-slate-900">Licenses</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-slate-400 text-sm animate-pulse">Loading</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">License Key</th>
<th class="px-5 py-3">School ID</th>
<th class="px-5 py-3">Status</th>
<th class="px-5 py-3">Tier</th>
<th class="px-5 py-3">Expires</th>
<th class="px-5 py-3">Last Validated</th>
<th class="px-5 py-3">Last IP</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-50">
<tr v-for="l in licenses" :key="l.id" class="hover:bg-slate-50">
<td class="px-5 py-3 font-mono text-xs">{{ l.key }}</td>
<td class="px-5 py-3 text-xs text-slate-500">{{ l.school_id }}</td>
<td class="px-5 py-3"><StatusBadge :status="l.status" /></td>
<td class="px-5 py-3 capitalize">{{ l.tier }}</td>
<td class="px-5 py-3 text-slate-500 text-xs">{{ l.expires_at ? new Date(l.expires_at).toLocaleDateString() : 'Never' }}</td>
<td class="px-5 py-3 text-slate-500 text-xs">{{ l.last_validated_at ? new Date(l.last_validated_at).toLocaleString() : '—' }}</td>
<td class="px-5 py-3 text-slate-500 text-xs font-mono">{{ l.last_seen_ip || '—' }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { getLicenses } from '@/lib/api'
import StatusBadge from '@/components/ui/StatusBadge.vue'
const licenses = ref<any[]>([])
const loading = ref(true)
onMounted(async () => { try { licenses.value = await getLicenses() } finally { loading.value = false } })
</script>

View File

@@ -0,0 +1,60 @@
<template>
<div class="w-full max-w-md">
<div class="bg-white rounded-2xl shadow-2xl overflow-hidden">
<div class="bg-gradient-to-r from-slate-800 to-slate-900 px-8 py-8 text-center">
<div class="w-12 h-12 rounded-xl bg-blue-500 flex items-center justify-center mx-auto mb-3">
<Layers :size="24" class="text-white" />
</div>
<h1 class="text-xl font-bold text-white">TapTrack Hub</h1>
<p class="text-slate-400 text-sm mt-1">Cloud Control Plane</p>
</div>
<form @submit.prevent="handleLogin" class="px-8 py-8 space-y-5">
<div>
<label class="block text-sm font-medium text-slate-700 mb-1.5">Email</label>
<input v-model="email" type="email" required autocomplete="email"
class="w-full border border-slate-200 rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="admin@taptrack.io" />
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1.5">Password</label>
<input v-model="password" type="password" required autocomplete="current-password"
class="w-full border border-slate-200 rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" />
</div>
<p v-if="error" class="text-sm text-red-600 bg-red-50 rounded-lg px-3 py-2">{{ error }}</p>
<button type="submit" :disabled="loading"
class="w-full py-2.5 rounded-lg bg-blue-600 hover:bg-blue-700 text-white text-sm font-semibold transition-colors disabled:opacity-60">
{{ loading ? 'Signing in…' : 'Sign In' }}
</button>
</form>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { Layers } from 'lucide-vue-next'
import { login } from '@/lib/api'
import { useAuthStore } from '@/stores/auth'
const router = useRouter()
const authStore = useAuthStore()
const email = ref('')
const password = ref('')
const loading = ref(false)
const error = ref('')
async function handleLogin() {
loading.value = true
error.value = ''
try {
const data = await login(email.value, password.value)
authStore.setAuth(data)
router.push(data.role === 'super_admin' ? '/dashboard' : '/portal')
} catch (e: any) {
error.value = e?.response?.data?.detail ?? 'Login failed'
} finally {
loading.value = false
}
}
</script>

View File

@@ -0,0 +1,9 @@
<template>
<div class="min-h-screen flex items-center justify-center bg-slate-50">
<div class="text-center">
<p class="text-6xl font-bold text-slate-200">404</p>
<p class="text-xl font-semibold text-slate-700 mt-4">Page not found</p>
<RouterLink to="/" class="mt-6 inline-block text-sm text-blue-600 hover:underline">Go home</RouterLink>
</div>
</div>
</template>

View File

@@ -0,0 +1,76 @@
<template>
<div class="space-y-6">
<div class="flex items-center gap-4">
<button @click="$router.back()" class="text-slate-400 hover:text-slate-700 transition-colors">
<ArrowLeft :size="20" />
</button>
<div>
<h1 class="text-2xl font-bold text-slate-900">{{ school?.name ?? '…' }}</h1>
<p class="text-sm text-slate-500 mt-0.5">{{ school?.slug }}</p>
</div>
<StatusBadge v-if="school" :status="school.status" class="ml-2" />
</div>
<div v-if="loading" class="animate-pulse h-48 bg-white rounded-xl" style="box-shadow:0 2px 8px #0000000A"></div>
<div v-else-if="school" class="grid grid-cols-1 xl:grid-cols-3 gap-6">
<!-- Info card -->
<div class="xl:col-span-2 bg-white rounded-xl p-6 space-y-4" style="box-shadow:0 2px 8px #0000000A">
<h2 class="text-base font-semibold text-slate-900">School Information</h2>
<div class="grid grid-cols-2 gap-4 text-sm">
<div><p class="text-slate-500">Contact</p><p class="font-medium">{{ school.contact_name || '—' }}</p></div>
<div><p class="text-slate-500">Email</p><p class="font-medium">{{ school.contact_email || '—' }}</p></div>
<div><p class="text-slate-500">City</p><p class="font-medium">{{ school.city || '—' }}</p></div>
<div><p class="text-slate-500">Billing Email</p><p class="font-medium">{{ school.billing_email || '—' }}</p></div>
<div><p class="text-slate-500">Tier</p><p class="font-medium capitalize">{{ school.tier }}</p></div>
<div><p class="text-slate-500">Student Limit</p><p class="font-medium">{{ school.student_limit.toLocaleString() }}</p></div>
<div><p class="text-slate-500">SMS Sender</p><p class="font-medium font-mono">{{ school.sms_sender_name }}</p></div>
<div>
<p class="text-slate-500">SMS Credits</p>
<p class="font-medium" :class="school.sms_credits < 50 ? 'text-red-600' : ''">
{{ school.sms_credits.toLocaleString() }}
</p>
</div>
</div>
</div>
<!-- License card -->
<div class="bg-white rounded-xl p-6 space-y-3" style="box-shadow:0 2px 8px #0000000A">
<h2 class="text-base font-semibold text-slate-900">License</h2>
<div class="text-sm space-y-2">
<div class="flex justify-between"><span class="text-slate-500">Key</span><span class="font-mono text-xs">{{ school.license_key || '—' }}</span></div>
<div class="flex justify-between"><span class="text-slate-500">Status</span><StatusBadge :status="school.license_status || 'pending'" /></div>
<div class="flex justify-between"><span class="text-slate-500">Expires</span><span>{{ school.license_expires_at ? new Date(school.license_expires_at).toLocaleDateString() : 'Never' }}</span></div>
<div class="flex justify-between"><span class="text-slate-500">Last Seen</span><span>{{ school.license_last_seen ? new Date(school.license_last_seen).toLocaleDateString() : '—' }}</span></div>
</div>
<button @click="copyKey" v-if="school.license_key"
class="w-full mt-2 py-1.5 text-xs font-medium border border-slate-200 rounded-lg hover:bg-slate-50 transition-colors flex items-center justify-center gap-1.5">
<Copy :size="12" /> Copy License Key
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { ArrowLeft, Copy } from 'lucide-vue-next'
import { getSchool } from '@/lib/api'
import StatusBadge from '@/components/ui/StatusBadge.vue'
import { useToast } from '@/composables/useToast'
const route = useRoute()
const toast = useToast()
const school = ref<any>(null)
const loading = ref(true)
onMounted(async () => {
try { school.value = await getSchool(route.params.id as string) }
finally { loading.value = false }
})
function copyKey() {
navigator.clipboard.writeText(school.value?.license_key ?? '')
toast.success('License key copied')
}
</script>

View File

@@ -0,0 +1,120 @@
<template>
<div class="space-y-6">
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold text-slate-900">Schools</h1>
<p class="text-sm text-slate-500 mt-0.5">{{ total }} registered schools</p>
</div>
<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 transition-colors">
<Plus :size="16" /> Add School
</button>
</div>
<!-- Filters -->
<div class="flex gap-3 flex-wrap">
<input v-model="search" type="text" placeholder="Search schools…"
class="border border-slate-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 w-64" />
<select v-model="statusFilter" class="border border-slate-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="">All Statuses</option>
<option value="active">Active</option>
<option value="pending">Pending</option>
<option value="suspended">Suspended</option>
<option value="expired">Expired</option>
</select>
</div>
<!-- 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-slate-400 text-sm animate-pulse">Loading schools</div>
<div v-else-if="schools.length === 0" class="p-12 text-center text-slate-400">
<Building2 :size="40" class="mx-auto mb-3 opacity-30" />
<p>No schools 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">School</th>
<th class="px-5 py-3">Status</th>
<th class="px-5 py-3">Tier</th>
<th class="px-5 py-3">SMS Credits</th>
<th class="px-5 py-3">License</th>
<th class="px-5 py-3">Last Seen</th>
<th class="px-5 py-3">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-50">
<tr v-for="s in schools" :key="s.id" class="hover:bg-slate-50 transition-colors">
<td class="px-5 py-3">
<p class="font-semibold text-slate-900">{{ s.name }}</p>
<p class="text-xs text-slate-400">{{ s.city || s.slug }}</p>
</td>
<td class="px-5 py-3">
<StatusBadge :status="s.status" />
</td>
<td class="px-5 py-3 capitalize text-slate-600">{{ s.tier }}</td>
<td class="px-5 py-3">
<span :class="s.sms_credits < 50 ? 'text-red-600 font-semibold' : 'text-slate-700'">
{{ s.sms_credits.toLocaleString() }}
</span>
</td>
<td class="px-5 py-3">
<span v-if="s.license_status" class="text-xs font-semibold px-2 py-0.5 rounded-full"
:class="s.license_status === 'active' ? 'bg-emerald-100 text-emerald-700' : 'bg-red-100 text-red-700'">
{{ s.license_status }}
</span>
<span v-else class="text-slate-400 text-xs"></span>
</td>
<td class="px-5 py-3 text-xs text-slate-500">
{{ s.license_last_seen ? new Date(s.license_last_seen).toLocaleDateString() : '—' }}
</td>
<td class="px-5 py-3">
<RouterLink :to="`/schools/${s.id}`" class="text-blue-600 hover:underline text-xs font-medium">
View
</RouterLink>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Pagination -->
<div v-if="total > perPage" class="flex items-center justify-between text-sm text-slate-600">
<span>Showing {{ (page - 1) * perPage + 1 }}{{ Math.min(page * perPage, total) }} of {{ total }}</span>
<div class="flex gap-2">
<button :disabled="page <= 1" @click="page--" class="px-3 py-1.5 border border-slate-200 rounded-lg hover:bg-slate-50 disabled:opacity-40">Prev</button>
<button :disabled="page * perPage >= total" @click="page++" class="px-3 py-1.5 border border-slate-200 rounded-lg hover:bg-slate-50 disabled:opacity-40">Next</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch, onMounted } from 'vue'
import { Plus, Building2 } from 'lucide-vue-next'
import { getSchools, type School } from '@/lib/api'
import StatusBadge from '@/components/ui/StatusBadge.vue'
const schools = ref<School[]>([])
const total = ref(0)
const page = ref(1)
const perPage = 25
const search = ref('')
const statusFilter = ref('')
const loading = ref(false)
const showCreate = ref(false)
let debounce: any = null
async function fetchSchools() {
loading.value = true
try {
const res = await getSchools({ page: page.value, per_page: perPage, search: search.value || undefined, status: statusFilter.value || undefined })
schools.value = res.items
total.value = res.total
} finally { loading.value = false }
}
watch(search, () => { clearTimeout(debounce); debounce = setTimeout(() => { page.value = 1; fetchSchools() }, 300) })
watch([page, statusFilter], fetchSchools)
onMounted(fetchSchools)
</script>

View File

@@ -0,0 +1,56 @@
<template>
<div class="space-y-6">
<h1 class="text-2xl font-bold text-slate-900">SMS Gateway</h1>
<div class="flex gap-3 flex-wrap">
<select v-model="statusFilter" class="border border-slate-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="">All Statuses</option>
<option value="pending">Pending</option>
<option value="sent">Sent</option>
<option value="failed">Failed</option>
<option value="cancelled">Cancelled</option>
</select>
</div>
<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 jobs found</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">Sender</th>
<th class="px-5 py-3">Status</th>
<th class="px-5 py-3">Trigger</th>
<th class="px-5 py-3">Created</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 font-mono text-xs">{{ j.sender_name }}</td>
<td class="px-5 py-3"><StatusBadge :status="j.status" /></td>
<td class="px-5 py-3 text-slate-500 text-xs">{{ j.trigger || '—' }}</td>
<td class="px-5 py-3 text-slate-500 text-xs">{{ new Date(j.created_at).toLocaleString() }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch, onMounted } from 'vue'
import { getSmsJobs } from '@/lib/api'
import StatusBadge from '@/components/ui/StatusBadge.vue'
const jobs = ref<any[]>([])
const loading = ref(false)
const statusFilter = ref('')
async function fetchJobs() {
loading.value = true
try { const r = await getSmsJobs({ status: statusFilter.value || undefined }); jobs.value = r.items }
finally { loading.value = false }
}
watch(statusFilter, fetchJobs)
onMounted(fetchJobs)
</script>

View File

@@ -0,0 +1,88 @@
<template>
<div class="space-y-6 max-w-3xl">
<div class="flex items-center gap-3">
<button @click="$router.back()" class="text-slate-400 hover:text-slate-700"><ArrowLeft :size="20" /></button>
<div>
<h1 class="text-xl font-bold text-slate-900">{{ ticket?.subject ?? '…' }}</h1>
<p class="text-xs text-slate-400 mt-0.5">{{ ticket?.ticket_number }}</p>
</div>
<StatusBadge v-if="ticket" :status="ticket.status" class="ml-auto" />
</div>
<div v-if="loading" class="animate-pulse h-40 bg-white rounded-xl"></div>
<template v-else-if="ticket">
<!-- Original message -->
<div class="bg-white rounded-xl p-6" style="box-shadow:0 2px 8px #0000000A">
<p class="text-sm text-slate-700 whitespace-pre-wrap">{{ ticket.body }}</p>
<p class="text-xs text-slate-400 mt-3">{{ new Date(ticket.created_at).toLocaleString() }}</p>
</div>
<!-- Replies -->
<div v-for="reply in ticket.replies" :key="reply.id"
class="rounded-xl p-5 text-sm"
:class="reply.is_internal ? 'bg-amber-50 border border-amber-200' : 'bg-white'"
style="box-shadow:0 2px 8px #0000000A">
<div class="flex items-center justify-between mb-2">
<span class="font-semibold text-slate-800 text-xs">{{ reply.author_id }}</span>
<span v-if="reply.is_internal" class="text-xs font-semibold text-amber-600 bg-amber-100 px-2 py-0.5 rounded-full">Internal</span>
</div>
<p class="text-slate-700 whitespace-pre-wrap">{{ reply.body }}</p>
<p class="text-xs text-slate-400 mt-2">{{ new Date(reply.created_at).toLocaleString() }}</p>
</div>
<!-- Reply form -->
<div class="bg-white rounded-xl p-6" style="box-shadow:0 2px 8px #0000000A">
<h3 class="text-sm font-semibold text-slate-900 mb-3">Add Reply</h3>
<textarea v-model="replyBody" rows="4" placeholder="Type your reply…"
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 class="flex items-center gap-3 mt-3">
<label v-if="isSuperAdmin" class="flex items-center gap-2 text-sm text-slate-600 cursor-pointer">
<input type="checkbox" v-model="isInternal" class="accent-amber-500" /> Internal note
</label>
<button @click="submitReply" :disabled="!replyBody.trim() || replying"
class="ml-auto px-5 py-2 rounded-lg bg-blue-600 text-white text-sm font-medium hover:bg-blue-700 disabled:opacity-50">
{{ replying ? 'Sending…' : 'Send Reply' }}
</button>
</div>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { useRoute } from 'vue-router'
import { ArrowLeft } from 'lucide-vue-next'
import { getTicket, addTicketReply } from '@/lib/api'
import StatusBadge from '@/components/ui/StatusBadge.vue'
import { useAuthStore } from '@/stores/auth'
import { useToast } from '@/composables/useToast'
const route = useRoute()
const authStore = useAuthStore()
const toast = useToast()
const isSuperAdmin = computed(() => authStore.isSuperAdmin)
const ticket = ref<any>(null)
const loading = ref(true)
const replyBody = ref('')
const isInternal = ref(false)
const replying = ref(false)
async function loadTicket() {
try { ticket.value = await getTicket(route.params.id as string) }
finally { loading.value = false }
}
async function submitReply() {
replying.value = true
try {
await addTicketReply(ticket.value.id, { body: replyBody.value, is_internal: isInternal.value })
replyBody.value = ''
toast.success('Reply sent')
await loadTicket()
} catch { toast.error('Failed to send reply') }
finally { replying.value = false }
}
onMounted(loadTicket)
</script>

View File

@@ -0,0 +1,74 @@
<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 v-if="!isSuperAdmin" @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>
<div class="flex gap-3">
<select v-model="statusFilter" class="border border-slate-200 rounded-lg px-3 py-2 text-sm">
<option value="">All</option>
<option value="open">Open</option>
<option value="in_progress">In Progress</option>
<option value="resolved">Resolved</option>
<option value="closed">Closed</option>
</select>
</div>
<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="tickets.length === 0" class="p-12 text-center text-slate-400">No tickets found</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">Priority</th>
<th class="px-5 py-3">Created</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-slate-600 text-xs">{{ t.category }}</td>
<td class="px-5 py-3"><StatusBadge :status="t.status" /></td>
<td class="px-5 py-3">
<span class="text-xs font-semibold capitalize px-2 py-0.5 rounded-full"
:class="t.priority === 'urgent' ? 'bg-red-100 text-red-700' : t.priority === 'high' ? 'bg-amber-100 text-amber-700' : 'bg-slate-100 text-slate-600'">
{{ t.priority }}
</span>
</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, watch, onMounted, computed } from 'vue'
import { Plus } from 'lucide-vue-next'
import { getTickets } from '@/lib/api'
import StatusBadge from '@/components/ui/StatusBadge.vue'
import { useAuthStore } from '@/stores/auth'
const authStore = useAuthStore()
const isSuperAdmin = computed(() => authStore.isSuperAdmin)
const tickets = ref<any[]>([])
const loading = ref(false)
const statusFilter = ref('')
const showCreate = ref(false)
async function fetchTickets() {
loading.value = true
try { const r = await getTickets({ status: statusFilter.value || undefined }); tickets.value = r.items }
finally { loading.value = false }
}
watch(statusFilter, fetchTickets)
onMounted(fetchTickets)
</script>

View File

@@ -0,0 +1,55 @@
<template>
<div class="space-y-6">
<div class="flex items-center justify-between">
<h1 class="text-2xl font-bold text-slate-900">Hub Users</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" /> Add User
</button>
</div>
<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>
<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">Name</th>
<th class="px-5 py-3">Email</th>
<th class="px-5 py-3">Role</th>
<th class="px-5 py-3">School</th>
<th class="px-5 py-3">Active</th>
<th class="px-5 py-3">Created</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-50">
<tr v-for="u in users" :key="u.id" class="hover:bg-slate-50">
<td class="px-5 py-3 font-medium text-slate-900">{{ u.full_name }}</td>
<td class="px-5 py-3 text-slate-600">{{ u.email }}</td>
<td class="px-5 py-3">
<span class="text-xs font-semibold px-2 py-0.5 rounded-full"
:class="u.role === 'super_admin' ? 'bg-purple-100 text-purple-700' : 'bg-blue-100 text-blue-700'">
{{ u.role }}
</span>
</td>
<td class="px-5 py-3 text-xs text-slate-500">{{ u.school_id || '—' }}</td>
<td class="px-5 py-3">
<span class="text-xs font-semibold px-2 py-0.5 rounded-full" :class="u.is_active ? 'bg-emerald-100 text-emerald-700' : 'bg-slate-100 text-slate-500'">
{{ u.is_active ? 'Active' : 'Inactive' }}
</span>
</td>
<td class="px-5 py-3 text-xs text-slate-500">{{ new Date(u.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 { getUsers } from '@/lib/api'
const users = ref<any[]>([])
const loading = ref(true)
const showCreate = ref(false)
onMounted(async () => { try { const r = await getUsers(); users.value = r.items } finally { loading.value = false } })
</script>

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>

View File

@@ -0,0 +1,125 @@
import { createRouter, createWebHistory } from 'vue-router'
const router = createRouter({
history: createWebHistory(),
routes: [
{ path: '/', redirect: '/dashboard' },
{
path: '/login',
name: 'login',
component: () => import('@/pages/LoginPage.vue'),
meta: { requiresAuth: false },
},
// Super Admin routes
{
path: '/dashboard',
name: 'dashboard',
component: () => import('@/pages/DashboardPage.vue'),
meta: { requiresAuth: true, layout: 'app', role: 'super_admin' },
},
{
path: '/schools',
name: 'schools',
component: () => import('@/pages/SchoolsPage.vue'),
meta: { requiresAuth: true, layout: 'app', role: 'super_admin' },
},
{
path: '/schools/:id',
name: 'school-detail',
component: () => import('@/pages/SchoolDetailPage.vue'),
meta: { requiresAuth: true, layout: 'app', role: 'super_admin' },
},
{
path: '/licenses',
name: 'licenses',
component: () => import('@/pages/LicensesPage.vue'),
meta: { requiresAuth: true, layout: 'app', role: 'super_admin' },
},
{
path: '/sms',
name: 'sms',
component: () => import('@/pages/SmsPage.vue'),
meta: { requiresAuth: true, layout: 'app', role: 'super_admin' },
},
{
path: '/billing',
name: 'billing',
component: () => import('@/pages/BillingPage.vue'),
meta: { requiresAuth: true, layout: 'app', role: 'super_admin' },
},
{
path: '/tickets',
name: 'tickets',
component: () => import('@/pages/TicketsPage.vue'),
meta: { requiresAuth: true, layout: 'app' },
},
{
path: '/tickets/:id',
name: 'ticket-detail',
component: () => import('@/pages/TicketDetailPage.vue'),
meta: { requiresAuth: true, layout: 'app' },
},
{
path: '/users',
name: 'users',
component: () => import('@/pages/UsersPage.vue'),
meta: { requiresAuth: true, layout: 'app', role: 'super_admin' },
},
{
path: '/announcements',
name: 'announcements',
component: () => import('@/pages/AnnouncementsPage.vue'),
meta: { requiresAuth: true, layout: 'app', role: 'super_admin' },
},
// School Portal routes
{
path: '/portal',
name: 'portal',
component: () => import('@/pages/portal/PortalOverviewPage.vue'),
meta: { requiresAuth: true, layout: 'portal' },
},
{
path: '/portal/billing',
name: 'portal-billing',
component: () => import('@/pages/portal/PortalBillingPage.vue'),
meta: { requiresAuth: true, layout: 'portal' },
},
{
path: '/portal/sms',
name: 'portal-sms',
component: () => import('@/pages/portal/PortalSmsPage.vue'),
meta: { requiresAuth: true, layout: 'portal' },
},
{
path: '/portal/tickets',
name: 'portal-tickets',
component: () => import('@/pages/portal/PortalTicketsPage.vue'),
meta: { requiresAuth: true, layout: 'portal' },
},
{
path: '/portal/profile',
name: 'portal-profile',
component: () => import('@/pages/portal/PortalProfilePage.vue'),
meta: { requiresAuth: true, layout: 'portal' },
},
{
path: '/:pathMatch(.*)*',
name: 'not-found',
component: () => import('@/pages/NotFoundPage.vue'),
},
],
})
router.beforeEach((to, _from, next) => {
const token = localStorage.getItem('hub_token')
const role = localStorage.getItem('hub_role')
const requiresAuth = to.meta.requiresAuth !== false
if (requiresAuth && !token) return next('/login')
if (to.name === 'login' && token) {
return next(role === 'super_admin' ? '/dashboard' : '/portal')
}
next()
})
export default router

View File

@@ -0,0 +1,50 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export const useAuthStore = defineStore('auth', () => {
const token = ref<string | null>(null)
const role = ref<string | null>(null)
const userId = ref<string | null>(null)
const fullName = ref<string | null>(null)
const schoolId = ref<string | null>(null)
const isAuthenticated = computed(() => !!token.value)
const isSuperAdmin = computed(() => role.value === 'super_admin')
const isSchoolAdmin = computed(() => role.value === 'school_admin')
function loadFromStorage() {
token.value = localStorage.getItem('hub_token')
role.value = localStorage.getItem('hub_role')
userId.value = localStorage.getItem('hub_user_id')
fullName.value = localStorage.getItem('hub_full_name')
schoolId.value = localStorage.getItem('hub_school_id')
}
function setAuth(data: { access_token: string; role: string; user_id: string; full_name: string; school_id: string | null }) {
token.value = data.access_token
role.value = data.role
userId.value = data.user_id
fullName.value = data.full_name
schoolId.value = data.school_id
localStorage.setItem('hub_token', data.access_token)
localStorage.setItem('hub_role', data.role)
localStorage.setItem('hub_user_id', data.user_id)
localStorage.setItem('hub_full_name', data.full_name)
if (data.school_id) localStorage.setItem('hub_school_id', data.school_id)
}
function logout() {
token.value = null
role.value = null
userId.value = null
fullName.value = null
schoolId.value = null
localStorage.removeItem('hub_token')
localStorage.removeItem('hub_role')
localStorage.removeItem('hub_user_id')
localStorage.removeItem('hub_full_name')
localStorage.removeItem('hub_school_id')
}
return { token, role, userId, fullName, schoolId, isAuthenticated, isSuperAdmin, isSchoolAdmin, loadFromStorage, setAuth, logout }
})

View File

@@ -0,0 +1,15 @@
import type { Config } from 'tailwindcss'
export default {
content: ['./index.html', './src/**/*.{vue,js,ts,jsx,tsx}'],
theme: {
extend: {
fontFamily: { sans: ['Inter', 'system-ui', 'sans-serif'] },
colors: {
sidebar: '#0F172A',
primary: '#3B82F6',
},
},
},
plugins: [],
} satisfies Config

22
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "preserve",
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true,
"paths": { "@/*": ["./src/*"] }
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

17
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,17 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { fileURLToPath, URL } from 'node:url'
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
server: {
proxy: {
'/api': { target: 'http://localhost:8000', changeOrigin: true },
},
},
})