feat: add Demo Requests module

- New demo_requests table (SQLAlchemy model + Alembic-ready)
- Public POST /api/demo-requests endpoint for the TapTrack website form
- Super-admin GET/PUT/DELETE endpoints to manage leads
- DemoRequestsPage.vue with stats row, filterable table, status updates, notes modal
- Sidebar nav item and route registered
This commit is contained in:
kevin-asprec
2026-03-17 09:50:14 +08:00
parent 56ecdbead3
commit 7d8cc3ca22
9 changed files with 385 additions and 7 deletions

View File

@@ -5,11 +5,12 @@ from fastapi.middleware.cors import CORSMiddleware
from app.database import engine, Base from app.database import engine, Base
# Import all models so Alembic/SQLAlchemy picks them up # Import all models so Alembic/SQLAlchemy picks them up
from app.models import user, school, license, sms, billing, ticket, audit, announcement, email_log, report # noqa from app.models import user, school, license, sms, billing, ticket, audit, announcement, email_log, report, demo_request # noqa
from app.routers import auth, schools, licenses, sms as sms_router, billing as billing_router from app.routers import auth, schools, licenses, sms as sms_router, billing as billing_router
from app.routers import tickets, users, dashboard, school_portal, announcements, sync, email as email_router from app.routers import tickets, users, dashboard, school_portal, announcements, sync, email as email_router
from app.routers import search, audit from app.routers import search, audit
from app.routers import demo_requests as demo_requests_router
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
@@ -48,6 +49,7 @@ app.include_router(sync.router)
app.include_router(email_router.router) app.include_router(email_router.router)
app.include_router(search.router) app.include_router(search.router)
app.include_router(audit.router) app.include_router(audit.router)
app.include_router(demo_requests_router.router)
@app.get("/api/health") @app.get("/api/health")
async def health(): async def health():

View File

@@ -0,0 +1,25 @@
import uuid
from datetime import datetime, timezone
from sqlalchemy import String, DateTime, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class DemoRequest(Base):
__tablename__ = "demo_requests"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
school_name: Mapped[str] = mapped_column(String(255), nullable=False)
contact_person: Mapped[str] = mapped_column(String(255), nullable=False)
phone_or_email: Mapped[str] = mapped_column(String(255), nullable=False)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
status: Mapped[str] = mapped_column(String(50), default="new", nullable=False)
# "new" | "contacted" | "converted" | "dismissed"
submitted_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
)

View File

@@ -0,0 +1,112 @@
"""Demo Requests — public POST from TapTrack Website, super admin manages."""
from datetime import datetime, timezone
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, desc
from app.auth.dependencies import require_super_admin
from app.database import get_db
from app.models.user import HubUser
from app.models.demo_request import DemoRequest
router = APIRouter(prefix="/api/demo-requests", tags=["demo-requests"])
class DemoRequestSubmit(BaseModel):
school: str
contact: str
phone: str
class DemoRequestUpdate(BaseModel):
status: Optional[str] = None # "new" | "contacted" | "converted" | "dismissed"
notes: Optional[str] = None
@router.post("", status_code=201)
async def submit_demo_request(
body: DemoRequestSubmit,
db: AsyncSession = Depends(get_db),
):
"""Public endpoint — called by TapTrack Website form."""
req = DemoRequest(
school_name=body.school,
contact_person=body.contact,
phone_or_email=body.phone,
)
db.add(req)
await db.commit()
return {"ok": True, "id": req.id}
@router.get("")
async def list_demo_requests(
status: Optional[str] = None,
limit: int = 50,
offset: int = 0,
_admin: HubUser = Depends(require_super_admin),
db: AsyncSession = Depends(get_db),
):
"""Super admin only — list all demo requests."""
stmt = select(DemoRequest).order_by(desc(DemoRequest.submitted_at))
if status:
stmt = stmt.where(DemoRequest.status == status)
stmt = stmt.limit(limit).offset(offset)
items = (await db.execute(stmt)).scalars().all()
return [
{
"id": r.id,
"school_name": r.school_name,
"contact_person": r.contact_person,
"phone_or_email": r.phone_or_email,
"status": r.status,
"notes": r.notes,
"submitted_at": r.submitted_at.isoformat(),
"updated_at": r.updated_at.isoformat(),
}
for r in items
]
@router.put("/{req_id}")
async def update_demo_request(
req_id: str,
body: DemoRequestUpdate,
_admin: HubUser = Depends(require_super_admin),
db: AsyncSession = Depends(get_db),
):
"""Super admin only — update status/notes on a demo request."""
req = (await db.execute(select(DemoRequest).where(DemoRequest.id == req_id))).scalar_one_or_none()
if not req:
raise HTTPException(404, detail="Demo request not found")
if body.status is not None:
valid_statuses = {"new", "contacted", "converted", "dismissed"}
if body.status not in valid_statuses:
raise HTTPException(400, detail=f"status must be one of: {', '.join(valid_statuses)}")
req.status = body.status
if body.notes is not None:
req.notes = body.notes
req.updated_at = datetime.now(timezone.utc)
await db.commit()
return {
"id": req.id,
"status": req.status,
"notes": req.notes,
"updated_at": req.updated_at.isoformat(),
}
@router.delete("/{req_id}", status_code=204)
async def delete_demo_request(
req_id: str,
_admin: HubUser = Depends(require_super_admin),
db: AsyncSession = Depends(get_db),
):
"""Super admin only — permanently delete a demo request."""
req = (await db.execute(select(DemoRequest).where(DemoRequest.id == req_id))).scalar_one_or_none()
if not req:
raise HTTPException(404, detail="Demo request not found")
await db.delete(req)
await db.commit()

