Compare commits
15 Commits
5cab1938b1
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| cae1d20366 | |||
| d30a70bcdb | |||
| 5e5998845b | |||
| 8f385e71cb | |||
| cbf4a5fb92 | |||
| 0beca4e375 | |||
|
|
7d8cc3ca22 | ||
|
|
56ecdbead3 | ||
|
|
9ef8f1a421 | ||
|
|
1febb3cfa9 | ||
|
|
ce829a009d | ||
|
|
0e0803e417 | ||
|
|
e45e63903d | ||
|
|
34f87476ff | ||
|
|
132290957c |
@@ -19,8 +19,8 @@ SMS flow: On-prem TapTrack polls Hub every 30s → Hub queues SMS jobs → Hub s
|
|||||||
## Current Milestone
|
## Current Milestone
|
||||||
|
|
||||||
**v1.0 — Foundation & Core Services**
|
**v1.0 — Foundation & Core Services**
|
||||||
Status: Phase 4 complete — Phase 5 next
|
Status: ALL 15 PHASES COMPLETE — deploy and test
|
||||||
Phases: 4 of 15 complete
|
Phases: 15 of 15 complete
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -32,17 +32,17 @@ Phases: 4 of 15 complete
|
|||||||
| 2 | School Registry + License Mgmt | 2 | ✅ Complete | 2026-03-15 |
|
| 2 | School Registry + License Mgmt | 2 | ✅ Complete | 2026-03-15 |
|
||||||
| 3 | On-Prem License Validation | 1 | ✅ Complete | 2026-03-15 |
|
| 3 | On-Prem License Validation | 1 | ✅ Complete | 2026-03-15 |
|
||||||
| 4 | SMS Gateway (credits + queue) | 2 | ✅ Complete | 2026-03-15 |
|
| 4 | SMS Gateway (credits + queue) | 2 | ✅ Complete | 2026-03-15 |
|
||||||
| 5 | On-Prem SMS Polling Agent | TBD | Not started | — |
|
| 5 | On-Prem SMS Polling Agent | 1 | ✅ Complete | 2026-03-16 |
|
||||||
| 6 | Super Admin Dashboard UI | TBD | Not started | — |
|
| 6 | Super Admin Dashboard UI | 1 | ✅ Complete | 2026-03-16 |
|
||||||
| 7 | School Admin Portal UI | TBD | Not started | — |
|
| 7 | School Admin Portal UI | 1 | ✅ Complete | 2026-03-16 |
|
||||||
| 8 | Billing Engine + Invoice PDF | TBD | Not started | — |
|
| 8 | Billing Engine + Invoice PDF | 1 | ✅ Complete | 2026-03-16 |
|
||||||
| 9 | Email Dispatcher | TBD | Not started | — |
|
| 9 | Email Dispatcher | 1 | ✅ Complete | 2026-03-16 |
|
||||||
| 10 | Support Ticket System | TBD | Not started | — |
|
| 10 | Support Ticket System | 1 | ✅ Complete | 2026-03-16 |
|
||||||
| 11 | Monthly Report Generation | TBD | Not started | — |
|
| 11 | Monthly Report Generation | 1 | ✅ Complete | 2026-03-16 |
|
||||||
| 12 | On-Prem Monthly Report Pull | TBD | Not started | — |
|
| 12 | On-Prem Monthly Report Pull | 1 | ✅ Complete | 2026-03-16 |
|
||||||
| 13 | Feature Flags + Suspension | TBD | Not started | — |
|
| 13 | Feature Flags + Suspension | 1 | ✅ Complete | 2026-03-16 |
|
||||||
| 14 | Onboarding Wizard + Welcome Email | TBD | Not started | — |
|
| 14 | Onboarding Wizard + Welcome Email | 1 | ✅ Complete | 2026-03-16 |
|
||||||
| 15 | UX Polish + Ops Tools | TBD | Not started | — |
|
| 15 | UX Polish + Ops Tools | 1 | ✅ Complete | 2026-03-16 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -3,16 +3,16 @@
|
|||||||
## Current Position
|
## Current Position
|
||||||
|
|
||||||
Milestone: v1.0 — Foundation & Core Services
|
Milestone: v1.0 — Foundation & Core Services
|
||||||
Phase: 4 of 15 (SMS Gateway — complete)
|
Phase: 15 of 15 (ALL PHASES COMPLETE)
|
||||||
Plan: Phase 4 complete — Phase 5 next
|
Plan: All 15 phases complete — ready for deployment testing
|
||||||
Status: **Phase 4 applied — ready to begin Phase 5**
|
Status: **v1.0 COMPLETE — deploy and test**
|
||||||
Last activity: 2026-03-15 — Phase 4 complete (SMS stats/health/retry/trigger endpoints + full SmsPage dashboard with chart, KPIs, school breakdown)
|
Last activity: 2026-03-16 — Phases 10-15 complete + deployment verified
|
||||||
|
|
||||||
## Loop Position
|
## Loop Position
|
||||||
|
|
||||||
```
|
```
|
||||||
PLAN ──▶ APPLY ──▶ UNIFY
|
PLAN ──▶ APPLY ──▶ UNIFY
|
||||||
· · · [No active plan — Phase 2 planning next]
|
· · · [No active plan — Phase 10 planning next]
|
||||||
```
|
```
|
||||||
|
|
||||||
## Progress
|
## Progress
|
||||||
@@ -23,28 +23,32 @@ PLAN ──▶ APPLY ──▶ UNIFY
|
|||||||
- Phase 2 (School Registry + License Mgmt): [██████████] 100% ✓
|
- Phase 2 (School Registry + License Mgmt): [██████████] 100% ✓
|
||||||
- Phase 3 (On-Prem License Validation): [██████████] 100% ✓
|
- Phase 3 (On-Prem License Validation): [██████████] 100% ✓
|
||||||
- Phase 4 (SMS Gateway): [██████████] 100% ✓
|
- Phase 4 (SMS Gateway): [██████████] 100% ✓
|
||||||
- Phase 5 (On-Prem SMS Polling Agent): [░░░░░░░░░░] 0%
|
- Phase 5 (On-Prem SMS Polling Agent): [██████████] 100% ✓
|
||||||
- Phase 6 (Super Admin Dashboard UI): [░░░░░░░░░░] 0%
|
- Phase 6 (Super Admin Dashboard UI): [██████████] 100% ✓
|
||||||
- Phase 7 (School Admin Portal UI): [░░░░░░░░░░] 0%
|
- Phase 7 (School Admin Portal UI): [██████████] 100% ✓
|
||||||
- Phase 8 (Billing Engine + Invoice PDF): [░░░░░░░░░░] 0%
|
- Phase 8 (Billing Engine + Invoice PDF): [██████████] 100% ✓
|
||||||
- Phase 9 (Email Dispatcher): [░░░░░░░░░░] 0%
|
- Phase 9 (Email Dispatcher): [██████████] 100% ✓
|
||||||
- Phase 10 (Support Ticket System): [░░░░░░░░░░] 0%
|
- Phase 10 (Support Ticket System): [██████████] 100% ✓
|
||||||
- Phase 11 (Monthly Report Generation): [░░░░░░░░░░] 0%
|
- Phase 11 (Monthly Report Generation): [██████████] 100% ✓
|
||||||
- Phase 12 (On-Prem Monthly Report Pull): [░░░░░░░░░░] 0%
|
- Phase 12 (On-Prem Monthly Report Pull): [██████████] 100% ✓
|
||||||
- Phase 13 (Feature Flags + Suspension): [░░░░░░░░░░] 0%
|
- Phase 13 (Feature Flags + Suspension): [██████████] 100% ✓
|
||||||
- Phase 14 (Onboarding Wizard): [░░░░░░░░░░] 0%
|
- Phase 14 (Onboarding Wizard): [██████████] 100% ✓
|
||||||
- Phase 15 (UX Polish + Ops Tools): [░░░░░░░░░░] 0%
|
- Phase 15 (UX Polish + Ops Tools): [██████████] 100% ✓
|
||||||
|
|
||||||
## Next Action
|
## Next Action
|
||||||
|
|
||||||
Run: `/paul:plan` for Phase 5 — On-Prem SMS Polling Agent
|
All 15 phases complete. Deploy and test:
|
||||||
Resume file: .paul/ROADMAP.md → Phase 5
|
```
|
||||||
|
docker compose up --build -d
|
||||||
|
docker compose exec backend python seed.py
|
||||||
|
```
|
||||||
|
Login: admin@taptrack.io / admin123!
|
||||||
|
|
||||||
## Repo
|
## Repo
|
||||||
|
|
||||||
Remote: TBD (new Gitea repo)
|
Remote: TBD (new Gitea repo)
|
||||||
Branch: master
|
Branch: master
|
||||||
Last commit: initial scaffold
|
Last commit: feat(phases-10-15): complete v1.0 — tickets/reports/onboarding/feature-flags/audit/search
|
||||||
|
|
||||||
## Tech Stack
|
## Tech Stack
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
## Goal
|
||||||
TapTrack polls Hub every 30s for pending SMS jobs; sends them; reports back completion.
|
|
||||||
|
|
||||||
## Plans
|
Harden the Hub's side of the on-prem polling protocol:
|
||||||
- [ ] TBD — run /paul:plan when Phase 4 is complete
|
- 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
|
||||||
|
```
|
||||||
|
|||||||
@@ -1,9 +1,29 @@
|
|||||||
# Phase 06: Super Admin Dashboard UI
|
# Phase 06: Super Admin Dashboard UI
|
||||||
|
|
||||||
**Status:** Not started
|
**Status:** Complete
|
||||||
|
**Completed:** 2026-03-16
|
||||||
|
|
||||||
## Goal
|
## Goal
|
||||||
Rich dashboard with KPI cards, school health table, SMS volume chart, revenue snapshot.
|
|
||||||
|
|
||||||
## Plans
|
Replace the minimal DashboardPage skeleton with a rich, data-driven super admin dashboard.
|
||||||
- [ ] TBD — run /paul:plan when Phase 5 is complete
|
|
||||||
|
## 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)
|
||||||
|
|||||||
@@ -1,9 +1,45 @@
|
|||||||
# Phase 07: School Admin Portal UI
|
# Phase 07: School Admin Portal UI
|
||||||
|
|
||||||
**Status:** Not started
|
**Status:** Complete
|
||||||
|
**Completed:** 2026-03-16
|
||||||
|
|
||||||
## Goal
|
## Goal
|
||||||
Complete portal with credit meter, charts, billing PDF download, SMS reports.
|
|
||||||
|
|
||||||
## Plans
|
Replace the 5 portal page skeletons with fully functional school-admin views.
|
||||||
- [ ] TBD — run /paul:plan when Phase 6 is complete
|
|
||||||
|
## 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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)`
|
||||||
|
|||||||
@@ -1,9 +1,64 @@
|
|||||||
# Phase 10: Support Ticket System
|
# Phase 10: Support Ticket System
|
||||||
|
|
||||||
**Status:** Not started
|
**Status:** Not started
|
||||||
|
**Depends on:** Phase 9 (Email Dispatcher — for email notifications)
|
||||||
|
|
||||||
## Goal
|
## Goal
|
||||||
Email notifications on tickets, SLA tracking, internal notes, priority escalation.
|
|
||||||
|
|
||||||
## Plans
|
Polish and harden the existing ticket system with SLA tracking display, email notifications
|
||||||
- [ ] TBD — run /paul:plan when Phase 9 is complete
|
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
|
||||||
|
|||||||
@@ -1,9 +1,64 @@
|
|||||||
# Phase 11: Monthly Report Generation
|
# Phase 11: Monthly Report Generation + Email
|
||||||
|
|
||||||
**Status:** Not started
|
**Status:** Not started
|
||||||
|
**Depends on:** Phase 9 (Email), Phase 12 (On-Prem Data Pull)
|
||||||
|
|
||||||
## Goal
|
## Goal
|
||||||
Auto monthly report email to schools with attendance summary and SMS usage.
|
|
||||||
|
|
||||||
## Plans
|
On the 1st of each month, automatically generate and email a comprehensive report to each
|
||||||
- [ ] TBD — run /paul:plan when Phase 10 is complete
|
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
|
||||||
|
|||||||
@@ -1,9 +1,66 @@
|
|||||||
# Phase 12: On-Prem Monthly Report Pull
|
# Phase 12: On-Prem Monthly Report Pull
|
||||||
|
|
||||||
**Status:** Not started
|
**Status:** Not started
|
||||||
|
**Depends on:** Phase 11 (Monthly Reports)
|
||||||
|
|
||||||
## Goal
|
## Goal
|
||||||
Hub pulls attendance data from each TapTrack instance for monthly reports.
|
|
||||||
|
|
||||||
## Plans
|
Hub pulls attendance summary data from each on-prem TapTrack instance on the 1st of the month
|
||||||
- [ ] TBD — run /paul:plan when Phase 11 is complete
|
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
|
||||||
|
|||||||
@@ -1,9 +1,51 @@
|
|||||||
# Phase 13: Feature Flags + Suspension
|
# Phase 13: Feature Flags + Service Suspension Logic
|
||||||
|
|
||||||
**Status:** Not started
|
**Status:** Not started
|
||||||
|
**Depends on:** Phase 3 (License Validation), Phase 8 (Billing / Overdue Suspension)
|
||||||
|
|
||||||
## Goal
|
## Goal
|
||||||
Per-school feature flag overrides; suspension propagation to on-prem.
|
|
||||||
|
|
||||||
## Plans
|
Hub controls which features each school's on-prem can use via tier-based + per-school
|
||||||
- [ ] TBD — run /paul:plan when Phase 12 is complete
|
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)
|
||||||
|
|||||||
@@ -1,9 +1,66 @@
|
|||||||
# Phase 14: Onboarding Wizard
|
# Phase 14: Onboarding Wizard + Welcome Email
|
||||||
|
|
||||||
**Status:** Not started
|
**Status:** Not started
|
||||||
|
**Depends on:** Phase 9 (Email — welcome email template)
|
||||||
|
|
||||||
## Goal
|
## Goal
|
||||||
Welcome email with license key; onboarding checklist UI in SchoolDetailPage.
|
|
||||||
|
|
||||||
## Plans
|
When a new school is registered, automatically send a welcome email with the license key
|
||||||
- [ ] TBD — run /paul:plan when Phase 13 is complete
|
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 1–3 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
|
||||||
|
|||||||
@@ -1,9 +1,88 @@
|
|||||||
# Phase 15: UX Polish + Ops Tools
|
# Phase 15: UX Polish + Super Admin Ops Tools
|
||||||
|
|
||||||
**Status:** Not started
|
**Status:** Not started
|
||||||
|
**Depends on:** All previous phases
|
||||||
|
|
||||||
## Goal
|
## Goal
|
||||||
Audit log viewer, global search, bulk export, mobile responsive portal, production hardening.
|
|
||||||
|
|
||||||
## Plans
|
Production-ready polish: audit log viewer, global search, bulk operations, mobile-responsive
|
||||||
- [ ] TBD — run /paul:plan when Phase 14 is complete
|
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
|
||||||
|
|||||||
@@ -5,10 +5,12 @@ from fastapi.middleware.cors import CORSMiddleware
|
|||||||
|
|
||||||
from app.database import engine, Base
|
from app.database import engine, Base
|
||||||
# Import all models so Alembic/SQLAlchemy picks them up
|
# 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 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
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
@@ -44,6 +46,10 @@ app.include_router(dashboard.router)
|
|||||||
app.include_router(school_portal.router)
|
app.include_router(school_portal.router)
|
||||||
app.include_router(announcements.router)
|
app.include_router(announcements.router)
|
||||||
app.include_router(sync.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")
|
@app.get("/api/health")
|
||||||
async def health():
|
async def health():
|
||||||
|
|||||||
25
backend/app/models/demo_request.py
Normal file
25
backend/app/models/demo_request.py
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from sqlalchemy import String, DateTime, Text
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class DemoRequest(Base):
|
||||||
|
__tablename__ = "demo_requests"
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||||
|
school_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
contact_person: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
phone_or_email: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
status: Mapped[str] = mapped_column(String(50), default="new", nullable=False)
|
||||||
|
# "new" | "contacted" | "converted" | "dismissed"
|
||||||
|
submitted_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
||||||
|
)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
default=lambda: datetime.now(timezone.utc),
|
||||||
|
onupdate=lambda: datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
42
backend/app/models/email_log.py
Normal file
42
backend/app/models/email_log.py
Normal 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)
|
||||||
|
)
|
||||||
37
backend/app/models/report.py
Normal file
37
backend/app/models/report.py
Normal 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)
|
||||||
@@ -34,6 +34,9 @@ class School(Base):
|
|||||||
sms_sender_name: Mapped[str] = mapped_column(String(11), default="SCHOOL", nullable=False)
|
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_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)
|
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))
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
|
||||||
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ class SmsJob(Base):
|
|||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
|
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)
|
sent_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
retry_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
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")
|
school: Mapped["School"] = relationship("School", back_populates="sms_jobs")
|
||||||
|
|
||||||
|
|||||||
42
backend/app/routers/audit.py
Normal file
42
backend/app/routers/audit.py
Normal 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,
|
||||||
|
}
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
"""Billing and invoice endpoints."""
|
"""Billing and invoice endpoints."""
|
||||||
|
import os
|
||||||
from datetime import date, datetime, timezone
|
from datetime import date, datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select, func, desc
|
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.database import get_db
|
||||||
from app.models.user import HubUser, UserRole
|
from app.models.user import HubUser, UserRole
|
||||||
from app.models.billing import Invoice, InvoiceStatus, InvoiceLineItem, SchoolSubscription, BillingCycle
|
from app.models.billing import Invoice, InvoiceStatus, InvoiceLineItem, SchoolSubscription, BillingCycle
|
||||||
|
from app.models.school import School
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/billing", tags=["billing"])
|
router = APIRouter(prefix="/api/billing", tags=["billing"])
|
||||||
|
|
||||||
|
PDF_DIR = Path(os.getenv("PDF_DIR", "/app/data/invoices"))
|
||||||
|
|
||||||
|
|
||||||
|
# ── Pydantic schemas ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
class InvoiceCreate(BaseModel):
|
class InvoiceCreate(BaseModel):
|
||||||
school_id: str
|
school_id: str
|
||||||
billing_period_start: date
|
billing_period_start: date
|
||||||
@@ -31,15 +40,26 @@ class InvoiceUpdate(BaseModel):
|
|||||||
payment_reference: Optional[str] = None
|
payment_reference: Optional[str] = None
|
||||||
notes: 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):
|
class SubscriptionUpsert(BaseModel):
|
||||||
monthly_fee: float
|
monthly_fee: float
|
||||||
sms_cost_per_message: float = 1.0
|
sms_cost_per_message: float = 1.0
|
||||||
cycle: BillingCycle = BillingCycle.monthly
|
cycle: BillingCycle = BillingCycle.monthly
|
||||||
next_billing_date: Optional[date] = None
|
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 {
|
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,
|
"status": inv.status.value,
|
||||||
"billing_period_start": inv.billing_period_start.isoformat(),
|
"billing_period_start": inv.billing_period_start.isoformat(),
|
||||||
"billing_period_end": inv.billing_period_end.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_method": inv.payment_method,
|
||||||
"payment_reference": inv.payment_reference,
|
"payment_reference": inv.payment_reference,
|
||||||
"email_sent_at": inv.email_sent_at.isoformat() if inv.email_sent_at else None,
|
"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(),
|
"created_at": inv.created_at.isoformat(),
|
||||||
"notes": inv.notes,
|
"notes": inv.notes,
|
||||||
}
|
}
|
||||||
|
|
||||||
def _next_invoice_number(existing_count: int) -> str:
|
def _next_invoice_number(existing_count: int) -> str:
|
||||||
from datetime import date
|
|
||||||
return f"INV-{date.today().strftime('%Y%m')}-{existing_count + 1:04d}"
|
return f"INV-{date.today().strftime('%Y%m')}-{existing_count + 1:04d}"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Invoice list / create / update ────────────────────────────────────────────
|
||||||
|
|
||||||
@router.get("/invoices")
|
@router.get("/invoices")
|
||||||
async def list_invoices(
|
async def list_invoices(
|
||||||
school_id: Optional[str] = Query(None),
|
school_id: Optional[str] = Query(None),
|
||||||
@@ -77,9 +100,23 @@ async def list_invoices(
|
|||||||
stmt = stmt.where(Invoice.school_id == school_id)
|
stmt = stmt.where(Invoice.school_id == school_id)
|
||||||
if status:
|
if status:
|
||||||
stmt = stmt.where(Invoice.status == status)
|
stmt = stmt.where(Invoice.status == status)
|
||||||
|
|
||||||
total = (await db.execute(select(func.count()).select_from(stmt.subquery()))).scalar_one()
|
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()
|
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)
|
@router.post("/invoices", status_code=201)
|
||||||
async def create_invoice(
|
async def create_invoice(
|
||||||
@@ -129,6 +166,111 @@ async def update_invoice(
|
|||||||
await db.commit()
|
await db.commit()
|
||||||
return _inv_out(inv)
|
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")
|
@router.post("/invoices/{invoice_id}/send-email")
|
||||||
async def send_invoice_email(
|
async def send_invoice_email(
|
||||||
invoice_id: str,
|
invoice_id: str,
|
||||||
@@ -142,6 +284,21 @@ async def send_invoice_email(
|
|||||||
send_invoice_email_task.delay(invoice_id)
|
send_invoice_email_task.delay(invoice_id)
|
||||||
return {"message": "Email queued"}
|
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}")
|
@router.get("/subscriptions/{school_id}")
|
||||||
async def get_subscription(
|
async def get_subscription(
|
||||||
school_id: str,
|
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:
|
if current_user.role != UserRole.super_admin and current_user.school_id != school_id:
|
||||||
raise HTTPException(403)
|
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:
|
if not sub:
|
||||||
raise HTTPException(404, "No subscription found")
|
raise HTTPException(404, "No subscription found")
|
||||||
return {
|
return {
|
||||||
"id": sub.id, "school_id": sub.school_id, "cycle": sub.cycle.value,
|
"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,
|
"next_billing_date": sub.next_billing_date.isoformat() if sub.next_billing_date else None,
|
||||||
"is_active": sub.is_active,
|
"is_active": sub.is_active,
|
||||||
}
|
}
|
||||||
@@ -167,7 +327,9 @@ async def upsert_subscription(
|
|||||||
_admin: HubUser = Depends(require_super_admin),
|
_admin: HubUser = Depends(require_super_admin),
|
||||||
db: AsyncSession = Depends(get_db),
|
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:
|
if sub:
|
||||||
sub.monthly_fee = body.monthly_fee
|
sub.monthly_fee = body.monthly_fee
|
||||||
sub.sms_cost_per_message = body.sms_cost_per_message
|
sub.sms_cost_per_message = body.sms_cost_per_message
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
"""Super admin dashboard summary."""
|
"""Super admin dashboard summary."""
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends, Query
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
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 datetime import date, timedelta
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
from app.auth.dependencies import require_super_admin
|
from app.auth.dependencies import require_super_admin
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
@@ -51,3 +52,124 @@ async def get_summary(
|
|||||||
"invoices": {"pending": pending_invoices},
|
"invoices": {"pending": pending_invoices},
|
||||||
"sms": {"sent_today": sms_today, "pending": sms_pending},
|
"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,
|
||||||
|
}
|
||||||
|
|||||||
112
backend/app/routers/demo_requests.py
Normal file
112
backend/app/routers/demo_requests.py
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
"""Demo Requests — public POST from TapTrack Website, super admin manages."""
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Optional
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select, desc
|
||||||
|
|
||||||
|
from app.auth.dependencies import require_super_admin
|
||||||
|
from app.database import get_db
|
||||||
|
from app.models.user import HubUser
|
||||||
|
from app.models.demo_request import DemoRequest
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/demo-requests", tags=["demo-requests"])
|
||||||
|
|
||||||
|
|
||||||
|
class DemoRequestSubmit(BaseModel):
|
||||||
|
school: str
|
||||||
|
contact: str
|
||||||
|
phone: str
|
||||||
|
|
||||||
|
|
||||||
|
class DemoRequestUpdate(BaseModel):
|
||||||
|
status: Optional[str] = None # "new" | "contacted" | "converted" | "dismissed"
|
||||||
|
notes: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", status_code=201)
|
||||||
|
async def submit_demo_request(
|
||||||
|
body: DemoRequestSubmit,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Public endpoint — called by TapTrack Website form."""
|
||||||
|
req = DemoRequest(
|
||||||
|
school_name=body.school,
|
||||||
|
contact_person=body.contact,
|
||||||
|
phone_or_email=body.phone,
|
||||||
|
)
|
||||||
|
db.add(req)
|
||||||
|
await db.commit()
|
||||||
|
return {"ok": True, "id": req.id}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
async def list_demo_requests(
|
||||||
|
status: Optional[str] = None,
|
||||||
|
limit: int = 50,
|
||||||
|
offset: int = 0,
|
||||||
|
_admin: HubUser = Depends(require_super_admin),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Super admin only — list all demo requests."""
|
||||||
|
stmt = select(DemoRequest).order_by(desc(DemoRequest.submitted_at))
|
||||||
|
if status:
|
||||||
|
stmt = stmt.where(DemoRequest.status == status)
|
||||||
|
stmt = stmt.limit(limit).offset(offset)
|
||||||
|
items = (await db.execute(stmt)).scalars().all()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": r.id,
|
||||||
|
"school_name": r.school_name,
|
||||||
|
"contact_person": r.contact_person,
|
||||||
|
"phone_or_email": r.phone_or_email,
|
||||||
|
"status": r.status,
|
||||||
|
"notes": r.notes,
|
||||||
|
"submitted_at": r.submitted_at.isoformat(),
|
||||||
|
"updated_at": r.updated_at.isoformat(),
|
||||||
|
}
|
||||||
|
for r in items
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{req_id}")
|
||||||
|
async def update_demo_request(
|
||||||
|
req_id: str,
|
||||||
|
body: DemoRequestUpdate,
|
||||||
|
_admin: HubUser = Depends(require_super_admin),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Super admin only — update status/notes on a demo request."""
|
||||||
|
req = (await db.execute(select(DemoRequest).where(DemoRequest.id == req_id))).scalar_one_or_none()
|
||||||
|
if not req:
|
||||||
|
raise HTTPException(404, detail="Demo request not found")
|
||||||
|
if body.status is not None:
|
||||||
|
valid_statuses = {"new", "contacted", "converted", "dismissed"}
|
||||||
|
if body.status not in valid_statuses:
|
||||||
|
raise HTTPException(400, detail=f"status must be one of: {', '.join(valid_statuses)}")
|
||||||
|
req.status = body.status
|
||||||
|
if body.notes is not None:
|
||||||
|
req.notes = body.notes
|
||||||
|
req.updated_at = datetime.now(timezone.utc)
|
||||||
|
await db.commit()
|
||||||
|
return {
|
||||||
|
"id": req.id,
|
||||||
|
"status": req.status,
|
||||||
|
"notes": req.notes,
|
||||||
|
"updated_at": req.updated_at.isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{req_id}", status_code=204)
|
||||||
|
async def delete_demo_request(
|
||||||
|
req_id: str,
|
||||||
|
_admin: HubUser = Depends(require_super_admin),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Super admin only — permanently delete a demo request."""
|
||||||
|
req = (await db.execute(select(DemoRequest).where(DemoRequest.id == req_id))).scalar_one_or_none()
|
||||||
|
if not req:
|
||||||
|
raise HTTPException(404, detail="Demo request not found")
|
||||||
|
await db.delete(req)
|
||||||
|
await db.commit()
|
||||||
94
backend/app/routers/email.py
Normal file
94
backend/app/routers/email.py
Normal 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"}
|
||||||
@@ -1,63 +1,238 @@
|
|||||||
"""School admin portal — school-scoped read endpoints."""
|
"""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.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select, func, desc, and_
|
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.auth.dependencies import require_school_admin, get_current_user
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.models.user import HubUser, UserRole
|
from app.models.user import HubUser, UserRole
|
||||||
from app.models.school import School
|
from app.models.school import School
|
||||||
from app.models.license import License
|
from app.models.license import License
|
||||||
from app.models.billing import Invoice
|
from app.models.billing import Invoice, SchoolSubscription
|
||||||
from app.models.sms import SmsJob, SmsJobStatus
|
from app.models.sms import SmsJob, SmsJobStatus, SmsCreditLedger, SmsCreditTx
|
||||||
from app.models.ticket import SupportTicket
|
from app.models.ticket import SupportTicket, TicketCategory
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/portal", tags=["school-portal"])
|
router = APIRouter(prefix="/api/portal", tags=["school-portal"])
|
||||||
|
|
||||||
|
|
||||||
async def _get_school(current_user: HubUser, db: AsyncSession) -> School:
|
async def _get_school(current_user: HubUser, db: AsyncSession) -> School:
|
||||||
if not current_user.school_id:
|
if not current_user.school_id:
|
||||||
raise HTTPException(400, "No school linked to your account")
|
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:
|
if not school:
|
||||||
raise HTTPException(404, "School not found")
|
raise HTTPException(404, "School not found")
|
||||||
return school
|
return school
|
||||||
|
|
||||||
|
|
||||||
@router.get("/overview")
|
@router.get("/overview")
|
||||||
async def portal_overview(
|
async def portal_overview(
|
||||||
current_user: HubUser = Depends(require_school_admin),
|
current_user: HubUser = Depends(require_school_admin),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
school = await _get_school(current_user, 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(
|
pending_inv = (await db.execute(
|
||||||
select(func.count()).where(
|
select(func.count()).where(
|
||||||
and_(Invoice.school_id == school.id, Invoice.status.in_(["sent", "overdue"]))
|
and_(Invoice.school_id == school.id, Invoice.status.in_(["sent", "overdue"]))
|
||||||
)
|
)
|
||||||
)).scalar_one()
|
)).scalar_one()
|
||||||
|
|
||||||
sms_this_month = (await db.execute(
|
sms_this_month = (await db.execute(
|
||||||
select(func.count()).where(
|
select(func.count()).where(
|
||||||
and_(SmsJob.school_id == school.id, SmsJob.status == SmsJobStatus.sent,
|
and_(
|
||||||
func.date_trunc("month", SmsJob.sent_at) == func.date_trunc("month", func.current_date()))
|
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()
|
)).scalar_one()
|
||||||
|
|
||||||
open_tickets = (await db.execute(
|
open_tickets = (await db.execute(
|
||||||
select(func.count()).where(
|
select(func.count()).where(
|
||||||
and_(SupportTicket.school_id == school.id, SupportTicket.status.in_(["open", "in_progress"]))
|
and_(SupportTicket.school_id == school.id, SupportTicket.status.in_(["open", "in_progress"]))
|
||||||
)
|
)
|
||||||
)).scalar_one()
|
)).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 {
|
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": {
|
"license": {
|
||||||
"key": lic.key if lic else None,
|
"key": lic.key if lic else None,
|
||||||
"status": lic.status.value 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,
|
"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,
|
"last_seen": lic.last_validated_at.isoformat() if lic and lic.last_validated_at else None,
|
||||||
},
|
},
|
||||||
"sms_credits": float(school.sms_credits),
|
"sms_credits": float(school.sms_credits),
|
||||||
"sms_credit_low_threshold": school.sms_credit_low_threshold,
|
"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_this_month": sms_this_month,
|
||||||
|
"sms_chart": chart,
|
||||||
"pending_invoices": pending_inv,
|
"pending_invoices": pending_inv,
|
||||||
"open_tickets": open_tickets,
|
"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.",
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
"""School registry endpoints — super admin only."""
|
"""School registry endpoints — super admin only."""
|
||||||
|
import json
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Optional, Any
|
from typing import Optional
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
from pydantic import BaseModel, EmailStr
|
from pydantic import BaseModel, EmailStr
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
@@ -16,6 +17,7 @@ from app.models.license import License, LicenseStatus
|
|||||||
|
|
||||||
router = APIRouter(prefix="/api/schools", tags=["schools"])
|
router = APIRouter(prefix="/api/schools", tags=["schools"])
|
||||||
|
|
||||||
|
|
||||||
class SchoolCreate(BaseModel):
|
class SchoolCreate(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
address: Optional[str] = None
|
address: Optional[str] = None
|
||||||
@@ -29,6 +31,7 @@ class SchoolCreate(BaseModel):
|
|||||||
sms_sender_name: str = "SCHOOL"
|
sms_sender_name: str = "SCHOOL"
|
||||||
notes: Optional[str] = None
|
notes: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class SchoolUpdate(BaseModel):
|
class SchoolUpdate(BaseModel):
|
||||||
name: Optional[str] = None
|
name: Optional[str] = None
|
||||||
address: Optional[str] = None
|
address: Optional[str] = None
|
||||||
@@ -41,41 +44,43 @@ class SchoolUpdate(BaseModel):
|
|||||||
student_limit: Optional[int] = None
|
student_limit: Optional[int] = None
|
||||||
sms_sender_name: Optional[str] = None
|
sms_sender_name: Optional[str] = None
|
||||||
status: Optional[SchoolStatus] = None
|
status: Optional[SchoolStatus] = None
|
||||||
|
hub_base_url: Optional[str] = None
|
||||||
notes: Optional[str] = None
|
notes: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class FeatureOverridesBody(BaseModel):
|
||||||
|
overrides: dict
|
||||||
|
|
||||||
|
|
||||||
def _school_out(s: School, license: License | None = None) -> 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 {
|
return {
|
||||||
"id": s.id,
|
"id": s.id, "name": s.name, "slug": s.slug, "address": s.address, "city": s.city,
|
||||||
"name": s.name,
|
"contact_name": s.contact_name, "contact_email": s.contact_email,
|
||||||
"slug": s.slug,
|
"contact_phone": s.contact_phone, "billing_email": s.billing_email,
|
||||||
"address": s.address,
|
"status": s.status.value, "tier": s.tier.value, "student_limit": s.student_limit,
|
||||||
"city": s.city,
|
"sms_sender_name": s.sms_sender_name, "sms_credits": float(s.sms_credits),
|
||||||
"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,
|
"sms_credit_low_threshold": s.sms_credit_low_threshold,
|
||||||
"created_at": s.created_at.isoformat(),
|
"hub_base_url": s.hub_base_url, "feature_overrides": feat,
|
||||||
"notes": s.notes,
|
"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_key": license.key if license else None,
|
||||||
"license_status": license.status.value 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_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,
|
"license_last_seen": license.last_validated_at.isoformat() if license and license.last_validated_at else None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("")
|
@router.get("")
|
||||||
async def list_schools(
|
async def list_schools(
|
||||||
page: int = Query(1, ge=1),
|
page: int = Query(1, ge=1), per_page: int = Query(25, ge=1, le=100),
|
||||||
per_page: int = Query(25, ge=1, le=100),
|
search: Optional[str] = Query(None), status: Optional[SchoolStatus] = Query(None),
|
||||||
search: Optional[str] = Query(None),
|
_admin: HubUser = Depends(require_super_admin), db: AsyncSession = Depends(get_db),
|
||||||
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))
|
stmt = select(School).order_by(desc(School.created_at))
|
||||||
if search:
|
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()
|
schools = (await db.execute(stmt.offset((page - 1) * per_page).limit(per_page))).scalars().all()
|
||||||
items = []
|
items = []
|
||||||
for s in schools:
|
for s in schools:
|
||||||
lic_res = await db.execute(select(License).where(License.school_id == s.id))
|
lic = (await db.execute(select(License).where(License.school_id == s.id))).scalar_one_or_none()
|
||||||
lic = lic_res.scalar_one_or_none()
|
|
||||||
items.append(_school_out(s, lic))
|
items.append(_school_out(s, lic))
|
||||||
return {"items": items, "total": total, "page": page, "per_page": per_page}
|
return {"items": items, "total": total, "page": page, "per_page": per_page}
|
||||||
|
|
||||||
|
|
||||||
@router.post("", status_code=201)
|
@router.post("", status_code=201)
|
||||||
async def create_school(
|
async def create_school(
|
||||||
body: SchoolCreate,
|
body: SchoolCreate,
|
||||||
@@ -98,34 +103,31 @@ async def create_school(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
slug = slugify(body.name)
|
slug = slugify(body.name)
|
||||||
# Ensure slug uniqueness
|
|
||||||
existing = (await db.execute(select(School).where(School.slug == slug))).scalar_one_or_none()
|
existing = (await db.execute(select(School).where(School.slug == slug))).scalar_one_or_none()
|
||||||
if existing:
|
if existing:
|
||||||
slug = f"{slug}-{uuid.uuid4().hex[:6]}"
|
slug = f"{slug}-{uuid.uuid4().hex[:6]}"
|
||||||
school = School(
|
school = School(
|
||||||
name=body.name,
|
name=body.name, slug=slug, address=body.address, city=body.city,
|
||||||
slug=slug,
|
contact_name=body.contact_name, contact_email=body.contact_email,
|
||||||
address=body.address,
|
contact_phone=body.contact_phone, billing_email=body.billing_email,
|
||||||
city=body.city,
|
tier=body.tier, student_limit=body.student_limit,
|
||||||
contact_name=body.contact_name,
|
sms_sender_name=body.sms_sender_name[:11], notes=body.notes,
|
||||||
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,
|
status=SchoolStatus.pending,
|
||||||
)
|
)
|
||||||
db.add(school)
|
db.add(school)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
# Auto-create license
|
|
||||||
lic = License(school_id=school.id, tier=body.tier.value, max_students=body.student_limit)
|
lic = License(school_id=school.id, tier=body.tier.value, max_students=body.student_limit)
|
||||||
db.add(lic)
|
db.add(lic)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(school)
|
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)
|
return _school_out(school, lic)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{school_id}")
|
@router.get("/{school_id}")
|
||||||
async def get_school(
|
async def get_school(
|
||||||
school_id: str,
|
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()
|
lic = (await db.execute(select(License).where(License.school_id == school_id))).scalar_one_or_none()
|
||||||
return _school_out(school, lic)
|
return _school_out(school, lic)
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{school_id}")
|
@router.put("/{school_id}")
|
||||||
async def update_school(
|
async def update_school(
|
||||||
school_id: str,
|
school_id: str, body: SchoolUpdate,
|
||||||
body: SchoolUpdate,
|
_admin: HubUser = Depends(require_super_admin), db: AsyncSession = Depends(get_db),
|
||||||
_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()
|
school = (await db.execute(select(School).where(School.id == school_id))).scalar_one_or_none()
|
||||||
if not school:
|
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()
|
lic = (await db.execute(select(License).where(License.school_id == school_id))).scalar_one_or_none()
|
||||||
return _school_out(school, lic)
|
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")
|
@router.post("/{school_id}/credits")
|
||||||
async def add_sms_credits(
|
async def add_sms_credits(
|
||||||
school_id: str,
|
school_id: str, amount: float, description: Optional[str] = None,
|
||||||
amount: float,
|
_admin: HubUser = Depends(require_super_admin), db: AsyncSession = Depends(get_db),
|
||||||
description: Optional[str] = None,
|
|
||||||
_admin: HubUser = Depends(require_super_admin),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
):
|
):
|
||||||
from app.models.sms import SmsCreditLedger, SmsCreditTx
|
from app.models.sms import SmsCreditLedger, SmsCreditTx
|
||||||
school = (await db.execute(select(School).where(School.id == school_id))).scalar_one_or_none()
|
school = (await db.execute(select(School).where(School.id == school_id))).scalar_one_or_none()
|
||||||
if not school:
|
if not school:
|
||||||
raise HTTPException(404, "School not found")
|
raise HTTPException(404, "School not found")
|
||||||
school.sms_credits = float(school.sms_credits) + amount
|
school.sms_credits = float(school.sms_credits) + amount
|
||||||
ledger = SmsCreditLedger(
|
db.add(SmsCreditLedger(
|
||||||
school_id=school_id,
|
school_id=school_id, tx_type=SmsCreditTx.topup, amount=amount,
|
||||||
tx_type=SmsCreditTx.topup,
|
|
||||||
amount=amount,
|
|
||||||
balance_after=float(school.sms_credits),
|
balance_after=float(school.sms_credits),
|
||||||
description=description or f"Manual top-up of {amount} credits",
|
description=description or f"Manual top-up of {amount} credits",
|
||||||
)
|
))
|
||||||
db.add(ledger)
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
return {"sms_credits": float(school.sms_credits), "added": amount}
|
return {"sms_credits": float(school.sms_credits), "added": amount}
|
||||||
|
|||||||
43
backend/app/routers/search.py
Normal file
43
backend/app/routers/search.py
Normal 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],
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
"""On-prem sync endpoint — polled by TapTrack every 30s to get SMS jobs and config."""
|
"""On-prem sync endpoint — polled by TapTrack every 30s to get SMS jobs and config."""
|
||||||
|
import uuid
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from fastapi import APIRouter, HTTPException, Request
|
from fastapi import APIRouter, HTTPException, Request
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
@@ -7,11 +8,28 @@ from fastapi import Depends
|
|||||||
|
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.models.license import License, LicenseStatus
|
from app.models.license import License, LicenseStatus
|
||||||
from app.models.school import School
|
from app.models.school import School, SchoolStatus
|
||||||
from app.models.sms import SmsJob, SmsJobStatus
|
from app.models.sms import SmsJob, SmsJobStatus, SmsCreditLedger, SmsCreditTx
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/sync", tags=["sync"])
|
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")
|
@router.post("/poll")
|
||||||
async def sync_poll(
|
async def sync_poll(
|
||||||
request: Request,
|
request: Request,
|
||||||
@@ -20,58 +38,143 @@ async def sync_poll(
|
|||||||
"""
|
"""
|
||||||
Called by on-prem TapTrack every 30s.
|
Called by on-prem TapTrack every 30s.
|
||||||
Returns pending SMS jobs and current config (sender_name, credits, feature flags).
|
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()
|
body = await request.json()
|
||||||
key = body.get("license_key", "")
|
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()
|
lic = (await db.execute(select(License).where(License.key == key))).scalar_one_or_none()
|
||||||
if not lic or lic.status == LicenseStatus.revoked:
|
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()
|
school = (await db.execute(select(School).where(School.id == lic.school_id))).scalar_one_or_none()
|
||||||
if not school:
|
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:
|
if sent_ids:
|
||||||
await db.execute(
|
# Fetch jobs to deduct credits (only for this school, only processing/pending)
|
||||||
update(SmsJob)
|
jobs_sent = (await db.execute(
|
||||||
.where(and_(SmsJob.id.in_(sent_ids), SmsJob.school_id == school.id))
|
select(SmsJob).where(
|
||||||
.values(status=SmsJobStatus.sent, sent_at=datetime.now(timezone.utc))
|
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)
|
if jobs_sent:
|
||||||
pending_jobs = (await db.execute(
|
credit_deductions = len(jobs_sent)
|
||||||
select(SmsJob)
|
new_balance = float(school.sms_credits) - credit_deductions
|
||||||
.where(and_(SmsJob.school_id == school.id, SmsJob.status == SmsJobStatus.pending))
|
new_balance = max(new_balance, 0.0) # floor at 0
|
||||||
.limit(50)
|
|
||||||
)).scalars().all()
|
|
||||||
|
|
||||||
# Mark as processing
|
# Deduct credits from school
|
||||||
job_ids = [j.id for j in pending_jobs]
|
school.sms_credits = new_balance
|
||||||
if job_ids:
|
|
||||||
await db.execute(
|
|
||||||
update(SmsJob)
|
|
||||||
.where(SmsJob.id.in_(job_ids))
|
|
||||||
.values(status=SmsJobStatus.processing)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Update last seen
|
# Write a single consolidated ledger entry for this batch
|
||||||
lic.last_validated_at = datetime.now(timezone.utc)
|
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
|
lic.last_seen_ip = request.client.host if request.client else None
|
||||||
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
|
suspended = school.status in (SchoolStatus.suspended, SchoolStatus.expired)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"sms_jobs": [
|
"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
|
for j in pending_jobs
|
||||||
],
|
],
|
||||||
"config": {
|
"config": {
|
||||||
"sms_sender_name": school.sms_sender_name,
|
"sms_sender_name": school.sms_sender_name,
|
||||||
"sms_credits": float(school.sms_credits),
|
"sms_credits": float(school.sms_credits),
|
||||||
"school_status": school.status.value,
|
"school_status": school.status.value,
|
||||||
|
"suspended": suspended,
|
||||||
|
"feature_flags": _tier_features(lic.tier, school.feature_overrides),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""Support ticket endpoints."""
|
"""Support ticket endpoints."""
|
||||||
import uuid
|
import os
|
||||||
from datetime import datetime, timezone
|
import threading
|
||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
@@ -14,45 +15,130 @@ from app.models.ticket import SupportTicket, TicketReply, TicketStatus, TicketPr
|
|||||||
|
|
||||||
router = APIRouter(prefix="/api/tickets", tags=["tickets"])
|
router = APIRouter(prefix="/api/tickets", tags=["tickets"])
|
||||||
|
|
||||||
|
|
||||||
class TicketCreate(BaseModel):
|
class TicketCreate(BaseModel):
|
||||||
subject: str
|
subject: str
|
||||||
body: str
|
body: str
|
||||||
category: TicketCategory = TicketCategory.general
|
category: TicketCategory = TicketCategory.general
|
||||||
|
|
||||||
|
|
||||||
class TicketUpdate(BaseModel):
|
class TicketUpdate(BaseModel):
|
||||||
status: Optional[TicketStatus] = None
|
status: Optional[TicketStatus] = None
|
||||||
priority: Optional[TicketPriority] = None
|
priority: Optional[TicketPriority] = None
|
||||||
assigned_to: Optional[str] = None
|
assigned_to: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class ReplyCreate(BaseModel):
|
class ReplyCreate(BaseModel):
|
||||||
body: str
|
body: str
|
||||||
is_internal: bool = False
|
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 {
|
return {
|
||||||
"id": t.id, "school_id": t.school_id, "ticket_number": t.ticket_number,
|
"id": t.id, "school_id": t.school_id, "school_name": school_name,
|
||||||
"subject": t.subject, "body": t.body, "category": t.category.value,
|
"ticket_number": t.ticket_number, "subject": t.subject, "body": t.body,
|
||||||
"status": t.status.value, "priority": t.priority.value,
|
"category": t.category.value, "status": t.status.value, "priority": t.priority.value,
|
||||||
"assigned_to": t.assigned_to,
|
"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,
|
"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,
|
"resolved_at": t.resolved_at.isoformat() if t.resolved_at else None,
|
||||||
"created_at": t.created_at.isoformat(),
|
"created_at": t.created_at.isoformat(), "updated_at": t.updated_at.isoformat(),
|
||||||
"updated_at": t.updated_at.isoformat(),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _next_ticket_number(count: int) -> str:
|
def _next_ticket_number(count: int) -> str:
|
||||||
from datetime import date
|
from datetime import date
|
||||||
return f"TKT-{date.today().year}-{count + 1:05d}"
|
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("")
|
@router.get("")
|
||||||
async def list_tickets(
|
async def list_tickets(
|
||||||
school_id: Optional[str] = Query(None),
|
school_id: Optional[str] = Query(None),
|
||||||
status: Optional[TicketStatus] = Query(None),
|
status: Optional[TicketStatus] = Query(None),
|
||||||
|
priority: Optional[TicketPriority] = Query(None),
|
||||||
page: int = Query(1, ge=1),
|
page: int = Query(1, ge=1),
|
||||||
per_page: int = Query(25),
|
per_page: int = Query(25),
|
||||||
current_user: HubUser = Depends(get_current_user),
|
current_user: HubUser = Depends(get_current_user),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
|
from app.models.school import School
|
||||||
stmt = select(SupportTicket).order_by(desc(SupportTicket.updated_at))
|
stmt = select(SupportTicket).order_by(desc(SupportTicket.updated_at))
|
||||||
if current_user.role != UserRole.super_admin:
|
if current_user.role != UserRole.super_admin:
|
||||||
stmt = stmt.where(SupportTicket.school_id == current_user.school_id)
|
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)
|
stmt = stmt.where(SupportTicket.school_id == school_id)
|
||||||
if status:
|
if status:
|
||||||
stmt = stmt.where(SupportTicket.status == 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()
|
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()
|
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)
|
@router.post("", status_code=201)
|
||||||
async def create_ticket(
|
async def create_ticket(
|
||||||
@@ -74,16 +169,17 @@ async def create_ticket(
|
|||||||
raise HTTPException(400, "No school associated with your account")
|
raise HTTPException(400, "No school associated with your account")
|
||||||
count = (await db.execute(select(func.count()).select_from(SupportTicket))).scalar_one()
|
count = (await db.execute(select(func.count()).select_from(SupportTicket))).scalar_one()
|
||||||
ticket = SupportTicket(
|
ticket = SupportTicket(
|
||||||
school_id=current_user.school_id,
|
school_id=current_user.school_id, submitted_by=current_user.id,
|
||||||
submitted_by=current_user.id,
|
ticket_number=_next_ticket_number(count), subject=body.subject,
|
||||||
ticket_number=_next_ticket_number(count),
|
body=body.body, category=body.category,
|
||||||
subject=body.subject,
|
|
||||||
body=body.body,
|
|
||||||
category=body.category,
|
|
||||||
)
|
)
|
||||||
db.add(ticket)
|
db.add(ticket)
|
||||||
await db.commit()
|
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}")
|
@router.get("/{ticket_id}")
|
||||||
async def get_ticket(
|
async def get_ticket(
|
||||||
@@ -91,23 +187,25 @@ async def get_ticket(
|
|||||||
current_user: HubUser = Depends(get_current_user),
|
current_user: HubUser = Depends(get_current_user),
|
||||||
db: AsyncSession = Depends(get_db),
|
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()
|
t = (await db.execute(select(SupportTicket).where(SupportTicket.id == ticket_id))).scalar_one_or_none()
|
||||||
if not t:
|
if not t:
|
||||||
raise HTTPException(404)
|
raise HTTPException(404)
|
||||||
if current_user.role != UserRole.super_admin and t.school_id != current_user.school_id:
|
if current_user.role != UserRole.super_admin and t.school_id != current_user.school_id:
|
||||||
raise HTTPException(403)
|
raise HTTPException(403)
|
||||||
replies_res = await db.execute(select(TicketReply).where(TicketReply.ticket_id == ticket_id).order_by(TicketReply.created_at))
|
replies = (await db.execute(
|
||||||
replies = replies_res.scalars().all()
|
select(TicketReply).where(TicketReply.ticket_id == ticket_id).order_by(TicketReply.created_at)
|
||||||
visible_replies = [r for r in replies if not r.is_internal or current_user.role == UserRole.super_admin]
|
)).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 {
|
return {
|
||||||
**_ticket_out(t),
|
**_ticket_out(t, school.name if school else None),
|
||||||
"replies": [
|
"replies": [{"id": r.id, "body": r.body, "is_internal": r.is_internal,
|
||||||
{"id": r.id, "body": r.body, "is_internal": r.is_internal,
|
"author_id": r.author_id, "created_at": r.created_at.isoformat()}
|
||||||
"author_id": r.author_id, "created_at": r.created_at.isoformat()}
|
for r in visible],
|
||||||
for r in visible_replies
|
|
||||||
],
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{ticket_id}")
|
@router.put("/{ticket_id}")
|
||||||
async def update_ticket(
|
async def update_ticket(
|
||||||
ticket_id: str,
|
ticket_id: str,
|
||||||
@@ -125,6 +223,7 @@ async def update_ticket(
|
|||||||
await db.commit()
|
await db.commit()
|
||||||
return _ticket_out(t)
|
return _ticket_out(t)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{ticket_id}/replies", status_code=201)
|
@router.post("/{ticket_id}/replies", status_code=201)
|
||||||
async def add_reply(
|
async def add_reply(
|
||||||
ticket_id: str,
|
ticket_id: str,
|
||||||
@@ -140,10 +239,33 @@ async def add_reply(
|
|||||||
is_internal = body.is_internal and current_user.role == UserRole.super_admin
|
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)
|
reply = TicketReply(ticket_id=ticket_id, author_id=current_user.id, body=body.body, is_internal=is_internal)
|
||||||
db.add(reply)
|
db.add(reply)
|
||||||
# Set first response time (super admin only)
|
|
||||||
if current_user.role == UserRole.super_admin and not t.first_response_at:
|
if current_user.role == UserRole.super_admin and not t.first_response_at:
|
||||||
t.first_response_at = datetime.now(timezone.utc)
|
t.first_response_at = datetime.now(timezone.utc)
|
||||||
if t.status == TicketStatus.open:
|
if t.status == TicketStatus.open:
|
||||||
t.status = TicketStatus.in_progress
|
t.status = TicketStatus.in_progress
|
||||||
await db.commit()
|
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)}
|
||||||
|
|||||||
@@ -1,32 +1,154 @@
|
|||||||
"""Simple SMTP email service."""
|
"""Email service — SMTP delivery with HTML templates and delivery logging."""
|
||||||
import smtplib
|
import smtplib
|
||||||
import logging
|
import logging
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
from email.mime.text import MIMEText
|
from email.mime.text import MIMEText
|
||||||
from email.mime.multipart import MIMEMultipart
|
from email.mime.multipart import MIMEMultipart
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
def send_email(to: str, subject: str, body: str, html: str | None = None) -> bool:
|
TEMPLATE_DIR = Path(__file__).parent.parent / "templates"
|
||||||
"""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}")
|
def _render_template(template_name: str, context: dict) -> str:
|
||||||
return False
|
"""Render a Jinja2 HTML email template. Returns empty string on failure."""
|
||||||
try:
|
try:
|
||||||
msg = MIMEMultipart("alternative")
|
from jinja2 import Environment, FileSystemLoader
|
||||||
msg["Subject"] = subject
|
env = Environment(loader=FileSystemLoader(str(TEMPLATE_DIR)), autoescape=True)
|
||||||
msg["From"] = settings.SMTP_FROM
|
tmpl = env.get_template(template_name)
|
||||||
msg["To"] = to
|
return tmpl.render(**context)
|
||||||
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
|
|
||||||
except Exception as e:
|
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
|
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
|
||||||
|
|||||||
70
backend/app/services/invoice_pdf.py
Normal file
70
backend/app/services/invoice_pdf.py
Normal 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)
|
||||||
@@ -1,45 +1,71 @@
|
|||||||
"""Celery tasks: invoice generation, email, overdue checks."""
|
"""Celery tasks: invoice generation, PDF, email, overdue escalation."""
|
||||||
import logging
|
import logging
|
||||||
from datetime import date, timedelta
|
from datetime import date, timedelta, datetime, timezone
|
||||||
|
|
||||||
from app.worker import celery_app
|
from app.worker import celery_app
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def _make_session():
|
def _make_session():
|
||||||
import os
|
import os
|
||||||
from sqlalchemy import create_engine
|
from sqlalchemy import create_engine
|
||||||
from sqlalchemy.orm import sessionmaker
|
from sqlalchemy.orm import sessionmaker
|
||||||
db_url = os.getenv("DATABASE_URL", "postgresql://postgres:postgres@db:5432/taptrack_hub")
|
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)()
|
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")
|
@celery_app.task(name="billing.generate_monthly_invoices")
|
||||||
def generate_monthly_invoices():
|
def generate_monthly_invoices():
|
||||||
"""On the 1st: create draft invoices for all active schools with a subscription."""
|
"""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 app.models.school import School, SchoolStatus
|
||||||
from sqlalchemy import select
|
|
||||||
from datetime import date
|
|
||||||
|
|
||||||
db = _make_session()
|
db = _make_session()
|
||||||
try:
|
try:
|
||||||
today = date.today()
|
today = date.today()
|
||||||
period_start = date(today.year, today.month, 1)
|
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_start = date(prev_month.year, prev_month.month, 1)
|
||||||
billing_end = period_start - timedelta(days=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()
|
count = db.execute(select(func.count()).select_from(Invoice)).scalar_one()
|
||||||
|
created = 0
|
||||||
|
|
||||||
for sub in subs:
|
for sub in subs:
|
||||||
school = db.get(School, sub.school_id)
|
school = db.get(School, sub.school_id)
|
||||||
if not school or school.status != SchoolStatus.active:
|
if not school or school.status != SchoolStatus.active:
|
||||||
continue
|
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)
|
total = float(sub.monthly_fee)
|
||||||
inv_num = f"INV-{billing_start.strftime('%Y%m')}-{count + 1:04d}"
|
inv_num = f"INV-{billing_start.strftime('%Y%m')}-{count + 1:04d}"
|
||||||
count += 1
|
count += 1
|
||||||
|
created += 1
|
||||||
|
|
||||||
inv = Invoice(
|
inv = Invoice(
|
||||||
school_id=sub.school_id,
|
school_id=sub.school_id,
|
||||||
invoice_number=inv_num,
|
invoice_number=inv_num,
|
||||||
@@ -51,6 +77,7 @@ def generate_monthly_invoices():
|
|||||||
)
|
)
|
||||||
db.add(inv)
|
db.add(inv)
|
||||||
db.flush()
|
db.flush()
|
||||||
|
|
||||||
db.add(InvoiceLineItem(
|
db.add(InvoiceLineItem(
|
||||||
invoice_id=inv.id,
|
invoice_id=inv.id,
|
||||||
description=f"Monthly subscription — {school.name}",
|
description=f"Monthly subscription — {school.name}",
|
||||||
@@ -58,21 +85,47 @@ def generate_monthly_invoices():
|
|||||||
unit_price=float(sub.monthly_fee),
|
unit_price=float(sub.monthly_fee),
|
||||||
amount=float(sub.monthly_fee),
|
amount=float(sub.monthly_fee),
|
||||||
))
|
))
|
||||||
|
|
||||||
|
# Auto-send invoice email
|
||||||
|
send_invoice_email_task.delay(inv.id)
|
||||||
|
|
||||||
db.commit()
|
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:
|
except Exception as e:
|
||||||
db.rollback()
|
db.rollback()
|
||||||
logger.error(f"generate_monthly_invoices error: {e}")
|
logger.error("generate_monthly_invoices error: %s", e)
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
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")
|
@celery_app.task(name="billing.send_invoice_email")
|
||||||
def send_invoice_email_task(invoice_id: str):
|
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.billing import Invoice, InvoiceStatus
|
||||||
from app.models.school import School
|
from app.models.school import School
|
||||||
from app.services.email import send_email
|
from app.services.email import send_email
|
||||||
from sqlalchemy import select
|
|
||||||
|
|
||||||
db = _make_session()
|
db = _make_session()
|
||||||
try:
|
try:
|
||||||
@@ -82,44 +135,164 @@ def send_invoice_email_task(invoice_id: str):
|
|||||||
school = db.get(School, inv.school_id)
|
school = db.get(School, inv.school_id)
|
||||||
if not school or not school.billing_email:
|
if not school or not school.billing_email:
|
||||||
return
|
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}.
|
plain = (
|
||||||
|
f"Dear {school.contact_name or school.name},\n\n"
|
||||||
Amount Due: PHP {float(inv.total_amount):,.2f}
|
f"Your invoice {inv.invoice_number} for PHP {float(inv.total_amount):,.2f} "
|
||||||
Due Date: {inv.due_date}
|
f"covering {inv.billing_period_start} to {inv.billing_period_end} is ready.\n"
|
||||||
|
f"Due: {inv.due_date or 'Upon receipt'}\n\n"
|
||||||
Please log in to your TapTrack Hub portal to view and pay your invoice.
|
f"Log in: {_hub_url()}/portal/billing\n\nTapTrack Hub Team"
|
||||||
|
)
|
||||||
Thank you,
|
send_email(
|
||||||
TapTrack Hub Team
|
to=school.billing_email,
|
||||||
"""
|
subject=f"Invoice {inv.invoice_number} — TapTrack Hub",
|
||||||
send_email(to=school.billing_email, subject=f"Invoice {inv.invoice_number} — TapTrack Hub", body=body)
|
body=plain,
|
||||||
from datetime import datetime, timezone
|
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)
|
inv.email_sent_at = datetime.now(timezone.utc)
|
||||||
if inv.status.value == "draft":
|
if inv.status == InvoiceStatus.draft:
|
||||||
inv.status = InvoiceStatus.sent
|
inv.status = InvoiceStatus.sent
|
||||||
db.commit()
|
db.commit()
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
@celery_app.task(name="billing.check_overdue")
|
@celery_app.task(name="billing.check_overdue")
|
||||||
def 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 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()
|
db = _make_session()
|
||||||
try:
|
try:
|
||||||
today = date.today()
|
today = date.today()
|
||||||
overdue = db.execute(
|
|
||||||
|
# 1. Mark newly overdue
|
||||||
|
newly_overdue = db.execute(
|
||||||
select(Invoice).where(
|
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()
|
).scalars().all()
|
||||||
for inv in overdue:
|
for inv in newly_overdue:
|
||||||
inv.status = InvoiceStatus.overdue
|
inv.status = InvoiceStatus.overdue
|
||||||
db.commit()
|
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:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from app.worker import celery_app
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@celery_app.task(name="license.check_expiry")
|
@celery_app.task(name="license.check_expiry")
|
||||||
def check_expiry():
|
def check_expiry():
|
||||||
"""Send expiry warning emails for licenses expiring in 30, 14, or 7 days."""
|
"""Send expiry warning emails for licenses expiring in 30, 14, or 7 days."""
|
||||||
@@ -34,7 +35,20 @@ def check_expiry():
|
|||||||
send_email(
|
send_email(
|
||||||
to=school.billing_email,
|
to=school.billing_email,
|
||||||
subject=f"[TapTrack Hub] License expires in {days_ahead} days — {school.name}",
|
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()
|
db.commit()
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
56
backend/app/tasks/onboarding.py
Normal file
56
backend/app/tasks/onboarding.py
Normal 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()
|
||||||
@@ -1,35 +1,196 @@
|
|||||||
"""Celery task: send monthly reports to schools."""
|
"""Celery tasks: monthly report generation and on-prem data pull."""
|
||||||
import logging
|
import logging
|
||||||
|
from datetime import datetime, timezone, timedelta, date
|
||||||
|
|
||||||
from app.worker import celery_app
|
from app.worker import celery_app
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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")
|
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)
|
||||||
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:
|
try:
|
||||||
today = date.today()
|
today = date.today()
|
||||||
prev_month_end = date(today.year, today.month, 1) - timedelta(days=1)
|
prev_end = date(today.year, today.month, 1) - timedelta(days=1)
|
||||||
prev_month_start = date(prev_month_end.year, prev_month_end.month, 1)
|
report_month = f"{prev_end.year}-{prev_end.month:02d}"
|
||||||
|
|
||||||
schools = db.execute(select(School).where(School.status == SchoolStatus.active)).scalars().all()
|
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:
|
for school in schools:
|
||||||
if not school.billing_email:
|
if not school.billing_email:
|
||||||
continue
|
continue
|
||||||
send_email(
|
|
||||||
to=school.billing_email,
|
sms_sent_count = db.execute(
|
||||||
subject=f"Monthly Report — {school.name} — {prev_month_start.strftime('%B %Y')}",
|
select(func.count()).where(
|
||||||
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",
|
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:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|||||||
@@ -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 logging
|
||||||
import os
|
import os
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone, timedelta
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from sqlalchemy import create_engine, select, update, and_
|
from sqlalchemy import create_engine, select, update, and_
|
||||||
@@ -19,9 +19,17 @@ def _make_sync_engine():
|
|||||||
_engine = _make_sync_engine()
|
_engine = _make_sync_engine()
|
||||||
_Session = sessionmaker(bind=_engine)
|
_Session = sessionmaker(bind=_engine)
|
||||||
|
|
||||||
|
|
||||||
@celery_app.task(name="sms.process_queue")
|
@celery_app.task(name="sms.process_queue")
|
||||||
def process_sms_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.sms import SmsJob, SmsJobStatus
|
||||||
from app.models.school import School
|
from app.models.school import School
|
||||||
|
|
||||||
@@ -44,6 +52,7 @@ def process_sms_queue():
|
|||||||
job.status = SmsJobStatus.sent
|
job.status = SmsJobStatus.sent
|
||||||
job.sent_at = datetime.now(timezone.utc)
|
job.sent_at = datetime.now(timezone.utc)
|
||||||
job.semaphore_message_id = result.get("message_id")
|
job.semaphore_message_id = result.get("message_id")
|
||||||
|
job.delivered_via = "push"
|
||||||
# Deduct credit
|
# Deduct credit
|
||||||
from app.models.sms import SmsCreditLedger, SmsCreditTx
|
from app.models.sms import SmsCreditLedger, SmsCreditTx
|
||||||
school.sms_credits = float(school.sms_credits) - 1.0
|
school.sms_credits = float(school.sms_credits) - 1.0
|
||||||
@@ -52,7 +61,7 @@ def process_sms_queue():
|
|||||||
tx_type=SmsCreditTx.deduct,
|
tx_type=SmsCreditTx.deduct,
|
||||||
amount=-1.0,
|
amount=-1.0,
|
||||||
balance_after=float(school.sms_credits),
|
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,
|
reference_id=job.id,
|
||||||
))
|
))
|
||||||
# Low credit alert
|
# Low credit alert
|
||||||
@@ -67,6 +76,46 @@ def process_sms_queue():
|
|||||||
finally:
|
finally:
|
||||||
db.close()
|
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:
|
def _send_semaphore(phone: str, message: str, sender: str) -> dict:
|
||||||
try:
|
try:
|
||||||
with httpx.Client(timeout=15) as client:
|
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:
|
except Exception as e:
|
||||||
return {"success": False, "error": str(e)}
|
return {"success": False, "error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
@celery_app.task(name="sms.send_low_credit_alert")
|
@celery_app.task(name="sms.send_low_credit_alert")
|
||||||
def send_low_credit_alert(school_id: str):
|
def send_low_credit_alert(school_id: str):
|
||||||
"""Send low credit warning email to school billing contact."""
|
"""Send low credit warning email to school billing contact."""
|
||||||
@@ -96,7 +146,20 @@ def send_low_credit_alert(school_id: str):
|
|||||||
send_email(
|
send_email(
|
||||||
to=school.billing_email,
|
to=school.billing_email,
|
||||||
subject=f"[TapTrack Hub] Low SMS Credits — {school.name}",
|
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:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|||||||
70
backend/app/tasks/tickets.py
Normal file
70
backend/app/tasks/tickets.py
Normal 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()
|
||||||
45
backend/app/templates/email/base.html
Normal file
45
backend/app/templates/email/base.html
Normal 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 · <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>
|
||||||
20
backend/app/templates/email/invoice.html
Normal file
20
backend/app/templates/email/invoice.html
Normal 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 %}
|
||||||
23
backend/app/templates/email/license_expiry.html
Normal file
23
backend/app/templates/email/license_expiry.html
Normal 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 %}
|
||||||
23
backend/app/templates/email/low_credit.html
Normal file
23
backend/app/templates/email/low_credit.html
Normal 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 %}
|
||||||
26
backend/app/templates/email/overdue_warning.html
Normal file
26
backend/app/templates/email/overdue_warning.html
Normal 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 & 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 %}
|
||||||
32
backend/app/templates/email/suspension.html
Normal file
32
backend/app/templates/email/suspension.html
Normal 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 %}
|
||||||
22
backend/app/templates/email/ticket_notification.html
Normal file
22
backend/app/templates/email/ticket_notification.html
Normal 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 %}
|
||||||
226
backend/app/templates/invoice.html
Normal file
226
backend/app/templates/invoice.html
Normal 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>
|
||||||
@@ -14,6 +14,8 @@ celery_app = Celery(
|
|||||||
"app.tasks.billing",
|
"app.tasks.billing",
|
||||||
"app.tasks.reports",
|
"app.tasks.reports",
|
||||||
"app.tasks.license",
|
"app.tasks.license",
|
||||||
|
"app.tasks.tickets",
|
||||||
|
"app.tasks.onboarding",
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -24,11 +26,16 @@ celery_app.conf.update(
|
|||||||
timezone="Asia/Manila",
|
timezone="Asia/Manila",
|
||||||
enable_utc=True,
|
enable_utc=True,
|
||||||
beat_schedule={
|
beat_schedule={
|
||||||
# Process pending SMS jobs every 30 seconds
|
# Process pending SMS jobs every 30 seconds (push path via Semaphore)
|
||||||
"process-sms-queue": {
|
"process-sms-queue": {
|
||||||
"task": "sms.process_queue",
|
"task": "sms.process_queue",
|
||||||
"schedule": 30.0,
|
"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 every day at 8am
|
||||||
"check-license-expiry": {
|
"check-license-expiry": {
|
||||||
"task": "license.check_expiry",
|
"task": "license.check_expiry",
|
||||||
@@ -39,6 +46,11 @@ celery_app.conf.update(
|
|||||||
"task": "billing.generate_monthly_invoices",
|
"task": "billing.generate_monthly_invoices",
|
||||||
"schedule": crontab(day_of_month=1, hour=6, minute=0),
|
"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 on the 1st at 7am
|
||||||
"send-monthly-reports": {
|
"send-monthly-reports": {
|
||||||
"task": "reports.send_monthly_reports",
|
"task": "reports.send_monthly_reports",
|
||||||
@@ -49,6 +61,11 @@ celery_app.conf.update(
|
|||||||
"task": "billing.check_overdue",
|
"task": "billing.check_overdue",
|
||||||
"schedule": crontab(hour=9, minute=0),
|
"schedule": crontab(hour=9, minute=0),
|
||||||
},
|
},
|
||||||
|
# Escalate stale tickets every hour
|
||||||
|
"escalate-stale-tickets": {
|
||||||
|
"task": "tickets.escalate_stale",
|
||||||
|
"schedule": crontab(minute=0),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ if config.config_file_name is not None:
|
|||||||
fileConfig(config.config_file_name)
|
fileConfig(config.config_file_name)
|
||||||
|
|
||||||
from app.database import Base
|
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
|
target_metadata = Base.metadata
|
||||||
|
|
||||||
DATABASE_URL = os.getenv("DATABASE_URL", "postgresql+asyncpg://postgres:postgres@db:5432/taptrack_hub")
|
DATABASE_URL = os.getenv("DATABASE_URL", "postgresql+asyncpg://postgres:postgres@db:5432/taptrack_hub")
|
||||||
|
|||||||
@@ -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")
|
||||||
38
backend/migrations/versions/002_phase9_email_logs.py
Normal file
38
backend/migrations/versions/002_phase9_email_logs.py
Normal 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")
|
||||||
59
backend/migrations/versions/003_phases11_15.py
Normal file
59
backend/migrations/versions/003_phases11_15.py
Normal 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")
|
||||||
@@ -8,6 +8,7 @@ pydantic[email]==2.10.3
|
|||||||
pydantic-settings==2.7.0
|
pydantic-settings==2.7.0
|
||||||
python-jose[cryptography]==3.3.0
|
python-jose[cryptography]==3.3.0
|
||||||
passlib[bcrypt]==1.7.4
|
passlib[bcrypt]==1.7.4
|
||||||
|
bcrypt==4.3.0
|
||||||
python-multipart==0.0.20
|
python-multipart==0.0.20
|
||||||
celery==5.4.0
|
celery==5.4.0
|
||||||
redis==5.2.1
|
redis==5.2.1
|
||||||
|
|||||||
@@ -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.ext.asyncio import create_async_engine, async_sessionmaker
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from app.database import Base
|
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.models.user import HubUser, UserRole
|
||||||
from app.auth.password import hash_password
|
from app.auth.password import hash_password
|
||||||
|
|
||||||
|
|||||||
6
docker-compose.override.yml
Normal file
6
docker-compose.override.yml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
|
||||||
|
services:
|
||||||
|
nginx:
|
||||||
|
ports:
|
||||||
|
- "8091:80"
|
||||||
|
|
||||||
@@ -1,5 +1,19 @@
|
|||||||
version: "3.9"
|
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: production
|
||||||
|
SEMAPHORE_API_KEY: ""
|
||||||
|
SMTP_HOST: ""
|
||||||
|
SMTP_PORT: "587"
|
||||||
|
SMTP_USER: ""
|
||||||
|
SMTP_PASSWORD: ""
|
||||||
|
SMTP_FROM: "noreply@taptrack.io"
|
||||||
|
HUB_BASE_URL: "http://taptrack-hub.juankibin.space"
|
||||||
|
PDF_DIR: "/app/data/invoices"
|
||||||
|
|
||||||
services:
|
services:
|
||||||
db:
|
db:
|
||||||
image: postgres:15-alpine
|
image: postgres:15-alpine
|
||||||
@@ -26,19 +40,8 @@ services:
|
|||||||
build: ./backend
|
build: ./backend
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: postgresql+asyncpg://postgres:postgres@db:5432/taptrack_hub
|
<<: *backend-env
|
||||||
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"
|
|
||||||
volumes:
|
volumes:
|
||||||
- ./backend:/app
|
|
||||||
- backend_data:/app/data
|
- backend_data:/app/data
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
@@ -46,24 +49,23 @@ services:
|
|||||||
redis:
|
redis:
|
||||||
condition: service_started
|
condition: service_started
|
||||||
|
|
||||||
|
seed:
|
||||||
|
build: ./backend
|
||||||
|
command: python seed.py
|
||||||
|
environment:
|
||||||
|
<<: *backend-env
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
restart: "no"
|
||||||
|
|
||||||
celery:
|
celery:
|
||||||
build: ./backend
|
build: ./backend
|
||||||
command: celery -A app.worker worker --loglevel=info --concurrency=2
|
command: celery -A app.worker worker --loglevel=info --concurrency=2
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: postgresql+asyncpg://postgres:postgres@db:5432/taptrack_hub
|
<<: *backend-env
|
||||||
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"
|
|
||||||
volumes:
|
volumes:
|
||||||
- ./backend:/app
|
|
||||||
- backend_data:/app/data
|
- backend_data:/app/data
|
||||||
depends_on:
|
depends_on:
|
||||||
- db
|
- db
|
||||||
@@ -74,19 +76,8 @@ services:
|
|||||||
command: celery -A app.worker beat --loglevel=info
|
command: celery -A app.worker beat --loglevel=info
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: postgresql+asyncpg://postgres:postgres@db:5432/taptrack_hub
|
<<: *backend-env
|
||||||
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"
|
|
||||||
volumes:
|
volumes:
|
||||||
- ./backend:/app
|
|
||||||
- backend_data:/app/data
|
- backend_data:/app/data
|
||||||
depends_on:
|
depends_on:
|
||||||
- db
|
- db
|
||||||
@@ -99,12 +90,10 @@ services:
|
|||||||
- backend
|
- backend
|
||||||
|
|
||||||
nginx:
|
nginx:
|
||||||
image: nginx:alpine
|
build: ./nginx
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "8090:80"
|
- "8091:80"
|
||||||
volumes:
|
|
||||||
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
|
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
- backend
|
||||||
- frontend
|
- frontend
|
||||||
@@ -113,3 +102,4 @@ volumes:
|
|||||||
db_data:
|
db_data:
|
||||||
redis_data:
|
redis_data:
|
||||||
backend_data:
|
backend_data:
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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'
|
import SidebarItem from './SidebarItem.vue'
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
@@ -32,6 +32,9 @@ const navItems = [
|
|||||||
{ label: 'Billing', to: '/billing', icon: Receipt },
|
{ label: 'Billing', to: '/billing', icon: Receipt },
|
||||||
{ label: 'Support', to: '/tickets', icon: Ticket },
|
{ label: 'Support', to: '/tickets', icon: Ticket },
|
||||||
{ label: 'Users', to: '/users', icon: Users },
|
{ 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>
|
</script>
|
||||||
|
|||||||
@@ -3,13 +3,47 @@
|
|||||||
<AppSidebar />
|
<AppSidebar />
|
||||||
<div class="flex-1 flex flex-col min-w-0 overflow-hidden">
|
<div class="flex-1 flex flex-col min-w-0 overflow-hidden">
|
||||||
<!-- Top bar -->
|
<!-- Top bar -->
|
||||||
<header class="h-14 bg-white border-b border-slate-200 flex items-center justify-between px-6 shrink-0">
|
<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">
|
<div class="text-sm text-slate-500 shrink-0">
|
||||||
TapTrack Hub
|
TapTrack Hub
|
||||||
<span class="mx-1.5 text-slate-300">·</span>
|
<span class="mx-1.5 text-slate-300">·</span>
|
||||||
<span class="text-slate-800 font-medium">{{ pageTitle }}</span>
|
<span class="text-slate-800 font-medium">{{ pageTitle }}</span>
|
||||||
</div>
|
</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>
|
<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">
|
<button @click="logout" class="text-slate-400 hover:text-slate-700 transition-colors" title="Logout">
|
||||||
<LogOut :size="18" />
|
<LogOut :size="18" />
|
||||||
@@ -25,24 +59,56 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { ref, computed, watch } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
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 AppSidebar from '@/components/sidebar/AppSidebar.vue'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import { globalSearch } from '@/lib/api'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
|
const searchQuery = ref('')
|
||||||
|
const showSearch = ref(false)
|
||||||
|
const searchResults = ref<any>(null)
|
||||||
|
|
||||||
const pageTitles: Record<string, string> = {
|
const pageTitles: Record<string, string> = {
|
||||||
dashboard: 'Dashboard', schools: 'Schools', 'school-detail': 'School Detail',
|
dashboard: 'Dashboard', schools: 'Schools', 'school-detail': 'School Detail',
|
||||||
licenses: 'Licenses', sms: 'SMS Gateway', billing: 'Billing',
|
licenses: 'Licenses', sms: 'SMS Gateway', billing: 'Billing',
|
||||||
tickets: 'Support Tickets', 'ticket-detail': 'Ticket Detail',
|
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 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() {
|
function logout() {
|
||||||
authStore.logout()
|
authStore.logout()
|
||||||
router.push('/login')
|
router.push('/login')
|
||||||
|
|||||||
@@ -31,7 +31,9 @@ export const changePassword = (current_password: string, new_password: string) =
|
|||||||
api.put('/auth/me/password', { current_password, new_password })
|
api.put('/auth/me/password', { current_password, new_password })
|
||||||
|
|
||||||
// ── Dashboard ─────────────────────────────────────────────────────────────────
|
// ── 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 ───────────────────────────────────────────────────────────────────
|
// ── Schools ───────────────────────────────────────────────────────────────────
|
||||||
export interface School {
|
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 updateSchool = (id: string, data: object) => api.put(`/schools/${id}`, data).then(r => r.data)
|
||||||
export const addSmsCredits = (id: string, amount: number, description?: string) =>
|
export const addSmsCredits = (id: string, amount: number, description?: string) =>
|
||||||
api.post(`/schools/${id}/credits`, null, { params: { amount, description } }).then(r => r.data)
|
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 ──────────────────────────────────────────────────────────────────
|
// ── Licenses ──────────────────────────────────────────────────────────────────
|
||||||
export const getLicenses = () => api.get('/licenses').then(r => r.data)
|
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)
|
export const triggerSmsQueue = () => api.post('/sms/trigger-queue').then(r => r.data)
|
||||||
|
|
||||||
// ── Billing ───────────────────────────────────────────────────────────────────
|
// ── Billing ───────────────────────────────────────────────────────────────────
|
||||||
export const getInvoices = (params?: object) => api.get('/billing/invoices', { params }).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 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 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 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) =>
|
export const upsertSubscription = (schoolId: string, data: object) =>
|
||||||
api.put(`/billing/subscriptions/${schoolId}`, data).then(r => r.data)
|
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 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 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 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 ─────────────────────────────────────────────────────────────────────
|
// ── Users ─────────────────────────────────────────────────────────────────────
|
||||||
export const getUsers = (params?: object) => api.get('/users', { params }).then(r => r.data)
|
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 createAnnouncement = (data: object) => api.post('/announcements', data).then(r => r.data)
|
||||||
export const deleteAnnouncement = (id: string) => api.delete(`/announcements/${id}`)
|
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 ─────────────────────────────────────────────────────────────
|
// ── 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)
|
||||||
|
|||||||
88
frontend/src/pages/AuditLogsPage.vue
Normal file
88
frontend/src/pages/AuditLogsPage.vue
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
<template>
|
||||||
|
<div class="space-y-6">
|
||||||
|
<h1 class="text-2xl font-bold text-slate-900">Audit Logs</h1>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-3">
|
||||||
|
<input v-model="actionFilter" type="text" placeholder="Filter by action…"
|
||||||
|
class="border border-slate-200 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 w-44" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-white rounded-xl overflow-hidden" style="box-shadow:0 2px 8px #0000000A">
|
||||||
|
<div class="flex items-center justify-between px-5 py-4 border-b border-slate-100">
|
||||||
|
<h2 class="text-base font-semibold text-slate-900">Activity</h2>
|
||||||
|
<span class="text-xs text-slate-400">{{ total }} records</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loading" class="p-8 text-center text-sm text-slate-400 animate-pulse">Loading…</div>
|
||||||
|
<div v-else-if="logs.length === 0" class="flex flex-col items-center justify-center py-14 text-slate-400">
|
||||||
|
<Shield :size="36" class="mb-3 opacity-30" />
|
||||||
|
<p class="font-medium text-sm">No audit records found</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table v-else class="w-full text-sm">
|
||||||
|
<thead class="bg-slate-50 border-b border-slate-100">
|
||||||
|
<tr class="text-left text-xs text-slate-500 font-semibold uppercase tracking-wide">
|
||||||
|
<th class="px-5 py-3">Time</th>
|
||||||
|
<th class="px-5 py-3">Actor</th>
|
||||||
|
<th class="px-5 py-3">Action</th>
|
||||||
|
<th class="px-5 py-3">Entity</th>
|
||||||
|
<th class="px-5 py-3">IP</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-slate-50">
|
||||||
|
<tr v-for="log in logs" :key="log.id" class="hover:bg-slate-50">
|
||||||
|
<td class="px-5 py-3 text-xs text-slate-500 whitespace-nowrap">
|
||||||
|
{{ new Date(log.created_at).toLocaleString('en-PH') }}
|
||||||
|
</td>
|
||||||
|
<td class="px-5 py-3 text-xs text-slate-700">{{ log.actor_email || '—' }}</td>
|
||||||
|
<td class="px-5 py-3">
|
||||||
|
<span class="font-mono text-xs bg-slate-100 text-slate-700 px-2 py-0.5 rounded">{{ log.action }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-5 py-3 text-xs text-slate-500">
|
||||||
|
<span v-if="log.entity_type" class="capitalize">{{ log.entity_type }}</span>
|
||||||
|
<span v-if="log.entity_id" class="font-mono text-xs ml-1 text-slate-400">{{ log.entity_id.slice(0,8) }}…</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-5 py-3 text-xs font-mono text-slate-400">{{ log.ip_address || '—' }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div v-if="total > perPage" class="px-5 py-3 border-t border-slate-100 flex items-center justify-between text-sm text-slate-600">
|
||||||
|
<span class="text-xs text-slate-400">Showing {{ (page-1)*perPage+1 }}–{{ Math.min(page*perPage,total) }} of {{ total }}</span>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button :disabled="page<=1" @click="page--;fetchLogs()" class="px-3 py-1.5 border border-slate-200 rounded-lg text-sm hover:bg-slate-50 disabled:opacity-40">Prev</button>
|
||||||
|
<button :disabled="page*perPage>=total" @click="page++;fetchLogs()" class="px-3 py-1.5 border border-slate-200 rounded-lg text-sm hover:bg-slate-50 disabled:opacity-40">Next</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch, onMounted } from 'vue'
|
||||||
|
import { Shield } from 'lucide-vue-next'
|
||||||
|
import { getAuditLogs } from '@/lib/api'
|
||||||
|
|
||||||
|
const logs = ref<any[]>([])
|
||||||
|
const total = ref(0)
|
||||||
|
const page = ref(1)
|
||||||
|
const perPage = 50
|
||||||
|
const loading = ref(false)
|
||||||
|
const actionFilter = ref('')
|
||||||
|
|
||||||
|
let debounce: any = null
|
||||||
|
watch(actionFilter, () => {
|
||||||
|
clearTimeout(debounce)
|
||||||
|
debounce = setTimeout(() => { page.value = 1; fetchLogs() }, 300)
|
||||||
|
})
|
||||||
|
|
||||||
|
async function fetchLogs() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const r = await getAuditLogs({ page: page.value, per_page: perPage, action: actionFilter.value || undefined })
|
||||||
|
logs.value = r.items; total.value = r.total
|
||||||
|
} finally { loading.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(fetchLogs)
|
||||||
|
</script>
|
||||||
@@ -1,24 +1,52 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="space-y-6">
|
<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>
|
<h1 class="text-2xl font-bold text-slate-900">Billing</h1>
|
||||||
<button @click="showCreate = true"
|
<div class="flex items-center gap-3">
|
||||||
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">
|
<button @click="triggerInvoices" :disabled="triggering"
|
||||||
<Plus :size="16" /> New Invoice
|
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>
|
</button>
|
||||||
</div>
|
</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">
|
<!-- Invoice table -->
|
||||||
<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>
|
|
||||||
<div class="bg-white rounded-xl overflow-hidden" style="box-shadow:0 2px 8px #0000000A">
|
<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-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">
|
<table v-else class="w-full text-sm">
|
||||||
<thead class="bg-slate-50 border-b border-slate-100">
|
<thead class="bg-slate-50 border-b border-slate-100">
|
||||||
<tr class="text-left text-xs text-slate-500 font-semibold uppercase tracking-wide">
|
<tr class="text-left text-xs text-slate-500 font-semibold uppercase tracking-wide">
|
||||||
@@ -32,40 +60,151 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="divide-y divide-slate-50">
|
<tbody class="divide-y divide-slate-50">
|
||||||
<tr v-for="inv in invoices" :key="inv.id" class="hover:bg-slate-50">
|
<tr v-for="inv in invoices" :key="inv.id"
|
||||||
<td class="px-5 py-3 font-mono text-xs font-semibold">{{ inv.invoice_number }}</td>
|
class="hover:bg-slate-50 transition-colors"
|
||||||
<td class="px-5 py-3 text-xs text-slate-600">{{ inv.school_id }}</td>
|
:class="inv.status === 'overdue' ? 'bg-red-50/30' : ''">
|
||||||
<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-mono text-xs font-semibold text-blue-600">{{ inv.invoice_number }}</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>
|
|
||||||
<td class="px-5 py-3">
|
<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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</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>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, watch, onMounted } from 'vue'
|
import { ref, watch, onMounted } from 'vue'
|
||||||
import { Plus } from 'lucide-vue-next'
|
import { Plus, RefreshCw, Download, Mail, Receipt } from 'lucide-vue-next'
|
||||||
import { getInvoices, sendInvoiceEmail } from '@/lib/api'
|
import {
|
||||||
|
getInvoices, sendInvoiceEmail, markInvoicePaid,
|
||||||
|
downloadInvoicePdf, triggerGenerateInvoices,
|
||||||
|
} from '@/lib/api'
|
||||||
import StatusBadge from '@/components/ui/StatusBadge.vue'
|
import StatusBadge from '@/components/ui/StatusBadge.vue'
|
||||||
import { useToast } from '@/composables/useToast'
|
import { useToast } from '@/composables/useToast'
|
||||||
|
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const invoices = ref<any[]>([])
|
|
||||||
const loading = ref(false)
|
const invoices = ref<any[]>([])
|
||||||
const statusFilter = ref('')
|
const total = ref(0)
|
||||||
const showCreate = ref(false)
|
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() {
|
async function fetchInvoices() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try { const r = await getInvoices({ status: statusFilter.value || undefined }); invoices.value = r.items }
|
try {
|
||||||
finally { loading.value = false }
|
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) {
|
async function sendEmail(id: string) {
|
||||||
@@ -73,6 +212,48 @@ async function sendEmail(id: string) {
|
|||||||
catch { toast.error('Failed to send email') }
|
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)
|
onMounted(fetchInvoices)
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,57 +1,395 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<div>
|
|
||||||
<h1 class="text-2xl font-bold text-slate-900">Dashboard</h1>
|
<!-- Header + refresh -->
|
||||||
<p class="text-sm text-slate-500 mt-0.5">{{ today }}</p>
|
<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>
|
</div>
|
||||||
|
|
||||||
<!-- KPI Cards -->
|
<!-- KPI Row -->
|
||||||
<div v-if="loading" class="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-5 gap-4 animate-pulse">
|
<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 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>
|
||||||
<div v-else class="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-5 gap-4">
|
<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="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="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="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="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="SMS Today" :value="summary?.sms?.sent_today ?? 0" icon="MessageSquare" color="purple" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Secondary row -->
|
<!-- Row 2: SMS chart + Revenue snapshot -->
|
||||||
<div class="grid grid-cols-1 xl:grid-cols-3 gap-6">
|
<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">
|
<!-- SMS Volume chart (30d) -->
|
||||||
<h2 class="text-base font-semibold text-slate-900 mb-1">Billing</h2>
|
<div class="xl:col-span-2 bg-white rounded-xl p-6" style="box-shadow:0 2px 8px #0000000A">
|
||||||
<p class="text-3xl font-bold text-slate-900">{{ summary?.invoices?.pending ?? 0 }}</p>
|
<h2 class="text-base font-semibold text-slate-900 mb-4">SMS Volume — Last 30 Days</h2>
|
||||||
<p class="text-sm text-slate-500 mt-1">Pending invoices</p>
|
<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>
|
</div>
|
||||||
<!-- SMS Queue -->
|
|
||||||
<div class="bg-white rounded-xl p-6" style="box-shadow:0 2px 8px #0000000A">
|
<!-- Revenue snapshot -->
|
||||||
<h2 class="text-base font-semibold text-slate-900 mb-1">SMS Queue</h2>
|
<div class="bg-white rounded-xl p-6 flex flex-col gap-4" style="box-shadow:0 2px 8px #0000000A">
|
||||||
<p class="text-3xl font-bold text-slate-900">{{ summary?.sms?.pending ?? 0 }}</p>
|
<h2 class="text-base font-semibold text-slate-900">Revenue</h2>
|
||||||
<p class="text-sm text-slate-500 mt-1">Jobs awaiting dispatch</p>
|
<div v-if="revenueLoading" class="space-y-3 animate-pulse">
|
||||||
</div>
|
<div v-for="i in 4" :key="i" class="h-10 bg-slate-50 rounded-lg"></div>
|
||||||
<!-- Suspended schools -->
|
</div>
|
||||||
<div class="bg-white rounded-xl p-6" style="box-shadow:0 2px 8px #0000000A">
|
<template v-else>
|
||||||
<h2 class="text-base font-semibold text-slate-900 mb-1">Suspended</h2>
|
<div class="flex items-center justify-between">
|
||||||
<p class="text-3xl font-bold text-red-500">{{ summary?.schools?.suspended ?? 0 }}</p>
|
<div>
|
||||||
<p class="text-sm text-slate-500 mt-1">Schools suspended</p>
|
<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>
|
||||||
</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 & 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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, computed, watch, onMounted } from 'vue'
|
||||||
import { getDashboardSummary } from '@/lib/api'
|
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 KpiCard from '@/components/ui/KpiCard.vue'
|
||||||
|
import StatusBadge from '@/components/ui/StatusBadge.vue'
|
||||||
|
|
||||||
const summary = ref<any>(null)
|
const router = useRouter()
|
||||||
const loading = ref(true)
|
|
||||||
const today = new Date().toLocaleDateString('en-PH', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })
|
|
||||||
|
|
||||||
onMounted(async () => {
|
// ── State ─────────────────────────────────────────────────────────────────────
|
||||||
try { summary.value = await getDashboardSummary() }
|
const summary = ref<any>(null)
|
||||||
finally { loading.value = false }
|
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>
|
</script>
|
||||||
|
|||||||
226
frontend/src/pages/DemoRequestsPage.vue
Normal file
226
frontend/src/pages/DemoRequestsPage.vue
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
<template>
|
||||||
|
<div class="space-y-6">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold text-slate-900">Demo Requests</h1>
|
||||||
|
<p class="text-sm text-slate-500 mt-0.5">Inbound requests from the TapTrack website</p>
|
||||||
|
</div>
|
||||||
|
<!-- Status filter -->
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<select
|
||||||
|
v-model="filterStatus"
|
||||||
|
class="text-sm border border-slate-200 rounded-lg px-3 py-2 bg-white text-slate-700 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
>
|
||||||
|
<option value="">All statuses</option>
|
||||||
|
<option value="new">New</option>
|
||||||
|
<option value="contacted">Contacted</option>
|
||||||
|
<option value="converted">Converted</option>
|
||||||
|
<option value="dismissed">Dismissed</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Stats row -->
|
||||||
|
<div class="grid grid-cols-4 gap-4">
|
||||||
|
<div
|
||||||
|
v-for="s in statCards"
|
||||||
|
:key="s.label"
|
||||||
|
class="bg-white rounded-xl p-4 flex flex-col gap-1"
|
||||||
|
style="box-shadow:0 2px 8px #0000000A"
|
||||||
|
>
|
||||||
|
<span class="text-xs text-slate-500 font-medium uppercase tracking-wide">{{ s.label }}</span>
|
||||||
|
<span class="text-2xl font-bold" :class="s.color">{{ s.count }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Table -->
|
||||||
|
<div class="bg-white rounded-xl overflow-hidden" style="box-shadow:0 2px 8px #0000000A">
|
||||||
|
<div v-if="loading" class="animate-pulse h-64 bg-slate-50"></div>
|
||||||
|
|
||||||
|
<table v-else class="w-full text-sm">
|
||||||
|
<thead class="bg-slate-50 border-b border-slate-100">
|
||||||
|
<tr>
|
||||||
|
<th class="text-left px-5 py-3 text-xs font-semibold text-slate-500 uppercase tracking-wide">School</th>
|
||||||
|
<th class="text-left px-5 py-3 text-xs font-semibold text-slate-500 uppercase tracking-wide">Contact</th>
|
||||||
|
<th class="text-left px-5 py-3 text-xs font-semibold text-slate-500 uppercase tracking-wide">Phone / Email</th>
|
||||||
|
<th class="text-left px-5 py-3 text-xs font-semibold text-slate-500 uppercase tracking-wide">Status</th>
|
||||||
|
<th class="text-left px-5 py-3 text-xs font-semibold text-slate-500 uppercase tracking-wide">Submitted</th>
|
||||||
|
<th class="px-5 py-3"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-slate-50">
|
||||||
|
<tr
|
||||||
|
v-for="r in filtered"
|
||||||
|
:key="r.id"
|
||||||
|
class="hover:bg-slate-50 transition-colors"
|
||||||
|
>
|
||||||
|
<td class="px-5 py-3.5 font-medium text-slate-900 max-w-[200px] truncate">{{ r.school_name }}</td>
|
||||||
|
<td class="px-5 py-3.5 text-slate-700">{{ r.contact_person }}</td>
|
||||||
|
<td class="px-5 py-3.5 text-slate-600">{{ r.phone_or_email }}</td>
|
||||||
|
<td class="px-5 py-3.5">
|
||||||
|
<select
|
||||||
|
:value="r.status"
|
||||||
|
class="text-xs border rounded-md px-2 py-1 focus:outline-none focus:ring-1 focus:ring-blue-500 cursor-pointer"
|
||||||
|
:class="statusClass(r.status)"
|
||||||
|
@change="changeStatus(r, ($event.target as HTMLSelectElement).value)"
|
||||||
|
>
|
||||||
|
<option value="new">New</option>
|
||||||
|
<option value="contacted">Contacted</option>
|
||||||
|
<option value="converted">Converted</option>
|
||||||
|
<option value="dismissed">Dismissed</option>
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td class="px-5 py-3.5 text-slate-500 text-xs whitespace-nowrap">
|
||||||
|
{{ formatDate(r.submitted_at) }}
|
||||||
|
</td>
|
||||||
|
<td class="px-5 py-3.5 text-right">
|
||||||
|
<button
|
||||||
|
@click="openNotes(r)"
|
||||||
|
title="Notes"
|
||||||
|
class="text-slate-400 hover:text-blue-600 transition-colors mr-3"
|
||||||
|
>
|
||||||
|
<MessageSquare :size="15" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="confirmDelete(r)"
|
||||||
|
title="Delete"
|
||||||
|
class="text-slate-400 hover:text-red-500 transition-colors"
|
||||||
|
>
|
||||||
|
<Trash2 :size="15" />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="filtered.length === 0">
|
||||||
|
<td colspan="6" class="px-5 py-12 text-center text-slate-400">No demo requests found.</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Notes modal -->
|
||||||
|
<div
|
||||||
|
v-if="notesModal"
|
||||||
|
class="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4"
|
||||||
|
@click.self="notesModal = null"
|
||||||
|
>
|
||||||
|
<div class="bg-white rounded-2xl w-full max-w-md p-6 space-y-4 shadow-2xl">
|
||||||
|
<h2 class="text-lg font-bold text-slate-900">Notes — {{ notesModal.school_name }}</h2>
|
||||||
|
<textarea
|
||||||
|
v-model="notesModal.notes"
|
||||||
|
rows="5"
|
||||||
|
placeholder="Add internal notes about this lead…"
|
||||||
|
class="w-full border border-slate-200 rounded-lg px-3 py-2.5 text-sm text-slate-700 focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none"
|
||||||
|
></textarea>
|
||||||
|
<div class="flex justify-end gap-3">
|
||||||
|
<button @click="notesModal = null" class="px-4 py-2 text-sm text-slate-600 hover:text-slate-900">Cancel</button>
|
||||||
|
<button
|
||||||
|
@click="saveNotes"
|
||||||
|
class="px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700"
|
||||||
|
>Save Notes</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { MessageSquare, Trash2 } from 'lucide-vue-next'
|
||||||
|
import { getDemoRequests, updateDemoRequest, deleteDemoRequest } from '@/lib/api'
|
||||||
|
import { useToast } from '@/composables/useToast'
|
||||||
|
|
||||||
|
const toast = useToast()
|
||||||
|
|
||||||
|
interface DemoRequest {
|
||||||
|
id: string
|
||||||
|
school_name: string
|
||||||
|
contact_person: string
|
||||||
|
phone_or_email: string
|
||||||
|
status: string
|
||||||
|
notes: string | null
|
||||||
|
submitted_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const requests = ref<DemoRequest[]>([])
|
||||||
|
const loading = ref(true)
|
||||||
|
const filterStatus = ref('')
|
||||||
|
const notesModal = ref<DemoRequest | null>(null)
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
requests.value = await getDemoRequests()
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const filtered = computed(() => {
|
||||||
|
if (!filterStatus.value) return requests.value
|
||||||
|
return requests.value.filter(r => r.status === filterStatus.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
const statCards = computed(() => [
|
||||||
|
{ label: 'Total', count: requests.value.length, color: 'text-slate-800' },
|
||||||
|
{ label: 'New', count: requests.value.filter(r => r.status === 'new').length, color: 'text-blue-600' },
|
||||||
|
{ label: 'Contacted', count: requests.value.filter(r => r.status === 'contacted').length, color: 'text-yellow-600' },
|
||||||
|
{ label: 'Converted', count: requests.value.filter(r => r.status === 'converted').length, color: 'text-green-600' },
|
||||||
|
])
|
||||||
|
|
||||||
|
function statusClass(status: string) {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
new: 'border-blue-200 bg-blue-50 text-blue-700',
|
||||||
|
contacted: 'border-yellow-200 bg-yellow-50 text-yellow-700',
|
||||||
|
converted: 'border-green-200 bg-green-50 text-green-700',
|
||||||
|
dismissed: 'border-slate-200 bg-slate-50 text-slate-500',
|
||||||
|
}
|
||||||
|
return map[status] ?? 'border-slate-200 bg-slate-50 text-slate-600'
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(iso: string) {
|
||||||
|
return new Date(iso).toLocaleDateString('en-PH', {
|
||||||
|
month: 'short', day: 'numeric', year: 'numeric',
|
||||||
|
hour: '2-digit', minute: '2-digit',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function changeStatus(req: DemoRequest, newStatus: string) {
|
||||||
|
try {
|
||||||
|
await updateDemoRequest(req.id, { status: newStatus })
|
||||||
|
req.status = newStatus
|
||||||
|
toast.success('Status updated')
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to update status')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openNotes(req: DemoRequest) {
|
||||||
|
// Clone so cancel doesn't mutate
|
||||||
|
notesModal.value = { ...req, notes: req.notes ?? '' }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveNotes() {
|
||||||
|
if (!notesModal.value) return
|
||||||
|
try {
|
||||||
|
await updateDemoRequest(notesModal.value.id, { notes: notesModal.value.notes ?? '' })
|
||||||
|
const target = requests.value.find(r => r.id === notesModal.value!.id)
|
||||||
|
if (target) target.notes = notesModal.value.notes
|
||||||
|
toast.success('Notes saved')
|
||||||
|
notesModal.value = null
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to save notes')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmDelete(req: DemoRequest) {
|
||||||
|
if (!confirm(`Delete demo request from "${req.school_name}"?`)) return
|
||||||
|
try {
|
||||||
|
await deleteDemoRequest(req.id)
|
||||||
|
requests.value = requests.value.filter(r => r.id !== req.id)
|
||||||
|
toast.success('Deleted')
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to delete')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
220
frontend/src/pages/EmailLogsPage.vue
Normal file
220
frontend/src/pages/EmailLogsPage.vue
Normal 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>
|
||||||
@@ -1,20 +1,80 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="space-y-6 max-w-3xl">
|
<div class="space-y-6 max-w-3xl">
|
||||||
<div class="flex items-center gap-3">
|
<!-- Header -->
|
||||||
<button @click="$router.back()" class="text-slate-400 hover:text-slate-700"><ArrowLeft :size="20" /></button>
|
<div class="flex items-center gap-3 flex-wrap">
|
||||||
<div>
|
<button @click="$router.back()" class="text-slate-400 hover:text-slate-700 shrink-0">
|
||||||
<h1 class="text-xl font-bold text-slate-900">{{ ticket?.subject ?? '…' }}</h1>
|
<ArrowLeft :size="20" />
|
||||||
<p class="text-xs text-slate-400 mt-0.5">{{ ticket?.ticket_number }}</p>
|
</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>
|
</div>
|
||||||
<StatusBadge v-if="ticket" :status="ticket.status" class="ml-auto" />
|
<StatusBadge v-if="ticket" :status="ticket.status" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="loading" class="animate-pulse h-40 bg-white rounded-xl"></div>
|
<div v-if="loading" class="animate-pulse h-40 bg-white rounded-xl"></div>
|
||||||
<template v-else-if="ticket">
|
<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 -->
|
<!-- Original message -->
|
||||||
<div class="bg-white rounded-xl p-6" style="box-shadow:0 2px 8px #0000000A">
|
<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-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>
|
</div>
|
||||||
|
|
||||||
<!-- Replies -->
|
<!-- Replies -->
|
||||||
@@ -23,21 +83,26 @@
|
|||||||
:class="reply.is_internal ? 'bg-amber-50 border border-amber-200' : 'bg-white'"
|
:class="reply.is_internal ? 'bg-amber-50 border border-amber-200' : 'bg-white'"
|
||||||
style="box-shadow:0 2px 8px #0000000A">
|
style="box-shadow:0 2px 8px #0000000A">
|
||||||
<div class="flex items-center justify-between mb-2">
|
<div class="flex items-center justify-between mb-2">
|
||||||
<span class="font-semibold text-slate-800 text-xs">{{ reply.author_id }}</span>
|
<span class="font-semibold text-slate-800 text-xs">{{ reply.author_id === authStore.userId ? 'You' : (reply.is_internal ? 'Support (Internal)' : 'Support') }}</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>
|
<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>
|
</div>
|
||||||
<p class="text-slate-700 whitespace-pre-wrap">{{ reply.body }}</p>
|
<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>
|
</div>
|
||||||
|
|
||||||
<!-- Reply form -->
|
<!-- 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>
|
<h3 class="text-sm font-semibold text-slate-900 mb-3">Add Reply</h3>
|
||||||
<textarea v-model="replyBody" rows="4" placeholder="Type your reply…"
|
<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>
|
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">
|
<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">
|
<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>
|
</label>
|
||||||
<button @click="submitReply" :disabled="!replyBody.trim() || replying"
|
<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">
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, computed } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import { ArrowLeft } from 'lucide-vue-next'
|
import { ArrowLeft, Lock } from 'lucide-vue-next'
|
||||||
import { getTicket, addTicketReply } from '@/lib/api'
|
import { getTicket, addTicketReply, updateTicket, getUsers } from '@/lib/api'
|
||||||
import StatusBadge from '@/components/ui/StatusBadge.vue'
|
import StatusBadge from '@/components/ui/StatusBadge.vue'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { useToast } from '@/composables/useToast'
|
import { useToast } from '@/composables/useToast'
|
||||||
@@ -62,15 +131,40 @@ const route = useRoute()
|
|||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const isSuperAdmin = computed(() => authStore.isSuperAdmin)
|
const isSuperAdmin = computed(() => authStore.isSuperAdmin)
|
||||||
|
|
||||||
const ticket = ref<any>(null)
|
const ticket = ref<any>(null)
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const replyBody = ref('')
|
const replyBody = ref('')
|
||||||
const isInternal = ref(false)
|
const isInternal = ref(false)
|
||||||
const replying = 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() {
|
async function loadTicket() {
|
||||||
try { ticket.value = await getTicket(route.params.id as string) }
|
loading.value = true
|
||||||
finally { loading.value = false }
|
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() {
|
async function submitReply() {
|
||||||
@@ -78,11 +172,27 @@ async function submitReply() {
|
|||||||
try {
|
try {
|
||||||
await addTicketReply(ticket.value.id, { body: replyBody.value, is_internal: isInternal.value })
|
await addTicketReply(ticket.value.id, { body: replyBody.value, is_internal: isInternal.value })
|
||||||
replyBody.value = ''
|
replyBody.value = ''
|
||||||
|
isInternal.value = false
|
||||||
toast.success('Reply sent')
|
toast.success('Reply sent')
|
||||||
await loadTicket()
|
await loadTicket()
|
||||||
} catch { toast.error('Failed to send reply') }
|
} catch { toast.error('Failed to send reply') }
|
||||||
finally { replying.value = false }
|
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>
|
</script>
|
||||||
|
|||||||
@@ -1,74 +1,157 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="space-y-6">
|
<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>
|
<h1 class="text-2xl font-bold text-slate-900">Support Tickets</h1>
|
||||||
<button v-if="!isSuperAdmin" @click="showCreate = true"
|
<div class="flex items-center gap-3">
|
||||||
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">
|
<button v-if="isSuperAdmin && selectedIds.length > 0"
|
||||||
<Plus :size="16" /> New Ticket
|
@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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-3">
|
|
||||||
<select v-model="statusFilter" class="border border-slate-200 rounded-lg px-3 py-2 text-sm">
|
<!-- Table -->
|
||||||
<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>
|
|
||||||
<div class="bg-white rounded-xl overflow-hidden" style="box-shadow:0 2px 8px #0000000A">
|
<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-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">
|
<table v-else class="w-full text-sm">
|
||||||
<thead class="bg-slate-50 border-b border-slate-100">
|
<thead class="bg-slate-50 border-b border-slate-100">
|
||||||
<tr class="text-left text-xs text-slate-500 font-semibold uppercase tracking-wide">
|
<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">Ticket #</th>
|
||||||
<th class="px-5 py-3">Subject</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">Status</th>
|
||||||
<th class="px-5 py-3">Priority</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>
|
<th class="px-5 py-3">Created</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="divide-y divide-slate-50">
|
<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-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 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"><StatusBadge :status="t.status" /></td>
|
||||||
<td class="px-5 py-3">
|
<td class="px-5 py-3">
|
||||||
<span class="text-xs font-semibold capitalize px-2 py-0.5 rounded-full"
|
<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'">
|
:class="priorityClass(t.priority)">{{ t.priority }}</span>
|
||||||
{{ t.priority }}
|
</td>
|
||||||
</span>
|
<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>
|
||||||
<td class="px-5 py-3 text-xs text-slate-500">{{ new Date(t.created_at).toLocaleDateString() }}</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, watch, onMounted, computed } from 'vue'
|
import { ref, computed, watch, onMounted } from 'vue'
|
||||||
import { Plus } from 'lucide-vue-next'
|
import { X, Ticket } from 'lucide-vue-next'
|
||||||
import { getTickets } from '@/lib/api'
|
import { getTickets, bulkCloseTickets } from '@/lib/api'
|
||||||
import StatusBadge from '@/components/ui/StatusBadge.vue'
|
import StatusBadge from '@/components/ui/StatusBadge.vue'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import { useToast } from '@/composables/useToast'
|
||||||
|
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
|
const toast = useToast()
|
||||||
const isSuperAdmin = computed(() => authStore.isSuperAdmin)
|
const isSuperAdmin = computed(() => authStore.isSuperAdmin)
|
||||||
|
|
||||||
const tickets = ref<any[]>([])
|
const tickets = ref<any[]>([])
|
||||||
|
const total = ref(0)
|
||||||
|
const page = ref(1)
|
||||||
|
const perPage = 25
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
const closing = ref(false)
|
||||||
const statusFilter = ref('')
|
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() {
|
async function fetchTickets() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try { const r = await getTickets({ status: statusFilter.value || undefined }); tickets.value = r.items }
|
selectedIds.value = []
|
||||||
finally { loading.value = false }
|
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)
|
onMounted(fetchTickets)
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,38 +1,188 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="space-y-6">
|
<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="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-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">
|
<table v-else class="w-full text-sm">
|
||||||
<thead class="bg-slate-50 border-b border-slate-100">
|
<thead class="bg-slate-50 border-b border-slate-100">
|
||||||
<tr class="text-left text-xs text-slate-500 font-semibold uppercase tracking-wide">
|
<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">Invoice #</th>
|
||||||
<th class="px-5 py-3">Period</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">Status</th>
|
||||||
<th class="px-5 py-3">Due Date</th>
|
<th class="px-5 py-3">Due</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="divide-y divide-slate-50">
|
<tbody class="divide-y divide-slate-50">
|
||||||
<tr v-for="inv in invoices" :key="inv.id" class="hover:bg-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 font-mono text-xs font-semibold text-blue-600">{{ 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 text-xs text-slate-500">
|
||||||
<td class="px-5 py-3 font-semibold">PHP {{ Number(inv.total_amount).toLocaleString() }}</td>
|
{{ 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"><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>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</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>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
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'
|
import StatusBadge from '@/components/ui/StatusBadge.vue'
|
||||||
const invoices = ref<any[]>([])
|
import { useToast } from '@/composables/useToast'
|
||||||
const loading = ref(true)
|
|
||||||
onMounted(async () => { try { const r = await getInvoices(); invoices.value = r.items } finally { loading.value = false } })
|
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>
|
</script>
|
||||||
|
|||||||
@@ -1,49 +1,229 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="space-y-6">
|
<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>
|
<div>
|
||||||
<h1 class="text-2xl font-bold text-slate-900">Overview</h1>
|
<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>
|
<p class="text-sm text-slate-500 mt-0.5">{{ data?.school?.name }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- KPI Cards -->
|
||||||
<div v-if="loading" class="grid grid-cols-2 md:grid-cols-4 gap-4 animate-pulse">
|
<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 v-for="i in 4" :key="i" class="bg-white rounded-xl h-24" style="box-shadow:0 2px 8px #0000000A"></div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
<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 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="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="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="Pending Invoices" :value="data?.pending_invoices ?? 0" icon="Receipt" color="red" />
|
||||||
</div>
|
</div>
|
||||||
<!-- License info -->
|
|
||||||
<div v-if="data?.license" class="bg-white rounded-xl p-6" style="box-shadow:0 2px 8px #0000000A">
|
<!-- Credit meter + License status -->
|
||||||
<h2 class="text-base font-semibold text-slate-900 mb-4">License Status</h2>
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
<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>
|
<!-- Credit meter -->
|
||||||
<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 class="bg-white rounded-xl p-6" style="box-shadow:0 2px 8px #0000000A">
|
||||||
<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 class="flex items-center justify-between mb-3">
|
||||||
<div><p class="text-slate-500">License Key</p><p class="font-mono text-xs truncate">{{ data.license.key }}</p></div>
|
<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>
|
||||||
</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 -->
|
<!-- Announcements -->
|
||||||
<div v-if="announcements.length > 0" class="bg-blue-50 border border-blue-200 rounded-xl p-5">
|
<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>
|
<h3 class="text-sm font-semibold text-blue-800 mb-3">Announcements</h3>
|
||||||
<div v-for="a in announcements" :key="a.id" class="mb-2">
|
<div v-for="a in announcements" :key="a.id" class="mb-3 last:mb-0">
|
||||||
<p class="text-sm font-medium text-blue-900">{{ a.title }}</p>
|
<p class="text-sm font-semibold text-blue-900">{{ a.title }}</p>
|
||||||
<p class="text-xs text-blue-700">{{ a.body }}</p>
|
<p class="text-sm text-blue-700 mt-0.5">{{ a.body }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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 { getPortalOverview, getAnnouncements } from '@/lib/api'
|
||||||
import KpiCard from '@/components/ui/KpiCard.vue'
|
import KpiCard from '@/components/ui/KpiCard.vue'
|
||||||
import StatusBadge from '@/components/ui/StatusBadge.vue'
|
import StatusBadge from '@/components/ui/StatusBadge.vue'
|
||||||
const data = ref<any>(null)
|
|
||||||
|
const data = ref<any>(null)
|
||||||
const announcements = ref<any[]>([])
|
const announcements = ref<any[]>([])
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
try { [data.value, announcements.value] = await Promise.all([getPortalOverview(), getAnnouncements()]) }
|
try {
|
||||||
finally { loading.value = false }
|
[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>
|
</script>
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
<template>
|
<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>
|
<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="bg-white rounded-xl p-6" style="box-shadow:0 2px 8px #0000000A">
|
||||||
<div class="flex items-center gap-4 mb-6">
|
<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 }}
|
{{ initials }}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -11,42 +13,96 @@
|
|||||||
<p class="text-sm text-slate-500">School Admin</p>
|
<p class="text-sm text-slate-500">School Admin</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h2 class="text-sm font-semibold text-slate-900 mb-4">Change Password</h2>
|
<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>
|
<div>
|
||||||
<label class="block text-sm font-medium text-slate-700 mb-1">Current Password</label>
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-slate-700 mb-1">New Password</label>
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-slate-700 mb-1">Confirm New Password</label>
|
<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>
|
</div>
|
||||||
<p v-if="pwError" class="text-sm text-red-600">{{ pwError }}</p>
|
<p v-if="pwError" class="text-sm text-red-600">{{ pwError }}</p>
|
||||||
<button type="submit" :disabled="saving || !pwForm.current || !pwForm.newPw || !pwForm.confirm"
|
<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' }}
|
{{ saving ? 'Saving…' : 'Update Password' }}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { changePassword } from '@/lib/api'
|
import { changePassword, getPortalOverview } from '@/lib/api'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { useToast } from '@/composables/useToast'
|
import { useToast } from '@/composables/useToast'
|
||||||
|
import StatusBadge from '@/components/ui/StatusBadge.vue'
|
||||||
|
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
const toast = useToast()
|
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 initials = computed(() =>
|
||||||
|
(authStore.fullName ?? '').split(' ').map(n => n[0]).join('').slice(0, 2).toUpperCase()
|
||||||
|
)
|
||||||
|
|
||||||
|
const pwForm = ref({ current: '', newPw: '', confirm: '' })
|
||||||
const pwError = ref('')
|
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() {
|
async function submitPw() {
|
||||||
pwError.value = ''
|
pwError.value = ''
|
||||||
|
|||||||
@@ -1,9 +1,133 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="space-y-6">
|
<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 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 class="flex items-center gap-3 px-5 py-4 border-b border-slate-100 flex-wrap">
|
||||||
<div v-else-if="jobs.length === 0" class="p-12 text-center text-slate-400">No SMS records</div>
|
<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">
|
<table v-else class="w-full text-sm">
|
||||||
<thead class="bg-slate-50 border-b border-slate-100">
|
<thead class="bg-slate-50 border-b border-slate-100">
|
||||||
<tr class="text-left text-xs text-slate-500 font-semibold uppercase tracking-wide">
|
<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">
|
<tbody class="divide-y divide-slate-50">
|
||||||
<tr v-for="j in jobs" :key="j.id" class="hover:bg-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 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"><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 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>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</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>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, computed, watch, onMounted } from 'vue'
|
||||||
import { getSmsJobs } from '@/lib/api'
|
import { RouterLink } from 'vue-router'
|
||||||
|
import { MessageSquare } from 'lucide-vue-next'
|
||||||
|
import { getPortalSmsStats, getSmsJobs } from '@/lib/api'
|
||||||
import StatusBadge from '@/components/ui/StatusBadge.vue'
|
import StatusBadge from '@/components/ui/StatusBadge.vue'
|
||||||
const jobs = ref<any[]>([])
|
|
||||||
const loading = ref(true)
|
const stats = ref<any>(null)
|
||||||
onMounted(async () => { try { const r = await getSmsJobs(); jobs.value = r.items } finally { loading.value = false } })
|
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>
|
</script>
|
||||||
|
|||||||
@@ -1,23 +1,29 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="space-y-6">
|
<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>
|
<h1 class="text-2xl font-bold text-slate-900">Support Tickets</h1>
|
||||||
<button @click="showCreate = true"
|
<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">
|
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
|
<Plus :size="16" />
|
||||||
|
New Ticket
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Create form -->
|
<!-- Create form -->
|
||||||
<div v-if="showCreate" class="bg-white rounded-xl p-6" style="box-shadow:0 2px 8px #0000000A">
|
<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>
|
<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>
|
<div>
|
||||||
<label class="block text-sm font-medium text-slate-700 mb-1">Subject</label>
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-slate-700 mb-1">Category</label>
|
<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="general">General</option>
|
||||||
<option value="billing">Billing</option>
|
<option value="billing">Billing</option>
|
||||||
<option value="technical">Technical</option>
|
<option value="technical">Technical</option>
|
||||||
@@ -27,10 +33,15 @@
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-slate-700 mb-1">Message</label>
|
<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>
|
||||||
<div class="flex gap-3">
|
<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"
|
<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">
|
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' }}
|
{{ submitting ? 'Submitting…' : 'Submit Ticket' }}
|
||||||
@@ -38,10 +49,37 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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 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-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">
|
<table v-else class="w-full text-sm">
|
||||||
<thead class="bg-slate-50 border-b border-slate-100">
|
<thead class="bg-slate-50 border-b border-slate-100">
|
||||||
<tr class="text-left text-xs text-slate-500 font-semibold uppercase tracking-wide">
|
<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">Subject</th>
|
||||||
<th class="px-5 py-3">Category</th>
|
<th class="px-5 py-3">Category</th>
|
||||||
<th class="px-5 py-3">Status</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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="divide-y divide-slate-50">
|
<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-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 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 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"><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>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</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>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
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 { getTickets, createTicket } from '@/lib/api'
|
||||||
import StatusBadge from '@/components/ui/StatusBadge.vue'
|
import StatusBadge from '@/components/ui/StatusBadge.vue'
|
||||||
import { useToast } from '@/composables/useToast'
|
import { useToast } from '@/composables/useToast'
|
||||||
|
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const tickets = ref<any[]>([])
|
const tickets = ref<any[]>([])
|
||||||
const loading = ref(false)
|
const total = ref(0)
|
||||||
const showCreate = ref(false)
|
const page = ref(1)
|
||||||
const submitting = ref(false)
|
const perPage = 20
|
||||||
const form = ref({ subject: '', body: '', category: 'general' })
|
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() {
|
async function fetchTickets() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try { const r = await getTickets(); tickets.value = r.items }
|
try {
|
||||||
finally { loading.value = false }
|
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() {
|
async function submitTicket() {
|
||||||
submitting.value = true
|
submitting.value = true
|
||||||
try {
|
try {
|
||||||
await createTicket(form.value)
|
await createTicket(form.value)
|
||||||
toast.success('Ticket submitted')
|
toast.success('Ticket submitted successfully')
|
||||||
showCreate.value = false
|
showCreate.value = false
|
||||||
form.value = { subject: '', body: '', category: 'general' }
|
form.value = { subject: '', body: '', category: 'general' }
|
||||||
|
page.value = 1
|
||||||
|
statusFilter.value = ''
|
||||||
await fetchTickets()
|
await fetchTickets()
|
||||||
} catch { toast.error('Failed to submit ticket') }
|
} catch { toast.error('Failed to submit ticket') }
|
||||||
finally { submitting.value = false }
|
finally { submitting.value = false }
|
||||||
|
|||||||
@@ -71,6 +71,24 @@ const router = createRouter({
|
|||||||
component: () => import('@/pages/AnnouncementsPage.vue'),
|
component: () => import('@/pages/AnnouncementsPage.vue'),
|
||||||
meta: { requiresAuth: true, layout: 'app', role: 'super_admin' },
|
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
|
// School Portal routes
|
||||||
{
|
{
|
||||||
path: '/portal',
|
path: '/portal',
|
||||||
|
|||||||
2
nginx/Dockerfile
Normal file
2
nginx/Dockerfile
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
FROM nginx:alpine
|
||||||
|
COPY nginx.conf /etc/nginx/nginx.conf
|
||||||
Reference in New Issue
Block a user