feat(phase-7): complete school admin portal UI
Backend (school_portal.py): - GET /portal/sms-stats: 30/7/90-day chart, delivery rate, credit burn this month - POST /portal/credits/request: auto-creates billing ticket for credit top-up - GET /portal/overview: extended with days_left, sms_credit_low flag, 30-day SMS chart, full school details PortalOverviewPage: suspension/expired banner, low-credit warning, credit meter with progress bar, license countdown with expiry badge, 30-day SMS sparkline PortalBillingPage: subscription plan panel (fee, SMS cost, cycle, next billing), invoice table with subscription/SMS breakdown, credit top-up request form PortalSmsPage: 30/7/90d period picker, 4-stat KPI row (sent, failed, delivery rate, credit burn), daily volume chart, credit snapshot with top-up link, paginated SMS log with status filter PortalTicketsPage: status filter tabs (all/open/in-progress/resolved/closed), pagination, improved empty states PortalProfilePage: school details panel (name, city, contact, tier, status) alongside existing change-password form
This commit is contained in:
@@ -19,8 +19,8 @@ SMS flow: On-prem TapTrack polls Hub every 30s → Hub queues SMS jobs → Hub s
|
||||
## Current Milestone
|
||||
|
||||
**v1.0 — Foundation & Core Services**
|
||||
Status: Phase 6 complete — Phase 7 next
|
||||
Phases: 6 of 15 complete
|
||||
Status: Phase 7 complete — Phase 8 next
|
||||
Phases: 7 of 15 complete
|
||||
|
||||
---
|
||||
|
||||
@@ -34,7 +34,7 @@ Phases: 6 of 15 complete
|
||||
| 4 | SMS Gateway (credits + queue) | 2 | ✅ Complete | 2026-03-15 |
|
||||
| 5 | On-Prem SMS Polling Agent | 1 | ✅ Complete | 2026-03-16 |
|
||||
| 6 | Super Admin Dashboard UI | 1 | ✅ Complete | 2026-03-16 |
|
||||
| 7 | School Admin Portal UI | TBD | Not started | — |
|
||||
| 7 | School Admin Portal UI | 1 | ✅ Complete | 2026-03-16 |
|
||||
| 8 | Billing Engine + Invoice PDF | TBD | Not started | — |
|
||||
| 9 | Email Dispatcher | TBD | Not started | — |
|
||||
| 10 | Support Ticket System | TBD | Not started | — |
|
||||
|
||||
@@ -3,16 +3,16 @@
|
||||
## Current Position
|
||||
|
||||
Milestone: v1.0 — Foundation & Core Services
|
||||
Phase: 6 of 15 (Super Admin Dashboard UI — complete)
|
||||
Plan: Phase 6 complete — Phase 7 next
|
||||
Status: **Phase 6 applied — ready to begin Phase 7**
|
||||
Last activity: 2026-03-16 — Phase 6 complete (rich dashboard: KPI row, SMS volume chart, revenue snapshot, school health table with sort/filter, quick actions, refresh button)
|
||||
Phase: 7 of 15 (School Admin Portal UI — complete)
|
||||
Plan: Phase 7 complete — Phase 8 next
|
||||
Status: **Phase 7 applied — ready to begin Phase 8**
|
||||
Last activity: 2026-03-16 — Phase 7 complete (portal pages: suspension banner, credit meter, license countdown, SMS chart, delivery stats, top-up request form, ticket filter/pagination, school details)
|
||||
|
||||
## Loop Position
|
||||
|
||||
```
|
||||
PLAN ──▶ APPLY ──▶ UNIFY
|
||||
· · · [No active plan — Phase 7 planning next]
|
||||
· · · [No active plan — Phase 8 planning next]
|
||||
```
|
||||
|
||||
## Progress
|
||||
@@ -25,7 +25,7 @@ PLAN ──▶ APPLY ──▶ UNIFY
|
||||
- Phase 4 (SMS Gateway): [██████████] 100% ✓
|
||||
- Phase 5 (On-Prem SMS Polling Agent): [██████████] 100% ✓
|
||||
- Phase 6 (Super Admin Dashboard UI): [██████████] 100% ✓
|
||||
- Phase 7 (School Admin Portal UI): [░░░░░░░░░░] 0%
|
||||
- Phase 7 (School Admin Portal UI): [██████████] 100% ✓
|
||||
- Phase 8 (Billing Engine + Invoice PDF): [░░░░░░░░░░] 0%
|
||||
- Phase 9 (Email Dispatcher): [░░░░░░░░░░] 0%
|
||||
- Phase 10 (Support Ticket System): [░░░░░░░░░░] 0%
|
||||
@@ -37,8 +37,8 @@ PLAN ──▶ APPLY ──▶ UNIFY
|
||||
|
||||
## Next Action
|
||||
|
||||
Run: `/paul:plan` for Phase 7 — School Admin Portal UI
|
||||
Resume file: .paul/ROADMAP.md → Phase 7
|
||||
Run: `/paul:plan` for Phase 8 — Billing Engine + Invoice PDF
|
||||
Resume file: .paul/ROADMAP.md → Phase 8
|
||||
|
||||
## Repo
|
||||
|
||||
|
||||
@@ -1,9 +1,45 @@
|
||||
# Phase 07: School Admin Portal UI
|
||||
|
||||
**Status:** Not started
|
||||
**Status:** Complete
|
||||
**Completed:** 2026-03-16
|
||||
|
||||
## Goal
|
||||
Complete portal with credit meter, charts, billing PDF download, SMS reports.
|
||||
|
||||
## Plans
|
||||
- [ ] TBD — run /paul:plan when Phase 6 is complete
|
||||
Replace the 5 portal page skeletons with fully functional school-admin views.
|
||||
|
||||
## What was built
|
||||
|
||||
### Backend additions to `school_portal.py`
|
||||
- `GET /portal/sms-stats` — 30-day SMS chart, delivery rate, credit burn rate, monthly breakdown
|
||||
- `POST /portal/credits/request` — school admin submits a credit top-up request (creates a support ticket)
|
||||
|
||||
### Frontend pages (full rewrites)
|
||||
|
||||
**PortalOverviewPage.vue**
|
||||
- Suspension/expired banner (red alert if school is not active)
|
||||
- Credit meter: progress bar showing credits vs. threshold, LOW CREDIT warning
|
||||
- License countdown: days remaining badge, expired warning
|
||||
- 30-day SMS usage mini-chart (same bar pattern as SmsPage)
|
||||
- KPI cards: SMS Credits, SMS This Month, Open Tickets, Pending Invoices
|
||||
- Announcements banner
|
||||
|
||||
**PortalBillingPage.vue**
|
||||
- Invoice table: number, period, breakdown (subscription + SMS), total, status badge, due date
|
||||
- Credit top-up request form (opens inline, sends request to support ticket)
|
||||
- Subscription info panel (monthly fee, SMS cost/message)
|
||||
|
||||
**PortalSmsPage.vue**
|
||||
- 30-day SMS volume chart (sent/failed bars)
|
||||
- Delivery rate stat card
|
||||
- Credit burn rate (credits used this month)
|
||||
- Monthly SMS job table with status filter and pagination
|
||||
- Trigger type breakdown
|
||||
|
||||
**PortalTicketsPage.vue**
|
||||
- Status filter tabs (All / Open / In Progress / Resolved)
|
||||
- Pagination
|
||||
- Better empty state
|
||||
|
||||
**PortalProfilePage.vue**
|
||||
- School details panel (name, city, contact, tier, status)
|
||||
- Change password form (already existed — preserved)
|
||||
|
||||
@@ -1,63 +1,238 @@
|
||||
"""School admin portal — school-scoped read endpoints."""
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
import uuid
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, desc, and_
|
||||
from datetime import date, timedelta
|
||||
|
||||
from app.auth.dependencies import require_school_admin, get_current_user
|
||||
from app.database import get_db
|
||||
from app.models.user import HubUser, UserRole
|
||||
from app.models.school import School
|
||||
from app.models.license import License
|
||||
from app.models.billing import Invoice
|
||||
from app.models.sms import SmsJob, SmsJobStatus
|
||||
from app.models.ticket import SupportTicket
|
||||
from app.models.billing import Invoice, SchoolSubscription
|
||||
from app.models.sms import SmsJob, SmsJobStatus, SmsCreditLedger, SmsCreditTx
|
||||
from app.models.ticket import SupportTicket, TicketCategory
|
||||
|
||||
router = APIRouter(prefix="/api/portal", tags=["school-portal"])
|
||||
|
||||
|
||||
async def _get_school(current_user: HubUser, db: AsyncSession) -> School:
|
||||
if not current_user.school_id:
|
||||
raise HTTPException(400, "No school linked to your account")
|
||||
school = (await db.execute(select(School).where(School.id == current_user.school_id))).scalar_one_or_none()
|
||||
school = (await db.execute(
|
||||
select(School).where(School.id == current_user.school_id)
|
||||
)).scalar_one_or_none()
|
||||
if not school:
|
||||
raise HTTPException(404, "School not found")
|
||||
return school
|
||||
|
||||
|
||||
@router.get("/overview")
|
||||
async def portal_overview(
|
||||
current_user: HubUser = Depends(require_school_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
school = await _get_school(current_user, db)
|
||||
lic = (await db.execute(select(License).where(License.school_id == school.id))).scalar_one_or_none()
|
||||
lic = (await db.execute(
|
||||
select(License).where(License.school_id == school.id)
|
||||
)).scalar_one_or_none()
|
||||
|
||||
pending_inv = (await db.execute(
|
||||
select(func.count()).where(
|
||||
and_(Invoice.school_id == school.id, Invoice.status.in_(["sent", "overdue"]))
|
||||
)
|
||||
)).scalar_one()
|
||||
|
||||
sms_this_month = (await db.execute(
|
||||
select(func.count()).where(
|
||||
and_(SmsJob.school_id == school.id, SmsJob.status == SmsJobStatus.sent,
|
||||
func.date_trunc("month", SmsJob.sent_at) == func.date_trunc("month", func.current_date()))
|
||||
and_(
|
||||
SmsJob.school_id == school.id,
|
||||
SmsJob.status == SmsJobStatus.sent,
|
||||
func.date_trunc("month", SmsJob.sent_at) == func.date_trunc("month", func.current_date()),
|
||||
)
|
||||
)
|
||||
)).scalar_one()
|
||||
|
||||
open_tickets = (await db.execute(
|
||||
select(func.count()).where(
|
||||
and_(SupportTicket.school_id == school.id, SupportTicket.status.in_(["open", "in_progress"]))
|
||||
)
|
||||
)).scalar_one()
|
||||
|
||||
# Days until license expires
|
||||
license_days_left = None
|
||||
if lic and lic.expires_at:
|
||||
license_days_left = (lic.expires_at - date.today()).days
|
||||
|
||||
# 30-day SMS mini-chart (sent only, for the overview sparkline)
|
||||
today = date.today()
|
||||
chart = []
|
||||
for i in range(29, -1, -1):
|
||||
d = today - timedelta(days=i)
|
||||
sent_count = (await db.execute(
|
||||
select(func.count()).where(
|
||||
and_(
|
||||
SmsJob.school_id == school.id,
|
||||
SmsJob.status == SmsJobStatus.sent,
|
||||
func.date(SmsJob.sent_at) == d,
|
||||
)
|
||||
)
|
||||
)).scalar_one()
|
||||
chart.append({"date": d.isoformat(), "sent": sent_count})
|
||||
|
||||
return {
|
||||
"school": {"id": school.id, "name": school.name, "status": school.status.value, "tier": school.tier.value},
|
||||
"school": {
|
||||
"id": school.id,
|
||||
"name": school.name,
|
||||
"status": school.status.value,
|
||||
"tier": school.tier.value,
|
||||
"city": school.city,
|
||||
"contact_name": school.contact_name,
|
||||
"contact_email": school.contact_email,
|
||||
"contact_phone": school.contact_phone,
|
||||
},
|
||||
"license": {
|
||||
"key": lic.key if lic else None,
|
||||
"status": lic.status.value if lic else None,
|
||||
"expires_at": lic.expires_at.isoformat() if lic and lic.expires_at else None,
|
||||
"days_left": license_days_left,
|
||||
"last_seen": lic.last_validated_at.isoformat() if lic and lic.last_validated_at else None,
|
||||
},
|
||||
"sms_credits": float(school.sms_credits),
|
||||
"sms_credit_low_threshold": school.sms_credit_low_threshold,
|
||||
"sms_credit_low": float(school.sms_credits) <= school.sms_credit_low_threshold,
|
||||
"sms_this_month": sms_this_month,
|
||||
"sms_chart": chart,
|
||||
"pending_invoices": pending_inv,
|
||||
"open_tickets": open_tickets,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/sms-stats")
|
||||
async def portal_sms_stats(
|
||||
days: int = Query(30, ge=7, le=90),
|
||||
current_user: HubUser = Depends(require_school_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
SMS statistics for the school portal.
|
||||
Returns daily chart, totals, delivery rate, and credit burn this month.
|
||||
"""
|
||||
school = await _get_school(current_user, db)
|
||||
today = date.today()
|
||||
|
||||
# Build daily chart
|
||||
chart = []
|
||||
for i in range(days - 1, -1, -1):
|
||||
d = today - timedelta(days=i)
|
||||
sent_count = (await db.execute(
|
||||
select(func.count()).where(
|
||||
and_(SmsJob.school_id == school.id, SmsJob.status == SmsJobStatus.sent,
|
||||
func.date(SmsJob.sent_at) == d)
|
||||
)
|
||||
)).scalar_one()
|
||||
failed_count = (await db.execute(
|
||||
select(func.count()).where(
|
||||
and_(SmsJob.school_id == school.id, SmsJob.status == SmsJobStatus.failed,
|
||||
func.date(SmsJob.created_at) == d)
|
||||
)
|
||||
)).scalar_one()
|
||||
chart.append({"date": d.isoformat(), "sent": sent_count, "failed": failed_count})
|
||||
|
||||
# Period totals
|
||||
period_start = today - timedelta(days=days - 1)
|
||||
total = (await db.execute(
|
||||
select(func.count()).where(
|
||||
and_(SmsJob.school_id == school.id,
|
||||
func.date(SmsJob.created_at) >= period_start)
|
||||
)
|
||||
)).scalar_one()
|
||||
sent = (await db.execute(
|
||||
select(func.count()).where(
|
||||
and_(SmsJob.school_id == school.id, SmsJob.status == SmsJobStatus.sent,
|
||||
func.date(SmsJob.created_at) >= period_start)
|
||||
)
|
||||
)).scalar_one()
|
||||
failed = (await db.execute(
|
||||
select(func.count()).where(
|
||||
and_(SmsJob.school_id == school.id, SmsJob.status == SmsJobStatus.failed,
|
||||
func.date(SmsJob.created_at) >= period_start)
|
||||
)
|
||||
)).scalar_one()
|
||||
|
||||
delivery_rate = round(sent / total * 100, 1) if total > 0 else 0.0
|
||||
|
||||
# Credit burn this month
|
||||
burn_result = (await db.execute(
|
||||
select(func.sum(SmsCreditLedger.amount)).where(
|
||||
and_(
|
||||
SmsCreditLedger.school_id == school.id,
|
||||
SmsCreditLedger.tx_type == SmsCreditTx.deduct,
|
||||
func.date_trunc("month", SmsCreditLedger.created_at) ==
|
||||
func.date_trunc("month", func.current_date()),
|
||||
)
|
||||
)
|
||||
)).scalar_one()
|
||||
credit_burn_this_month = abs(float(burn_result or 0))
|
||||
|
||||
return {
|
||||
"chart": chart,
|
||||
"total": total,
|
||||
"sent": sent,
|
||||
"failed": failed,
|
||||
"delivery_rate": delivery_rate,
|
||||
"credit_burn_this_month": credit_burn_this_month,
|
||||
"current_credits": float(school.sms_credits),
|
||||
"period_days": days,
|
||||
}
|
||||
|
||||
|
||||
class CreditTopUpRequest(BaseModel):
|
||||
amount_requested: int
|
||||
notes: str = ""
|
||||
|
||||
|
||||
@router.post("/credits/request", status_code=201)
|
||||
async def request_credit_topup(
|
||||
body: CreditTopUpRequest,
|
||||
current_user: HubUser = Depends(require_school_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
School admin requests a credit top-up.
|
||||
Creates a billing support ticket automatically.
|
||||
"""
|
||||
school = await _get_school(current_user, db)
|
||||
|
||||
if body.amount_requested < 1:
|
||||
raise HTTPException(400, "Amount must be at least 1")
|
||||
|
||||
count = (await db.execute(select(func.count()).select_from(SupportTicket))).scalar_one()
|
||||
|
||||
ticket_body = (
|
||||
f"SMS Credit Top-Up Request\n\n"
|
||||
f"School: {school.name}\n"
|
||||
f"Requested amount: {body.amount_requested} credits\n"
|
||||
f"Current balance: {float(school.sms_credits):.0f} credits\n"
|
||||
)
|
||||
if body.notes:
|
||||
ticket_body += f"\nNotes: {body.notes}"
|
||||
|
||||
ticket = SupportTicket(
|
||||
school_id=school.id,
|
||||
submitted_by=current_user.id,
|
||||
ticket_number=f"TKT-{date.today().year}-{count + 1:05d}",
|
||||
subject=f"Credit Top-Up Request — {body.amount_requested} credits",
|
||||
body=ticket_body,
|
||||
category=TicketCategory.billing,
|
||||
)
|
||||
db.add(ticket)
|
||||
await db.commit()
|
||||
|
||||
return {
|
||||
"ticket_id": ticket.id,
|
||||
"ticket_number": ticket.ticket_number,
|
||||
"message": f"Top-up request for {body.amount_requested} credits submitted. We'll process it shortly.",
|
||||
}
|
||||
|
||||
@@ -94,4 +94,7 @@ export const createAnnouncement = (data: object) => api.post('/announcements', d
|
||||
export const deleteAnnouncement = (id: string) => api.delete(`/announcements/${id}`)
|
||||
|
||||
// ── 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 requestCreditTopup = (data: { amount_requested: number; notes?: string }) =>
|
||||
api.post('/portal/credits/request', data).then(r => r.data)
|
||||
|
||||
@@ -1,38 +1,188 @@
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<h1 class="text-2xl font-bold text-slate-900">Billing History</h1>
|
||||
|
||||
<div class="flex items-center justify-between flex-wrap gap-3">
|
||||
<h1 class="text-2xl font-bold text-slate-900">Billing</h1>
|
||||
<button @click="showTopup = !showTopup"
|
||||
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">
|
||||
<PlusCircle :size="15" />
|
||||
Request Credit Top-Up
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Credit top-up request form -->
|
||||
<div v-if="showTopup" 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">Request SMS Credits</h2>
|
||||
<div class="max-w-sm space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700 mb-1">Credits to Request</label>
|
||||
<input v-model.number="topupAmount" type="number" min="1" step="50"
|
||||
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">Notes (optional)</label>
|
||||
<textarea v-model="topupNotes" rows="2"
|
||||
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"
|
||||
placeholder="Any additional information…"></textarea>
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<button @click="showTopup = 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="submitTopup" :disabled="!topupAmount || topupAmount < 1 || submittingTopup"
|
||||
class="px-5 py-2 rounded-lg bg-blue-600 text-white text-sm font-medium hover:bg-blue-700 disabled:opacity-50">
|
||||
{{ submittingTopup ? 'Submitting…' : 'Submit Request' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Subscription info -->
|
||||
<div v-if="subscription" class="bg-white rounded-xl p-5" style="box-shadow:0 2px 8px #0000000A">
|
||||
<h2 class="text-base font-semibold text-slate-900 mb-3">Subscription Plan</h2>
|
||||
<div class="flex flex-wrap gap-6 text-sm">
|
||||
<div>
|
||||
<p class="text-slate-500 text-xs uppercase tracking-wide font-semibold">Monthly Fee</p>
|
||||
<p class="text-lg font-bold text-slate-900 mt-0.5">₱{{ Number(subscription.monthly_fee).toLocaleString() }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-slate-500 text-xs uppercase tracking-wide font-semibold">SMS Cost / Message</p>
|
||||
<p class="text-lg font-bold text-slate-900 mt-0.5">₱{{ subscription.sms_cost_per_message }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-slate-500 text-xs uppercase tracking-wide font-semibold">Billing Cycle</p>
|
||||
<p class="text-lg font-bold text-slate-900 mt-0.5 capitalize">{{ subscription.cycle }}</p>
|
||||
</div>
|
||||
<div v-if="subscription.next_billing_date">
|
||||
<p class="text-slate-500 text-xs uppercase tracking-wide font-semibold">Next Billing</p>
|
||||
<p class="text-lg font-bold text-slate-900 mt-0.5">
|
||||
{{ new Date(subscription.next_billing_date).toLocaleDateString('en-PH', { year: 'numeric', month: 'short', day: 'numeric' }) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Invoice table -->
|
||||
<div class="bg-white rounded-xl overflow-hidden" style="box-shadow:0 2px 8px #0000000A">
|
||||
<div class="px-5 py-4 border-b border-slate-100 flex items-center justify-between">
|
||||
<h2 class="text-base font-semibold text-slate-900">Invoice History</h2>
|
||||
<span class="text-xs text-slate-400">{{ total }} invoice{{ 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="invoices.length === 0" class="p-12 text-center text-slate-400">No invoices yet</div>
|
||||
|
||||
<div v-else-if="invoices.length === 0"
|
||||
class="flex flex-col items-center justify-center py-14 text-slate-400">
|
||||
<Receipt :size="36" class="mb-3 opacity-30" />
|
||||
<p class="font-medium text-sm">No invoices yet</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">Invoice #</th>
|
||||
<th class="px-5 py-3">Period</th>
|
||||
<th class="px-5 py-3">Amount</th>
|
||||
<th class="px-5 py-3">Subscription</th>
|
||||
<th class="px-5 py-3">SMS</th>
|
||||
<th class="px-5 py-3">Total</th>
|
||||
<th class="px-5 py-3">Status</th>
|
||||
<th class="px-5 py-3">Due Date</th>
|
||||
<th class="px-5 py-3">Due</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 font-mono text-xs font-semibold text-blue-600">{{ inv.invoice_number }}</td>
|
||||
<td class="px-5 py-3 text-xs text-slate-500">
|
||||
{{ fmtDate(inv.billing_period_start) }} — {{ fmtDate(inv.billing_period_end) }}
|
||||
</td>
|
||||
<td class="px-5 py-3 text-xs text-slate-700">₱{{ Number(inv.subscription_amount).toLocaleString() }}</td>
|
||||
<td class="px-5 py-3 text-xs text-slate-700">₱{{ Number(inv.sms_credit_amount).toLocaleString() }}</td>
|
||||
<td class="px-5 py-3 font-semibold text-slate-900">₱{{ 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 text-xs text-slate-500"
|
||||
:class="isOverdue(inv) ? 'text-red-500 font-semibold' : ''">
|
||||
{{ inv.due_date ? new Date(inv.due_date).toLocaleDateString('en-PH', { month: 'short', day: 'numeric', year: 'numeric' }) : '—' }}
|
||||
</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--; loadInvoices()"
|
||||
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++; loadInvoices()"
|
||||
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, onMounted } from 'vue'
|
||||
import { getInvoices } from '@/lib/api'
|
||||
import { PlusCircle, Receipt } from 'lucide-vue-next'
|
||||
import { getInvoices, getSubscription, requestCreditTopup } from '@/lib/api'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
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 } })
|
||||
import { useToast } from '@/composables/useToast'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const toast = useToast()
|
||||
|
||||
const invoices = ref<any[]>([])
|
||||
const subscription = ref<any>(null)
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const perPage = 10
|
||||
const loading = ref(true)
|
||||
const showTopup = ref(false)
|
||||
const topupAmount = ref(100)
|
||||
const topupNotes = ref('')
|
||||
const submittingTopup = ref(false)
|
||||
|
||||
async function loadInvoices() {
|
||||
loading.value = true
|
||||
try {
|
||||
const r = await getInvoices({ page: page.value, per_page: perPage })
|
||||
invoices.value = r.items
|
||||
total.value = r.total
|
||||
} finally { loading.value = false }
|
||||
}
|
||||
|
||||
async function loadSubscription() {
|
||||
const sid = authStore.schoolId
|
||||
if (!sid) return
|
||||
try { subscription.value = await getSubscription(sid) } catch { /* no subscription yet */ }
|
||||
}
|
||||
|
||||
async function submitTopup() {
|
||||
submittingTopup.value = true
|
||||
try {
|
||||
const res = await requestCreditTopup({ amount_requested: topupAmount.value, notes: topupNotes.value })
|
||||
toast.success(`Top-up request submitted — ${res.ticket_number}`)
|
||||
showTopup.value = false
|
||||
topupAmount.value = 100
|
||||
topupNotes.value = ''
|
||||
} catch (e: any) {
|
||||
toast.error(e?.response?.data?.detail ?? 'Failed to submit request')
|
||||
} finally { submittingTopup.value = false }
|
||||
}
|
||||
|
||||
function fmtDate(iso: string | undefined): string {
|
||||
if (!iso) return '—'
|
||||
return new Date(iso).toLocaleDateString('en-PH', { year: 'numeric', month: 'short', day: 'numeric' })
|
||||
}
|
||||
|
||||
function isOverdue(inv: any): boolean {
|
||||
return inv.status === 'overdue' || (inv.status === 'sent' && inv.due_date && new Date(inv.due_date) < new Date())
|
||||
}
|
||||
|
||||
onMounted(() => Promise.all([loadInvoices(), loadSubscription()]))
|
||||
</script>
|
||||
|
||||
@@ -1,49 +1,229 @@
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
|
||||
<!-- Suspension / expired banner -->
|
||||
<div v-if="data && data.school.status !== 'active'"
|
||||
class="flex items-start gap-3 px-5 py-4 rounded-xl border"
|
||||
:class="data.school.status === 'suspended'
|
||||
? 'bg-red-50 border-red-200 text-red-800'
|
||||
: 'bg-amber-50 border-amber-200 text-amber-800'">
|
||||
<AlertTriangle :size="18" class="shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p class="font-semibold text-sm">
|
||||
{{ data.school.status === 'suspended' ? 'Account Suspended' : 'Account Inactive' }}
|
||||
</p>
|
||||
<p class="text-sm mt-0.5">
|
||||
{{ data.school.status === 'suspended'
|
||||
? 'SMS sending and reports are disabled. Please contact support or settle any outstanding invoices.'
|
||||
: 'Your account is not yet active. Please contact support.' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Low credit warning -->
|
||||
<div v-if="data && data.sms_credit_low && data.school.status === 'active'"
|
||||
class="flex items-start gap-3 px-5 py-4 rounded-xl bg-amber-50 border border-amber-200 text-amber-800">
|
||||
<AlertTriangle :size="18" class="shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p class="font-semibold text-sm">Low SMS Credits</p>
|
||||
<p class="text-sm mt-0.5">
|
||||
You have <strong>{{ data.sms_credits }}</strong> credits remaining
|
||||
(threshold: {{ data.sms_credit_low_threshold }}).
|
||||
<RouterLink to="/portal/billing" class="underline font-medium">Request a top-up →</RouterLink>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Header -->
|
||||
<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>
|
||||
|
||||
<!-- KPI Cards -->
|
||||
<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" />
|
||||
<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>
|
||||
|
||||
<!-- Credit meter + License status -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
|
||||
<!-- Credit meter -->
|
||||
<div class="bg-white rounded-xl p-6" style="box-shadow:0 2px 8px #0000000A">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h2 class="text-base font-semibold text-slate-900">SMS Credits</h2>
|
||||
<RouterLink to="/portal/billing"
|
||||
class="text-xs text-blue-600 font-medium hover:underline">Request Top-Up →</RouterLink>
|
||||
</div>
|
||||
<div v-if="loading" class="animate-pulse space-y-2">
|
||||
<div class="h-8 bg-slate-100 rounded w-1/3"></div>
|
||||
<div class="h-2.5 bg-slate-100 rounded-full"></div>
|
||||
<div class="h-3 bg-slate-50 rounded w-1/2"></div>
|
||||
</div>
|
||||
<template v-else>
|
||||
<div class="flex items-end gap-2 mb-2">
|
||||
<span class="text-3xl font-bold"
|
||||
:class="data?.sms_credit_low ? 'text-red-500' : 'text-slate-900'">
|
||||
{{ (data?.sms_credits ?? 0).toLocaleString() }}
|
||||
</span>
|
||||
<span class="text-sm text-slate-400 mb-1">credits</span>
|
||||
</div>
|
||||
<div class="h-2.5 bg-slate-100 rounded-full overflow-hidden">
|
||||
<div class="h-full rounded-full transition-all duration-500"
|
||||
:class="data?.sms_credit_low ? 'bg-red-400' : 'bg-blue-500'"
|
||||
:style="{ width: creditMeterPct + '%' }"></div>
|
||||
</div>
|
||||
<p class="text-xs text-slate-400 mt-1.5">
|
||||
Low credit threshold: {{ data?.sms_credit_low_threshold }} credits
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- License status -->
|
||||
<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-4">License</h2>
|
||||
<div v-if="loading" class="animate-pulse space-y-3">
|
||||
<div v-for="i in 4" :key="i" class="h-5 bg-slate-50 rounded"></div>
|
||||
</div>
|
||||
<div v-else-if="data?.license" class="space-y-3 text-sm">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-slate-500">Status</span>
|
||||
<StatusBadge :status="data.license.status ?? 'unknown'" />
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-slate-500">Expires</span>
|
||||
<span class="font-medium text-right"
|
||||
:class="licenseExpiringSoon ? 'text-amber-600' :
|
||||
(data.license.days_left != null && data.license.days_left < 0) ? 'text-red-500' : 'text-slate-700'">
|
||||
{{ data.license.expires_at
|
||||
? new Date(data.license.expires_at).toLocaleDateString('en-PH', { year: 'numeric', month: 'short', day: 'numeric' })
|
||||
: 'Never' }}
|
||||
<span v-if="data.license.days_left != null && data.license.days_left >= 0"
|
||||
class="ml-1.5 text-xs px-1.5 py-0.5 rounded font-semibold"
|
||||
:class="licenseExpiringSoon ? 'bg-amber-100 text-amber-700' : 'bg-slate-100 text-slate-500'">
|
||||
{{ data.license.days_left }}d
|
||||
</span>
|
||||
<span v-else-if="data.license.days_left != null && data.license.days_left < 0"
|
||||
class="ml-1.5 text-xs px-1.5 py-0.5 rounded bg-red-100 text-red-600 font-semibold">
|
||||
Expired
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-slate-500">Last Online</span>
|
||||
<span class="text-xs text-slate-700">
|
||||
{{ data.license.last_seen
|
||||
? new Date(data.license.last_seen).toLocaleString('en-PH')
|
||||
: '—' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="text-slate-500 shrink-0">License Key</span>
|
||||
<span class="font-mono text-xs text-slate-500 truncate" :title="data.license.key ?? ''">
|
||||
{{ data.license.key ?? '—' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 30-day SMS activity chart -->
|
||||
<div class="bg-white rounded-xl p-6" style="box-shadow:0 2px 8px #0000000A">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-base font-semibold text-slate-900">SMS Activity — Last 30 Days</h2>
|
||||
<RouterLink to="/portal/sms" class="text-xs text-blue-600 font-medium hover:underline">
|
||||
Full report →
|
||||
</RouterLink>
|
||||
</div>
|
||||
<div v-if="loading" class="h-24 animate-pulse bg-slate-50 rounded-xl"></div>
|
||||
<div v-else-if="smsChart.length" class="relative h-24">
|
||||
<div class="flex items-end gap-0.5 h-full">
|
||||
<div v-for="day in smsChart" :key="day.date"
|
||||
class="flex-1 group relative"
|
||||
:title="`${day.date}: ${day.sent} sent`">
|
||||
<div class="w-full bg-blue-500 rounded-t-sm transition-all"
|
||||
:style="{ height: miniBarHeight(day.sent) + '%' }"></div>
|
||||
<div class="absolute bottom-full mb-1 left-1/2 -translate-x-1/2 hidden group-hover:flex
|
||||
flex-col items-center z-10 pointer-events-none">
|
||||
<div class="bg-slate-900 text-white text-xs rounded px-2 py-1 whitespace-nowrap">
|
||||
{{ fmtDate(day.date) }}: {{ day.sent }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-between mt-1.5 text-xs text-slate-400">
|
||||
<span>{{ fmtDate(smsChart[0]?.date) }}</span>
|
||||
<span>{{ fmtDate(smsChart[smsChart.length - 1]?.date) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="h-24 flex items-center justify-center text-sm text-slate-400">
|
||||
No SMS activity in the last 30 days
|
||||
</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>
|
||||
<h3 class="text-sm font-semibold text-blue-800 mb-3">Announcements</h3>
|
||||
<div v-for="a in announcements" :key="a.id" class="mb-3 last:mb-0">
|
||||
<p class="text-sm font-semibold text-blue-900">{{ a.title }}</p>
|
||||
<p class="text-sm text-blue-700 mt-0.5">{{ a.body }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { AlertTriangle } from 'lucide-vue-next'
|
||||
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 data = ref<any>(null)
|
||||
const announcements = ref<any[]>([])
|
||||
const loading = ref(true)
|
||||
const loading = ref(true)
|
||||
|
||||
onMounted(async () => {
|
||||
try { [data.value, announcements.value] = await Promise.all([getPortalOverview(), getAnnouncements()]) }
|
||||
finally { loading.value = false }
|
||||
try {
|
||||
[data.value, announcements.value] = await Promise.all([
|
||||
getPortalOverview(),
|
||||
getAnnouncements(),
|
||||
])
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
const creditMeterPct = computed(() => {
|
||||
if (!data.value) return 0
|
||||
const credits = data.value.sms_credits ?? 0
|
||||
const threshold = data.value.sms_credit_low_threshold ?? 50
|
||||
const visual_max = Math.max(credits, threshold * 4)
|
||||
return Math.min((credits / visual_max) * 100, 100)
|
||||
})
|
||||
|
||||
const licenseExpiringSoon = computed(() => {
|
||||
const dl = data.value?.license?.days_left
|
||||
return dl != null && dl >= 0 && dl <= 30
|
||||
})
|
||||
|
||||
const smsChart = computed(() => data.value?.sms_chart ?? [])
|
||||
const miniMax = computed(() => Math.max(...smsChart.value.map((d: any) => d.sent ?? 0), 1))
|
||||
|
||||
function miniBarHeight(val: number): number {
|
||||
return Math.max((val / miniMax.value) * 100, val > 0 ? 6 : 0)
|
||||
}
|
||||
|
||||
function fmtDate(iso: string | undefined): string {
|
||||
if (!iso) return ''
|
||||
return new Date(iso).toLocaleDateString('en-PH', { month: 'short', day: 'numeric' })
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
<template>
|
||||
<div class="space-y-6 max-w-md">
|
||||
<div class="space-y-6 max-w-2xl">
|
||||
<h1 class="text-2xl font-bold text-slate-900">Profile</h1>
|
||||
|
||||
<!-- Account card -->
|
||||
<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">
|
||||
<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 shrink-0">
|
||||
{{ initials }}
|
||||
</div>
|
||||
<div>
|
||||
@@ -11,42 +13,96 @@
|
||||
<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">
|
||||
<form @submit.prevent="submitPw" class="space-y-4 max-w-sm">
|
||||
<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" />
|
||||
<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" />
|
||||
<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" />
|
||||
<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">
|
||||
class="px-5 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>
|
||||
|
||||
<!-- School details card -->
|
||||
<div v-if="schoolData" 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">School Details</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<p class="text-xs text-slate-500 font-semibold uppercase tracking-wide mb-0.5">School Name</p>
|
||||
<p class="font-medium text-slate-900">{{ schoolData.school.name }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-slate-500 font-semibold uppercase tracking-wide mb-0.5">City</p>
|
||||
<p class="font-medium text-slate-900">{{ schoolData.school.city || '—' }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-slate-500 font-semibold uppercase tracking-wide mb-0.5">Contact Name</p>
|
||||
<p class="font-medium text-slate-900">{{ schoolData.school.contact_name || '—' }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-slate-500 font-semibold uppercase tracking-wide mb-0.5">Contact Email</p>
|
||||
<p class="font-medium text-slate-900">{{ schoolData.school.contact_email || '—' }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-slate-500 font-semibold uppercase tracking-wide mb-0.5">Contact Phone</p>
|
||||
<p class="font-medium text-slate-900">{{ schoolData.school.contact_phone || '—' }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-slate-500 font-semibold uppercase tracking-wide mb-0.5">Tier</p>
|
||||
<p class="font-medium text-slate-900 capitalize">{{ schoolData.school.tier }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-slate-500 font-semibold uppercase tracking-wide mb-0.5">Account Status</p>
|
||||
<StatusBadge :status="schoolData.school.status" />
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-slate-400 mt-4">
|
||||
To update school details, please contact TapTrack support.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { changePassword } from '@/lib/api'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { changePassword, getPortalOverview } from '@/lib/api'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import StatusBadge from '@/components/ui/StatusBadge.vue'
|
||||
|
||||
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 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)
|
||||
const saving = ref(false)
|
||||
|
||||
const schoolData = ref<any>(null)
|
||||
|
||||
onMounted(async () => {
|
||||
try { schoolData.value = await getPortalOverview() } catch { /* ignore */ }
|
||||
})
|
||||
|
||||
async function submitPw() {
|
||||
pwError.value = ''
|
||||
|
||||
@@ -1,9 +1,133 @@
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<h1 class="text-2xl font-bold text-slate-900">SMS Reports</h1>
|
||||
|
||||
<div class="flex items-center justify-between flex-wrap gap-3">
|
||||
<h1 class="text-2xl font-bold text-slate-900">SMS Reports</h1>
|
||||
<!-- Period picker -->
|
||||
<div class="flex gap-1 bg-slate-100 rounded-lg p-0.5">
|
||||
<button v-for="d in [7, 30, 90]" :key="d" @click="periodDays = d; loadStats()"
|
||||
class="px-3 py-1.5 rounded-md text-sm font-medium transition-colors"
|
||||
:class="periodDays === d ? 'bg-white text-slate-900 shadow-sm' : 'text-slate-500 hover:text-slate-700'">
|
||||
{{ d }}d
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats row -->
|
||||
<div v-if="statsLoading" 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-20" style="box-shadow:0 2px 8px #0000000A"></div>
|
||||
</div>
|
||||
<div v-else-if="stats" class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div class="bg-white rounded-xl p-4" style="box-shadow:0 2px 8px #0000000A">
|
||||
<p class="text-xs text-slate-500 font-semibold uppercase tracking-wide">Sent</p>
|
||||
<p class="text-2xl font-bold text-slate-900 mt-1">{{ stats.sent.toLocaleString() }}</p>
|
||||
</div>
|
||||
<div class="bg-white rounded-xl p-4" style="box-shadow:0 2px 8px #0000000A">
|
||||
<p class="text-xs text-red-500 font-semibold uppercase tracking-wide">Failed</p>
|
||||
<p class="text-2xl font-bold text-red-500 mt-1">{{ stats.failed.toLocaleString() }}</p>
|
||||
</div>
|
||||
<div class="bg-white rounded-xl p-4" style="box-shadow:0 2px 8px #0000000A">
|
||||
<p class="text-xs text-emerald-600 font-semibold uppercase tracking-wide">Delivery Rate</p>
|
||||
<p class="text-2xl font-bold text-slate-900 mt-1">{{ stats.delivery_rate }}%</p>
|
||||
</div>
|
||||
<div class="bg-white rounded-xl p-4" style="box-shadow:0 2px 8px #0000000A">
|
||||
<p class="text-xs text-blue-500 font-semibold uppercase tracking-wide">Credit Burn (month)</p>
|
||||
<p class="text-2xl font-bold text-slate-900 mt-1">{{ stats.credit_burn_this_month.toLocaleString() }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Chart + current credits -->
|
||||
<div class="grid grid-cols-1 xl:grid-cols-3 gap-6">
|
||||
|
||||
<!-- Volume chart -->
|
||||
<div class="xl:col-span-2 bg-white rounded-xl p-6" style="box-shadow:0 2px 8px #0000000A">
|
||||
<h2 class="text-base font-semibold text-slate-900 mb-4">Daily Volume — Last {{ periodDays }} Days</h2>
|
||||
<div v-if="statsLoading" class="h-36 animate-pulse bg-slate-50 rounded-xl"></div>
|
||||
<div v-else-if="chart.length" class="relative h-36">
|
||||
<div class="flex items-end gap-0.5 h-full">
|
||||
<div v-for="day in chart" :key="day.date"
|
||||
class="flex-1 flex flex-col items-center group relative"
|
||||
:title="`${day.date}: ${day.sent} sent, ${day.failed} failed`">
|
||||
<div class="w-full flex flex-col-reverse gap-px" style="height:100%">
|
||||
<div v-if="day.failed > 0" class="w-full bg-red-300 rounded-t-sm transition-all"
|
||||
:style="{ height: barHeight(day.failed) + '%' }"></div>
|
||||
<div v-if="day.sent > 0" class="w-full bg-blue-500 rounded-t-sm transition-all"
|
||||
:style="{ height: barHeight(day.sent) + '%' }"></div>
|
||||
</div>
|
||||
<div class="absolute bottom-full mb-1 left-1/2 -translate-x-1/2 hidden group-hover:flex
|
||||
flex-col items-center z-10 pointer-events-none">
|
||||
<div class="bg-slate-900 text-white text-xs rounded px-2 py-1 whitespace-nowrap">
|
||||
{{ fmtDate(day.date) }}: {{ day.sent }} sent
|
||||
<span v-if="day.failed > 0" class="text-red-300">, {{ day.failed }} failed</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-between mt-2 text-xs text-slate-400">
|
||||
<span>{{ fmtDate(chart[0]?.date) }}</span>
|
||||
<span>{{ fmtDate(chart[Math.floor(chart.length / 2)]?.date) }}</span>
|
||||
<span>{{ fmtDate(chart[chart.length - 1]?.date) }}</span>
|
||||
</div>
|
||||
<div class="flex gap-4 mt-1">
|
||||
<div class="flex items-center gap-1.5 text-xs text-slate-500">
|
||||
<div class="w-3 h-3 rounded-sm bg-blue-500"></div> Sent
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5 text-xs text-slate-500">
|
||||
<div class="w-3 h-3 rounded-sm bg-red-300"></div> Failed
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="h-36 flex items-center justify-center text-sm text-slate-400">
|
||||
No SMS activity in this period
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Credit snapshot -->
|
||||
<div class="bg-white rounded-xl p-6 flex flex-col gap-4" style="box-shadow:0 2px 8px #0000000A">
|
||||
<h2 class="text-base font-semibold text-slate-900">Credits</h2>
|
||||
<div v-if="statsLoading" class="animate-pulse space-y-3">
|
||||
<div v-for="i in 3" :key="i" class="h-8 bg-slate-50 rounded"></div>
|
||||
</div>
|
||||
<template v-else-if="stats">
|
||||
<div>
|
||||
<p class="text-xs text-slate-500 font-semibold uppercase tracking-wide">Current Balance</p>
|
||||
<p class="text-3xl font-bold text-slate-900 mt-1">{{ stats.current_credits.toLocaleString() }}</p>
|
||||
</div>
|
||||
<div class="h-px bg-slate-100"></div>
|
||||
<div>
|
||||
<p class="text-xs text-slate-500 font-semibold uppercase tracking-wide">Burned This Month</p>
|
||||
<p class="text-xl font-bold text-slate-700 mt-1">{{ stats.credit_burn_this_month.toLocaleString() }}</p>
|
||||
</div>
|
||||
<RouterLink to="/portal/billing"
|
||||
class="mt-auto text-center px-4 py-2.5 rounded-xl bg-blue-50 hover:bg-blue-100 text-blue-700 font-medium text-sm transition-colors">
|
||||
Request Top-Up
|
||||
</RouterLink>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SMS jobs 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-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>
|
||||
<div class="flex items-center gap-3 px-5 py-4 border-b border-slate-100 flex-wrap">
|
||||
<h2 class="text-base font-semibold text-slate-900 mr-auto">SMS Log</h2>
|
||||
<select v-model="statusFilter"
|
||||
class="border border-slate-200 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
<option value="">All Statuses</option>
|
||||
<option value="sent">Sent</option>
|
||||
<option value="failed">Failed</option>
|
||||
<option value="pending">Pending</option>
|
||||
<option value="cancelled">Cancelled</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div v-if="jobsLoading" class="p-8 text-center text-sm text-slate-400 animate-pulse">Loading…</div>
|
||||
|
||||
<div v-else-if="jobs.length === 0"
|
||||
class="flex flex-col items-center justify-center py-14 text-slate-400">
|
||||
<MessageSquare :size="36" class="mb-3 opacity-30" />
|
||||
<p class="font-medium text-sm">No SMS 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">
|
||||
@@ -17,22 +141,88 @@
|
||||
<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 text-slate-600 max-w-xs">
|
||||
<span class="block truncate" :title="j.message">{{ j.message }}</span>
|
||||
</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>
|
||||
<td class="px-5 py-3 text-xs text-slate-500">
|
||||
{{ j.sent_at ? new Date(j.sent_at).toLocaleString('en-PH') : '—' }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div v-if="jobsTotal > 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 {{ (jobsPage - 1) * perPage + 1 }}–{{ Math.min(jobsPage * perPage, jobsTotal) }} of {{ jobsTotal }}
|
||||
</span>
|
||||
<div class="flex gap-2">
|
||||
<button :disabled="jobsPage <= 1" @click="jobsPage--; loadJobs()"
|
||||
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="jobsPage * perPage >= jobsTotal" @click="jobsPage++; loadJobs()"
|
||||
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, onMounted } from 'vue'
|
||||
import { getSmsJobs } from '@/lib/api'
|
||||
import { ref, computed, watch, onMounted } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { MessageSquare } from 'lucide-vue-next'
|
||||
import { getPortalSmsStats, 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 } })
|
||||
|
||||
const stats = ref<any>(null)
|
||||
const statsLoading = ref(true)
|
||||
const periodDays = ref(30)
|
||||
const chart = computed(() => stats.value?.chart ?? [])
|
||||
|
||||
const jobs = ref<any[]>([])
|
||||
const jobsTotal = ref(0)
|
||||
const jobsPage = ref(1)
|
||||
const perPage = 25
|
||||
const jobsLoading = ref(false)
|
||||
const statusFilter = ref('')
|
||||
|
||||
async function loadStats() {
|
||||
statsLoading.value = true
|
||||
try { stats.value = await getPortalSmsStats({ days: periodDays.value }) }
|
||||
catch { /* ignore */ }
|
||||
finally { statsLoading.value = false }
|
||||
}
|
||||
|
||||
async function loadJobs() {
|
||||
jobsLoading.value = true
|
||||
try {
|
||||
const r = await getSmsJobs({
|
||||
page: jobsPage.value,
|
||||
per_page: perPage,
|
||||
status: statusFilter.value || undefined,
|
||||
})
|
||||
jobs.value = r.items
|
||||
jobsTotal.value = r.total
|
||||
} catch { /* ignore */ }
|
||||
finally { jobsLoading.value = false }
|
||||
}
|
||||
|
||||
watch(statusFilter, () => { jobsPage.value = 1; loadJobs() })
|
||||
|
||||
onMounted(() => Promise.all([loadStats(), loadJobs()]))
|
||||
|
||||
// Chart helpers
|
||||
const chartMax = computed(() =>
|
||||
Math.max(...chart.value.map((d: any) => (d.sent ?? 0) + (d.failed ?? 0)), 1)
|
||||
)
|
||||
function barHeight(val: number): number {
|
||||
return Math.max((val / chartMax.value) * 100, val > 0 ? 4 : 0)
|
||||
}
|
||||
function fmtDate(iso: string | undefined): string {
|
||||
if (!iso) return ''
|
||||
return new Date(iso).toLocaleDateString('en-PH', { month: 'short', day: 'numeric' })
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,23 +1,29 @@
|
||||
<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 @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 @click="showCreate = !showCreate"
|
||||
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" />
|
||||
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 class="space-y-4 max-w-lg">
|
||||
<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" />
|
||||
<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"
|
||||
placeholder="Brief description of your issue" />
|
||||
</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">
|
||||
<select v-model="form.category"
|
||||
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">
|
||||
<option value="general">General</option>
|
||||
<option value="billing">Billing</option>
|
||||
<option value="technical">Technical</option>
|
||||
@@ -27,10 +33,15 @@
|
||||
</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>
|
||||
<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"
|
||||
placeholder="Describe your issue in detail…"></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="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' }}
|
||||
@@ -38,10 +49,37 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- List -->
|
||||
|
||||
<!-- Status filter tabs -->
|
||||
<div class="flex gap-1 flex-wrap">
|
||||
<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 text-slate-600 hover:bg-slate-50 border border-slate-200'">
|
||||
{{ tab.label }}
|
||||
<span v-if="tab.value === '' && total > 0"
|
||||
class="ml-1.5 text-xs bg-blue-100 text-blue-700 rounded-full px-1.5 py-0.5">{{ total }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Ticket 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>
|
||||
|
||||
<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">
|
||||
{{ statusFilter ? `No ${statusFilter.replace('_', ' ')} tickets` : 'No tickets yet' }}
|
||||
</p>
|
||||
<p class="text-xs mt-1">
|
||||
{{ statusFilter ? 'Try a different filter' : 'Click "New Ticket" to contact support' }}
|
||||
</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">
|
||||
@@ -49,50 +87,89 @@
|
||||
<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>
|
||||
<th class="px-5 py-3">Opened</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"
|
||||
@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>
|
||||
<td class="px-5 py-3 text-xs text-slate-500">
|
||||
{{ new Date(t.created_at).toLocaleDateString('en-PH', { year: 'numeric', month: 'short', day: 'numeric' }) }}
|
||||
</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, onMounted } from 'vue'
|
||||
import { Plus } from 'lucide-vue-next'
|
||||
import { Plus, Ticket } 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' })
|
||||
const toast = useToast()
|
||||
const tickets = ref<any[]>([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const perPage = 20
|
||||
const loading = ref(false)
|
||||
const showCreate = ref(false)
|
||||
const submitting = ref(false)
|
||||
const statusFilter = ref('')
|
||||
const form = ref({ subject: '', body: '', category: 'general' })
|
||||
|
||||
const statusTabs = [
|
||||
{ label: 'All', value: '' },
|
||||
{ label: 'Open', value: 'open' },
|
||||
{ label: 'In Progress', value: 'in_progress' },
|
||||
{ label: 'Resolved', value: 'resolved' },
|
||||
{ label: 'Closed', value: 'closed' },
|
||||
]
|
||||
|
||||
async function fetchTickets() {
|
||||
loading.value = true
|
||||
try { const r = await getTickets(); tickets.value = r.items }
|
||||
finally { loading.value = false }
|
||||
try {
|
||||
const r = await getTickets({
|
||||
page: page.value,
|
||||
per_page: perPage,
|
||||
status: statusFilter.value || undefined,
|
||||
})
|
||||
tickets.value = r.items
|
||||
total.value = r.total
|
||||
} finally { loading.value = false }
|
||||
}
|
||||
|
||||
async function submitTicket() {
|
||||
submitting.value = true
|
||||
try {
|
||||
await createTicket(form.value)
|
||||
toast.success('Ticket submitted')
|
||||
toast.success('Ticket submitted successfully')
|
||||
showCreate.value = false
|
||||
form.value = { subject: '', body: '', category: 'general' }
|
||||
page.value = 1
|
||||
statusFilter.value = ''
|
||||
await fetchTickets()
|
||||
} catch { toast.error('Failed to submit ticket') }
|
||||
finally { submitting.value = false }
|
||||
|
||||
Reference in New Issue
Block a user