feat(phases-10-15): complete TapTrack Hub v1.0
Phase 10 — Support Ticket System:
- tickets.py router: SLA status (on_track/at_risk/breached/responded), email
notifications on create+reply via background threads, school_name in list,
priority filter, bulk-close endpoint
- tasks/tickets.py: escalate_stale Celery task (48h→high, 72h no reply→urgent)
- worker.py: escalate_stale scheduled every hour
- templates/email/ticket_notification.html: HTML ticket notification email
- TicketsPage.vue: status tabs, SLA badge, priority badge, school name column,
checkbox bulk-close, pagination
- TicketDetailPage.vue: inline priority/status/assignee selectors, SLA timer,
internal note lock icon, closed-ticket guard
Phase 11 — Monthly Report Generation:
- models/report.py: MonthlyReport + SchoolMonthlyStats ORM models
- tasks/reports.py: send_monthly_reports enhanced with SMS stats, attendance
data, invoice summary, stores MonthlyReport record per school per month
Phase 12 — On-Prem Monthly Report Pull:
- tasks/reports.py: pull_monthly_stats task — httpx GET to each school's
hub_base_url, upserts SchoolMonthlyStats; runs 1st at 5am
- worker.py: pull_monthly_stats scheduled 1st at 5am
Phase 13 — Feature Flags + Suspension:
- models/school.py: hub_base_url, feature_overrides (JSON), onboarding_completed_at
- routers/schools.py: PUT /{id}/feature-overrides endpoint
- routers/sync.py: _tier_features() merges school.feature_overrides into poll config
Phase 14 — Onboarding Wizard + Welcome Email:
- tasks/onboarding.py: send_welcome_email Celery task with license key
- routers/schools.py: auto-trigger welcome email on POST /schools,
POST /{id}/activate (status→active + onboarding_completed_at),
POST /{id}/resend-welcome
Phase 15 — UX Polish + Ops Tools:
- routers/search.py: GET /api/search?q= (schools + invoices + tickets, 5 each)
- routers/audit.py: GET /api/audit-logs (paginated, filterable)
- AppLayout.vue: global search bar with debounced dropdown, result navigation
- AuditLogsPage.vue: new page with filter + pagination
- AppSidebar.vue: Audit Logs nav item added
- router/index.ts: /audit-logs route
- api.ts: globalSearch, getAuditLogs, activateSchool, resendWelcomeEmail,
updateFeatureOverrides, bulkCloseTickets
Deployment:
- docker-compose.yml: x-backend-env anchor (DRY), PDF_DIR env var,
seed service (one-shot python seed.py on first boot)
- migrations/003_phases11_15.py: monthly_reports, school_monthly_stats tables
+ schools hub_base_url/feature_overrides/onboarding_completed_at columns
This commit is contained in:
@@ -21,7 +21,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { LayoutDashboard, Building2, KeyRound, MessageSquare, Receipt, Ticket, Users, Megaphone, Layers, Mail } from 'lucide-vue-next'
|
||||
import { LayoutDashboard, Building2, KeyRound, MessageSquare, Receipt, Ticket, Users, Megaphone, Layers, Mail, Shield } from 'lucide-vue-next'
|
||||
import SidebarItem from './SidebarItem.vue'
|
||||
|
||||
const navItems = [
|
||||
@@ -34,5 +34,6 @@ const navItems = [
|
||||
{ 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 },
|
||||
]
|
||||
</script>
|
||||
|
||||
@@ -3,13 +3,47 @@
|
||||
<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">
|
||||
<header class="h-14 bg-white border-b border-slate-200 flex items-center justify-between px-6 shrink-0 gap-4">
|
||||
<div class="text-sm text-slate-500 shrink-0">
|
||||
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">
|
||||
<!-- Global search -->
|
||||
<div class="relative flex-1 max-w-xs">
|
||||
<Search :size="14" class="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 pointer-events-none" />
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
@focus="showSearch = true"
|
||||
@blur="setTimeout(() => showSearch = false, 200)"
|
||||
@keydown.escape="showSearch = false; searchQuery = ''"
|
||||
type="text"
|
||||
placeholder="Search schools, invoices, tickets…"
|
||||
class="w-full pl-8 pr-3 py-1.5 text-sm border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 bg-slate-50"
|
||||
/>
|
||||
<!-- Results dropdown -->
|
||||
<div v-if="showSearch && searchQuery.length >= 2 && searchResults"
|
||||
class="absolute top-full mt-1 left-0 right-0 bg-white rounded-xl shadow-lg border border-slate-200 z-50 overflow-hidden">
|
||||
<template v-if="hasResults">
|
||||
<template v-for="(group, key) in searchResults" :key="key">
|
||||
<template v-if="group.length > 0">
|
||||
<p class="px-3 py-1.5 text-xs font-semibold text-slate-400 uppercase tracking-wide bg-slate-50">{{ key }}</p>
|
||||
<button v-for="item in group" :key="item.id"
|
||||
@click="navigate(item)"
|
||||
class="w-full text-left px-4 py-2.5 hover:bg-blue-50 text-sm flex items-center gap-3 transition-colors">
|
||||
<span class="font-medium text-slate-900 truncate">{{ item.name || item.invoice_number || item.subject }}</span>
|
||||
<span class="ml-auto text-xs px-1.5 py-0.5 rounded-full shrink-0"
|
||||
:class="item.status === 'active' || item.status === 'paid' ? 'bg-emerald-100 text-emerald-700' : 'bg-slate-100 text-slate-500'">
|
||||
{{ item.status }}
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
</template>
|
||||
</template>
|
||||
<p v-else class="px-4 py-3 text-sm text-slate-400 text-center">No results</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 shrink-0">
|
||||
<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" />
|
||||
@@ -25,24 +59,52 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { LogOut } from 'lucide-vue-next'
|
||||
import { LogOut, Search } from 'lucide-vue-next'
|
||||
import AppSidebar from '@/components/sidebar/AppSidebar.vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { globalSearch } from '@/lib/api'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const searchQuery = ref('')
|
||||
const showSearch = ref(false)
|
||||
const searchResults = ref<any>(null)
|
||||
|
||||
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',
|
||||
users: 'Users', announcements: 'Announcements', 'email-logs': 'Email Logs',
|
||||
'audit-logs': 'Audit Logs',
|
||||
}
|
||||
const pageTitle = computed(() => pageTitles[route.name as string] ?? '')
|
||||
|
||||
const hasResults = computed(() => searchResults.value &&
|
||||
Object.values(searchResults.value).some((g: any) => g.length > 0)
|
||||
)
|
||||
|
||||
let debounce: any = null
|
||||
watch(searchQuery, (q) => {
|
||||
clearTimeout(debounce)
|
||||
if (q.length < 2) { searchResults.value = null; return }
|
||||
debounce = setTimeout(async () => {
|
||||
try { searchResults.value = await globalSearch(q) } catch { searchResults.value = null }
|
||||
}, 300)
|
||||
})
|
||||
|
||||
function navigate(item: any) {
|
||||
showSearch.value = false
|
||||
searchQuery.value = ''
|
||||
searchResults.value = null
|
||||
if (item.type === 'school') router.push(`/schools/${item.id}`)
|
||||
else if (item.type === 'invoice') router.push('/billing')
|
||||
else if (item.type === 'ticket') router.push(`/tickets/${item.id}`)
|
||||
}
|
||||
|
||||
function logout() {
|
||||
authStore.logout()
|
||||
router.push('/login')
|
||||
|
||||
@@ -51,6 +51,10 @@ export const createSchool = (data: object) => api.post('/schools', data).then(r
|
||||
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)
|
||||
export const activateSchool = (id: string) => api.post(`/schools/${id}/activate`).then(r => r.data)
|
||||
export const resendWelcomeEmail = (id: string) => api.post(`/schools/${id}/resend-welcome`).then(r => r.data)
|
||||
export const updateFeatureOverrides = (id: string, overrides: object) =>
|
||||
api.put(`/schools/${id}/feature-overrides`, { overrides }).then(r => r.data)
|
||||
|
||||
// ── Licenses ──────────────────────────────────────────────────────────────────
|
||||
export const getLicenses = () => api.get('/licenses').then(r => r.data)
|
||||
@@ -88,6 +92,7 @@ export const getTicket = (id: string) => api.get(`/tickets/${id}`).then(r => r.d
|
||||
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)
|
||||
export const bulkCloseTickets = (ids: string[]) => api.post('/tickets/bulk-close', { ticket_ids: ids }).then(r => r.data)
|
||||
|
||||
// ── Users ─────────────────────────────────────────────────────────────────────
|
||||
export const getUsers = (params?: object) => api.get('/users', { params }).then(r => r.data)
|
||||
@@ -99,6 +104,10 @@ 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}`)
|
||||
|
||||
// ── Search + Audit ────────────────────────────────────────────────────────────
|
||||
export const globalSearch = (q: string) => api.get('/search', { params: { q } }).then(r => r.data)
|
||||
export const getAuditLogs = (params?: object) => api.get('/audit-logs', { params }).then(r => r.data)
|
||||
|
||||
// ── Email Logs ────────────────────────────────────────────────────────────────
|
||||
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)
|
||||
|
||||
88
frontend/src/pages/AuditLogsPage.vue
Normal file
88
frontend/src/pages/AuditLogsPage.vue
Normal file
@@ -0,0 +1,88 @@
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<h1 class="text-2xl font-bold text-slate-900">Audit Logs</h1>
|
||||
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<input v-model="actionFilter" type="text" placeholder="Filter by action…"
|
||||
class="border border-slate-200 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 w-44" />
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-xl overflow-hidden" style="box-shadow:0 2px 8px #0000000A">
|
||||
<div class="flex items-center justify-between px-5 py-4 border-b border-slate-100">
|
||||
<h2 class="text-base font-semibold text-slate-900">Activity</h2>
|
||||
<span class="text-xs text-slate-400">{{ total }} records</span>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="p-8 text-center text-sm text-slate-400 animate-pulse">Loading…</div>
|
||||
<div v-else-if="logs.length === 0" class="flex flex-col items-center justify-center py-14 text-slate-400">
|
||||
<Shield :size="36" class="mb-3 opacity-30" />
|
||||
<p class="font-medium text-sm">No audit records 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">Time</th>
|
||||
<th class="px-5 py-3">Actor</th>
|
||||
<th class="px-5 py-3">Action</th>
|
||||
<th class="px-5 py-3">Entity</th>
|
||||
<th class="px-5 py-3">IP</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-50">
|
||||
<tr v-for="log in logs" :key="log.id" class="hover:bg-slate-50">
|
||||
<td class="px-5 py-3 text-xs text-slate-500 whitespace-nowrap">
|
||||
{{ new Date(log.created_at).toLocaleString('en-PH') }}
|
||||
</td>
|
||||
<td class="px-5 py-3 text-xs text-slate-700">{{ log.actor_email || '—' }}</td>
|
||||
<td class="px-5 py-3">
|
||||
<span class="font-mono text-xs bg-slate-100 text-slate-700 px-2 py-0.5 rounded">{{ log.action }}</span>
|
||||
</td>
|
||||
<td class="px-5 py-3 text-xs text-slate-500">
|
||||
<span v-if="log.entity_type" class="capitalize">{{ log.entity_type }}</span>
|
||||
<span v-if="log.entity_id" class="font-mono text-xs ml-1 text-slate-400">{{ log.entity_id.slice(0,8) }}…</span>
|
||||
</td>
|
||||
<td class="px-5 py-3 text-xs font-mono text-slate-400">{{ log.ip_address || '—' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div v-if="total > perPage" class="px-5 py-3 border-t border-slate-100 flex items-center justify-between text-sm text-slate-600">
|
||||
<span class="text-xs text-slate-400">Showing {{ (page-1)*perPage+1 }}–{{ Math.min(page*perPage,total) }} of {{ total }}</span>
|
||||
<div class="flex gap-2">
|
||||
<button :disabled="page<=1" @click="page--;fetchLogs()" class="px-3 py-1.5 border border-slate-200 rounded-lg text-sm hover:bg-slate-50 disabled:opacity-40">Prev</button>
|
||||
<button :disabled="page*perPage>=total" @click="page++;fetchLogs()" class="px-3 py-1.5 border border-slate-200 rounded-lg text-sm hover:bg-slate-50 disabled:opacity-40">Next</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onMounted } from 'vue'
|
||||
import { Shield } from 'lucide-vue-next'
|
||||
import { getAuditLogs } from '@/lib/api'
|
||||
|
||||
const logs = ref<any[]>([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const perPage = 50
|
||||
const loading = ref(false)
|
||||
const actionFilter = ref('')
|
||||
|
||||
let debounce: any = null
|
||||
watch(actionFilter, () => {
|
||||
clearTimeout(debounce)
|
||||
debounce = setTimeout(() => { page.value = 1; fetchLogs() }, 300)
|
||||
})
|
||||
|
||||
async function fetchLogs() {
|
||||
loading.value = true
|
||||
try {
|
||||
const r = await getAuditLogs({ page: page.value, per_page: perPage, action: actionFilter.value || undefined })
|
||||
logs.value = r.items; total.value = r.total
|
||||
} finally { loading.value = false }
|
||||
}
|
||||
|
||||
onMounted(fetchLogs)
|
||||
</script>
|
||||
@@ -1,20 +1,80 @@
|
||||
<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>
|
||||
<!-- Header -->
|
||||
<div class="flex items-center gap-3 flex-wrap">
|
||||
<button @click="$router.back()" class="text-slate-400 hover:text-slate-700 shrink-0">
|
||||
<ArrowLeft :size="20" />
|
||||
</button>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h1 class="text-xl font-bold text-slate-900 truncate">{{ ticket?.subject ?? '…' }}</h1>
|
||||
<p class="text-xs text-slate-400 mt-0.5">{{ ticket?.ticket_number }} · {{ ticket?.school_name }}</p>
|
||||
</div>
|
||||
<StatusBadge v-if="ticket" :status="ticket.status" class="ml-auto" />
|
||||
<StatusBadge v-if="ticket" :status="ticket.status" />
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="animate-pulse h-40 bg-white rounded-xl"></div>
|
||||
<template v-else-if="ticket">
|
||||
|
||||
<!-- Meta row -->
|
||||
<div class="bg-white rounded-xl p-4 flex flex-wrap gap-6 text-sm" style="box-shadow:0 2px 8px #0000000A">
|
||||
<!-- Priority -->
|
||||
<div>
|
||||
<p class="text-xs text-slate-400 font-semibold uppercase tracking-wide mb-1">Priority</p>
|
||||
<template v-if="isSuperAdmin">
|
||||
<select v-model="editPriority" @change="saveField('priority', editPriority)"
|
||||
class="border border-slate-200 rounded-lg px-2 py-1 text-xs focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
<option value="low">Low</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="high">High</option>
|
||||
<option value="urgent">Urgent</option>
|
||||
</select>
|
||||
</template>
|
||||
<span v-else class="text-xs font-semibold capitalize px-2 py-0.5 rounded-full"
|
||||
:class="priorityClass(ticket.priority)">{{ ticket.priority }}</span>
|
||||
</div>
|
||||
<!-- Status -->
|
||||
<div>
|
||||
<p class="text-xs text-slate-400 font-semibold uppercase tracking-wide mb-1">Status</p>
|
||||
<template v-if="isSuperAdmin">
|
||||
<select v-model="editStatus" @change="saveField('status', editStatus)"
|
||||
class="border border-slate-200 rounded-lg px-2 py-1 text-xs focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
<option value="open">Open</option>
|
||||
<option value="in_progress">In Progress</option>
|
||||
<option value="resolved">Resolved</option>
|
||||
<option value="closed">Closed</option>
|
||||
</select>
|
||||
</template>
|
||||
<StatusBadge v-else :status="ticket.status" />
|
||||
</div>
|
||||
<!-- SLA -->
|
||||
<div>
|
||||
<p class="text-xs text-slate-400 font-semibold uppercase tracking-wide mb-1">SLA</p>
|
||||
<span class="text-xs font-semibold px-2 py-0.5 rounded-full" :class="slaClass(ticket.sla_status)">
|
||||
{{ slaLabel(ticket.sla_status) }}
|
||||
</span>
|
||||
<p class="text-xs text-slate-400 mt-0.5">Opened {{ openedAgo }}</p>
|
||||
</div>
|
||||
<!-- Assignee (super admin only) -->
|
||||
<div v-if="isSuperAdmin">
|
||||
<p class="text-xs text-slate-400 font-semibold uppercase tracking-wide mb-1">Assignee</p>
|
||||
<select v-model="editAssignee" @change="saveField('assigned_to', editAssignee || null)"
|
||||
class="border border-slate-200 rounded-lg px-2 py-1 text-xs focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
<option value="">Unassigned</option>
|
||||
<option v-for="u in admins" :key="u.id" :value="u.id">{{ u.full_name }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<!-- Category -->
|
||||
<div>
|
||||
<p class="text-xs text-slate-400 font-semibold uppercase tracking-wide mb-1">Category</p>
|
||||
<span class="text-xs text-slate-600 capitalize">{{ ticket.category }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Original message -->
|
||||
<div class="bg-white rounded-xl p-6" style="box-shadow:0 2px 8px #0000000A">
|
||||
<p class="text-xs text-slate-400 font-semibold mb-2">Original Message</p>
|
||||
<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>
|
||||
<p class="text-xs text-slate-400 mt-3">{{ new Date(ticket.created_at).toLocaleString('en-PH') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Replies -->
|
||||
@@ -23,21 +83,26 @@
|
||||
: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>
|
||||
<span class="font-semibold text-slate-800 text-xs">{{ reply.author_id === authStore.userId ? 'You' : (reply.is_internal ? 'Support (Internal)' : 'Support') }}</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<span v-if="reply.is_internal" class="flex items-center gap-1 text-xs font-semibold text-amber-600 bg-amber-100 px-2 py-0.5 rounded-full">
|
||||
<Lock :size="10" /> Internal Note
|
||||
</span>
|
||||
<span class="text-xs text-slate-400">{{ new Date(reply.created_at).toLocaleString('en-PH') }}</span>
|
||||
</div>
|
||||
</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">
|
||||
<div v-if="ticket.status !== 'closed'" 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
|
||||
<input type="checkbox" v-model="isInternal" class="accent-amber-500" />
|
||||
<Lock :size="13" class="text-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">
|
||||
@@ -45,15 +110,19 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="bg-slate-50 rounded-xl p-4 text-center text-sm text-slate-400">
|
||||
This ticket is closed. No further replies can be added.
|
||||
</div>
|
||||
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { ArrowLeft } from 'lucide-vue-next'
|
||||
import { getTicket, addTicketReply } from '@/lib/api'
|
||||
import { ArrowLeft, Lock } from 'lucide-vue-next'
|
||||
import { getTicket, addTicketReply, updateTicket, getUsers } from '@/lib/api'
|
||||
import StatusBadge from '@/components/ui/StatusBadge.vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
@@ -62,15 +131,40 @@ 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)
|
||||
const admins = ref<any[]>([])
|
||||
const editPriority = ref('')
|
||||
const editStatus = ref('')
|
||||
const editAssignee = ref('')
|
||||
|
||||
const openedAgo = computed(() => {
|
||||
if (!ticket.value) return ''
|
||||
const h = Math.floor((Date.now() - new Date(ticket.value.created_at).getTime()) / 3600000)
|
||||
if (h < 24) return `${h}h ago`
|
||||
return `${Math.floor(h / 24)}d ago`
|
||||
})
|
||||
|
||||
async function loadTicket() {
|
||||
try { ticket.value = await getTicket(route.params.id as string) }
|
||||
finally { loading.value = false }
|
||||
loading.value = true
|
||||
try {
|
||||
ticket.value = await getTicket(route.params.id as string)
|
||||
editPriority.value = ticket.value.priority
|
||||
editStatus.value = ticket.value.status
|
||||
editAssignee.value = ticket.value.assigned_to || ''
|
||||
} finally { loading.value = false }
|
||||
}
|
||||
|
||||
async function saveField(field: string, value: any) {
|
||||
try {
|
||||
await updateTicket(ticket.value.id, { [field]: value })
|
||||
toast.success('Updated')
|
||||
await loadTicket()
|
||||
} catch { toast.error('Update failed') }
|
||||
}
|
||||
|
||||
async function submitReply() {
|
||||
@@ -78,11 +172,27 @@ async function submitReply() {
|
||||
try {
|
||||
await addTicketReply(ticket.value.id, { body: replyBody.value, is_internal: isInternal.value })
|
||||
replyBody.value = ''
|
||||
isInternal.value = false
|
||||
toast.success('Reply sent')
|
||||
await loadTicket()
|
||||
} catch { toast.error('Failed to send reply') }
|
||||
finally { replying.value = false }
|
||||
}
|
||||
|
||||
onMounted(loadTicket)
|
||||
function priorityClass(p: string) {
|
||||
return { urgent: 'bg-red-100 text-red-700', high: 'bg-amber-100 text-amber-700', medium: 'bg-blue-100 text-blue-700', low: 'bg-slate-100 text-slate-500' }[p] ?? ''
|
||||
}
|
||||
function slaClass(s: string) {
|
||||
return { on_track: 'bg-emerald-100 text-emerald-700', at_risk: 'bg-amber-100 text-amber-700', breached: 'bg-red-100 text-red-700', responded: 'bg-blue-100 text-blue-700', resolved: 'bg-slate-100 text-slate-400' }[s] ?? ''
|
||||
}
|
||||
function slaLabel(s: string) {
|
||||
return { on_track: 'On Track', at_risk: 'At Risk', breached: 'Breached', responded: 'Responded', resolved: 'Resolved' }[s] ?? s
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadTicket()
|
||||
if (isSuperAdmin.value) {
|
||||
try { const r = await getUsers({ per_page: 100 }); admins.value = r.items } catch {}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,74 +1,157 @@
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center justify-between flex-wrap gap-3">
|
||||
<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
|
||||
<div class="flex items-center gap-3">
|
||||
<button v-if="isSuperAdmin && selectedIds.length > 0"
|
||||
@click="doBulkClose" :disabled="closing"
|
||||
class="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-slate-700 text-white text-sm font-medium hover:bg-slate-800 disabled:opacity-50">
|
||||
<X :size="14" /> Close {{ selectedIds.length }} Selected
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button v-for="tab in statusTabs" :key="tab.value"
|
||||
@click="statusFilter = tab.value; page = 1; fetchTickets()"
|
||||
class="px-3 py-1.5 rounded-lg text-sm font-medium transition-colors"
|
||||
:class="statusFilter === tab.value ? 'bg-blue-600 text-white' : 'bg-white border border-slate-200 text-slate-600 hover:bg-slate-50'">
|
||||
{{ tab.label }}
|
||||
</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>
|
||||
|
||||
<!-- Table -->
|
||||
<div class="bg-white rounded-xl overflow-hidden" style="box-shadow:0 2px 8px #0000000A">
|
||||
<div class="flex items-center justify-between px-5 py-4 border-b border-slate-100">
|
||||
<h2 class="text-base font-semibold text-slate-900">Tickets</h2>
|
||||
<span class="text-xs text-slate-400">{{ total }} ticket{{ total !== 1 ? 's' : '' }}</span>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<div v-else-if="tickets.length === 0"
|
||||
class="flex flex-col items-center justify-center py-14 text-slate-400">
|
||||
<Ticket :size="36" class="mb-3 opacity-30" />
|
||||
<p class="font-medium text-sm">No tickets 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 v-if="isSuperAdmin" class="px-4 py-3 w-8">
|
||||
<input type="checkbox" @change="toggleAll" :checked="allSelected" class="accent-blue-600" />
|
||||
</th>
|
||||
<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 v-if="isSuperAdmin" class="px-5 py-3">School</th>
|
||||
<th class="px-5 py-3">Status</th>
|
||||
<th class="px-5 py-3">Priority</th>
|
||||
<th class="px-5 py-3">SLA</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}`)">
|
||||
<tr v-for="t in tickets" :key="t.id"
|
||||
class="hover:bg-slate-50 cursor-pointer transition-colors"
|
||||
:class="t.priority === 'urgent' ? 'bg-red-50/30' : ''"
|
||||
@click.exact="$router.push(`/tickets/${t.id}`)">
|
||||
<td v-if="isSuperAdmin" class="px-4 py-3 w-8" @click.stop>
|
||||
<input type="checkbox" :value="t.id" v-model="selectedIds" class="accent-blue-600" />
|
||||
</td>
|
||||
<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 v-if="isSuperAdmin" class="px-5 py-3 text-xs text-slate-500">{{ t.school_name || '—' }}</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>
|
||||
:class="priorityClass(t.priority)">{{ t.priority }}</span>
|
||||
</td>
|
||||
<td class="px-5 py-3">
|
||||
<span class="text-xs font-semibold px-2 py-0.5 rounded-full"
|
||||
:class="slaClass(t.sla_status)">{{ slaLabel(t.sla_status) }}</span>
|
||||
</td>
|
||||
<td class="px-5 py-3 text-xs text-slate-500">
|
||||
{{ new Date(t.created_at).toLocaleDateString('en-PH', { month: 'short', day: 'numeric' }) }}
|
||||
</td>
|
||||
<td class="px-5 py-3 text-xs text-slate-500">{{ new Date(t.created_at).toLocaleDateString() }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div v-if="total > perPage" class="px-5 py-3 border-t border-slate-100 flex items-center justify-between text-sm text-slate-600">
|
||||
<span class="text-xs text-slate-400">Showing {{ (page-1)*perPage+1 }}–{{ Math.min(page*perPage,total) }} of {{ total }}</span>
|
||||
<div class="flex gap-2">
|
||||
<button :disabled="page<=1" @click="page--;fetchTickets()" class="px-3 py-1.5 border border-slate-200 rounded-lg text-sm hover:bg-slate-50 disabled:opacity-40">Prev</button>
|
||||
<button :disabled="page*perPage>=total" @click="page++;fetchTickets()" class="px-3 py-1.5 border border-slate-200 rounded-lg text-sm hover:bg-slate-50 disabled:opacity-40">Next</button>
|
||||
</div>
|
||||
</div>
|
||||
</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 { ref, computed, watch, onMounted } from 'vue'
|
||||
import { X, Ticket } from 'lucide-vue-next'
|
||||
import { getTickets, bulkCloseTickets } from '@/lib/api'
|
||||
import StatusBadge from '@/components/ui/StatusBadge.vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const toast = useToast()
|
||||
const isSuperAdmin = computed(() => authStore.isSuperAdmin)
|
||||
|
||||
const tickets = ref<any[]>([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const perPage = 25
|
||||
const loading = ref(false)
|
||||
const closing = ref(false)
|
||||
const statusFilter = ref('')
|
||||
const showCreate = ref(false)
|
||||
const selectedIds = ref<string[]>([])
|
||||
|
||||
const statusTabs = [
|
||||
{ label: 'All', value: '' }, { label: 'Open', value: 'open' },
|
||||
{ label: 'In Progress', value: 'in_progress' }, { label: 'Resolved', value: 'resolved' },
|
||||
{ label: 'Closed', value: 'closed' },
|
||||
]
|
||||
|
||||
const allSelected = computed(() => tickets.value.length > 0 && tickets.value.every(t => selectedIds.value.includes(t.id)))
|
||||
function toggleAll(e: Event) {
|
||||
const cb = e.target as HTMLInputElement
|
||||
selectedIds.value = cb.checked ? tickets.value.map(t => t.id) : []
|
||||
}
|
||||
|
||||
async function fetchTickets() {
|
||||
loading.value = true
|
||||
try { const r = await getTickets({ status: statusFilter.value || undefined }); tickets.value = r.items }
|
||||
finally { loading.value = false }
|
||||
selectedIds.value = []
|
||||
try {
|
||||
const r = await getTickets({ status: statusFilter.value || undefined, page: page.value, per_page: perPage })
|
||||
tickets.value = r.items; total.value = r.total
|
||||
} finally { loading.value = false }
|
||||
}
|
||||
watch(statusFilter, fetchTickets)
|
||||
|
||||
async function doBulkClose() {
|
||||
if (!selectedIds.value.length) return
|
||||
closing.value = true
|
||||
try {
|
||||
const r = await bulkCloseTickets(selectedIds.value)
|
||||
toast.success(`Closed ${r.closed} ticket${r.closed !== 1 ? 's' : ''}`)
|
||||
selectedIds.value = []
|
||||
fetchTickets()
|
||||
} catch { toast.error('Failed to close tickets') }
|
||||
finally { closing.value = false }
|
||||
}
|
||||
|
||||
function priorityClass(p: string) {
|
||||
return { urgent: 'bg-red-100 text-red-700', high: 'bg-amber-100 text-amber-700', medium: 'bg-blue-100 text-blue-700', low: 'bg-slate-100 text-slate-500' }[p] ?? 'bg-slate-100 text-slate-500'
|
||||
}
|
||||
function slaClass(s: string) {
|
||||
return { on_track: 'bg-emerald-100 text-emerald-700', at_risk: 'bg-amber-100 text-amber-700', breached: 'bg-red-100 text-red-700', responded: 'bg-blue-100 text-blue-700', resolved: 'bg-slate-100 text-slate-400' }[s] ?? 'bg-slate-100 text-slate-500'
|
||||
}
|
||||
function slaLabel(s: string) {
|
||||
return { on_track: 'On Track', at_risk: 'At Risk', breached: 'Breached', responded: 'Responded', resolved: 'Resolved' }[s] ?? s
|
||||
}
|
||||
|
||||
onMounted(fetchTickets)
|
||||
</script>
|
||||
|
||||
@@ -77,6 +77,12 @@ const router = createRouter({
|
||||
component: () => import('@/pages/EmailLogsPage.vue'),
|
||||
meta: { requiresAuth: true, layout: 'app', role: 'super_admin' },
|
||||
},
|
||||
{
|
||||
path: '/audit-logs',
|
||||
name: 'audit-logs',
|
||||
component: () => import('@/pages/AuditLogsPage.vue'),
|
||||
meta: { requiresAuth: true, layout: 'app', role: 'super_admin' },
|
||||
},
|
||||
// School Portal routes
|
||||
{
|
||||
path: '/portal',
|
||||
|
||||
Reference in New Issue
Block a user