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:
@@ -5,11 +5,12 @@ from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.database import engine, Base
|
||||
# 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 tickets, users, dashboard, school_portal, announcements, sync, email as email_router
|
||||
from app.routers import search, audit
|
||||
from app.routers import demo_requests as demo_requests_router
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
@@ -48,6 +49,7 @@ app.include_router(sync.router)
|
||||
app.include_router(email_router.router)
|
||||
app.include_router(search.router)
|
||||
app.include_router(audit.router)
|
||||
app.include_router(demo_requests_router.router)
|
||||
|
||||
@app.get("/api/health")
|
||||
async def health():
|
||||
|
||||
25
backend/app/models/demo_request.py
Normal file
25
backend/app/models/demo_request.py
Normal 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),
|
||||
)
|
||||
112
backend/app/routers/demo_requests.py
Normal file
112
backend/app/routers/demo_requests.py
Normal 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()
|
||||
@@ -10,7 +10,7 @@ if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
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
|
||||
|
||||
DATABASE_URL = os.getenv("DATABASE_URL", "postgresql+asyncpg://postgres:postgres@db:5432/taptrack_hub")
|
||||
|
||||
@@ -8,7 +8,7 @@ from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
|
||||
from sqlalchemy import select
|
||||
from app.database import Base
|
||||
# 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.auth.password import hash_password
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
</template>
|
||||
|
||||
<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'
|
||||
|
||||
const navItems = [
|
||||
@@ -32,8 +32,9 @@ const navItems = [
|
||||
{ label: 'Billing', to: '/billing', icon: Receipt },
|
||||
{ label: 'Support', to: '/tickets', icon: Ticket },
|
||||
{ label: 'Users', to: '/users', icon: Users },
|
||||
{ label: 'Announcements', to: '/announcements', icon: Megaphone },
|
||||
{ label: 'Email Logs', to: '/email-logs', icon: Mail },
|
||||
{ label: 'Audit Logs', to: '/audit-logs', icon: Shield },
|
||||
{ label: 'Announcements', to: '/announcements', icon: Megaphone },
|
||||
{ label: 'Demo Requests', to: '/demo-requests', icon: ClipboardList },
|
||||
{ label: 'Email Logs', to: '/email-logs', icon: Mail },
|
||||
{ label: 'Audit Logs', to: '/audit-logs', icon: Shield },
|
||||
]
|
||||
</script>
|
||||
|
||||
@@ -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 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 ─────────────────────────────────────────────────────────────
|
||||
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)
|
||||
|
||||
226
frontend/src/pages/DemoRequestsPage.vue
Normal file
226
frontend/src/pages/DemoRequestsPage.vue
Normal 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>
|
||||
@@ -83,6 +83,12 @@ const router = createRouter({
|
||||
component: () => import('@/pages/AuditLogsPage.vue'),
|
||||
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
|
||||
{
|
||||
path: '/portal',
|
||||
|
||||
Reference in New Issue
Block a user