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:
12
frontend/Dockerfile
Normal file
12
frontend/Dockerfile
Normal 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
14
frontend/index.html
Normal 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
10
frontend/nginx.conf
Normal 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
26
frontend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
3
frontend/postcss.config.js
Normal file
3
frontend/postcss.config.js
Normal file
@@ -0,0 +1,3 @@
|
||||
export default {
|
||||
plugins: { tailwindcss: {}, autoprefixer: {} },
|
||||
}
|
||||
26
frontend/src/App.vue
Normal file
26
frontend/src/App.vue
Normal 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>
|
||||
10
frontend/src/assets/main.css
Normal file
10
frontend/src/assets/main.css
Normal 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; }
|
||||
37
frontend/src/components/sidebar/AppSidebar.vue
Normal file
37
frontend/src/components/sidebar/AppSidebar.vue
Normal 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>
|
||||
31
frontend/src/components/sidebar/PortalSidebar.vue
Normal file
31
frontend/src/components/sidebar/PortalSidebar.vue
Normal 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>
|
||||
21
frontend/src/components/sidebar/SidebarItem.vue
Normal file
21
frontend/src/components/sidebar/SidebarItem.vue
Normal 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>
|
||||
31
frontend/src/components/ui/KpiCard.vue
Normal file
31
frontend/src/components/ui/KpiCard.vue
Normal 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>
|
||||
26
frontend/src/components/ui/StatusBadge.vue
Normal file
26
frontend/src/components/ui/StatusBadge.vue
Normal 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>
|
||||
36
frontend/src/components/ui/ToastStack.vue
Normal file
36
frontend/src/components/ui/ToastStack.vue
Normal 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>
|
||||
22
frontend/src/composables/useToast.ts
Normal file
22
frontend/src/composables/useToast.ts
Normal 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 }
|
||||
50
frontend/src/layouts/AppLayout.vue
Normal file
50
frontend/src/layouts/AppLayout.vue
Normal 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>
|
||||
5
frontend/src/layouts/AuthLayout.vue
Normal file
5
frontend/src/layouts/AuthLayout.vue
Normal 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>
|
||||
43
frontend/src/layouts/PortalLayout.vue
Normal file
43
frontend/src/layouts/PortalLayout.vue
Normal 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
90
frontend/src/lib/api.ts
Normal 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
10
frontend/src/main.ts
Normal 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')
|
||||
46
frontend/src/pages/AnnouncementsPage.vue
Normal file
46
frontend/src/pages/AnnouncementsPage.vue
Normal 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>
|
||||
78
frontend/src/pages/BillingPage.vue
Normal file
78
frontend/src/pages/BillingPage.vue
Normal 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>
|
||||
57
frontend/src/pages/DashboardPage.vue
Normal file
57
frontend/src/pages/DashboardPage.vue
Normal 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>
|
||||
41
frontend/src/pages/LicensesPage.vue
Normal file
41
frontend/src/pages/LicensesPage.vue
Normal 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>
|
||||
60
frontend/src/pages/LoginPage.vue
Normal file
60
frontend/src/pages/LoginPage.vue
Normal 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>
|
||||
9
frontend/src/pages/NotFoundPage.vue
Normal file
9
frontend/src/pages/NotFoundPage.vue
Normal 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>
|
||||
76
frontend/src/pages/SchoolDetailPage.vue
Normal file
76
frontend/src/pages/SchoolDetailPage.vue
Normal 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>
|
||||
120
frontend/src/pages/SchoolsPage.vue
Normal file
120
frontend/src/pages/SchoolsPage.vue
Normal 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>
|
||||
56
frontend/src/pages/SmsPage.vue
Normal file
56
frontend/src/pages/SmsPage.vue
Normal 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>
|
||||
88
frontend/src/pages/TicketDetailPage.vue
Normal file
88
frontend/src/pages/TicketDetailPage.vue
Normal 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>
|
||||
74
frontend/src/pages/TicketsPage.vue
Normal file
74
frontend/src/pages/TicketsPage.vue
Normal 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>
|
||||
55
frontend/src/pages/UsersPage.vue
Normal file
55
frontend/src/pages/UsersPage.vue
Normal 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>
|
||||
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>
|
||||
125
frontend/src/router/index.ts
Normal file
125
frontend/src/router/index.ts
Normal 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
|
||||
50
frontend/src/stores/auth.ts
Normal file
50
frontend/src/stores/auth.ts
Normal 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 }
|
||||
})
|
||||
15
frontend/tailwind.config.ts
Normal file
15
frontend/tailwind.config.ts
Normal 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
22
frontend/tsconfig.json
Normal 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" }]
|
||||
}
|
||||
10
frontend/tsconfig.node.json
Normal file
10
frontend/tsconfig.node.json
Normal 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
17
frontend/vite.config.ts
Normal 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 },
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user