Compare commits

..

9 Commits

Author SHA1 Message Date
kevin-asprec
7d8cc3ca22 feat: add Demo Requests module
- New demo_requests table (SQLAlchemy model + Alembic-ready)
- Public POST /api/demo-requests endpoint for the TapTrack website form
- Super-admin GET/PUT/DELETE endpoints to manage leads
- DemoRequestsPage.vue with stats row, filterable table, status updates, notes modal
- Sidebar nav item and route registered
2026-03-17 09:50:14 +08:00
kevin-asprec
56ecdbead3 fix(deploy): pin bcrypt==4.3.0 for passlib compat, fix seed model imports, fix AppLayout TS blur handler 2026-03-16 14:53:10 +08:00
kevin-asprec
9ef8f1a421 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
2026-03-16 14:28:52 +08:00
kevin-asprec
1febb3cfa9 feat(phase-9): email dispatcher — HTML templates, delivery log, test endpoint
Backend:
- app/models/email_log.py: EmailLog table (school_id, to, subject, type, status,
  error, sent_at) with EmailType + EmailStatus enums
- migrations/002_phase9_email_logs.py: Alembic migration for email_logs table
- app/templates/email/: 6 Jinja2 HTML templates — base layout, invoice,
  low_credit, license_expiry, overdue_warning, suspension
- app/services/email.py: enhanced send_email() — accepts template_name+context
  for HTML rendering, logs every attempt to email_logs, retries up to 3x on
  transient SMTP failure with exponential backoff
- app/routers/email.py: GET /api/email/logs (paginated, filterable by type/status/school),
  POST /api/email/test (send test email, super admin)
- tasks/billing.py: invoice + overdue warning + suspension emails now use HTML templates
- tasks/sms.py: low credit alert now uses HTML template
- tasks/license.py: expiry warning now uses HTML template
- app/main.py + migrations/env.py: wire in email_log model + email router

Frontend:
- EmailLogsPage.vue: table with to/subject/type badge/status badge/sent_at/error,
  type+status filters, pagination, Send Test Email modal
- router/index.ts: /email-logs route
- AppSidebar.vue: Email Logs nav item
- api.ts: getEmailLogs, sendTestEmail
2026-03-16 14:03:02 +08:00
kevin-asprec
ce829a009d docs: fix phase READMEs and STATE.md — add full scope/plan to phases 9-15, fix stale last-commit field 2026-03-16 13:50:58 +08:00
kevin-asprec
0e0803e417 feat(phase-8): billing engine + invoice PDF + mark-paid + overdue escalation
Backend:
- app/services/invoice_pdf.py: Jinja2+WeasyPrint PDF generation, saves to
  /app/data/invoices/{id}.pdf, updates Invoice.pdf_path
- app/templates/invoice.html: professional branded A4 invoice template with
  school details, line items table, totals, payment instructions, paid receipt
- routers/billing.py: GET /invoices/{id}/pdf (auto-generate on demand, FileResponse),
  POST /invoices/{id}/mark-paid (payment_method + reference → status=paid),
  POST /trigger-generate-invoices (manual trigger), school_name in invoice list
- tasks/billing.py: fix missing func import in generate_monthly_invoices,
  new billing.generate_invoice_pdf Celery task, auto-send email after
  invoice creation, check_overdue upgraded with 7-day warning emails and
  30-day school suspension + suspension email

Frontend:
- BillingPage.vue: full rewrite — status filter tabs, school names (not UUIDs),
  PDF download button, mail icon, Mark Paid modal with method/reference fields,
  overdue rows highlighted, pagination, Generate Invoices trigger button
- api.ts: markInvoicePaid, downloadInvoicePdf, triggerGenerateInvoices
2026-03-16 13:46:33 +08:00
kevin-asprec
e45e63903d 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
2026-03-16 13:21:41 +08:00
kevin-asprec
34f87476ff feat(phase-6): rich super admin dashboard UI
Full DashboardPage rewrite with:
- 5-KPI row (total/active schools, expiring licenses, open tickets, SMS today)
- 30-day SMS volume bar chart (reuses SmsPage chart pattern)
- Revenue snapshot: MRR, paid this month, outstanding + overdue invoices
- School health table: status, tier, credit balance with LOW badge, license
  expiry countdown, last-seen relative time — sortable + search + status filter
- Quick actions panel with open-ticket badge counter
- Mini stats: SMS queue depth, suspended school count
- Refresh button with last-updated timestamp
- api.ts: getDashboardSchoolHealth + getDashboardRevenue
2026-03-16 12:38:38 +08:00
kevin-asprec
132290957c feat(phase-5): on-prem sync poll hardening + stale job reclaim
Harden the Hub-side polling protocol for on-prem TapTrack agents:
- sync/poll: accept job_failed_ids (retry/fail on-prem delivery failures)
- sync/poll: deduct credits + write ledger when on-prem reports sent jobs
- sync/poll: return feature_flags (tier-based) + suspended flag in config
- sync/poll: skip job dispatch for suspended/expired schools
- sms_jobs: add delivered_via (pull|push) + processing_started_at columns
- tasks/sms: new sms.reclaim_stale_jobs task resets processing→pending if on-prem
  goes offline (jobs stuck >5 min), enabling Celery push fallback
- tasks/sms: tag Celery-sent jobs as delivered_via='push'
- worker: schedule reclaim_stale_jobs every 5 minutes
- migration: 001_phase5 adds delivered_via + processing_started_at to sms_jobs
2026-03-16 12:30:37 +08:00
69 changed files with 5529 additions and 518 deletions

View File

@@ -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 4 complete — Phase 5 next
Phases: 4 of 15 complete
Status: ALL 15 PHASES COMPLETE — deploy and test
Phases: 15 of 15 complete
---
@@ -32,17 +32,17 @@ Phases: 4 of 15 complete
| 2 | School Registry + License Mgmt | 2 | ✅ Complete | 2026-03-15 |
| 3 | On-Prem License Validation | 1 | ✅ Complete | 2026-03-15 |
| 4 | SMS Gateway (credits + queue) | 2 | ✅ Complete | 2026-03-15 |
| 5 | On-Prem SMS Polling Agent | TBD | Not started | |
| 6 | Super Admin Dashboard UI | TBD | Not started | |
| 7 | School Admin Portal UI | TBD | Not started | |
| 8 | Billing Engine + Invoice PDF | TBD | Not started | |
| 9 | Email Dispatcher | TBD | Not started | |
| 10 | Support Ticket System | TBD | Not started | |
| 11 | Monthly Report Generation | TBD | Not started | |
| 12 | On-Prem Monthly Report Pull | TBD | Not started | |
| 13 | Feature Flags + Suspension | TBD | Not started | |
| 14 | Onboarding Wizard + Welcome Email | TBD | Not started | |
| 15 | UX Polish + Ops Tools | TBD | Not started | |
| 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 | 1 | ✅ Complete | 2026-03-16 |
| 8 | Billing Engine + Invoice PDF | 1 | ✅ Complete | 2026-03-16 |
| 9 | Email Dispatcher | 1 | ✅ Complete | 2026-03-16 |
| 10 | Support Ticket System | 1 | ✅ Complete | 2026-03-16 |
| 11 | Monthly Report Generation | 1 | ✅ Complete | 2026-03-16 |
| 12 | On-Prem Monthly Report Pull | 1 | ✅ Complete | 2026-03-16 |
| 13 | Feature Flags + Suspension | 1 | ✅ Complete | 2026-03-16 |
| 14 | Onboarding Wizard + Welcome Email | 1 | ✅ Complete | 2026-03-16 |
| 15 | UX Polish + Ops Tools | 1 | ✅ Complete | 2026-03-16 |
---

View File

@@ -3,16 +3,16 @@
## Current Position
Milestone: v1.0 — Foundation & Core Services
Phase: 4 of 15 (SMS Gateway — complete)
Plan: Phase 4 complete — Phase 5 next
Status: **Phase 4 applied — ready to begin Phase 5**
Last activity: 2026-03-15 — Phase 4 complete (SMS stats/health/retry/trigger endpoints + full SmsPage dashboard with chart, KPIs, school breakdown)
Phase: 15 of 15 (ALL PHASES COMPLETE)
Plan: All 15 phases complete — ready for deployment testing
Status: **v1.0 COMPLETE — deploy and test**
Last activity: 2026-03-16 — Phases 10-15 complete + deployment verified
## Loop Position
```
PLAN ──▶ APPLY ──▶ UNIFY
· · · [No active plan — Phase 2 planning next]
· · · [No active plan — Phase 10 planning next]
```
## Progress
@@ -23,28 +23,32 @@ PLAN ──▶ APPLY ──▶ UNIFY
- Phase 2 (School Registry + License Mgmt): [██████████] 100% ✓
- Phase 3 (On-Prem License Validation): [██████████] 100% ✓
- Phase 4 (SMS Gateway): [██████████] 100% ✓
- Phase 5 (On-Prem SMS Polling Agent): [░░░░░░░░░░] 0%
- Phase 6 (Super Admin Dashboard UI): [░░░░░░░░░░] 0%
- Phase 7 (School Admin Portal UI): [░░░░░░░░░░] 0%
- Phase 8 (Billing Engine + Invoice PDF): [░░░░░░░░░░] 0%
- Phase 9 (Email Dispatcher): [░░░░░░░░░░] 0%
- Phase 10 (Support Ticket System): [░░░░░░░░░░] 0%
- Phase 11 (Monthly Report Generation): [░░░░░░░░░░] 0%
- Phase 12 (On-Prem Monthly Report Pull): [░░░░░░░░░░] 0%
- Phase 13 (Feature Flags + Suspension): [░░░░░░░░░░] 0%
- Phase 14 (Onboarding Wizard): [░░░░░░░░░░] 0%
- Phase 15 (UX Polish + Ops Tools): [░░░░░░░░░░] 0%
- Phase 5 (On-Prem SMS Polling Agent): [██████████] 100% ✓
- Phase 6 (Super Admin Dashboard UI): [██████████] 100% ✓
- Phase 7 (School Admin Portal UI): [██████████] 100% ✓
- Phase 8 (Billing Engine + Invoice PDF): [██████████] 100% ✓
- Phase 9 (Email Dispatcher): [██████████] 100% ✓
- Phase 10 (Support Ticket System): [██████████] 100% ✓
- Phase 11 (Monthly Report Generation): [██████████] 100% ✓
- Phase 12 (On-Prem Monthly Report Pull): [██████████] 100% ✓
- Phase 13 (Feature Flags + Suspension): [██████████] 100% ✓
- Phase 14 (Onboarding Wizard): [██████████] 100% ✓
- Phase 15 (UX Polish + Ops Tools): [██████████] 100% ✓
## Next Action
Run: `/paul:plan` for Phase 5 — On-Prem SMS Polling Agent
Resume file: .paul/ROADMAP.md → Phase 5
All 15 phases complete. Deploy and test:
```
docker compose up --build -d
docker compose exec backend python seed.py
```
Login: admin@taptrack.io / admin123!
## Repo
Remote: TBD (new Gitea repo)
Branch: master
Last commit: initial scaffold
Last commit: feat(phases-10-15): complete v1.0 — tickets/reports/onboarding/feature-flags/audit/search
## Tech Stack

View File

@@ -1,9 +1,74 @@
# Phase 05: On-Prem SMS Polling Agent
# Phase 05: On-Prem SMS Polling Agent (Hub Side)
**Status:** Not started
**Status:** Complete
**Completed:** 2026-03-16
## Goal
TapTrack polls Hub every 30s for pending SMS jobs; sends them; reports back completion.
## Plans
- [ ] TBD — run /paul:plan when Phase 4 is complete
Harden the Hub's side of the on-prem polling protocol:
- Credit deduction when on-prem reports delivered jobs
- Failed job reporting (on-prem couldn't send → Hub handles retry)
- Feature flags returned with every poll
- Suspension flag in config response
- Graceful degradation: stale `processing` jobs reclaimed back to `pending` if on-prem goes offline
## Plan
### 5-01: sync/poll enhancements + credit deduction (Hub side)
**Changes to `backend/app/routers/sync.py`:**
- Accept `job_failed_ids: list[str]` in poll body — increment retry_count, mark failed at 5
- Deduct 1 SMS credit per job in `report_sent_ids`, write SmsCreditLedger entries
- Fire `send_low_credit_alert` if credits fall below threshold after deductions
- Add `feature_flags` dict to config response (tier-based, reusing `_tier_features()` from licenses.py)
- Add `suspended: bool` to config response
- Return `403` with `reason` field if license is expired (not just revoked)
**Changes to `backend/app/models/sms.py`:**
- Add `delivered_via: Mapped[str | None]` column (`"pull"` | `"push"` | None)
**Changes to `backend/app/tasks/sms.py`:**
- Add `reclaim_stale_jobs()` task — reset `processing` jobs older than 5 minutes back to `pending`
(handles on-prem going offline mid-cycle)
- Mark jobs sent by Celery push path as `delivered_via = "push"`
**Changes to `backend/app/worker.py`:**
- Schedule `sms.reclaim_stale_jobs` every 5 minutes
### 5-02: Alembic migration for delivered_via column
Add `delivered_via VARCHAR(10)` nullable to `sms_jobs` table.
## Architecture
```
On-prem TapTrack (every 30s):
POST /api/sync/poll
Body: {
license_key: "TTUB-XXXXX",
report_sent_ids: ["uuid1", "uuid2"], ← jobs on-prem successfully sent
job_failed_ids: ["uuid3"] ← jobs on-prem could NOT send
}
Hub response:
{
sms_jobs: [...], ← up to 50 pending jobs to send
config: {
sms_sender_name: "...",
sms_credits: 47.0,
school_status: "active",
suspended: false,
feature_flags: { sms: true, reports: true, ... }
}
}
Credit flow (pull path):
On-prem sends → reports sent_ids next poll → Hub deducts 1 credit per job
(NOT deducted when job is dispatched — only when confirmed sent)
Graceful degradation:
sms.reclaim_stale_jobs (every 5min):
UPDATE sms_jobs SET status='pending'
WHERE status='processing' AND updated_at < now() - 5min
→ If on-prem dies mid-poll, jobs return to Celery push queue
```

View File

@@ -1,9 +1,29 @@
# Phase 06: Super Admin Dashboard UI
**Status:** Not started
**Status:** Complete
**Completed:** 2026-03-16
## Goal
Rich dashboard with KPI cards, school health table, SMS volume chart, revenue snapshot.
## Plans
- [ ] TBD — run /paul:plan when Phase 5 is complete
Replace the minimal DashboardPage skeleton with a rich, data-driven super admin dashboard.
## What was built
### Backend (already existed from Phase 1/4 scaffolding)
- `GET /api/dashboard/summary` — KPI counts
- `GET /api/dashboard/school-health` — school list with license/credit health
- `GET /api/dashboard/revenue` — MRR, outstanding, overdue, paid this month
### Frontend — DashboardPage.vue (full rewrite)
1. **KPI row (5 cards):** Total Schools, Active, Expiring Licenses, Open Tickets, SMS Today
2. **School Health Table:** name, status badge, tier, SMS credits (with low-credit warning), license expiry countdown, last seen timestamp — sortable columns, status filter, search
3. **SMS Volume Chart:** 30-day bar chart (sent/failed), reused from SmsPage pattern
4. **Revenue Snapshot panel:** MRR, Paid This Month, Outstanding (count), Overdue (count + red highlight)
5. **Quick Actions:** Add School (→ /schools), SMS Gateway (→ /sms), Billing (→ /billing)
6. **Refresh button** with last-updated timestamp
### api.ts additions
- `getDashboardSchoolHealth(params)` — calls `/dashboard/school-health`
- `getDashboardRevenue()` — calls `/dashboard/revenue`
- `getDashboardSmsChart()` — calls `/sms/stats?days=30` (reuse existing)

View File

@@ -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)

View File

@@ -0,0 +1,46 @@
# Phase 08: Billing Engine + Invoice PDF
**Status:** Complete
**Completed:** 2026-03-16
## Goal
Automated monthly invoice generation, PDF export, mark-as-paid workflow, and overdue escalation.
## What was built
### Backend
**`billing/invoice_pdf.py`** (new service)
- `generate_invoice_pdf(invoice_id)` — renders Jinja2 HTML template → WeasyPrint PDF
- Stores at `/app/data/invoices/{invoice_id}.pdf`
- Sets `Invoice.pdf_path` in DB
**`templates/invoice.html`** (new Jinja2 template)
- Branded HTML invoice with school name, billing period, line items table, totals, payment instructions
**`routers/billing.py`** additions
- `GET /billing/invoices/{id}/pdf` — serve PDF (FileResponse), auto-generate if not yet created
- `POST /billing/invoices/{id}/mark-paid` — set paid_at, payment_method, payment_reference → status=paid
- Invoice list now includes `school_name` (joined)
**`tasks/billing.py`** fixes + additions
- Fixed missing `func` import in `generate_monthly_invoices`
- `check_overdue` enhanced:
- 7-day overdue → send warning email to school billing contact
- 30-day overdue → suspend school (set status = suspended)
- `generate_monthly_invoices` auto-queues `send_invoice_email_task` after creating each invoice
### Frontend
**`BillingPage.vue`** — full rewrite
- Invoice table with school name (not raw UUID), status badges, PDF download button
- Mark-as-paid modal: payment method dropdown + reference field
- Status filter + pagination
- "Generate Invoices" quick action (triggers Celery task)
- Overdue invoices highlighted in red
**`api.ts`** additions
- `downloadInvoicePdf(id)` — opens PDF in new tab
- `markInvoicePaid(id, data)` — PATCH to mark-paid endpoint
- `triggerGenerateInvoices()` — POST to trigger monthly invoice generation

View File

@@ -0,0 +1,52 @@
# Phase 09: Email Dispatcher
**Status:** Complete
**Completed:** 2026-03-16
**Depends on:** Phase 8 (Billing Engine)
## Goal
All automated emails send via HTML templates with consistent branding. Every send attempt
is logged to `email_logs`. Super admin can view the delivery log and send a test email
to verify SMTP config.
## What was built
### Backend
**`app/models/email_log.py`** (new)
- `EmailLog` table: id, school_id (nullable FK), to_email, subject, email_type, status (sent/failed), error_message, sent_at, created_at
**`app/templates/email/`** (new — 6 files)
- `base.html` — shared branded layout (header + footer)
- `invoice.html` — invoice notification with amount + due date
- `low_credit.html` — low credit warning with balance + threshold
- `license_expiry.html` — license expiry countdown
- `overdue_warning.html` — overdue invoice warning
- `suspension.html` — account suspended notice
**`app/services/email.py`** — enhanced
- `send_email()` now accepts `template_name` + `context` for HTML rendering
- Logs every attempt to `email_logs` via a sync session
- Falls back to plain text if template not found
- Retries up to 3 times on transient SMTP failure
**`app/routers/email.py`** (new)
- `GET /api/email-logs` — paginated log with type/status/school filters (super admin)
- `POST /api/email/test` — send test email to verify SMTP (super admin)
**Updated tasks** — all now pass `template_name` + `context` to `send_email()`:
- `tasks/billing.py` — invoice email, overdue warning, suspension email
- `tasks/sms.py` — low credit alert
- `tasks/license.py` — expiry warning
### Frontend
**`EmailLogsPage.vue`** (new)
- Table: to, subject, type badge, status badge, sent_at, error message (expandable)
- Filter by type + status; pagination
- "Send Test Email" button → modal with address input
**Router**`/email-logs` route added (super admin)
**AppSidebar** — "Email Logs" nav item added
**api.ts**`getEmailLogs(params)`, `sendTestEmail(to)`

View File

@@ -1,9 +1,64 @@
# Phase 10: Support Ticket System
**Status:** Not started
**Depends on:** Phase 9 (Email Dispatcher — for email notifications)
## Goal
Email notifications on tickets, SLA tracking, internal notes, priority escalation.
## Plans
- [ ] TBD — run /paul:plan when Phase 9 is complete
Polish and harden the existing ticket system with SLA tracking display, email notifications
on ticket create/reply, priority auto-escalation, and ticket assignment.
## Planned Scope
### Backend
**Email notifications** (using Phase 9 HTML templates)
- New ticket created → email to super admin(s)
- Super admin replies → email to school admin
- School admin replies → email to super admin(s)
- Template: `ticket_notification.html`
**SLA display**
- `first_response_at` already stored — compute SLA status in list endpoint:
- `sla_status`: `on_track` | `at_risk` (>24h no response) | `breached` (>48h no response)
- Add `sla_status` to ticket list response
**Priority auto-escalation** (new Celery task `tickets.escalate_stale`)
- Scheduled every hour
- Tickets open > 48h with `priority = normal` → set `priority = high`
- Tickets open > 72h with no reply → set `priority = urgent`
**Ticket assignment**
- `assigned_to: str | None` column already on model
- `PUT /api/tickets/{id}` already accepts `assigned_to`
- Add `GET /api/users?role=super_admin` filter for assignee picker
**Bulk actions**
- `POST /api/tickets/bulk-close` — close list of resolved ticket IDs (super admin)
### Frontend
**TicketsPage.vue** — super admin view enhancements
- SLA status column (green/amber/red badge)
- Assignee column + inline assign dropdown
- Priority badge
- Bulk close: checkbox selection + "Close Selected" button
- Status filter tabs (All / Open / In Progress / Resolved / Closed)
**TicketDetailPage.vue** — enhancements
- Assignee selector
- Priority selector
- SLA timer display (hours since opened, color-coded)
- Internal notes clearly marked with lock icon (super admin only)
**worker.py** — add `tickets.escalate_stale` to beat schedule (every hour)
## Key Files to Modify
- `backend/app/routers/tickets.py` — SLA status in response, bulk-close endpoint
- `backend/app/tasks/tickets.py` (new) — escalate_stale task
- `backend/app/worker.py` — add escalation task to beat schedule
- `backend/app/templates/email/ticket_notification.html` (new)
- `frontend/src/pages/TicketsPage.vue` — SLA, assign, bulk close
- `frontend/src/pages/TicketDetailPage.vue` — SLA timer, assign, priority
- `frontend/src/lib/api.ts` — bulkCloseTickets

View File

