# ASAT Admin Portal — Implementation Documentation

**Project:** Aparri School of Arts and Trades — Online Enrollment Portal
**Module:** Admin Portal (Prototype)
**Stack:** Laravel 13 · Livewire v4.2 · Tailwind CSS v3 · MySQL
**Date:** April 2026

---

## Table of Contents

1. [Overview](#1-overview)
2. [Architecture Summary](#2-architecture-summary)
3. [Routes](#3-routes)
4. [Feature Breakdown](#4-feature-breakdown)
   - [4.1 Dashboard](#41-dashboard)
   - [4.2 Application List](#42-application-list)
   - [4.3 Application Review (Approve / Reject)](#43-application-review-approve--reject)
   - [4.4 User Management](#44-user-management)
   - [4.5 Course Management](#45-course-management)
   - [4.6 Invoice Management](#46-invoice-management)
   - [4.7 Reports & Exports](#47-reports--exports)
   - [4.8 Audit Log](#48-audit-log)
5. [Database Schema](#5-database-schema)
6. [Security Controls](#6-security-controls)
7. [Email Notifications](#7-email-notifications)
8. [File Structure Reference](#8-file-structure-reference)
9. [Engineering Notes](#9-engineering-notes)

---

## 1. Overview

The Admin Portal is the staff-facing management interface of the ASAT Enrollment System. It gives administrators full control over:

- Reviewing and deciding on student enrollment applications (approve / reject)
- Managing users — activating/deactivating accounts, promoting/demoting roles
- Managing available courses and programs
- Creating and managing invoices with printable PDF receipts
- Generating Excel and PDF enrollment reports with date-range and status filters
- Browsing a full Spatie activity audit log

All admin routes are protected by `auth`, `verified`, and `role:admin` middleware. No admin action can be performed by a student, and several actions include additional guards (e.g., admins cannot demote or deactivate themselves).

---

## 2. Architecture Summary

```
┌──────────────────────────────────────────────────────┐
│                   Admin Browser                       │
└────────────────────────┬─────────────────────────────┘
                         │ HTTPS · CSRF-protected
┌────────────────────────▼─────────────────────────────┐
│              Laravel Router (web.php)                 │
│   Middleware: auth · verified · role:admin            │
└────┬──────────┬──────────┬───────────┬───────────────┘
     │          │          │           │
  Livewire   Controller  Policy    External Packages
  ──────────   ──────────  ──────    ──────────────────
  Admin\      Dashboard   review()  Spatie ActivityLog
  ApplicationList  Applications         ↳ audit trail
             Users        view()   Maatwebskie Excel
             Courses               ↳ .xlsx export
             Invoices              barryvdh DomPDF
             Reports               ↳ receipt + report
             Audit
                │
         Eloquent Models
         ──────────────
         Application ──► ApplicationStatusHistory (auto)
         Course            ──► Notification (auto)
         Invoice
         User
```

---

## 3. Routes

All routes are prefixed `/admin/` with name prefix `admin.`.

| Method | URI | Route Name | Handler |
|--------|-----|------------|---------|
| GET | `/admin/dashboard` | `admin.dashboard` | `DashboardController@index` |
| GET | `/admin/applications` | `admin.applications.index` | `Livewire\Admin\ApplicationList` |
| GET | `/admin/applications/{application}` | `admin.applications.show` | `ApplicationController@show` |
| POST | `/admin/applications/{application}/approve` | `admin.applications.approve` | `ApplicationController@approve` |
| POST | `/admin/applications/{application}/reject` | `admin.applications.reject` | `ApplicationController@reject` |
| GET | `/admin/applications/{application}/documents/{document}/download` | `admin.documents.download` | `DocumentController@download` |
| GET | `/admin/courses` | `admin.courses.index` | `CourseController@index` |
| GET | `/admin/courses/create` | `admin.courses.create` | `CourseController@create` |
| POST | `/admin/courses` | `admin.courses.store` | `CourseController@store` |
| GET | `/admin/courses/{course}/edit` | `admin.courses.edit` | `CourseController@edit` |
| PUT | `/admin/courses/{course}` | `admin.courses.update` | `CourseController@update` |
| POST | `/admin/courses/{course}/toggle` | `admin.courses.toggle` | `CourseController@toggle` |
| GET | `/admin/users` | `admin.users.index` | `UserController@index` |
| GET | `/admin/users/{user}` | `admin.users.show` | `UserController@show` |
| POST | `/admin/users/{user}/toggle` | `admin.users.toggle` | `UserController@toggle` |
| POST | `/admin/users/{user}/promote` | `admin.users.promote` | `UserController@promote` |
| POST | `/admin/users/{user}/demote` | `admin.users.demote` | `UserController@demote` |
| GET | `/admin/reports` | `admin.reports.index` | `ReportController@index` |
| GET | `/admin/reports/excel` | `admin.reports.excel` | `ReportController@exportExcel` |
| GET | `/admin/reports/pdf` | `admin.reports.pdf` | `ReportController@exportPdf` |
| GET | `/admin/audit` | `admin.audit.index` | `AuditLogController@index` |
| GET | `/admin/invoices` | `admin.invoices.index` | `InvoiceController@index` |
| GET | `/admin/invoices/{invoice}` | `admin.invoices.show` | `InvoiceController@show` |
| POST | `/admin/invoices/{invoice}/mark-paid` | `admin.invoices.markPaid` | `InvoiceController@markPaid` |
| POST | `/admin/invoices/{invoice}/waive` | `admin.invoices.waive` | `InvoiceController@waive` |
| GET | `/admin/invoices/{invoice}/receipt` | `admin.invoices.receipt` | `InvoiceController@receipt` |
| GET | `/admin/applications/{application}/invoices/create` | `admin.applications.invoices.create` | `InvoiceController@create` |
| POST | `/admin/applications/{application}/invoices` | `admin.applications.invoices.store` | `InvoiceController@store` |

**Total: 27 admin routes.**

---

## 4. Feature Breakdown

### 4.1 Dashboard

**Controller:** `App\Http\Controllers\Admin\DashboardController@index`
**View:** `resources/views/admin/dashboard.blade.php`

Displays a high-level enrollment summary:

| Stat Card | Query |
|-----------|-------|
| Total Submitted | `Application::submitted()->count()` |
| Pending Review | `Application::pending()->count()` (submitted + under_review) |
| Approved | `where('status', 'approved')->count()` |
| Rejected | `where('status', 'rejected')->count()` |

Below the stat cards: a **Recent Applications** table showing the 5 latest submitted applications with columns — Reference, Student Name, Course, Status badge, Submitted (diffForHumans), and a "Review" link to the detail page.

---

### 4.2 Application List

**Livewire:** `App\Livewire\Admin\ApplicationList`
**View:** `resources/views/livewire/admin/application-list.blade.php`

A real-time searchable and filterable paginated table of all **submitted** applications (Draft status is excluded).

| Property | Default | Behaviour |
|----------|---------|-----------|
| `$search` | `''` | Debounced 300 ms; matches `reference_number`, `first_name`, `last_name`, `email` |
| `$statusFilter` | `'all'` | `all` / `submitted` / `under_review` / `approved` / `rejected` |
| `$perPage` | `15` | Rows per page |

Both `updatingSearch()` and `updatingStatusFilter()` call `resetPage()` to prevent stale pagination.

**Columns:** Reference · Applicant (name + email) · Course · Status badge · Submitted date · Review link

---

### 4.3 Application Review (Approve / Reject)

**Controller:** `App\Http\Controllers\Admin\ApplicationController`
**View:** `resources/views/admin/applications/show.blade.php`

The detail view is split into four sections:

1. **Personal Information** — read-only grid (name, email, phone, birth date, gender, address)
2. **Academic Information** — read-only grid (course, academic year, school last attended, year graduated)
3. **Submitted Documents** — list of uploaded files with admin download links (streams via `admin.documents.download`)
4. **Review Decision** — shown conditionally:
   - If status is `submitted` or `under_review`: two side-by-side forms — **Approve** (optional remarks) and **Reject** (required remarks)
   - If already decided: a read-only "Review Notes" card showing remarks, reviewer name, and reviewed timestamp

#### `approve(Request $request, Application $application)`

```
1. authorize('review', $application)          ← policy: admin only
2. validate: remarks nullable|string|max:1000
3. $application->update([
       status      => Approved,
       remarks     => $request->remarks,
       reviewed_by => auth()->id(),
       reviewed_at => now(),
   ])
4. Application::booted() fires automatically:
   → writes ApplicationStatusHistory record
   → dispatches ApplicationStatusChanged notification (database + mail) to student
5. queues ApplicationApprovedMail to $application->email
6. redirect → back with success flash
```

#### `reject(Request $request, Application $application)`

Same flow as approve, except:
- `remarks` is **required** (admin must explain the rejection)
- Status set to `Rejected`
- Queues `ApplicationRejectedMail`

---

### 4.4 User Management

**Controller:** `App\Http\Controllers\Admin\UserController`

#### Index — `GET /admin/users`

**View:** `resources/views/admin/users/index.blade.php`

- Search by name or email (GET parameter `search`)
- Filter by role: all / student / admin (GET parameter `role`)
- Paginated 20 per page
- Columns: Name + Email · Role badge · Application count · Active/Inactive badge · Registered date · View + Toggle buttons

#### Show — `GET /admin/users/{user}`

**View:** `resources/views/admin/users/show.blade.php`

Four sections:
1. **Profile Information** — name, email, phone, gender, birth date, registered date, address
2. **Recent Applications** — up to 5 latest applications with status badge and Review link
3. **Account Actions** — three action buttons:

| Action | Method | Guard |
|--------|--------|-------|
| Activate / Deactivate | `toggle()` | Cannot deactivate self |
| Promote to Admin | `promote()` | Aborts if already admin |
| Demote to Student | `demote()` | Aborts if trying to demote self |

#### Role Management Detail

```php
// promote()
abort_if($user->hasRole('admin'), 422, 'User is already an admin.');
$user->syncRoles(['admin']);

// demote()
abort_if($user->id === auth()->id(), 403, 'Cannot demote yourself.');
$user->syncRoles(['student']);

// toggle()
abort_if($user->id === auth()->id(), 403, 'Cannot deactivate your own account.');
$user->update(['is_active' => !$user->is_active]);
```

---

### 4.5 Course Management

**Controller:** `App\Http\Controllers\Admin\CourseController`
**Views:** `resources/views/admin/courses/index.blade.php`, `form.blade.php`

Courses are the programs/tracks students can apply to. They are grouped by `category` in the index view.

#### Fields

| Field | Type | Validation |
|-------|------|-----------|
| name | string | required, max:255 |
| code | string | required, max:20, **unique** (ignores self on update) |
| category | string | required, max:100 |
| description | text | nullable |
| is_active | boolean | checked = visible to students in ApplicationForm |

#### Actions

- **Create** — `GET /admin/courses/create` + `POST /admin/courses`
- **Edit** — `GET /admin/courses/{course}/edit` + `PUT /admin/courses/{course}` (shared `form.blade.php`, label adapts)
- **Toggle Active** — `POST /admin/courses/{course}/toggle` — flips `is_active`; inactive courses are hidden from the student application form's course dropdown

---

### 4.6 Invoice Management

**Controller:** `App\Http\Controllers\Admin\InvoiceController`
**Views:** `resources/views/admin/invoices/`

Invoices are created by admins against a specific `Application`. They track enrollment fees and payment status.

#### Invoice Lifecycle

```
[Pending] ──markPaid()──► [Paid]   (receipt generated)
         ──waive()──────► [Waived]
```

#### Create Invoice — `GET /admin/applications/{application}/invoices/create`

**View:** `admin/invoices/create.blade.php`

Shows the linked application's info as context. Fields:

| Field | Validation |
|-------|-----------|
| description | required, string |
| amount | required, numeric, min:0 |
| due_date | required, date |

On store: generates `Invoice::generateInvoiceNumber()` → `INV-{YEAR}-{NNNNN}`, sets `created_by = auth()->id()`.

#### Invoice Detail — `GET /admin/invoices/{invoice}`

**View:** `admin/invoices/show.blade.php`

Shows invoice metadata (number, amount, description, student, application reference, due date, creator).

| Status | What's shown |
|--------|-------------|
| **Pending** | Record Payment form + Waive link |
| **Paid** | Receipt number, paid_at, payment method, notes, Download Receipt PDF button |
| **Waived** | Status badge only |

#### Record Payment — `POST /admin/invoices/{invoice}/mark-paid`

```
validate: payment_method required|in:cash,bank_transfer,gcash,maya,check,other
          payment_notes  nullable|string|max:500
→ update: status=Paid, paid_at=now(), recorded_by=auth()->id(),
          receipt_number = Invoice::generateReceiptNumber()  → RCP-{YEAR}-{NNNNN}
```

#### PDF Receipt — `GET /admin/invoices/{invoice}/receipt`

- Aborts 404 if invoice is not `Paid`
- Renders `admin/invoices/receipt-pdf.blade.php` via `barryvdh/laravel-dompdf`
- 80 mm thermal receipt format
- Content: school name, "PAID" stamp, invoice/receipt numbers, student, description, amount, payment method/notes, footer
- Downloaded as `receipt_{RCP-YYYY-NNNNN}.pdf`

---

### 4.7 Reports & Exports

**Controller:** `App\Http\Controllers\Admin\ReportController`
**Views:** `resources/views/admin/reports/index.blade.php`, `pdf.blade.php`

#### Filter Parameters (GET)

| Param | Description |
|-------|-------------|
| `status` | Filter by `ApplicationStatus` value (optional) |
| `from` | Start date (inclusive, optional) |
| `to` | End date (inclusive, optional) |

#### Summary Stats (index view)

Five stat cards computed from the filtered result set:
Total · Submitted · Under Review · Approved · Rejected

Below: a filterable applications table with Reference, Name, Course, Academic Year, Status badge, Submitted date.

#### Export: Excel — `GET /admin/reports/excel`

- Uses `Maatwebskie\Excel` via `App\Exports\ApplicationsExport`
- Accepts same filter parameters as index
- Downloads as `applications_{YYYYMMDD}_{HHiiss}.xlsx`

#### Export: PDF — `GET /admin/reports/pdf`

- Rebuilds the same filtered query
- Renders `admin/reports/pdf.blade.php` via DomPDF
- **A4 landscape** format
- Branded header bar (prussian `#14213d`), meta row (applied filters + total count)
- 8-column table: Reference · Full Name · Email · Course · Academic Year · School Last Attended · Status · Submitted Date
- Downloads as `enrollment_report_{YYYY-MM-DD_HHiiss}.pdf`

---

### 4.8 Audit Log

**Controller:** `App\Http\Controllers\Admin\AuditLogController@index`
**View:** `resources/views/admin/audit/index.blade.php`
**Package:** `spatie/laravel-activitylog`

The audit log automatically records every change to `status`, `remarks`, `reviewed_by`, and `submitted_at` on the `Application` model (configured in `Application::getActivitylogOptions()`).

#### Filters

| Filter | Source |
|--------|--------|
| Search | Matches `description` field |
| Log Name | Dropdown of distinct `log_name` values (e.g., `application`) |

#### Table Columns

| Column | Source |
|--------|--------|
| When | `created_at` (diffForHumans) |
| Log Name | `log_name` badge |
| Description | `description` (e.g., "updated") |
| Subject | `subject_type` + `subject_id` |
| Causer | `causer.name` (the admin or system that triggered it) |
| Changed Attributes | `properties.attributes` vs `properties.old` — shows `old → new` diff per field |

> **Note:** The Spatie activity log and the custom `application_status_histories` table serve complementary purposes. Spatie captures **all field-level changes** with old/new values. The status histories table captures **status-specific transitions** with a human-readable note for display in the student's timeline.

---

## 5. Database Schema

### `courses`

| Column | Type | Notes |
|--------|------|-------|
| id | bigint | — |
| name | varchar(255) | — |
| code | varchar(20) | unique |
| category | varchar(100) | used for grouping in index + student form |
| description | text | nullable |
| is_active | boolean | default true; false = hidden from students |
| created_at / updated_at | timestamp | — |

### `invoices`

| Column | Type | Notes |
|--------|------|-------|
| id | bigint | — |
| invoice_number | varchar | `INV-{YEAR}-{NNNNN}` |
| application_id | bigint FK | constrained |
| user_id | bigint FK | constrained (denormalised for convenience) |
| amount | decimal | — |
| description | text | — |
| due_date | date | — |
| status | enum/string | `InvoiceStatus` cast: Pending / Paid / Waived |
| payment_method | varchar | nullable |
| payment_notes | text | nullable |
| paid_at | timestamp | nullable |
| receipt_number | varchar | `RCP-{YEAR}-{NNNNN}`, nullable |
| created_by | bigint FK | → users (admin who created it) |
| recorded_by | bigint FK | → users (admin who marked it paid), nullable |
| created_at / updated_at | timestamp | — |

### `activity_log` (Spatie)

| Column | Notes |
|--------|-------|
| log_name | `application` for Application model changes |
| description | `created` / `updated` |
| subject_type / subject_id | Polymorphic reference to changed model |
| causer_type / causer_id | Who triggered the change (User) |
| properties | JSON — `{attributes: {...}, old: {...}}` |

### Reference Number Formats

| Model | Format | Example |
|-------|--------|---------|
| Application | `ASAT-{YEAR}-{NNNNNN}` | `ASAT-2026-000042` |
| Invoice | `INV-{YEAR}-{NNNNN}` | `INV-2026-00015` |
| Receipt | `RCP-{YEAR}-{NNNNN}` | `RCP-2026-00008` |

---

## 6. Security Controls

### Authentication & Authorization

| Control | Implementation |
|---------|----------------|
| Route-level guard | `['auth', 'verified', 'role:admin']` middleware on all admin routes |
| Application review gate | `$this->authorize('review', $application)` → `ApplicationPolicy::review()` — admins only |
| Document access gate | `ApplicationPolicy::view()` — allows admin (any) or owning student |

### Self-Action Guards

Prevents admins from locking themselves out or destabilizing their own session:

| Action | Guard |
|--------|-------|
| Deactivate user | `abort_if($user->id === auth()->id(), 403)` |
| Demote to student | `abort_if($user->id === auth()->id(), 403)` |
| Promote already-admin | `abort_if($user->hasRole('admin'), 422)` |

### Input Validation Summary

| Controller | Validated Fields |
|------------|-----------------|
| `approve()` | `remarks` nullable\|string\|max:1000 |
| `reject()` | `remarks` required\|string\|max:1000 |
| `CourseController::store/update` | name required, code unique, category required |
| `InvoiceController::store` | amount numeric\|min:0, due_date date |
| `InvoiceController::markPaid` | payment_method required\|in:cash,bank_transfer,gcash,maya,check,other |
| `UserController::index` | Role filter: implicit enum check via query scope |

### Document Downloads (Admin)

Admin document downloads go through a dedicated route with a **separate policy check**:

```
GET /admin/applications/{application}/documents/{document}/download
→ DocumentController@download
→ authorize('view', $document)        ← DocumentPolicy — allows admin
→ Storage::disk('local')->download()  ← private disk, never directly accessible
```

### CSRF

All POST/PUT/DELETE forms use `@csrf`. No state-changing action is reachable via GET.

---

## 7. Email Notifications

All three mail classes are `Queueable` — they are dispatched to the queue and do not block the HTTP response.

| Class | Triggered by | Subject |
|-------|-------------|---------|
| `ApplicationSubmittedMail` | `Student\ApplicationForm::submit()` | "Application Received — {reference_number}" |
| `ApplicationApprovedMail` | `AdminApplicationController::approve()` | "Congratulations! Your Application Has Been Approved" |
| `ApplicationRejectedMail` | `AdminApplicationController::reject()` | "Update on Your Enrollment Application" |

All three accept `public Application $application` in their constructor and render a corresponding Blade email template:

| Mail | Template |
|------|----------|
| `ApplicationSubmittedMail` | `resources/views/emails/application-submitted.blade.php` |
| `ApplicationApprovedMail` | `resources/views/emails/application-approved.blade.php` |
| `ApplicationRejectedMail` | `resources/views/emails/application-rejected.blade.php` |

Additionally, the `ApplicationStatusChanged` **notification** (not a Mail class) sends a branded transactional email automatically when status transitions to `UnderReview`, `Approved`, or `Rejected` — triggered by `Application::booted()`.

---

## 8. File Structure Reference

```
app/
├── Exports/
│   └── ApplicationsExport.php       # Maatwebskie Excel export

├── Http/Controllers/Admin/
│   ├── ApplicationController.php    # show · approve · reject
│   ├── AuditLogController.php       # index (Spatie ActivityLog)
│   ├── CourseController.php         # index · create · store · edit · update · toggle
│   ├── DashboardController.php      # index (stats + recent apps)
│   ├── DocumentController.php       # download (private disk stream)
│   ├── InvoiceController.php        # index · create · store · show · markPaid · waive · receipt
│   ├── ReportController.php         # index · exportExcel · exportPdf
│   └── UserController.php           # index · show · toggle · promote · demote

├── Livewire/Admin/
│   └── ApplicationList.php          # WithPagination, search + status filter

├── Mail/
│   ├── ApplicationApprovedMail.php
│   ├── ApplicationRejectedMail.php
│   └── ApplicationSubmittedMail.php

├── Models/
│   ├── Application.php              # central model, booted() observer
│   ├── ApplicationStatusHistory.php # audit timeline entries
│   ├── Course.php                   # active scope, hasMany Applications
│   ├── Invoice.php                  # generateInvoiceNumber/ReceiptNumber
│   └── User.php                     # HasRoles, Notifiable

└── Policies/
    ├── ApplicationPolicy.php        # view · create · update · review
    └── DocumentPolicy.php           # view · create

database/migrations/
├── 2026_04_03_123831_create_courses_table.php
├── 2026_04_03_123832_add_course_id_to_applications_table.php
├── 2026_04_03_130625_create_invoices_table.php
└── 2026_04_08_000001_create_notifications_table.php

resources/views/
├── admin/
│   ├── dashboard.blade.php          # 4 stats + recent applications table
│   ├── applications/
│   │   └── show.blade.php           # personal · academic · docs · review form
│   ├── audit/
│   │   └── index.blade.php          # filter + paginated activity table
│   ├── courses/
│   │   ├── index.blade.php          # grouped by category
│   │   └── form.blade.php           # shared create/edit form
│   ├── invoices/
│   │   ├── index.blade.php          # paginated invoice list
│   │   ├── show.blade.php           # detail + record payment / receipt
│   │   ├── create.blade.php         # create invoice scoped to application
│   │   └── receipt-pdf.blade.php    # 80mm thermal PDF (no layout)
│   ├── reports/
│   │   ├── index.blade.php          # filter + stats + table + export buttons
│   │   └── pdf.blade.php            # A4 landscape PDF (no layout)
│   └── users/
│       ├── index.blade.php          # search + role filter + paginated table
│       └── show.blade.php           # profile + apps + account actions
│
├── emails/
│   ├── application-approved.blade.php
│   ├── application-rejected.blade.php
│   └── application-submitted.blade.php
│
└── livewire/admin/
    └── application-list.blade.php   # search input + status select + table + pagination
```

---

## 9. Engineering Notes

### Why Approve/Reject Are Controller Actions (Not Livewire)

Approve and reject are deliberate state transitions with policy gates, email side effects, and audit trail implications. Using a traditional controller POST action keeps the authorization check explicit (`$this->authorize`) and makes the logic easier to test and audit compared to hiding it inside a Livewire component.

### Automatic Status History & Notification via `booted()`

When an admin calls `$application->update([status => Approved, ...])`, the `Application::booted()` observer fires automatically:

```php
static::updated(function (Application $app) {
    if ($app->wasChanged('status')) {
        // 1. Write audit timeline entry
        ApplicationStatusHistory::create([...]);

        // 2. Notify student (UnderReview / Approved / Rejected only)
        $app->user?->notify(new ApplicationStatusChanged($app, ...));
    }
});
```

This means no manual history or notification calls are needed in the admin controller — `update()` is enough.

### Dual Audit Systems

| System | Purpose | When to use |
|--------|---------|-------------|
| `application_status_histories` | Student-facing timeline — one row per status transition with a human note | Show progress to student |
| `spatie/activitylog` | Admin-facing full field diff — captures old+new values for any logged field | Audit trail / compliance |

Both run automatically on `Application::update()`. No extra code is required.

### Spatie Role Middleware

Admin role enforcement uses the Spatie `role:admin` middleware alias registered in `bootstrap/app.php`. This is applied at the route group level, so individual controllers do not need to repeat the check — only action-level policy gates (like `review`) are applied per method.

### PDF Generation

Two separate PDF use cases with different formats:

| Use Case | View | Format | Package |
|----------|------|--------|---------|
| Thermal Receipt | `invoices/receipt-pdf.blade.php` | 80 mm width, portrait | DomPDF |
| Enrollment Report | `reports/pdf.blade.php` | A4 landscape | DomPDF |

Both views are standalone HTML files (no `x-app-layout`) so DomPDF's renderer does not try to resolve Livewire or Vite assets.

### Excel Export Architecture

```
ReportController::exportExcel()
  → App\Exports\ApplicationsExport   (implements FromQuery, WithHeadings)
  → Maatwebskie\Excel::download()
  → returns .xlsx stream
```

The `ApplicationsExport` class shares the same filter logic as the report index page, so the exported data always matches what the admin sees on screen.

### `is_active` Flag vs Role

A deactivated user (`is_active = false`) is blocked at middleware level — they cannot log in. The `role:student` / `role:admin` Spatie middleware still applies independently. Both checks must pass for a user to access the system.