View File

@@ -10,7 +10,7 @@ if config.config_file_name is not None:
fileConfig(config.config_file_name) fileConfig(config.config_file_name)
from app.database import Base from app.database import Base
from app.models import user, school, license, sms, billing, ticket, audit, announcement, email_log, report # noqa from app.models import user, school, license, sms, billing, ticket, audit, announcement, email_log, report, demo_request # noqa
target_metadata = Base.metadata target_metadata = Base.metadata
DATABASE_URL = os.getenv("DATABASE_URL", "postgresql+asyncpg://postgres:postgres@db:5432/taptrack_hub") DATABASE_URL = os.getenv("DATABASE_URL", "postgresql+asyncpg://postgres:postgres@db:5432/taptrack_hub")

View File

@@ -8,7 +8,7 @@ from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
from sqlalchemy import select from sqlalchemy import select
from app.database import Base from app.database import Base
# Import all models so Base.metadata has the complete schema # Import all models so Base.metadata has the complete schema
from app.models import user, school, license, sms, billing, ticket, audit, announcement, email_log, report # noqa from app.models import user, school, license, sms, billing, ticket, audit, announcement, email_log, report, demo_request # noqa
from app.models.user import HubUser, UserRole from app.models.user import HubUser, UserRole
from app.auth.password import hash_password from app.auth.password import hash_password

View File