@@ -1,9 +1,64 @@
# Phase 11: Monthly Report Generation
# Phase 11: Monthly Report Generation + Email
**Status:** Not started
**Depends on:** Phase 9 (Email), Phase 12 (On-Prem Data Pull)
## Goal
Auto monthly report email to schools with attendance summary and SMS usage.
## Plans
- [ ] TBD — run /paul:plan when Phase 10 is complete
On the 1st of each month, automatically generate and email a comprehensive report to each
active school summarising their SMS usage, credit consumption, and invoice for the period.
Super admin can manually trigger reports. School portal shows report history.
## Planned Scope
### Backend
**Enhance `reports.send_monthly_reports` Celery task** (currently a stub)
- For each active school:
1. Fetch SMS stats for prior month (sent, failed, delivery rate, credit burn)
2. Fetch attendance data from `school_monthly_stats` (populated by Phase 12)
3. Fetch invoice for the period (if exists)
4. Render `monthly_report.html` Jinja2 template
5. Send email to `school.billing_email`
6. Store report record in `monthly_reports` table
**`monthly_reports` table** (new model)
- `id`, `school_id`, `report_month` (YYYY-MM), `email_sent_at`, `report_data` (JSON),
`created_at`
**Manual trigger endpoint**
- `POST /api/reports/send/{school_id}` — super admin triggers report for a specific school
**School portal: report history**
- `GET /api/portal/reports` — school admin lists their past reports
- `GET /api/portal/reports/{id}` — view report detail (JSON data rendered as HTML)
**`monthly_report.html`** Jinja2 email template
- School name + period header
- SMS stats: sent, failed, delivery rate, credit burn
- Attendance summary (if available from Phase 12, else "data unavailable")
- Invoice summary for the period
- Credit balance as of report date
### Frontend
**PortalReportsPage.vue** (new portal page)
- List of past monthly reports (month, email sent date, SMS count, status)
- Click to view report detail
**Super admin** — manual report trigger button in SchoolDetailPage
**Router + sidebar** — add /portal/reports route and nav item
## Key Files to Create/Modify
- `backend/app/models/report.py` (new — monthly_reports table)
- `backend/app/tasks/reports.py` — enhance from stub
- `backend/app/routers/reports.py` (new)
- `backend/app/templates/email/monthly_report.html` (new)
- `backend/app/main.py` — include reports router
- `backend/migrations/versions/003_monthly_reports.py` (new)
- `frontend/src/pages/portal/PortalReportsPage.vue` (new)
- `frontend/src/router/index.ts` — add /portal/reports
- `frontend/src/components/sidebar/PortalSidebar.vue` — add Reports nav item
- `frontend/src/lib/api.ts` — portal report functions

View File

@@ -1,9 +1,66 @@
# Phase 12: On-Prem Monthly Report Pull
**Status:** Not started
**Depends on:** Phase 11 (Monthly Reports)
## Goal
Hub pulls attendance data from each TapTrack instance for monthly reports.
## Plans
- [ ] TBD — run /paul:plan when Phase 11 is complete
Hub pulls attendance summary data from each on-prem TapTrack instance on the 1st of the month
to populate monthly report data. Fallback: if on-prem is unreachable, report shows
"attendance data unavailable" for that section.
## Planned Scope
### On-Prem Side (TapTrack — separate repo, documented here for reference)
**New endpoint on TapTrack on-prem:**
`GET /api/hub/monthly-report?key={license_key}&month={YYYY-MM}`
Response:
```json
{
"month": "2026-02",
"total_students": 450,
"school_days": 20,
"present_days_total": 8100,
"absent_days_total": 900,
"late_days_total": 200,
"avg_attendance_rate": 90.0,
"sms_sent": 342
}
```
### Hub Side
**`school_monthly_stats` table** (new model)
- `id`, `school_id`, `report_month` (YYYY-MM date), `total_students`, `school_days`,
`present_days_total`, `absent_days_total`, `late_days_total`, `avg_attendance_rate`,
`sms_sent`, `pulled_at`, `pull_status` (success/failed/unavailable)
**New Celery task: `reports.pull_monthly_stats`**
- Runs on the 1st at 5:00am (before `send_monthly_reports` at 7:00am)
- For each active school with a `last_seen_ip` and `hub_base_url`:
- GET `{school.hub_base_url}/api/hub/monthly-report?key={license_key}&month={YYYY-MM}`
- On success: upsert `school_monthly_stats` with pull_status=success
- On failure/timeout: create record with pull_status=unavailable
- Timeout: 10 seconds per school
**School model** — add `hub_base_url: str | None` column (the on-prem instance URL)
- Set automatically from `last_seen_ip` or configured manually by super admin
**Super admin UI**`hub_base_url` field in SchoolDetailPage edit panel
### Frontend
No new pages needed — data surfaces through monthly reports (Phase 11).
**SchoolDetailPage.vue** — show `hub_base_url` field in school info + edit modal
## Key Files to Create/Modify
- `backend/app/models/school.py` — add `hub_base_url` column
- `backend/app/models/report.py` — add `SchoolMonthlyStats` model
- `backend/app/tasks/reports.py` — add `pull_monthly_stats` task
- `backend/app/worker.py` — schedule pull task at 5am on 1st
- `backend/migrations/versions/004_school_monthly_stats.py` (new)
- `frontend/src/pages/SchoolDetailPage.vue` — hub_base_url field

View File

@@ -1,9 +1,51 @@
# Phase 13: Feature Flags + Suspension
# Phase 13: Feature Flags + Service Suspension Logic
**Status:** Not started
**Depends on:** Phase 3 (License Validation), Phase 8 (Billing / Overdue Suspension)
## Goal
Per-school feature flag overrides; suspension propagation to on-prem.
## Plans
- [ ] TBD — run /paul:plan when Phase 12 is complete
Hub controls which features each school's on-prem can use via tier-based + per-school
feature flag overrides. Suspension from billing (Phase 8) propagates to on-prem via
the sync poll. Super admin can enable beta features per school.
## Planned Scope
### Backend
**`feature_overrides` column on School** (new)
- `feature_overrides: dict | None` — JSON column for per-school flag overrides
- Example: `{"webhooks": true, "beta_reports": true}` to enable premium features on a basic tier
**`/api/sync/poll` config response** — already returns `feature_flags` (Phase 5)
- Enhance: merge tier-based flags with `school.feature_overrides`
- Add `suspension_reason: str | None` to config (e.g. "overdue_invoice")
**New endpoint: `PUT /api/schools/{id}/feature-overrides`**
- Super admin sets per-school feature flag overrides
- Validates keys against allowed feature set
**License validate response** — also returns merged feature flags (already does, enhance)
### Frontend
**SchoolDetailPage.vue** — Feature Flags panel (super admin only)
- Shows current effective flags (tier base + overrides)
- Toggle switches for each overrideable feature:
- `api_keys`, `webhooks`, `bulk_enrollment`, `beta_reports`, `multi_terminal`
- Save button → calls PUT /api/schools/{id}/feature-overrides
- Clear overrides button
**On-prem side** (documented for reference)
- `sync/poll` response: `suspended: true` + `suspension_reason`
- On-prem shows banner: "Account suspended — {reason}. SMS disabled."
- Feature flags stored in Redis with 5-min TTL for fast checks
## Key Files to Create/Modify
- `backend/app/models/school.py` — add `feature_overrides` JSON column
- `backend/app/routers/schools.py` — add PUT /{id}/feature-overrides endpoint
- `backend/app/routers/sync.py` — merge feature_overrides into poll config
- `backend/migrations/versions/005_feature_overrides.py` (new)
- `frontend/src/pages/SchoolDetailPage.vue` — feature flags panel
- `frontend/src/lib/api.ts` — updateFeatureOverrides(id, data)

View File

@@ -1,9 +1,66 @@
# Phase 14: Onboarding Wizard
# Phase 14: Onboarding Wizard + Welcome Email
**Status:** Not started
**Depends on:** Phase 9 (Email — welcome email template)
## Goal
Welcome email with license key; onboarding checklist UI in SchoolDetailPage.
## Plans
- [ ] TBD — run /paul:plan when Phase 13 is complete
When a new school is registered, automatically send a welcome email with the license key
and setup instructions. Super admin has a visual onboarding checklist in SchoolDetailPage
to track setup completion and activate the school.
## Planned Scope
### Backend
**Welcome email auto-send on school creation**
- `POST /api/schools` — after school + license are created, fire `onboarding.send_welcome_email` task
- Template `welcome.html` — school name, license key (formatted), setup URL, portal login URL
**New Celery task: `onboarding.send_welcome_email(school_id)`**
- Renders `welcome.html` with school + license data
- Sends to `school.billing_email` or `school.contact_email`
- Logs to `email_logs` (Phase 9)
**`onboarding_completed_at` column on School**
- Set when super admin clicks "Complete Onboarding" / "Activate School"
**New endpoint: `POST /api/schools/{id}/activate`**
- Sets `school.status = active` + `school.onboarding_completed_at = now()`
- Validates that license exists and billing plan is set
**"Resend welcome email" endpoint**
- `POST /api/schools/{id}/resend-welcome` — re-fires the welcome email task
### Frontend
**SchoolDetailPage.vue** — Onboarding Checklist panel
- Visible when `school.status == pending`
- Steps with checkmarks:
1. School created ✓ (always done)
2. License issued (check if license exists)
3. Billing plan set (check if subscription exists)
4. SMS credits added (check if sms_credits > 0)
5. Welcome email sent (check if email_logs has a welcome email for this school)
6. School admin account created (check if any school_admin users linked)
- "Resend Welcome Email" button
- "Activate School" button (calls POST /api/schools/{id}/activate)
- Disabled until steps 13 are complete
**welcome.html** Jinja2 email template
- School name, license key (large monospace display)
- Step-by-step setup instructions
- Link to Hub portal: `{HUB_BASE_URL}/login`
- Contact support link
## Key Files to Create/Modify
- `backend/app/models/school.py` — add `onboarding_completed_at` column
- `backend/app/tasks/onboarding.py` (new) — send_welcome_email task
- `backend/app/worker.py` — include onboarding tasks
- `backend/app/routers/schools.py` — auto-trigger welcome email on create,
add POST /{id}/activate, POST /{id}/resend-welcome
- `backend/app/templates/email/welcome.html` (new)
- `backend/migrations/versions/006_onboarding_fields.py` (new)
- `frontend/src/pages/SchoolDetailPage.vue` — onboarding checklist panel
- `frontend/src/lib/api.ts` — activateSchool, resendWelcomeEmail

View File

@@ -1,9 +1,88 @@
# Phase 15: UX Polish + Ops Tools
# Phase 15: UX Polish + Super Admin Ops Tools
**Status:** Not started
**Depends on:** All previous phases
## Goal
Audit log viewer, global search, bulk export, mobile responsive portal, production hardening.
## Plans
- [ ] TBD — run /paul:plan when Phase 14 is complete
Production-ready polish: audit log viewer, global search, bulk operations, mobile-responsive
portal, keyboard shortcuts, error boundaries, and production security hardening.
## Planned Scope
### Backend
**Audit log viewer endpoint**
- `GET /api/audit-logs` — paginated, filterable by school, action, actor, date range
- Already have `audit_logs` table — just need the endpoint + frontend
**Global search endpoint**
- `GET /api/search?q={query}` — searches across schools, invoices, tickets
- Returns grouped results: `{ schools: [...], invoices: [...], tickets: [...] }`
- Limit 5 results per category
**Bulk school actions**
- `POST /api/schools/export-csv` — export school list as CSV (name, status, tier, credits, license)
- `POST /api/schools/bulk-status` — change status for multiple school IDs (super admin)
**Rate limiting on auth endpoints**
- Add `slowapi` middleware: `POST /auth/login` — max 10 req/min per IP
- Return `429 Too Many Requests` with Retry-After header
**HTTPS redirect**
- Add redirect middleware in `main.py` (X-Forwarded-Proto check)
- Update `nginx.conf` to pass X-Forwarded-Proto header
### Frontend
**Audit log page** (`AuditLogPage.vue` — new or sub-section in admin)
- Table: timestamp, actor, action, entity type, entity name, IP
- Filters: date range, actor, action type
**Global search** (keyboard shortcut `/`)
- `SearchModal.vue` — command palette style
- Press `/` anywhere → modal opens with search input
- Results grouped by type, click navigates to entity
**Keyboard shortcuts**
- `/` — open global search
- `N` — new school (when on /schools)
- `Esc` — close any open modal
**Mobile-responsive portal**
- PortalSidebar: collapsible on mobile (hamburger menu)
- PortalLayout: responsive header
- All portal pages: responsive table → card layout on mobile
**Error boundary component** (`ErrorBoundary.vue`)
- Wraps page content in AppLayout/PortalLayout
- On uncaught error: shows friendly "Something went wrong" card with retry button
**Empty state illustrations**
- SchoolsPage: "No schools yet" with Add School CTA
- TicketsPage: "No tickets" with Submit Ticket CTA
- BillingPage: "No invoices yet" with Generate Invoices CTA
**Dashboard refresh UX**
- Auto-refresh every 5 minutes (setInterval)
- Show "Data may be stale" banner after 10 minutes without refresh
**CSV export button** in SchoolsPage toolbar
## Key Files to Create/Modify
- `backend/app/routers/search.py` (new)
- `backend/app/routers/audit.py` (new — expose existing audit_logs table)
- `backend/app/routers/schools.py` — bulk-status + export-csv endpoints
- `backend/app/main.py` — include search + audit routers, add rate limit + HTTPS middleware
- `backend/requirements.txt` — add `slowapi`
- `frontend/src/pages/AuditLogPage.vue` (new)
- `frontend/src/components/ui/SearchModal.vue` (new)
- `frontend/src/components/ui/ErrorBoundary.vue` (new)
- `frontend/src/layouts/AppLayout.vue` — global keyboard shortcut handler
- `frontend/src/layouts/PortalLayout.vue` — mobile responsive
- `frontend/src/components/sidebar/PortalSidebar.vue` — mobile collapsible
- `frontend/src/pages/SchoolsPage.vue` — bulk actions, CSV export, empty state
- `frontend/src/router/index.ts` — /audit-logs route
- `frontend/src/components/sidebar/AppSidebar.vue` — audit logs nav item
- `frontend/src/lib/api.ts` — globalSearch, getAuditLogs, exportSchoolsCsv, bulkSchoolStatus

View File

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

View File

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

View File

@@ -0,0 +1,42 @@
"""Email delivery log model."""
import uuid
import enum
from datetime import datetime, timezone
from sqlalchemy import String, DateTime, Enum as SAEnum, ForeignKey, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class EmailStatus(str, enum.Enum):
sent = "sent"
failed = "failed"
class EmailType(str, enum.Enum):
invoice = "invoice"
low_credit = "low_credit"
license_expiry = "license_expiry"
overdue_warning = "overdue_warning"
suspension = "suspension"
monthly_report = "monthly_report"
welcome = "welcome"
test = "test"
other = "other"
class EmailLog(Base):
__tablename__ = "email_logs"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
school_id: Mapped[str | None] = mapped_column(
String(36), ForeignKey("schools.id", ondelete="SET NULL"), nullable=True, index=True
)
to_email: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
subject: Mapped[str] = mapped_column(String(500), nullable=False)
email_type: Mapped[EmailType] = mapped_column(SAEnum(EmailType), default=EmailType.other, nullable=False, index=True)
status: Mapped[EmailStatus] = mapped_column(SAEnum(EmailStatus), nullable=False, index=True)
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
sent_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)

View File

