diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5d92bd2..800929a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,39 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+## [0.8.0] - 2026-02-03
+
+### Added
+
+- Admin Dashboard with comprehensive statistics:
+ - Occupancy overview card with current rate and comparison to last month
+ - Revenue summary card with this month, YTD, and comparison
+ - Bookings stat card with pending/confirmed counts
+ - Guests stat card with total, new, and repeat counts
+ - Today's Activity widget showing check-ins and check-outs
+ - Upcoming Bookings widget (next 7 days)
+ - Quick Actions widget for common tasks
+ - Chart.js integration for visual trend charts:
+ - Occupancy trend line chart (30 days)
+ - Revenue trend bar chart (6 months)
+- Reports page with three report types:
+ - Occupancy Report: by room, by building, with progress bars
+ - Revenue Report: by room, by pricing tier, with averages
+ - Guest Statistics: top guests, nationality breakdown
+ - Date range filters (this month, last month, this year, custom)
+- Export functionality:
+ - CSV export for all report types (native PHP)
+ - PDF export using mPDF library with professional styling
+- New Composer dependency: mpdf/mpdf ^8.2 for PDF generation
+- Dashboard and Reports CSS styles in admin.css (~350 lines)
+- JavaScript chart initialization and report page handlers
+
+### Changed
+
+- Dashboard now uses dedicated `src/Admin/Dashboard.php` class
+- Admin menu now includes Reports submenu item
+- Asset enqueuing conditionally loads Chart.js on dashboard page
+
## [0.7.2] - 2026-02-03
### Fixed
diff --git a/CLAUDE.md b/CLAUDE.md
index 9f406b3..e794c8f 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -849,3 +849,89 @@ Admin features always work; frontend requires valid license.
- Merged to main (fast-forward)
- Tagged: `v0.7.1`
- Pushed to origin: dev, main, v0.7.1
+
+### 2026-02-03 - Version 0.8.0 (Dashboard & Reports)
+
+**Completed:**
+
+- Created `src/Admin/Dashboard.php` class (~700 lines)
+ - `render()` method for full dashboard page
+ - Occupancy stat card with current rate, room count, comparison to last month
+ - Revenue stat card with this month, YTD, comparison
+ - Bookings stat card with pending/confirmed counts
+ - Guests stat card with total, new this month, repeat guests
+ - Today's Activity widget showing check-ins and check-outs
+ - Upcoming Bookings widget with next 7 days' bookings
+ - Quick Actions widget (New Booking, New Guest, Calendar, Reports)
+ - Occupancy trend chart (30-day line chart)
+ - Revenue trend chart (6-month bar chart)
+ - Data methods: `get_occupancy_stats()`, `get_revenue_stats()`, `get_booking_stats()`, `get_guest_stats()`
+ - Chart data methods: `get_occupancy_trend_data()`, `get_revenue_trend_data()`
+ - Transient caching for expensive calculations (1-hour expiry)
+- Created `src/Admin/Reports.php` class (~1100 lines)
+ - Tabbed interface: Occupancy, Revenue, Guests
+ - Date range filters with presets (this month, last month, this year, custom)
+ - Occupancy Report: by room, by building with progress bars and status labels
+ - Revenue Report: by room, by pricing tier, with averages
+ - Guest Statistics: top guests by revenue, nationality breakdown
+ - CSV export using native PHP `fputcsv()`
+ - PDF export using mPDF with professional HTML styling
+ - Summary cards with key metrics
+ - Progress bar visualizations for occupancy rates
+- Added mPDF dependency to `composer.json` (`mpdf/mpdf ^8.2`)
+- Updated `src/Plugin.php`
+ - Added Dashboard and Reports class imports
+ - `render_dashboard_page()` delegates to `Dashboard::render()`
+ - Added `render_reports_page()` method
+ - Reports submenu registration
+ - Updated menu ordering to include Reports
+ - Chart.js CDN enqueuing on dashboard page
+ - Chart data passed via `wp_localize_script()`
+- Dashboard CSS styles (~350 lines in admin.css)
+ - Responsive grid layout (4-col stats, 2-col charts, 3-col activity)
+ - Stat cards with icons and gradients
+ - Widget components with headers
+ - Activity list styling
+ - Upcoming bookings table
+ - Quick action buttons grid
+- Reports CSS styles (~200 lines in admin.css)
+ - Filter form layout
+ - Summary cards with primary variant
+ - Progress bars for occupancy
+ - Status labels (high/medium/low)
+ - Export buttons styling
+- JavaScript additions in admin.js
+ - `initDashboardCharts()` for Chart.js initialization
+ - Occupancy line chart with tooltips and styling
+ - Revenue bar chart with currency formatting
+ - `initReportsPage()` for custom date toggle
+- Updated version to 0.8.0
+
+**Files Created:**
+
+- `src/Admin/Dashboard.php` - Dashboard page with widgets and charts
+- `src/Admin/Reports.php` - Reports page with tabs and export
+
+**Files Changed:**
+
+- `composer.json` - Added mpdf/mpdf dependency
+- `composer.lock` - Updated with mPDF and dependencies
+- `src/Plugin.php` - Dashboard/Reports integration, Chart.js enqueuing
+- `assets/css/admin.css` - Dashboard and Reports styles (~550 lines added)
+- `assets/js/admin.js` - Chart initialization, reports page handlers
+- `wp-bnb.php` - Version bump to 0.8.0
+- `CHANGELOG.md` - Added v0.8.0 release notes
+- `PLAN.md` - Marked Phase 8 as complete
+
+**Learnings:**
+
+- Chart.js CDN loading requires conditional enqueuing to avoid loading on all admin pages
+- Dashboard data methods should use transient caching for expensive queries
+- PDF export with mPDF requires HTML string generation with inline CSS
+- Reports use `get_posts()` with meta queries for date range filtering
+- Progress bar visualization done with CSS positioning and `min(100, value)` clamping
+- Chart.js 4.x uses `new Chart()` constructor with configuration object
+- PDF generation needs `try/catch` for mPDF exceptions
+- CSV export with BOM (`\xEF\xBB\xBF`) ensures Excel compatibility
+- Guest data aggregation from bookings uses unique key pattern for anonymous guests
+- Occupancy calculation: (booked nights / total room nights) * 100
diff --git a/PLAN.md b/PLAN.md
index 709848f..70e6f20 100644
--- a/PLAN.md
+++ b/PLAN.md
@@ -164,21 +164,21 @@ This document outlines the implementation plan for the WP BnB Management plugin.
- [x] Room-specific inquiries
- [x] Auto-response templates (uses default CF7 mail templates)
-## Phase 8: Dashboard & Reports (v0.8.0)
+## Phase 8: Dashboard & Reports (v0.8.0) - Complete
### Admin Dashboard
-- [ ] Occupancy overview
-- [ ] Upcoming check-ins/check-outs
-- [ ] Revenue summary
-- [ ] Quick actions
+- [x] Occupancy overview
+- [x] Upcoming check-ins/check-outs
+- [x] Revenue summary
+- [x] Quick actions
### Reports
-- [ ] Occupancy report
-- [ ] Revenue report
-- [ ] Guest statistics
-- [ ] Export functionality (CSV, PDF)
+- [x] Occupancy report
+- [x] Revenue report
+- [x] Guest statistics
+- [x] Export functionality (CSV, PDF)
## Phase 9: Prometheus Metrics (v0.9.0)
@@ -307,7 +307,7 @@ The plugin will provide extensive hooks for customization:
| 0.5.0 | Services | Complete |
| 0.6.0 | Frontend | Complete |
| 0.7.0 | CF7 Integration | Complete |
-| 0.8.0 | Dashboard | TBD |
+| 0.8.0 | Dashboard | Complete |
| 0.9.0 | Prometheus Metrics | TBD |
| 0.10.0 | Security Audit | TBD |
| 1.0.0 | Stable Release | TBD |
diff --git a/assets/css/admin.css b/assets/css/admin.css
index e8294c4..4e03ff8 100644
--- a/assets/css/admin.css
+++ b/assets/css/admin.css
@@ -4,7 +4,382 @@
* @package Magdev\WpBnb
*/
-/* Dashboard */
+/* ============================================
+ Dashboard
+ ============================================ */
+.wp-bnb-dashboard-grid {
+ margin-top: 20px;
+}
+
+.wp-bnb-dashboard-row {
+ display: grid;
+ gap: 20px;
+ margin-bottom: 20px;
+}
+
+/* Stats Row - 4 columns */
+.wp-bnb-stats-row {
+ grid-template-columns: repeat(4, 1fr);
+}
+
+/* Charts Row - 2 columns */
+.wp-bnb-charts-row {
+ grid-template-columns: repeat(2, 1fr);
+}
+
+/* Activity Row - 3 columns */
+.wp-bnb-activity-row {
+ grid-template-columns: 1fr 1.5fr 1fr;
+}
+
+/* Responsive adjustments */
+@media screen and (max-width: 1400px) {
+ .wp-bnb-stats-row {
+ grid-template-columns: repeat(2, 1fr);
+ }
+ .wp-bnb-activity-row {
+ grid-template-columns: 1fr 1fr;
+ }
+ .wp-bnb-activity-row .wp-bnb-quick-actions {
+ grid-column: span 2;
+ }
+}
+
+@media screen and (max-width: 782px) {
+ .wp-bnb-stats-row,
+ .wp-bnb-charts-row,
+ .wp-bnb-activity-row {
+ grid-template-columns: 1fr;
+ }
+ .wp-bnb-activity-row .wp-bnb-quick-actions {
+ grid-column: span 1;
+ }
+}
+
+/* Stat Cards */
+.wp-bnb-stat-card {
+ background: #fff;
+ border: 1px solid #c3c4c7;
+ border-radius: 8px;
+ padding: 20px;
+ display: flex;
+ align-items: flex-start;
+ gap: 15px;
+ transition: box-shadow 0.2s ease;
+}
+
+.wp-bnb-stat-card:hover {
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
+}
+
+.wp-bnb-stat-icon {
+ width: 48px;
+ height: 48px;
+ border-radius: 12px;
+ background: linear-gradient(135deg, #2271b1, #135e96);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+}
+
+.wp-bnb-stat-icon.revenue {
+ background: linear-gradient(135deg, #00a32a, #007017);
+}
+
+.wp-bnb-stat-icon.bookings {
+ background: linear-gradient(135deg, #9b59b6, #8e44ad);
+}
+
+.wp-bnb-stat-icon.guests {
+ background: linear-gradient(135deg, #e67e22, #d35400);
+}
+
+.wp-bnb-stat-icon .dashicons {
+ color: #fff;
+ font-size: 24px;
+ width: 24px;
+ height: 24px;
+}
+
+.wp-bnb-stat-content {
+ flex: 1;
+ min-width: 0;
+}
+
+.wp-bnb-stat-label {
+ font-size: 13px;
+ color: #50575e;
+ margin-bottom: 4px;
+}
+
+.wp-bnb-stat-value {
+ font-size: 28px;
+ font-weight: 600;
+ color: #1d2327;
+ line-height: 1.2;
+ margin-bottom: 4px;
+}
+
+.wp-bnb-stat-meta {
+ font-size: 12px;
+ color: #787c82;
+}
+
+.wp-bnb-stat-change {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ font-size: 12px;
+ margin-top: 8px;
+ padding: 2px 8px;
+ border-radius: 4px;
+}
+
+.wp-bnb-stat-change.positive {
+ background: #d4edda;
+ color: #00a32a;
+}
+
+.wp-bnb-stat-change.negative {
+ background: #f8d7da;
+ color: #d63638;
+}
+
+.wp-bnb-stat-change .dashicons {
+ font-size: 14px;
+ width: 14px;
+ height: 14px;
+}
+
+/* Widgets */
+.wp-bnb-widget {
+ background: #fff;
+ border: 1px solid #c3c4c7;
+ border-radius: 8px;
+ overflow: hidden;
+}
+
+.wp-bnb-widget-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 15px 20px;
+ background: #f6f7f7;
+ border-bottom: 1px solid #c3c4c7;
+}
+
+.wp-bnb-widget-header h3 {
+ margin: 0;
+ font-size: 14px;
+ font-weight: 600;
+ color: #1d2327;
+}
+
+.wp-bnb-widget-header .wp-bnb-widget-date {
+ font-size: 12px;
+ color: #787c82;
+}
+
+.wp-bnb-widget-header .wp-bnb-view-all {
+ font-size: 12px;
+ text-decoration: none;
+}
+
+.wp-bnb-widget-content {
+ padding: 20px;
+}
+
+/* Chart Widgets */
+.wp-bnb-chart-widget .wp-bnb-widget-content {
+ padding: 15px;
+}
+
+/* Empty State */
+.wp-bnb-empty-state {
+ text-align: center;
+ padding: 30px 20px;
+ color: #787c82;
+}
+
+.wp-bnb-empty-state .dashicons {
+ font-size: 48px;
+ width: 48px;
+ height: 48px;
+ color: #c3c4c7;
+ margin-bottom: 10px;
+}
+
+.wp-bnb-empty-state p {
+ margin: 0;
+ font-size: 13px;
+}
+
+/* Activity Section */
+.wp-bnb-activity-section {
+ margin-bottom: 20px;
+}
+
+.wp-bnb-activity-section:last-child {
+ margin-bottom: 0;
+}
+
+.wp-bnb-activity-section h4 {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin: 0 0 12px;
+ padding-bottom: 8px;
+ border-bottom: 1px solid #f0f0f1;
+ font-size: 13px;
+ font-weight: 600;
+ color: #1d2327;
+}
+
+.wp-bnb-activity-section h4 .dashicons {
+ font-size: 16px;
+ width: 16px;
+ height: 16px;
+ color: #2271b1;
+}
+
+.wp-bnb-activity-section h4 .count {
+ background: #2271b1;
+ color: #fff;
+ font-size: 11px;
+ font-weight: 600;
+ padding: 2px 6px;
+ border-radius: 10px;
+ margin-left: auto;
+}
+
+.wp-bnb-activity-list {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+}
+
+.wp-bnb-activity-list li {
+ margin-bottom: 8px;
+}
+
+.wp-bnb-activity-list li:last-child {
+ margin-bottom: 0;
+}
+
+.wp-bnb-activity-list a {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 8px 12px;
+ background: #f6f7f7;
+ border-radius: 4px;
+ text-decoration: none;
+ transition: background 0.15s ease;
+}
+
+.wp-bnb-activity-list a:hover {
+ background: #dcdcde;
+}
+
+.wp-bnb-activity-list strong {
+ color: #1d2327;
+ font-weight: 600;
+}
+
+.wp-bnb-activity-list .room {
+ font-size: 12px;
+ color: #787c82;
+}
+
+/* Upcoming Bookings Table */
+.wp-bnb-upcoming-table {
+ width: 100%;
+ border-collapse: collapse;
+}
+
+.wp-bnb-upcoming-table th {
+ text-align: left;
+ padding: 8px 12px;
+ font-size: 12px;
+ font-weight: 600;
+ color: #50575e;
+ background: #f6f7f7;
+ border-bottom: 1px solid #c3c4c7;
+}
+
+.wp-bnb-upcoming-table td {
+ padding: 10px 12px;
+ font-size: 13px;
+ border-bottom: 1px solid #f0f0f1;
+ vertical-align: middle;
+}
+
+.wp-bnb-upcoming-table tr:last-child td {
+ border-bottom: none;
+}
+
+.wp-bnb-upcoming-table a {
+ color: #2271b1;
+ text-decoration: none;
+ font-weight: 500;
+}
+
+.wp-bnb-upcoming-table a:hover {
+ color: #135e96;
+ text-decoration: underline;
+}
+
+.wp-bnb-upcoming-table .wp-bnb-status-badge {
+ font-size: 10px;
+ padding: 3px 8px;
+}
+
+/* Quick Actions */
+.wp-bnb-quick-actions .wp-bnb-widget-content {
+ padding: 15px;
+}
+
+.wp-bnb-actions-grid {
+ display: grid;
+ grid-template-columns: repeat(2, 1fr);
+ gap: 10px;
+}
+
+.wp-bnb-action-btn {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+ padding: 20px 10px;
+ background: #f6f7f7;
+ border: 1px solid #c3c4c7;
+ border-radius: 6px;
+ text-decoration: none;
+ color: #1d2327;
+ transition: all 0.2s ease;
+}
+
+.wp-bnb-action-btn:hover {
+ background: #2271b1;
+ border-color: #2271b1;
+ color: #fff;
+}
+
+.wp-bnb-action-btn .dashicons {
+ font-size: 24px;
+ width: 24px;
+ height: 24px;
+}
+
+.wp-bnb-action-btn span:last-child {
+ font-size: 12px;
+ font-weight: 500;
+ text-align: center;
+}
+
+/* Legacy Dashboard (backward compatibility) */
.wp-bnb-dashboard {
background: #fff;
border: 1px solid #c3c4c7;
@@ -1345,3 +1720,305 @@
font-size: 18px;
color: #135e96;
}
+
+/* ============================================
+ Reports Page
+ ============================================ */
+
+/* Reports Tabs */
+.wp-bnb-reports-tabs .nav-tab {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+}
+
+.wp-bnb-reports-tabs .nav-tab .dashicons {
+ font-size: 16px;
+ width: 16px;
+ height: 16px;
+}
+
+/* Reports Content Container */
+.wp-bnb-reports-content {
+ background: #fff;
+ border: 1px solid #c3c4c7;
+ border-top: none;
+ padding: 20px;
+}
+
+/* Reports Filters */
+.wp-bnb-reports-filters {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ flex-wrap: wrap;
+ gap: 15px;
+ padding: 15px 20px;
+ background: #f6f7f7;
+ border: 1px solid #c3c4c7;
+ border-radius: 4px;
+ margin-bottom: 20px;
+}
+
+.wp-bnb-filter-form {
+ display: flex;
+ align-items: center;
+ gap: 15px;
+ flex-wrap: wrap;
+}
+
+.wp-bnb-filter-group {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.wp-bnb-filter-group label {
+ font-weight: 600;
+ font-size: 13px;
+ color: #50575e;
+}
+
+.wp-bnb-period-select {
+ min-width: 150px;
+}
+
+.wp-bnb-custom-dates {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.wp-bnb-custom-dates input[type="date"] {
+ width: 140px;
+}
+
+.wp-bnb-report-period {
+ font-size: 13px;
+ color: #50575e;
+}
+
+.wp-bnb-report-period strong {
+ color: #1d2327;
+}
+
+/* Report Body */
+.wp-bnb-report-body {
+ margin-bottom: 20px;
+}
+
+.wp-bnb-report-section {
+ margin-bottom: 30px;
+}
+
+.wp-bnb-report-section h3 {
+ font-size: 14px;
+ font-weight: 600;
+ color: #1d2327;
+ margin: 25px 0 15px;
+ padding-bottom: 8px;
+ border-bottom: 1px solid #c3c4c7;
+}
+
+.wp-bnb-report-section h3:first-child {
+ margin-top: 0;
+}
+
+/* Summary Cards */
+.wp-bnb-summary-cards {
+ display: grid;
+ grid-template-columns: repeat(4, 1fr);
+ gap: 15px;
+ margin-bottom: 25px;
+}
+
+.wp-bnb-summary-cards.secondary {
+ grid-template-columns: repeat(3, 1fr);
+ margin-bottom: 30px;
+}
+
+@media screen and (max-width: 1200px) {
+ .wp-bnb-summary-cards {
+ grid-template-columns: repeat(2, 1fr);
+ }
+ .wp-bnb-summary-cards.secondary {
+ grid-template-columns: repeat(3, 1fr);
+ }
+}
+
+@media screen and (max-width: 782px) {
+ .wp-bnb-summary-cards,
+ .wp-bnb-summary-cards.secondary {
+ grid-template-columns: 1fr;
+ }
+}
+
+.wp-bnb-summary-card {
+ background: #f6f7f7;
+ border: 1px solid #c3c4c7;
+ border-radius: 6px;
+ padding: 20px;
+ text-align: center;
+}
+
+.wp-bnb-summary-card.primary {
+ background: linear-gradient(135deg, #d4edda, #c3e6cb);
+ border-color: #a3d4aa;
+}
+
+.wp-bnb-summary-card.small {
+ padding: 15px;
+}
+
+.wp-bnb-summary-value {
+ display: block;
+ font-size: 28px;
+ font-weight: 700;
+ color: #2271b1;
+ line-height: 1.2;
+ margin-bottom: 5px;
+}
+
+.wp-bnb-summary-card.primary .wp-bnb-summary-value {
+ color: #00a32a;
+}
+
+.wp-bnb-summary-card.small .wp-bnb-summary-value {
+ font-size: 20px;
+}
+
+.wp-bnb-summary-label {
+ display: block;
+ font-size: 12px;
+ color: #50575e;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+}
+
+/* Report Tables */
+.wp-bnb-report-table {
+ border-collapse: collapse;
+}
+
+.wp-bnb-report-table th,
+.wp-bnb-report-table td {
+ padding: 10px 12px;
+ text-align: left;
+}
+
+.wp-bnb-report-table th.num,
+.wp-bnb-report-table td.num {
+ text-align: right;
+}
+
+.wp-bnb-report-table tfoot th {
+ background: #f0f6fc;
+ font-weight: 600;
+}
+
+.wp-bnb-report-table a {
+ color: #2271b1;
+ text-decoration: none;
+}
+
+.wp-bnb-report-table a:hover {
+ color: #135e96;
+ text-decoration: underline;
+}
+
+/* Progress Bar */
+.wp-bnb-progress-bar {
+ position: relative;
+ background: #dcdcde;
+ border-radius: 10px;
+ height: 20px;
+ overflow: hidden;
+ min-width: 100px;
+}
+
+.wp-bnb-progress-fill {
+ position: absolute;
+ top: 0;
+ left: 0;
+ height: 100%;
+ background: linear-gradient(90deg, #2271b1, #135e96);
+ border-radius: 10px;
+ transition: width 0.3s ease;
+}
+
+.wp-bnb-progress-text {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ font-size: 11px;
+ font-weight: 600;
+ color: #1d2327;
+ text-shadow: 0 0 3px #fff;
+}
+
+/* Status Labels */
+.wp-bnb-status {
+ display: inline-block;
+ padding: 3px 8px;
+ border-radius: 4px;
+ font-size: 11px;
+ font-weight: 600;
+ text-transform: uppercase;
+}
+
+.wp-bnb-status.high {
+ background: #d4edda;
+ color: #00a32a;
+}
+
+.wp-bnb-status.medium {
+ background: #fff3cd;
+ color: #856404;
+}
+
+.wp-bnb-status.low {
+ background: #f8d7da;
+ color: #d63638;
+}
+
+/* No Data Message */
+.wp-bnb-no-data {
+ padding: 30px;
+ text-align: center;
+ color: #787c82;
+ font-style: italic;
+ background: #f6f7f7;
+ border-radius: 4px;
+}
+
+/* Export Buttons */
+.wp-bnb-export-buttons {
+ padding: 20px;
+ background: #f6f7f7;
+ border: 1px solid #c3c4c7;
+ border-radius: 4px;
+}
+
+.wp-bnb-export-buttons h4 {
+ margin: 0 0 15px;
+ font-size: 14px;
+ font-weight: 600;
+}
+
+.wp-bnb-export-actions {
+ display: flex;
+ gap: 10px;
+}
+
+.wp-bnb-export-actions .button {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+}
+
+.wp-bnb-export-actions .button .dashicons {
+ font-size: 16px;
+ width: 16px;
+ height: 16px;
+}
diff --git a/assets/js/admin.js b/assets/js/admin.js
index 6738259..70009fa 100644
--- a/assets/js/admin.js
+++ b/assets/js/admin.js
@@ -1024,6 +1024,181 @@
updateServicesTotal();
}
+ /**
+ * Initialize dashboard charts.
+ */
+ function initDashboardCharts() {
+ // Only run on dashboard page.
+ if (!wpBnbAdmin.isDashboard || typeof Chart === 'undefined') {
+ return;
+ }
+
+ var chartData = wpBnbAdmin.chartData;
+ if (!chartData) {
+ return;
+ }
+
+ // Chart.js default configuration.
+ Chart.defaults.font.family = '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif';
+ Chart.defaults.font.size = 12;
+ Chart.defaults.color = '#50575e';
+
+ // Initialize Occupancy Chart.
+ var occupancyCtx = document.getElementById('wp-bnb-occupancy-chart');
+ if (occupancyCtx && chartData.occupancy) {
+ new Chart(occupancyCtx, {
+ type: 'line',
+ data: {
+ labels: chartData.occupancy.labels,
+ datasets: [{
+ label: wpBnbAdmin.i18n.occupancy,
+ data: chartData.occupancy.data,
+ borderColor: '#2271b1',
+ backgroundColor: 'rgba(34, 113, 177, 0.1)',
+ fill: true,
+ tension: 0.3,
+ pointRadius: 3,
+ pointHoverRadius: 5,
+ pointBackgroundColor: '#2271b1',
+ pointBorderColor: '#fff',
+ pointBorderWidth: 2
+ }]
+ },
+ options: {
+ responsive: true,
+ maintainAspectRatio: false,
+ plugins: {
+ legend: {
+ display: false
+ },
+ tooltip: {
+ backgroundColor: '#1d2327',
+ titleColor: '#fff',
+ bodyColor: '#fff',
+ padding: 12,
+ displayColors: false,
+ callbacks: {
+ label: function(context) {
+ return context.parsed.y.toFixed(1) + '%';
+ }
+ }
+ }
+ },
+ scales: {
+ y: {
+ beginAtZero: true,
+ max: 100,
+ ticks: {
+ callback: function(value) {
+ return value + '%';
+ }
+ },
+ grid: {
+ color: 'rgba(0, 0, 0, 0.05)'
+ }
+ },
+ x: {
+ grid: {
+ display: false
+ }
+ }
+ },
+ interaction: {
+ intersect: false,
+ mode: 'index'
+ }
+ }
+ });
+ }
+
+ // Initialize Revenue Chart.
+ var revenueCtx = document.getElementById('wp-bnb-revenue-chart');
+ if (revenueCtx && chartData.revenue) {
+ new Chart(revenueCtx, {
+ type: 'bar',
+ data: {
+ labels: chartData.revenue.labels,
+ datasets: [{
+ label: wpBnbAdmin.i18n.revenue,
+ data: chartData.revenue.data,
+ backgroundColor: 'rgba(0, 163, 42, 0.8)',
+ borderColor: '#00a32a',
+ borderWidth: 1,
+ borderRadius: 4,
+ barPercentage: 0.6
+ }]
+ },
+ options: {
+ responsive: true,
+ maintainAspectRatio: false,
+ plugins: {
+ legend: {
+ display: false
+ },
+ tooltip: {
+ backgroundColor: '#1d2327',
+ titleColor: '#fff',
+ bodyColor: '#fff',
+ padding: 12,
+ displayColors: false,
+ callbacks: {
+ label: function(context) {
+ return new Intl.NumberFormat('de-CH', {
+ style: 'currency',
+ currency: 'CHF'
+ }).format(context.parsed.y);
+ }
+ }
+ }
+ },
+ scales: {
+ y: {
+ beginAtZero: true,
+ ticks: {
+ callback: function(value) {
+ return new Intl.NumberFormat('de-CH', {
+ style: 'currency',
+ currency: 'CHF',
+ maximumFractionDigits: 0
+ }).format(value);
+ }
+ },
+ grid: {
+ color: 'rgba(0, 0, 0, 0.05)'
+ }
+ },
+ x: {
+ grid: {
+ display: false
+ }
+ }
+ }
+ }
+ });
+ }
+ }
+
+ /**
+ * Initialize reports page functionality.
+ */
+ function initReportsPage() {
+ var $periodSelect = $('.wp-bnb-period-select');
+ var $customDates = $('.wp-bnb-custom-dates');
+
+ if (!$periodSelect.length) {
+ return;
+ }
+
+ // Toggle custom date fields based on period selection.
+ $periodSelect.on('change', function() {
+ if ($(this).val() === 'custom') {
+ $customDates.show();
+ } else {
+ $customDates.hide();
+ }
+ });
+ }
+
// Initialize on document ready.
$(document).ready(function() {
initLicenseManagement();
@@ -1037,6 +1212,8 @@
initGuestSearch();
initServicePricing();
initBookingServices();
+ initDashboardCharts();
+ initReportsPage();
});
})(jQuery);
diff --git a/composer.json b/composer.json
index 3a294ac..6a41cc2 100644
--- a/composer.json
+++ b/composer.json
@@ -22,7 +22,8 @@
"require": {
"php": ">=8.3.0",
"twig/twig": "^3.0",
- "magdev/wc-licensed-product-client": "^0.2"
+ "magdev/wc-licensed-product-client": "^0.2",
+ "mpdf/mpdf": "^8.2"
},
"autoload": {
"psr-4": {
diff --git a/composer.lock b/composer.lock
index d52cebc..7d358aa 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "aed1e4dd36ea76994768a8379100314b",
+ "content-hash": "ae9fdb5fb51bbef492ad4f2a40406fd3",
"packages": [
{
"name": "magdev/wc-licensed-product-client",
@@ -56,6 +56,289 @@
"relative": true
}
},
+ {
+ "name": "mpdf/mpdf",
+ "version": "v8.2.7",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/mpdf/mpdf.git",
+ "reference": "b59670a09498689c33ce639bac8f5ba26721dab3"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/mpdf/mpdf/zipball/b59670a09498689c33ce639bac8f5ba26721dab3",
+ "reference": "b59670a09498689c33ce639bac8f5ba26721dab3",
+ "shasum": ""
+ },
+ "require": {
+ "ext-gd": "*",
+ "ext-mbstring": "*",
+ "mpdf/psr-http-message-shim": "^1.0 || ^2.0",
+ "mpdf/psr-log-aware-trait": "^2.0 || ^3.0",
+ "myclabs/deep-copy": "^1.7",
+ "paragonie/random_compat": "^1.4|^2.0|^9.99.99",
+ "php": "^5.6 || ^7.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0",
+ "psr/http-message": "^1.0 || ^2.0",
+ "psr/log": "^1.0 || ^2.0 || ^3.0",
+ "setasign/fpdi": "^2.1"
+ },
+ "require-dev": {
+ "mockery/mockery": "^1.3.0",
+ "mpdf/qrcode": "^1.1.0",
+ "squizlabs/php_codesniffer": "^3.5.0",
+ "tracy/tracy": "~2.5",
+ "yoast/phpunit-polyfills": "^1.0"
+ },
+ "suggest": {
+ "ext-bcmath": "Needed for generation of some types of barcodes",
+ "ext-xml": "Needed mainly for SVG manipulation",
+ "ext-zlib": "Needed for compression of embedded resources, such as fonts"
+ },
+ "type": "library",
+ "autoload": {
+ "files": [
+ "src/functions.php"
+ ],
+ "psr-4": {
+ "Mpdf\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "GPL-2.0-only"
+ ],
+ "authors": [
+ {
+ "name": "Matěj Humpál",
+ "role": "Developer, maintainer"
+ },
+ {
+ "name": "Ian Back",
+ "role": "Developer (retired)"
+ }
+ ],
+ "description": "PHP library generating PDF files from UTF-8 encoded HTML",
+ "homepage": "https://mpdf.github.io",
+ "keywords": [
+ "pdf",
+ "php",
+ "utf-8"
+ ],
+ "support": {
+ "docs": "https://mpdf.github.io",
+ "issues": "https://github.com/mpdf/mpdf/issues",
+ "source": "https://github.com/mpdf/mpdf"
+ },
+ "funding": [
+ {
+ "url": "https://www.paypal.me/mpdf",
+ "type": "custom"
+ }
+ ],
+ "time": "2025-12-01T10:18:02+00:00"
+ },
+ {
+ "name": "mpdf/psr-http-message-shim",
+ "version": "v2.0.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/mpdf/psr-http-message-shim.git",
+ "reference": "f25a0153d645e234f9db42e5433b16d9b113920f"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/mpdf/psr-http-message-shim/zipball/f25a0153d645e234f9db42e5433b16d9b113920f",
+ "reference": "f25a0153d645e234f9db42e5433b16d9b113920f",
+ "shasum": ""
+ },
+ "require": {
+ "psr/http-message": "^2.0"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Mpdf\\PsrHttpMessageShim\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Mark Dorison",
+ "email": "mark@chromatichq.com"
+ },
+ {
+ "name": "Kristofer Widholm",
+ "email": "kristofer@chromatichq.com"
+ },
+ {
+ "name": "Nigel Cunningham",
+ "email": "nigel.cunningham@technocrat.com.au"
+ }
+ ],
+ "description": "Shim to allow support of different psr/message versions.",
+ "support": {
+ "issues": "https://github.com/mpdf/psr-http-message-shim/issues",
+ "source": "https://github.com/mpdf/psr-http-message-shim/tree/v2.0.1"
+ },
+ "time": "2023-10-02T14:34:03+00:00"
+ },
+ {
+ "name": "mpdf/psr-log-aware-trait",
+ "version": "v3.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/mpdf/psr-log-aware-trait.git",
+ "reference": "a633da6065e946cc491e1c962850344bb0bf3e78"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/mpdf/psr-log-aware-trait/zipball/a633da6065e946cc491e1c962850344bb0bf3e78",
+ "reference": "a633da6065e946cc491e1c962850344bb0bf3e78",
+ "shasum": ""
+ },
+ "require": {
+ "psr/log": "^3.0"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Mpdf\\PsrLogAwareTrait\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Mark Dorison",
+ "email": "mark@chromatichq.com"
+ },
+ {
+ "name": "Kristofer Widholm",
+ "email": "kristofer@chromatichq.com"
+ }
+ ],
+ "description": "Trait to allow support of different psr/log versions.",
+ "support": {
+ "issues": "https://github.com/mpdf/psr-log-aware-trait/issues",
+ "source": "https://github.com/mpdf/psr-log-aware-trait/tree/v3.0.0"
+ },
+ "time": "2023-05-03T06:19:36+00:00"
+ },
+ {
+ "name": "myclabs/deep-copy",
+ "version": "1.13.4",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/myclabs/DeepCopy.git",
+ "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a",
+ "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.1 || ^8.0"
+ },
+ "conflict": {
+ "doctrine/collections": "<1.6.8",
+ "doctrine/common": "<2.13.3 || >=3 <3.2.2"
+ },
+ "require-dev": {
+ "doctrine/collections": "^1.6.8",
+ "doctrine/common": "^2.13.3 || ^3.2.2",
+ "phpspec/prophecy": "^1.10",
+ "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13"
+ },
+ "type": "library",
+ "autoload": {
+ "files": [
+ "src/DeepCopy/deep_copy.php"
+ ],
+ "psr-4": {
+ "DeepCopy\\": "src/DeepCopy/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "description": "Create deep copies (clones) of your objects",
+ "keywords": [
+ "clone",
+ "copy",
+ "duplicate",
+ "object",
+ "object graph"
+ ],
+ "support": {
+ "issues": "https://github.com/myclabs/DeepCopy/issues",
+ "source": "https://github.com/myclabs/DeepCopy/tree/1.13.4"
+ },
+ "funding": [
+ {
+ "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2025-08-01T08:46:24+00:00"
+ },
+ {
+ "name": "paragonie/random_compat",
+ "version": "v9.99.100",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/paragonie/random_compat.git",
+ "reference": "996434e5492cb4c3edcb9168db6fbb1359ef965a"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/paragonie/random_compat/zipball/996434e5492cb4c3edcb9168db6fbb1359ef965a",
+ "reference": "996434e5492cb4c3edcb9168db6fbb1359ef965a",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">= 7"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "4.*|5.*",
+ "vimeo/psalm": "^1"
+ },
+ "suggest": {
+ "ext-libsodium": "Provides a modern crypto API that can be used to generate random bytes."
+ },
+ "type": "library",
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Paragon Initiative Enterprises",
+ "email": "security@paragonie.com",
+ "homepage": "https://paragonie.com"
+ }
+ ],
+ "description": "PHP 5.x polyfill for random_bytes() and random_int() from PHP 7",
+ "keywords": [
+ "csprng",
+ "polyfill",
+ "pseudorandom",
+ "random"
+ ],
+ "support": {
+ "email": "info@paragonie.com",
+ "issues": "https://github.com/paragonie/random_compat/issues",
+ "source": "https://github.com/paragonie/random_compat"
+ },
+ "time": "2020-10-15T08:29:30+00:00"
+ },
{
"name": "psr/cache",
"version": "3.0.0",
@@ -313,6 +596,78 @@
},
"time": "2024-09-11T13:17:53+00:00"
},
+ {
+ "name": "setasign/fpdi",
+ "version": "v2.6.4",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/Setasign/FPDI.git",
+ "reference": "4b53852fde2734ec6a07e458a085db627c60eada"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/Setasign/FPDI/zipball/4b53852fde2734ec6a07e458a085db627c60eada",
+ "reference": "4b53852fde2734ec6a07e458a085db627c60eada",
+ "shasum": ""
+ },
+ "require": {
+ "ext-zlib": "*",
+ "php": "^7.1 || ^8.0"
+ },
+ "conflict": {
+ "setasign/tfpdf": "<1.31"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^7",
+ "setasign/fpdf": "~1.8.6",
+ "setasign/tfpdf": "~1.33",
+ "squizlabs/php_codesniffer": "^3.5",
+ "tecnickcom/tcpdf": "^6.8"
+ },
+ "suggest": {
+ "setasign/fpdf": "FPDI will extend this class but as it is also possible to use TCPDF or tFPDF as an alternative. There's no fixed dependency configured."
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "setasign\\Fpdi\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Jan Slabon",
+ "email": "jan.slabon@setasign.com",
+ "homepage": "https://www.setasign.com"
+ },
+ {
+ "name": "Maximilian Kresse",
+ "email": "maximilian.kresse@setasign.com",
+ "homepage": "https://www.setasign.com"
+ }
+ ],
+ "description": "FPDI is a collection of PHP classes facilitating developers to read pages from existing PDF documents and use them as templates in FPDF. Because it is also possible to use FPDI with TCPDF, there are no fixed dependencies defined. Please see suggestions for packages which evaluates the dependencies automatically.",
+ "homepage": "https://www.setasign.com/fpdi",
+ "keywords": [
+ "fpdf",
+ "fpdi",
+ "pdf"
+ ],
+ "support": {
+ "issues": "https://github.com/Setasign/FPDI/issues",
+ "source": "https://github.com/Setasign/FPDI/tree/v2.6.4"
+ },
+ "funding": [
+ {
+ "url": "https://tidelift.com/funding/github/packagist/setasign/fpdi",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2025-08-05T09:57:14+00:00"
+ },
{
"name": "symfony/deprecation-contracts",
"version": "v3.6.0",
diff --git a/src/Admin/Dashboard.php b/src/Admin/Dashboard.php
new file mode 100644
index 0000000..48ef396
--- /dev/null
+++ b/src/Admin/Dashboard.php
@@ -0,0 +1,942 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ' . esc_html__( 'activate your license', 'wp-bnb' ) . ''
+ );
+ ?>
+
+
+ = 0 ? 'positive' : 'negative';
+ $change_icon = $change >= 0 ? 'arrow-up-alt' : 'arrow-down-alt';
+ ?>
+
+
+
+
+
+
+
%
+
+
+
+ 0 ) : ?>
+
+
+
+
+
+
+
+ 0 ? ( ( $this_month - $last_month ) / $last_month ) * 100 : 0;
+ $change_class = $change >= 0 ? 'positive' : 'negative';
+ $change_icon = $change >= 0 ? 'arrow-up-alt' : 'arrow-down-alt';
+ ?>
+
+
+
+
+
+
+
+
+
+
+ 0 ) : ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Room::POST_TYPE,
+ 'post_status' => 'publish',
+ 'posts_per_page' => -1,
+ 'fields' => 'ids',
+ )
+ );
+ $total_rooms = count( $rooms );
+
+ // Get currently occupied rooms.
+ $current_bookings = Availability::get_current_bookings();
+ $occupied_rooms = count( $current_bookings );
+
+ $rate = $total_rooms > 0 ? ( $occupied_rooms / $total_rooms ) * 100 : 0;
+
+ // Calculate last month's average occupancy.
+ $previous_rate = self::get_average_occupancy_for_month(
+ (int) gmdate( 'Y', strtotime( '-1 month' ) ),
+ (int) gmdate( 'n', strtotime( '-1 month' ) )
+ );
+
+ return array(
+ 'rate' => $rate,
+ 'occupied' => $occupied_rooms,
+ 'total' => $total_rooms,
+ 'previous_rate' => $previous_rate,
+ );
+ }
+
+ /**
+ * Get average occupancy rate for a specific month.
+ *
+ * @param int $year Year.
+ * @param int $month Month.
+ * @return float Average occupancy percentage.
+ */
+ private static function get_average_occupancy_for_month( int $year, int $month ): float {
+ $cache_key = self::CACHE_KEY . "_occupancy_{$year}_{$month}";
+ $cached = get_transient( $cache_key );
+
+ if ( false !== $cached ) {
+ return (float) $cached;
+ }
+
+ $rooms = get_posts(
+ array(
+ 'post_type' => Room::POST_TYPE,
+ 'post_status' => 'publish',
+ 'posts_per_page' => -1,
+ 'fields' => 'ids',
+ )
+ );
+ $total_rooms = count( $rooms );
+
+ if ( $total_rooms === 0 ) {
+ return 0.0;
+ }
+
+ $days_in_month = (int) gmdate( 't', mktime( 0, 0, 0, $month, 1, $year ) );
+ $total_room_nights = $total_rooms * $days_in_month;
+ $booked_nights = 0;
+
+ $month_start = sprintf( '%04d-%02d-01', $year, $month );
+ $month_end = sprintf( '%04d-%02d-%02d', $year, $month, $days_in_month );
+
+ $bookings = get_posts(
+ array(
+ 'post_type' => Booking::POST_TYPE,
+ 'post_status' => 'publish',
+ 'posts_per_page' => -1,
+ 'meta_query' => array(
+ 'relation' => 'AND',
+ array(
+ 'key' => '_bnb_booking_status',
+ 'value' => array( 'confirmed', 'checked_in', 'checked_out' ),
+ 'compare' => 'IN',
+ ),
+ array(
+ 'key' => '_bnb_booking_check_in',
+ 'value' => $month_end,
+ 'compare' => '<=',
+ 'type' => 'DATE',
+ ),
+ array(
+ 'key' => '_bnb_booking_check_out',
+ 'value' => $month_start,
+ 'compare' => '>=',
+ 'type' => 'DATE',
+ ),
+ ),
+ )
+ );
+
+ foreach ( $bookings as $booking ) {
+ $check_in = get_post_meta( $booking->ID, '_bnb_booking_check_in', true );
+ $check_out = get_post_meta( $booking->ID, '_bnb_booking_check_out', true );
+
+ // Clamp to month boundaries.
+ $start = max( $check_in, $month_start );
+ $end = min( $check_out, gmdate( 'Y-m-d', strtotime( $month_end . ' +1 day' ) ) );
+
+ $nights = Booking::calculate_nights( $start, $end );
+ $booked_nights += $nights;
+ }
+
+ $rate = ( $booked_nights / $total_room_nights ) * 100;
+
+ set_transient( $cache_key, $rate, self::CACHE_EXPIRY );
+
+ return $rate;
+ }
+
+ /**
+ * Get revenue statistics.
+ *
+ * @return array{this_month: float, last_month: float, ytd: float}
+ */
+ public static function get_revenue_stats(): array {
+ $this_month = self::get_revenue_for_period(
+ gmdate( 'Y-m-01' ),
+ gmdate( 'Y-m-t' )
+ );
+
+ $last_month = self::get_revenue_for_period(
+ gmdate( 'Y-m-01', strtotime( '-1 month' ) ),
+ gmdate( 'Y-m-t', strtotime( '-1 month' ) )
+ );
+
+ $ytd = self::get_revenue_for_period(
+ gmdate( 'Y-01-01' ),
+ gmdate( 'Y-m-d' )
+ );
+
+ return array(
+ 'this_month' => $this_month,
+ 'last_month' => $last_month,
+ 'ytd' => $ytd,
+ );
+ }
+
+ /**
+ * Get revenue for a specific period.
+ *
+ * @param string $start_date Start date (Y-m-d).
+ * @param string $end_date End date (Y-m-d).
+ * @return float Total revenue.
+ */
+ public static function get_revenue_for_period( string $start_date, string $end_date ): float {
+ $bookings = get_posts(
+ array(
+ 'post_type' => Booking::POST_TYPE,
+ 'post_status' => 'publish',
+ 'posts_per_page' => -1,
+ 'meta_query' => array(
+ 'relation' => 'AND',
+ array(
+ 'key' => '_bnb_booking_status',
+ 'value' => array( 'confirmed', 'checked_in', 'checked_out' ),
+ 'compare' => 'IN',
+ ),
+ array(
+ 'key' => '_bnb_booking_check_in',
+ 'value' => $start_date,
+ 'compare' => '>=',
+ 'type' => 'DATE',
+ ),
+ array(
+ 'key' => '_bnb_booking_check_in',
+ 'value' => $end_date,
+ 'compare' => '<=',
+ 'type' => 'DATE',
+ ),
+ ),
+ )
+ );
+
+ $total = 0.0;
+ foreach ( $bookings as $booking ) {
+ $price = get_post_meta( $booking->ID, '_bnb_booking_calculated_price', true );
+ $total += (float) $price;
+
+ // Add services total.
+ $services_total = Booking::calculate_booking_services_total( $booking->ID );
+ $total += $services_total;
+ }
+
+ return $total;
+ }
+
+ /**
+ * Get booking statistics.
+ *
+ * @return array{this_month: int, pending: int, confirmed: int}
+ */
+ public static function get_booking_stats(): array {
+ $month_start = gmdate( 'Y-m-01' );
+ $month_end = gmdate( 'Y-m-t' );
+
+ // Bookings created this month.
+ $this_month = get_posts(
+ array(
+ 'post_type' => Booking::POST_TYPE,
+ 'post_status' => 'publish',
+ 'posts_per_page' => -1,
+ 'fields' => 'ids',
+ 'date_query' => array(
+ array(
+ 'after' => $month_start,
+ 'before' => $month_end,
+ 'inclusive' => true,
+ ),
+ ),
+ )
+ );
+
+ // Pending bookings.
+ $pending = get_posts(
+ array(
+ 'post_type' => Booking::POST_TYPE,
+ 'post_status' => 'publish',
+ 'posts_per_page' => -1,
+ 'fields' => 'ids',
+ 'meta_query' => array(
+ array(
+ 'key' => '_bnb_booking_status',
+ 'value' => 'pending',
+ ),
+ ),
+ )
+ );
+
+ // Confirmed bookings.
+ $confirmed = get_posts(
+ array(
+ 'post_type' => Booking::POST_TYPE,
+ 'post_status' => 'publish',
+ 'posts_per_page' => -1,
+ 'fields' => 'ids',
+ 'meta_query' => array(
+ array(
+ 'key' => '_bnb_booking_status',
+ 'value' => 'confirmed',
+ ),
+ ),
+ )
+ );
+
+ return array(
+ 'this_month' => count( $this_month ),
+ 'pending' => count( $pending ),
+ 'confirmed' => count( $confirmed ),
+ );
+ }
+
+ /**
+ * Get guest statistics.
+ *
+ * @return array{total: int, new_this_month: int, repeat: int}
+ */
+ public static function get_guest_stats(): array {
+ $month_start = gmdate( 'Y-m-01' );
+ $month_end = gmdate( 'Y-m-t' );
+
+ // Total guests.
+ $total = get_posts(
+ array(
+ 'post_type' => Guest::POST_TYPE,
+ 'post_status' => 'publish',
+ 'posts_per_page' => -1,
+ 'fields' => 'ids',
+ )
+ );
+
+ // New guests this month.
+ $new_this_month = get_posts(
+ array(
+ 'post_type' => Guest::POST_TYPE,
+ 'post_status' => 'publish',
+ 'posts_per_page' => -1,
+ 'fields' => 'ids',
+ 'date_query' => array(
+ array(
+ 'after' => $month_start,
+ 'before' => $month_end,
+ 'inclusive' => true,
+ ),
+ ),
+ )
+ );
+
+ // Repeat guests (2+ bookings).
+ $all_guests = get_posts(
+ array(
+ 'post_type' => Guest::POST_TYPE,
+ 'post_status' => 'publish',
+ 'posts_per_page' => -1,
+ )
+ );
+
+ $repeat = 0;
+ foreach ( $all_guests as $guest ) {
+ $booking_count = Guest::get_booking_count( $guest->ID );
+ if ( $booking_count >= 2 ) {
+ ++$repeat;
+ }
+ }
+
+ return array(
+ 'total' => count( $total ),
+ 'new_this_month' => count( $new_this_month ),
+ 'repeat' => $repeat,
+ );
+ }
+
+ /**
+ * Get upcoming bookings.
+ *
+ * @param int $days Number of days to look ahead.
+ * @return array<\WP_Post> Array of booking posts.
+ */
+ private static function get_upcoming_bookings( int $days = 7 ): array {
+ $today = gmdate( 'Y-m-d' );
+ $end_date = gmdate( 'Y-m-d', strtotime( "+{$days} days" ) );
+
+ return get_posts(
+ array(
+ 'post_type' => Booking::POST_TYPE,
+ 'post_status' => 'publish',
+ 'posts_per_page' => 10,
+ 'meta_query' => array(
+ 'relation' => 'AND',
+ array(
+ 'key' => '_bnb_booking_status',
+ 'value' => array( 'pending', 'confirmed' ),
+ 'compare' => 'IN',
+ ),
+ array(
+ 'key' => '_bnb_booking_check_in',
+ 'value' => $today,
+ 'compare' => '>=',
+ 'type' => 'DATE',
+ ),
+ array(
+ 'key' => '_bnb_booking_check_in',
+ 'value' => $end_date,
+ 'compare' => '<=',
+ 'type' => 'DATE',
+ ),
+ ),
+ 'orderby' => 'meta_value',
+ 'meta_key' => '_bnb_booking_check_in',
+ 'order' => 'ASC',
+ )
+ );
+ }
+
+ /**
+ * Get occupancy trend data for charts.
+ *
+ * @param int $days Number of days to include.
+ * @return array{labels: array, data: array}
+ */
+ public static function get_occupancy_trend_data( int $days = 30 ): array {
+ $labels = array();
+ $data = array();
+
+ $rooms = get_posts(
+ array(
+ 'post_type' => Room::POST_TYPE,
+ 'post_status' => 'publish',
+ 'posts_per_page' => -1,
+ 'fields' => 'ids',
+ )
+ );
+ $total_rooms = count( $rooms );
+
+ if ( $total_rooms === 0 ) {
+ return array(
+ 'labels' => array(),
+ 'data' => array(),
+ );
+ }
+
+ for ( $i = $days - 1; $i >= 0; $i-- ) {
+ $date = gmdate( 'Y-m-d', strtotime( "-{$i} days" ) );
+ $labels[] = wp_date( 'M j', strtotime( $date ) );
+
+ // Count bookings active on this date.
+ $bookings = get_posts(
+ array(
+ 'post_type' => Booking::POST_TYPE,
+ 'post_status' => 'publish',
+ 'posts_per_page' => -1,
+ 'fields' => 'ids',
+ 'meta_query' => array(
+ 'relation' => 'AND',
+ array(
+ 'key' => '_bnb_booking_status',
+ 'value' => array( 'confirmed', 'checked_in', 'checked_out' ),
+ 'compare' => 'IN',
+ ),
+ array(
+ 'key' => '_bnb_booking_check_in',
+ 'value' => $date,
+ 'compare' => '<=',
+ 'type' => 'DATE',
+ ),
+ array(
+ 'key' => '_bnb_booking_check_out',
+ 'value' => $date,
+ 'compare' => '>',
+ 'type' => 'DATE',
+ ),
+ ),
+ )
+ );
+
+ $rate = ( count( $bookings ) / $total_rooms ) * 100;
+ $data[] = round( $rate, 1 );
+ }
+
+ return array(
+ 'labels' => $labels,
+ 'data' => $data,
+ );
+ }
+
+ /**
+ * Get revenue trend data for charts.
+ *
+ * @param int $months Number of months to include.
+ * @return array{labels: array, data: array}
+ */
+ public static function get_revenue_trend_data( int $months = 6 ): array {
+ $labels = array();
+ $data = array();
+
+ for ( $i = $months - 1; $i >= 0; $i-- ) {
+ $month_start = gmdate( 'Y-m-01', strtotime( "-{$i} months" ) );
+ $month_end = gmdate( 'Y-m-t', strtotime( "-{$i} months" ) );
+ $month_name = gmdate( 'M Y', strtotime( $month_start ) );
+
+ $labels[] = $month_name;
+ $data[] = self::get_revenue_for_period( $month_start, $month_end );
+ }
+
+ return array(
+ 'labels' => $labels,
+ 'data' => $data,
+ );
+ }
+}
diff --git a/src/Admin/Reports.php b/src/Admin/Reports.php
new file mode 100644
index 0000000..7a04a8d
--- /dev/null
+++ b/src/Admin/Reports.php
@@ -0,0 +1,1368 @@
+
+
+ 'wp-bnb-reports',
+ 'tab' => $tab,
+ );
+
+ if ( $period ) {
+ $args['period'] = $period;
+ }
+
+ if ( 'custom' === $period && $start_date && $end_date ) {
+ $args['start_date'] = $start_date;
+ $args['end_date'] = $end_date;
+ }
+
+ return add_query_arg( $args, admin_url( 'admin.php' ) );
+ }
+
+ /**
+ * Get date range based on period selection.
+ *
+ * @param string $period Period key.
+ * @param string $start_date Custom start date.
+ * @param string $end_date Custom end date.
+ * @return array{start: string, end: string, label: string}
+ */
+ private static function get_date_range( string $period, string $start_date, string $end_date ): array {
+ switch ( $period ) {
+ case 'last_month':
+ return array(
+ 'start' => gmdate( 'Y-m-01', strtotime( '-1 month' ) ),
+ 'end' => gmdate( 'Y-m-t', strtotime( '-1 month' ) ),
+ 'label' => gmdate( 'F Y', strtotime( '-1 month' ) ),
+ );
+
+ case 'this_year':
+ return array(
+ 'start' => gmdate( 'Y-01-01' ),
+ 'end' => gmdate( 'Y-m-d' ),
+ 'label' => gmdate( 'Y' ),
+ );
+
+ case 'last_year':
+ return array(
+ 'start' => gmdate( 'Y-01-01', strtotime( '-1 year' ) ),
+ 'end' => gmdate( 'Y-12-31', strtotime( '-1 year' ) ),
+ 'label' => gmdate( 'Y', strtotime( '-1 year' ) ),
+ );
+
+ case 'custom':
+ if ( $start_date && $end_date ) {
+ return array(
+ 'start' => $start_date,
+ 'end' => $end_date,
+ 'label' => sprintf(
+ /* translators: 1: Start date, 2: End date */
+ __( '%1$s to %2$s', 'wp-bnb' ),
+ wp_date( get_option( 'date_format' ), strtotime( $start_date ) ),
+ wp_date( get_option( 'date_format' ), strtotime( $end_date ) )
+ ),
+ );
+ }
+ // Fall through to this_month if custom dates not provided.
+
+ case 'this_month':
+ default:
+ return array(
+ 'start' => gmdate( 'Y-m-01' ),
+ 'end' => gmdate( 'Y-m-t' ),
+ 'label' => gmdate( 'F Y' ),
+ );
+ }
+ }
+
+ /**
+ * Render date filter controls.
+ *
+ * @param string $active_tab Current tab.
+ * @param string $period Current period.
+ * @param string $start_date Custom start date.
+ * @param string $end_date Custom end date.
+ * @param array $dates Date range data.
+ * @return void
+ */
+ private static function render_date_filter( string $active_tab, string $period, string $start_date, string $end_date, array $dates ): void {
+ ?>
+
+
+
+
+
+
+ %
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ |
+ |
+ |
+ |
+ |
+
+
+
+
+
+ |
+
+
+
+ |
+ |
+ |
+
+
+ |
+
+ = 80 ) {
+ echo '' . esc_html__( 'High', 'wp-bnb' ) . '';
+ } elseif ( $room['rate'] >= 50 ) {
+ echo '' . esc_html__( 'Medium', 'wp-bnb' ) . '';
+ } else {
+ echo '' . esc_html__( 'Low', 'wp-bnb' ) . '';
+ }
+ ?>
+ |
+
+
+
+
+
+
+
+
+
+
+
+
+ |
+ |
+ |
+ |
+
+
+
+
+
+ |
+
+
+
+ |
+ |
+ |
+
+
+ |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ |
+ |
+ |
+ |
+ |
+
+
+
+
+
+ |
+
+
+
+ |
+ |
+ |
+ |
+ % |
+
+
+
+
+
+ |
+ |
+ |
+ |
+ 100% |
+
+
+
+
+
+
+
+
+
+
+
+ |
+ |
+ |
+ |
+
+
+
+ $tier ) : ?>
+
+ |
+ |
+ |
+ % |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ |
+ |
+ |
+ |
+
+
+
+
+
+ |
+
+
+
+
+
+
+
+ |
+ |
+ |
+ |
+
+
+
+
+
+
+
+
+
+
+
+
+ |
+ |
+ |
+
+
+
+
+
+ |
+ |
+ % |
+
+
+
+
+
+
+
+
+ 'wp-bnb-reports',
+ 'tab' => $tab,
+ 'period' => $period,
+ 'export' => $format,
+ '_wpnonce' => $nonce,
+ );
+
+ if ( 'custom' === $period ) {
+ $args['start_date'] = $start_date;
+ $args['end_date'] = $end_date;
+ }
+
+ return add_query_arg( $args, admin_url( 'admin.php' ) );
+ }
+
+ /**
+ * Handle export requests.
+ *
+ * @return void
+ */
+ private static function handle_export(): void {
+ // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Nonce verified below.
+ if ( ! isset( $_GET['export'] ) ) {
+ return;
+ }
+
+ // Verify nonce.
+ if ( ! isset( $_GET['_wpnonce'] ) || ! wp_verify_nonce( sanitize_key( $_GET['_wpnonce'] ), 'wp_bnb_export_report' ) ) {
+ wp_die( esc_html__( 'Security check failed.', 'wp-bnb' ) );
+ }
+
+ // Check permissions.
+ if ( ! current_user_can( 'manage_options' ) ) {
+ wp_die( esc_html__( 'You do not have permission to export reports.', 'wp-bnb' ) );
+ }
+
+ $format = sanitize_key( wp_unslash( $_GET['export'] ) );
+ $tab = isset( $_GET['tab'] ) ? sanitize_key( $_GET['tab'] ) : 'occupancy';
+ $period = isset( $_GET['period'] ) ? sanitize_key( $_GET['period'] ) : 'this_month';
+
+ $start_date = isset( $_GET['start_date'] ) ? sanitize_text_field( wp_unslash( $_GET['start_date'] ) ) : '';
+ $end_date = isset( $_GET['end_date'] ) ? sanitize_text_field( wp_unslash( $_GET['end_date'] ) ) : '';
+
+ $dates = self::get_date_range( $period, $start_date, $end_date );
+
+ if ( 'csv' === $format ) {
+ self::export_csv( $tab, $dates );
+ } elseif ( 'pdf' === $format ) {
+ self::export_pdf( $tab, $dates );
+ }
+ }
+
+ /**
+ * Export report as CSV.
+ *
+ * @param string $tab Report tab.
+ * @param array $dates Date range.
+ * @return void
+ */
+ private static function export_csv( string $tab, array $dates ): void {
+ $filename = sprintf( 'wp-bnb-%s-report-%s.csv', $tab, gmdate( 'Y-m-d' ) );
+
+ header( 'Content-Type: text/csv; charset=utf-8' );
+ header( 'Content-Disposition: attachment; filename=' . $filename );
+ header( 'Pragma: no-cache' );
+ header( 'Expires: 0' );
+
+ $output = fopen( 'php://output', 'w' );
+
+ // Add BOM for Excel compatibility.
+ fwrite( $output, "\xEF\xBB\xBF" );
+
+ switch ( $tab ) {
+ case 'revenue':
+ self::export_revenue_csv( $output, $dates );
+ break;
+ case 'guests':
+ self::export_guests_csv( $output, $dates );
+ break;
+ default:
+ self::export_occupancy_csv( $output, $dates );
+ break;
+ }
+
+ fclose( $output );
+ exit;
+ }
+
+ /**
+ * Export occupancy report as CSV.
+ *
+ * @param resource $output File handle.
+ * @param array $dates Date range.
+ * @return void
+ */
+ private static function export_occupancy_csv( $output, array $dates ): void {
+ $data = self::get_occupancy_data( $dates['start'], $dates['end'] );
+
+ // Report header.
+ fputcsv( $output, array( __( 'Occupancy Report', 'wp-bnb' ), $dates['label'] ) );
+ fputcsv( $output, array() );
+
+ // Summary.
+ fputcsv( $output, array( __( 'Summary', 'wp-bnb' ) ) );
+ fputcsv( $output, array( __( 'Overall Occupancy', 'wp-bnb' ), number_format( $data['overall_rate'], 1 ) . '%' ) );
+ fputcsv( $output, array( __( 'Nights Booked', 'wp-bnb' ), $data['total_nights_booked'] ) );
+ fputcsv( $output, array( __( 'Nights Available', 'wp-bnb' ), $data['total_nights_available'] ) );
+ fputcsv( $output, array() );
+
+ // Room data.
+ fputcsv( $output, array( __( 'Room', 'wp-bnb' ), __( 'Building', 'wp-bnb' ), __( 'Nights Booked', 'wp-bnb' ), __( 'Occupancy %', 'wp-bnb' ) ) );
+ foreach ( $data['rooms'] as $room ) {
+ fputcsv( $output, array( $room['name'], $room['building'], $room['nights_booked'], number_format( $room['rate'], 1 ) . '%' ) );
+ }
+ }
+
+ /**
+ * Export revenue report as CSV.
+ *
+ * @param resource $output File handle.
+ * @param array $dates Date range.
+ * @return void
+ */
+ private static function export_revenue_csv( $output, array $dates ): void {
+ $data = self::get_revenue_data( $dates['start'], $dates['end'] );
+
+ // Report header.
+ fputcsv( $output, array( __( 'Revenue Report', 'wp-bnb' ), $dates['label'] ) );
+ fputcsv( $output, array() );
+
+ // Summary.
+ fputcsv( $output, array( __( 'Summary', 'wp-bnb' ) ) );
+ fputcsv( $output, array( __( 'Total Revenue', 'wp-bnb' ), Calculator::formatPrice( $data['total'] ) ) );
+ fputcsv( $output, array( __( 'Room Revenue', 'wp-bnb' ), Calculator::formatPrice( $data['room_revenue'] ) ) );
+ fputcsv( $output, array( __( 'Services Revenue', 'wp-bnb' ), Calculator::formatPrice( $data['services_revenue'] ) ) );
+ fputcsv( $output, array( __( 'Bookings', 'wp-bnb' ), $data['bookings_count'] ) );
+ fputcsv( $output, array() );
+
+ // By room.
+ fputcsv( $output, array( __( 'Room', 'wp-bnb' ), __( 'Bookings', 'wp-bnb' ), __( 'Nights', 'wp-bnb' ), __( 'Revenue', 'wp-bnb' ), __( '% of Total', 'wp-bnb' ) ) );
+ foreach ( $data['by_room'] as $room ) {
+ fputcsv( $output, array( $room['name'], $room['bookings'], $room['nights'], Calculator::formatPrice( $room['revenue'] ), number_format( $room['percentage'], 1 ) . '%' ) );
+ }
+ }
+
+ /**
+ * Export guests report as CSV.
+ *
+ * @param resource $output File handle.
+ * @param array $dates Date range.
+ * @return void
+ */
+ private static function export_guests_csv( $output, array $dates ): void {
+ $data = self::get_guests_data( $dates['start'], $dates['end'] );
+
+ // Report header.
+ fputcsv( $output, array( __( 'Guests Report', 'wp-bnb' ), $dates['label'] ) );
+ fputcsv( $output, array() );
+
+ // Summary.
+ fputcsv( $output, array( __( 'Summary', 'wp-bnb' ) ) );
+ fputcsv( $output, array( __( 'Total Guests', 'wp-bnb' ), $data['total_guests'] ) );
+ fputcsv( $output, array( __( 'New Guests', 'wp-bnb' ), $data['new_guests'] ) );
+ fputcsv( $output, array( __( 'Repeat Guests', 'wp-bnb' ), $data['repeat_guests'] ) );
+ fputcsv( $output, array() );
+
+ // Top guests.
+ fputcsv( $output, array( __( 'Guest', 'wp-bnb' ), __( 'Bookings', 'wp-bnb' ), __( 'Nights', 'wp-bnb' ), __( 'Total Spent', 'wp-bnb' ) ) );
+ foreach ( $data['top_guests'] as $guest ) {
+ fputcsv( $output, array( $guest['name'], $guest['bookings'], $guest['nights'], Calculator::formatPrice( $guest['total_spent'] ) ) );
+ }
+ }
+
+ /**
+ * Export report as PDF.
+ *
+ * @param string $tab Report tab.
+ * @param array $dates Date range.
+ * @return void
+ */
+ private static function export_pdf( string $tab, array $dates ): void {
+ $filename = sprintf( 'wp-bnb-%s-report-%s.pdf', $tab, gmdate( 'Y-m-d' ) );
+
+ // Get site info.
+ $site_name = get_bloginfo( 'name' );
+
+ // Build HTML content.
+ $html = self::get_pdf_html( $tab, $dates, $site_name );
+
+ // Generate PDF using mPDF.
+ try {
+ // Use WordPress temp directory for mPDF.
+ $temp_dir = get_temp_dir() . 'mpdf';
+ if ( ! file_exists( $temp_dir ) ) {
+ wp_mkdir_p( $temp_dir );
+ }
+
+ $mpdf = new Mpdf(
+ array(
+ 'mode' => 'utf-8',
+ 'format' => 'A4',
+ 'margin_left' => 15,
+ 'margin_right' => 15,
+ 'margin_top' => 15,
+ 'margin_bottom' => 15,
+ 'tempDir' => $temp_dir,
+ )
+ );
+
+ $mpdf->SetTitle( sprintf( '%s - %s Report', $site_name, ucfirst( $tab ) ) );
+ $mpdf->SetAuthor( $site_name );
+ $mpdf->SetCreator( 'WP BnB' );
+
+ $mpdf->WriteHTML( $html );
+ $mpdf->Output( $filename, 'D' );
+ } catch ( \Exception $e ) {
+ wp_die( esc_html( sprintf( __( 'PDF generation failed: %s', 'wp-bnb' ), $e->getMessage() ) ) );
+ }
+
+ exit;
+ }
+
+ /**
+ * Get PDF HTML content.
+ *
+ * @param string $tab Report tab.
+ * @param array $dates Date range.
+ * @param string $site_name Site name.
+ * @return string HTML content.
+ */
+ private static function get_pdf_html( string $tab, array $dates, string $site_name ): string {
+ $title = '';
+ switch ( $tab ) {
+ case 'revenue':
+ $title = __( 'Revenue Report', 'wp-bnb' );
+ $data = self::get_revenue_data( $dates['start'], $dates['end'] );
+ break;
+ case 'guests':
+ $title = __( 'Guests Report', 'wp-bnb' );
+ $data = self::get_guests_data( $dates['start'], $dates['end'] );
+ break;
+ default:
+ $title = __( 'Occupancy Report', 'wp-bnb' );
+ $data = self::get_occupancy_data( $dates['start'], $dates['end'] );
+ break;
+ }
+
+ $html = '';
+
+ $html .= '' . esc_html( $title ) . '
';
+ $html .= '' . esc_html( $site_name ) . ' | ' . esc_html( $dates['label'] ) . '
';
+
+ // Generate report-specific content.
+ switch ( $tab ) {
+ case 'revenue':
+ $html .= self::get_revenue_pdf_content( $data );
+ break;
+ case 'guests':
+ $html .= self::get_guests_pdf_content( $data );
+ break;
+ default:
+ $html .= self::get_occupancy_pdf_content( $data );
+ break;
+ }
+
+ $html .= '';
+
+ $html .= '';
+
+ return $html;
+ }
+
+ /**
+ * Get occupancy PDF content.
+ *
+ * @param array $data Report data.
+ * @return string HTML content.
+ */
+ private static function get_occupancy_pdf_content( array $data ): string {
+ $html = '' . esc_html__( 'Summary', 'wp-bnb' ) . '
';
+ $html .= '';
+ $html .= '' . number_format( $data['overall_rate'], 1 ) . '% ' . esc_html__( 'Occupancy', 'wp-bnb' ) . ' | ';
+ $html .= '' . number_format( $data['total_nights_booked'] ) . ' ' . esc_html__( 'Nights Booked', 'wp-bnb' ) . ' | ';
+ $html .= '' . number_format( $data['total_nights_available'] ) . ' ' . esc_html__( 'Available', 'wp-bnb' ) . ' | ';
+ $html .= '' . count( $data['rooms'] ) . ' ' . esc_html__( 'Rooms', 'wp-bnb' ) . ' | ';
+ $html .= '
';
+
+ if ( ! empty( $data['rooms'] ) ) {
+ $html .= '' . esc_html__( 'Occupancy by Room', 'wp-bnb' ) . '
';
+ $html .= '| ' . esc_html__( 'Room', 'wp-bnb' ) . ' | ' . esc_html__( 'Building', 'wp-bnb' ) . ' | ' . esc_html__( 'Nights', 'wp-bnb' ) . ' | ' . esc_html__( 'Rate', 'wp-bnb' ) . ' |
';
+ foreach ( $data['rooms'] as $room ) {
+ $html .= '| ' . esc_html( $room['name'] ) . ' | ' . esc_html( $room['building'] ) . ' | ' . $room['nights_booked'] . ' | ' . number_format( $room['rate'], 1 ) . '% |
';
+ }
+ $html .= '
';
+ }
+
+ return $html;
+ }
+
+ /**
+ * Get revenue PDF content.
+ *
+ * @param array $data Report data.
+ * @return string HTML content.
+ */
+ private static function get_revenue_pdf_content( array $data ): string {
+ $html = '' . esc_html__( 'Summary', 'wp-bnb' ) . '
';
+ $html .= '';
+ $html .= '' . esc_html( Calculator::formatPrice( $data['total'] ) ) . ' ' . esc_html__( 'Total', 'wp-bnb' ) . ' | ';
+ $html .= '' . esc_html( Calculator::formatPrice( $data['room_revenue'] ) ) . ' ' . esc_html__( 'Rooms', 'wp-bnb' ) . ' | ';
+ $html .= '' . esc_html( Calculator::formatPrice( $data['services_revenue'] ) ) . ' ' . esc_html__( 'Services', 'wp-bnb' ) . ' | ';
+ $html .= '' . $data['bookings_count'] . ' ' . esc_html__( 'Bookings', 'wp-bnb' ) . ' | ';
+ $html .= '
';
+
+ if ( ! empty( $data['by_room'] ) ) {
+ $html .= '' . esc_html__( 'Revenue by Room', 'wp-bnb' ) . '
';
+ $html .= '| ' . esc_html__( 'Room', 'wp-bnb' ) . ' | ' . esc_html__( 'Bookings', 'wp-bnb' ) . ' | ' . esc_html__( 'Nights', 'wp-bnb' ) . ' | ' . esc_html__( 'Revenue', 'wp-bnb' ) . ' | % |
';
+ foreach ( $data['by_room'] as $room ) {
+ $html .= '| ' . esc_html( $room['name'] ) . ' | ' . $room['bookings'] . ' | ' . $room['nights'] . ' | ' . esc_html( Calculator::formatPrice( $room['revenue'] ) ) . ' | ' . number_format( $room['percentage'], 1 ) . '% |
';
+ }
+ $html .= '
';
+ }
+
+ return $html;
+ }
+
+ /**
+ * Get guests PDF content.
+ *
+ * @param array $data Report data.
+ * @return string HTML content.
+ */
+ private static function get_guests_pdf_content( array $data ): string {
+ $html = '' . esc_html__( 'Summary', 'wp-bnb' ) . '
';
+ $html .= '';
+ $html .= '' . $data['total_guests'] . ' ' . esc_html__( 'Total', 'wp-bnb' ) . ' | ';
+ $html .= '' . $data['new_guests'] . ' ' . esc_html__( 'New', 'wp-bnb' ) . ' | ';
+ $html .= '' . $data['repeat_guests'] . ' ' . esc_html__( 'Repeat', 'wp-bnb' ) . ' | ';
+ $html .= '' . esc_html( Calculator::formatPrice( $data['avg_guest_value'] ) ) . ' ' . esc_html__( 'Avg Value', 'wp-bnb' ) . ' | ';
+ $html .= '
';
+
+ if ( ! empty( $data['top_guests'] ) ) {
+ $html .= '' . esc_html__( 'Top Guests', 'wp-bnb' ) . '
';
+ $html .= '| ' . esc_html__( 'Guest', 'wp-bnb' ) . ' | ' . esc_html__( 'Bookings', 'wp-bnb' ) . ' | ' . esc_html__( 'Nights', 'wp-bnb' ) . ' | ' . esc_html__( 'Spent', 'wp-bnb' ) . ' |
';
+ foreach ( $data['top_guests'] as $guest ) {
+ $html .= '| ' . esc_html( $guest['name'] ) . ' | ' . $guest['bookings'] . ' | ' . $guest['nights'] . ' | ' . esc_html( Calculator::formatPrice( $guest['total_spent'] ) ) . ' |
';
+ }
+ $html .= '
';
+ }
+
+ return $html;
+ }
+
+ /**
+ * Get occupancy data for a date range.
+ *
+ * @param string $start_date Start date (Y-m-d).
+ * @param string $end_date End date (Y-m-d).
+ * @return array Report data.
+ */
+ public static function get_occupancy_data( string $start_date, string $end_date ): array {
+ $rooms = get_posts(
+ array(
+ 'post_type' => Room::POST_TYPE,
+ 'post_status' => 'publish',
+ 'posts_per_page' => -1,
+ )
+ );
+
+ $days_in_period = max( 1, Booking::calculate_nights( $start_date, $end_date ) );
+ $total_rooms = count( $rooms );
+ $total_nights_available = $total_rooms * $days_in_period;
+
+ $room_data = array();
+ $building_data = array();
+ $total_nights_booked = 0;
+
+ foreach ( $rooms as $room ) {
+ $building = Room::get_building( $room->ID );
+ $building_id = $building ? $building->ID : 0;
+
+ // Get bookings for this room in the period.
+ $bookings = get_posts(
+ array(
+ 'post_type' => Booking::POST_TYPE,
+ 'post_status' => 'publish',
+ 'posts_per_page' => -1,
+ 'meta_query' => array(
+ 'relation' => 'AND',
+ array(
+ 'key' => '_bnb_booking_room_id',
+ 'value' => $room->ID,
+ ),
+ array(
+ 'key' => '_bnb_booking_status',
+ 'value' => array( 'confirmed', 'checked_in', 'checked_out' ),
+ 'compare' => 'IN',
+ ),
+ array(
+ 'key' => '_bnb_booking_check_in',
+ 'value' => $end_date,
+ 'compare' => '<=',
+ 'type' => 'DATE',
+ ),
+ array(
+ 'key' => '_bnb_booking_check_out',
+ 'value' => $start_date,
+ 'compare' => '>=',
+ 'type' => 'DATE',
+ ),
+ ),
+ )
+ );
+
+ $nights_booked = 0;
+ foreach ( $bookings as $booking ) {
+ $check_in = get_post_meta( $booking->ID, '_bnb_booking_check_in', true );
+ $check_out = get_post_meta( $booking->ID, '_bnb_booking_check_out', true );
+
+ // Clamp to period boundaries.
+ $effective_start = max( $check_in, $start_date );
+ $effective_end = min( $check_out, gmdate( 'Y-m-d', strtotime( $end_date . ' +1 day' ) ) );
+
+ $nights = Booking::calculate_nights( $effective_start, $effective_end );
+ $nights_booked += $nights;
+ }
+
+ $rate = $days_in_period > 0 ? ( $nights_booked / $days_in_period ) * 100 : 0;
+ $total_nights_booked += $nights_booked;
+
+ $room_data[] = array(
+ 'id' => $room->ID,
+ 'name' => $room->post_title,
+ 'building' => $building ? $building->post_title : __( 'Unassigned', 'wp-bnb' ),
+ 'building_id' => $building_id,
+ 'nights_booked' => $nights_booked,
+ 'rate' => $rate,
+ );
+
+ // Aggregate building data.
+ if ( $building_id ) {
+ if ( ! isset( $building_data[ $building_id ] ) ) {
+ $building_data[ $building_id ] = array(
+ 'id' => $building_id,
+ 'name' => $building->post_title,
+ 'rooms' => 0,
+ 'nights_booked' => 0,
+ 'total_nights' => 0,
+ );
+ }
+ $building_data[ $building_id ]['rooms']++;
+ $building_data[ $building_id ]['nights_booked'] += $nights_booked;
+ $building_data[ $building_id ]['total_nights'] += $days_in_period;
+ }
+ }
+
+ // Calculate building rates.
+ foreach ( $building_data as &$building ) {
+ $building['rate'] = $building['total_nights'] > 0
+ ? ( $building['nights_booked'] / $building['total_nights'] ) * 100
+ : 0;
+ }
+
+ // Sort rooms by occupancy rate descending.
+ usort( $room_data, fn( $a, $b ) => $b['rate'] <=> $a['rate'] );
+
+ $overall_rate = $total_nights_available > 0
+ ? ( $total_nights_booked / $total_nights_available ) * 100
+ : 0;
+
+ return array(
+ 'overall_rate' => $overall_rate,
+ 'total_nights_booked' => $total_nights_booked,
+ 'total_nights_available' => $total_nights_available,
+ 'rooms' => $room_data,
+ 'buildings' => array_values( $building_data ),
+ );
+ }
+
+ /**
+ * Get revenue data for a date range.
+ *
+ * @param string $start_date Start date (Y-m-d).
+ * @param string $end_date End date (Y-m-d).
+ * @return array Report data.
+ */
+ public static function get_revenue_data( string $start_date, string $end_date ): array {
+ $bookings = get_posts(
+ array(
+ 'post_type' => Booking::POST_TYPE,
+ 'post_status' => 'publish',
+ 'posts_per_page' => -1,
+ 'meta_query' => array(
+ 'relation' => 'AND',
+ array(
+ 'key' => '_bnb_booking_status',
+ 'value' => array( 'confirmed', 'checked_in', 'checked_out' ),
+ 'compare' => 'IN',
+ ),
+ array(
+ 'key' => '_bnb_booking_check_in',
+ 'value' => $start_date,
+ 'compare' => '>=',
+ 'type' => 'DATE',
+ ),
+ array(
+ 'key' => '_bnb_booking_check_in',
+ 'value' => $end_date,
+ 'compare' => '<=',
+ 'type' => 'DATE',
+ ),
+ ),
+ )
+ );
+
+ $total = 0.0;
+ $room_revenue = 0.0;
+ $services_revenue = 0.0;
+ $total_nights = 0;
+ $by_room = array();
+ $by_tier = array(
+ 'short_term' => array(
+ 'label' => __( 'Short-term (1-6 nights)', 'wp-bnb' ),
+ 'bookings' => 0,
+ 'revenue' => 0,
+ 'percentage' => 0,
+ ),
+ 'mid_term' => array(
+ 'label' => __( 'Mid-term (7-29 nights)', 'wp-bnb' ),
+ 'bookings' => 0,
+ 'revenue' => 0,
+ 'percentage' => 0,
+ ),
+ 'long_term' => array(
+ 'label' => __( 'Long-term (30+ nights)', 'wp-bnb' ),
+ 'bookings' => 0,
+ 'revenue' => 0,
+ 'percentage' => 0,
+ ),
+ );
+
+ foreach ( $bookings as $booking ) {
+ $room_id = (int) get_post_meta( $booking->ID, '_bnb_booking_room_id', true );
+ $room_price = (float) get_post_meta( $booking->ID, '_bnb_booking_calculated_price', true );
+ $check_in = get_post_meta( $booking->ID, '_bnb_booking_check_in', true );
+ $check_out = get_post_meta( $booking->ID, '_bnb_booking_check_out', true );
+ $nights = Booking::calculate_nights( $check_in, $check_out );
+ $svc_total = Booking::calculate_booking_services_total( $booking->ID );
+
+ $booking_total = $room_price + $svc_total;
+
+ $total += $booking_total;
+ $room_revenue += $room_price;
+ $services_revenue += $svc_total;
+ $total_nights += $nights;
+
+ // By room.
+ if ( ! isset( $by_room[ $room_id ] ) ) {
+ $room = get_post( $room_id );
+ $by_room[ $room_id ] = array(
+ 'id' => $room_id,
+ 'name' => $room ? $room->post_title : __( 'Unknown Room', 'wp-bnb' ),
+ 'bookings' => 0,
+ 'nights' => 0,
+ 'revenue' => 0,
+ );
+ }
+ $by_room[ $room_id ]['bookings']++;
+ $by_room[ $room_id ]['nights'] += $nights;
+ $by_room[ $room_id ]['revenue'] += $booking_total;
+
+ // By tier.
+ if ( $nights < 7 ) {
+ $tier = 'short_term';
+ } elseif ( $nights < 30 ) {
+ $tier = 'mid_term';
+ } else {
+ $tier = 'long_term';
+ }
+ $by_tier[ $tier ]['bookings']++;
+ $by_tier[ $tier ]['revenue'] += $booking_total;
+ }
+
+ // Calculate percentages.
+ foreach ( $by_room as &$room ) {
+ $room['percentage'] = $total > 0 ? ( $room['revenue'] / $total ) * 100 : 0;
+ }
+
+ foreach ( $by_tier as &$tier ) {
+ $tier['percentage'] = $total > 0 ? ( $tier['revenue'] / $total ) * 100 : 0;
+ }
+
+ // Sort by revenue descending.
+ usort( $by_room, fn( $a, $b ) => $b['revenue'] <=> $a['revenue'] );
+
+ $bookings_count = count( $bookings );
+ $avg_booking_value = $bookings_count > 0 ? $total / $bookings_count : 0;
+ $avg_nightly_rate = $total_nights > 0 ? $room_revenue / $total_nights : 0;
+ $avg_nights = $bookings_count > 0 ? $total_nights / $bookings_count : 0;
+
+ return array(
+ 'total' => $total,
+ 'room_revenue' => $room_revenue,
+ 'services_revenue' => $services_revenue,
+ 'bookings_count' => $bookings_count,
+ 'total_nights' => $total_nights,
+ 'avg_booking_value' => $avg_booking_value,
+ 'avg_nightly_rate' => $avg_nightly_rate,
+ 'avg_nights' => $avg_nights,
+ 'by_room' => $by_room,
+ 'by_tier' => $by_tier,
+ );
+ }
+
+ /**
+ * Get guests data for a date range.
+ *
+ * @param string $start_date Start date (Y-m-d).
+ * @param string $end_date End date (Y-m-d).
+ * @return array Report data.
+ */
+ public static function get_guests_data( string $start_date, string $end_date ): array {
+ // Get all guests.
+ $all_guests = get_posts(
+ array(
+ 'post_type' => Guest::POST_TYPE,
+ 'post_status' => 'publish',
+ 'posts_per_page' => -1,
+ )
+ );
+
+ // Get guests created in the period.
+ $new_guests = get_posts(
+ array(
+ 'post_type' => Guest::POST_TYPE,
+ 'post_status' => 'publish',
+ 'posts_per_page' => -1,
+ 'date_query' => array(
+ array(
+ 'after' => $start_date,
+ 'before' => $end_date,
+ 'inclusive' => true,
+ ),
+ ),
+ )
+ );
+
+ // Get bookings in the period with guest data.
+ $bookings = get_posts(
+ array(
+ 'post_type' => Booking::POST_TYPE,
+ 'post_status' => 'publish',
+ 'posts_per_page' => -1,
+ 'meta_query' => array(
+ 'relation' => 'AND',
+ array(
+ 'key' => '_bnb_booking_status',
+ 'value' => array( 'confirmed', 'checked_in', 'checked_out' ),
+ 'compare' => 'IN',
+ ),
+ array(
+ 'key' => '_bnb_booking_check_in',
+ 'value' => $start_date,
+ 'compare' => '>=',
+ 'type' => 'DATE',
+ ),
+ array(
+ 'key' => '_bnb_booking_check_in',
+ 'value' => $end_date,
+ 'compare' => '<=',
+ 'type' => 'DATE',
+ ),
+ ),
+ )
+ );
+
+ // Aggregate guest data from bookings.
+ $guest_stats = array();
+ $total_revenue = 0.0;
+
+ foreach ( $bookings as $booking ) {
+ $guest_id = (int) get_post_meta( $booking->ID, '_bnb_booking_guest_id', true );
+ $guest_name = get_post_meta( $booking->ID, '_bnb_booking_guest_name', true );
+ $check_in = get_post_meta( $booking->ID, '_bnb_booking_check_in', true );
+ $check_out = get_post_meta( $booking->ID, '_bnb_booking_check_out', true );
+ $price = (float) get_post_meta( $booking->ID, '_bnb_booking_calculated_price', true );
+ $svc_total = Booking::calculate_booking_services_total( $booking->ID );
+
+ $nights = Booking::calculate_nights( $check_in, $check_out );
+ $booking_total = $price + $svc_total;
+ $total_revenue += $booking_total;
+
+ $key = $guest_id ?: 'anon_' . sanitize_title( $guest_name );
+ if ( ! isset( $guest_stats[ $key ] ) ) {
+ $guest_stats[ $key ] = array(
+ 'id' => $guest_id,
+ 'name' => $guest_name ?: __( 'Unknown', 'wp-bnb' ),
+ 'bookings' => 0,
+ 'nights' => 0,
+ 'total_spent' => 0,
+ );
+ }
+ $guest_stats[ $key ]['bookings']++;
+ $guest_stats[ $key ]['nights'] += $nights;
+ $guest_stats[ $key ]['total_spent'] += $booking_total;
+ }
+
+ // Count repeat guests (2+ bookings ever).
+ $repeat_count = 0;
+ foreach ( $all_guests as $guest ) {
+ $booking_count = Guest::get_booking_count( $guest->ID );
+ if ( $booking_count >= 2 ) {
+ ++$repeat_count;
+ }
+ }
+
+ // Sort by total spent and get top 10.
+ usort( $guest_stats, fn( $a, $b ) => $b['total_spent'] <=> $a['total_spent'] );
+ $top_guests = array_slice( $guest_stats, 0, 10 );
+
+ // Get nationality breakdown.
+ $by_nationality = array();
+ foreach ( $all_guests as $guest ) {
+ $nationality = get_post_meta( $guest->ID, '_bnb_guest_nationality', true );
+ if ( ! $nationality ) {
+ $nationality = __( 'Not specified', 'wp-bnb' );
+ }
+ if ( ! isset( $by_nationality[ $nationality ] ) ) {
+ $by_nationality[ $nationality ] = array(
+ 'name' => $nationality,
+ 'count' => 0,
+ );
+ }
+ $by_nationality[ $nationality ]['count']++;
+ }
+
+ // Calculate percentages and sort.
+ $total_guests = count( $all_guests );
+ foreach ( $by_nationality as &$nat ) {
+ $nat['percentage'] = $total_guests > 0 ? ( $nat['count'] / $total_guests ) * 100 : 0;
+ }
+ usort( $by_nationality, fn( $a, $b ) => $b['count'] <=> $a['count'] );
+
+ $guest_count_in_period = count( array_unique( array_keys( $guest_stats ) ) );
+ $avg_guest_value = $guest_count_in_period > 0 ? $total_revenue / $guest_count_in_period : 0;
+
+ return array(
+ 'total_guests' => $total_guests,
+ 'new_guests' => count( $new_guests ),
+ 'repeat_guests' => $repeat_count,
+ 'avg_guest_value' => $avg_guest_value,
+ 'top_guests' => $top_guests,
+ 'by_nationality' => array_slice( $by_nationality, 0, 10 ),
+ );
+ }
+}
diff --git a/src/Plugin.php b/src/Plugin.php
index b227373..90cc575 100644
--- a/src/Plugin.php
+++ b/src/Plugin.php
@@ -10,6 +10,8 @@ declare( strict_types=1 );
namespace Magdev\WpBnb;
use Magdev\WpBnb\Admin\Calendar as CalendarAdmin;
+use Magdev\WpBnb\Admin\Dashboard as DashboardAdmin;
+use Magdev\WpBnb\Admin\Reports as ReportsAdmin;
use Magdev\WpBnb\Admin\Seasons as SeasonsAdmin;
use Magdev\WpBnb\Blocks\BlockRegistrar;
use Magdev\WpBnb\Booking\Availability;
@@ -248,6 +250,7 @@ final class Plugin {
$is_plugin_page = strpos( $hook_suffix, 'wp-bnb' ) !== false;
$is_our_post_type = in_array( $post_type, array( Building::POST_TYPE, Room::POST_TYPE, Booking::POST_TYPE, Guest::POST_TYPE, Service::POST_TYPE ), true );
$is_edit_screen = in_array( $hook_suffix, array( 'post.php', 'post-new.php' ), true );
+ $is_dashboard = 'toplevel_page_wp-bnb' === $hook_suffix;
if ( ! $is_plugin_page && ! ( $is_our_post_type && $is_edit_screen ) ) {
return;
@@ -268,6 +271,18 @@ final class Plugin {
$script_deps[] = 'jquery-ui-sortable';
}
+ // Add Chart.js for dashboard.
+ if ( $is_dashboard ) {
+ wp_enqueue_script(
+ 'chartjs',
+ 'https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js',
+ array(),
+ '4.4.1',
+ true
+ );
+ $script_deps[] = 'chartjs';
+ }
+
wp_enqueue_script(
'wp-bnb-admin',
WP_BNB_URL . 'assets/js/admin.js',
@@ -276,43 +291,53 @@ final class Plugin {
true
);
- wp_localize_script(
- 'wp-bnb-admin',
- 'wpBnbAdmin',
- array(
- 'ajaxUrl' => admin_url( 'admin-ajax.php' ),
- 'nonce' => wp_create_nonce( 'wp_bnb_admin_nonce' ),
- 'postType' => $post_type,
- 'i18n' => array(
- 'validating' => __( 'Validating...', 'wp-bnb' ),
- 'activating' => __( 'Activating...', 'wp-bnb' ),
- 'error' => __( 'An error occurred. Please try again.', 'wp-bnb' ),
- 'selectImages' => __( 'Select Images', 'wp-bnb' ),
- 'addToGallery' => __( 'Add to Gallery', 'wp-bnb' ),
- 'confirmRemove' => __( 'Are you sure you want to remove this image?', 'wp-bnb' ),
- 'increase' => __( 'increase', 'wp-bnb' ),
- 'discount' => __( 'discount', 'wp-bnb' ),
- 'normalPrice' => __( 'Normal price', 'wp-bnb' ),
- 'checking' => __( 'Checking availability...', 'wp-bnb' ),
- 'available' => __( 'Available', 'wp-bnb' ),
- 'notAvailable' => __( 'Not available - conflicts with existing booking', 'wp-bnb' ),
- 'selectRoomAndDates' => __( 'Select room and dates to check availability', 'wp-bnb' ),
- 'nights' => __( 'nights', 'wp-bnb' ),
- 'night' => __( 'night', 'wp-bnb' ),
- 'calculating' => __( 'Calculating price...', 'wp-bnb' ),
- 'searchingGuests' => __( 'Searching...', 'wp-bnb' ),
- 'noGuestsFound' => __( 'No guests found', 'wp-bnb' ),
- 'selectGuest' => __( 'Select', 'wp-bnb' ),
- 'guestBlocked' => __( 'Blocked', 'wp-bnb' ),
- 'perNightDescription' => __( 'This price will be charged per night of the stay.', 'wp-bnb' ),
- 'perBookingDescription' => __( 'This price will be charged once for the booking.', 'wp-bnb' ),
- 'justNow' => __( 'Just now', 'wp-bnb' ),
- 'updateAvailable' => __( 'Update available!', 'wp-bnb' ),
- 'upToDate' => __( '(You are up to date)', 'wp-bnb' ),
- 'checkingUpdates' => __( 'Checking for updates...', 'wp-bnb' ),
- ),
- )
+ // Build localize data.
+ $localize_data = array(
+ 'ajaxUrl' => admin_url( 'admin-ajax.php' ),
+ 'nonce' => wp_create_nonce( 'wp_bnb_admin_nonce' ),
+ 'postType' => $post_type,
+ 'isDashboard' => $is_dashboard,
+ 'i18n' => array(
+ 'validating' => __( 'Validating...', 'wp-bnb' ),
+ 'activating' => __( 'Activating...', 'wp-bnb' ),
+ 'error' => __( 'An error occurred. Please try again.', 'wp-bnb' ),
+ 'selectImages' => __( 'Select Images', 'wp-bnb' ),
+ 'addToGallery' => __( 'Add to Gallery', 'wp-bnb' ),
+ 'confirmRemove' => __( 'Are you sure you want to remove this image?', 'wp-bnb' ),
+ 'increase' => __( 'increase', 'wp-bnb' ),
+ 'discount' => __( 'discount', 'wp-bnb' ),
+ 'normalPrice' => __( 'Normal price', 'wp-bnb' ),
+ 'checking' => __( 'Checking availability...', 'wp-bnb' ),
+ 'available' => __( 'Available', 'wp-bnb' ),
+ 'notAvailable' => __( 'Not available - conflicts with existing booking', 'wp-bnb' ),
+ 'selectRoomAndDates' => __( 'Select room and dates to check availability', 'wp-bnb' ),
+ 'nights' => __( 'nights', 'wp-bnb' ),
+ 'night' => __( 'night', 'wp-bnb' ),
+ 'calculating' => __( 'Calculating price...', 'wp-bnb' ),
+ 'searchingGuests' => __( 'Searching...', 'wp-bnb' ),
+ 'noGuestsFound' => __( 'No guests found', 'wp-bnb' ),
+ 'selectGuest' => __( 'Select', 'wp-bnb' ),
+ 'guestBlocked' => __( 'Blocked', 'wp-bnb' ),
+ 'perNightDescription' => __( 'This price will be charged per night of the stay.', 'wp-bnb' ),
+ 'perBookingDescription' => __( 'This price will be charged once for the booking.', 'wp-bnb' ),
+ 'justNow' => __( 'Just now', 'wp-bnb' ),
+ 'updateAvailable' => __( 'Update available!', 'wp-bnb' ),
+ 'upToDate' => __( '(You are up to date)', 'wp-bnb' ),
+ 'checkingUpdates' => __( 'Checking for updates...', 'wp-bnb' ),
+ 'occupancy' => __( 'Occupancy %', 'wp-bnb' ),
+ 'revenue' => __( 'Revenue', 'wp-bnb' ),
+ ),
);
+
+ // Add chart data for dashboard.
+ if ( $is_dashboard ) {
+ $localize_data['chartData'] = array(
+ 'occupancy' => DashboardAdmin::get_occupancy_trend_data( 30 ),
+ 'revenue' => DashboardAdmin::get_revenue_trend_data( 6 ),
+ );
+ }
+
+ wp_localize_script( 'wp-bnb-admin', 'wpBnbAdmin', $localize_data );
}
/**
@@ -448,6 +473,16 @@ final class Plugin {
array( $this, 'render_dashboard_page' )
);
+ // Reports submenu.
+ add_submenu_page(
+ 'wp-bnb',
+ __( 'Reports', 'wp-bnb' ),
+ __( 'Reports', 'wp-bnb' ),
+ 'manage_options',
+ 'wp-bnb-reports',
+ array( $this, 'render_reports_page' )
+ );
+
// Settings submenu.
add_submenu_page(
'wp-bnb',
@@ -476,15 +511,16 @@ final class Plugin {
// Define the desired order of menu slugs.
$desired_order = array(
- 'wp-bnb', // Dashboard.
+ 'wp-bnb', // Dashboard.
'edit.php?post_type=bnb_building', // Buildings.
'edit.php?post_type=bnb_room', // Rooms.
'edit.php?post_type=bnb_booking', // Bookings.
'edit.php?post_type=bnb_guest', // Guests.
'edit.php?post_type=bnb_service', // Services.
- 'wp-bnb-calendar', // Calendar.
- 'wp-bnb-seasons', // Seasons.
- 'wp-bnb-settings', // Settings (always last).
+ 'wp-bnb-calendar', // Calendar.
+ 'wp-bnb-reports', // Reports.
+ 'wp-bnb-seasons', // Seasons.
+ 'wp-bnb-settings', // Settings (always last).
);
$current_menu = $submenu['wp-bnb'];
@@ -528,39 +564,16 @@ final class Plugin {
* @return void
*/
public function render_dashboard_page(): void {
- $license_valid = LicenseManager::is_license_valid();
- $is_localhost = LicenseManager::is_localhost();
- ?>
-
-
+ DashboardAdmin::render();
+ }
-
-
-
-
-
- ' . esc_html__( 'activate your license', 'wp-bnb' ) . ''
- );
- ?>
-
-
-
-
-
-
-