@@ -21,7 +21,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { LayoutDashboard, Building2, KeyRound, MessageSquare, Receipt, Ticket, Users, Megaphone, Layers, Mail, Shield } from 'lucide-vue-next' import { LayoutDashboard, Building2, KeyRound, MessageSquare, Receipt, Ticket, Users, Megaphone, Layers, Mail, Shield, ClipboardList } from 'lucide-vue-next'
import SidebarItem from './SidebarItem.vue' import SidebarItem from './SidebarItem.vue'
const navItems = [ const navItems = [
@@ -33,6 +33,7 @@ const navItems = [
{ label: 'Support', to: '/tickets', icon: Ticket }, { label: 'Support', to: '/tickets', icon: Ticket },
{ label: 'Users', to: '/users', icon: Users }, { label: 'Users', to: '/users', icon: Users },
{ label: 'Announcements', to: '/announcements', icon: Megaphone }, { label: 'Announcements', to: '/announcements', icon: Megaphone },
{ label: 'Demo Requests', to: '/demo-requests', icon: ClipboardList },
{ label: 'Email Logs', to: '/email-logs', icon: Mail }, { label: 'Email Logs', to: '/email-logs', icon: Mail },
{ label: 'Audit Logs', to: '/audit-logs', icon: Shield }, { label: 'Audit Logs', to: '/audit-logs', icon: Shield },
] ]

View File

@@ -112,6 +112,12 @@ export const getAuditLogs = (params?: object) => api.get('/audit-logs', { para
export const getEmailLogs = (params?: object) => api.get('/email/logs', { params }).then(r => r.data) export const getEmailLogs = (params?: object) => api.get('/email/logs', { params }).then(r => r.data)
export const sendTestEmail = (to: string) => api.post('/email/test', { to }).then(r => r.data) export const sendTestEmail = (to: string) => api.post('/email/test', { to }).then(r => r.data)
// ── Demo Requests ─────────────────────────────────────────────────────────────
export const getDemoRequests = (params?: object) => api.get('/demo-requests', { params }).then(r => r.data)
export const updateDemoRequest = (id: string, data: { status?: string; notes?: string }) =>
api.put(`/demo-requests/${id}`, data).then(r => r.data)
export const deleteDemoRequest = (id: string) => api.delete(`/demo-requests/${id}`)
// ── School Portal ───────────────────────────────────────────────────────────── // ── School Portal ─────────────────────────────────────────────────────────────
export const getPortalOverview = () => api.get('/portal/overview').then(r => r.data) export const getPortalOverview = () => api.get('/portal/overview').then(r => r.data)
export const getPortalSmsStats = (params?: object) => api.get('/portal/sms-stats', { params }).then(r => r.data) export const getPortalSmsStats = (params?: object) => api.get('/portal/sms-stats', { params }).then(r => r.data)

View File

@@ -0,0 +1,226 @@
<template>
<div class="space-y-6">
<!-- Header -->
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold text-slate-900">Demo Requests</h1>
<p class="text-sm text-slate-500 mt-0.5">Inbound requests from the TapTrack website</p>
</div>
<!-- Status filter -->
<div class="flex items-center gap-2">
<select
v-model="filterStatus"
class="text-sm border border-slate-200 rounded-lg px-3 py-2 bg-white text-slate-700 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="">All statuses</option>
<option value="new">New</option>
<option value="contacted">Contacted</option>
<option value="converted">Converted</option>
<option value="dismissed">Dismissed</option>
</select>
</div>
</div>
<!-- Stats row -->
<div class="grid grid-cols-4 gap-4">
<div
v-for="s in statCards"
:key="s.label"
class="bg-white rounded-xl p-4 flex flex-col gap-1"
style="box-shadow:0 2px 8px #0000000A"
>
<span class="text-xs text-slate-500 font-medium uppercase tracking-wide">{{ s.label }}</span>
<span class="text-2xl font-bold" :class="s.color">{{ s.count }}</span>
</div>
</div>
<!-- Table -->
<div class="bg-white rounded-xl overflow-hidden" style="box-shadow:0 2px 8px #0000000A">
<div v-if="loading" class="animate-pulse h-64 bg-slate-50"></div>
<table v-else class="w-full text-sm">
<thead class="bg-slate-50 border-b border-slate-100">
<tr>
<th class="text-left px-5 py-3 text-xs font-semibold text-slate-500 uppercase tracking-wide">School</th>
<th class="text-left px-5 py-3 text-xs font-semibold text-slate-500 uppercase tracking-wide">Contact</th>
<th class="text-left px-5 py-3 text-xs font-semibold text-slate-500 uppercase tracking-wide">Phone / Email</th>
<th class="text-left px-5 py-3 text-xs font-semibold text-slate-500 uppercase tracking-wide">Status</th>
<th class="text-left px-5 py-3 text-xs font-semibold text-slate-500 uppercase tracking-wide">Submitted</th>
<th class="px-5 py-3"></th>
</tr>
</thead>
<tbody class="divide-y divide-slate-50">
<tr
v-for="r in filtered"
:key="r.id"
class="hover:bg-slate-50 transition-colors"
>
<td class="px-5 py-3.5 font-medium text-slate-900 max-w-[200px] truncate">{{ r.school_name }}</td>
<td class="px-5 py-3.5 text-slate-700">{{ r.contact_person }}</td>
<td class="px-5 py-3.5 text-slate-600">{{ r.phone_or_email }}</td>
<td class="px-5 py-3.5">
<select
:value="r.status"
class="text-xs border rounded-md px-2 py-1 focus:outline-none focus:ring-1 focus:ring-blue-500 cursor-pointer"
:class="statusClass(r.status)"
@change="changeStatus(r, ($event.target as HTMLSelectElement).value)"
>
<option value="new">New</option>
<option value="contacted">Contacted</option>
<option value="converted">Converted</option>
<option value="dismissed">Dismissed</option>
</select>
</td>
<td class="px-5 py-3.5 text-slate-500 text-xs whitespace-nowrap">
{{ formatDate(r.submitted_at) }}
</td>
<td class="px-5 py-3.5 text-right">
<button
@click="openNotes(r)"
title="Notes"
class="text-slate-400 hover:text-blue-600 transition-colors mr-3"
>
<MessageSquare :size="15" />
</button>
<button
@click="confirmDelete(r)"
title="Delete"
class="text-slate-400 hover:text-red-500 transition-colors"
>
<Trash2 :size="15" />
</button>
</td>
</tr>
<tr v-if="filtered.length === 0">
<td colspan="6" class="px-5 py-12 text-center text-slate-400">No demo requests found.</td>
</tr>
</tbody>
</table>
</div>
<!-- Notes modal -->
<div
v-if="notesModal"
class="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4"
@click.self="notesModal = null"
>
<div class="bg-white rounded-2xl w-full max-w-md p-6 space-y-4 shadow-2xl">
<h2 class="text-lg font-bold text-slate-900">Notes {{ notesModal.school_name }}</h2>
<textarea
v-model="notesModal.notes"
rows="5"
placeholder="Add internal notes about this lead…"
class="w-full border border-slate-200 rounded-lg px-3 py-2.5 text-sm text-slate-700 focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none"
></textarea>
<div class="flex justify-end gap-3">
<button @click="notesModal = null" class="px-4 py-2 text-sm text-slate-600 hover:text-slate-900">Cancel</button>
<button
@click="saveNotes"
class="px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700"
>Save Notes</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { MessageSquare, Trash2 } from 'lucide-vue-next'
import { getDemoRequests, updateDemoRequest, deleteDemoRequest } from '@/lib/api'
import { useToast } from '@/composables/useToast'
const toast = useToast()
interface DemoRequest {
id: string
school_name: string
contact_person: string
phone_or_email: string
status: string
notes: string | null
submitted_at: string
updated_at: string
}
const requests = ref<DemoRequest[]>([])
const loading = ref(true)
const filterStatus = ref('')
const notesModal = ref<DemoRequest | null>(null)
onMounted(async () => {
try {
requests.value = await getDemoRequests()
} finally {
loading.value = false
}
})
const filtered = computed(() => {
if (!filterStatus.value) return requests.value
return requests.value.filter(r => r.status === filterStatus.value)
})
const statCards = computed(() => [
{ label: 'Total', count: requests.value.length, color: 'text-slate-800' },
{ label: 'New', count: requests.value.filter(r => r.status === 'new').length, color: 'text-blue-600' },
{ label: 'Contacted', count: requests.value.filter(r => r.status === 'contacted').length, color: 'text-yellow-600' },
{ label: 'Converted', count: requests.value.filter(r => r.status === 'converted').length, color: 'text-green-600' },
])
function statusClass(status: string) {
const map: Record<string, string> = {
new: 'border-blue-200 bg-blue-50 text-blue-700',
contacted: 'border-yellow-200 bg-yellow-50 text-yellow-700',
converted: 'border-green-200 bg-green-50 text-green-700',
dismissed: 'border-slate-200 bg-slate-50 text-slate-500',
}
return map[status] ?? 'border-slate-200 bg-slate-50 text-slate-600'
}
function formatDate(iso: string) {
return new Date(iso).toLocaleDateString('en-PH', {
month: 'short', day: 'numeric', year: 'numeric',
hour: '2-digit', minute: '2-digit',
})
}
async function changeStatus(req: DemoRequest, newStatus: string) {
try {
await updateDemoRequest(req.id, { status: newStatus })
req.status = newStatus
toast.success('Status updated')
} catch {
toast.error('Failed to update status')
}
}
function openNotes(req: DemoRequest) {
// Clone so cancel doesn't mutate
notesModal.value = { ...req, notes: req.notes ?? '' }
}
async function saveNotes() {
if (!notesModal.value) return
try {
await updateDemoRequest(notesModal.value.id, { notes: notesModal.value.notes ?? '' })
const target = requests.value.find(r => r.id === notesModal.value!.id)
if (target) target.notes = notesModal.value.notes
toast.success('Notes saved')
notesModal.value = null
} catch {
toast.error('Failed to save notes')
}
}
async function confirmDelete(req: DemoRequest) {
if (!confirm(`Delete demo request from "${req.school_name}"?`)) return
try {
await deleteDemoRequest(req.id)
requests.value = requests.value.filter(r => r.id !== req.id)
toast.success('Deleted')
} catch {
toast.error('Failed to delete')
}
}
</script>

View File

@@ -83,6 +83,12 @@ const router = createRouter({
component: () => import('@/pages/AuditLogsPage.vue'), component: () => import('@/pages/AuditLogsPage.vue'),
meta: { requiresAuth: true, layout: 'app', role: 'super_admin' }, meta: { requiresAuth: true, layout: 'app', role: 'super_admin' },
}, },
{
path: '/demo-requests',
name: 'demo-requests',
component: () => import('@/pages/DemoRequestsPage.vue'),
meta: { requiresAuth: true, layout: 'app', role: 'super_admin' },
},
// School Portal routes // School Portal routes
{ {
path: '/portal', path: '/portal',