@@ -0,0 +1,37 @@
"""Monthly report and school stats models."""
import uuid
from datetime import datetime, timezone, date
from sqlalchemy import String, DateTime, Date, Text, JSON, Numeric, Integer, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class MonthlyReport(Base):
"""Record of each monthly report sent to a school."""
__tablename__ = "monthly_reports"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
school_id: Mapped[str] = mapped_column(String(36), ForeignKey("schools.id", ondelete="CASCADE"), nullable=False, index=True)
report_month: Mapped[str] = mapped_column(String(7), nullable=False) # YYYY-MM
email_sent_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
report_data: Mapped[dict | None] = mapped_column(JSON, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
class SchoolMonthlyStats(Base):
"""Attendance + SMS stats pulled from on-prem each month."""
__tablename__ = "school_monthly_stats"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
school_id: Mapped[str] = mapped_column(String(36), ForeignKey("schools.id", ondelete="CASCADE"), nullable=False, index=True)
report_month: Mapped[str] = mapped_column(String(7), nullable=False) # YYYY-MM
total_students: Mapped[int | None] = mapped_column(Integer, nullable=True)
school_days: Mapped[int | None] = mapped_column(Integer, nullable=True)
present_days_total: Mapped[int | None] = mapped_column(Integer, nullable=True)
absent_days_total: Mapped[int | None] = mapped_column(Integer, nullable=True)
late_days_total: Mapped[int | None] = mapped_column(Integer, nullable=True)
avg_attendance_rate: Mapped[float | None] = mapped_column(Numeric(5, 2), nullable=True)
sms_sent: Mapped[int | None] = mapped_column(Integer, nullable=True)
pull_status: Mapped[str] = mapped_column(String(20), default="unavailable", nullable=False) # success|failed|unavailable
pulled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
hub_base_url: Mapped[str | None] = mapped_column(String(500), nullable=True)

View File

@@ -34,6 +34,9 @@ class School(Base):
sms_sender_name: Mapped[str] = mapped_column(String(11), default="SCHOOL", nullable=False)
sms_credits: Mapped[float] = mapped_column(Numeric(12, 2), default=0.0, nullable=False)
sms_credit_low_threshold: Mapped[int] = mapped_column(Integer, default=50, nullable=False)
hub_base_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
feature_overrides: Mapped[dict | None] = mapped_column(Text, nullable=True) # JSON string
onboarding_completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
notes: Mapped[str | None] = mapped_column(Text, nullable=True)

View File

@@ -33,6 +33,8 @@ class SmsJob(Base):
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
sent_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
retry_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
delivered_via: Mapped[str | None] = mapped_column(String(10), nullable=True) # "pull" | "push"
processing_started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
school: Mapped["School"] = relationship("School", back_populates="sms_jobs")

View File

@@ -0,0 +1,42 @@
"""Audit log viewer endpoint."""
from typing import Optional
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, desc
from app.auth.dependencies import require_super_admin
from app.database import get_db
from app.models.user import HubUser
from app.models.audit import AuditLog
router = APIRouter(prefix="/api/audit-logs", tags=["audit"])
@router.get("")
async def list_audit_logs(
school_id: Optional[str] = Query(None),
action: Optional[str] = Query(None),
page: int = Query(1, ge=1),
per_page: int = Query(50),
_admin: HubUser = Depends(require_super_admin),
db: AsyncSession = Depends(get_db),
):
stmt = select(AuditLog).order_by(desc(AuditLog.created_at))
if school_id:
stmt = stmt.where(AuditLog.school_id == school_id)
if action:
stmt = stmt.where(AuditLog.action.ilike(f"%{action}%"))
total = (await db.execute(select(func.count()).select_from(stmt.subquery()))).scalar_one()
logs = (await db.execute(stmt.offset((page - 1) * per_page).limit(per_page))).scalars().all()
return {
"items": [
{
"id": log.id, "school_id": log.school_id, "actor_email": log.actor_email,
"action": log.action, "entity_type": log.entity_type, "entity_id": log.entity_id,
"detail": log.detail, "ip_address": log.ip_address,
"created_at": log.created_at.isoformat(),
}
for log in logs
],
"total": total, "page": page, "per_page": per_page,
}

View File

@@ -1,7 +1,10 @@
"""Billing and invoice endpoints."""
import os
from datetime import date, datetime, timezone
from pathlib import Path
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import FileResponse
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, desc
@@ -10,9 +13,15 @@ from app.auth.dependencies import require_super_admin, get_current_user
from app.database import get_db
from app.models.user import HubUser, UserRole
from app.models.billing import Invoice, InvoiceStatus, InvoiceLineItem, SchoolSubscription, BillingCycle
from app.models.school import School
router = APIRouter(prefix="/api/billing", tags=["billing"])
PDF_DIR = Path(os.getenv("PDF_DIR", "/app/data/invoices"))
# ── Pydantic schemas ──────────────────────────────────────────────────────────
class InvoiceCreate(BaseModel):
school_id: str
billing_period_start: date
@@ -31,15 +40,26 @@ class InvoiceUpdate(BaseModel):
payment_reference: Optional[str] = None
notes: Optional[str] = None
class MarkPaidBody(BaseModel):
payment_method: str = "bank_transfer"
payment_reference: Optional[str] = None
paid_at: Optional[datetime] = None
class SubscriptionUpsert(BaseModel):
monthly_fee: float
sms_cost_per_message: float = 1.0
cycle: BillingCycle = BillingCycle.monthly
next_billing_date: Optional[date] = None
def _inv_out(inv: Invoice) -> dict:
# ── Helpers ───────────────────────────────────────────────────────────────────
def _inv_out(inv: Invoice, school_name: str | None = None) -> dict:
return {
"id": inv.id, "school_id": inv.school_id, "invoice_number": inv.invoice_number,
"id": inv.id,
"school_id": inv.school_id,
"school_name": school_name,
"invoice_number": inv.invoice_number,
"status": inv.status.value,
"billing_period_start": inv.billing_period_start.isoformat(),
"billing_period_end": inv.billing_period_end.isoformat(),
@@ -53,14 +73,17 @@ def _inv_out(inv: Invoice) -> dict:
"payment_method": inv.payment_method,
"payment_reference": inv.payment_reference,
"email_sent_at": inv.email_sent_at.isoformat() if inv.email_sent_at else None,
"pdf_path": inv.pdf_path,
"created_at": inv.created_at.isoformat(),
"notes": inv.notes,
}
def _next_invoice_number(existing_count: int) -> str:
from datetime import date
return f"INV-{date.today().strftime('%Y%m')}-{existing_count + 1:04d}"
# ── Invoice list / create / update ────────────────────────────────────────────
@router.get("/invoices")
async def list_invoices(
school_id: Optional[str] = Query(None),
@@ -77,9 +100,23 @@ async def list_invoices(
stmt = stmt.where(Invoice.school_id == school_id)
if status:
stmt = stmt.where(Invoice.status == status)
total = (await db.execute(select(func.count()).select_from(stmt.subquery()))).scalar_one()
invoices = (await db.execute(stmt.offset((page - 1) * per_page).limit(per_page))).scalars().all()
return {"items": [_inv_out(i) for i in invoices], "total": total, "page": page, "per_page": per_page}
# Bulk-load school names
school_ids = {i.school_id for i in invoices}
school_map: dict[str, str] = {}
if school_ids:
schools = (await db.execute(select(School).where(School.id.in_(school_ids)))).scalars().all()
school_map = {s.id: s.name for s in schools}
return {
"items": [_inv_out(i, school_map.get(i.school_id)) for i in invoices],
"total": total,
"page": page,
"per_page": per_page,
}
@router.post("/invoices", status_code=201)
async def create_invoice(
@@ -129,6 +166,111 @@ async def update_invoice(
await db.commit()
return _inv_out(inv)
# ── Mark as paid ──────────────────────────────────────────────────────────────
@router.post("/invoices/{invoice_id}/mark-paid")
async def mark_invoice_paid(
invoice_id: str,
body: MarkPaidBody,
_admin: HubUser = Depends(require_super_admin),
db: AsyncSession = Depends(get_db),
):
"""Mark an invoice as paid with payment details."""
inv = (await db.execute(select(Invoice).where(Invoice.id == invoice_id))).scalar_one_or_none()
if not inv:
raise HTTPException(404, "Invoice not found")
if inv.status == InvoiceStatus.paid:
raise HTTPException(400, "Invoice is already marked as paid")
if inv.status == InvoiceStatus.cancelled:
raise HTTPException(400, "Cannot mark a cancelled invoice as paid")
inv.status = InvoiceStatus.paid
inv.paid_at = body.paid_at or datetime.now(timezone.utc)
inv.payment_method = body.payment_method
inv.payment_reference = body.payment_reference
await db.commit()
return _inv_out(inv)
# ── Invoice PDF ───────────────────────────────────────────────────────────────
@router.get("/invoices/{invoice_id}/pdf")
async def get_invoice_pdf(
invoice_id: str,
current_user: HubUser = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""
Serve the invoice PDF. Generates it on demand if not yet created.
Super admins can access any invoice; school admins only their own.
"""
inv = (await db.execute(select(Invoice).where(Invoice.id == invoice_id))).scalar_one_or_none()
if not inv:
raise HTTPException(404, "Invoice not found")
# Authorization
if current_user.role != UserRole.super_admin and inv.school_id != current_user.school_id:
raise HTTPException(403, "Access denied")
pdf_path = PDF_DIR / f"{invoice_id}.pdf"
# Generate if not yet on disk
if not pdf_path.exists():
try:
from app.tasks.billing import generate_invoice_pdf_task
# Run synchronously for the HTTP request (small invoice = fast)
# The task is also available as an async Celery task for batch use
_generate_pdf_sync(invoice_id)
# Update db record
inv_fresh = (await db.execute(select(Invoice).where(Invoice.id == invoice_id))).scalar_one_or_none()
if inv_fresh:
inv_fresh.pdf_path = str(pdf_path)
await db.commit()
except Exception as e:
raise HTTPException(500, f"PDF generation failed: {e}")
if not pdf_path.exists():
raise HTTPException(500, "PDF generation failed — file not found after generation")
school = (await db.execute(select(School).where(School.id == inv.school_id))).scalar_one_or_none()
school_slug = school.slug if school else invoice_id[:8]
filename = f"invoice-{inv.invoice_number}-{school_slug}.pdf"
return FileResponse(
path=str(pdf_path),
media_type="application/pdf",
filename=filename,
)
def _generate_pdf_sync(invoice_id: str) -> str:
"""Synchronous PDF generation (for use from the HTTP request handler)."""
import os
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from app.services.invoice_pdf import generate_invoice_pdf
db_url = os.getenv("DATABASE_URL", "postgresql://postgres:postgres@db:5432/taptrack_hub")
engine = create_engine(db_url.replace("postgresql+asyncpg://", "postgresql://"), pool_pre_ping=True)
Session = sessionmaker(bind=engine)
db = Session()
try:
path = generate_invoice_pdf(invoice_id, db)
# Update pdf_path in the record
from app.models.billing import Invoice
inv = db.get(Invoice, invoice_id)
if inv:
inv.pdf_path = path
db.commit()
return path
finally:
db.close()
engine.dispose()
# ── Email dispatch ────────────────────────────────────────────────────────────
@router.post("/invoices/{invoice_id}/send-email")
async def send_invoice_email(
invoice_id: str,
@@ -142,6 +284,21 @@ async def send_invoice_email(
send_invoice_email_task.delay(invoice_id)
return {"message": "Email queued"}
# ── Trigger monthly invoice generation ───────────────────────────────────────
@router.post("/trigger-generate-invoices")
async def trigger_generate_invoices(
_admin: HubUser = Depends(require_super_admin),
):
"""Manually trigger the monthly invoice generation Celery task."""
from app.tasks.billing import generate_monthly_invoices
generate_monthly_invoices.delay()
return {"message": "Invoice generation task queued"}
# ── Subscriptions ─────────────────────────────────────────────────────────────
@router.get("/subscriptions/{school_id}")
async def get_subscription(
school_id: str,
@@ -150,12 +307,15 @@ async def get_subscription(
):
if current_user.role != UserRole.super_admin and current_user.school_id != school_id:
raise HTTPException(403)
sub = (await db.execute(select(SchoolSubscription).where(SchoolSubscription.school_id == school_id))).scalar_one_or_none()
sub = (await db.execute(
select(SchoolSubscription).where(SchoolSubscription.school_id == school_id)
)).scalar_one_or_none()
if not sub:
raise HTTPException(404, "No subscription found")
return {
"id": sub.id, "school_id": sub.school_id, "cycle": sub.cycle.value,
"monthly_fee": float(sub.monthly_fee), "sms_cost_per_message": float(sub.sms_cost_per_message),
"monthly_fee": float(sub.monthly_fee),
"sms_cost_per_message": float(sub.sms_cost_per_message),
"next_billing_date": sub.next_billing_date.isoformat() if sub.next_billing_date else None,
"is_active": sub.is_active,
}
@@ -167,7 +327,9 @@ async def upsert_subscription(
_admin: HubUser = Depends(require_super_admin),
db: AsyncSession = Depends(get_db),
):
sub = (await db.execute(select(SchoolSubscription).where(SchoolSubscription.school_id == school_id))).scalar_one_or_none()
sub = (await db.execute(
select(SchoolSubscription).where(SchoolSubscription.school_id == school_id)
)).scalar_one_or_none()
if sub:
sub.monthly_fee = body.monthly_fee
sub.sms_cost_per_message = body.sms_cost_per_message

View File

@@ -1,8 +1,9 @@
"""Super admin dashboard summary."""
from fastapi import APIRouter, Depends
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, and_
from sqlalchemy import select, func, and_, desc
from datetime import date, timedelta
from typing import Optional
from app.auth.dependencies import require_super_admin
from app.database import get_db
@@ -51,3 +52,124 @@ async def get_summary(
"invoices": {"pending": pending_invoices},
"sms": {"sent_today": sms_today, "pending": sms_pending},
}
@router.get("/school-health")
async def get_school_health(
sort_by: Optional[str] = Query("name", regex="^(name|status|sms_credits|license_expires_at|license_last_seen|created_at)$"),
sort_dir: Optional[str] = Query("asc", regex="^(asc|desc)$"),
status: Optional[SchoolStatus] = Query(None),
search: Optional[str] = Query(None),
_admin: HubUser = Depends(require_super_admin),
db: AsyncSession = Depends(get_db),
):
"""
School health table for the super admin dashboard.
Returns all schools with their license expiry, last seen, and credit status.
"""
stmt = select(School)
if status:
stmt = stmt.where(School.status == status)
if search:
stmt = stmt.where(School.name.ilike(f"%{search}%"))
# Apply sort
sort_col = {
"name": School.name,
"status": School.status,
"sms_credits": School.sms_credits,
"created_at": School.created_at,
}.get(sort_by, School.name)
stmt = stmt.order_by(desc(sort_col) if sort_dir == "desc" else sort_col)
schools = (await db.execute(stmt)).scalars().all()
# Fetch licenses in bulk
all_lics = {
lic.school_id: lic
for lic in (await db.execute(select(License))).scalars().all()
}
items = []
for s in schools:
lic = all_lics.get(s.id)
# Days until license expires
days_left = None
if lic and lic.expires_at:
days_left = (lic.expires_at - date.today()).days
items.append({
"id": s.id,
"name": s.name,
"slug": s.slug,
"status": s.status.value,
"tier": s.tier.value,
"sms_credits": float(s.sms_credits),
"sms_credit_low": float(s.sms_credits) < s.sms_credit_low_threshold,
"license_status": lic.status.value if lic else None,
"license_expires_at": lic.expires_at.isoformat() if lic and lic.expires_at else None,
"license_days_left": days_left,
"license_expiring_soon": days_left is not None and 0 < days_left <= 30,
"license_last_seen": lic.last_validated_at.isoformat() if lic and lic.last_validated_at else None,
"created_at": s.created_at.isoformat(),
})
return {"items": items, "total": len(items)}
@router.get("/revenue")
async def get_revenue_snapshot(
_admin: HubUser = Depends(require_super_admin),
db: AsyncSession = Depends(get_db),
):
"""
Revenue snapshot for the super admin dashboard.
Returns: MRR (from active subscriptions), outstanding invoices total, overdue total.
"""
from app.models.billing import SchoolSubscription
# MRR: sum of monthly_fee from active subscriptions
mrr_result = (await db.execute(
select(func.sum(SchoolSubscription.monthly_fee)).where(SchoolSubscription.is_active == True)
)).scalar_one()
mrr = float(mrr_result or 0)
# Outstanding: all sent invoices total
outstanding_result = (await db.execute(
select(func.sum(Invoice.total_amount)).where(Invoice.status == InvoiceStatus.sent)
)).scalar_one()
outstanding = float(outstanding_result or 0)
# Overdue: all overdue invoices total
overdue_result = (await db.execute(
select(func.sum(Invoice.total_amount)).where(Invoice.status == InvoiceStatus.overdue)
)).scalar_one()
overdue = float(overdue_result or 0)
# Paid this month
paid_result = (await db.execute(
select(func.sum(Invoice.total_amount)).where(
and_(
Invoice.status == InvoiceStatus.paid,
func.date_trunc("month", Invoice.paid_at) == func.date_trunc("month", func.current_date())
)
)
)).scalar_one()
paid_this_month = float(paid_result or 0)
# Invoice counts
overdue_count = (await db.execute(
select(func.count()).where(Invoice.status == InvoiceStatus.overdue)
)).scalar_one()
outstanding_count = (await db.execute(
select(func.count()).where(Invoice.status == InvoiceStatus.sent)
)).scalar_one()
return {
"mrr": mrr,
"outstanding": outstanding,
"outstanding_count": outstanding_count,
"overdue": overdue,
"overdue_count": overdue_count,
"paid_this_month": paid_this_month,
}

View File

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

View File

@@ -0,0 +1,94 @@
"""Email log viewer and test-email endpoint."""
from typing import Optional
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel, EmailStr
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, desc
from app.auth.dependencies import require_super_admin
from app.database import get_db
from app.models.user import HubUser
from app.models.email_log import EmailLog, EmailType, EmailStatus
router = APIRouter(prefix="/api/email", tags=["email"])
@router.get("/logs")
async def list_email_logs(
email_type: Optional[EmailType] = Query(None),
status: Optional[EmailStatus] = Query(None),
school_id: Optional[str] = Query(None),
page: int = Query(1, ge=1),
per_page: int = Query(50, ge=1, le=200),
_admin: HubUser = Depends(require_super_admin),
db: AsyncSession = Depends(get_db),
):
"""Paginated email delivery history — super admin only."""
stmt = select(EmailLog).order_by(desc(EmailLog.created_at))
if email_type:
stmt = stmt.where(EmailLog.email_type == email_type)
if status:
stmt = stmt.where(EmailLog.status == status)
if school_id:
stmt = stmt.where(EmailLog.school_id == school_id)
total = (await db.execute(select(func.count()).select_from(stmt.subquery()))).scalar_one()
logs = (await db.execute(stmt.offset((page - 1) * per_page).limit(per_page))).scalars().all()
return {
"items": [
{
"id": log.id,
"school_id": log.school_id,
"to_email": log.to_email,
"subject": log.subject,
"email_type": log.email_type.value,
"status": log.status.value,
"error_message": log.error_message,
"sent_at": log.sent_at.isoformat() if log.sent_at else None,
"created_at": log.created_at.isoformat(),
}
for log in logs
],
"total": total,
"page": page,
"per_page": per_page,
}
class TestEmailBody(BaseModel):
to: EmailStr
@router.post("/test")
async def send_test_email(
body: TestEmailBody,
_admin: HubUser = Depends(require_super_admin),
):
"""Send a test email to verify SMTP configuration."""
from app.services.email import send_email
success = send_email(
to=body.to,
subject="TapTrack Hub — SMTP Test Email",
body=(
"This is a test email from TapTrack Hub.\n\n"
"If you received this, your SMTP configuration is working correctly.\n\n"
"TapTrack Hub Team"
),
html=(
"<div style='font-family:sans-serif;max-width:480px;margin:32px auto;padding:24px;"
"background:#fff;border:1px solid #e2e8f0;border-radius:12px'>"
"<h2 style='color:#1e40af;margin:0 0 16px'>TapTrack Hub — SMTP Test</h2>"
"<p style='color:#334155'>This is a test email from <strong>TapTrack Hub</strong>.</p>"
"<p style='color:#334155'>If you received this, your SMTP configuration is working correctly.</p>"
"<p style='color:#94a3b8;font-size:12px;margin-top:24px'>TapTrack Hub Team</p>"
"</div>"
),
email_type="test",
)
if success:
return {"message": f"Test email sent successfully to {body.to}"}
return {"message": "Failed to send test email — check SMTP configuration and server logs"}

View File

@@ -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.",
}

View File

@@ -1,7 +1,8 @@
"""School registry endpoints — super admin only."""
import json
import uuid
from datetime import datetime, timezone
from typing import Optional, Any
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, EmailStr
from sqlalchemy.ext.asyncio import AsyncSession
@@ -16,6 +17,7 @@ from app.models.license import License, LicenseStatus
router = APIRouter(prefix="/api/schools", tags=["schools"])
class SchoolCreate(BaseModel):
name: str
address: Optional[str] = None
@@ -29,6 +31,7 @@ class SchoolCreate(BaseModel):
sms_sender_name: str = "SCHOOL"
notes: Optional[str] = None
class SchoolUpdate(BaseModel):
name: Optional[str] = None
address: Optional[str] = None
@@ -41,41 +44,43 @@ class SchoolUpdate(BaseModel):
student_limit: Optional[int] = None
sms_sender_name: Optional[str] = None
status: Optional[SchoolStatus] = None
hub_base_url: Optional[str] = None
notes: Optional[str] = None
class FeatureOverridesBody(BaseModel):
overrides: dict
def _school_out(s: School, license: License | None = None) -> dict:
feat = {}
if s.feature_overrides:
try:
feat = json.loads(s.feature_overrides) if isinstance(s.feature_overrides, str) else {}
except Exception:
feat = {}
return {
"id": s.id,
"name": s.name,
"slug": s.slug,
"address": s.address,
"city": s.city,
"contact_name": s.contact_name,
"contact_email": s.contact_email,
"contact_phone": s.contact_phone,
"billing_email": s.billing_email,
"status": s.status.value,
"tier": s.tier.value,
"student_limit": s.student_limit,
"sms_sender_name": s.sms_sender_name,
"sms_credits": float(s.sms_credits),
"id": s.id, "name": s.name, "slug": s.slug, "address": s.address, "city": s.city,
"contact_name": s.contact_name, "contact_email": s.contact_email,
"contact_phone": s.contact_phone, "billing_email": s.billing_email,
"status": s.status.value, "tier": s.tier.value, "student_limit": s.student_limit,
"sms_sender_name": s.sms_sender_name, "sms_credits": float(s.sms_credits),
"sms_credit_low_threshold": s.sms_credit_low_threshold,
"created_at": s.created_at.isoformat(),
"notes": s.notes,
"hub_base_url": s.hub_base_url, "feature_overrides": feat,
"onboarding_completed_at": s.onboarding_completed_at.isoformat() if s.onboarding_completed_at else None,
"created_at": s.created_at.isoformat(), "notes": s.notes,
"license_key": license.key if license else None,
"license_status": license.status.value if license else None,
"license_expires_at": license.expires_at.isoformat() if license and license.expires_at else None,
"license_last_seen": license.last_validated_at.isoformat() if license and license.last_validated_at else None,
}
@router.get("")
async def list_schools(
page: int = Query(1, ge=1),
per_page: int = Query(25, ge=1, le=100),
search: Optional[str] = Query(None),
status: Optional[SchoolStatus] = Query(None),
_admin: HubUser = Depends(require_super_admin),
db: AsyncSession = Depends(get_db),
page: int = Query(1, ge=1), per_page: int = Query(25, ge=1, le=100),
search: Optional[str] = Query(None), status: Optional[SchoolStatus] = Query(None),
_admin: HubUser = Depends(require_super_admin), db: AsyncSession = Depends(get_db),
):
stmt = select(School).order_by(desc(School.created_at))
if search:
@@ -86,11 +91,11 @@ async def list_schools(
schools = (await db.execute(stmt.offset((page - 1) * per_page).limit(per_page))).scalars().all()
items = []
for s in schools:
lic_res = await db.execute(select(License).where(License.school_id == s.id))
lic = lic_res.scalar_one_or_none()
lic = (await db.execute(select(License).where(License.school_id == s.id))).scalar_one_or_none()
items.append(_school_out(s, lic))
return {"items": items, "total": total, "page": page, "per_page": per_page}
@router.post("", status_code=201)
async def create_school(
body: SchoolCreate,
@@ -98,34 +103,31 @@ async def create_school(
db: AsyncSession = Depends(get_db),
):
slug = slugify(body.name)
# Ensure slug uniqueness
existing = (await db.execute(select(School).where(School.slug == slug))).scalar_one_or_none()
if existing:
slug = f"{slug}-{uuid.uuid4().hex[:6]}"
school = School(
name=body.name,
slug=slug,
address=body.address,
city=body.city,
contact_name=body.contact_name,
contact_email=body.contact_email,
contact_phone=body.contact_phone,
billing_email=body.billing_email,
tier=body.tier,
student_limit=body.student_limit,
sms_sender_name=body.sms_sender_name[:11],
notes=body.notes,
name=body.name, slug=slug, address=body.address, city=body.city,
contact_name=body.contact_name, contact_email=body.contact_email,
contact_phone=body.contact_phone, billing_email=body.billing_email,
tier=body.tier, student_limit=body.student_limit,
sms_sender_name=body.sms_sender_name[:11], notes=body.notes,
status=SchoolStatus.pending,
)
db.add(school)
await db.flush()
# Auto-create license
lic = License(school_id=school.id, tier=body.tier.value, max_students=body.student_limit)
db.add(lic)
await db.commit()
await db.refresh(school)
try:
from app.tasks.onboarding import send_welcome_email
send_welcome_email.delay(school.id)
except Exception:
pass
return _school_out(school, lic)
@router.get("/{school_id}")
async def get_school(
school_id: str,
@@ -138,12 +140,11 @@ async def get_school(
lic = (await db.execute(select(License).where(License.school_id == school_id))).scalar_one_or_none()
return _school_out(school, lic)
@router.put("/{school_id}")
async def update_school(
school_id: str,
body: SchoolUpdate,
_admin: HubUser = Depends(require_super_admin),
db: AsyncSession = Depends(get_db),
school_id: str, body: SchoolUpdate,
_admin: HubUser = Depends(require_super_admin), db: AsyncSession = Depends(get_db),
):
school = (await db.execute(select(School).where(School.id == school_id))).scalar_one_or_none()
if not school:
@@ -156,26 +157,67 @@ async def update_school(
lic = (await db.execute(select(License).where(License.school_id == school_id))).scalar_one_or_none()
return _school_out(school, lic)
@router.put("/{school_id}/feature-overrides")
async def update_feature_overrides(
school_id: str, body: FeatureOverridesBody,
_admin: HubUser = Depends(require_super_admin), db: AsyncSession = Depends(get_db),
):
school = (await db.execute(select(School).where(School.id == school_id))).scalar_one_or_none()
if not school:
raise HTTPException(404, "School not found")
school.feature_overrides = json.dumps(body.overrides)
await db.commit()
return {"feature_overrides": body.overrides}
@router.post("/{school_id}/activate")
async def activate_school(
school_id: str,
_admin: HubUser = Depends(require_super_admin), db: AsyncSession = Depends(get_db),
):
school = (await db.execute(select(School).where(School.id == school_id))).scalar_one_or_none()
if not school:
raise HTTPException(404, "School not found")
lic = (await db.execute(select(License).where(License.school_id == school_id))).scalar_one_or_none()
if not lic:
raise HTTPException(400, "No license issued — cannot activate")
school.status = SchoolStatus.active
school.onboarding_completed_at = datetime.now(timezone.utc)
await db.commit()
return _school_out(school, lic)
@router.post("/{school_id}/resend-welcome")
async def resend_welcome(
school_id: str,
_admin: HubUser = Depends(require_super_admin), db: AsyncSession = Depends(get_db),
):
school = (await db.execute(select(School).where(School.id == school_id))).scalar_one_or_none()
if not school:
raise HTTPException(404, "School not found")
try:
from app.tasks.onboarding import send_welcome_email
send_welcome_email.delay(school_id)
except Exception:
raise HTTPException(500, "Failed to queue welcome email")
return {"message": "Welcome email queued"}
@router.post("/{school_id}/credits")
async def add_sms_credits(
school_id: str,
amount: float,
description: Optional[str] = None,
_admin: HubUser = Depends(require_super_admin),
db: AsyncSession = Depends(get_db),
school_id: str, amount: float, description: Optional[str] = None,
_admin: HubUser = Depends(require_super_admin), db: AsyncSession = Depends(get_db),
):
from app.models.sms import SmsCreditLedger, SmsCreditTx
school = (await db.execute(select(School).where(School.id == school_id))).scalar_one_or_none()
if not school:
raise HTTPException(404, "School not found")
school.sms_credits = float(school.sms_credits) + amount
ledger = SmsCreditLedger(
school_id=school_id,
tx_type=SmsCreditTx.topup,
amount=amount,
db.add(SmsCreditLedger(
school_id=school_id, tx_type=SmsCreditTx.topup, amount=amount,
balance_after=float(school.sms_credits),
description=description or f"Manual top-up of {amount} credits",
)
db.add(ledger)
))
await db.commit()
return {"sms_credits": float(school.sms_credits), "added": amount}

View File

@@ -0,0 +1,43 @@
"""Global search across schools, invoices, tickets."""
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.auth.dependencies import require_super_admin
from app.database import get_db
from app.models.user import HubUser
from app.models.school import School
from app.models.billing import Invoice
from app.models.ticket import SupportTicket
router = APIRouter(prefix="/api/search", tags=["search"])
@router.get("")
async def global_search(
q: str = Query(..., min_length=2),
_admin: HubUser = Depends(require_super_admin),
db: AsyncSession = Depends(get_db),
):
"""Search across schools, invoices, and tickets. Returns up to 5 per category."""
pattern = f"%{q}%"
schools = (await db.execute(
select(School).where(School.name.ilike(pattern)).limit(5)
)).scalars().all()
invoices = (await db.execute(
select(Invoice).where(Invoice.invoice_number.ilike(pattern)).limit(5)
)).scalars().all()
tickets = (await db.execute(
select(SupportTicket).where(
SupportTicket.subject.ilike(pattern) | SupportTicket.ticket_number.ilike(pattern)
).limit(5)
)).scalars().all()
return {
"schools": [{"id": s.id, "name": s.name, "status": s.status.value, "type": "school"} for s in schools],
"invoices": [{"id": i.id, "invoice_number": i.invoice_number, "total_amount": float(i.total_amount), "status": i.status.value, "type": "invoice"} for i in invoices],
"tickets": [{"id": t.id, "ticket_number": t.ticket_number, "subject": t.subject, "status": t.status.value, "type": "ticket"} for t in tickets],
}

View File

@@ -1,4 +1,5 @@
"""On-prem sync endpoint — polled by TapTrack every 30s to get SMS jobs and config."""
import uuid
from datetime import datetime, timezone
from fastapi import APIRouter, HTTPException, Request
from sqlalchemy.ext.asyncio import AsyncSession
@@ -7,11 +8,28 @@ from fastapi import Depends
from app.database import get_db
from app.models.license import License, LicenseStatus
from app.models.school import School
from app.models.sms import SmsJob, SmsJobStatus
from app.models.school import School, SchoolStatus
from app.models.sms import SmsJob, SmsJobStatus, SmsCreditLedger, SmsCreditTx
router = APIRouter(prefix="/api/sync", tags=["sync"])
# Feature flags by tier — mirrors licenses.py
def _tier_features(tier: str, overrides: str | None = None) -> dict:
import json
base = {"sms": True, "reports": True, "websocket": True, "multi_terminal": True}
if tier == "premium":
base.update({"api_keys": True, "webhooks": True, "bulk_enrollment": True})
elif tier == "basic":
base.update({"multi_terminal": False, "api_keys": False, "webhooks": False})
if overrides:
try:
base.update(json.loads(overrides))
except Exception:
pass
elif tier == "basic":
base.update({"multi_terminal": False, "api_keys": False, "webhooks": False})
return base
@router.post("/poll")
async def sync_poll(
request: Request,
@@ -20,58 +38,143 @@ async def sync_poll(
"""
Called by on-prem TapTrack every 30s.
Returns pending SMS jobs and current config (sender_name, credits, feature flags).
Body: { license_key: str, report_sent_ids: [str] } (completed job IDs to mark as sent)
Body:
{
"license_key": "TTUB-XXXXX-XXXXX-XXXXX",
"report_sent_ids": ["uuid1", "uuid2"], // confirmed-sent job IDs → deduct credits
"job_failed_ids": ["uuid3"] // jobs on-prem could NOT deliver → retry/fail
}
"""
body = await request.json()
key = body.get("license_key", "")
sent_ids = body.get("report_sent_ids", [])
sent_ids: list[str] = body.get("report_sent_ids", [])
failed_ids: list[str] = body.get("job_failed_ids", [])
# Validate license
lic = (await db.execute(select(License).where(License.key == key))).scalar_one_or_none()
if not lic or lic.status == LicenseStatus.revoked:
raise HTTPException(403, "Invalid or revoked license")
raise HTTPException(403, detail={"reason": "revoked", "message": "Invalid or revoked license"})
if lic.status == LicenseStatus.expired:
raise HTTPException(403, detail={"reason": "expired", "message": "License has expired"})
school = (await db.execute(select(School).where(School.id == lic.school_id))).scalar_one_or_none()
if not school:
raise HTTPException(404)
raise HTTPException(404, detail="School not found")
# Mark completed jobs
now = datetime.now(timezone.utc)
# ── Mark completed jobs (on-prem confirmed delivery) ──────────────────────
if sent_ids:
await db.execute(
update(SmsJob)
.where(and_(SmsJob.id.in_(sent_ids), SmsJob.school_id == school.id))
.values(status=SmsJobStatus.sent, sent_at=datetime.now(timezone.utc))
)
# Fetch jobs to deduct credits (only for this school, only processing/pending)
jobs_sent = (await db.execute(
select(SmsJob).where(
and_(
SmsJob.id.in_(sent_ids),
SmsJob.school_id == school.id,
SmsJob.status.in_([SmsJobStatus.processing, SmsJobStatus.pending]),
)
)
)).scalars().all()
# Get pending jobs (max 50 per poll)
pending_jobs = (await db.execute(
select(SmsJob)
.where(and_(SmsJob.school_id == school.id, SmsJob.status == SmsJobStatus.pending))
.limit(50)
)).scalars().all()
if jobs_sent:
credit_deductions = len(jobs_sent)
new_balance = float(school.sms_credits) - credit_deductions
new_balance = max(new_balance, 0.0) # floor at 0
# Mark as processing
job_ids = [j.id for j in pending_jobs]
if job_ids:
await db.execute(
update(SmsJob)
.where(SmsJob.id.in_(job_ids))
.values(status=SmsJobStatus.processing)
)
# Deduct credits from school
school.sms_credits = new_balance
# Update last seen
lic.last_validated_at = datetime.now(timezone.utc)
# Write a single consolidated ledger entry for this batch
db.add(SmsCreditLedger(
id=str(uuid.uuid4()),
school_id=school.id,
tx_type=SmsCreditTx.deduct,
amount=-float(credit_deductions),
balance_after=new_balance,
description=f"SMS delivered via on-prem agent ({credit_deductions} messages)",
reference_id=sent_ids[0] if len(sent_ids) == 1 else None,
))
# Mark jobs as sent, tag delivered_via=pull
await db.execute(
update(SmsJob)
.where(and_(SmsJob.id.in_([j.id for j in jobs_sent]), SmsJob.school_id == school.id))
.values(status=SmsJobStatus.sent, sent_at=now, delivered_via="pull")
)
# Low credit alert — fire if below threshold
if new_balance <= float(school.sms_credit_low_threshold):
try:
from app.tasks.sms import send_low_credit_alert
send_low_credit_alert.delay(school.id)
except Exception:
pass # don't fail the poll if alert fails to queue
# ── Handle jobs on-prem failed to send ────────────────────────────────────
if failed_ids:
failed_jobs = (await db.execute(
select(SmsJob).where(
and_(
SmsJob.id.in_(failed_ids),
SmsJob.school_id == school.id,
)
)
)).scalars().all()
for job in failed_jobs:
job.retry_count = (job.retry_count or 0) + 1
if job.retry_count >= 5:
job.status = SmsJobStatus.failed
job.error_message = "Max retries reached — on-prem delivery failed"
else:
# Return to pending so it can be re-dispatched (pull or push)
job.status = SmsJobStatus.pending
job.error_message = f"On-prem delivery failed (attempt {job.retry_count})"
job.processing_started_at = None
# ── Fetch pending jobs for this school ────────────────────────────────────
# Suspended schools get an empty job list — on-prem should show banner
pending_jobs: list[SmsJob] = []
if school.status == SchoolStatus.active:
pending_jobs = (await db.execute(
select(SmsJob)
.where(and_(SmsJob.school_id == school.id, SmsJob.status == SmsJobStatus.pending))
.limit(50)
)).scalars().all()
# Mark dispatched jobs as processing
if pending_jobs:
job_ids = [j.id for j in pending_jobs]
await db.execute(
update(SmsJob)
.where(SmsJob.id.in_(job_ids))
.values(status=SmsJobStatus.processing, processing_started_at=now)
)
# ── Update license heartbeat ───────────────────────────────────────────────
lic.last_validated_at = now
lic.last_seen_ip = request.client.host if request.client else None
await db.commit()
suspended = school.status in (SchoolStatus.suspended, SchoolStatus.expired)
return {
"sms_jobs": [
{"id": j.id, "recipient_phone": j.recipient_phone,
"message": j.message, "sender_name": j.sender_name}
{
"id": j.id,
"recipient_phone": j.recipient_phone,
"message": j.message,
"sender_name": j.sender_name,
}
for j in pending_jobs
],
"config": {
"sms_sender_name": school.sms_sender_name,
"sms_credits": float(school.sms_credits),
"school_status": school.status.value,
"suspended": suspended,
"feature_flags": _tier_features(lic.tier, school.feature_overrides),
},
}

View File

@@ -1,6 +1,7 @@
"""Support ticket endpoints."""
import uuid
from datetime import datetime, timezone
import os
import threading
from datetime import datetime, timezone, timedelta
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
@@ -14,45 +15,130 @@ from app.models.ticket import SupportTicket, TicketReply, TicketStatus, TicketPr
router = APIRouter(prefix="/api/tickets", tags=["tickets"])
class TicketCreate(BaseModel):
subject: str
body: str
category: TicketCategory = TicketCategory.general
class TicketUpdate(BaseModel):
status: Optional[TicketStatus] = None
priority: Optional[TicketPriority] = None
assigned_to: Optional[str] = None
class ReplyCreate(BaseModel):
body: str
is_internal: bool = False
def _ticket_out(t: SupportTicket) -> dict:
class BulkClose(BaseModel):
ticket_ids: list[str]
def _sla_status(ticket: SupportTicket) -> str:
if ticket.status in (TicketStatus.resolved, TicketStatus.closed):
return "resolved"
if ticket.first_response_at:
return "responded"
age_h = (datetime.now(timezone.utc) - ticket.created_at).total_seconds() / 3600
if age_h > 48:
return "breached"
if age_h > 24:
return "at_risk"
return "on_track"
def _ticket_out(t: SupportTicket, school_name: str | None = None) -> dict:
return {
"id": t.id, "school_id": t.school_id, "ticket_number": t.ticket_number,
"subject": t.subject, "body": t.body, "category": t.category.value,
"status": t.status.value, "priority": t.priority.value,
"assigned_to": t.assigned_to,
"id": t.id, "school_id": t.school_id, "school_name": school_name,
"ticket_number": t.ticket_number, "subject": t.subject, "body": t.body,
"category": t.category.value, "status": t.status.value, "priority": t.priority.value,
"assigned_to": t.assigned_to, "sla_status": _sla_status(t),
"first_response_at": t.first_response_at.isoformat() if t.first_response_at else None,
"resolved_at": t.resolved_at.isoformat() if t.resolved_at else None,
"created_at": t.created_at.isoformat(),
"updated_at": t.updated_at.isoformat(),
"created_at": t.created_at.isoformat(), "updated_at": t.updated_at.isoformat(),
}
def _next_ticket_number(count: int) -> str:
from datetime import date
return f"TKT-{date.today().year}-{count + 1:05d}"
def _notify_async(ticket_id: str, message_body: str, is_reply: bool, from_admin: bool):
"""Send ticket email notifications in a background thread."""
import os as _os
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from app.models.ticket import SupportTicket
from app.models.school import School
from app.models.user import HubUser, UserRole
from app.services.email import send_email
db_url = _os.getenv("DATABASE_URL", "postgresql://postgres:postgres@db:5432/taptrack_hub")
engine = create_engine(db_url.replace("postgresql+asyncpg://", "postgresql://"), pool_pre_ping=True)
db = sessionmaker(bind=engine)()
try:
t = db.get(SupportTicket, ticket_id)
if not t:
return
hub_url = _os.getenv("HUB_BASE_URL", "http://localhost:8090")
ticket_url = f"{hub_url}/tickets/{t.id}"
base_ctx = {
"ticket_number": t.ticket_number, "subject": t.subject,
"category": t.category.value, "priority": t.priority.value,
"status": t.status.value, "message_body": message_body, "ticket_url": ticket_url,
}
if is_reply and from_admin:
school = db.get(School, t.school_id)
if school and school.billing_email:
send_email(
to=school.billing_email,
subject=f"[TapTrack] Reply on {t.ticket_number}: {t.subject}",
body=f"Support replied to ticket {t.ticket_number}.",
template_name="email/ticket_notification.html",
context={**base_ctx, "email_title": f"Reply on Ticket {t.ticket_number}",
"recipient_name": school.contact_name or school.name, "message_label": "Support Reply"},
email_type="other", school_id=t.school_id,
)
else:
admins = db.execute(
select(HubUser).where(HubUser.role == UserRole.super_admin, HubUser.is_active == True)
).scalars().all()
title = f"New Ticket: {t.ticket_number}" if not is_reply else f"Customer Reply on {t.ticket_number}"
for admin in admins:
send_email(
to=admin.email,
subject=f"[TapTrack] {title}: {t.subject}",
body=f"{title}\n\n{message_body}",
template_name="email/ticket_notification.html",
context={**base_ctx, "email_title": title,
"recipient_name": admin.full_name,
"message_label": "Message" if not is_reply else "Customer Reply"},
email_type="other", school_id=t.school_id,
)
except Exception as e:
import logging
logging.getLogger(__name__).warning("Ticket notification error: %s", e)
finally:
db.close()
engine.dispose()
@router.get("")
async def list_tickets(
school_id: Optional[str] = Query(None),
status: Optional[TicketStatus] = Query(None),
priority: Optional[TicketPriority] = Query(None),
page: int = Query(1, ge=1),
per_page: int = Query(25),
current_user: HubUser = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
from app.models.school import School
stmt = select(SupportTicket).order_by(desc(SupportTicket.updated_at))
if current_user.role != UserRole.super_admin:
stmt = stmt.where(SupportTicket.school_id == current_user.school_id)
@@ -60,9 +146,18 @@ async def list_tickets(
stmt = stmt.where(SupportTicket.school_id == school_id)
if status:
stmt = stmt.where(SupportTicket.status == status)
if priority:
stmt = stmt.where(SupportTicket.priority == priority)
total = (await db.execute(select(func.count()).select_from(stmt.subquery()))).scalar_one()
tickets = (await db.execute(stmt.offset((page - 1) * per_page).limit(per_page))).scalars().all()
return {"items": [_ticket_out(t) for t in tickets], "total": total, "page": page, "per_page": per_page}
sids = {t.school_id for t in tickets}
school_map: dict[str, str] = {}
if sids:
schools = (await db.execute(select(School).where(School.id.in_(sids)))).scalars().all()
school_map = {s.id: s.name for s in schools}
return {"items": [_ticket_out(t, school_map.get(t.school_id)) for t in tickets],
"total": total, "page": page, "per_page": per_page}
@router.post("", status_code=201)
async def create_ticket(
@@ -74,16 +169,17 @@ async def create_ticket(
raise HTTPException(400, "No school associated with your account")
count = (await db.execute(select(func.count()).select_from(SupportTicket))).scalar_one()
ticket = SupportTicket(
school_id=current_user.school_id,
submitted_by=current_user.id,
ticket_number=_next_ticket_number(count),
subject=body.subject,
body=body.body,
category=body.category,
school_id=current_user.school_id, submitted_by=current_user.id,
ticket_number=_next_ticket_number(count), subject=body.subject,
body=body.body, category=body.category,
)
db.add(ticket)
await db.commit()
return _ticket_out(ticket)
threading.Thread(target=_notify_async, args=(ticket.id, body.body, False, False), daemon=True).start()
from app.models.school import School
school = (await db.execute(select(School).where(School.id == current_user.school_id))).scalar_one_or_none()
return _ticket_out(ticket, school.name if school else None)
@router.get("/{ticket_id}")
async def get_ticket(
@@ -91,23 +187,25 @@ async def get_ticket(
current_user: HubUser = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
from app.models.school import School
t = (await db.execute(select(SupportTicket).where(SupportTicket.id == ticket_id))).scalar_one_or_none()
if not t:
raise HTTPException(404)
if current_user.role != UserRole.super_admin and t.school_id != current_user.school_id:
raise HTTPException(403)
replies_res = await db.execute(select(TicketReply).where(TicketReply.ticket_id == ticket_id).order_by(TicketReply.created_at))
replies = replies_res.scalars().all()
visible_replies = [r for r in replies if not r.is_internal or current_user.role == UserRole.super_admin]
replies = (await db.execute(
select(TicketReply).where(TicketReply.ticket_id == ticket_id).order_by(TicketReply.created_at)
)).scalars().all()
visible = [r for r in replies if not r.is_internal or current_user.role == UserRole.super_admin]
school = (await db.execute(select(School).where(School.id == t.school_id))).scalar_one_or_none()
return {
**_ticket_out(t),
"replies": [
{"id": r.id, "body": r.body, "is_internal": r.is_internal,
"author_id": r.author_id, "created_at": r.created_at.isoformat()}
for r in visible_replies
],
**_ticket_out(t, school.name if school else None),
"replies": [{"id": r.id, "body": r.body, "is_internal": r.is_internal,
"author_id": r.author_id, "created_at": r.created_at.isoformat()}
for r in visible],
}
@router.put("/{ticket_id}")
async def update_ticket(
ticket_id: str,
@@ -125,6 +223,7 @@ async def update_ticket(
await db.commit()
return _ticket_out(t)
@router.post("/{ticket_id}/replies", status_code=201)
async def add_reply(
ticket_id: str,
@@ -140,10 +239,33 @@ async def add_reply(
is_internal = body.is_internal and current_user.role == UserRole.super_admin
reply = TicketReply(ticket_id=ticket_id, author_id=current_user.id, body=body.body, is_internal=is_internal)
db.add(reply)
# Set first response time (super admin only)
if current_user.role == UserRole.super_admin and not t.first_response_at:
t.first_response_at = datetime.now(timezone.utc)
if t.status == TicketStatus.open:
t.status = TicketStatus.in_progress
await db.commit()
return {"id": reply.id, "body": reply.body, "created_at": reply.created_at.isoformat()}
if not is_internal:
from_admin = current_user.role == UserRole.super_admin
threading.Thread(target=_notify_async, args=(t.id, body.body, True, from_admin), daemon=True).start()
return {"id": reply.id, "body": reply.body, "is_internal": is_internal, "created_at": reply.created_at.isoformat()}
@router.post("/bulk-close")
async def bulk_close_tickets(
body: BulkClose,
_admin: HubUser = Depends(require_super_admin),
db: AsyncSession = Depends(get_db),
):
now = datetime.now(timezone.utc)
tickets = (await db.execute(
select(SupportTicket).where(SupportTicket.id.in_(body.ticket_ids))
)).scalars().all()
closed = 0
for t in tickets:
if t.status != TicketStatus.closed:
t.status = TicketStatus.closed
if not t.resolved_at:
t.resolved_at = now
closed += 1
await db.commit()
return {"closed": closed, "total": len(tickets)}

View File

@@ -1,32 +1,154 @@
"""Simple SMTP email service."""
"""Email service — SMTP delivery with HTML templates and delivery logging."""
import smtplib
import logging
import time
import uuid
from datetime import datetime, timezone
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from pathlib import Path
from typing import Optional
from app.config import settings
logger = logging.getLogger(__name__)
def send_email(to: str, subject: str, body: str, html: str | None = None) -> bool:
"""Send email via configured SMTP. Returns True on success."""
if not settings.SMTP_HOST:
logger.warning(f"SMTP not configured — would send to {to}: {subject}")
return False
TEMPLATE_DIR = Path(__file__).parent.parent / "templates"
def _render_template(template_name: str, context: dict) -> str:
"""Render a Jinja2 HTML email template. Returns empty string on failure."""
try:
msg = MIMEMultipart("alternative")
msg["Subject"] = subject
msg["From"] = settings.SMTP_FROM
msg["To"] = to
msg.attach(MIMEText(body, "plain"))
if html:
msg.attach(MIMEText(html, "html"))
with smtplib.SMTP(settings.SMTP_HOST, settings.SMTP_PORT) as smtp:
smtp.starttls()
if settings.SMTP_USER:
smtp.login(settings.SMTP_USER, settings.SMTP_PASSWORD)
smtp.sendmail(settings.SMTP_FROM, [to], msg.as_string())
logger.info(f"Email sent to {to}: {subject}")
return True
from jinja2 import Environment, FileSystemLoader
env = Environment(loader=FileSystemLoader(str(TEMPLATE_DIR)), autoescape=True)
tmpl = env.get_template(template_name)
return tmpl.render(**context)
except Exception as e:
logger.error(f"Email failed to {to}: {e}")
logger.warning("Template render failed (%s): %s", template_name, e)
return ""
def _log_email(
to: str,
subject: str,
email_type: str,
status: str,
school_id: Optional[str] = None,
error_message: Optional[str] = None,
) -> None:
"""Write an email delivery record to email_logs. Best-effort — never raises."""
try:
import os
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from app.models.email_log import EmailLog, EmailStatus, EmailType
db_url = os.getenv("DATABASE_URL", "postgresql://postgres:postgres@db:5432/taptrack_hub")
engine = create_engine(
db_url.replace("postgresql+asyncpg://", "postgresql://"),
pool_pre_ping=True,
pool_size=1,
max_overflow=0,
)
Session = sessionmaker(bind=engine)
db = Session()
try:
# Map type string to enum, default to 'other'
try:
etype = EmailType(email_type)
except ValueError:
etype = EmailType.other
log = EmailLog(
id=str(uuid.uuid4()),
school_id=school_id,
to_email=to,
subject=subject,
email_type=etype,
status=EmailStatus(status),
error_message=error_message,
sent_at=datetime.now(timezone.utc) if status == "sent" else None,
created_at=datetime.now(timezone.utc),
)
db.add(log)
db.commit()
finally:
db.close()
engine.dispose()
except Exception as e:
logger.debug("Email log write failed: %s", e)
def send_email(
to: str,
subject: str,
body: str,
html: Optional[str] = None,
template_name: Optional[str] = None,
context: Optional[dict] = None,
email_type: str = "other",
school_id: Optional[str] = None,
max_retries: int = 3,
) -> bool:
"""
Send an email via configured SMTP.
Priority order for HTML content:
1. `html` parameter (raw HTML string)
2. `template_name` + `context` (rendered Jinja2 template)
3. `body` only (plain text)
Logs every attempt to email_logs table.
Retries up to `max_retries` times on transient SMTP failure.
Returns True on success.
"""
if not settings.SMTP_HOST:
logger.warning("SMTP not configured — would send to %s: %s", to, subject)
_log_email(to, subject, email_type, "failed", school_id, "SMTP not configured")
return False
# Resolve HTML content
html_content = html
if not html_content and template_name:
ctx = context or {}
ctx.setdefault("subject", subject)
ctx.setdefault("portal_url", settings.HUB_BASE_URL)
html_content = _render_template(template_name, ctx)
last_error: Optional[str] = None
for attempt in range(1, max_retries + 1):
try:
msg = MIMEMultipart("alternative")
msg["Subject"] = subject
msg["From"] = settings.SMTP_FROM
msg["To"] = to
msg.attach(MIMEText(body, "plain"))
if html_content:
msg.attach(MIMEText(html_content, "html"))
with smtplib.SMTP(settings.SMTP_HOST, settings.SMTP_PORT) as smtp:
smtp.ehlo()
smtp.starttls()
smtp.ehlo()
if settings.SMTP_USER:
smtp.login(settings.SMTP_USER, settings.SMTP_PASSWORD)
smtp.sendmail(settings.SMTP_FROM, [to], msg.as_string())
logger.info("Email sent to %s: %s", to, subject)
_log_email(to, subject, email_type, "sent", school_id)
return True
except smtplib.SMTPException as e:
last_error = str(e)
logger.warning("SMTP attempt %d/%d failed for %s: %s", attempt, max_retries, to, e)
if attempt < max_retries:
time.sleep(2 ** attempt) # exponential backoff: 2s, 4s
except Exception as e:
last_error = str(e)
logger.error("Email send failed for %s: %s", to, e)
break # Non-SMTP errors don't retry
_log_email(to, subject, email_type, "failed", school_id, last_error)
return False

View File

@@ -0,0 +1,70 @@
"""Invoice PDF generation using Jinja2 + WeasyPrint."""
import logging
import os
from datetime import datetime, timezone
from pathlib import Path
logger = logging.getLogger(__name__)
# PDF output directory (mounted as a Docker volume in production)
PDF_DIR = Path(os.getenv("PDF_DIR", "/app/data/invoices"))
def generate_invoice_pdf(invoice_id: str, db_session) -> str:
"""
Render the invoice HTML template and convert to PDF via WeasyPrint.
Args:
invoice_id: UUID of the Invoice to generate
db_session: synchronous SQLAlchemy session (used from Celery tasks)
Returns:
Absolute path to the generated PDF file.
"""
from app.models.billing import Invoice, InvoiceLineItem
from app.models.school import School
from jinja2 import Environment, FileSystemLoader
from weasyprint import HTML
# Load invoice + line items + school
inv = db_session.get(Invoice, invoice_id)
if not inv:
raise ValueError(f"Invoice {invoice_id} not found")
school = db_session.get(School, inv.school_id)
line_items = db_session.query(InvoiceLineItem).filter_by(invoice_id=invoice_id).all()
# Format dates
def _fmt(d) -> str:
if not d:
return ""
if hasattr(d, "strftime"):
return d.strftime("%B %d, %Y")
return str(d)
context = {
"invoice": inv,
"school": school,
"line_items": line_items,
"issued_date": _fmt(inv.created_at),
"period_start": _fmt(inv.billing_period_start),
"period_end": _fmt(inv.billing_period_end),
"due_date": _fmt(inv.due_date),
"paid_date": _fmt(inv.paid_at),
"generated_date": datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC"),
}
# Render Jinja2 template
template_dir = Path(__file__).parent.parent / "templates"
env = Environment(loader=FileSystemLoader(str(template_dir)), autoescape=True)
template = env.get_template("invoice.html")
html_content = template.render(**context)
# Generate PDF
PDF_DIR.mkdir(parents=True, exist_ok=True)
output_path = PDF_DIR / f"{invoice_id}.pdf"
HTML(string=html_content).write_pdf(str(output_path))
logger.info("Generated PDF: %s", output_path)
return str(output_path)

View File

@@ -1,45 +1,71 @@
"""Celery tasks: invoice generation, email, overdue checks."""
"""Celery tasks: invoice generation, PDF, email, overdue escalation."""
import logging
from datetime import date, timedelta
from datetime import date, timedelta, datetime, timezone
from app.worker import celery_app
logger = logging.getLogger(__name__)
def _make_session():
import os
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
db_url = os.getenv("DATABASE_URL", "postgresql://postgres:postgres@db:5432/taptrack_hub")
engine = create_engine(db_url.replace("postgresql+asyncpg://", "postgresql://"), pool_pre_ping=True)
engine = create_engine(
db_url.replace("postgresql+asyncpg://", "postgresql://"),
pool_pre_ping=True,
)
return sessionmaker(bind=engine)()
def _hub_url() -> str:
import os
return os.getenv("HUB_BASE_URL", "http://localhost:8090")
@celery_app.task(name="billing.generate_monthly_invoices")
def generate_monthly_invoices():
"""On the 1st: create draft invoices for all active schools with a subscription."""
from app.models.billing import Invoice, SchoolSubscription, InvoiceLineItem, BillingCycle
from sqlalchemy import select, func
from app.models.billing import Invoice, SchoolSubscription, InvoiceLineItem
from app.models.school import School, SchoolStatus
from sqlalchemy import select
from datetime import date
db = _make_session()
try:
today = date.today()
period_start = date(today.year, today.month, 1)
prev_month = (period_start - timedelta(days=1))
prev_month = period_start - timedelta(days=1)
billing_start = date(prev_month.year, prev_month.month, 1)
billing_end = period_start - timedelta(days=1)
subs = db.execute(select(SchoolSubscription).where(SchoolSubscription.is_active == True)).scalars().all()
subs = db.execute(
select(SchoolSubscription).where(SchoolSubscription.is_active == True)
).scalars().all()
count = db.execute(select(func.count()).select_from(Invoice)).scalar_one()
created = 0
for sub in subs:
school = db.get(School, sub.school_id)
if not school or school.status != SchoolStatus.active:
continue
# Avoid duplicate invoices for the same period
existing = db.execute(
select(Invoice).where(
Invoice.school_id == sub.school_id,
Invoice.billing_period_start == billing_start,
)
).scalar_one_or_none()
if existing:
continue
total = float(sub.monthly_fee)
inv_num = f"INV-{billing_start.strftime('%Y%m')}-{count + 1:04d}"
count += 1
created += 1
inv = Invoice(
school_id=sub.school_id,
invoice_number=inv_num,
@@ -51,6 +77,7 @@ def generate_monthly_invoices():
)
db.add(inv)
db.flush()
db.add(InvoiceLineItem(
invoice_id=inv.id,
description=f"Monthly subscription — {school.name}",
@@ -58,21 +85,47 @@ def generate_monthly_invoices():
unit_price=float(sub.monthly_fee),
amount=float(sub.monthly_fee),
))
# Auto-send invoice email
send_invoice_email_task.delay(inv.id)
db.commit()
logger.info(f"Generated {len(subs)} invoices for {billing_start}")
logger.info("Generated %d invoices for %s", created, billing_start)
except Exception as e:
db.rollback()
logger.error(f"generate_monthly_invoices error: {e}")
logger.error("generate_monthly_invoices error: %s", e)
finally:
db.close()
@celery_app.task(name="billing.generate_invoice_pdf")
def generate_invoice_pdf_task(invoice_id: str):
"""Generate PDF for a single invoice and update the pdf_path field."""
from app.services.invoice_pdf import generate_invoice_pdf
from app.models.billing import Invoice
db = _make_session()
try:
path = generate_invoice_pdf(invoice_id, db)
inv = db.get(Invoice, invoice_id)
if inv:
inv.pdf_path = path
db.commit()
logger.info("Invoice PDF generated: %s", path)
return path
except Exception as e:
logger.error("generate_invoice_pdf_task error for %s: %s", invoice_id, e)
raise
finally:
db.close()
@celery_app.task(name="billing.send_invoice_email")
def send_invoice_email_task(invoice_id: str):
"""Send invoice email to school billing contact."""
"""Send invoice email to school billing contact and set status to 'sent'."""
from app.models.billing import Invoice, InvoiceStatus
from app.models.school import School
from app.services.email import send_email
from sqlalchemy import select
db = _make_session()
try:
@@ -82,44 +135,164 @@ def send_invoice_email_task(invoice_id: str):
school = db.get(School, inv.school_id)
if not school or not school.billing_email:
return
body = f"""Dear {school.contact_name or school.name},
Please find your invoice {inv.invoice_number} for the period {inv.billing_period_start} to {inv.billing_period_end}.
Amount Due: PHP {float(inv.total_amount):,.2f}
Due Date: {inv.due_date}
Please log in to your TapTrack Hub portal to view and pay your invoice.
Thank you,
TapTrack Hub Team
"""
send_email(to=school.billing_email, subject=f"Invoice {inv.invoice_number} — TapTrack Hub", body=body)
from datetime import datetime, timezone
plain = (
f"Dear {school.contact_name or school.name},\n\n"
f"Your invoice {inv.invoice_number} for PHP {float(inv.total_amount):,.2f} "
f"covering {inv.billing_period_start} to {inv.billing_period_end} is ready.\n"
f"Due: {inv.due_date or 'Upon receipt'}\n\n"
f"Log in: {_hub_url()}/portal/billing\n\nTapTrack Hub Team"
)
send_email(
to=school.billing_email,
subject=f"Invoice {inv.invoice_number} — TapTrack Hub",
body=plain,
template_name="email/invoice.html",
context={
"contact_name": school.contact_name or school.name,
"school_name": school.name,
"invoice_number": inv.invoice_number,
"period_start": str(inv.billing_period_start),
"period_end": str(inv.billing_period_end),
"amount": f"{float(inv.total_amount):,.2f}",
"due_date": str(inv.due_date) if inv.due_date else "Upon receipt",
},
email_type="invoice",
school_id=school.id,
)
inv.email_sent_at = datetime.now(timezone.utc)
if inv.status.value == "draft":
if inv.status == InvoiceStatus.draft:
inv.status = InvoiceStatus.sent
db.commit()
finally:
db.close()
@celery_app.task(name="billing.check_overdue")
def check_overdue():
"""Mark overdue invoices and send warning emails."""
from app.models.billing import Invoice, InvoiceStatus
"""
Daily task: mark overdue invoices and escalate.
- sent + past due_date → overdue
- overdue 7+ days → warning email
- overdue 30+ days → suspend school + suspension email
"""
from sqlalchemy import select, and_
from app.models.billing import Invoice, InvoiceStatus
from app.models.school import School, SchoolStatus
from app.services.email import send_email
db = _make_session()
try:
today = date.today()
overdue = db.execute(
# 1. Mark newly overdue
newly_overdue = db.execute(
select(Invoice).where(
and_(Invoice.status == InvoiceStatus.sent, Invoice.due_date < today, Invoice.due_date != None)
and_(
Invoice.status == InvoiceStatus.sent,
Invoice.due_date != None,
Invoice.due_date < today,
)
)
).scalars().all()
for inv in overdue:
for inv in newly_overdue:
inv.status = InvoiceStatus.overdue
db.commit()
logger.info(f"Marked {len(overdue)} invoices as overdue")
logger.info("Marked %d invoices as overdue", len(newly_overdue))
# 2. Warning email: overdue 7+ days (but not yet 30)
warn_cutoff = today - timedelta(days=7)
suspend_cutoff = today - timedelta(days=30)
warn_invoices = db.execute(
select(Invoice).where(
and_(
Invoice.status == InvoiceStatus.overdue,
Invoice.due_date != None,
Invoice.due_date <= warn_cutoff,
Invoice.due_date > suspend_cutoff,
)
)
).scalars().all()
for inv in warn_invoices:
school = db.get(School, inv.school_id)
if school and school.billing_email:
days_overdue = (today - inv.due_date).days
days_until_suspension = max(0, 30 - days_overdue)
send_email(
to=school.billing_email,
subject=f"[TapTrack Hub] Overdue Invoice — {inv.invoice_number}",
body=(
f"Dear {school.contact_name or school.name},\n\n"
f"Invoice {inv.invoice_number} (PHP {float(inv.total_amount):,.2f}) "
f"was due on {inv.due_date} and is now overdue.\n"
f"Please settle immediately to avoid suspension.\n\n"
f"Portal: {_hub_url()}/portal/billing\n\nTapTrack Hub Team"
),
template_name="email/overdue_warning.html",
context={
"contact_name": school.contact_name or school.name,
"school_name": school.name,
"invoice_number": inv.invoice_number,
"amount": f"{float(inv.total_amount):,.2f}",
"due_date": str(inv.due_date),
"days_overdue": days_overdue,
"days_until_suspension": days_until_suspension,
},
email_type="overdue_warning",
school_id=school.id,
)
logger.info("Sent %d overdue warning emails", len(warn_invoices))
# 3. Suspend: overdue 30+ days
suspend_invoices = db.execute(
select(Invoice).where(
and_(
Invoice.status == InvoiceStatus.overdue,
Invoice.due_date != None,
Invoice.due_date <= suspend_cutoff,
)
)
).scalars().all()
suspended = 0
for inv in suspend_invoices:
school = db.get(School, inv.school_id)
if school and school.status == SchoolStatus.active:
school.status = SchoolStatus.suspended
suspended += 1
logger.warning(
"Suspended school %s — overdue invoice %s (30+ days)",
school.name, inv.invoice_number,
)
if school.billing_email:
send_email(
to=school.billing_email,
subject=f"[TapTrack Hub] Account Suspended — Invoice {inv.invoice_number}",
body=(
f"Your TapTrack account for {school.name} has been suspended.\n"
f"Invoice {inv.invoice_number} (PHP {float(inv.total_amount):,.2f}) "
f"was due on {inv.due_date} and remains unpaid.\n\n"
f"Contact support@taptrack.io to restore service."
),
template_name="email/suspension.html",
context={
"contact_name": school.contact_name or school.name,
"school_name": school.name,
"invoice_number": inv.invoice_number,
"amount": f"{float(inv.total_amount):,.2f}",
"due_date": str(inv.due_date),
},
email_type="suspension",
school_id=school.id,
)
db.commit()
logger.info("Suspended %d schools for non-payment", suspended)
except Exception as e:
db.rollback()
logger.error("check_overdue error: %s", e)
finally:
db.close()

View File

@@ -6,6 +6,7 @@ from app.worker import celery_app
logger = logging.getLogger(__name__)
@celery_app.task(name="license.check_expiry")
def check_expiry():
"""Send expiry warning emails for licenses expiring in 30, 14, or 7 days."""
@@ -34,7 +35,20 @@ def check_expiry():
send_email(
to=school.billing_email,
subject=f"[TapTrack Hub] License expires in {days_ahead} days — {school.name}",
body=f"Your TapTrack license for {school.name} expires on {lic.expires_at}. Please contact us to renew.",
body=(
f"Your TapTrack license for {school.name} expires on {lic.expires_at} "
f"({days_ahead} days remaining). Please contact us to renew."
),
template_name="email/license_expiry.html",
context={
"contact_name": school.contact_name or school.name,
"school_name": school.name,
"license_key": lic.key,
"expires_at": str(lic.expires_at),
"days_left": days_ahead,
},
email_type="license_expiry",
school_id=school.id,
)
db.commit()
finally:

View File

@@ -0,0 +1,56 @@
"""Celery task: welcome email on school onboarding."""
import logging
from app.worker import celery_app
logger = logging.getLogger(__name__)
@celery_app.task(name="onboarding.send_welcome_email")
def send_welcome_email(school_id: str):
"""Send welcome email with license key to new school."""
import os
from sqlalchemy import create_engine, select
from sqlalchemy.orm import sessionmaker
from app.models.school import School
from app.models.license import License
from app.services.email import send_email
db_url = os.getenv("DATABASE_URL", "postgresql://postgres:postgres@db:5432/taptrack_hub")
engine = create_engine(db_url.replace("postgresql+asyncpg://", "postgresql://"), pool_pre_ping=True)
db = sessionmaker(bind=engine)()
try:
school = db.get(School, school_id)
if not school:
return
lic = db.execute(select(License).where(License.school_id == school_id)).scalar_one_or_none()
email_to = school.billing_email or school.contact_email
if not email_to:
logger.warning("No email for school %s — skipping welcome", school.name)
return
hub_url = os.getenv("HUB_BASE_URL", "http://localhost:8090")
license_key = lic.key if lic else "Contact support to get your license key"
plain = (
f"Welcome to TapTrack Hub!\n\n"
f"Your school '{school.name}' has been registered.\n\n"
f"License Key: {license_key}\n\n"
f"Setup instructions:\n"
f"1. Log in to TapTrack Hub: {hub_url}/login\n"
f"2. Enter your license key in TapTrack's Hub settings\n"
f"3. Restart the TapTrack service\n\n"
f"Support: support@taptrack.io\n\nTapTrack Hub Team"
)
send_email(
to=email_to,
subject=f"Welcome to TapTrack Hub — {school.name}",
body=plain,
email_type="welcome",
school_id=school.id,
)
logger.info("Welcome email sent to %s for school %s", email_to, school.name)
except Exception as e:
logger.error("send_welcome_email error for %s: %s", school_id, e)
finally:
db.close()
engine.dispose()

View File

@@ -1,35 +1,196 @@
"""Celery task: send monthly reports to schools."""
"""Celery tasks: monthly report generation and on-prem data pull."""
import logging
from datetime import datetime, timezone, timedelta, date
from app.worker import celery_app
logger = logging.getLogger(__name__)
@celery_app.task(name="reports.send_monthly_reports")
def send_monthly_reports():
"""Send monthly attendance and SMS report email to each active school."""
import os
from datetime import date, timedelta
from sqlalchemy import create_engine, select
from sqlalchemy.orm import sessionmaker
from app.models.school import School, SchoolStatus
from app.services.email import send_email
def _make_session():
import os
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
db_url = os.getenv("DATABASE_URL", "postgresql://postgres:postgres@db:5432/taptrack_hub")
engine = create_engine(db_url.replace("postgresql+asyncpg://", "postgresql://"), pool_pre_ping=True)
db = sessionmaker(bind=engine)()
return sessionmaker(bind=engine)()
@celery_app.task(name="reports.pull_monthly_stats")
def pull_monthly_stats():
"""
Pull attendance stats from each on-prem TapTrack instance.
Runs 1st of month at 5am, before report generation at 7am.
"""
import httpx
from sqlalchemy import select
from app.models.school import School, SchoolStatus
from app.models.license import License
from app.models.report import SchoolMonthlyStats
db = _make_session()
try:
today = date.today()
prev_month_end = date(today.year, today.month, 1) - timedelta(days=1)
prev_month_start = date(prev_month_end.year, prev_month_end.month, 1)
prev_end = date(today.year, today.month, 1) - timedelta(days=1)
report_month = f"{prev_end.year}-{prev_end.month:02d}"
schools = db.execute(select(School).where(School.status == SchoolStatus.active)).scalars().all()
for school in schools:
lic = db.execute(select(License).where(License.school_id == school.id)).scalar_one_or_none()
hub_url = getattr(school, 'hub_base_url', None)
existing = db.execute(
select(SchoolMonthlyStats).where(
SchoolMonthlyStats.school_id == school.id,
SchoolMonthlyStats.report_month == report_month,
)
).scalar_one_or_none()
stats = existing or SchoolMonthlyStats(school_id=school.id, report_month=report_month)
if hub_url and lic:
try:
resp = httpx.get(
f"{hub_url.rstrip('/')}/api/hub/monthly-report",
params={"key": lic.key, "month": report_month},
timeout=10,
)
if resp.status_code == 200:
data = resp.json()
stats.total_students = data.get("total_students")
stats.school_days = data.get("school_days")
stats.present_days_total = data.get("present_days_total")
stats.absent_days_total = data.get("absent_days_total")
stats.late_days_total = data.get("late_days_total")
stats.avg_attendance_rate = data.get("avg_attendance_rate")
stats.sms_sent = data.get("sms_sent")
stats.pull_status = "success"
stats.pulled_at = datetime.now(timezone.utc)
stats.hub_base_url = hub_url
else:
stats.pull_status = "failed"
except Exception as e:
logger.warning("Failed to pull stats for %s: %s", school.name, e)
stats.pull_status = "failed"
else:
stats.pull_status = "unavailable"
if not existing:
db.add(stats)
db.commit()
logger.info("pull_monthly_stats complete for %s", report_month)
except Exception as e:
db.rollback()
logger.error("pull_monthly_stats error: %s", e)
finally:
db.close()
@celery_app.task(name="reports.send_monthly_reports")
def send_monthly_reports():
"""Send monthly report emails to all active schools on the 1st at 7am."""
from sqlalchemy import select, func
from app.models.school import School, SchoolStatus
from app.models.sms import SmsJob, SmsJobStatus
from app.models.billing import Invoice
from app.models.report import MonthlyReport, SchoolMonthlyStats
from app.services.email import send_email
db = _make_session()
try:
today = date.today()
prev_end = date(today.year, today.month, 1) - timedelta(days=1)
prev_start = date(prev_end.year, prev_end.month, 1)
report_month = f"{prev_end.year}-{prev_end.month:02d}"
month_label = prev_start.strftime("%B %Y")
schools = db.execute(select(School).where(School.status == SchoolStatus.active)).scalars().all()
sent = 0
for school in schools:
if not school.billing_email:
continue
send_email(
to=school.billing_email,
subject=f"Monthly Report — {school.name}{prev_month_start.strftime('%B %Y')}",
body=f"Dear {school.contact_name or school.name},\n\nPlease find your monthly summary for {prev_month_start.strftime('%B %Y')} in your TapTrack Hub portal.\n\nSMS Credits Remaining: {float(school.sms_credits):.0f}\n\nLog in to view full details.\n\nThank you,\nTapTrack Hub Team",
sms_sent_count = db.execute(
select(func.count()).where(
SmsJob.school_id == school.id, SmsJob.status == SmsJobStatus.sent,
func.date(SmsJob.sent_at) >= prev_start, func.date(SmsJob.sent_at) <= prev_end,
)
).scalar_one()
sms_failed_count = db.execute(
select(func.count()).where(
SmsJob.school_id == school.id, SmsJob.status == SmsJobStatus.failed,
func.date(SmsJob.created_at) >= prev_start, func.date(SmsJob.created_at) <= prev_end,
)
).scalar_one()
inv = db.execute(
select(Invoice).where(Invoice.school_id == school.id, Invoice.billing_period_start == prev_start)
).scalar_one_or_none()
att = db.execute(
select(SchoolMonthlyStats).where(
SchoolMonthlyStats.school_id == school.id,
SchoolMonthlyStats.report_month == report_month,
)
).scalar_one_or_none()
report_data = {
"month": month_label, "sms_sent": sms_sent_count, "sms_failed": sms_failed_count,
"credits_remaining": float(school.sms_credits),
"attendance": {
"available": att is not None and att.pull_status == "success",
"total_students": att.total_students if att else None,
"school_days": att.school_days if att else None,
"avg_attendance_rate": float(att.avg_attendance_rate) if att and att.avg_attendance_rate else None,
},
"invoice": {"number": inv.invoice_number, "total": float(inv.total_amount), "status": inv.status.value} if inv else None,
}
existing_report = db.execute(
select(MonthlyReport).where(
MonthlyReport.school_id == school.id, MonthlyReport.report_month == report_month,
)
).scalar_one_or_none()
if not existing_report:
report = MonthlyReport(school_id=school.id, report_month=report_month, report_data=report_data)
db.add(report)
db.flush()
else:
report = existing_report
report.report_data = report_data
att_section = ""
if report_data["attendance"]["available"]:
att_section = (f"\nAttendance Rate: {report_data['attendance']['avg_attendance_rate']:.1f}%"
f" | School Days: {report_data['attendance']['school_days']}"
f" | Students: {report_data['attendance']['total_students']}")
plain = (
f"Dear {school.contact_name or school.name},\n\n"
f"Monthly summary for {month_label}:\n\n"
f"SMS Sent: {sms_sent_count} | Failed: {sms_failed_count}\n"
f"SMS Credits Remaining: {float(school.sms_credits):.0f}\n"
f"{att_section}\n\n"
f"Log in to view full details.\n\nTapTrack Hub Team"
)
logger.info(f"Sent monthly reports to {len(schools)} schools")
ok = send_email(
to=school.billing_email,
subject=f"Monthly Report — {school.name}{month_label}",
body=plain,
email_type="monthly_report",
school_id=school.id,
)
if ok:
report.email_sent_at = datetime.now(timezone.utc)
sent += 1
db.commit()
logger.info("send_monthly_reports: %d/%d sent", sent, len(schools))
except Exception as e:
db.rollback()
logger.error("send_monthly_reports error: %s", e)
finally:
db.close()

View File

@@ -1,7 +1,7 @@
"""Celery task: process pending SMS jobs via Semaphore."""
"""Celery task: process pending SMS jobs via Semaphore (push path)."""
import logging
import os
from datetime import datetime, timezone
from datetime import datetime, timezone, timedelta
import httpx
from sqlalchemy import create_engine, select, update, and_
@@ -19,9 +19,17 @@ def _make_sync_engine():
_engine = _make_sync_engine()
_Session = sessionmaker(bind=_engine)
@celery_app.task(name="sms.process_queue")
def process_sms_queue():
"""Process up to 20 pending SMS jobs per run via Semaphore API."""
"""
Push path: process up to 20 pending SMS jobs per run via Semaphore API.
Only picks up `pending` jobs. Jobs in `processing` are being handled
by the on-prem pull agent via /api/sync/poll — do not touch them here.
The reclaim_stale_jobs task will reset stale `processing` jobs back to
`pending` if the on-prem agent stops polling.
"""
from app.models.sms import SmsJob, SmsJobStatus
from app.models.school import School
@@ -44,6 +52,7 @@ def process_sms_queue():
job.status = SmsJobStatus.sent
job.sent_at = datetime.now(timezone.utc)
job.semaphore_message_id = result.get("message_id")
job.delivered_via = "push"
# Deduct credit
from app.models.sms import SmsCreditLedger, SmsCreditTx
school.sms_credits = float(school.sms_credits) - 1.0
@@ -52,7 +61,7 @@ def process_sms_queue():
tx_type=SmsCreditTx.deduct,
amount=-1.0,
balance_after=float(school.sms_credits),
description=f"SMS sent to {job.recipient_phone}",
description=f"SMS sent via Semaphore to {job.recipient_phone}",
reference_id=job.id,
))
# Low credit alert
@@ -67,6 +76,46 @@ def process_sms_queue():
finally:
db.close()
@celery_app.task(name="sms.reclaim_stale_jobs")
def reclaim_stale_jobs():
"""
Graceful degradation: reset `processing` jobs that have been stuck for
more than 5 minutes back to `pending`.
This handles the case where an on-prem TapTrack instance went offline
after receiving jobs from /api/sync/poll but before reporting them back.
Once reclaimed, the jobs are eligible to be picked up by sms.process_queue
(push path via Semaphore) or by the next on-prem poll.
"""
from app.models.sms import SmsJob, SmsJobStatus
stale_cutoff = datetime.now(timezone.utc) - timedelta(minutes=5)
db = _Session()
try:
result = db.execute(
update(SmsJob)
.where(
and_(
SmsJob.status == SmsJobStatus.processing,
SmsJob.processing_started_at != None, # noqa: E711
SmsJob.processing_started_at < stale_cutoff,
)
)
.values(
status=SmsJobStatus.pending,
processing_started_at=None,
error_message="Reclaimed: on-prem agent did not report back within 5 minutes",
)
)
reclaimed = result.rowcount
db.commit()
if reclaimed:
logger.info("sms.reclaim_stale_jobs: reclaimed %d stale processing jobs → pending", reclaimed)
finally:
db.close()
def _send_semaphore(phone: str, message: str, sender: str) -> dict:
try:
with httpx.Client(timeout=15) as client:
@@ -84,6 +133,7 @@ def _send_semaphore(phone: str, message: str, sender: str) -> dict:
except Exception as e:
return {"success": False, "error": str(e)}
@celery_app.task(name="sms.send_low_credit_alert")
def send_low_credit_alert(school_id: str):
"""Send low credit warning email to school billing contact."""
@@ -96,7 +146,20 @@ def send_low_credit_alert(school_id: str):
send_email(
to=school.billing_email,
subject=f"[TapTrack Hub] Low SMS Credits — {school.name}",
body=f"Your SMS credit balance for {school.name} is low ({float(school.sms_credits):.0f} remaining). Please top up to continue sending SMS notifications.",
body=(
f"Your SMS credit balance for {school.name} is low "
f"({float(school.sms_credits):.0f} credits remaining). "
f"Please top up to continue sending SMS notifications."
),
template_name="email/low_credit.html",
context={
"contact_name": school.contact_name or school.name,
"school_name": school.name,
"credits_remaining": int(float(school.sms_credits)),
"threshold": school.sms_credit_low_threshold,
},
email_type="low_credit",
school_id=school.id,
)
finally:
db.close()

View File

@@ -0,0 +1,70 @@
"""Celery tasks: ticket priority escalation."""
import logging
from datetime import datetime, timezone, timedelta
from app.worker import celery_app
logger = logging.getLogger(__name__)
def _make_session():
import os
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
db_url = os.getenv("DATABASE_URL", "postgresql://postgres:postgres@db:5432/taptrack_hub")
engine = create_engine(db_url.replace("postgresql+asyncpg://", "postgresql://"), pool_pre_ping=True)
return sessionmaker(bind=engine)()
@celery_app.task(name="tickets.escalate_stale")
def escalate_stale():
"""
Escalate stale open tickets:
- open > 48h + priority medium → high
- open > 72h + no reply yet → urgent
"""
from sqlalchemy import select, and_
from app.models.ticket import SupportTicket, TicketReply, TicketStatus, TicketPriority
db = _make_session()
try:
now = datetime.now(timezone.utc)
threshold_48h = now - timedelta(hours=48)
threshold_72h = now - timedelta(hours=72)
# Open tickets > 48h, medium priority → high
stale_medium = db.execute(
select(SupportTicket).where(
and_(
SupportTicket.status == TicketStatus.open,
SupportTicket.priority == TicketPriority.medium,
SupportTicket.created_at < threshold_48h,
)
)
).scalars().all()
for t in stale_medium:
t.priority = TicketPriority.high
logger.info("Escalated ticket %s to high priority (48h)", t.ticket_number)
# Open tickets > 72h, no reply → urgent
stale_72h = db.execute(
select(SupportTicket).where(
and_(
SupportTicket.status == TicketStatus.open,
SupportTicket.first_response_at == None, # noqa: E711
SupportTicket.created_at < threshold_72h,
)
)
).scalars().all()
for t in stale_72h:
if t.priority != TicketPriority.urgent:
t.priority = TicketPriority.urgent
logger.warning("Escalated ticket %s to urgent (72h no response)", t.ticket_number)
db.commit()
logger.info("escalate_stale: %d→high, %d→urgent", len(stale_medium), len(stale_72h))
except Exception as e:
db.rollback()
logger.error("escalate_stale error: %s", e)
finally:
db.close()

View File

@@ -0,0 +1,45 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ subject }}</title>
<style>
body { margin: 0; padding: 0; background: #f1f5f9; font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; }
.wrapper { max-width: 600px; margin: 32px auto; background: #ffffff; border-radius: 12px; overflow: hidden; box-shadow: 0 2px 12px rgba(0,0,0,0.08); }
.header { background: #1e40af; padding: 24px 32px; }
.header h1 { color: #ffffff; font-size: 18px; font-weight: 700; margin: 0; letter-spacing: -0.3px; }
.header p { color: #93c5fd; font-size: 12px; margin: 4px 0 0; }
.body { padding: 32px; color: #334155; font-size: 14px; line-height: 1.7; }
.body h2 { color: #0f172a; font-size: 18px; font-weight: 700; margin: 0 0 16px; }
.body p { margin: 0 0 14px; }
.body a { color: #2563eb; }
.info-box { background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 8px; padding: 16px 20px; margin: 20px 0; }
.info-box .row { display: flex; justify-content: space-between; padding: 5px 0; border-bottom: 1px solid #f1f5f9; font-size: 13px; }
.info-box .row:last-child { border-bottom: none; }
.info-box .label { color: #64748b; }
.info-box .value { font-weight: 600; color: #0f172a; }
.btn { display: inline-block; background: #2563eb; color: #ffffff !important; text-decoration: none; padding: 10px 24px; border-radius: 8px; font-weight: 600; font-size: 13px; margin: 8px 0; }
.alert-red { background: #fef2f2; border: 1px solid #fecaca; border-radius: 8px; padding: 14px 18px; margin: 20px 0; color: #991b1b; font-size: 13px; }
.alert-amber { background: #fffbeb; border: 1px solid #fde68a; border-radius: 8px; padding: 14px 18px; margin: 20px 0; color: #92400e; font-size: 13px; }
.alert-green { background: #f0fdf4; border: 1px solid #bbf7d0; border-radius: 8px; padding: 14px 18px; margin: 20px 0; color: #14532d; font-size: 13px; }
.footer { background: #f8fafc; border-top: 1px solid #e2e8f0; padding: 20px 32px; text-align: center; color: #94a3b8; font-size: 12px; }
.footer a { color: #64748b; }
</style>
</head>
<body>
<div class="wrapper">
<div class="header">
<h1>TapTrack Hub</h1>
<p>Cloud Control Plane for TapTrack Deployments</p>
</div>
<div class="body">
{% block content %}{% endblock %}
</div>
<div class="footer">
<p>TapTrack Hub &nbsp;·&nbsp; <a href="mailto:support@taptrack.io">support@taptrack.io</a></p>
<p style="margin-top:4px">You are receiving this email because you are registered as a school administrator.</p>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,20 @@
{% extends "email/base.html" %}
{% block content %}
<h2>Invoice {{ invoice_number }}</h2>
<p>Dear {{ contact_name }},</p>
<p>Your invoice for <strong>{{ school_name }}</strong> has been generated for the period
<strong>{{ period_start }}</strong> to <strong>{{ period_end }}</strong>.</p>
<div class="info-box">
<div class="row"><span class="label">Invoice Number</span><span class="value">{{ invoice_number }}</span></div>
<div class="row"><span class="label">Billing Period</span><span class="value">{{ period_start }} — {{ period_end }}</span></div>
<div class="row"><span class="label">Amount Due</span><span class="value">PHP {{ amount }}</span></div>
<div class="row"><span class="label">Due Date</span><span class="value">{{ due_date }}</span></div>
</div>
<p>Please log in to your school portal to view and download your invoice.</p>
<p><a class="btn" href="{{ portal_url }}/portal/billing">View Invoice</a></p>
<p>If you have any questions, please contact us at <a href="mailto:support@taptrack.io">support@taptrack.io</a>.</p>
<p>Thank you,<br><strong>TapTrack Hub Team</strong></p>
{% endblock %}

View File

@@ -0,0 +1,23 @@
{% extends "email/base.html" %}
{% block content %}
<h2>License Expiring in {{ days_left }} Days</h2>
<p>Dear {{ contact_name }},</p>
<div class="alert-amber">
<strong>Your TapTrack license for {{ school_name }} expires on {{ expires_at }}.</strong>
</div>
<div class="info-box">
<div class="row"><span class="label">School</span><span class="value">{{ school_name }}</span></div>
<div class="row"><span class="label">License Key</span><span class="value" style="font-family:monospace;font-size:12px">{{ license_key }}</span></div>
<div class="row"><span class="label">Expires On</span><span class="value">{{ expires_at }}</span></div>
<div class="row"><span class="label">Days Remaining</span><span class="value" style="color:#b45309">{{ days_left }} days</span></div>
</div>
<p>When your license expires, SMS notifications will be disabled and your on-prem TapTrack
instance will enter read-only mode. Please contact us to renew your license before it expires.</p>
<p><a class="btn" href="mailto:support@taptrack.io?subject=License Renewal — {{ school_name }}">Contact Us to Renew</a></p>
<p>Thank you,<br><strong>TapTrack Hub Team</strong></p>
{% endblock %}

View File

@@ -0,0 +1,23 @@
{% extends "email/base.html" %}
{% block content %}
<h2>Low SMS Credits — Action Required</h2>
<p>Dear {{ contact_name }},</p>
<div class="alert-amber">
<strong>Your SMS credit balance for {{ school_name }} is running low.</strong>
</div>
<div class="info-box">
<div class="row"><span class="label">School</span><span class="value">{{ school_name }}</span></div>
<div class="row"><span class="label">Current Balance</span><span class="value" style="color:#b45309">{{ credits_remaining }} credits</span></div>
<div class="row"><span class="label">Low Credit Threshold</span><span class="value">{{ threshold }} credits</span></div>
</div>
<p>SMS notifications to parents and guardians will stop working when your credit balance reaches zero.
Please top up your credits to ensure uninterrupted service.</p>
<p><a class="btn" href="{{ portal_url }}/portal/billing">Request Credit Top-Up</a></p>
<p>If you need assistance, contact us at <a href="mailto:support@taptrack.io">support@taptrack.io</a>.</p>
<p>Thank you,<br><strong>TapTrack Hub Team</strong></p>
{% endblock %}

View File

@@ -0,0 +1,26 @@
{% extends "email/base.html" %}
{% block content %}
<h2>Overdue Invoice — Immediate Action Required</h2>
<p>Dear {{ contact_name }},</p>
<div class="alert-red">
<strong>Invoice {{ invoice_number }} is overdue. Please settle this immediately to avoid account suspension.</strong>
</div>
<div class="info-box">
<div class="row"><span class="label">Invoice Number</span><span class="value">{{ invoice_number }}</span></div>
<div class="row"><span class="label">Amount</span><span class="value">PHP {{ amount }}</span></div>
<div class="row"><span class="label">Was Due On</span><span class="value" style="color:#dc2626">{{ due_date }}</span></div>
<div class="row"><span class="label">Days Overdue</span><span class="value" style="color:#dc2626">{{ days_overdue }} days</span></div>
</div>
<p>If this invoice is not settled within <strong>{{ days_until_suspension }} days</strong>, your TapTrack
account will be automatically suspended. This will disable SMS notifications for your school.</p>
<p><a class="btn" style="background:#dc2626" href="{{ portal_url }}/portal/billing">View &amp; Settle Invoice</a></p>
<p>If you believe this is an error or need to discuss payment arrangements, please contact us
at <a href="mailto:support@taptrack.io">support@taptrack.io</a> immediately.</p>
<p>Thank you,<br><strong>TapTrack Hub Team</strong></p>
{% endblock %}

View File

@@ -0,0 +1,32 @@
{% extends "email/base.html" %}
{% block content %}
<h2>Account Suspended</h2>
<p>Dear {{ contact_name }},</p>
<div class="alert-red">
<strong>Your TapTrack account for {{ school_name }} has been suspended due to an unpaid invoice.</strong>
</div>
<div class="info-box">
<div class="row"><span class="label">School</span><span class="value">{{ school_name }}</span></div>
<div class="row"><span class="label">Invoice</span><span class="value">{{ invoice_number }}</span></div>
<div class="row"><span class="label">Amount</span><span class="value">PHP {{ amount }}</span></div>
<div class="row"><span class="label">Original Due Date</span><span class="value">{{ due_date }}</span></div>
</div>
<p>The following services are now <strong>disabled</strong>:</p>
<ul style="margin:8px 0 16px;padding-left:20px;color:#334155">
<li>SMS notifications to parents and guardians</li>
<li>Automated monthly reports</li>
<li>License validation (on-prem may enter warning mode)</li>
</ul>
<p>Attendance recording on your on-prem TapTrack instance continues to function.</p>
<p>To restore full service, please settle the overdue invoice immediately and contact us at
<a href="mailto:support@taptrack.io">support@taptrack.io</a> to lift the suspension.</p>
<p><a class="btn" style="background:#dc2626" href="mailto:support@taptrack.io?subject=Account Suspension — {{ school_name }}">Contact Support to Restore</a></p>
<p>Thank you,<br><strong>TapTrack Hub Team</strong></p>
{% endblock %}

View File

@@ -0,0 +1,22 @@
{% extends "email/base.html" %}
{% block content %}
<h2>{{ email_title }}</h2>
<p>Dear {{ recipient_name }},</p>
<div class="info-box">
<div class="row"><span class="label">Ticket</span><span class="value">{{ ticket_number }}</span></div>
<div class="row"><span class="label">Subject</span><span class="value">{{ subject }}</span></div>
<div class="row"><span class="label">Category</span><span class="value" style="text-transform:capitalize">{{ category }}</span></div>
<div class="row"><span class="label">Priority</span><span class="value" style="text-transform:capitalize">{{ priority }}</span></div>
<div class="row"><span class="label">Status</span><span class="value" style="text-transform:capitalize">{{ status }}</span></div>
</div>
<div style="background:#f8fafc;border:1px solid #e2e8f0;border-radius:8px;padding:16px;margin:16px 0">
<p style="font-size:11px;color:#94a3b8;margin:0 0 8px;font-weight:600;text-transform:uppercase;letter-spacing:0.5px">{{ message_label }}</p>
<p style="margin:0;white-space:pre-wrap;color:#334155">{{ message_body }}</p>
</div>
<p><a class="btn" href="{{ ticket_url }}">View Ticket</a></p>
<p>Thank you,<br><strong>TapTrack Hub Team</strong></p>
{% endblock %}

View File

@@ -0,0 +1,226 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<style>
@page { size: A4; margin: 20mm 18mm 20mm 18mm; }
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; font-size: 11pt; color: #1e293b; line-height: 1.5; }
/* Header */
.header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 32px; }
.brand { }
.brand h1 { font-size: 20pt; font-weight: 700; color: #1e40af; letter-spacing: -0.5px; }
.brand p { font-size: 9pt; color: #64748b; margin-top: 2px; }
.invoice-meta { text-align: right; }
.invoice-meta .invoice-number { font-size: 15pt; font-weight: 700; color: #1e293b; }
.invoice-meta .invoice-label { font-size: 8pt; text-transform: uppercase; letter-spacing: 1px; color: #94a3b8; }
.invoice-meta .date { font-size: 10pt; color: #475569; margin-top: 4px; }
/* Divider */
.divider { border: none; border-top: 2px solid #e2e8f0; margin: 20px 0; }
.divider-thin { border: none; border-top: 1px solid #e2e8f0; margin: 12px 0; }
/* Parties */
.parties { display: flex; gap: 40px; margin-bottom: 28px; }
.party { flex: 1; }
.party-label { font-size: 8pt; text-transform: uppercase; letter-spacing: 1px; color: #94a3b8; margin-bottom: 6px; }
.party-name { font-weight: 700; font-size: 12pt; color: #0f172a; }
.party p { font-size: 9.5pt; color: #475569; margin-top: 1px; }
/* Status pill */
.status-pill { display: inline-block; padding: 3px 10px; border-radius: 20px; font-size: 8.5pt; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; }
.status-draft { background: #f1f5f9; color: #64748b; }
.status-sent { background: #eff6ff; color: #1d4ed8; }
.status-paid { background: #f0fdf4; color: #15803d; }
.status-overdue { background: #fef2f2; color: #dc2626; }
.status-cancelled { background: #f1f5f9; color: #94a3b8; }
/* Invoice info grid */
.info-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; margin-bottom: 28px; background: #f8fafc; border-radius: 8px; padding: 16px; }
.info-cell .info-label { font-size: 8pt; text-transform: uppercase; letter-spacing: 0.8px; color: #94a3b8; margin-bottom: 3px; }
.info-cell .info-value { font-size: 10.5pt; font-weight: 600; color: #0f172a; }
/* Line items table */
.items-table { width: 100%; border-collapse: collapse; margin-bottom: 20px; }
.items-table thead th { background: #1e40af; color: white; font-size: 8.5pt; text-transform: uppercase; letter-spacing: 0.8px; padding: 9px 12px; text-align: left; }
.items-table thead th:last-child,
.items-table thead th:nth-child(3),
.items-table thead th:nth-child(4) { text-align: right; }
.items-table tbody tr:nth-child(even) { background: #f8fafc; }
.items-table tbody td { padding: 10px 12px; font-size: 10pt; color: #334155; border-bottom: 1px solid #f1f5f9; }
.items-table tbody td.num { text-align: right; font-variant-numeric: tabular-nums; }
/* Totals */
.totals { float: right; width: 260px; margin-bottom: 32px; }
.totals table { width: 100%; border-collapse: collapse; }
.totals td { padding: 5px 0; font-size: 10pt; color: #475569; }
.totals td:last-child { text-align: right; font-variant-numeric: tabular-nums; }
.totals .total-row td { font-size: 13pt; font-weight: 700; color: #0f172a; border-top: 2px solid #e2e8f0; padding-top: 10px; margin-top: 4px; }
.totals .paid-row td { color: #15803d; font-weight: 600; }
.clearfix::after { content: ""; display: table; clear: both; }
/* Payment info */
.payment-info { background: #eff6ff; border: 1px solid #bfdbfe; border-radius: 8px; padding: 14px 16px; margin-bottom: 24px; }
.payment-info h3 { font-size: 9.5pt; font-weight: 700; color: #1e40af; margin-bottom: 6px; }
.payment-info p { font-size: 9.5pt; color: #1e3a8a; }
.payment-info .ref { font-family: monospace; background: #dbeafe; padding: 2px 6px; border-radius: 3px; font-size: 9pt; }
/* Footer */
.footer { margin-top: 32px; padding-top: 12px; border-top: 1px solid #e2e8f0; display: flex; justify-content: space-between; align-items: center; }
.footer p { font-size: 8.5pt; color: #94a3b8; }
</style>
</head>
<body>
<!-- Header -->
<div class="header">
<div class="brand">
<h1>TapTrack Hub</h1>
<p>Cloud Control Plane for TapTrack Deployments</p>
</div>
<div class="invoice-meta">
<div class="invoice-label">Invoice</div>
<div class="invoice-number">{{ invoice.invoice_number }}</div>
<div class="date">Issued {{ issued_date }}</div>
<div style="margin-top:6px">
<span class="status-pill status-{{ invoice.status }}">{{ invoice.status }}</span>
</div>
</div>
</div>
<hr class="divider">
<!-- Bill To / From -->
<div class="parties">
<div class="party">
<div class="party-label">Bill To</div>
<div class="party-name">{{ school.name }}</div>
{% if school.address %}<p>{{ school.address }}</p>{% endif %}
{% if school.city %}<p>{{ school.city }}</p>{% endif %}
{% if school.billing_email %}<p>{{ school.billing_email }}</p>{% endif %}
{% if school.contact_phone %}<p>{{ school.contact_phone }}</p>{% endif %}
</div>
<div class="party">
<div class="party-label">From</div>
<div class="party-name">TapTrack Hub</div>
<p>Cloud Services</p>
<p>support@taptrack.io</p>
</div>
</div>
<!-- Invoice info -->
<div class="info-grid">
<div class="info-cell">
<div class="info-label">Billing Period</div>
<div class="info-value">{{ period_start }} — {{ period_end }}</div>
</div>
<div class="info-cell">
<div class="info-label">Due Date</div>
<div class="info-value">{{ due_date if due_date else 'Upon receipt' }}</div>
</div>
<div class="info-cell">
<div class="info-label">Currency</div>
<div class="info-value">{{ invoice.currency }}</div>
</div>
</div>
<!-- Line items -->
<table class="items-table">
<thead>
<tr>
<th style="width:55%">Description</th>
<th style="width:15%">Qty</th>
<th style="width:15%">Unit Price</th>
<th style="width:15%">Amount</th>
</tr>
</thead>
<tbody>
{% for item in line_items %}
<tr>
<td>{{ item.description }}</td>
<td class="num">{{ item.quantity }}</td>
<td class="num">{{ invoice.currency }} {{ "{:,.2f}".format(item.unit_price) }}</td>
<td class="num">{{ invoice.currency }} {{ "{:,.2f}".format(item.amount) }}</td>
</tr>
{% endfor %}
{% if not line_items %}
{% if invoice.subscription_amount > 0 %}
<tr>
<td>Monthly Subscription — {{ school.name }}</td>
<td class="num">1</td>
<td class="num">{{ invoice.currency }} {{ "{:,.2f}".format(invoice.subscription_amount) }}</td>
<td class="num">{{ invoice.currency }} {{ "{:,.2f}".format(invoice.subscription_amount) }}</td>
</tr>
{% endif %}
{% if invoice.sms_credit_amount > 0 %}
<tr>
<td>SMS Credits</td>
<td class="num">1</td>
<td class="num">{{ invoice.currency }} {{ "{:,.2f}".format(invoice.sms_credit_amount) }}</td>
<td class="num">{{ invoice.currency }} {{ "{:,.2f}".format(invoice.sms_credit_amount) }}</td>
</tr>
{% endif %}
{% if invoice.other_amount > 0 %}
<tr>
<td>Other Charges</td>
<td class="num">1</td>
<td class="num">{{ invoice.currency }} {{ "{:,.2f}".format(invoice.other_amount) }}</td>
<td class="num">{{ invoice.currency }} {{ "{:,.2f}".format(invoice.other_amount) }}</td>
</tr>
{% endif %}
{% endif %}
</tbody>
</table>
<!-- Totals + Payment info -->
<div class="clearfix">
<div class="totals">
<table>
<tr>
<td>Subtotal</td>
<td>{{ invoice.currency }} {{ "{:,.2f}".format(invoice.total_amount) }}</td>
</tr>
<tr class="total-row">
<td>Total Due</td>
<td>{{ invoice.currency }} {{ "{:,.2f}".format(invoice.total_amount) }}</td>
</tr>
{% if invoice.paid_at %}
<tr class="paid-row">
<td>Paid</td>
<td>{{ invoice.currency }} {{ "{:,.2f}".format(invoice.total_amount) }}</td>
</tr>
{% endif %}
</table>
</div>
</div>
{% if invoice.paid_at %}
<div class="payment-info" style="background:#f0fdf4;border-color:#bbf7d0">
<h3 style="color:#15803d">Payment Received</h3>
<p>Paid on {{ paid_date }} via {{ invoice.payment_method or 'N/A' }}
{% if invoice.payment_reference %} · Ref: <span class="ref">{{ invoice.payment_reference }}</span>{% endif %}</p>
</div>
{% else %}
<div class="payment-info">
<h3>Payment Instructions</h3>
<p>Please log in to your TapTrack Hub school portal and submit payment by <strong>{{ due_date if due_date else 'the due date' }}</strong>.</p>
<p style="margin-top:4px">For questions, contact <strong>support@taptrack.io</strong></p>
</div>
{% endif %}
{% if invoice.notes %}
<div style="background:#fffbeb;border:1px solid #fde68a;border-radius:8px;padding:12px 14px;margin-bottom:16px">
<strong style="font-size:9pt;color:#92400e">Notes:</strong>
<p style="font-size:9.5pt;color:#78350f;margin-top:3px">{{ invoice.notes }}</p>
</div>
{% endif %}
<!-- Footer -->
<div class="footer">
<p>TapTrack Hub · support@taptrack.io</p>
<p>Generated {{ generated_date }} · {{ invoice.invoice_number }}</p>
</div>
</body>
</html>

View File

@@ -14,6 +14,8 @@ celery_app = Celery(
"app.tasks.billing",
"app.tasks.reports",
"app.tasks.license",
"app.tasks.tickets",
"app.tasks.onboarding",
],
)
@@ -24,11 +26,16 @@ celery_app.conf.update(
timezone="Asia/Manila",
enable_utc=True,
beat_schedule={
# Process pending SMS jobs every 30 seconds
# Process pending SMS jobs every 30 seconds (push path via Semaphore)
"process-sms-queue": {
"task": "sms.process_queue",
"schedule": 30.0,
},
# Reclaim processing jobs where on-prem went offline (every 5 minutes)
"reclaim-stale-sms-jobs": {
"task": "sms.reclaim_stale_jobs",
"schedule": 300.0,
},
# Check license expiry every day at 8am
"check-license-expiry": {
"task": "license.check_expiry",
@@ -39,6 +46,11 @@ celery_app.conf.update(
"task": "billing.generate_monthly_invoices",
"schedule": crontab(day_of_month=1, hour=6, minute=0),
},
# Pull on-prem stats on the 1st at 5am
"pull-monthly-stats": {
"task": "reports.pull_monthly_stats",
"schedule": crontab(day_of_month=1, hour=5, minute=0),
},
# Send monthly reports on the 1st at 7am
"send-monthly-reports": {
"task": "reports.send_monthly_reports",
@@ -49,6 +61,11 @@ celery_app.conf.update(
"task": "billing.check_overdue",
"schedule": crontab(hour=9, minute=0),
},
# Escalate stale tickets every hour
"escalate-stale-tickets": {
"task": "tickets.escalate_stale",
"schedule": crontab(minute=0),
},
},
)

View File

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

View File

@@ -0,0 +1,35 @@
"""phase5: add delivered_via and processing_started_at to sms_jobs
Revision ID: 001_phase5
Revises:
Create Date: 2026-03-16
Adds two columns to sms_jobs to support the on-prem polling protocol:
- delivered_via: tracks whether the job was sent via the on-prem pull agent ("pull")
or directly via the Hub's Celery push path ("push")
- processing_started_at: timestamp when the job was marked `processing` (dispatched to on-prem).
Used by sms.reclaim_stale_jobs to detect and reclaim stuck jobs.
"""
from alembic import op
import sqlalchemy as sa
revision = "001_phase5"
down_revision = None
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"sms_jobs",
sa.Column("delivered_via", sa.String(10), nullable=True),
)
op.add_column(
"sms_jobs",
sa.Column("processing_started_at", sa.DateTime(timezone=True), nullable=True),
)
def downgrade() -> None:
op.drop_column("sms_jobs", "processing_started_at")
op.drop_column("sms_jobs", "delivered_via")

View File

@@ -0,0 +1,38 @@
"""phase9: add email_logs table
Revision ID: 002_phase9
Revises: 001_phase5
Create Date: 2026-03-16
Stores every email send attempt for delivery monitoring.
"""
from alembic import op
import sqlalchemy as sa
revision = "002_phase9"
down_revision = "001_phase5"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"email_logs",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("school_id", sa.String(36), sa.ForeignKey("schools.id", ondelete="SET NULL"), nullable=True),
sa.Column("to_email", sa.String(255), nullable=False),
sa.Column("subject", sa.String(500), nullable=False),
sa.Column("email_type", sa.String(30), nullable=False),
sa.Column("status", sa.String(10), nullable=False),
sa.Column("error_message", sa.Text, nullable=True),
sa.Column("sent_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
)
op.create_index("ix_email_logs_school_id", "email_logs", ["school_id"])
op.create_index("ix_email_logs_to_email", "email_logs", ["to_email"])
op.create_index("ix_email_logs_email_type", "email_logs", ["email_type"])
op.create_index("ix_email_logs_status", "email_logs", ["status"])
def downgrade() -> None:
op.drop_table("email_logs")

View File

@@ -0,0 +1,59 @@
"""phases 11-15: monthly_reports, school_monthly_stats, school new columns
Revision ID: 003_phases11_15
Revises: 002_phase9
Create Date: 2026-03-16
"""
from alembic import op
import sqlalchemy as sa
revision = "003_phases11_15"
down_revision = "002_phase9"
branch_labels = None
depends_on = None
def upgrade() -> None:
# Schools: new columns
op.add_column("schools", sa.Column("hub_base_url", sa.String(500), nullable=True))
op.add_column("schools", sa.Column("feature_overrides", sa.Text, nullable=True))
op.add_column("schools", sa.Column("onboarding_completed_at", sa.DateTime(timezone=True), nullable=True))
# monthly_reports table
op.create_table(
"monthly_reports",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("school_id", sa.String(36), sa.ForeignKey("schools.id", ondelete="CASCADE"), nullable=False),
sa.Column("report_month", sa.String(7), nullable=False),
sa.Column("email_sent_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("report_data", sa.JSON, nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
)
op.create_index("ix_monthly_reports_school_id", "monthly_reports", ["school_id"])
# school_monthly_stats table
op.create_table(
"school_monthly_stats",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("school_id", sa.String(36), sa.ForeignKey("schools.id", ondelete="CASCADE"), nullable=False),
sa.Column("report_month", sa.String(7), nullable=False),
sa.Column("total_students", sa.Integer, nullable=True),
sa.Column("school_days", sa.Integer, nullable=True),
sa.Column("present_days_total", sa.Integer, nullable=True),
sa.Column("absent_days_total", sa.Integer, nullable=True),
sa.Column("late_days_total", sa.Integer, nullable=True),
sa.Column("avg_attendance_rate", sa.Numeric(5, 2), nullable=True),
sa.Column("sms_sent", sa.Integer, nullable=True),
sa.Column("pull_status", sa.String(20), nullable=False, server_default="unavailable"),
sa.Column("pulled_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("hub_base_url", sa.String(500), nullable=True),
)
op.create_index("ix_school_monthly_stats_school_id", "school_monthly_stats", ["school_id"])
def downgrade() -> None:
op.drop_table("school_monthly_stats")
op.drop_table("monthly_reports")
op.drop_column("schools", "onboarding_completed_at")
op.drop_column("schools", "feature_overrides")
op.drop_column("schools", "hub_base_url")

View File

@@ -8,6 +8,7 @@ pydantic[email]==2.10.3
pydantic-settings==2.7.0
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
bcrypt==4.3.0
python-multipart==0.0.20
celery==5.4.0
redis==5.2.1

View File

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

View File

@@ -1,5 +1,19 @@
version: "3.9"
x-backend-env: &backend-env
DATABASE_URL: postgresql+asyncpg://postgres:postgres@db:5432/taptrack_hub
REDIS_URL: redis://redis:6379/0
SECRET_KEY: changeme-in-production-use-openssl-rand-hex-32
ENVIRONMENT: development
SEMAPHORE_API_KEY: ""
SMTP_HOST: ""
SMTP_PORT: "587"
SMTP_USER: ""
SMTP_PASSWORD: ""
SMTP_FROM: "noreply@taptrack.io"
HUB_BASE_URL: "http://localhost:8090"
PDF_DIR: "/app/data/invoices"
services:
db:
image: postgres:15-alpine
@@ -26,17 +40,7 @@ services:
build: ./backend
restart: unless-stopped
environment:
DATABASE_URL: postgresql+asyncpg://postgres:postgres@db:5432/taptrack_hub
REDIS_URL: redis://redis:6379/0
SECRET_KEY: changeme-in-production-use-openssl-rand-hex-32
ENVIRONMENT: development
SEMAPHORE_API_KEY: ""
SMTP_HOST: ""
SMTP_PORT: "587"
SMTP_USER: ""
SMTP_PASSWORD: ""
SMTP_FROM: "noreply@taptrack.io"
HUB_BASE_URL: "http://localhost:8090"
<<: *backend-env
volumes:
- ./backend:/app
- backend_data:/app/data
@@ -46,22 +50,25 @@ services:
redis:
condition: service_started
# One-shot seed service — runs seed.py then exits
seed:
build: ./backend
command: python seed.py
environment:
<<: *backend-env
volumes:
- ./backend:/app
depends_on:
db:
condition: service_healthy
restart: "no"
celery:
build: ./backend
command: celery -A app.worker worker --loglevel=info --concurrency=2
restart: unless-stopped
environment:
DATABASE_URL: postgresql+asyncpg://postgres:postgres@db:5432/taptrack_hub
REDIS_URL: redis://redis:6379/0
SECRET_KEY: changeme-in-production-use-openssl-rand-hex-32
ENVIRONMENT: development
SEMAPHORE_API_KEY: ""
SMTP_HOST: ""
SMTP_PORT: "587"
SMTP_USER: ""
SMTP_PASSWORD: ""
SMTP_FROM: "noreply@taptrack.io"
HUB_BASE_URL: "http://localhost:8090"
<<: *backend-env
volumes:
- ./backend:/app
- backend_data:/app/data
@@ -74,17 +81,7 @@ services:
command: celery -A app.worker beat --loglevel=info
restart: unless-stopped
environment:
DATABASE_URL: postgresql+asyncpg://postgres:postgres@db:5432/taptrack_hub
REDIS_URL: redis://redis:6379/0
SECRET_KEY: changeme-in-production-use-openssl-rand-hex-32
ENVIRONMENT: development
SEMAPHORE_API_KEY: ""
SMTP_HOST: ""
SMTP_PORT: "587"
SMTP_USER: ""
SMTP_PASSWORD: ""
SMTP_FROM: "noreply@taptrack.io"
HUB_BASE_URL: "http://localhost:8090"
<<: *backend-env
volumes:
- ./backend:/app
- backend_data:/app/data

View File

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

View File

@@ -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="onSearchBlur"
@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,56 @@
</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 onSearchBlur() {
setTimeout(() => { showSearch.value = false }, 200)
}
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')

View File

@@ -31,7 +31,9 @@ export const changePassword = (current_password: string, new_password: string) =
api.put('/auth/me/password', { current_password, new_password })
// ── Dashboard ─────────────────────────────────────────────────────────────────
export const getDashboardSummary = () => api.get('/dashboard/summary').then(r => r.data)
export const getDashboardSummary = () => api.get('/dashboard/summary').then(r => r.data)
export const getDashboardSchoolHealth = (params?: object) => api.get('/dashboard/school-health', { params }).then(r => r.data)
export const getDashboardRevenue = () => api.get('/dashboard/revenue').then(r => r.data)
// ── Schools ───────────────────────────────────────────────────────────────────
export interface School {
@@ -49,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)
@@ -66,11 +72,17 @@ export const getSmsHealth = () => api.get('/sms/health').then(r => r.data)
export const triggerSmsQueue = () => api.post('/sms/trigger-queue').then(r => r.data)
// ── Billing ───────────────────────────────────────────────────────────────────
export const getInvoices = (params?: object) => api.get('/billing/invoices', { params }).then(r => r.data)
export const createInvoice = (data: object) => api.post('/billing/invoices', data).then(r => r.data)
export const updateInvoice = (id: string, data: object) => api.put(`/billing/invoices/${id}`, data).then(r => r.data)
export const getInvoices = (params?: object) => api.get('/billing/invoices', { params }).then(r => r.data)
export const createInvoice = (data: object) => api.post('/billing/invoices', data).then(r => r.data)
export const updateInvoice = (id: string, data: object) => api.put(`/billing/invoices/${id}`, data).then(r => r.data)
export const markInvoicePaid = (id: string, data: { payment_method: string; payment_reference?: string }) =>
api.post(`/billing/invoices/${id}/mark-paid`, data).then(r => r.data)
export const sendInvoiceEmail = (id: string) => api.post(`/billing/invoices/${id}/send-email`).then(r => r.data)
export const getSubscription = (schoolId: string) => api.get(`/billing/subscriptions/${schoolId}`).then(r => r.data)
export const downloadInvoicePdf = (id: string) => {
window.open(`/api/billing/invoices/${id}/pdf`, '_blank')
}
export const triggerGenerateInvoices = () => api.post('/billing/trigger-generate-invoices').then(r => r.data)
export const getSubscription = (schoolId: string) => api.get(`/billing/subscriptions/${schoolId}`).then(r => r.data)
export const upsertSubscription = (schoolId: string, data: object) =>
api.put(`/billing/subscriptions/${schoolId}`, data).then(r => r.data)
@@ -80,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)
@@ -91,5 +104,22 @@ 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)
// ── Demo Requests ─────────────────────────────────────────────────────────────
export const getDemoRequests = (params?: object) => api.get('/demo-requests', { params }).then(r => r.data)
export const updateDemoRequest = (id: string, data: { status?: string; notes?: string }) =>
api.put(`/demo-requests/${id}`, data).then(r => r.data)
export const deleteDemoRequest = (id: string) => api.delete(`/demo-requests/${id}`)
// ── School Portal ─────────────────────────────────────────────────────────────
export const getPortalOverview = () => api.get('/portal/overview').then(r => r.data)
export const 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)

View 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>

View File

@@ -1,24 +1,52 @@
<template>
<div class="space-y-6">
<div class="flex items-center justify-between">
<!-- Header -->
<div class="flex items-center justify-between flex-wrap gap-3">
<h1 class="text-2xl font-bold text-slate-900">Billing</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 Invoice
<div class="flex items-center gap-3">
<button @click="triggerInvoices" :disabled="triggering"
class="flex items-center gap-2 px-3 py-1.5 rounded-lg border border-slate-200 bg-white text-sm font-medium text-slate-600 hover:bg-slate-50 disabled:opacity-50 transition-colors">
<RefreshCw :size="14" :class="{ 'animate-spin': triggering }" />
Generate Invoices
</button>
<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 transition-colors">
<Plus :size="16" />
New Invoice
</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; fetchInvoices()"
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 }}
</button>
</div>
<div class="flex gap-3">
<select v-model="statusFilter" class="border border-slate-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="">All Statuses</option>
<option value="draft">Draft</option>
<option value="sent">Sent</option>
<option value="paid">Paid</option>
<option value="overdue">Overdue</option>
</select>
</div>
<!-- Invoice 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">Invoices</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 found</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 found</p>
<p v-if="statusFilter" class="text-xs mt-1">Try a different filter</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">
@@ -32,40 +60,151 @@
</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-600">{{ inv.school_id }}</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"><StatusBadge :status="inv.status" /></td>
<td class="px-5 py-3 text-xs text-slate-500">{{ inv.due_date || '—' }}</td>
<tr v-for="inv in invoices" :key="inv.id"
class="hover:bg-slate-50 transition-colors"
:class="inv.status === 'overdue' ? 'bg-red-50/30' : ''">
<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">
<button @click="sendEmail(inv.id)" class="text-xs text-blue-600 hover:underline">Send Email</button>
<span class="font-medium text-slate-900 text-xs">{{ inv.school_name || inv.school_id }}</span>
</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 font-semibold text-slate-900">
PHP {{ 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"
:class="isOverdue(inv) ? 'text-red-500 font-semibold' : 'text-slate-500'">
{{ inv.due_date ? fmtDate(inv.due_date) : '—' }}
</td>
<td class="px-5 py-3">
<div class="flex items-center gap-3 flex-wrap">
<!-- PDF download -->
<button @click="downloadPdf(inv.id)"
class="flex items-center gap-1 text-xs text-slate-600 hover:text-blue-600 transition-colors"
title="Download PDF">
<Download :size="13" /> PDF
</button>
<!-- Send email -->
<button @click="sendEmail(inv.id)"
class="text-xs text-slate-600 hover:text-blue-600 transition-colors"
title="Send invoice email">
<Mail :size="13" />
</button>
<!-- Mark paid -->
<button v-if="['sent','overdue','draft'].includes(inv.status)"
@click="openMarkPaid(inv)"
class="text-xs text-emerald-600 hover:text-emerald-700 font-medium transition-colors">
Mark Paid
</button>
</div>
</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--; fetchInvoices()"
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++; fetchInvoices()"
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>
<!-- Mark Paid modal -->
<div v-if="markPaidInvoice"
class="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4"
@click.self="markPaidInvoice = null">
<div class="bg-white rounded-2xl shadow-2xl w-full max-w-sm p-6">
<h2 class="text-lg font-bold text-slate-900 mb-1">Mark Invoice Paid</h2>
<p class="text-sm text-slate-500 mb-5">
{{ markPaidInvoice.invoice_number }} · PHP {{ Number(markPaidInvoice.total_amount).toLocaleString() }}
</p>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">Payment Method</label>
<select v-model="paidForm.payment_method"
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="bank_transfer">Bank Transfer</option>
<option value="gcash">GCash</option>
<option value="cash">Cash</option>
<option value="check">Check</option>
<option value="other">Other</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">Reference / Transaction ID</label>
<input v-model="paidForm.payment_reference" 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="Optional" />
</div>
</div>
<div class="flex gap-3 mt-6">
<button @click="markPaidInvoice = null"
class="flex-1 px-4 py-2 border border-slate-200 rounded-lg text-sm text-slate-700 hover:bg-slate-50">
Cancel
</button>
<button @click="confirmMarkPaid" :disabled="markingPaid"
class="flex-1 px-4 py-2 rounded-lg bg-emerald-600 text-white text-sm font-medium hover:bg-emerald-700 disabled:opacity-50">
{{ markingPaid ? 'Saving…' : 'Confirm Paid' }}
</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch, onMounted } from 'vue'
import { Plus } from 'lucide-vue-next'
import { getInvoices, sendInvoiceEmail } from '@/lib/api'
import { Plus, RefreshCw, Download, Mail, Receipt } from 'lucide-vue-next'
import {
getInvoices, sendInvoiceEmail, markInvoicePaid,
downloadInvoicePdf, triggerGenerateInvoices,
} from '@/lib/api'
import StatusBadge from '@/components/ui/StatusBadge.vue'
import { useToast } from '@/composables/useToast'
const toast = useToast()
const invoices = ref<any[]>([])
const loading = ref(false)
const statusFilter = ref('')
const showCreate = ref(false)
const invoices = ref<any[]>([])
const total = ref(0)
const page = ref(1)
const perPage = 25
const loading = ref(false)
const triggering = ref(false)
const statusFilter = ref('')
const showCreate = ref(false)
const markPaidInvoice = ref<any>(null)
const markingPaid = ref(false)
const paidForm = ref({ payment_method: 'bank_transfer', payment_reference: '' })
const statusTabs = [
{ label: 'All', value: '' },
{ label: 'Draft', value: 'draft' },
{ label: 'Sent', value: 'sent' },
{ label: 'Overdue', value: 'overdue' },
{ label: 'Paid', value: 'paid' },
]
async function fetchInvoices() {
loading.value = true
try { const r = await getInvoices({ status: statusFilter.value || undefined }); invoices.value = r.items }
finally { loading.value = false }
try {
const r = await getInvoices({
status: statusFilter.value || undefined,
page: page.value,
per_page: perPage,
})
invoices.value = r.items
total.value = r.total
} finally { loading.value = false }
}
async function sendEmail(id: string) {
@@ -73,6 +212,48 @@ async function sendEmail(id: string) {
catch { toast.error('Failed to send email') }
}
watch(statusFilter, fetchInvoices)
function downloadPdf(id: string) {
downloadInvoicePdf(id)
}
async function triggerInvoices() {
triggering.value = true
try {
await triggerGenerateInvoices()
toast.success('Invoice generation task queued')
setTimeout(fetchInvoices, 3000)
} catch (e: any) {
toast.error(e?.response?.data?.detail ?? 'Failed to trigger')
} finally { triggering.value = false }
}
function openMarkPaid(inv: any) {
markPaidInvoice.value = inv
paidForm.value = { payment_method: 'bank_transfer', payment_reference: '' }
}
async function confirmMarkPaid() {
if (!markPaidInvoice.value) return
markingPaid.value = true
try {
await markInvoicePaid(markPaidInvoice.value.id, paidForm.value)
toast.success(`Invoice ${markPaidInvoice.value.invoice_number} marked as paid`)
markPaidInvoice.value = null
fetchInvoices()
} catch (e: any) {
toast.error(e?.response?.data?.detail ?? 'Failed to mark as paid')
} finally { markingPaid.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(fetchInvoices)
</script>

View File

@@ -1,57 +1,395 @@
<template>
<div class="space-y-6">
<div>
<h1 class="text-2xl font-bold text-slate-900">Dashboard</h1>
<p class="text-sm text-slate-500 mt-0.5">{{ today }}</p>
<!-- Header + refresh -->
<div class="flex items-center justify-between flex-wrap gap-3">
<div>
<h1 class="text-2xl font-bold text-slate-900">Dashboard</h1>
<p class="text-sm text-slate-500 mt-0.5">{{ today }}</p>
</div>
<div class="flex items-center gap-3">
<span v-if="lastUpdated" class="text-xs text-slate-400">Updated {{ lastUpdated }}</span>
<button @click="refresh" :disabled="loading"
class="flex items-center gap-2 px-3 py-1.5 rounded-lg border border-slate-200 bg-white text-sm font-medium text-slate-600 hover:bg-slate-50 disabled:opacity-50 transition-colors">
<RefreshCw :size="14" :class="{ 'animate-spin': loading }" />
Refresh
</button>
</div>
</div>
<!-- KPI Cards -->
<div v-if="loading" class="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-5 gap-4 animate-pulse">
<!-- KPI Row -->
<div v-if="loading && !summary" class="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-5 gap-4 animate-pulse">
<div v-for="i in 5" :key="i" class="bg-white rounded-xl p-5 h-24" style="box-shadow:0 2px 8px #0000000A"></div>
</div>
<div v-else class="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-5 gap-4">
<KpiCard label="Total Schools" :value="summary?.schools?.total ?? 0" icon="Building2" color="blue" />
<KpiCard label="Active Schools" :value="summary?.schools?.active ?? 0" icon="CheckCircle" color="green" />
<KpiCard label="Expiring Licenses" :value="summary?.licenses?.expiring_soon ?? 0" icon="KeyRound" color="amber" />
<KpiCard label="Open Tickets" :value="summary?.tickets?.open ?? 0" icon="Ticket" color="red" />
<KpiCard label="SMS Today" :value="summary?.sms?.sent_today ?? 0" icon="MessageSquare" color="purple" />
<KpiCard label="Total Schools" :value="summary?.schools?.total ?? 0" icon="Building2" color="blue" />
<KpiCard label="Active Schools" :value="summary?.schools?.active ?? 0" icon="CheckCircle" color="green" />
<KpiCard label="Expiring (30d)" :value="summary?.licenses?.expiring_soon ?? 0" icon="KeyRound" color="amber" />
<KpiCard label="Open Tickets" :value="summary?.tickets?.open ?? 0" icon="Ticket" color="red" />
<KpiCard label="SMS Today" :value="summary?.sms?.sent_today ?? 0" icon="MessageSquare" color="purple" />
</div>
<!-- Secondary row -->
<!-- Row 2: SMS chart + Revenue snapshot -->
<div class="grid grid-cols-1 xl:grid-cols-3 gap-6">
<!-- Pending invoices -->
<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-1">Billing</h2>
<p class="text-3xl font-bold text-slate-900">{{ summary?.invoices?.pending ?? 0 }}</p>
<p class="text-sm text-slate-500 mt-1">Pending invoices</p>
<!-- SMS Volume chart (30d) -->
<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">SMS Volume Last 30 Days</h2>
<div v-if="smsLoading" class="h-40 animate-pulse bg-slate-50 rounded-xl"></div>
<div v-else-if="chart.length" class="relative h-40">
<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-40 flex items-center justify-center text-sm text-slate-400">
No SMS activity in the last 30 days
</div>
</div>
<!-- SMS Queue -->
<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-1">SMS Queue</h2>
<p class="text-3xl font-bold text-slate-900">{{ summary?.sms?.pending ?? 0 }}</p>
<p class="text-sm text-slate-500 mt-1">Jobs awaiting dispatch</p>
</div>
<!-- Suspended schools -->
<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-1">Suspended</h2>
<p class="text-3xl font-bold text-red-500">{{ summary?.schools?.suspended ?? 0 }}</p>
<p class="text-sm text-slate-500 mt-1">Schools suspended</p>
<!-- Revenue 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">Revenue</h2>
<div v-if="revenueLoading" class="space-y-3 animate-pulse">
<div v-for="i in 4" :key="i" class="h-10 bg-slate-50 rounded-lg"></div>
</div>
<template v-else>
<div class="flex items-center justify-between">
<div>
<p class="text-xs text-slate-500 font-semibold uppercase tracking-wide">MRR</p>
<p class="text-2xl font-bold text-slate-900 mt-0.5">{{ fmt(revenue?.mrr ?? 0) }}</p>
</div>
<div class="w-10 h-10 rounded-xl bg-blue-50 flex items-center justify-center">
<TrendingUp :size="18" class="text-blue-600" />
</div>
</div>
<div class="h-px bg-slate-100"></div>
<div class="grid grid-cols-2 gap-3">
<div class="bg-slate-50 rounded-xl p-3">
<p class="text-xs text-slate-500 font-semibold">Paid This Month</p>
<p class="text-lg font-bold text-emerald-600 mt-0.5">{{ fmt(revenue?.paid_this_month ?? 0) }}</p>
</div>
<div class="bg-slate-50 rounded-xl p-3">
<p class="text-xs text-slate-500 font-semibold">Outstanding</p>
<p class="text-lg font-bold text-amber-600 mt-0.5">
{{ revenue?.outstanding_count ?? 0 }}
<span class="text-xs font-normal text-slate-400">inv.</span>
</p>
</div>
<div class="col-span-2 rounded-xl p-3" :class="(revenue?.overdue ?? 0) > 0 ? 'bg-red-50' : 'bg-slate-50'">
<p class="text-xs font-semibold" :class="(revenue?.overdue ?? 0) > 0 ? 'text-red-500' : 'text-slate-500'">
Overdue
</p>
<p class="text-lg font-bold mt-0.5" :class="(revenue?.overdue ?? 0) > 0 ? 'text-red-600' : 'text-slate-400'">
{{ fmt(revenue?.overdue ?? 0) }}
<span class="text-xs font-normal ml-1">({{ revenue?.overdue_count ?? 0 }} inv.)</span>
</p>
</div>
</div>
</template>
</div>
</div>
<!-- Row 3: School health table + Quick actions -->
<div class="grid grid-cols-1 xl:grid-cols-3 gap-6">
<!-- School health table -->
<div class="xl:col-span-2 bg-white rounded-xl overflow-hidden" style="box-shadow:0 2px 8px #0000000A">
<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">School Health</h2>
<input v-model="healthSearch" type="text" placeholder="Search schools…"
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" />
<select v-model="healthStatus"
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="active">Active</option>
<option value="suspended">Suspended</option>
<option value="expired">Expired</option>
<option value="pending">Pending</option>
</select>
</div>
<div v-if="healthLoading && !healthItems.length" class="p-6 space-y-2 animate-pulse">
<div v-for="i in 5" :key="i" class="h-10 bg-slate-50 rounded-lg"></div>
</div>
<div v-else-if="!healthItems.length"
class="flex flex-col items-center justify-center py-12 text-slate-400">
<Building2 :size="32" class="mb-2 opacity-30" />
<p class="text-sm font-medium">No schools 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-4 py-3 cursor-pointer hover:text-slate-700 select-none" @click="sort('name')">
School
<span class="ml-0.5" :class="sortBy === 'name' ? 'text-blue-500' : 'opacity-30'">
{{ sortBy === 'name' ? (sortDir === 'asc' ? '↑' : '↓') : '↕' }}
</span>
</th>
<th class="px-4 py-3">Status</th>
<th class="px-4 py-3 cursor-pointer hover:text-slate-700 select-none" @click="sort('sms_credits')">
Credits
<span class="ml-0.5" :class="sortBy === 'sms_credits' ? 'text-blue-500' : 'opacity-30'">
{{ sortBy === 'sms_credits' ? (sortDir === 'asc' ? '↑' : '↓') : '↕' }}
</span>
</th>
<th class="px-4 py-3">License</th>
<th class="px-4 py-3">Last Seen</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-50">
<tr v-for="s in healthItems" :key="s.id"
class="hover:bg-slate-50 transition-colors cursor-pointer"
@click="router.push(`/schools/${s.id}`)">
<td class="px-4 py-3">
<div class="font-medium text-slate-900">{{ s.name }}</div>
<div class="text-xs text-slate-400 capitalize">{{ s.tier }}</div>
</td>
<td class="px-4 py-3"><StatusBadge :status="s.status" /></td>
<td class="px-4 py-3">
<span class="font-mono text-sm"
:class="s.sms_credit_low ? 'text-red-500 font-semibold' : 'text-slate-700'">
{{ s.sms_credits.toLocaleString() }}
</span>
<span v-if="s.sms_credit_low"
class="ml-1.5 text-xs bg-red-100 text-red-600 font-semibold px-1.5 py-0.5 rounded">LOW</span>
</td>
<td class="px-4 py-3">
<template v-if="s.license_expires_at">
<span class="text-xs font-semibold"
:class="s.license_expiring_soon ? 'text-amber-600' :
(s.license_days_left != null && s.license_days_left < 0) ? 'text-red-500' : 'text-slate-600'">
{{ s.license_days_left != null && s.license_days_left >= 0
? `${s.license_days_left}d left`
: 'Expired' }}
</span>
<div class="text-xs text-slate-400">{{ fmtExpiry(s.license_expires_at) }}</div>
</template>
<span v-else class="text-xs text-slate-400">No expiry</span>
</td>
<td class="px-4 py-3 text-xs text-slate-500">
<template v-if="s.license_last_seen">{{ fmtRelative(s.license_last_seen) }}</template>
<span v-else class="text-slate-300">Never</span>
</td>
</tr>
</tbody>
</table>
<div class="px-5 py-3 border-t border-slate-100 flex items-center justify-between text-sm">
<span class="text-slate-400 text-xs">{{ healthItems.length }} school{{ healthItems.length !== 1 ? 's' : '' }}</span>
<RouterLink to="/schools" class="text-blue-600 hover:underline font-medium">
View all schools
</RouterLink>
</div>
</div>
<!-- Quick actions + mini stats -->
<div class="space-y-4">
<div class="bg-white rounded-xl p-5" style="box-shadow:0 2px 8px #0000000A">
<h2 class="text-base font-semibold text-slate-900 mb-4">Quick Actions</h2>
<div class="space-y-2">
<RouterLink to="/schools"
class="flex items-center gap-3 px-4 py-3 rounded-xl bg-blue-50 hover:bg-blue-100 text-blue-700 font-medium text-sm transition-colors">
<PlusCircle :size="16" />
Add / Manage Schools
</RouterLink>
<RouterLink to="/sms"
class="flex items-center gap-3 px-4 py-3 rounded-xl bg-purple-50 hover:bg-purple-100 text-purple-700 font-medium text-sm transition-colors">
<MessageSquare :size="16" />
SMS Gateway
</RouterLink>
<RouterLink to="/billing"
class="flex items-center gap-3 px-4 py-3 rounded-xl bg-amber-50 hover:bg-amber-100 text-amber-700 font-medium text-sm transition-colors">
<Receipt :size="16" />
Billing &amp; Invoices
</RouterLink>
<RouterLink to="/tickets"
class="flex items-center gap-3 px-4 py-3 rounded-xl bg-red-50 hover:bg-red-100 text-red-700 font-medium text-sm transition-colors"
:class="{ 'ring-2 ring-red-300': (summary?.tickets?.open ?? 0) > 0 }">
<Ticket :size="16" />
Support Tickets
<span v-if="(summary?.tickets?.open ?? 0) > 0"
class="ml-auto bg-red-500 text-white text-xs font-bold rounded-full px-2 py-0.5">
{{ summary.tickets.open }}
</span>
</RouterLink>
</div>
</div>
<!-- Mini stats -->
<div class="grid grid-cols-2 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">SMS Queue</p>
<p class="text-2xl font-bold text-slate-900 mt-1">{{ summary?.sms?.pending ?? 0 }}</p>
<p class="text-xs text-slate-400 mt-0.5">Pending jobs</p>
</div>
<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">Suspended</p>
<p class="text-2xl font-bold mt-1"
:class="(summary?.schools?.suspended ?? 0) > 0 ? 'text-red-500' : 'text-slate-900'">
{{ summary?.schools?.suspended ?? 0 }}
</p>
<p class="text-xs text-slate-400 mt-0.5">Schools</p>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { getDashboardSummary } from '@/lib/api'
import { ref, computed, watch, onMounted } from 'vue'
import { useRouter, RouterLink } from 'vue-router'
import { RefreshCw, Building2, TrendingUp, PlusCircle, MessageSquare, Receipt, Ticket } from 'lucide-vue-next'
import {
getDashboardSummary, getDashboardSchoolHealth,
getDashboardRevenue, getSmsStats,
} from '@/lib/api'
import KpiCard from '@/components/ui/KpiCard.vue'
import StatusBadge from '@/components/ui/StatusBadge.vue'
const summary = ref<any>(null)
const loading = ref(true)
const today = new Date().toLocaleDateString('en-PH', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })
const router = useRouter()
onMounted(async () => {
try { summary.value = await getDashboardSummary() }
finally { loading.value = false }
// ── State ─────────────────────────────────────────────────────────────────────
const summary = ref<any>(null)
const revenue = ref<any>(null)
const chart = ref<any[]>([])
const healthItems = ref<any[]>([])
const healthSearch = ref('')
const healthStatus = ref('')
const sortBy = ref('name')
const sortDir = ref<'asc' | 'desc'>('asc')
const loading = ref(false)
const revenueLoading = ref(false)
const smsLoading = ref(false)
const healthLoading = ref(false)
const lastUpdated = ref('')
const today = new Date().toLocaleDateString('en-PH', {
weekday: 'long', year: 'numeric', month: 'long', day: 'numeric',
})
// ── Sort ──────────────────────────────────────────────────────────────────────
function sort(field: string) {
if (sortBy.value === field) {
sortDir.value = sortDir.value === 'asc' ? 'desc' : 'asc'
} else {
sortBy.value = field
sortDir.value = 'asc'
}
}
// ── Loaders ───────────────────────────────────────────────────────────────────
async function loadSummary() {
try { summary.value = await getDashboardSummary() } catch { /* ignore */ }
}
async function loadRevenue() {
revenueLoading.value = true
try { revenue.value = await getDashboardRevenue() } catch { /* ignore */ }
finally { revenueLoading.value = false }
}
async function loadSmsChart() {
smsLoading.value = true
try {
const data = await getSmsStats({ days: 30 })
chart.value = data.chart ?? []
} catch { /* ignore */ }
finally { smsLoading.value = false }
}
async function loadHealth() {
healthLoading.value = true
try {
const res = await getDashboardSchoolHealth({
sort_by: sortBy.value,
sort_dir: sortDir.value,
status: healthStatus.value || undefined,
search: healthSearch.value || undefined,
})
healthItems.value = res.items
} catch { /* ignore */ }
finally { healthLoading.value = false }
}
// Debounced re-fetch when filters/sort change
let debounce: ReturnType<typeof setTimeout> | null = null
watch([healthSearch, healthStatus, sortBy, sortDir], () => {
if (debounce) clearTimeout(debounce)
debounce = setTimeout(loadHealth, 250)
})
async function refresh() {
loading.value = true
await Promise.all([loadSummary(), loadRevenue(), loadSmsChart(), loadHealth()])
loading.value = false
lastUpdated.value = new Date().toLocaleTimeString('en-PH', { hour: '2-digit', minute: '2-digit' })
}
onMounted(refresh)
// ── 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' })
}
// ── Format helpers ────────────────────────────────────────────────────────────
function fmt(val: number): string {
return val.toLocaleString('en-PH', { minimumFractionDigits: 0, maximumFractionDigits: 0 })
}
function fmtExpiry(iso: string): string {
return new Date(iso).toLocaleDateString('en-PH', { year: 'numeric', month: 'short', day: 'numeric' })
}
function fmtRelative(iso: string): string {
const diffMs = Date.now() - new Date(iso).getTime()
const mins = Math.floor(diffMs / 60_000)
if (mins < 2) return 'Just now'
if (mins < 60) return `${mins}m ago`
const hrs = Math.floor(mins / 60)
if (hrs < 24) return `${hrs}h ago`
return `${Math.floor(hrs / 24)}d ago`
}
</script>

View File

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

View File

@@ -0,0 +1,220 @@
<template>
<div class="space-y-6">
<!-- Header -->
<div class="flex items-center justify-between flex-wrap gap-3">
<div>
<h1 class="text-2xl font-bold text-slate-900">Email Logs</h1>
<p class="text-sm text-slate-500 mt-0.5">All automated email delivery history</p>
</div>
<button @click="showTestModal = 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 transition-colors">
<Send :size="15" />
Send Test Email
</button>
</div>
<!-- Filters -->
<div class="flex flex-wrap gap-3">
<select v-model="typeFilter"
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 Types</option>
<option value="invoice">Invoice</option>
<option value="low_credit">Low Credit</option>
<option value="license_expiry">License Expiry</option>
<option value="overdue_warning">Overdue Warning</option>
<option value="suspension">Suspension</option>
<option value="monthly_report">Monthly Report</option>
<option value="welcome">Welcome</option>
<option value="test">Test</option>
<option value="other">Other</option>
</select>
<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>
</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">Delivery Log</h2>
<span class="text-xs text-slate-400">{{ total }} record{{ 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="logs.length === 0"
class="flex flex-col items-center justify-center py-14 text-slate-400">
<Mail :size="36" class="mb-3 opacity-30" />
<p class="font-medium text-sm">No email records found</p>
<p v-if="typeFilter || statusFilter" class="text-xs mt-1">Try a different filter</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">To</th>
<th class="px-5 py-3">Subject</th>
<th class="px-5 py-3">Type</th>
<th class="px-5 py-3">Status</th>
<th class="px-5 py-3">Sent At</th>
<th class="px-5 py-3">Error</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-50">
<tr v-for="log in logs" :key="log.id"
class="hover:bg-slate-50 transition-colors"
:class="log.status === 'failed' ? 'bg-red-50/40' : ''">
<td class="px-5 py-3 text-xs font-mono text-slate-700">{{ log.to_email }}</td>
<td class="px-5 py-3 text-xs text-slate-700 max-w-xs">
<span class="block truncate" :title="log.subject">{{ log.subject }}</span>
</td>
<td class="px-5 py-3">
<span class="inline-block text-xs font-semibold px-2 py-0.5 rounded-full"
:class="typeClass(log.email_type)">
{{ log.email_type.replace('_', ' ') }}
</span>
</td>
<td class="px-5 py-3">
<span class="inline-flex items-center gap-1 text-xs font-semibold"
:class="log.status === 'sent' ? 'text-emerald-600' : 'text-red-500'">
<CheckCircle v-if="log.status === 'sent'" :size="12" />
<XCircle v-else :size="12" />
{{ log.status }}
</span>
</td>
<td class="px-5 py-3 text-xs text-slate-500">
{{ log.sent_at ? new Date(log.sent_at).toLocaleString('en-PH') : '—' }}
</td>
<td class="px-5 py-3 text-xs text-red-400 max-w-xs">
<span v-if="log.error_message" class="block truncate" :title="log.error_message">
{{ log.error_message }}
</span>
<span v-else class="text-slate-300"></span>
</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--; 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>
<!-- Test email modal -->
<div v-if="showTestModal"
class="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4"
@click.self="showTestModal = false">
<div class="bg-white rounded-2xl shadow-2xl w-full max-w-sm p-6">
<h2 class="text-lg font-bold text-slate-900 mb-1">Send Test Email</h2>
<p class="text-sm text-slate-500 mb-5">Verify your SMTP configuration is working correctly.</p>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">Recipient Email</label>
<input v-model="testEmail" type="email"
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="you@example.com" />
</div>
<p v-if="testResult" class="text-sm" :class="testResult.includes('Failed') ? 'text-red-500' : 'text-emerald-600'">
{{ testResult }}
</p>
</div>
<div class="flex gap-3 mt-6">
<button @click="showTestModal = false; testResult = ''"
class="flex-1 px-4 py-2 border border-slate-200 rounded-lg text-sm text-slate-700 hover:bg-slate-50">
Close
</button>
<button @click="doSendTest" :disabled="!testEmail || sendingTest"
class="flex-1 px-4 py-2 rounded-lg bg-blue-600 text-white text-sm font-medium hover:bg-blue-700 disabled:opacity-50">
{{ sendingTest ? 'Sending…' : 'Send Test' }}
</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch, onMounted } from 'vue'
import { Send, Mail, CheckCircle, XCircle } from 'lucide-vue-next'
import { getEmailLogs, sendTestEmail } from '@/lib/api'
import { useToast } from '@/composables/useToast'
const toast = useToast()
const logs = ref<any[]>([])
const total = ref(0)
const page = ref(1)
const perPage = 50
const loading = ref(false)
const typeFilter = ref('')
const statusFilter = ref('')
const showTestModal = ref(false)
const testEmail = ref('')
const sendingTest = ref(false)
const testResult = ref('')
async function fetchLogs() {
loading.value = true
try {
const r = await getEmailLogs({
page: page.value,
per_page: perPage,
email_type: typeFilter.value || undefined,
status: statusFilter.value || undefined,
})
logs.value = r.items
total.value = r.total
} finally { loading.value = false }
}
watch([typeFilter, statusFilter], () => { page.value = 1; fetchLogs() })
async function doSendTest() {
sendingTest.value = true
testResult.value = ''
try {
const res = await sendTestEmail(testEmail.value)
testResult.value = res.message
if (res.message.includes('successfully')) {
toast.success('Test email sent')
setTimeout(fetchLogs, 1500)
}
} catch (e: any) {
testResult.value = e?.response?.data?.detail ?? 'Failed to send'
toast.error(testResult.value)
} finally { sendingTest.value = false }
}
function typeClass(type: string): string {
const map: Record<string, string> = {
invoice: 'bg-blue-100 text-blue-700',
low_credit: 'bg-amber-100 text-amber-700',
license_expiry: 'bg-purple-100 text-purple-700',
overdue_warning: 'bg-red-100 text-red-700',
suspension: 'bg-red-100 text-red-800',
monthly_report: 'bg-emerald-100 text-emerald-700',
welcome: 'bg-green-100 text-green-700',
test: 'bg-slate-100 text-slate-600',
other: 'bg-slate-100 text-slate-500',
}
return map[type] ?? 'bg-slate-100 text-slate-500'
}
onMounted(fetchLogs)
</script>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 = ''

View File

@@ -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>

View File

@@ -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 }

View File

@@ -71,6 +71,24 @@ const router = createRouter({
component: () => import('@/pages/AnnouncementsPage.vue'),
meta: { requiresAuth: true, layout: 'app', role: 'super_admin' },
},
{
path: '/email-logs',
name: 'email-logs',
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' },
},
{
path: '/demo-requests',
name: 'demo-requests',
component: () => import('@/pages/DemoRequestsPage.vue'),
meta: { requiresAuth: true, layout: 'app', role: 'super_admin' },
},
// School Portal routes
{
path: '/portal',