- 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
227 lines
8.4 KiB
Vue
227 lines
8.4 KiB
Vue
<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>
|