created admin panel with some of its content

This commit is contained in:
Ghassan Yusuf 2026-01-26 00:50:43 +03:00
parent 2ee4b76599
commit b16351c416
52 changed files with 5690 additions and 203 deletions

143
ADMIN_ACCESS_GUIDE.md Normal file
View File

@ -0,0 +1,143 @@
# Admin Panel Access Guide
## How to Access the Admin Dashboard
### Step 1: Make Your User a Super Admin
Run this command in your terminal (replace with your email):
```bash
php artisan admin:make-super your-email@example.com
```
**Example:**
```bash
php artisan admin:make-super admin@takeone.com
```
You should see:
```
✅ Successfully made 'Your Name' (your-email@example.com) a super admin!
They can now access the admin panel at: http://localhost:8000/admin
```
---
### Step 2: Login to Your Account
1. Go to: `http://localhost:8000/login`
2. Login with your credentials
3. After successful login, you'll be redirected to the explore page
---
### Step 3: Access the Admin Panel
Once logged in as a super admin, navigate to:
```
http://localhost:8000/admin
```
Or click on "Admin Panel" link in the user dropdown menu (if you add it to the main layout).
---
## Admin Panel Features
### Available Routes:
1. **Dashboard** - `/admin`
- Platform statistics
- Quick action cards
2. **All Clubs** - `/admin/clubs`
- View all clubs in grid layout
- Search clubs
- Create new club
- Edit existing clubs
- Delete clubs
3. **All Members** - `/admin/members`
- View all platform members
- Search members
- View member details
4. **Database Backup** - `/admin/backup`
- Download full database backup (JSON)
- Restore from backup
- Export authentication users
---
## Troubleshooting
### "403 Unauthorized" Error
If you see a 403 error when accessing `/admin`, it means:
- You're not logged in, OR
- Your user doesn't have the super-admin role
**Solution:**
1. Make sure you're logged in
2. Run the command: `php artisan admin:make-super your-email@example.com`
3. Logout and login again
4. Try accessing `/admin` again
### "Role not found" Error
If the command fails with "Role not found":
**Solution:**
Run the seeder to create roles:
```bash
php artisan db:seed --class=RolePermissionSeeder
```
Then try the make-super command again.
---
## Quick Test Checklist
After getting access, test these features:
- [ ] Dashboard loads with statistics
- [ ] Navigate to All Clubs page
- [ ] Search for clubs (if any exist)
- [ ] Click "Add New Club" button
- [ ] Navigate to All Members page
- [ ] Search for members
- [ ] Navigate to Database Backup page
- [ ] Check sidebar navigation works
- [ ] Test responsive design (resize browser)
---
## Adding Admin Link to Main Navigation
To make it easier to access the admin panel, you can add a link in the main layout.
Edit `resources/views/layouts/app.blade.php` and add this in the user dropdown:
```blade
@if(Auth::user()->isSuperAdmin())
<a class="dropdown-item small" href="{{ route('admin.platform.index') }}">
<i class="bi bi-shield me-2"></i>Admin Panel
</a>
<div class="dropdown-divider"></div>
@endif
```
---
## Security Notes
- Only users with the `super-admin` role can access `/admin` routes
- All admin routes are protected with `role:super-admin` middleware
- Destructive actions (delete, restore) have confirmation dialogs
- Bank account information is encrypted in the database
---
**Need Help?** Check the ADMIN_PANEL_PROGRESS.md file for implementation details.

View File

@ -0,0 +1,228 @@
# Admin Panel Layout Update - Complete ✅
## Summary of Changes
Successfully updated the admin panel to use the same boxed layout as the explore page with consistent top navigation bar.
---
## Files Modified
### 1. Main Layout
- **`resources/views/layouts/app.blade.php`**
- Added "Admin Panel" link in user dropdown
- Only visible to users with super-admin role
- Uses `Auth::user()->isSuperAdmin()` method
### 2. Admin Pages (All Updated)
All 6 admin pages now use `@extends('layouts.app')` with boxed container:
1. **`resources/views/admin/platform/index.blade.php`** - Dashboard
- ✅ Boxed layout with `<div class="container py-4">`
- ✅ Clickable stat cards (Total Clubs, Total Members, Active Clubs)
- ✅ Circular icon backgrounds for quick action cards
- ✅ Hover effects on all cards
- ✅ "Back to Explore" link
2. **`resources/views/admin/platform/clubs.blade.php`** - All Clubs
- ✅ Boxed layout
- ✅ Search bar with red "Add New Club" button
- ✅ Club cards with cover images and circular logos
- ✅ "Back to Dashboard" and "Back to Explore" links
3. **`resources/views/admin/platform/create-club.blade.php`** - Create Club
- ✅ Boxed layout
- ✅ Centered page header
- ✅ Form with shadow-sm card
- ✅ Primary blue submit button
4. **`resources/views/admin/platform/edit-club.blade.php`** - Edit Club
- ✅ Boxed layout
- ✅ Centered page header
- ✅ Pre-filled form fields
- ✅ Primary blue update button
5. **`resources/views/admin/platform/members.blade.php`** - All Members
- ✅ Boxed layout
- ✅ Member cards with avatars
- ✅ Search functionality
- ✅ Navigation links
6. **`resources/views/admin/platform/backup.blade.php`** - Database Backup
- ✅ Boxed layout
- ✅ Three operation cards
- ✅ Warning messages
- ✅ Navigation links
---
## Key Features Implemented
### 1. Consistent Layout
- All admin pages use the same top navigation bar as explore page
- Boxed container (`<div class="container py-4">`)
- Consistent card styling with `border-0 shadow-sm`
- Uniform spacing and padding
### 2. Clickable Statistics Cards
- **Total Clubs** → Links to `/admin/clubs`
- **Total Members** → Links to `/admin/members`
- **Active Clubs** → Links to `/admin/clubs?status=active`
- **Total Revenue** → Display only (no link)
### 3. Circular Icon Backgrounds
- Quick action cards have circular icon containers
- Size: 80px × 80px
- Centered icons with proper alignment
- Color-coded backgrounds (primary, success, warning)
### 4. Hover Effects
- All cards have `hover-lift` class
- Smooth transform and shadow transitions
- Cursor changes to pointer on clickable elements
### 5. Navigation
- "Admin Panel" link in user dropdown (super admin only)
- "Back to Dashboard" links on sub-pages
- "Back to Explore" links on all pages
- Breadcrumb-style navigation
---
## Access Control
### How to Access Admin Panel
1. **Make a user super admin:**
```bash
php artisan admin:make-super your-email@example.com
```
2. **Login and access:**
- Login at: `http://localhost:8000/login`
- Click user dropdown in top-right
- Click "Admin Panel" (only visible to super admins)
- Or navigate directly to: `http://localhost:8000/admin`
### User Dropdown Visibility
- **Super Admin:** Sees "Admin Panel" link
- **Regular User:** Does NOT see "Admin Panel" link
- **Non-authenticated:** Redirected to login
---
## Visual Improvements
### Before
- Separate admin layout with sidebar
- Different styling from main app
- Inconsistent navigation
### After
- ✅ Same top bar as explore page
- ✅ Boxed container layout
- ✅ Consistent Bootstrap 5 styling
- ✅ Circular icon backgrounds
- ✅ Clickable stat cards
- ✅ Hover effects throughout
- ✅ Seamless navigation between admin and user views
---
## Button Styling
### Primary Actions
- **Color:** Primary blue (`btn-primary`)
- **Usage:** Submit forms, main actions
- **Examples:** "Create Club", "Update Club", "Go to Clubs"
### Danger Actions
- **Color:** Red (`btn-danger`)
- **Usage:** Add new items, destructive actions
- **Examples:** "Add New Club", "Delete", "Restore Database"
### Secondary Actions
- **Color:** Gray (`btn-secondary`, `btn-outline-primary`)
- **Usage:** Cancel, back navigation
- **Examples:** "Cancel", "Back to Dashboard"
---
## Responsive Design
All pages are fully responsive:
- **Mobile (< 768px):** Cards stack vertically
- **Tablet (768px - 1024px):** 2-column grid
- **Desktop (> 1024px):** 3-4 column grid
- Container stays boxed on all screen sizes
---
## Testing Checklist
### ✅ Completed
- [x] Layout structure updated
- [x] All pages use app layout
- [x] Stat cards made clickable
- [x] Icon circles implemented
- [x] Hover effects added
- [x] Navigation links added
- [x] Admin Panel link in dropdown
### 🔍 Ready for Testing
- [ ] Login as super admin
- [ ] Verify "Admin Panel" link appears
- [ ] Click stat cards (Total Clubs, Total Members, Active Clubs)
- [ ] Test all navigation links
- [ ] Verify responsive design
- [ ] Test on different browsers
- [ ] Verify access control (non-admin users)
---
## Next Steps
1. **Test the admin panel:**
- Make yourself super admin: `php artisan admin:make-super your-email@example.com`
- Login and navigate to admin panel
- Click through all pages
- Test clickable cards
- Verify responsive design
2. **Optional Enhancements:**
- Add loading states
- Implement real-time statistics
- Add more filtering options
- Enhance search functionality
- Add export features
3. **Continue with Phase 5:**
- Club-level admin dashboard
- Individual club management
- Member management per club
- Financial tracking per club
---
## Documentation Files
- **ADMIN_ACCESS_GUIDE.md** - How to access admin panel
- **ADMIN_PANEL_PROGRESS.md** - Overall implementation progress
- **TESTING_ADMIN_PANEL.md** - Comprehensive testing guide
- **ADMIN_LAYOUT_UPDATE_SUMMARY.md** - This file
---
## Support
If you encounter any issues:
1. Check browser console for errors
2. Verify you're logged in as super admin
3. Clear browser cache
4. Run `php artisan config:clear`
5. Run `php artisan view:clear`
---
**Status:** ✅ Complete and Ready for Testing
**Last Updated:** January 2026

295
ADMIN_PANEL_PROGRESS.md Normal file
View File

@ -0,0 +1,295 @@
# TAKEONE Admin Panel - Implementation Progress Report
**Date:** January 25, 2026
**Status:** Phase 1-4 COMPLETED ✅
---
## ✅ COMPLETED PHASES
### Phase 1: Database Schema Expansion - COMPLETED ✅
**All 14 migrations created and executed successfully:**
1. ✅ `2026_01_25_100000_create_roles_and_permissions_tables.php`
- roles, permissions, role_permission, user_roles tables
- Support for tenant-specific roles
2. ✅ `2026_01_25_100001_expand_tenants_table.php`
- Added: slogan, description, enrollment_fee, VAT fields
- Added: email, phone (JSON), currency, timezone, country
- Added: favicon, cover_image, owner details
- Added: settings (JSON) for code prefixes
- Added: soft deletes
3. ✅ `2026_01_25_100002_create_club_facilities_table.php`
- Facilities with GPS coordinates and availability status
4. ✅ `2026_01_25_100003_create_club_instructors_table.php`
- Instructors with skills (JSON), rating, experience
5. ✅ `2026_01_25_100004_create_club_activities_table.php`
- Activities with schedule (JSON), duration, frequency
6. ✅ `2026_01_25_100005_create_club_packages_table.php`
- Packages with age ranges, pricing, type (single/multi)
7. ✅ `2026_01_25_100006_create_club_package_activities_table.php`
- Pivot table linking packages, activities, and instructors
8. ✅ `2026_01_25_100007_create_club_member_subscriptions_table.php`
- Subscriptions with payment tracking and status
9. ✅ `2026_01_25_100008_create_club_transactions_table.php`
- Financial transactions (income/expense/refund)
10. ✅ `2026_01_25_100009_create_club_gallery_images_table.php`
- Gallery management with display order
11. ✅ `2026_01_25_100010_create_club_social_links_table.php`
- Social media links with icons
12. ✅ `2026_01_25_100011_create_club_bank_accounts_table.php`
- Encrypted bank account details (account_number, IBAN, SWIFT)
13. ✅ `2026_01_25_100012_create_club_messages_table.php`
- Internal messaging with read tracking
14. ✅ `2026_01_25_100013_create_club_reviews_table.php`
- Club reviews with approval system
---
### Phase 2: Models & Relationships - COMPLETED ✅
**All 13 models created with full relationships:**
1. ✅ `Role.php` - With permissions relationship and hasPermission() method
2. ✅ `Permission.php` - Basic permission model
3. ✅ `ClubFacility.php` - With GPS decimal casting
4. ✅ `ClubInstructor.php` - With skills array casting
5. ✅ `ClubActivity.php` - With schedule JSON casting
6. ✅ `ClubPackage.php` - With activities many-to-many relationship
7. ✅ `ClubMemberSubscription.php` - With expiry checking methods
8. ✅ `ClubTransaction.php` - With type scopes (income/expense/refund)
9. ✅ `ClubGalleryImage.php` - With uploader relationship
10. ✅ `ClubSocialLink.php` - With display order
11. ✅ `ClubBankAccount.php` - With encrypted accessors for sensitive data
12. ✅ `ClubMessage.php` - With read/unread scopes and markAsRead()
13. ✅ `ClubReview.php` - With approved/pending scopes
**Updated existing models:**
- ✅ `Tenant.php` - Added 12 new relationships, soft deletes, computed attributes (averageRating, activeMembersCount, url)
- ✅ `User.php` - Added role methods (hasRole, hasPermission, isSuperAdmin, isClubAdmin, isInstructor, assignRole, removeRole)
---
### Phase 3: Role-Based Access Control (RBAC) - COMPLETED ✅
**Middleware:**
- ✅ `CheckRole.php` - Role-based access control middleware
- ✅ `CheckPermission.php` - Permission-based access control middleware
- ✅ Registered in `bootstrap/app.php` with aliases: 'role', 'permission'
**Seeder:**
- ✅ `RolePermissionSeeder.php` - Created and executed
- 4 Roles: Super Admin, Club Admin, Instructor, Member
- 20 Permissions covering all admin operations
- Proper role-permission assignments
**Helper Methods in User Model:**
- ✅ `hasRole($roleSlug, $tenantId)` - Check specific role
- ✅ `hasAnyRole($roleSlugs, $tenantId)` - Check multiple roles
- ✅ `hasPermission($permissionSlug, $tenantId)` - Check permission
- ✅ `isSuperAdmin()` - Quick super admin check
- ✅ `isClubAdmin($tenantId)` - Quick club admin check
- ✅ `isInstructor($tenantId)` - Quick instructor check
- ✅ `assignRole($roleSlug, $tenantId)` - Assign role to user
- ✅ `removeRole($roleSlug, $tenantId)` - Remove role from user
---
### Phase 4: Platform-Level Admin (Super Admin) - COMPLETED ✅
**Controller:**
- ✅ `Admin/PlatformController.php` - Fully implemented with 13 methods:
- `index()` - Dashboard with stats
- `clubs()` - All clubs listing with search
- `createClub()` - Show create form
- `storeClub()` - Store new club
- `editClub()` - Show edit form
- `updateClub()` - Update club
- `destroyClub()` - Delete club
- `members()` - All members listing with search
- `backup()` - Backup page
- `downloadBackup()` - Download JSON backup
- `restoreBackup()` - Restore from JSON
- `exportAuthUsers()` - Export users with passwords
**Routes (all protected with role:super-admin middleware):**
- ✅ `GET /admin` - Platform dashboard
- ✅ `GET /admin/clubs` - All clubs management
- ✅ `GET /admin/clubs/create` - Create club form
- ✅ `POST /admin/clubs` - Store new club
- ✅ `GET /admin/clubs/{club}/edit` - Edit club form
- ✅ `PUT /admin/clubs/{club}` - Update club
- ✅ `DELETE /admin/clubs/{club}` - Delete club
- ✅ `GET /admin/members` - All members management
- ✅ `GET /admin/backup` - Backup & restore page
- ✅ `GET /admin/backup/download` - Download backup
- ✅ `POST /admin/backup/restore` - Restore backup
- ✅ `GET /admin/backup/export-users` - Export auth users
**Views:**
- ✅ `layouts/admin.blade.php` - Admin panel layout with:
- Fixed sidebar navigation
- Top navbar with user dropdown
- Alert messages (success/error)
- Responsive design
- Custom admin styling
- ✅ `admin/platform/index.blade.php` - Dashboard with:
- 4 stat cards (Total Clubs, Total Members, Active Clubs, Total Revenue)
- 3 quick action cards (Manage Clubs, Manage Members, Database Backup)
- Recent activity placeholder
- ✅ `admin/platform/clubs.blade.php` - All clubs management with:
- Search functionality
- Grid layout with club cards
- Cover images and logos
- Stats per club (members, packages, trainers)
- Owner information
- Edit and delete actions
- Pagination
- Empty state
- ✅ `admin/platform/members.blade.php` - All members management with:
- Search functionality
- Grid layout with member cards
- Avatar display
- Adult/Child badges
- Club count badges
- Contact information
- Gender, age, nationality display
- Horoscope and birthday countdown
- Member since date
- View and edit actions
- Pagination
- Empty state
- ✅ `admin/platform/backup.blade.php` - Database backup with:
- 3-column operation layout
- Download full backup (JSON)
- Restore from backup (with warnings)
- Export auth users
- Best practices section
- Restore warnings
- Confirmation modal
- Safety checks
---
## 📊 OVERALL PROGRESS
**Completed:** Phases 1-4 (40% of total project)
**Status:** Platform-level admin fully functional
### What's Working:
✅ Complete database schema for admin panel
✅ All Eloquent models with relationships
✅ Role-based access control system
✅ Platform admin dashboard
✅ All clubs management (CRUD with search)
✅ All members management (view with search)
✅ Database backup and restore functionality
✅ Responsive admin UI with Bootstrap 5
✅ Middleware protection on all admin routes
---
## 🔜 REMAINING PHASES
### Phase 5: Club-Level Admin Dashboard (NEXT PRIORITY)
- Club admin sidebar layout
- Dashboard with club-specific stats
- 11 management modules (details, gallery, facilities, etc.)
### Phase 6: Core Features Implementation
- Multi-currency support
- Multi-timezone support
- File upload & management
- Financial system with charts
- Analytics dashboard
- Messaging system
### Phase 7: Additional Features
- Club details management (6 tabs)
- Gallery, facilities, instructors management
- Activities, packages, members management
### Phase 8: Components & Reusables
- Blade components for dropdowns
- Reusable UI components
### Phase 9: Testing & Quality Assurance
- Feature tests
- Seeders for demo data
- Code quality checks
### Phase 10: Documentation & Deployment
- Documentation
- Deployment preparation
---
## 📝 TECHNICAL NOTES
**Architecture:**
- Multi-tenancy with tenant_id foreign keys
- Soft deletes on critical tables
- Encrypted sensitive data (bank accounts)
- JSON columns for flexible data (phone, settings, skills, schedule)
- Proper indexing on foreign keys and search fields
**Security:**
- Role-based middleware on all admin routes
- CSRF protection on all forms
- Encrypted bank account information
- Confirmation dialogs on destructive actions
- Input validation on all forms
**Performance:**
- Eager loading relationships (with, withCount)
- Pagination on large datasets
- Indexed foreign keys
- Efficient queries with scopes
**UI/UX:**
- Consistent Bootstrap 5 styling
- Responsive design
- Empty states for better UX
- Loading states and feedback
- Search and filter functionality
- Card-based layouts
- Icon usage throughout
---
## 🎯 NEXT STEPS
1. **Create club admin layout** with sidebar navigation
2. **Build club dashboard** with stats and charts
3. **Implement club details management** (6 tabs)
4. **Add gallery management** CRUD
5. **Build facilities management** with GPS
6. **Create instructors management** with skills
7. **Implement activities management** with scheduling
8. **Build packages management** with pricing
9. **Add members management** for club
10. **Create financial management** with transactions
---
**Last Updated:** January 25, 2026
**Next Review:** After Phase 5 completion

View File

@ -0,0 +1,117 @@
# Explore Page - Location-Based Sorting Implementation
## Summary
Implemented location-based sorting for clubs in the `/explore` page. When the "All" or "Clubs" tabs are selected, clubs are now sorted by distance from nearest to farthest based on the user's set location.
## Changes Made
### 1. Backend Changes - `app/Http/Controllers/ClubController.php`
#### Modified `all()` Method
- **Added Parameters**: Now accepts optional `latitude` and `longitude` query parameters
- **Distance Calculation**: When location is provided, calculates distance for each club using the Haversine formula
- **Sorting Logic**: Sorts clubs by distance (nearest first)
- Clubs with GPS coordinates and calculated distance appear first (sorted by distance)
- Clubs without GPS coordinates appear at the end
- **Response**: Returns clubs with distance information included
**Key Features:**
- Reuses existing `calculateDistance()` method for consistency
- Handles clubs without GPS coordinates gracefully
- Returns `null` for distance when location is not provided
- Maintains backward compatibility (works with or without location parameters)
### 2. Frontend Changes - `resources/views/clubs/explore.blade.php`
#### Modified `fetchAllClubs()` Function
- **Location Integration**: Now passes user location (latitude/longitude) as query parameters when available
- **Dynamic URL Building**: Constructs URL with location parameters if `userLocation` is set
- **Fallback**: Works without location parameters if user location is unavailable
#### Modified Category Button Logic
- **Updated Behavior**: Both "All" and "Clubs" (sports-clubs) tabs now use `fetchAllClubs()`
- **Consistent Sorting**: Ensures location-based sorting is applied for both categories
- **Other Categories**: Other categories continue to use `fetchNearbyClubs()` as before
## How It Works
1. **User Location Detection**:
- Page automatically detects user's location on load
- Location is stored in `userLocation` variable
2. **Tab Selection**:
- When "All" or "Clubs" tab is clicked, `fetchAllClubs()` is called
- If user location is available, it's sent to the backend
3. **Backend Processing**:
- Backend receives location parameters
- Calculates distance for each club with GPS coordinates
- Sorts clubs by distance (nearest to farthest)
- Returns sorted list with distance information
4. **Display**:
- Clubs are displayed in cards with distance shown
- Card style matches the exact design specification
- Distance is displayed as "X.XX km away"
## Card Style
The card implementation maintains the exact style as specified:
- Club image with logo overlay (bottom-right corner)
- "Sports Club" badge (top-right corner)
- Club name (bold, large text)
- Distance with navigation icon (red text)
- Owner/address information
- Stats grid (Members, Packages, Trainers)
- Action buttons (Join Club, View Details)
## Testing
To test the implementation:
1. Visit `http://localhost:8000/explore`
2. Allow location access when prompted
3. Click on "All" tab - clubs should be sorted by distance
4. Click on "Clubs" tab - clubs should be sorted by distance
5. Verify distance is displayed correctly on each card
6. Verify card style matches the design specification
## Technical Details
### Distance Calculation
- Uses Haversine formula for accurate distance calculation
- Returns distance in kilometers
- Rounded to 2 decimal places for display
### Sorting Algorithm
```php
// Clubs with distance come first (sorted by distance)
// Clubs without distance come last (maintain original order)
if (both have distance) -> sort by distance
if (only one has distance) -> prioritize it
if (neither has distance) -> maintain order
```
### API Endpoints Used
- `GET /clubs/all?latitude={lat}&longitude={lng}` - Fetch all clubs with distance sorting
- `GET /clubs/nearby?latitude={lat}&longitude={lng}&radius={km}` - Fetch nearby clubs within radius
## Files Modified
1. `app/Http/Controllers/ClubController.php` - Backend logic
2. `resources/views/clubs/explore.blade.php` - Frontend JavaScript (also fixed missing `pageMap` variable declaration bug)
## Bug Fixes
During implementation, discovered and fixed an existing bug:
- **Missing Variable Declaration**: Added `let pageMap;` declaration that was causing JavaScript errors
## Backward Compatibility
- All changes are backward compatible
- Works with or without location parameters
- Existing functionality remains intact
- No database schema changes required
## Future Enhancements
Potential improvements for future iterations:
- Add distance unit toggle (km/miles)
- Add custom radius filter for "All" tab
- Cache distance calculations for performance
- Add map view with sorted markers

425
TESTING_ADMIN_PANEL.md Normal file
View File

@ -0,0 +1,425 @@
# Admin Panel Testing Guide
## Pre-Testing Setup
### Step 1: Assign Super Admin Role
Run this command with your email:
```bash
php artisan admin:make-super your-email@example.com
```
### Step 2: Start the Development Server
```bash
php artisan serve
```
The server should start at: `http://localhost:8000`
---
## Testing Checklist
### ✅ Authentication & Authorization
- [ ] **Login as regular user**
- Navigate to `/admin`
- Should see 403 Forbidden error
- [ ] **Login as super admin**
- Navigate to `/admin`
- Should see admin dashboard
- [ ] **Test middleware protection**
- Logout
- Try to access `/admin` directly
- Should redirect to login page
---
### ✅ Platform Dashboard (`/admin`)
- [ ] **Page loads successfully**
- No errors in browser console
- All stat cards display correctly
- [ ] **Statistics display**
- Total Clubs count
- Total Members count
- Active Clubs count
- Total Revenue (BHD)
- [ ] **Quick action cards**
- "Manage Clubs" button works
- "Manage Members" button works
- "Database Backup" button works
- [ ] **Sidebar navigation**
- All menu items visible
- Active state highlights current page
- Icons display correctly
---
### ✅ All Clubs Management (`/admin/clubs`)
- [ ] **Page loads successfully**
- Grid layout displays
- Search bar visible
- "Add New Club" button visible
- [ ] **Empty state** (if no clubs exist)
- Friendly message displays
- "Add New Club" button works
- [ ] **Club cards display** (if clubs exist)
- Cover image or placeholder
- Club logo or initial
- Club name
- Address (if available)
- Stats (members, packages, trainers)
- Owner information
- Edit and Delete buttons
- [ ] **Search functionality**
- Enter search term
- Results filter correctly
- Clear search button works
- [ ] **Pagination**
- Multiple pages display if >12 clubs
- Page navigation works
---
### ✅ Create Club (`/admin/clubs/create`)
- [ ] **Form loads successfully**
- All fields visible
- Owner dropdown populated
- Default values set (currency, timezone, country)
- [ ] **Auto-slug generation**
- Type in club name
- Slug field auto-fills
- Special characters converted to hyphens
- [ ] **Form validation**
- Submit empty form
- Required field errors display
- Invalid email shows error
- Invalid GPS coordinates show error
- [ ] **File uploads**
- Select logo image
- Select cover image
- File size validation (max 2MB)
- [ ] **Successful submission**
- Fill all required fields
- Submit form
- Redirects to clubs list
- Success message displays
- New club appears in list
- [ ] **Cancel button**
- Click cancel
- Returns to clubs list
- No data saved
---
### ✅ Edit Club (`/admin/clubs/{id}/edit`)
- [ ] **Form loads with existing data**
- All fields pre-filled
- Current logo displays (if exists)
- Current cover image displays (if exists)
- [ ] **Update basic information**
- Change club name
- Change slug
- Submit form
- Changes saved successfully
- [ ] **Update contact information**
- Change email
- Change phone
- Change currency/timezone/country
- Submit and verify changes
- [ ] **Update location**
- Change address
- Change GPS coordinates
- Submit and verify changes
- [ ] **Replace images**
- Upload new logo
- Upload new cover image
- Old images deleted
- New images display
- [ ] **Form validation**
- Enter invalid data
- Errors display correctly
- [ ] **Cancel button**
- Click cancel
- Returns to clubs list
- No changes saved
---
### ✅ Delete Club
- [ ] **Delete confirmation**
- Click delete button on club card
- Confirmation dialog appears
- Warning message clear
- [ ] **Cancel deletion**
- Click cancel in dialog
- Club not deleted
- Remains in list
- [ ] **Confirm deletion**
- Click delete button
- Confirm in dialog
- Club deleted successfully
- Success message displays
- Club removed from list
- Associated files deleted
---
### ✅ All Members Management (`/admin/members`)
- [ ] **Page loads successfully**
- Grid layout displays
- Search bar visible
- [ ] **Empty state** (if no members)
- Friendly message displays
- [ ] **Member cards display** (if members exist)
- Avatar or initial
- Full name
- Adult/Child badge
- Club count badge
- Contact information
- Gender, age, nationality
- Horoscope and birthday
- Member since date
- View and Edit buttons
- [ ] **Search functionality**
- Search by name
- Search by phone
- Search by nationality
- Results filter correctly
- [ ] **Pagination**
- Multiple pages if >20 members
- Navigation works
- [ ] **View member**
- Click "View" button
- Redirects to member profile
- [ ] **Edit member**
- Click "Edit" button
- Redirects to edit form
---
### ✅ Database Backup (`/admin/backup`)
- [ ] **Page loads successfully**
- Three operation cards display
- Warning message visible
- Best practices section visible
- [ ] **Download Backup**
- Click "Download Full Backup"
- Confirmation dialog appears
- JSON file downloads
- File name includes timestamp
- File contains all tables
- [ ] **Restore Database**
- Click "Restore from Backup"
- Modal opens
- Warning messages display
- File input accepts only JSON
- Checkbox required
- Cancel button works
- [ ] **Restore functionality** (⚠️ TEST IN STAGING ONLY)
- Upload valid backup JSON
- Check confirmation checkbox
- Submit form
- Final confirmation dialog
- Database restored successfully
- Success message displays
- [ ] **Export Auth Users**
- Click "Export Users"
- JSON file downloads
- Contains user data with encrypted passwords
---
### ✅ UI/UX Elements
- [ ] **Sidebar navigation**
- Fixed position on scroll
- Active state highlights
- All links work
- "Back to Explore" link works
- [ ] **Top navbar**
- User name displays
- Dropdown menu works
- Profile link works
- Logout works
- [ ] **Alert messages**
- Success messages display (green)
- Error messages display (red)
- Dismissible with X button
- Auto-dismiss after 5 seconds (optional)
- [ ] **Responsive design**
- Test on mobile (< 768px)
- Sidebar collapses
- Cards stack vertically
- Forms remain usable
- Tables scroll horizontally
- [ ] **Loading states**
- Forms disable on submit
- Loading indicators show (if implemented)
- [ ] **Empty states**
- Friendly messages
- Helpful icons
- Call-to-action buttons
---
### ✅ Performance & Security
- [ ] **Page load times**
- Dashboard loads < 2 seconds
- Clubs list loads < 3 seconds
- Members list loads < 3 seconds
- [ ] **Database queries**
- Check Laravel Debugbar (if installed)
- No N+1 query problems
- Eager loading used
- [ ] **CSRF protection**
- All forms have @csrf token
- Forms fail without token
- [ ] **File upload security**
- Only images accepted
- File size limits enforced
- Files stored securely
- [ ] **SQL injection prevention**
- Try SQL in search fields
- No errors or data leaks
- [ ] **XSS prevention**
- Try JavaScript in text fields
- Scripts not executed
---
## Browser Compatibility
Test in multiple browsers:
- [ ] Chrome/Edge (Chromium)
- [ ] Firefox
- [ ] Safari (if on Mac)
---
## Common Issues & Solutions
### Issue: 403 Forbidden on /admin
**Solution:**
```bash
php artisan admin:make-super your-email@example.com
```
Then logout and login again.
### Issue: Role not found
**Solution:**
```bash
php artisan db:seed --class=RolePermissionSeeder
```
### Issue: Images not displaying
**Solution:**
```bash
php artisan storage:link
```
### Issue: Validation errors not showing
**Check:**
- @error directives in blade files
- Form has @csrf token
- Input names match validation rules
---
## Test Data Creation
### Create Test Club via Tinker:
```bash
php artisan tinker
```
```php
$user = User::first();
$club = Tenant::create([
'owner_user_id' => $user->id,
'club_name' => 'Test Taekwondo Club',
'slug' => 'test-taekwondo',
'email' => 'test@club.com',
'currency' => 'BHD',
'timezone' => 'Asia/Bahrain',
'country' => 'BH',
'address' => 'Test Address, Manama',
'gps_lat' => 26.0667,
'gps_long' => 50.5577,
]);
```
---
## Reporting Issues
When reporting issues, include:
1. **Steps to reproduce**
2. **Expected behavior**
3. **Actual behavior**
4. **Browser and version**
5. **Screenshots** (if applicable)
6. **Error messages** (from browser console or Laravel log)
---
**Happy Testing! 🚀**

371
TODO_ADMIN_PANEL.md Normal file
View File

@ -0,0 +1,371 @@
# TAKEONE Admin Panel Implementation TODO
## Project Overview
Building a comprehensive Laravel-based admin panel for multi-club sports management platform.
**Tech Stack:** Laravel 12, Blade Templates, Bootstrap 5, Tailwind CSS, PostgreSQL/MySQL
**Start Date:** 2026-01-25
**Status:** In Progress
---
## Phase 1: Database Schema Expansion ⏳
### Core Admin Tables
- [ ] Create roles and permissions tables migration
- [ ] Expand tenants (clubs) table with all required fields
- [ ] Create club facilities table
- [ ] Create club instructors table
- [ ] Create club activities table
- [ ] Create club packages table
- [ ] Create club package activities (pivot) table
- [ ] Create club member subscriptions table
- [ ] Create club transactions table
- [ ] Create club gallery images table
- [ ] Create club social links table
- [ ] Create club bank accounts table (encrypted)
- [ ] Create club messages table
- [ ] Create club reviews table
- [ ] Create club settings table
---
## Phase 2: Models & Relationships
### Eloquent Models
- [ ] Role model
- [ ] Permission model
- [ ] ClubFacility model
- [ ] ClubInstructor model
- [ ] ClubActivity model
- [ ] ClubPackage model
- [ ] ClubMemberSubscription model
- [ ] ClubTransaction model
- [ ] ClubGalleryImage model
- [ ] ClubSocialLink model
- [ ] ClubBankAccount model
- [ ] ClubMessage model
- [ ] ClubReview model
- [ ] ClubSettings model
- [ ] Update Tenant model with new relationships
- [ ] Update User model with admin relationships
---
## Phase 3: Role-Based Access Control (RBAC)
### Authorization System
- [ ] Create role middleware
- [ ] Create permission middleware
- [ ] Create policy classes for all models
- [ ] Create role seeder (Super Admin, Club Admin, Instructor, Member)
- [ ] Create permission seeder
- [ ] Add helper functions for role checking
- [ ] Update User model with role methods
---
## Phase 4: Platform-Level Admin (PRIORITY)
### Routes
- [ ] Platform admin routes group
- [ ] All clubs management routes
- [ ] All members management routes
- [ ] Database backup/restore routes
### Controllers
- [ ] Admin\PlatformController
- [ ] Dashboard overview
- [ ] Statistics and analytics
- [ ] Admin\ClubManagementController
- [ ] Index (all clubs grid)
- [ ] Create new club
- [ ] Edit club
- [ ] Delete club
- [ ] Search/filter clubs
- [ ] Admin\MemberManagementController
- [ ] Index (all members grid)
- [ ] Create member
- [ ] Edit member
- [ ] Delete member
- [ ] Search/filter members
- [ ] Add child member
- [ ] Admin\BackupController
- [ ] Download database backup (JSON)
- [ ] Restore database from backup
- [ ] Export auth users
### Views (Blade Templates)
- [ ] layouts/admin.blade.php (platform admin layout)
- [ ] admin/dashboard.blade.php
- [ ] admin/clubs/index.blade.php (grid view)
- [ ] admin/clubs/create.blade.php
- [ ] admin/clubs/edit.blade.php
- [ ] admin/members/index.blade.php (grid view)
- [ ] admin/members/create.blade.php
- [ ] admin/members/edit.blade.php
- [ ] admin/backup/index.blade.php
---
## Phase 5: Club-Level Admin Dashboard
### Routes
- [ ] Club admin routes group (/club/{slug}/admin)
- [ ] Dashboard routes
- [ ] Club details routes (multi-tab)
- [ ] Gallery routes
- [ ] Facilities routes
- [ ] Instructors routes
- [ ] Activities routes
- [ ] Packages routes
- [ ] Members routes
- [ ] Financials routes
- [ ] Messages routes
- [ ] Analytics routes
### Controllers
- [ ] Club\DashboardController
- [ ] Club\ClubDetailsController
- [ ] Club\GalleryController
- [ ] Club\FacilityController
- [ ] Club\InstructorController
- [ ] Club\ActivityController
- [ ] Club\PackageController
- [ ] Club\MemberController
- [ ] Club\FinancialController
- [ ] Club\MessageController
- [ ] Club\AnalyticsController
### Views (Blade Templates)
- [ ] layouts/club-admin.blade.php (with sidebar)
- [ ] club/admin/dashboard.blade.php
- [ ] club/admin/details/index.blade.php (tabs)
- [ ] club/admin/gallery/index.blade.php
- [ ] club/admin/facilities/index.blade.php
- [ ] club/admin/instructors/index.blade.php
- [ ] club/admin/activities/index.blade.php
- [ ] club/admin/packages/index.blade.php
- [ ] club/admin/members/index.blade.php
- [ ] club/admin/financials/index.blade.php
- [ ] club/admin/messages/index.blade.php
- [ ] club/admin/analytics/index.blade.php
---
## Phase 6: Core Features Implementation
### Multi-Currency Support
- [ ] Create currency dropdown component
- [ ] Add currency helper functions
- [ ] Store currency in club settings
- [ ] Display prices in club currency
### Multi-Timezone Support
- [ ] Create timezone dropdown component
- [ ] Add timezone helper functions
- [ ] Store timezone in club settings
- [ ] Display times in club timezone
### File Upload & Management
- [ ] Configure storage (S3 or local)
- [ ] Image optimization on upload
- [ ] Gallery management CRUD
- [ ] File deletion handling
### Financial System
- [ ] Transaction ledger view
- [ ] Income tracking
- [ ] Expense tracking
- [ ] Refund handling
- [ ] Financial reports
- [ ] CSV export functionality
- [ ] Charts (Chart.js integration)
### Backup & Restore
- [ ] JSON export of all tables
- [ ] Secure backup storage
- [ ] Restore functionality
- [ ] Validation on restore
- [ ] Export auth users with encrypted passwords
### Analytics Dashboard
- [ ] Member growth chart
- [ ] Revenue trends chart
- [ ] Package popularity metrics
- [ ] Activity attendance rates
- [ ] Instructor performance metrics
- [ ] Member retention rates
- [ ] Financial health indicators
### Messaging System
- [ ] Inbox/conversation list
- [ ] Message thread view
- [ ] Send message functionality
- [ ] Mark as read/unread
- [ ] Notification integration
---
## Phase 7: Additional Features
### Club Details Management
- [ ] Basic information tab
- [ ] Contact information tab
- [ ] Location & GPS tab (with map)
- [ ] Branding assets tab
- [ ] Social media links tab
- [ ] Banking tab (encrypted)
- [ ] Settings tab (code prefixes)
- [ ] Danger zone (delete club)
### Gallery Management
- [ ] Grid display of images
- [ ] Multiple image upload
- [ ] Lightbox view
- [ ] Delete images
- [ ] Image captions
### Facilities Management
- [ ] Card-based layout
- [ ] Add facility
- [ ] Edit facility
- [ ] Delete facility
- [ ] GPS location on map
- [ ] Availability status
### Instructors Management
- [ ] Card layout
- [ ] Add instructor
- [ ] Edit instructor
- [ ] Delete instructor
- [ ] Skills/specialties tags
- [ ] Rating system
- [ ] Search/filter
### Activities Management
- [ ] Grid of activity cards
- [ ] Add activity
- [ ] Edit activity
- [ ] Delete activity
- [ ] Duplicate activity
- [ ] Schedule management
- [ ] Facility assignment
### Packages Management
- [ ] Large card layout
- [ ] Add package
- [ ] Edit package
- [ ] Delete package
- [ ] Duplicate package
- [ ] Single/Multi activity types
- [ ] Age range configuration
- [ ] Price management
- [ ] Activity inclusion
### Members Management
- [ ] Current members tab
- [ ] Pending requests tab
- [ ] Status filters
- [ ] Add existing user
- [ ] Walk-in registration
- [ ] Member invitation system
- [ ] Subscription management
---
## Phase 8: Components & Reusables
### Blade Components
- [ ] Country dropdown component
- [ ] Currency dropdown component
- [ ] Timezone dropdown component
- [ ] Phone code dropdown component
- [ ] Nationality dropdown component
- [ ] Image upload modal component
- [ ] Confirmation modal component
- [ ] Stats card component
- [ ] Chart component wrapper
---
## Phase 9: Testing & Quality Assurance
### Tests
- [ ] Feature tests for platform admin
- [ ] Feature tests for club admin
- [ ] Policy tests
- [ ] Model tests
- [ ] Controller tests
- [ ] Validation tests
### Seeders
- [ ] Role and permission seeder
- [ ] Demo clubs seeder
- [ ] Demo members seeder
- [ ] Demo packages seeder
- [ ] Demo transactions seeder
### Code Quality
- [ ] Run Laravel Pint (code formatting)
- [ ] Security audit (CSRF, XSS, SQL injection)
- [ ] Performance optimization (caching, eager loading)
- [ ] Database indexing
- [ ] Query optimization
---
## Phase 10: Documentation & Deployment
### Documentation
- [ ] API documentation
- [ ] Admin user guide
- [ ] Installation guide
- [ ] Deployment guide
- [ ] Database schema documentation
### Deployment Preparation
- [ ] Environment configuration
- [ ] Storage setup
- [ ] Backup automation
- [ ] SSL certificates
- [ ] Performance monitoring
- [ ] Error logging
---
## Current Progress
**Phase 1:** Not Started
**Phase 2:** Not Started
**Phase 3:** Not Started
**Phase 4:** Not Started (PRIORITY)
**Overall:** 0% Complete
---
## Notes
- Using Blade templates (not React) for consistency with existing codebase
- Bootstrap 5 + custom soft purple theme
- All club-specific tables have tenant_id foreign key
- Soft deletes on important records
- Encrypted storage for bank account information
- Multi-currency support with BHD as default
- Multi-timezone handling
- File upload with image optimization
- Role-based access control throughout
---
## Next Steps
1. Create all database migrations (Phase 1)
2. Create all Eloquent models (Phase 2)
3. Implement RBAC system (Phase 3)
4. Build platform-level admin (Phase 4) - PRIORITY
5. Continue with remaining phases
---
**Last Updated:** 2026-01-25

View File

@ -0,0 +1,53 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use App\Models\User;
use App\Models\Role;
class MakeSuperAdmin extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'admin:make-super {email : The email of the user to make super admin}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Make a user a super admin';
/**
* Execute the console command.
*/
public function handle()
{
$email = $this->argument('email');
$user = User::where('email', $email)->first();
if (!$user) {
$this->error("User with email '{$email}' not found!");
return 1;
}
// Check if user already has super-admin role
if ($user->hasRole('super-admin')) {
$this->info("User '{$user->full_name}' ({$email}) is already a super admin!");
return 0;
}
// Assign super-admin role
$user->assignRole('super-admin');
$this->info("✅ Successfully made '{$user->full_name}' ({$email}) a super admin!");
$this->info("They can now access the admin panel at: " . url('/admin'));
return 0;
}
}

View File

@ -0,0 +1,338 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
class PlatformController extends Controller
{
/**
* Display the platform admin dashboard.
*/
public function index()
{
// Get all clubs with counts
$clubs = Tenant::with(['owner'])
->withCount(['members', 'packages', 'instructors'])
->latest()
->get();
$clubsCount = $clubs->count();
return view('admin.platform.index', compact('clubs', 'clubsCount'));
}
/**
* Display all clubs management page.
*/
public function clubs(Request $request)
{
$search = $request->input('search');
$clubs = Tenant::with(['owner', 'members'])
->withCount(['members', 'packages', 'instructors'])
->when($search, function ($query, $search) {
$query->where('club_name', 'like', "%{$search}%")
->orWhere('address', 'like', "%{$search}%")
->orWhere('description', 'like', "%{$search}%");
})
->latest()
->paginate(12);
return view('admin.platform.clubs', compact('clubs', 'search'));
}
/**
* Display all members management page.
*/
public function members(Request $request)
{
$search = $request->input('search');
$members = User::with(['memberClubs', 'dependents'])
->withCount('memberClubs')
->when($search, function ($query, $search) {
$query->where('full_name', 'like', "%{$search}%")
->orWhere('email', 'like', "%{$search}%")
->orWhere('nationality', 'like', "%{$search}%")
->orWhereRaw("JSON_EXTRACT(mobile, '$.number') LIKE ?", ["%{$search}%"]);
})
->latest()
->paginate(20);
return view('admin.platform.members', compact('members', 'search'));
}
/**
* Show create club form.
*/
public function createClub()
{
$users = User::orderBy('full_name')->get();
return view('admin.platform.create-club', compact('users'));
}
/**
* Store a new club.
*/
public function storeClub(Request $request)
{
$validated = $request->validate([
'owner_user_id' => 'required|exists:users,id',
'club_name' => 'required|string|max:255',
'slug' => 'required|string|max:255|unique:tenants,slug',
'email' => 'nullable|email',
'phone_code' => 'nullable|string',
'phone_number' => 'nullable|string',
'currency' => 'nullable|string|max:3',
'timezone' => 'nullable|string',
'country' => 'nullable|string',
'address' => 'nullable|string',
'gps_lat' => 'nullable|numeric|between:-90,90',
'gps_long' => 'nullable|numeric|between:-180,180',
'logo' => 'nullable|image|max:2048',
'cover_image' => 'nullable|image|max:2048',
]);
// Handle phone as JSON
if ($request->filled('phone_code') && $request->filled('phone_number')) {
$validated['phone'] = [
'code' => $request->phone_code,
'number' => $request->phone_number,
];
}
// Handle logo upload
if ($request->hasFile('logo')) {
$validated['logo'] = $request->file('logo')->store('clubs/logos', 'public');
}
// Handle cover image upload
if ($request->hasFile('cover_image')) {
$validated['cover_image'] = $request->file('cover_image')->store('clubs/covers', 'public');
}
$club = Tenant::create($validated);
// Assign club-admin role to owner
$owner = User::find($validated['owner_user_id']);
$owner->assignRole('club-admin', $club->id);
return redirect()->route('admin.platform.clubs')
->with('success', 'Club created successfully!');
}
/**
* Show edit club form.
*/
public function editClub(Tenant $club)
{
$users = User::orderBy('full_name')->get();
return view('admin.platform.edit-club', compact('club', 'users'));
}
/**
* Update a club.
*/
public function updateClub(Request $request, Tenant $club)
{
$validated = $request->validate([
'owner_user_id' => 'required|exists:users,id',
'club_name' => 'required|string|max:255',
'slug' => 'required|string|max:255|unique:tenants,slug,' . $club->id,
'email' => 'nullable|email',
'phone_code' => 'nullable|string',
'phone_number' => 'nullable|string',
'currency' => 'nullable|string|max:3',
'timezone' => 'nullable|string',
'country' => 'nullable|string',
'address' => 'nullable|string',
'gps_lat' => 'nullable|numeric|between:-90,90',
'gps_long' => 'nullable|numeric|between:-180,180',
'logo' => 'nullable|image|max:2048',
'cover_image' => 'nullable|image|max:2048',
]);
// Handle phone as JSON
if ($request->filled('phone_code') && $request->filled('phone_number')) {
$validated['phone'] = [
'code' => $request->phone_code,
'number' => $request->phone_number,
];
}
// Handle logo upload
if ($request->hasFile('logo')) {
// Delete old logo
if ($club->logo) {
Storage::disk('public')->delete($club->logo);
}
$validated['logo'] = $request->file('logo')->store('clubs/logos', 'public');
}
// Handle cover image upload
if ($request->hasFile('cover_image')) {
// Delete old cover
if ($club->cover_image) {
Storage::disk('public')->delete($club->cover_image);
}
$validated['cover_image'] = $request->file('cover_image')->store('clubs/covers', 'public');
}
$club->update($validated);
return redirect()->route('admin.platform.clubs')
->with('success', 'Club updated successfully!');
}
/**
* Delete a club.
*/
public function destroyClub(Tenant $club)
{
// Delete associated files
if ($club->logo) {
Storage::disk('public')->delete($club->logo);
}
if ($club->cover_image) {
Storage::disk('public')->delete($club->cover_image);
}
if ($club->favicon) {
Storage::disk('public')->delete($club->favicon);
}
// Delete club (cascade will handle related records)
$club->delete();
return redirect()->route('admin.platform.clubs')
->with('success', 'Club deleted successfully!');
}
/**
* Display database backup page.
*/
public function backup()
{
return view('admin.platform.backup');
}
/**
* Download database backup as JSON.
*/
public function downloadBackup()
{
$tables = [
'users',
'user_relationships',
'tenants',
'memberships',
'invoices',
'roles',
'permissions',
'role_permission',
'user_roles',
'club_facilities',
'club_instructors',
'club_activities',
'club_packages',
'club_package_activities',
'club_member_subscriptions',
'club_transactions',
'club_gallery_images',
'club_social_links',
'club_bank_accounts',
'club_messages',
'club_reviews',
'health_records',
'tournament_events',
'performance_results',
'goals',
'attendance_records',
'club_affiliations',
'skill_acquisitions',
];
$backup = [];
foreach ($tables as $table) {
$backup[$table] = DB::table($table)->get()->toArray();
}
$filename = 'takeone_backup_' . date('Y-m-d_H-i-s') . '.json';
return response()->json($backup, 200, [
'Content-Type' => 'application/json',
'Content-Disposition' => 'attachment; filename="' . $filename . '"',
]);
}
/**
* Restore database from JSON backup.
*/
public function restoreBackup(Request $request)
{
$request->validate([
'backup_file' => 'required|file|mimes:json',
]);
$file = $request->file('backup_file');
$content = file_get_contents($file->getRealPath());
$backup = json_decode($content, true);
if (!$backup) {
return back()->with('error', 'Invalid backup file format.');
}
DB::beginTransaction();
try {
// Disable foreign key checks
DB::statement('SET FOREIGN_KEY_CHECKS=0');
foreach ($backup as $table => $records) {
// Truncate table
DB::table($table)->truncate();
// Insert records in chunks
if (!empty($records)) {
$chunks = array_chunk($records, 100);
foreach ($chunks as $chunk) {
DB::table($table)->insert($chunk);
}
}
}
// Re-enable foreign key checks
DB::statement('SET FOREIGN_KEY_CHECKS=1');
DB::commit();
return back()->with('success', 'Database restored successfully!');
} catch (\Exception $e) {
DB::rollBack();
DB::statement('SET FOREIGN_KEY_CHECKS=1');
return back()->with('error', 'Restore failed: ' . $e->getMessage());
}
}
/**
* Export all authentication users.
*/
public function exportAuthUsers()
{
$users = User::select('id', 'full_name', 'email', 'password', 'created_at')
->get()
->toArray();
$filename = 'auth_users_' . date('Y-m-d_H-i-s') . '.json';
return response()->json($users, 200, [
'Content-Type' => 'application/json',
'Content-Disposition' => 'attachment; filename="' . $filename . '"',
]);
}
}

View File

@ -104,12 +104,30 @@ class ClubController extends Controller
/** /**
* Get all clubs for the map view. * Get all clubs for the map view.
* If latitude and longitude are provided, calculate distance and sort by nearest.
*/ */
public function all() public function all(Request $request)
{ {
$clubs = Tenant::with('owner') $userLat = $request->input('latitude');
->get() $userLng = $request->input('longitude');
->map(function ($club) {
$clubs = Tenant::with('owner')->get();
// If user location is provided, calculate distance for each club
if ($userLat !== null && $userLng !== null) {
$clubsWithDistance = $clubs->map(function ($club) use ($userLat, $userLng) {
$distance = null;
// Calculate distance only if club has GPS coordinates
if ($club->gps_lat && $club->gps_long) {
$distance = $this->calculateDistance(
$userLat,
$userLng,
$club->gps_lat,
$club->gps_long
);
}
return [ return [
'id' => $club->id, 'id' => $club->id,
'club_name' => $club->club_name, 'club_name' => $club->club_name,
@ -117,14 +135,57 @@ class ClubController extends Controller
'logo' => $club->logo, 'logo' => $club->logo,
'gps_lat' => $club->gps_lat ? (float) $club->gps_lat : null, 'gps_lat' => $club->gps_lat ? (float) $club->gps_lat : null,
'gps_long' => $club->gps_long ? (float) $club->gps_long : null, 'gps_long' => $club->gps_long ? (float) $club->gps_long : null,
'distance' => $distance !== null ? round($distance, 2) : null,
'owner_name' => $club->owner ? $club->owner->full_name : 'N/A',
];
});
// Sort by distance (nearest first), clubs without GPS coordinates go to the end
$clubsWithDistance = $clubsWithDistance->sort(function ($a, $b) {
// If both have distance, sort by distance
if ($a['distance'] !== null && $b['distance'] !== null) {
return $a['distance'] <=> $b['distance'];
}
// If only one has distance, prioritize it
if ($a['distance'] !== null) {
return -1;
}
if ($b['distance'] !== null) {
return 1;
}
// If neither has distance, maintain original order
return 0;
})->values();
return response()->json([
'success' => true,
'clubs' => $clubsWithDistance,
'total' => $clubsWithDistance->count(),
'user_location' => [
'latitude' => $userLat,
'longitude' => $userLng,
],
]);
}
// If no location provided, return clubs without distance calculation
$clubsData = $clubs->map(function ($club) {
return [
'id' => $club->id,
'club_name' => $club->club_name,
'slug' => $club->slug,
'logo' => $club->logo,
'gps_lat' => $club->gps_lat ? (float) $club->gps_lat : null,
'gps_long' => $club->gps_long ? (float) $club->gps_long : null,
'distance' => null,
'owner_name' => $club->owner ? $club->owner->full_name : 'N/A', 'owner_name' => $club->owner ? $club->owner->full_name : 'N/A',
]; ];
}); });
return response()->json([ return response()->json([
'success' => true, 'success' => true,
'clubs' => $clubs, 'clubs' => $clubsData,
'total' => $clubs->count(), 'total' => $clubsData->count(),
]); ]);
} }
} }

View File

@ -0,0 +1,35 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class CheckPermission
{
/**
* Handle an incoming request.
*
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
*/
public function handle(Request $request, Closure $next, string $permission): Response
{
if (!auth()->check()) {
return redirect()->route('login');
}
$user = auth()->user();
$tenantId = $request->route('tenant') ?? $request->route('slug')
? \App\Models\Tenant::where('slug', $request->route('slug'))->value('id')
: null;
// Check if user has the required permission
if ($user->hasPermission($permission, $tenantId)) {
return $next($request);
}
// If user doesn't have required permission, abort with 403
abort(403, 'Unauthorized action.');
}
}

View File

@ -0,0 +1,35 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class CheckRole
{
/**
* Handle an incoming request.
*
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
*/
public function handle(Request $request, Closure $next, string ...$roles): Response
{
if (!auth()->check()) {
return redirect()->route('login');
}
$user = auth()->user();
$tenantId = $request->route('tenant') ?? $request->route('slug')
? \App\Models\Tenant::where('slug', $request->route('slug'))->value('id')
: null;
// Check if user has any of the required roles
if ($user->hasAnyRole($roles, $tenantId)) {
return $next($request);
}
// If user doesn't have required role, abort with 403
abort(403, 'Unauthorized action.');
}
}

View File

@ -0,0 +1,72 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
class ClubActivity extends Model
{
use HasFactory;
/**
* The table associated with the model.
*
* @var string
*/
protected $table = 'club_activities';
/**
* The attributes that are mass assignable.
*
* @var array<int, string>
*/
protected $fillable = [
'tenant_id',
'name',
'duration_minutes',
'frequency_per_week',
'facility_id',
'schedule',
'description',
];
/**
* The attributes that should be cast.
*
* @var array<string, string>
*/
protected $casts = [
'duration_minutes' => 'integer',
'frequency_per_week' => 'integer',
'schedule' => 'array',
];
/**
* Get the club that owns the activity.
*/
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class);
}
/**
* Get the facility where the activity takes place.
*/
public function facility(): BelongsTo
{
return $this->belongsTo(ClubFacility::class, 'facility_id');
}
/**
* Get the packages that include this activity.
*/
public function packages(): BelongsToMany
{
return $this->belongsToMany(ClubPackage::class, 'club_package_activities', 'activity_id', 'package_id')
->withPivot('instructor_id')
->withTimestamps();
}
}

View File

@ -0,0 +1,104 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Support\Facades\Crypt;
class ClubBankAccount extends Model
{
use HasFactory;
/**
* The table associated with the model.
*
* @var string
*/
protected $table = 'club_bank_accounts';
/**
* The attributes that are mass assignable.
*
* @var array<int, string>
*/
protected $fillable = [
'tenant_id',
'bank_name',
'account_name',
'account_number',
'iban',
'swift_code',
'is_primary',
];
/**
* The attributes that should be cast.
*
* @var array<string, string>
*/
protected $casts = [
'is_primary' => 'boolean',
];
/**
* Get the club that owns the bank account.
*/
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class);
}
/**
* Encrypt and decrypt account_number.
*/
protected function accountNumber(): Attribute
{
return Attribute::make(
get: fn ($value) => $value ? Crypt::decryptString($value) : null,
set: fn ($value) => $value ? Crypt::encryptString($value) : null,
);
}
/**
* Encrypt and decrypt iban.
*/
protected function iban(): Attribute
{
return Attribute::make(
get: fn ($value) => $value ? Crypt::decryptString($value) : null,
set: fn ($value) => $value ? Crypt::encryptString($value) : null,
);
}
/**
* Encrypt and decrypt swift_code.
*/
protected function swiftCode(): Attribute
{
return Attribute::make(
get: fn ($value) => $value ? Crypt::decryptString($value) : null,
set: fn ($value) => $value ? Crypt::encryptString($value) : null,
);
}
/**
* Get masked account number for display.
*/
public function getMaskedAccountNumber(): string
{
$accountNumber = $this->account_number;
if (!$accountNumber) {
return 'N/A';
}
$length = strlen($accountNumber);
if ($length <= 4) {
return str_repeat('*', $length);
}
return str_repeat('*', $length - 4) . substr($accountNumber, -4);
}
}

View File

@ -0,0 +1,62 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class ClubFacility extends Model
{
use HasFactory;
/**
* The table associated with the model.
*
* @var string
*/
protected $table = 'club_facilities';
/**
* The attributes that are mass assignable.
*
* @var array<int, string>
*/
protected $fillable = [
'tenant_id',
'name',
'photo',
'address',
'gps_lat',
'gps_long',
'is_available',
];
/**
* The attributes that should be cast.
*
* @var array<string, string>
*/
protected $casts = [
'gps_lat' => 'decimal:7',
'gps_long' => 'decimal:7',
'is_available' => 'boolean',
];
/**
* Get the club that owns the facility.
*/
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class);
}
/**
* Get the activities for the facility.
*/
public function activities(): HasMany
{
return $this->hasMany(ClubActivity::class, 'facility_id');
}
}

View File

@ -0,0 +1,57 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ClubGalleryImage extends Model
{
use HasFactory;
/**
* The table associated with the model.
*
* @var string
*/
protected $table = 'club_gallery_images';
/**
* The attributes that are mass assignable.
*
* @var array<int, string>
*/
protected $fillable = [
'tenant_id',
'image_path',
'caption',
'uploaded_by',
'display_order',
];
/**
* The attributes that should be cast.
*
* @var array<string, string>
*/
protected $casts = [
'display_order' => 'integer',
];
/**
* Get the club that owns the image.
*/
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class);
}
/**
* Get the user who uploaded the image.
*/
public function uploader(): BelongsTo
{
return $this->belongsTo(User::class, 'uploaded_by');
}
}

View File

@ -0,0 +1,71 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
class ClubInstructor extends Model
{
use HasFactory;
/**
* The table associated with the model.
*
* @var string
*/
protected $table = 'club_instructors';
/**
* The attributes that are mass assignable.
*
* @var array<int, string>
*/
protected $fillable = [
'tenant_id',
'user_id',
'role',
'experience_years',
'rating',
'skills',
'bio',
];
/**
* The attributes that should be cast.
*
* @var array<string, string>
*/
protected $casts = [
'experience_years' => 'integer',
'rating' => 'decimal:2',
'skills' => 'array',
];
/**
* Get the club that owns the instructor.
*/
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class);
}
/**
* Get the user associated with the instructor.
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
/**
* Get the packages that include this instructor.
*/
public function packages(): BelongsToMany
{
return $this->belongsToMany(ClubPackage::class, 'club_package_activities', 'instructor_id', 'package_id')
->withTimestamps();
}
}

View File

@ -0,0 +1,112 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Carbon\Carbon;
class ClubMemberSubscription extends Model
{
use HasFactory;
/**
* The table associated with the model.
*
* @var string
*/
protected $table = 'club_member_subscriptions';
/**
* The attributes that are mass assignable.
*
* @var array<int, string>
*/
protected $fillable = [
'tenant_id',
'user_id',
'package_id',
'start_date',
'end_date',
'status',
'payment_status',
'amount_paid',
'amount_due',
'notes',
];
/**
* The attributes that should be cast.
*
* @var array<string, string>
*/
protected $casts = [
'start_date' => 'date',
'end_date' => 'date',
'amount_paid' => 'decimal:2',
'amount_due' => 'decimal:2',
];
/**
* Get the club that owns the subscription.
*/
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class);
}
/**
* Get the member associated with the subscription.
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
/**
* Get the package associated with the subscription.
*/
public function package(): BelongsTo
{
return $this->belongsTo(ClubPackage::class, 'package_id');
}
/**
* Get the transactions for the subscription.
*/
public function transactions(): HasMany
{
return $this->hasMany(ClubTransaction::class, 'subscription_id');
}
/**
* Check if subscription is expiring soon (within 3 days).
*/
public function isExpiringSoon(): bool
{
if ($this->status !== 'active') {
return false;
}
$daysUntilExpiry = Carbon::now()->diffInDays($this->end_date, false);
return $daysUntilExpiry >= 0 && $daysUntilExpiry <= 3;
}
/**
* Check if subscription is expired.
*/
public function isExpired(): bool
{
return $this->end_date < Carbon::now();
}
/**
* Get days until expiry.
*/
public function daysUntilExpiry(): int
{
return Carbon::now()->diffInDays($this->end_date, false);
}
}

View File

@ -0,0 +1,95 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ClubMessage extends Model
{
use HasFactory;
/**
* The table associated with the model.
*
* @var string
*/
protected $table = 'club_messages';
/**
* The attributes that are mass assignable.
*
* @var array<int, string>
*/
protected $fillable = [
'tenant_id',
'sender_id',
'recipient_id',
'subject',
'message',
'is_read',
'read_at',
];
/**
* The attributes that should be cast.
*
* @var array<string, string>
*/
protected $casts = [
'is_read' => 'boolean',
'read_at' => 'datetime',
];
/**
* Get the club that owns the message.
*/
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class);
}
/**
* Get the sender of the message.
*/
public function sender(): BelongsTo
{
return $this->belongsTo(User::class, 'sender_id');
}
/**
* Get the recipient of the message.
*/
public function recipient(): BelongsTo
{
return $this->belongsTo(User::class, 'recipient_id');
}
/**
* Mark message as read.
*/
public function markAsRead(): void
{
$this->update([
'is_read' => true,
'read_at' => now(),
]);
}
/**
* Scope a query to only include unread messages.
*/
public function scopeUnread($query)
{
return $query->where('is_read', false);
}
/**
* Scope a query to only include read messages.
*/
public function scopeRead($query)
{
return $query->where('is_read', true);
}
}

View File

@ -0,0 +1,90 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
class ClubPackage extends Model
{
use HasFactory;
/**
* The table associated with the model.
*
* @var string
*/
protected $table = 'club_packages';
/**
* The attributes that are mass assignable.
*
* @var array<int, string>
*/
protected $fillable = [
'tenant_id',
'name',
'cover_image',
'type',
'age_min',
'age_max',
'gender',
'price',
'duration_months',
'session_count',
'description',
'is_active',
];
/**
* The attributes that should be cast.
*
* @var array<string, string>
*/
protected $casts = [
'age_min' => 'integer',
'age_max' => 'integer',
'price' => 'decimal:2',
'duration_months' => 'integer',
'session_count' => 'integer',
'is_active' => 'boolean',
];
/**
* Get the club that owns the package.
*/
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class);
}
/**
* Get the activities included in the package.
*/
public function activities(): BelongsToMany
{
return $this->belongsToMany(ClubActivity::class, 'club_package_activities', 'package_id', 'activity_id')
->withPivot('instructor_id')
->withTimestamps();
}
/**
* Get the subscriptions for the package.
*/
public function subscriptions(): HasMany
{
return $this->hasMany(ClubMemberSubscription::class, 'package_id');
}
/**
* Get active subscriptions for the package.
*/
public function activeSubscriptions(): HasMany
{
return $this->hasMany(ClubMemberSubscription::class, 'package_id')
->where('status', 'active');
}
}

74
app/Models/ClubReview.php Normal file
View File

@ -0,0 +1,74 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ClubReview extends Model
{
use HasFactory;
/**
* The table associated with the model.
*
* @var string
*/
protected $table = 'club_reviews';
/**
* The attributes that are mass assignable.
*
* @var array<int, string>
*/
protected $fillable = [
'tenant_id',
'user_id',
'rating',
'comment',
'is_approved',
];
/**
* The attributes that should be cast.
*
* @var array<string, string>
*/
protected $casts = [
'rating' => 'integer',
'is_approved' => 'boolean',
];
/**
* Get the club that owns the review.
*/
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class);
}
/**
* Get the user who wrote the review.
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
/**
* Scope a query to only include approved reviews.
*/
public function scopeApproved($query)
{
return $query->where('is_approved', true);
}
/**
* Scope a query to only include pending reviews.
*/
public function scopePending($query)
{
return $query->where('is_approved', false);
}
}

View File

@ -0,0 +1,49 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ClubSocialLink extends Model
{
use HasFactory;
/**
* The table associated with the model.
*
* @var string
*/
protected $table = 'club_social_links';
/**
* The attributes that are mass assignable.
*
* @var array<int, string>
*/
protected $fillable = [
'tenant_id',
'platform',
'url',
'icon',
'display_order',
];
/**
* The attributes that should be cast.
*
* @var array<string, string>
*/
protected $casts = [
'display_order' => 'integer',
];
/**
* Get the club that owns the social link.
*/
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class);
}
}

View File

@ -0,0 +1,95 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ClubTransaction extends Model
{
use HasFactory;
/**
* The table associated with the model.
*
* @var string
*/
protected $table = 'club_transactions';
/**
* The attributes that are mass assignable.
*
* @var array<int, string>
*/
protected $fillable = [
'tenant_id',
'user_id',
'type',
'category',
'amount',
'payment_method',
'description',
'transaction_date',
'reference_number',
'subscription_id',
];
/**
* The attributes that should be cast.
*
* @var array<string, string>
*/
protected $casts = [
'amount' => 'decimal:2',
'transaction_date' => 'date',
];
/**
* Get the club that owns the transaction.
*/
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class);
}
/**
* Get the user associated with the transaction.
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
/**
* Get the subscription associated with the transaction.
*/
public function subscription(): BelongsTo
{
return $this->belongsTo(ClubMemberSubscription::class, 'subscription_id');
}
/**
* Scope a query to only include income transactions.
*/
public function scopeIncome($query)
{
return $query->where('type', 'income');
}
/**
* Scope a query to only include expense transactions.
*/
public function scopeExpense($query)
{
return $query->where('type', 'expense');
}
/**
* Scope a query to only include refund transactions.
*/
public function scopeRefund($query)
{
return $query->where('type', 'refund');
}
}

32
app/Models/Permission.php Normal file
View File

@ -0,0 +1,32 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
class Permission extends Model
{
use HasFactory;
/**
* The attributes that are mass assignable.
*
* @var array<int, string>
*/
protected $fillable = [
'name',
'slug',
'description',
];
/**
* Get the roles for the permission.
*/
public function roles(): BelongsToMany
{
return $this->belongsToMany(Role::class, 'role_permission')
->withTimestamps();
}
}

50
app/Models/Role.php Normal file
View File

@ -0,0 +1,50 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
class Role extends Model
{
use HasFactory;
/**
* The attributes that are mass assignable.
*
* @var array<int, string>
*/
protected $fillable = [
'name',
'slug',
'description',
];
/**
* Get the permissions for the role.
*/
public function permissions(): BelongsToMany
{
return $this->belongsToMany(Permission::class, 'role_permission')
->withTimestamps();
}
/**
* Get the users for the role.
*/
public function users(): BelongsToMany
{
return $this->belongsToMany(User::class, 'user_roles')
->withPivot('tenant_id')
->withTimestamps();
}
/**
* Check if role has a specific permission.
*/
public function hasPermission(string $permissionSlug): bool
{
return $this->permissions()->where('slug', $permissionSlug)->exists();
}
}

View File

@ -7,10 +7,11 @@ use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
class Tenant extends Model class Tenant extends Model
{ {
use HasFactory; use HasFactory, SoftDeletes;
/** /**
* The attributes that are mass assignable. * The attributes that are mass assignable.
@ -22,8 +23,25 @@ class Tenant extends Model
'club_name', 'club_name',
'slug', 'slug',
'logo', 'logo',
'slogan',
'description',
'enrollment_fee',
'commercial_reg_number',
'vat_reg_number',
'vat_percentage',
'email',
'phone',
'currency',
'timezone',
'country',
'address',
'favicon',
'cover_image',
'owner_name',
'owner_email',
'gps_lat', 'gps_lat',
'gps_long', 'gps_long',
'settings',
]; ];
/** /**
@ -34,6 +52,10 @@ class Tenant extends Model
protected $casts = [ protected $casts = [
'gps_lat' => 'decimal:7', 'gps_lat' => 'decimal:7',
'gps_long' => 'decimal:7', 'gps_long' => 'decimal:7',
'enrollment_fee' => 'decimal:2',
'vat_percentage' => 'decimal:2',
'phone' => 'array',
'settings' => 'array',
]; ];
/** /**
@ -61,4 +83,124 @@ class Tenant extends Model
{ {
return $this->hasMany(Invoice::class); return $this->hasMany(Invoice::class);
} }
/**
* Get the facilities for the club.
*/
public function facilities(): HasMany
{
return $this->hasMany(ClubFacility::class);
}
/**
* Get the instructors for the club.
*/
public function instructors(): HasMany
{
return $this->hasMany(ClubInstructor::class);
}
/**
* Get the activities for the club.
*/
public function activities(): HasMany
{
return $this->hasMany(ClubActivity::class);
}
/**
* Get the packages for the club.
*/
public function packages(): HasMany
{
return $this->hasMany(ClubPackage::class);
}
/**
* Get the subscriptions for the club.
*/
public function subscriptions(): HasMany
{
return $this->hasMany(ClubMemberSubscription::class);
}
/**
* Get the transactions for the club.
*/
public function transactions(): HasMany
{
return $this->hasMany(ClubTransaction::class);
}
/**
* Get the gallery images for the club.
*/
public function galleryImages(): HasMany
{
return $this->hasMany(ClubGalleryImage::class);
}
/**
* Get the social links for the club.
*/
public function socialLinks(): HasMany
{
return $this->hasMany(ClubSocialLink::class);
}
/**
* Get the bank accounts for the club.
*/
public function bankAccounts(): HasMany
{
return $this->hasMany(ClubBankAccount::class);
}
/**
* Get the messages for the club.
*/
public function messages(): HasMany
{
return $this->hasMany(ClubMessage::class);
}
/**
* Get the reviews for the club.
*/
public function reviews(): HasMany
{
return $this->hasMany(ClubReview::class);
}
/**
* Get approved reviews for the club.
*/
public function approvedReviews(): HasMany
{
return $this->hasMany(ClubReview::class)->where('is_approved', true);
}
/**
* Get the average rating for the club.
*/
public function getAverageRatingAttribute(): float
{
return $this->approvedReviews()->avg('rating') ?? 0;
}
/**
* Get active members count.
*/
public function getActiveMembersCountAttribute(): int
{
return $this->subscriptions()->where('status', 'active')->distinct('user_id')->count('user_id');
}
/**
* Get the club URL.
*/
public function getUrlAttribute(): string
{
return url("/club/{$this->slug}");
}
} }

View File

@ -258,6 +258,154 @@ class User extends Authenticatable
return $this->hasMany(ClubAffiliation::class, 'member_id'); return $this->hasMany(ClubAffiliation::class, 'member_id');
} }
/**
* Get the roles for the user.
*/
public function roles(): BelongsToMany
{
return $this->belongsToMany(Role::class, 'user_roles')
->withPivot('tenant_id')
->withTimestamps();
}
/**
* Get the subscriptions for the user.
*/
public function subscriptions(): HasMany
{
return $this->hasMany(ClubMemberSubscription::class);
}
/**
* Get the transactions for the user.
*/
public function transactions(): HasMany
{
return $this->hasMany(ClubTransaction::class);
}
/**
* Get the sent messages for the user.
*/
public function sentMessages(): HasMany
{
return $this->hasMany(ClubMessage::class, 'sender_id');
}
/**
* Get the received messages for the user.
*/
public function receivedMessages(): HasMany
{
return $this->hasMany(ClubMessage::class, 'recipient_id');
}
/**
* Get the reviews written by the user.
*/
public function reviews(): HasMany
{
return $this->hasMany(ClubReview::class);
}
/**
* Check if user has a specific role.
*/
public function hasRole(string $roleSlug, ?int $tenantId = null): bool
{
$query = $this->roles()->where('slug', $roleSlug);
if ($tenantId !== null) {
$query->wherePivot('tenant_id', $tenantId);
}
return $query->exists();
}
/**
* Check if user has any of the given roles.
*/
public function hasAnyRole(array $roleSlugs, ?int $tenantId = null): bool
{
$query = $this->roles()->whereIn('slug', $roleSlugs);
if ($tenantId !== null) {
$query->wherePivot('tenant_id', $tenantId);
}
return $query->exists();
}
/**
* Check if user has a specific permission.
*/
public function hasPermission(string $permissionSlug, ?int $tenantId = null): bool
{
$roles = $tenantId !== null
? $this->roles()->wherePivot('tenant_id', $tenantId)->get()
: $this->roles;
foreach ($roles as $role) {
if ($role->hasPermission($permissionSlug)) {
return true;
}
}
return false;
}
/**
* Check if user is super admin.
*/
public function isSuperAdmin(): bool
{
return $this->hasRole('super-admin');
}
/**
* Check if user is club admin for a specific club.
*/
public function isClubAdmin(?int $tenantId = null): bool
{
return $this->hasRole('club-admin', $tenantId);
}
/**
* Check if user is instructor for a specific club.
*/
public function isInstructor(?int $tenantId = null): bool
{
return $this->hasRole('instructor', $tenantId);
}
/**
* Assign a role to the user.
*/
public function assignRole(string $roleSlug, ?int $tenantId = null): void
{
$role = Role::where('slug', $roleSlug)->firstOrFail();
$this->roles()->attach($role->id, ['tenant_id' => $tenantId]);
}
/**
* Remove a role from the user.
*/
public function removeRole(string $roleSlug, ?int $tenantId = null): void
{
$role = Role::where('slug', $roleSlug)->first();
if (!$role) {
return;
}
if ($tenantId !== null) {
$this->roles()->wherePivot('tenant_id', $tenantId)->detach($role->id);
} else {
$this->roles()->detach($role->id);
}
}
/** /**
* Send the email verification notification. * Send the email verification notification.
* Override to prevent sending the default Laravel notification. * Override to prevent sending the default Laravel notification.

View File

@ -12,7 +12,10 @@ return Application::configure(basePath: dirname(__DIR__))
health: '/up', health: '/up',
) )
->withMiddleware(function (Middleware $middleware): void { ->withMiddleware(function (Middleware $middleware): void {
// $middleware->alias([
'role' => \App\Http\Middleware\CheckRole::class,
'permission' => \App\Http\Middleware\CheckPermission::class,
]);
}) })
->withExceptions(function (Exceptions $exceptions): void { ->withExceptions(function (Exceptions $exceptions): void {
// //

View File

@ -0,0 +1,64 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
// Roles table
Schema::create('roles', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('slug')->unique();
$table->text('description')->nullable();
$table->timestamps();
});
// Permissions table
Schema::create('permissions', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('slug')->unique();
$table->text('description')->nullable();
$table->timestamps();
});
// Role-Permission pivot table
Schema::create('role_permission', function (Blueprint $table) {
$table->id();
$table->foreignId('role_id')->constrained('roles')->cascadeOnDelete();
$table->foreignId('permission_id')->constrained('permissions')->cascadeOnDelete();
$table->timestamps();
$table->unique(['role_id', 'permission_id']);
});
// User-Role pivot table (with tenant_id for club-specific roles)
Schema::create('user_roles', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained('users')->cascadeOnDelete();
$table->foreignId('role_id')->constrained('roles')->cascadeOnDelete();
$table->foreignId('tenant_id')->nullable()->constrained('tenants')->cascadeOnDelete();
$table->timestamps();
$table->unique(['user_id', 'role_id', 'tenant_id']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('user_roles');
Schema::dropIfExists('role_permission');
Schema::dropIfExists('permissions');
Schema::dropIfExists('roles');
}
};

View File

@ -0,0 +1,84 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('tenants', function (Blueprint $table) {
// Basic Information
$table->string('slogan')->nullable()->after('club_name');
$table->text('description')->nullable()->after('slogan');
$table->decimal('enrollment_fee', 10, 2)->default(0)->after('description');
$table->string('commercial_reg_number')->nullable()->after('enrollment_fee');
$table->string('vat_reg_number')->nullable()->after('commercial_reg_number');
$table->decimal('vat_percentage', 5, 2)->default(0)->after('vat_reg_number');
// Contact Information
$table->string('email')->nullable()->after('vat_percentage');
$table->json('phone')->nullable()->after('email'); // {code: '+973', number: '12345678'}
$table->string('currency', 3)->default('BHD')->after('phone'); // ISO 4217 currency code
$table->string('timezone')->default('Asia/Bahrain')->after('currency');
$table->string('country')->default('Bahrain')->after('timezone');
$table->text('address')->nullable()->after('country');
// Branding Assets
$table->string('favicon')->nullable()->after('logo');
$table->string('cover_image')->nullable()->after('favicon');
// Owner Information (denormalized for quick access)
$table->string('owner_name')->nullable()->after('owner_user_id');
$table->string('owner_email')->nullable()->after('owner_name');
// Settings (JSON for code prefixes and other configurations)
$table->json('settings')->nullable()->after('gps_long');
// Example settings structure:
// {
// "member_code_prefix": "MEM",
// "child_code_prefix": "CHILD",
// "invoice_code_prefix": "INV",
// "receipt_code_prefix": "REC",
// "expense_code_prefix": "EXP",
// "specialist_code_prefix": "SPEC"
// }
// Soft Deletes
$table->softDeletes()->after('updated_at');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('tenants', function (Blueprint $table) {
$table->dropSoftDeletes();
$table->dropColumn([
'slogan',
'description',
'enrollment_fee',
'commercial_reg_number',
'vat_reg_number',
'vat_percentage',
'email',
'phone',
'currency',
'timezone',
'country',
'address',
'favicon',
'cover_image',
'owner_name',
'owner_email',
'settings',
]);
});
}
};

View File

@ -0,0 +1,36 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('club_facilities', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->constrained('tenants')->cascadeOnDelete();
$table->string('name');
$table->string('photo')->nullable();
$table->text('address')->nullable();
$table->decimal('gps_lat', 10, 7)->nullable();
$table->decimal('gps_long', 10, 7)->nullable();
$table->boolean('is_available')->default(true);
$table->timestamps();
$table->index('tenant_id');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('club_facilities');
}
};

View File

@ -0,0 +1,38 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('club_instructors', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->constrained('tenants')->cascadeOnDelete();
$table->foreignId('user_id')->constrained('users')->cascadeOnDelete();
$table->string('role')->default('Instructor'); // e.g., "TaeKwonDo Instructor", "Fitness Coach"
$table->integer('experience_years')->default(0);
$table->decimal('rating', 3, 2)->default(0.00); // 0.00 to 5.00
$table->json('skills')->nullable(); // ["taekwondo", "fitness", "self-defense"]
$table->text('bio')->nullable();
$table->timestamps();
$table->index('tenant_id');
$table->index('user_id');
$table->unique(['tenant_id', 'user_id']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('club_instructors');
}
};

View File

@ -0,0 +1,43 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('club_activities', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->constrained('tenants')->cascadeOnDelete();
$table->string('name'); // e.g., "TAEKWONDO (S.M.W) CLASS A"
$table->integer('duration_minutes')->default(60);
$table->integer('frequency_per_week')->default(3); // How many times per week
$table->foreignId('facility_id')->nullable()->constrained('club_facilities')->nullOnDelete();
$table->json('schedule')->nullable();
// Example schedule structure:
// [
// {"day": "Saturday", "time": "16:00"},
// {"day": "Monday", "time": "16:00"},
// {"day": "Wednesday", "time": "16:00"}
// ]
$table->text('description')->nullable();
$table->timestamps();
$table->index('tenant_id');
$table->index('facility_id');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('club_activities');
}
};

View File

@ -0,0 +1,41 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('club_packages', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->constrained('tenants')->cascadeOnDelete();
$table->string('name'); // e.g., "Juniors & Fighters (S.M.W) Group A"
$table->string('cover_image')->nullable();
$table->enum('type', ['single', 'multi'])->default('single'); // Single Activity or Multi Activity
$table->integer('age_min')->nullable(); // Minimum age
$table->integer('age_max')->nullable(); // Maximum age
$table->enum('gender', ['mixed', 'male', 'female'])->default('mixed');
$table->decimal('price', 10, 2); // Price in club's currency
$table->integer('duration_months')->default(1); // Package duration in months
$table->integer('session_count')->default(0); // Total sessions included (0 = unlimited)
$table->text('description')->nullable();
$table->boolean('is_active')->default(true);
$table->timestamps();
$table->index('tenant_id');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('club_packages');
}
};

View File

@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('club_package_activities', function (Blueprint $table) {
$table->id();
$table->foreignId('package_id')->constrained('club_packages')->cascadeOnDelete();
$table->foreignId('activity_id')->constrained('club_activities')->cascadeOnDelete();
$table->foreignId('instructor_id')->nullable()->constrained('club_instructors')->nullOnDelete();
$table->timestamps();
$table->unique(['package_id', 'activity_id']);
$table->index('package_id');
$table->index('activity_id');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('club_package_activities');
}
};

View File

@ -0,0 +1,43 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('club_member_subscriptions', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->constrained('tenants')->cascadeOnDelete();
$table->foreignId('user_id')->constrained('users')->cascadeOnDelete();
$table->foreignId('package_id')->constrained('club_packages')->cascadeOnDelete();
$table->date('start_date');
$table->date('end_date');
$table->enum('status', ['active', 'expired', 'cancelled', 'pending'])->default('pending');
$table->enum('payment_status', ['paid', 'unpaid', 'partial', 'refunded'])->default('unpaid');
$table->decimal('amount_paid', 10, 2)->default(0);
$table->decimal('amount_due', 10, 2)->default(0);
$table->text('notes')->nullable();
$table->timestamps();
$table->index('tenant_id');
$table->index('user_id');
$table->index('package_id');
$table->index('status');
$table->index('end_date'); // For expiring subscriptions queries
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('club_member_subscriptions');
}
};

View File

@ -0,0 +1,43 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('club_transactions', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->constrained('tenants')->cascadeOnDelete();
$table->foreignId('user_id')->nullable()->constrained('users')->nullOnDelete(); // Member associated with transaction
$table->enum('type', ['income', 'expense', 'refund'])->default('income');
$table->string('category')->nullable(); // e.g., "Membership Fee", "Equipment", "Utilities"
$table->decimal('amount', 10, 2);
$table->enum('payment_method', ['cash', 'card', 'bank_transfer', 'online', 'other'])->default('cash');
$table->text('description')->nullable();
$table->date('transaction_date');
$table->string('reference_number')->nullable(); // Invoice/Receipt number
$table->foreignId('subscription_id')->nullable()->constrained('club_member_subscriptions')->nullOnDelete();
$table->timestamps();
$table->index('tenant_id');
$table->index('user_id');
$table->index('type');
$table->index('transaction_date');
$table->index('reference_number');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('club_transactions');
}
};

View File

@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('club_gallery_images', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->constrained('tenants')->cascadeOnDelete();
$table->string('image_path');
$table->string('caption')->nullable();
$table->foreignId('uploaded_by')->constrained('users')->cascadeOnDelete();
$table->integer('display_order')->default(0);
$table->timestamps();
$table->index('tenant_id');
$table->index('uploaded_by');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('club_gallery_images');
}
};

View File

@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('club_social_links', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->constrained('tenants')->cascadeOnDelete();
$table->string('platform'); // e.g., "Instagram", "TikTok", "Snapchat", "WhatsApp", "Phone", "Email"
$table->string('url'); // Full URL or contact info
$table->string('icon')->nullable(); // Icon class or path
$table->integer('display_order')->default(0);
$table->timestamps();
$table->index('tenant_id');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('club_social_links');
}
};

View File

@ -0,0 +1,36 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('club_bank_accounts', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->constrained('tenants')->cascadeOnDelete();
$table->string('bank_name');
$table->string('account_name');
$table->text('account_number'); // Encrypted
$table->text('iban')->nullable(); // Encrypted
$table->text('swift_code')->nullable(); // Encrypted
$table->boolean('is_primary')->default(false);
$table->timestamps();
$table->index('tenant_id');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('club_bank_accounts');
}
};

View File

@ -0,0 +1,39 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('club_messages', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->constrained('tenants')->cascadeOnDelete();
$table->foreignId('sender_id')->constrained('users')->cascadeOnDelete();
$table->foreignId('recipient_id')->constrained('users')->cascadeOnDelete();
$table->string('subject')->nullable();
$table->text('message');
$table->boolean('is_read')->default(false);
$table->timestamp('read_at')->nullable();
$table->timestamps();
$table->index('tenant_id');
$table->index('sender_id');
$table->index('recipient_id');
$table->index('is_read');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('club_messages');
}
};

View File

@ -0,0 +1,37 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('club_reviews', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->constrained('tenants')->cascadeOnDelete();
$table->foreignId('user_id')->constrained('users')->cascadeOnDelete();
$table->integer('rating'); // 1-5 stars
$table->text('comment')->nullable();
$table->boolean('is_approved')->default(false);
$table->timestamps();
$table->index('tenant_id');
$table->index('user_id');
$table->index('rating');
$table->unique(['tenant_id', 'user_id']); // One review per user per club
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('club_reviews');
}
};

View File

@ -0,0 +1,123 @@
<?php
namespace Database\Seeders;
use Illuminate\Database\Seeder;
use App\Models\Role;
use App\Models\Permission;
class RolePermissionSeeder extends Seeder
{
/**
* Run the database seeds.
*/
public function run(): void
{
// Create Permissions
$permissions = [
// Platform Admin Permissions
['name' => 'Manage All Clubs', 'slug' => 'manage-all-clubs', 'description' => 'Create, edit, delete any club'],
['name' => 'Manage All Members', 'slug' => 'manage-all-members', 'description' => 'View and manage all platform members'],
['name' => 'Database Backup', 'slug' => 'database-backup', 'description' => 'Backup and restore database'],
['name' => 'View Platform Analytics', 'slug' => 'view-platform-analytics', 'description' => 'View platform-wide analytics'],
// Club Admin Permissions
['name' => 'Manage Club Details', 'slug' => 'manage-club-details', 'description' => 'Edit club information and settings'],
['name' => 'Manage Facilities', 'slug' => 'manage-facilities', 'description' => 'Create, edit, delete facilities'],
['name' => 'Manage Instructors', 'slug' => 'manage-instructors', 'description' => 'Add, edit, remove instructors'],
['name' => 'Manage Activities', 'slug' => 'manage-activities', 'description' => 'Create, edit, delete activities'],
['name' => 'Manage Packages', 'slug' => 'manage-packages', 'description' => 'Create, edit, delete packages'],
['name' => 'Manage Members', 'slug' => 'manage-members', 'description' => 'Add, edit, remove club members'],
['name' => 'Manage Financials', 'slug' => 'manage-financials', 'description' => 'View and manage club finances'],
['name' => 'Manage Gallery', 'slug' => 'manage-gallery', 'description' => 'Upload and manage gallery images'],
['name' => 'Manage Messages', 'slug' => 'manage-messages', 'description' => 'Send and receive messages'],
['name' => 'View Analytics', 'slug' => 'view-analytics', 'description' => 'View club analytics and reports'],
// Instructor Permissions
['name' => 'View Members', 'slug' => 'view-members', 'description' => 'View club members'],
['name' => 'Mark Attendance', 'slug' => 'mark-attendance', 'description' => 'Mark member attendance'],
['name' => 'Send Messages', 'slug' => 'send-messages', 'description' => 'Send messages to members'],
// Member Permissions
['name' => 'View Own Profile', 'slug' => 'view-own-profile', 'description' => 'View own profile and subscriptions'],
['name' => 'Update Own Profile', 'slug' => 'update-own-profile', 'description' => 'Update own profile information'],
['name' => 'View Club Info', 'slug' => 'view-club-info', 'description' => 'View club information'],
];
foreach ($permissions as $permission) {
Permission::firstOrCreate(
['slug' => $permission['slug']],
$permission
);
}
// Create Roles
$roles = [
[
'name' => 'Super Admin',
'slug' => 'super-admin',
'description' => 'Platform administrator with full access',
'permissions' => [
'manage-all-clubs',
'manage-all-members',
'database-backup',
'view-platform-analytics',
]
],
[
'name' => 'Club Admin',
'slug' => 'club-admin',
'description' => 'Club owner/administrator with full club access',
'permissions' => [
'manage-club-details',
'manage-facilities',
'manage-instructors',
'manage-activities',
'manage-packages',
'manage-members',
'manage-financials',
'manage-gallery',
'manage-messages',
'view-analytics',
]
],
[
'name' => 'Instructor',
'slug' => 'instructor',
'description' => 'Club instructor with limited access',
'permissions' => [
'view-members',
'mark-attendance',
'send-messages',
'view-club-info',
]
],
[
'name' => 'Member',
'slug' => 'member',
'description' => 'Club member with basic access',
'permissions' => [
'view-own-profile',
'update-own-profile',
'view-club-info',
]
],
];
foreach ($roles as $roleData) {
$role = Role::firstOrCreate(
['slug' => $roleData['slug']],
[
'name' => $roleData['name'],
'description' => $roleData['description'],
]
);
// Attach permissions to role
$permissionIds = Permission::whereIn('slug', $roleData['permissions'])->pluck('id');
$role->permissions()->sync($permissionIds);
}
$this->command->info('Roles and permissions seeded successfully!');
}
}

View File

@ -0,0 +1,195 @@
@extends('layouts.admin')
@section('admin-content')
<div>
<!-- Page Header -->
<div class="mb-4">
<h1 class="h2 fw-bold mb-2">Database Backup & Restore</h1>
<p class="text-muted">Manage platform database backups</p>
</div>
<!-- Warning Message -->
<div class="alert alert-warning d-flex align-items-center mb-4" role="alert">
<i class="bi bi-exclamation-triangle-fill me-3" style="font-size: 1.5rem;"></i>
<div>
<strong>Important:</strong> Database backup and restore operations are powerful tools. Always test backups in a safe environment before using them in production.
</div>
</div>
<!-- Operations Grid -->
<div class="row g-4 mb-4">
<!-- Download Backup -->
<div class="col-md-4">
<div class="card border-0 shadow-sm h-100">
<div class="card-body text-center">
<div class="mb-3">
<i class="bi bi-download text-primary" style="font-size: 3rem;"></i>
</div>
<h5 class="card-title">Download Backup</h5>
<p class="text-muted small mb-4">
Export the complete database as a JSON file. This includes all tables from the public schema.
</p>
<a href="{{ route('admin.platform.backup.download') }}" class="btn btn-primary w-100" onclick="return confirm('This will download a complete backup of the database. Continue?')">
<i class="bi bi-download me-2"></i>Download Full Backup
</a>
<small class="text-muted d-block mt-3">
<i class="bi bi-info-circle me-1"></i>File format: JSON
</small>
</div>
</div>
</div>
<!-- Restore Database -->
<div class="col-md-4">
<div class="card border-0 shadow-sm h-100 border-danger">
<div class="card-body text-center">
<div class="mb-3">
<i class="bi bi-arrow-clockwise text-danger" style="font-size: 3rem;"></i>
</div>
<h5 class="card-title text-danger">Restore Database</h5>
<p class="text-muted small mb-4">
Upload a JSON backup file to restore the database. <strong class="text-danger">This will overwrite all existing data!</strong>
</p>
<button type="button" class="btn btn-danger w-100" data-bs-toggle="modal" data-bs-target="#restoreModal">
<i class="bi bi-arrow-clockwise me-2"></i>Restore from Backup
</button>
<small class="text-danger d-block mt-3">
<i class="bi bi-exclamation-triangle me-1"></i>Use with extreme caution
</small>
</div>
</div>
</div>
<!-- Export Auth Users -->
<div class="col-md-4">
<div class="card border-0 shadow-sm h-100">
<div class="card-body text-center">
<div class="mb-3">
<i class="bi bi-people text-success" style="font-size: 3rem;"></i>
</div>
<h5 class="card-title">Export Auth Users</h5>
<p class="text-muted small mb-4">
Download all authentication users with encrypted passwords for migration purposes.
</p>
<a href="{{ route('admin.platform.backup.export-users') }}" class="btn btn-success w-100">
<i class="bi bi-download me-2"></i>Export Users
</a>
<small class="text-muted d-block mt-3">
<i class="bi bi-info-circle me-1"></i>Includes encrypted passwords
</small>
</div>
</div>
</div>
</div>
<!-- Best Practices -->
<div class="card border-0 shadow-sm mb-4">
<div class="card-header bg-white border-0">
<h5 class="mb-0"><i class="bi bi-lightbulb me-2"></i>Best Practices</h5>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-6">
<h6 class="text-primary mb-3">Backup Guidelines</h6>
<ul class="list-unstyled">
<li class="mb-2">
<i class="bi bi-check-circle text-success me-2"></i>
Schedule regular automated backups (daily recommended)
</li>
<li class="mb-2">
<i class="bi bi-check-circle text-success me-2"></i>
Store backups in multiple secure locations
</li>
<li class="mb-2">
<i class="bi bi-check-circle text-success me-2"></i>
Test backup restoration in a staging environment
</li>
<li class="mb-2">
<i class="bi bi-check-circle text-success me-2"></i>
Keep backups for at least 30 days
</li>
<li class="mb-2">
<i class="bi bi-check-circle text-success me-2"></i>
Document your backup and restore procedures
</li>
</ul>
</div>
<div class="col-md-6">
<h6 class="text-danger mb-3">Restore Warnings</h6>
<ul class="list-unstyled">
<li class="mb-2">
<i class="bi bi-exclamation-triangle text-danger me-2"></i>
Always backup current data before restoring
</li>
<li class="mb-2">
<i class="bi bi-exclamation-triangle text-danger me-2"></i>
Verify backup file integrity before restoration
</li>
<li class="mb-2">
<i class="bi bi-exclamation-triangle text-danger me-2"></i>
Test restore in staging environment first
</li>
<li class="mb-2">
<i class="bi bi-exclamation-triangle text-danger me-2"></i>
Notify all users before performing restore
</li>
<li class="mb-2">
<i class="bi bi-exclamation-triangle text-danger me-2"></i>
Restoration will overwrite ALL existing data
</li>
</ul>
</div>
</div>
</div>
</div>
</div>
<!-- Restore Modal -->
<div class="modal fade" id="restoreModal" tabindex="-1" aria-labelledby="restoreModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header bg-danger text-white">
<h5 class="modal-title" id="restoreModalLabel">
<i class="bi bi-exclamation-triangle me-2"></i>Restore Database
</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<form action="{{ route('admin.platform.backup.restore') }}" method="POST" enctype="multipart/form-data" onsubmit="return confirmRestore()">
@csrf
<div class="modal-body">
<div class="alert alert-danger">
<strong>Warning:</strong> This action will permanently delete all current data and replace it with the backup file contents. This cannot be undone!
</div>
<div class="mb-3">
<label for="backup_file" class="form-label">Select Backup File (JSON)</label>
<input type="file" class="form-control" id="backup_file" name="backup_file" accept=".json" required>
<small class="text-muted">Only JSON backup files are accepted</small>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="confirmRestore" required>
<label class="form-check-label" for="confirmRestore">
I understand that this will overwrite all existing data
</label>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-danger">
<i class="bi bi-arrow-clockwise me-2"></i>Restore Database
</button>
</div>
</form>
</div>
</div>
</div>
@push('scripts')
<script>
function confirmRestore() {
return confirm('FINAL WARNING: Are you absolutely sure you want to restore the database? This will delete ALL current data!');
}
</script>
@endpush
@endsection

View File

@ -0,0 +1,198 @@
@extends('layouts.admin')
@section('admin-content')
<div>
<!-- Page Header -->
<div class="mb-4">
<h1 class="h2 fw-bold mb-2">All Clubs</h1>
<p class="text-muted">Manage all clubs on the platform</p>
</div>
<!-- Search and Actions Bar -->
<div class="d-flex justify-content-between align-items-center mb-4">
<div class="flex-grow-1 me-3">
<input type="text" id="clubSearch" class="form-control" placeholder="Search clubs by name, location, or description..." value="{{ $search ?? '' }}">
</div>
<a href="{{ route('admin.platform.clubs.create') }}" class="btn btn-primary">
<i class="bi bi-plus-circle me-2"></i>Add New Club
</a>
</div>
<!-- Clubs Grid -->
@if($clubs->count() > 0)
<div class="row g-4 mb-4" id="clubsGrid">
@foreach($clubs as $club)
<div class="col-md-6 col-xl-4 club-card-wrapper"
data-club-name="{{ $club->club_name }}"
data-club-address="{{ $club->address ?? '' }}"
data-club-owner="{{ $club->owner->full_name ?? '' }}">
<a href="{{ route('admin.platform.clubs.edit', $club) }}" class="text-decoration-none">
<div class="card border shadow-sm overflow-hidden club-card" style="border-radius: 0; cursor: pointer; transition: all 0.3s ease;">
<!-- Cover Image -->
<div class="position-relative overflow-hidden" style="height: 192px;">
@if($club->cover_image)
<img src="{{ asset('storage/' . $club->cover_image) }}" alt="{{ $club->club_name }}" loading="lazy" class="w-100 h-100 club-cover-img" style="object-fit: cover; transition: transform 0.3s ease;">
@else
<div class="w-100 h-100 d-flex align-items-center justify-content-center" style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);">
<i class="bi bi-image text-white" style="font-size: 3rem; opacity: 0.3;"></i>
</div>
@endif
<!-- Club Logo - Bottom Left -->
<div class="position-absolute" style="bottom: 8px; left: 8px;">
<div class="bg-white shadow border p-0.5" style="width: 80px; height: 80px; border-radius: 50%; border-color: rgba(0,0,0,0.1) !important;">
@if($club->logo)
<img src="{{ asset('storage/' . $club->logo) }}" alt="{{ $club->club_name }} logo" loading="lazy" class="w-100 h-100 rounded-circle" style="object-fit: contain;">
@else
<div class="w-100 h-100 rounded-circle bg-primary d-flex align-items-center justify-content-center">
<span class="text-white fw-bold fs-4">{{ substr($club->club_name, 0, 1) }}</span>
</div>
@endif
</div>
</div>
<!-- Admin Badge - Top Left -->
<div class="position-absolute" style="top: 8px; left: 8px;">
<span class="badge text-white px-3 py-1" style="background-color: rgba(147, 51, 234, 0.9); border-radius: 9999px; font-size: 0.75rem; font-weight: 600;">Admin</span>
</div>
</div>
<!-- Card Body -->
<div class="p-4" style="background-color: white;">
<div class="mb-3">
<!-- Club Name -->
<h3 class="fw-semibold mb-2 club-title" style="font-size: 1.125rem; color: #1f2937; transition: color 0.3s ease;">{{ $club->club_name }}</h3>
<!-- Address -->
@if($club->address)
<div class="d-flex align-items-center text-muted" style="font-size: 0.875rem;">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="me-1 flex-shrink-0">
<path d="M20 10c0 4.993-5.539 10.193-7.399 11.799a1 1 0 0 1-1.202 0C9.539 20.193 4 14.993 4 10a8 8 0 0 1 16 0"></path>
<circle cx="12" cy="10" r="3"></circle>
</svg>
<span class="text-truncate">{{ $club->address }}</span>
</div>
@endif
</div>
<!-- Stats Grid -->
<div class="row g-2 text-center" style="font-size: 0.75rem;">
<div class="col-4">
<div class="p-2 rounded" style="background-color: rgba(147, 51, 234, 0.05);">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="mx-auto mb-1" style="color: hsl(var(--primary));">
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"></path>
<circle cx="9" cy="7" r="4"></circle>
<path d="M22 21v-2a4 4 0 0 0-3-3.87"></path>
<path d="M16 3.13a4 4 0 0 1 0 7.75"></path>
</svg>
<p class="fw-semibold mb-0" style="color: #1f2937;">{{ $club->members_count }}</p>
<p class="text-muted mb-0">Members</p>
</div>
</div>
<div class="col-4">
<div class="p-2 rounded" style="background-color: rgba(147, 51, 234, 0.05);">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="mx-auto mb-1" style="color: hsl(var(--primary));">
<path d="M14.4 14.4 9.6 9.6"></path>
<path d="M18.657 21.485a2 2 0 1 1-2.829-2.828l-1.767 1.768a2 2 0 1 1-2.829-2.829l6.364-6.364a2 2 0 1 1 2.829 2.829l-1.768 1.767a2 2 0 1 1 2.828 2.829z"></path>
<path d="m21.5 21.5-1.4-1.4"></path>
<path d="M3.9 3.9 2.5 2.5"></path>
<path d="M6.404 12.768a2 2 0 1 1-2.829-2.829l1.768-1.767a2 2 0 1 1-2.828-2.829l2.828-2.828a2 2 0 1 1 2.829 2.828l1.767-1.768a2 2 0 1 1 2.829 2.829z"></path>
</svg>
<p class="fw-semibold mb-0" style="color: #1f2937;">{{ $club->packages_count }}</p>
<p class="text-muted mb-0">Packages</p>
</div>
</div>
<div class="col-4">
<div class="p-2 rounded" style="background-color: rgba(147, 51, 234, 0.05);">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="mx-auto mb-1" style="color: hsl(var(--primary));">
<path d="M11.525 2.295a.53.53 0 0 1 .95 0l2.31 4.679a2.123 2.123 0 0 0 1.595 1.16l5.166.756a.53.53 0 0 1 .294.904l-3.736 3.638a2.123 2.123 0 0 0-.611 1.878l.882 5.14a.53.53 0 0 1-.771.56l-4.618-2.428a2.122 2.122 0 0 0-1.973 0L6.396 21.01a.53.53 0 0 1-.77-.56l.881-5.139a2.122 2.122 0 0 0-.611-1.879L2.16 9.795a.53.53 0 0 1 .294-.906l5.165-.755a2.122 2.122 0 0 0 1.597-1.16z"></path>
</svg>
<p class="fw-semibold mb-0" style="color: #1f2937;">{{ $club->instructors_count }}</p>
<p class="text-muted mb-0">Trainers</p>
</div>
</div>
</div>
</div>
</div>
</a>
</div>
@endforeach
</div>
<!-- Pagination -->
<div class="d-flex justify-content-center mb-4">
{{ $clubs->links() }}
</div>
@else
<div class="card border-0 shadow-sm mb-4">
<div class="card-body text-center py-5">
<i class="bi bi-building text-muted" style="font-size: 4rem;"></i>
<h5 class="mt-3 mb-2">No Clubs Found</h5>
<p class="text-muted mb-4">
@if($search)
No clubs match your search criteria.
@else
Get started by creating your first club.
@endif
</p>
@if(!$search)
<a href="{{ route('admin.platform.clubs.create') }}" class="btn btn-primary">
<i class="bi bi-plus-circle me-2"></i>Add New Club
</a>
@endif
</div>
</div>
@endif
</div>
@push('styles')
<style>
.club-card {
transition: all 0.3s ease-in-out;
}
.club-card:hover {
transform: translateY(-8px);
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04) !important;
}
.club-card:hover .club-cover-img {
transform: scale(1.1);
}
.club-card:hover .club-title {
color: hsl(var(--primary)) !important;
}
.club-card-wrapper {
transition: opacity 0.3s ease;
}
.club-card-wrapper.hidden {
display: none;
}
</style>
@endpush
@push('scripts')
<script>
// Real-time search filtering
document.getElementById('clubSearch').addEventListener('input', function(e) {
const searchTerm = e.target.value.toLowerCase();
const clubCards = document.querySelectorAll('.club-card-wrapper');
clubCards.forEach(function(card) {
const clubName = card.getAttribute('data-club-name').toLowerCase();
const clubAddress = card.getAttribute('data-club-address').toLowerCase();
const clubOwner = card.getAttribute('data-club-owner').toLowerCase();
if (clubName.includes(searchTerm) || clubAddress.includes(searchTerm) || clubOwner.includes(searchTerm)) {
card.classList.remove('hidden');
} else {
card.classList.add('hidden');
}
});
});
</script>
@endpush
@endsection

View File

@ -0,0 +1,187 @@
@extends('layouts.admin')
@section('page-title', 'Create New Club')
@section('page-subtitle', 'Add a new club to the platform')
@section('content')
<div class="row">
<div class="col-lg-8 mx-auto">
<div class="card border-0 shadow-sm">
<div class="card-header bg-white">
<h5 class="mb-0"><i class="bi bi-building me-2"></i>Club Information</h5>
</div>
<div class="card-body">
<form action="{{ route('admin.platform.clubs.store') }}" method="POST" enctype="multipart/form-data">
@csrf
<!-- Owner Selection -->
<div class="mb-4">
<label for="owner_user_id" class="form-label">Club Owner <span class="text-danger">*</span></label>
<select class="form-select @error('owner_user_id') is-invalid @enderror" id="owner_user_id" name="owner_user_id" required>
<option value="">Select Owner</option>
@foreach($users as $user)
<option value="{{ $user->id }}" {{ old('owner_user_id') == $user->id ? 'selected' : '' }}>
{{ $user->full_name }} ({{ $user->email }})
</option>
@endforeach
</select>
@error('owner_user_id')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
<small class="text-muted">The user who will manage this club</small>
</div>
<!-- Basic Information -->
<h6 class="border-bottom pb-2 mb-3">Basic Information</h6>
<div class="row mb-3">
<div class="col-md-6">
<label for="club_name" class="form-label">Club Name <span class="text-danger">*</span></label>
<input type="text" class="form-control @error('club_name') is-invalid @enderror" id="club_name" name="club_name" value="{{ old('club_name') }}" required>
@error('club_name')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
</div>
<div class="col-md-6">
<label for="slug" class="form-label">Club Slug <span class="text-danger">*</span></label>
<input type="text" class="form-control @error('slug') is-invalid @enderror" id="slug" name="slug" value="{{ old('slug') }}" required>
@error('slug')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
<small class="text-muted">URL-friendly identifier (e.g., bh-taekwondo)</small>
</div>
</div>
<!-- Contact Information -->
<h6 class="border-bottom pb-2 mb-3 mt-4">Contact Information</h6>
<div class="row mb-3">
<div class="col-md-6">
<label for="email" class="form-label">Club Email</label>
<input type="email" class="form-control @error('email') is-invalid @enderror" id="email" name="email" value="{{ old('email') }}">
@error('email')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
</div>
<div class="col-md-6">
<label for="phone_number" class="form-label">Phone Number</label>
<div class="input-group">
<select class="form-select" name="phone_code" style="max-width: 120px;">
<option value="+973" {{ old('phone_code') == '+973' ? 'selected' : '' }}>+973 (BH)</option>
<option value="+966" {{ old('phone_code') == '+966' ? 'selected' : '' }}>+966 (SA)</option>
<option value="+971" {{ old('phone_code') == '+971' ? 'selected' : '' }}>+971 (AE)</option>
<option value="+965" {{ old('phone_code') == '+965' ? 'selected' : '' }}>+965 (KW)</option>
</select>
<input type="text" class="form-control" name="phone_number" value="{{ old('phone_number') }}" placeholder="12345678">
</div>
</div>
</div>
<div class="row mb-3">
<div class="col-md-4">
<label for="currency" class="form-label">Currency</label>
<select class="form-select" id="currency" name="currency">
<option value="BHD" {{ old('currency', 'BHD') == 'BHD' ? 'selected' : '' }}>BHD - Bahrain Dinar</option>
<option value="SAR" {{ old('currency') == 'SAR' ? 'selected' : '' }}>SAR - Saudi Riyal</option>
<option value="AED" {{ old('currency') == 'AED' ? 'selected' : '' }}>AED - UAE Dirham</option>
<option value="KWD" {{ old('currency') == 'KWD' ? 'selected' : '' }}>KWD - Kuwaiti Dinar</option>
</select>
</div>
<div class="col-md-4">
<label for="timezone" class="form-label">Timezone</label>
<select class="form-select" id="timezone" name="timezone">
<option value="Asia/Bahrain" {{ old('timezone', 'Asia/Bahrain') == 'Asia/Bahrain' ? 'selected' : '' }}>Asia/Bahrain</option>
<option value="Asia/Riyadh" {{ old('timezone') == 'Asia/Riyadh' ? 'selected' : '' }}>Asia/Riyadh</option>
<option value="Asia/Dubai" {{ old('timezone') == 'Asia/Dubai' ? 'selected' : '' }}>Asia/Dubai</option>
<option value="Asia/Kuwait" {{ old('timezone') == 'Asia/Kuwait' ? 'selected' : '' }}>Asia/Kuwait</option>
</select>
</div>
<div class="col-md-4">
<label for="country" class="form-label">Country</label>
<select class="form-select" id="country" name="country">
<option value="BH" {{ old('country', 'BH') == 'BH' ? 'selected' : '' }}>Bahrain</option>
<option value="SA" {{ old('country') == 'SA' ? 'selected' : '' }}>Saudi Arabia</option>
<option value="AE" {{ old('country') == 'AE' ? 'selected' : '' }}>United Arab Emirates</option>
<option value="KW" {{ old('country') == 'KW' ? 'selected' : '' }}>Kuwait</option>
</select>
</div>
</div>
<!-- Location -->
<h6 class="border-bottom pb-2 mb-3 mt-4">Location</h6>
<div class="mb-3">
<label for="address" class="form-label">Address</label>
<textarea class="form-control @error('address') is-invalid @enderror" id="address" name="address" rows="2">{{ old('address') }}</textarea>
@error('address')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
</div>
<div class="row mb-3">
<div class="col-md-6">
<label for="gps_lat" class="form-label">GPS Latitude</label>
<input type="number" step="0.0000001" class="form-control @error('gps_lat') is-invalid @enderror" id="gps_lat" name="gps_lat" value="{{ old('gps_lat') }}" placeholder="26.0667">
@error('gps_lat')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
</div>
<div class="col-md-6">
<label for="gps_long" class="form-label">GPS Longitude</label>
<input type="number" step="0.0000001" class="form-control @error('gps_long') is-invalid @enderror" id="gps_long" name="gps_long" value="{{ old('gps_long') }}" placeholder="50.5577">
@error('gps_long')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
</div>
</div>
<!-- Branding -->
<h6 class="border-bottom pb-2 mb-3 mt-4">Branding</h6>
<div class="row mb-3">
<div class="col-md-6">
<label for="logo" class="form-label">Club Logo</label>
<input type="file" class="form-control @error('logo') is-invalid @enderror" id="logo" name="logo" accept="image/*">
@error('logo')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
<small class="text-muted">Recommended: Square image, max 2MB</small>
</div>
<div class="col-md-6">
<label for="cover_image" class="form-label">Cover Image</label>
<input type="file" class="form-control @error('cover_image') is-invalid @enderror" id="cover_image" name="cover_image" accept="image/*">
@error('cover_image')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
<small class="text-muted">Recommended: 1200x400px, max 2MB</small>
</div>
</div>
<!-- Actions -->
<div class="d-flex justify-content-between mt-4 pt-3 border-top">
<a href="{{ route('admin.platform.clubs') }}" class="btn btn-secondary">
<i class="bi bi-arrow-left me-2"></i>Cancel
</a>
<button type="submit" class="btn text-white" style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);">
<i class="bi bi-check-circle me-2"></i>Create Club
</button>
</div>
</form>
</div>
</div>
</div>
</div>
@endsection
@push('scripts')
<script>
// Auto-generate slug from club name
document.getElementById('club_name').addEventListener('input', function(e) {
const slug = e.target.value
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
document.getElementById('slug').value = slug;
});
</script>
@endpush

View File

@ -0,0 +1,187 @@
@extends('layouts.admin')
@section('page-title', 'Edit Club')
@section('page-subtitle', 'Update club information')
@section('content')
<div class="row">
<div class="col-lg-8 mx-auto">
<div class="card border-0 shadow-sm">
<div class="card-header bg-white d-flex justify-content-between align-items-center">
<h5 class="mb-0"><i class="bi bi-building me-2"></i>{{ $club->club_name }}</h5>
<span class="badge bg-secondary">{{ $club->slug }}</span>
</div>
<div class="card-body">
<form action="{{ route('admin.platform.clubs.update', $club) }}" method="POST" enctype="multipart/form-data">
@csrf
@method('PUT')
<!-- Owner Selection -->
<div class="mb-4">
<label for="owner_user_id" class="form-label">Club Owner <span class="text-danger">*</span></label>
<select class="form-select @error('owner_user_id') is-invalid @enderror" id="owner_user_id" name="owner_user_id" required>
<option value="">Select Owner</option>
@foreach($users as $user)
<option value="{{ $user->id }}" {{ (old('owner_user_id', $club->owner_user_id) == $user->id) ? 'selected' : '' }}>
{{ $user->full_name }} ({{ $user->email }})
</option>
@endforeach
</select>
@error('owner_user_id')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
</div>
<!-- Basic Information -->
<h6 class="border-bottom pb-2 mb-3">Basic Information</h6>
<div class="row mb-3">
<div class="col-md-6">
<label for="club_name" class="form-label">Club Name <span class="text-danger">*</span></label>
<input type="text" class="form-control @error('club_name') is-invalid @enderror" id="club_name" name="club_name" value="{{ old('club_name', $club->club_name) }}" required>
@error('club_name')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
</div>
<div class="col-md-6">
<label for="slug" class="form-label">Club Slug <span class="text-danger">*</span></label>
<input type="text" class="form-control @error('slug') is-invalid @enderror" id="slug" name="slug" value="{{ old('slug', $club->slug) }}" required>
@error('slug')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
<small class="text-muted">URL-friendly identifier</small>
</div>
</div>
<!-- Contact Information -->
<h6 class="border-bottom pb-2 mb-3 mt-4">Contact Information</h6>
<div class="row mb-3">
<div class="col-md-6">
<label for="email" class="form-label">Club Email</label>
<input type="email" class="form-control @error('email') is-invalid @enderror" id="email" name="email" value="{{ old('email', $club->email) }}">
@error('email')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
</div>
<div class="col-md-6">
<label for="phone_number" class="form-label">Phone Number</label>
<div class="input-group">
<select class="form-select" name="phone_code" style="max-width: 120px;">
<option value="+973" {{ old('phone_code', $club->phone['code'] ?? '+973') == '+973' ? 'selected' : '' }}>+973 (BH)</option>
<option value="+966" {{ old('phone_code', $club->phone['code'] ?? '') == '+966' ? 'selected' : '' }}>+966 (SA)</option>
<option value="+971" {{ old('phone_code', $club->phone['code'] ?? '') == '+971' ? 'selected' : '' }}>+971 (AE)</option>
<option value="+965" {{ old('phone_code', $club->phone['code'] ?? '') == '+965' ? 'selected' : '' }}>+965 (KW)</option>
</select>
<input type="text" class="form-control" name="phone_number" value="{{ old('phone_number', $club->phone['number'] ?? '') }}" placeholder="12345678">
</div>
</div>
</div>
<div class="row mb-3">
<div class="col-md-4">
<label for="currency" class="form-label">Currency</label>
<select class="form-select" id="currency" name="currency">
<option value="BHD" {{ old('currency', $club->currency ?? 'BHD') == 'BHD' ? 'selected' : '' }}>BHD - Bahrain Dinar</option>
<option value="SAR" {{ old('currency', $club->currency) == 'SAR' ? 'selected' : '' }}>SAR - Saudi Riyal</option>
<option value="AED" {{ old('currency', $club->currency) == 'AED' ? 'selected' : '' }}>AED - UAE Dirham</option>
<option value="KWD" {{ old('currency', $club->currency) == 'KWD' ? 'selected' : '' }}>KWD - Kuwaiti Dinar</option>
</select>
</div>
<div class="col-md-4">
<label for="timezone" class="form-label">Timezone</label>
<select class="form-select" id="timezone" name="timezone">
<option value="Asia/Bahrain" {{ old('timezone', $club->timezone ?? 'Asia/Bahrain') == 'Asia/Bahrain' ? 'selected' : '' }}>Asia/Bahrain</option>
<option value="Asia/Riyadh" {{ old('timezone', $club->timezone) == 'Asia/Riyadh' ? 'selected' : '' }}>Asia/Riyadh</option>
<option value="Asia/Dubai" {{ old('timezone', $club->timezone) == 'Asia/Dubai' ? 'selected' : '' }}>Asia/Dubai</option>
<option value="Asia/Kuwait" {{ old('timezone', $club->timezone) == 'Asia/Kuwait' ? 'selected' : '' }}>Asia/Kuwait</option>
</select>
</div>
<div class="col-md-4">
<label for="country" class="form-label">Country</label>
<select class="form-select" id="country" name="country">
<option value="BH" {{ old('country', $club->country ?? 'BH') == 'BH' ? 'selected' : '' }}>Bahrain</option>
<option value="SA" {{ old('country', $club->country) == 'SA' ? 'selected' : '' }}>Saudi Arabia</option>
<option value="AE" {{ old('country', $club->country) == 'AE' ? 'selected' : '' }}>United Arab Emirates</option>
<option value="KW" {{ old('country', $club->country) == 'KW' ? 'selected' : '' }}>Kuwait</option>
</select>
</div>
</div>
<!-- Location -->
<h6 class="border-bottom pb-2 mb-3 mt-4">Location</h6>
<div class="mb-3">
<label for="address" class="form-label">Address</label>
<textarea class="form-control @error('address') is-invalid @enderror" id="address" name="address" rows="2">{{ old('address', $club->address) }}</textarea>
@error('address')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
</div>
<div class="row mb-3">
<div class="col-md-6">
<label for="gps_lat" class="form-label">GPS Latitude</label>
<input type="number" step="0.0000001" class="form-control @error('gps_lat') is-invalid @enderror" id="gps_lat" name="gps_lat" value="{{ old('gps_lat', $club->gps_lat) }}">
@error('gps_lat')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
</div>
<div class="col-md-6">
<label for="gps_long" class="form-label">GPS Longitude</label>
<input type="number" step="0.0000001" class="form-control @error('gps_long') is-invalid @enderror" id="gps_long" name="gps_long" value="{{ old('gps_long', $club->gps_long) }}">
@error('gps_long')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
</div>
</div>
<!-- Branding -->
<h6 class="border-bottom pb-2 mb-3 mt-4">Branding</h6>
<div class="row mb-3">
<div class="col-md-6">
<label for="logo" class="form-label">Club Logo</label>
@if($club->logo)
<div class="mb-2">
<img src="{{ asset('storage/' . $club->logo) }}" alt="Current Logo" class="img-thumbnail" style="max-width: 100px;">
<small class="d-block text-muted">Current logo</small>
</div>
@endif
<input type="file" class="form-control @error('logo') is-invalid @enderror" id="logo" name="logo" accept="image/*">
@error('logo')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
<small class="text-muted">Leave empty to keep current logo</small>
</div>
<div class="col-md-6">
<label for="cover_image" class="form-label">Cover Image</label>
@if($club->cover_image)
<div class="mb-2">
<img src="{{ asset('storage/' . $club->cover_image) }}" alt="Current Cover" class="img-thumbnail" style="max-width: 200px;">
<small class="d-block text-muted">Current cover image</small>
</div>
@endif
<input type="file" class="form-control @error('cover_image') is-invalid @enderror" id="cover_image" name="cover_image" accept="image/*">
@error('cover_image')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
<small class="text-muted">Leave empty to keep current cover</small>
</div>
</div>
<!-- Actions -->
<div class="d-flex justify-content-between mt-4 pt-3 border-top">
<a href="{{ route('admin.platform.clubs') }}" class="btn btn-secondary">
<i class="bi bi-arrow-left me-2"></i>Cancel
</a>
<button type="submit" class="btn text-white" style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);">
<i class="bi bi-check-circle me-2"></i>Update Club
</button>
</div>
</form>
</div>
</div>
</div>
</div>
@endsection

View File

@ -0,0 +1,185 @@
@extends('layouts.admin')
@section('admin-content')
<div class="space-y-6">
<!-- Header -->
<div class="flex items-center justify-between">
<div>
<h2 class="text-3xl font-bold">All Clubs</h2>
<p class="text-muted-foreground mt-1">Manage all clubs on the platform</p>
</div>
<div class="flex items-center gap-2">
<a href="{{ route('admin.platform.clubs.create') }}" class="btn btn-primary">
<i class="bi bi-plus-lg me-2"></i>Add New Club
</a>
</div>
</div>
<!-- Search Bar -->
<div class="relative">
<i class="bi bi-search position-absolute" style="left: 12px; top: 50%; transform: translateY(-50%); color: hsl(var(--muted-foreground));"></i>
<input
type="text"
class="form-control ps-5"
placeholder="Search clubs by name, location, or description..."
id="searchInput"
/>
</div>
<!-- Clubs Grid -->
@if($clubs->isEmpty())
<div class="card p-5 text-center">
<p class="text-muted-foreground mb-0">No clubs found. Create your first club to get started.</p>
</div>
@else
<div class="row g-4" id="clubsGrid">
@foreach($clubs as $club)
<div class="col-md-6 col-xl-4 club-card"
data-name="{{ strtolower($club->club_name) }}"
data-location="{{ strtolower($club->location ?? '') }}"
data-description="{{ strtolower($club->description ?? '') }}">
<div class="card h-100 hover-card" style="cursor: pointer;" onclick="window.location='{{ route('admin.platform.clubs.edit', $club->id) }}'">
<!-- Club Cover Image -->
<div class="position-relative" style="height: 192px; overflow: hidden;">
@if($club->cover_image)
<img src="{{ asset('storage/' . $club->cover_image) }}"
alt="{{ $club->club_name }}"
class="w-100 h-100 object-fit-cover club-cover-img"
loading="lazy">
@else
<div class="w-100 h-100 d-flex align-items-center justify-content-center"
style="background: linear-gradient(135deg, hsl(250 60% 75%), hsl(250 60% 65%));">
<i class="bi bi-building text-white" style="font-size: 3rem;"></i>
</div>
@endif
<!-- Club Logo Overlay -->
@if($club->logo)
<div class="position-absolute" style="bottom: 8px; left: 8px;">
<div class="rounded-circle bg-white shadow-lg border p-1" style="width: 80px; height: 80px;">
<img src="{{ asset('storage/' . $club->logo) }}"
alt="{{ $club->club_name }} logo"
class="w-100 h-100 object-fit-contain rounded-circle"
loading="lazy">
</div>
</div>
@endif
<!-- Admin Badge -->
<div class="position-absolute" style="top: 8px; left: 8px;">
<span class="badge text-white" style="background: linear-gradient(135deg, hsl(250 60% 75%), hsl(250 60% 65%));">
Admin
</span>
</div>
<!-- Rating Badge -->
@if($club->rating)
<div class="position-absolute" style="top: 8px; right: 8px;">
<span class="badge bg-white text-dark">
<i class="bi bi-star-fill text-warning"></i>
{{ number_format($club->rating, 1) }}
</span>
</div>
@endif
</div>
<!-- Card Content -->
<div class="card-body p-4">
<div class="mb-3">
<h3 class="h5 fw-semibold mb-2 club-name-hover">{{ $club->club_name }}</h3>
@if($club->location)
<div class="d-flex align-items-center text-muted small">
<i class="bi bi-geo-alt me-1"></i>
<span class="text-truncate">{{ $club->location }}</span>
</div>
@endif
</div>
<!-- Stats Grid -->
<div class="row g-2 text-center small">
<div class="col-4">
<div class="p-2 rounded" style="background-color: hsl(var(--accent));">
<i class="bi bi-people d-block mb-1" style="color: hsl(var(--primary));"></i>
<p class="fw-semibold mb-0">{{ $club->members_count ?? 0 }}</p>
<p class="text-muted mb-0" style="font-size: 0.75rem;">Members</p>
</div>
</div>
<div class="col-4">
<div class="p-2 rounded" style="background-color: hsl(var(--accent));">
<i class="bi bi-box d-block mb-1" style="color: hsl(var(--primary));"></i>
<p class="fw-semibold mb-0">{{ $club->packages_count ?? 0 }}</p>
<p class="text-muted mb-0" style="font-size: 0.75rem;">Packages</p>
</div>
</div>
<div class="col-4">
<div class="p-2 rounded" style="background-color: hsl(var(--accent));">
<i class="bi bi-star d-block mb-1" style="color: hsl(var(--primary));"></i>
<p class="fw-semibold mb-0">{{ $club->trainers_count ?? 0 }}</p>
<p class="text-muted mb-0" style="font-size: 0.75rem;">Trainers</p>
</div>
</div>
</div>
</div>
</div>
</div>
@endforeach
</div>
@endif
</div>
@push('styles')
<style>
.hover-card {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
border: 1px solid hsl(var(--border));
}
.hover-card:hover {
transform: translateY(-8px);
box-shadow: 0 12px 28px rgba(102, 126, 234, 0.15);
}
.club-cover-img {
transition: transform 0.3s ease;
}
.hover-card:hover .club-cover-img {
transform: scale(1.1);
}
.club-name-hover {
transition: color 0.3s ease;
}
.hover-card:hover .club-name-hover {
color: hsl(var(--primary));
}
.space-y-6 > * + * {
margin-top: 1.5rem;
}
</style>
@endpush
@push('scripts')
<script>
// Search functionality
document.getElementById('searchInput').addEventListener('input', function(e) {
const searchTerm = e.target.value.toLowerCase();
const clubCards = document.querySelectorAll('.club-card');
clubCards.forEach(card => {
const name = card.dataset.name;
const location = card.dataset.location;
const description = card.dataset.description;
const matches = name.includes(searchTerm) ||
location.includes(searchTerm) ||
description.includes(searchTerm);
card.style.display = matches ? '' : 'none';
});
});
</script>
@endpush
@endsection

View File

@ -0,0 +1,281 @@
@extends('layouts.admin')
@section('admin-content')
<div>
<!-- Page Header -->
<div class="mb-4">
<h1 class="h2 fw-bold mb-2">All Members</h1>
<p class="text-muted">Manage all platform members</p>
</div>
<!-- Search and Actions Bar -->
<div class="d-flex justify-content-between align-items-center mb-4">
<div class="flex-grow-1 me-3">
<input type="text" id="memberSearch" class="form-control" placeholder="Search members by name, phone, nationality, or gender..." value="{{ $search ?? '' }}">
</div>
<div class="d-flex gap-2">
<button class="btn btn-outline-primary">
<i class="bi bi-person-plus me-2"></i>Add Child Member
</button>
<button class="btn btn-primary">
<i class="bi bi-plus-circle me-2"></i>Create Member
</button>
</div>
</div>
<!-- Members Grid -->
@if($members->count() > 0)
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-4 mb-4" id="membersGrid">
@foreach($members as $member)
<div class="col member-card-wrapper"
data-member-name="{{ $member->full_name }}"
data-member-phone="{{ $member->formatted_mobile ?? '' }}"
data-member-nationality="{{ $member->nationality ?? '' }}"
data-member-gender="{{ $member->gender ?? '' }}">
<a href="{{ route('family.show', $member->id) }}" class="text-decoration-none">
<div class="card h-100 shadow-sm border overflow-hidden d-flex flex-column family-card">
<!-- Header with gradient background -->
<div class="p-4 pb-3" style="background: linear-gradient(135deg, {{ $member->gender == 'm' ? 'rgba(147, 51, 234, 0.1) 0%, rgba(147, 51, 234, 0.05) 50%' : 'rgba(214, 51, 132, 0.1) 0%, rgba(214, 51, 132, 0.05) 50%' }}, transparent 100%);">
<div class="d-flex align-items-start gap-3">
<div class="position-relative">
<div class="rounded-circle border border-4 border-white shadow" style="width: 80px; height: 80px; overflow: hidden; box-shadow: 0 0 0 2px {{ $member->gender == 'm' ? 'rgba(147, 51, 234, 0.3)' : 'rgba(214, 51, 132, 0.3)' }} !important;">
@if($member->profile_picture)
<img src="{{ asset('storage/' . $member->profile_picture) }}" alt="{{ $member->full_name }}" class="w-100 h-100" style="object-fit: cover;">
@else
<div class="w-100 h-100 d-flex align-items-center justify-content-center text-white fw-bold fs-4" style="background: linear-gradient(135deg, {{ $member->gender == 'm' ? '#8b5cf6 0%, #7c3aed 100%' : '#d63384 0%, #a61e4d 100%' }});">
{{ strtoupper(substr($member->full_name, 0, 1)) }}
</div>
@endif
</div>
</div>
<div class="flex-grow-1 min-w-0">
<h5 class="fw-bold mb-2 text-truncate">{{ $member->full_name }}</h5>
<div class="d-flex flex-wrap gap-2">
@php
$age = $member->age;
$ageGroup = 'Adult';
if ($age < 2) {
$ageGroup = 'Infant';
} elseif ($age < 4) {
$ageGroup = 'Toddler';
} elseif ($age < 6) {
$ageGroup = 'Preschooler';
} elseif ($age < 13) {
$ageGroup = 'Child';
} elseif ($age < 20) {
$ageGroup = 'Teenager';
} elseif ($age < 40) {
$ageGroup = 'Young Adult';
} elseif ($age < 60) {
$ageGroup = 'Adult';
} else {
$ageGroup = 'Senior';
}
@endphp
<span class="badge {{ $member->gender == 'm' ? 'bg-primary' : 'bg-danger' }}">{{ $ageGroup }}</span>
@if($member->member_clubs_count > 0)
<span class="badge bg-success">{{ $member->member_clubs_count }} {{ Str::plural('Club', $member->member_clubs_count) }}</span>
@endif
</div>
</div>
</div>
</div>
<!-- Contact Info -->
<div class="px-4 py-3 bg-light border-top border-bottom">
@if($member->mobile && isset($member->mobile['number']))
<div class="d-flex align-items-center gap-2 small mb-2">
<i class="bi bi-telephone-fill {{ $member->gender == 'm' ? 'text-primary' : 'text-danger' }}"></i>
<span class="fw-medium text-muted">{{ $member->mobile['code'] ?? '' }} {{ $member->mobile['number'] }}</span>
</div>
@endif
@if($member->email)
<div class="d-flex align-items-center gap-2 small">
<i class="bi bi-envelope-fill {{ $member->gender == 'm' ? 'text-primary' : 'text-danger' }}"></i>
<span class="fw-medium text-muted text-truncate">{{ $member->email }}</span>
</div>
@endif
</div>
<!-- Details -->
<div class="px-4 py-3 flex-grow-1">
<div class="row g-3 mb-3">
<div class="col-6">
<div class="small text-muted text-uppercase fw-medium mb-1" style="font-size: 0.7rem; letter-spacing: 0.5px;">Gender</div>
<div class="fw-semibold text-muted text-capitalize">{{ $member->gender == 'm' ? 'Male' : 'Female' }}</div>
</div>
<div class="col-6">
<div class="small text-muted text-uppercase fw-medium mb-1" style="font-size: 0.7rem; letter-spacing: 0.5px;">Age</div>
<div class="fw-semibold text-muted">{{ $member->age }} years</div>
</div>
</div>
<div class="row g-3 mb-3">
<div class="col-6">
<div class="small text-muted text-uppercase fw-medium mb-1" style="font-size: 0.7rem; letter-spacing: 0.5px;">Nationality</div>
<div class="fw-semibold text-muted fs-5 nationality-display" data-iso3="{{ $member->nationality }}">{{ $member->nationality }}</div>
</div>
<div class="col-6">
<div class="small text-muted text-uppercase fw-medium mb-1" style="font-size: 0.7rem; letter-spacing: 0.5px;">Horoscope</div>
<div class="fw-semibold text-muted">
@php
$horoscopeSymbols = [
'Aries' => '♈',
'Taurus' => '♉',
'Gemini' => '♊',
'Cancer' => '♋',
'Leo' => '♌',
'Virgo' => '♍',
'Libra' => '♎',
'Scorpio' => '♏',
'Sagittarius' => '♐',
'Capricorn' => '♑',
'Aquarius' => '♒',
'Pisces' => '♓'
];
$horoscope = $member->horoscope ?? 'N/A';
$symbol = $horoscopeSymbols[$horoscope] ?? '';
@endphp
{{ $symbol }} {{ $horoscope }}
</div>
</div>
</div>
<div class="pt-2 border-top">
<div class="d-flex justify-content-between align-items-center small mb-2">
<span class="text-muted fw-medium">Next Birthday</span>
<span class="fw-semibold text-muted">
@if($member->birthdate)
{{ $member->birthdate->copy()->year(now()->year)->isFuture()
? $member->birthdate->copy()->year(now()->year)->diffForHumans(['parts' => 2, 'syntax' => \Carbon\CarbonInterface::DIFF_ABSOLUTE])
: $member->birthdate->copy()->year(now()->year + 1)->diffForHumans(['parts' => 2, 'syntax' => \Carbon\CarbonInterface::DIFF_ABSOLUTE]) }}
@else
N/A
@endif
</span>
</div>
<div class="d-flex justify-content-between align-items-center small">
<span class="text-muted fw-medium">Member Since</span>
<span class="fw-semibold text-muted">{{ $member->created_at->format('d/m/Y') }}</span>
</div>
</div>
</div>
<!-- Footer -->
<div class="px-4 py-2 {{ $member->gender == 'm' ? 'bg-primary' : 'bg-danger' }} bg-opacity-10 border-top">
<div class="d-flex align-items-center justify-content-center gap-2 small">
<span class="fw-medium text-white">
PLATFORM MEMBER
</span>
</div>
</div>
</div>
</a>
</div>
@endforeach
</div>
<!-- Pagination -->
<div class="d-flex justify-content-center mb-4">
{{ $members->links() }}
</div>
@else
<div class="card border-0 shadow-sm mb-4">
<div class="card-body text-center py-5">
<i class="bi bi-people text-muted" style="font-size: 4rem;"></i>
<h5 class="mt-3 mb-2">No Members Found</h5>
<p class="text-muted mb-0">
@if($search)
No members match your search criteria.
@else
No members registered on the platform yet.
@endif
</p>
</div>
</div>
@endif
</div>
@push('styles')
<style>
/* Family Card Hover Effects */
.family-card {
transition: all 0.3s ease-in-out;
cursor: pointer;
}
.family-card:hover {
transform: translateY(-8px);
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.15) !important;
}
.family-card:hover .rounded-circle {
transform: scale(1.1);
transition: transform 0.3s ease-in-out;
}
/* Remove underline from card links */
a.text-decoration-none:hover .family-card {
text-decoration: none;
}
.member-card-wrapper {
transition: opacity 0.3s ease;
}
.member-card-wrapper.hidden {
display: none;
}
</style>
@endpush
@push('scripts')
<script>
document.addEventListener('DOMContentLoaded', function() {
// Load countries from JSON file
fetch('/data/countries.json')
.then(response => response.json())
.then(countries => {
// Convert all nationality displays from ISO3 to country name with flag
document.querySelectorAll('.nationality-display').forEach(element => {
const iso3Code = element.getAttribute('data-iso3');
if (!iso3Code) return;
const country = countries.find(c => c.iso3 === iso3Code);
if (country) {
// Get flag emoji from ISO2 code
const flagEmoji = country.iso2
.toUpperCase()
.split('')
.map(char => String.fromCodePoint(127397 + char.charCodeAt(0)))
.join('');
element.textContent = `${flagEmoji} ${country.iso2.toUpperCase()}`;
}
});
})
.catch(error => console.error('Error loading countries:', error));
// Real-time search filtering
document.getElementById('memberSearch').addEventListener('input', function(e) {
const searchTerm = e.target.value.toLowerCase();
const memberCards = document.querySelectorAll('.member-card-wrapper');
memberCards.forEach(function(card) {
const memberName = card.getAttribute('data-member-name').toLowerCase();
const memberPhone = card.getAttribute('data-member-phone').toLowerCase();
const memberNationality = card.getAttribute('data-member-nationality').toLowerCase();
const memberGender = card.getAttribute('data-member-gender').toLowerCase();
if (memberName.includes(searchTerm) ||
memberPhone.includes(searchTerm) ||
memberNationality.includes(searchTerm) ||
memberGender.includes(searchTerm)) {
card.classList.remove('hidden');
} else {
card.classList.add('hidden');
}
});
});
});
</script>
@endpush
@endsection

View File

@ -84,12 +84,16 @@
<!-- Clubs Grid --> <!-- Clubs Grid -->
<div class="row justify-content-center" id="clubsGrid" style="display: none;"> <div class="row justify-content-center" id="clubsGrid" style="display: none;">
<div class="col-lg-10"> <div class="col-12">
<div class="row g-4" id="clubsContainer"> <div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-4" id="clubsContainer">
<!-- Club cards will be inserted here --> <!-- Club cards will be inserted here -->
</div> </div>
</div> </div>
<div class="col-12 d-flex justify-content-center" id="noResultsContainer" style="display: none;"> </div>
<!-- No Results -->
<div class="row justify-content-center" id="noResultsContainer" style="display: none;">
<div class="col-12 d-flex justify-content-center">
<div id="noResults" class="d-flex flex-column align-items-center justify-content-center text-center" style="min-height: 400px;"> <div id="noResults" class="d-flex flex-column align-items-center justify-content-center text-center" style="min-height: 400px;">
<i class="bi bi-inbox" style="font-size: 4rem; color: #dee2e6;"></i> <i class="bi bi-inbox" style="font-size: 4rem; color: #dee2e6;"></i>
<h4 class="mt-3 text-muted">No Results Found</h4> <h4 class="mt-3 text-muted">No Results Found</h4>
@ -98,7 +102,6 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Map Modal --> <!-- Map Modal -->
@ -155,56 +158,20 @@
} }
.club-card { .club-card {
transition: all 0.3s ease; transition: all 0.3s ease-in-out;
border: none;
border-radius: 12px;
overflow: hidden;
} }
.club-card:hover { .club-card:hover {
transform: translateY(-5px); transform: translateY(-8px);
box-shadow: 0 8px 20px rgba(0,0,0,0.15); box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04) !important;
} }
.club-card-img { .club-card:hover .club-cover-img {
height: 200px; transform: scale(1.1);
object-fit: cover;
width: 100%;
} }
.club-badge { .club-card:hover .club-title {
position: absolute; color: #dc3545 !important;
top: 10px;
right: 10px;
background: #0d6efd;
color: white;
padding: 0.25rem 0.75rem;
border-radius: 20px;
font-size: 0.75rem;
font-weight: 600;
}
.stat-box {
background: hsl(var(--muted));
border-radius: 8px;
padding: 0.75rem;
text-align: center;
}
.stat-box i {
font-size: 1.25rem;
color: hsl(var(--primary));
}
.stat-box .stat-number {
font-size: 1.25rem;
font-weight: 700;
color: hsl(var(--foreground));
}
.stat-box .stat-label {
font-size: 0.75rem;
color: hsl(var(--muted-foreground));
} }
/* Pulsing animation for location marker */ /* Pulsing animation for location marker */
@ -259,6 +226,7 @@
<script> <script>
let map; let map;
let pageMap;
let userMarker; let userMarker;
let searchRadiusCircle; let searchRadiusCircle;
let userLocation = null; let userLocation = null;
@ -300,7 +268,9 @@ document.addEventListener('DOMContentLoaded', function() {
this.classList.add('active', 'btn-primary'); this.classList.add('active', 'btn-primary');
currentCategory = this.dataset.category; currentCategory = this.dataset.category;
if (currentCategory === 'all') {
// For 'all' and 'sports-clubs' (Clubs) categories, fetch all clubs with location-based sorting
if (currentCategory === 'all' || currentCategory === 'sports-clubs') {
fetchAllClubs(); fetchAllClubs();
} else if (userLocation) { } else if (userLocation) {
fetchNearbyClubs(userLocation.latitude, userLocation.longitude); fetchNearbyClubs(userLocation.latitude, userLocation.longitude);
@ -364,9 +334,6 @@ function startWatchingLocation() {
// Fetch nearby clubs // Fetch nearby clubs
fetchNearbyClubs(userLocation.latitude, userLocation.longitude); fetchNearbyClubs(userLocation.latitude, userLocation.longitude);
// Initialize page map
initPageMap(userLocation.latitude, userLocation.longitude);
// Stop watching after first successful location // Stop watching after first successful location
if (watchId) { if (watchId) {
navigator.geolocation.clearWatch(watchId); navigator.geolocation.clearWatch(watchId);
@ -406,42 +373,6 @@ function updateLocationDisplay(lat, lng) {
`<i class="bi bi-geo-alt-fill me-1 fs-5"></i>${lat.toFixed(4)}, ${lng.toFixed(4)}`; `<i class="bi bi-geo-alt-fill me-1 fs-5"></i>${lat.toFixed(4)}, ${lng.toFixed(4)}`;
} }
// Initialize page map
function initPageMap(lat, lng) {
if (pageMap) {
pageMap.remove();
}
pageMap = L.map('pageMap').setView([lat, lng], 13);
L.tileLayer('http://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; OpenStreetMap contributors',
maxZoom: 19
}).addTo(pageMap);
// Add user marker
userMarker = L.marker([lat, lng], {
icon: L.divIcon({
className: 'user-location-marker',
html: '<i class="bi bi-geo-alt-fill pulse-marker" style="font-size: 36px; color: #dc3545; filter: drop-shadow(0 3px 6px rgba(0,0,0,0.4));"></i>',
iconSize: [36, 36],
iconAnchor: [18, 36]
})
}).addTo(pageMap);
// Add club markers
if (allClubs.length > 0) {
allClubs.forEach(club => {
if (club.gps_lat && club.gps_long) {
L.marker([club.gps_lat, club.gps_long]).addTo(pageMap)
.bindPopup(`<b>${club.club_name}</b><br>${club.owner_name || 'N/A'}`);
}
});
}
setTimeout(() => pageMap.invalidateSize(), 100);
document.getElementById('mapSection').style.display = 'block';
}
// Initialize map in modal // Initialize map in modal
function initMap(lat, lng) { function initMap(lat, lng) {
@ -478,14 +409,6 @@ function initMap(lat, lng) {
updateModalLocation(position.lat, position.lng); updateModalLocation(position.lat, position.lng);
}); });
// Search radius circle (removed - no red tint on map)
// searchRadiusCircle = L.circle([lat, lng], {
// color: '#dc3545',
// fillColor: '#dc3545',
// fillOpacity: 0.1,
// radius: 50000
// }).addTo(map);
setTimeout(() => map.invalidateSize(), 100); setTimeout(() => map.invalidateSize(), 100);
} }
@ -525,7 +448,13 @@ function fetchAllClubs() {
document.getElementById('loadingSpinner').style.display = 'block'; document.getElementById('loadingSpinner').style.display = 'block';
document.getElementById('clubsGrid').style.display = 'none'; document.getElementById('clubsGrid').style.display = 'none';
fetch(`{{ route('clubs.all') }}`, { // Build URL with location parameters if available
let url = `{{ route('clubs.all') }}`;
if (userLocation) {
url += `?latitude=${userLocation.latitude}&longitude=${userLocation.longitude}`;
}
fetch(url, {
method: 'GET', method: 'GET',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@ -565,99 +494,94 @@ function displayClubs(clubs) {
noResultsContainer.style.display = 'none'; noResultsContainer.style.display = 'none';
// Update map markers if page map exists
if (pageMap) {
// Clear existing club markers (assuming user marker is the first)
pageMap.eachLayer(function(layer) {
if (layer instanceof L.Marker && layer !== userMarker) {
pageMap.removeLayer(layer);
}
});
// Add new club markers
clubs.forEach(club => {
if (club.gps_lat && club.gps_long) {
L.marker([club.gps_lat, club.gps_long]).addTo(pageMap)
.bindPopup(`<b>${club.club_name}</b><br>${club.owner_name || 'N/A'}`);
}
});
}
clubs.forEach(club => { clubs.forEach(club => {
const card = document.createElement('div'); const card = document.createElement('div');
card.className = 'col-md-6 col-lg-4'; card.className = 'col';
card.innerHTML = ` card.innerHTML = `
<div class="rounded-none border bg-card text-card-foreground shadow-sm hover:shadow-elevated transition-all cursor-pointer overflow-hidden group"> <div class="card border shadow-sm overflow-hidden club-card" style="border-radius: 0; cursor: pointer; transition: all 0.3s ease;">
<div class="relative h-64 overflow-hidden"> <!-- Cover Image -->
<img src="https://via.placeholder.com/400x200?text=${encodeURIComponent(club.club_name)}" alt="${club.club_name}" class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"> <div class="position-relative overflow-hidden" style="height: 192px;">
<div class="absolute bottom-3 right-3 z-10"> <img src="https://via.placeholder.com/400x200?text=${encodeURIComponent(club.club_name)}" alt="${club.club_name}" loading="lazy" class="w-100 h-100 club-cover-img" style="object-fit: cover; transition: transform 0.3s ease;">
<div class="w-14 h-14 rounded-full border-2 border-white/90 shadow-lg overflow-hidden bg-white/95 backdrop-blur">
<img src="https://via.placeholder.com/50x50?text=Logo" alt="${club.club_name} logo" class="w-full h-full object-contain rounded-full"> <!-- Club Logo - Bottom Left -->
<div class="position-absolute" style="bottom: 8px; left: 8px;">
<div class="bg-white shadow border p-0.5" style="width: 80px; height: 80px; border-radius: 50%; border-color: rgba(0,0,0,0.1) !important;">
<img src="https://via.placeholder.com/80x80?text=Logo" alt="${club.club_name} logo" loading="lazy" class="w-100 h-100 rounded-circle" style="object-fit: contain;">
</div> </div>
</div> </div>
<div class="inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 border-transparent absolute top-4 right-4 bg-brand-red text-white hover:bg-brand-red-dark">Sports Club</div>
<!-- Sports Club Badge - Top Left -->
<div class="position-absolute" style="top: 8px; left: 8px;">
<span class="badge text-white px-3 py-1" style="background-color: #dc3545; border-radius: 9999px; font-size: 0.75rem; font-weight: 600;">Sports Club</span>
</div> </div>
<div class="px-5 pt-4 pb-2">
<h3 class="text-2xl font-bold text-foreground leading-tight line-clamp-2">${club.club_name}</h3>
</div> </div>
<div class="p-6 px-5 pb-5 pt-2 space-y-3">
<div> <!-- Card Body -->
<div class="flex items-center gap-1 text-sm text-brand-red mb-1"> <div class="p-4" style="background-color: white;">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-navigation w-4 h-4"> <div class="mb-3">
<!-- Club Name -->
<h3 class="fw-semibold mb-2 club-title" style="font-size: 1.125rem; color: #1f2937; transition: color 0.3s ease;">${club.club_name}</h3>
<!-- Distance -->
<div class="d-flex align-items-center mb-1" style="font-size: 0.875rem; color: #dc3545;">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="me-1 flex-shrink-0">
<polygon points="3 11 22 2 13 21 11 13 3 11"></polygon> <polygon points="3 11 22 2 13 21 11 13 3 11"></polygon>
</svg> </svg>
<span class="font-medium">${club.distance ? club.distance + ' km away' : 'Location available'}</span> <span class="fw-semibold">${club.distance ? club.distance + ' km away' : 'Location available'}</span>
</div> </div>
<div class="flex items-center gap-1 text-sm text-muted-foreground">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-building w-4 h-4"> <!-- Address/Owner -->
<rect width="16" height="20" x="4" y="2" rx="2" ry="2"></rect> <div class="d-flex align-items-center text-muted" style="font-size: 0.875rem;">
<path d="M9 22v-4h6v4"></path> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="me-1 flex-shrink-0">
<path d="M8 6h.01"></path> <path d="M20 10c0 4.993-5.539 10.193-7.399 11.799a1 1 0 0 1-1.202 0C9.539 20.193 4 14.993 4 10a8 8 0 0 1 16 0"></path>
<path d="M16 6h.01"></path> <circle cx="12" cy="10" r="3"></circle>
<path d="M12 6h.01"></path>
<path d="M12 10h.01"></path>
<path d="M12 14h.01"></path>
<path d="M16 10h.01"></path>
<path d="M16 14h.01"></path>
<path d="M8 10h.01"></path>
<path d="M8 14h.01"></path>
</svg> </svg>
<span>${club.owner_name || 'N/A'}</span> <span class="text-truncate">${club.owner_name || 'N/A'}</span>
</div> </div>
</div> </div>
<div class="grid grid-cols-3 gap-2 border-t pt-3">
<div class="bg-brand-red/5 rounded-lg p-2 border border-brand-red/10 hover:border-brand-red/20 transition-colors"> <!-- Stats Grid -->
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-users w-4 h-4 mx-auto mb-1 text-brand-red"> <div class="row g-2 text-center mb-3" style="font-size: 0.75rem;">
<div class="col-4">
<div class="p-2 rounded" style="background-color: rgba(220, 53, 69, 0.05);">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="mx-auto mb-1" style="color: #dc3545;">
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"></path> <path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"></path>
<circle cx="9" cy="7" r="4"></circle> <circle cx="9" cy="7" r="4"></circle>
<path d="M22 21v-2a4 4 0 0 0-3-3.87"></path> <path d="M22 21v-2a4 4 0 0 0-3-3.87"></path>
<path d="M16 3.13a4 4 0 0 1 0 7.75"></path> <path d="M16 3.13a4 4 0 0 1 0 7.75"></path>
</svg> </svg>
<p class="text-lg font-bold text-center">0</p> <p class="fw-semibold mb-0" style="color: #1f2937;">13</p>
<p class="text-[10px] text-muted-foreground text-center font-medium">Members</p> <p class="text-muted mb-0">Members</p>
</div> </div>
<div class="bg-brand-red/5 rounded-lg p-2 border border-brand-red/10 hover:border-brand-red/20 transition-colors"> </div>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-package w-4 h-4 mx-auto mb-1 text-brand-red"> <div class="col-4">
<path d="M11 21.73a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73z"></path> <div class="p-2 rounded" style="background-color: rgba(220, 53, 69, 0.05);">
<path d="M12 22V12"></path> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="mx-auto mb-1" style="color: #dc3545;">
<path d="m3.3 7 7.703 4.734a2 2 0 0 0 1.994 0L20.7 7"></path> <path d="M14.4 14.4 9.6 9.6"></path>
<path d="m7.5 4.27 9 5.15"></path> <path d="M18.657 21.485a2 2 0 1 1-2.829-2.828l-1.767 1.768a2 2 0 1 1-2.829-2.829l6.364-6.364a2 2 0 1 1 2.829 2.829l-1.768 1.767a2 2 0 1 1 2.828 2.829z"></path>
<path d="m21.5 21.5-1.4-1.4"></path>
<path d="M3.9 3.9 2.5 2.5"></path>
<path d="M6.404 12.768a2 2 0 1 1-2.829-2.829l1.768-1.767a2 2 0 1 1-2.828-2.829l2.828-2.828a2 2 0 1 1 2.829 2.828l1.767-1.768a2 2 0 1 1 2.829 2.829z"></path>
</svg> </svg>
<p class="text-lg font-bold text-center">0</p> <p class="fw-semibold mb-0" style="color: #1f2937;">0</p>
<p class="text-[10px] text-muted-foreground text-center font-medium">Packages</p> <p class="text-muted mb-0">Packages</p>
</div> </div>
<div class="bg-brand-red/5 rounded-lg p-2 border border-brand-red/10 hover:border-brand-red/20 transition-colors"> </div>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-user w-4 h-4 mx-auto mb-1 text-brand-red"> <div class="col-4">
<path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"></path> <div class="p-2 rounded" style="background-color: rgba(220, 53, 69, 0.05);">
<circle cx="12" cy="7" r="4"></circle> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="mx-auto mb-1" style="color: #dc3545;">
<path d="M11.525 2.295a.53.53 0 0 1 .95 0l2.31 4.679a2.123 2.123 0 0 0 1.595 1.16l5.166.756a.53.53 0 0 1 .294.904l-3.736 3.638a2.123 2.123 0 0 0-.611 1.878l.882 5.14a.53.53 0 0 1-.771.56l-4.618-2.428a2.122 2.122 0 0 0-1.973 0L6.396 21.01a.53.53 0 0 1-.77-.56l.881-5.139a2.122 2.122 0 0 0-.611-1.879L2.16 9.795a.53.53 0 0 1 .294-.906l5.165-.755a2.122 2.122 0 0 0 1.597-1.16z"></path>
</svg> </svg>
<p class="text-lg font-bold text-center">0</p> <p class="fw-semibold mb-0" style="color: #1f2937;">0</p>
<p class="text-[10px] text-muted-foreground text-center font-medium">Trainers</p> <p class="text-muted mb-0">Trainers</p>
</div> </div>
</div> </div>
<div class="flex gap-2"> </div>
<button class="inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm ring-offset-background transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 hover:shadow-medium hover:scale-105 h-10 px-4 py-2 flex-1 bg-brand-red hover:bg-brand-red-dark text-white font-semibold shadow-sm">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-user-plus w-4 h-4 mr-2"> <!-- Action Buttons -->
<div class="d-flex gap-2">
<button class="btn btn-danger flex-fill fw-semibold" style="font-size: 0.875rem;">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="me-1">
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"></path> <path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"></path>
<circle cx="9" cy="7" r="4"></circle> <circle cx="9" cy="7" r="4"></circle>
<line x1="19" x2="19" y1="8" y2="14"></line> <line x1="19" x2="19" y1="8" y2="14"></line>
@ -665,9 +589,7 @@ function displayClubs(clubs) {
</svg> </svg>
Join Club Join Club
</button> </button>
<button class="inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm ring-offset-background transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 hover:text-accent-foreground h-10 px-4 py-2 flex-1 text-brand-red hover:bg-brand-red/10 font-semibold"> <button class="btn btn-outline-danger flex-fill fw-semibold" style="font-size: 0.875rem;">View Details</button>
View Details
</button>
</div> </div>
</div> </div>
</div> </div>

View File

@ -0,0 +1,165 @@
@extends('layouts.app')
@section('content')
<style>
/* Admin Layout - BOXED with Sidebar */
.admin-wrapper {
max-width: 1320px;
margin: 20px auto;
padding: 0 15px;
display: flex;
gap: 20px;
}
.admin-sidebar {
width: 256px;
min-width: 256px;
border-right: 1px solid hsl(var(--border));
background-color: hsl(var(--muted) / 0.3);
padding: 1.5rem;
border-radius: 0.75rem;
height: fit-content;
position: sticky;
top: 92px;
}
.admin-sidebar h2 {
font-size: 0.875rem;
font-weight: 600;
color: hsl(var(--muted-foreground));
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 1rem;
}
.admin-sidebar nav {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.admin-nav-item {
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 1rem;
border-radius: 0.5rem;
transition: all 0.2s;
text-decoration: none;
color: hsl(var(--foreground));
background-color: hsl(var(--card));
border: 1px solid transparent;
}
.admin-nav-item:hover {
background-color: hsl(var(--muted));
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
text-decoration: none;
color: hsl(var(--foreground));
}
.admin-nav-item.active {
background-color: hsl(var(--primary));
color: white;
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3);
}
.admin-nav-item.active .nav-item-text,
.admin-nav-item.active .nav-item-subtitle {
color: white !important;
}
.admin-nav-item i {
width: 20px;
height: 20px;
flex-shrink: 0;
margin-top: 2px;
}
.nav-item-content {
flex: 1;
text-align: left;
}
.nav-item-text {
font-weight: 600;
font-size: 0.875rem;
margin: 0;
}
.nav-item-subtitle {
font-size: 0.75rem;
color: hsl(var(--muted-foreground));
margin: 0.25rem 0 0 0;
}
.admin-nav-item.active .nav-item-subtitle {
color: rgba(255, 255, 255, 0.8);
}
.admin-nav-divider {
border-top: 1px solid hsl(var(--border));
margin: 1rem 0;
}
.admin-content {
flex: 1;
min-width: 0;
}
@media (max-width: 992px) {
.admin-wrapper {
flex-direction: column;
}
.admin-sidebar {
width: 100%;
position: relative;
top: 0;
}
}
</style>
<!-- Admin Wrapper - BOXED -->
<div class="admin-wrapper">
<!-- Sidebar -->
<aside class="admin-sidebar">
<h2>Admin Panel</h2>
<nav>
<a href="{{ route('admin.platform.clubs') }}" class="admin-nav-item {{ request()->routeIs('admin.platform.clubs*') || request()->routeIs('admin.platform.index') ? 'active' : '' }}">
<i class="bi bi-building"></i>
<div class="nav-item-content">
<p class="nav-item-text">All Clubs</p>
<p class="nav-item-subtitle">Manage {{ $clubsCount ?? 0 }} {{ ($clubsCount ?? 0) === 1 ? 'club' : 'clubs' }}</p>
</div>
</a>
<a href="{{ route('admin.platform.members') }}" class="admin-nav-item {{ request()->routeIs('admin.platform.members*') ? 'active' : '' }}">
<i class="bi bi-people"></i>
<div class="nav-item-content">
<p class="nav-item-text">All Members</p>
<p class="nav-item-subtitle">View all platform members</p>
</div>
</a>
<a href="{{ route('admin.platform.backup') }}" class="admin-nav-item {{ request()->routeIs('admin.platform.backup*') ? 'active' : '' }}">
<i class="bi bi-database"></i>
<div class="nav-item-content">
<p class="nav-item-text">Backup & Restore</p>
<p class="nav-item-subtitle">Database management</p>
</div>
</a>
<div class="admin-nav-divider"></div>
<a href="{{ route('clubs.explore') }}" class="admin-nav-item" style="border: 1px solid hsl(var(--border));">
<i class="bi bi-eye text-primary"></i>
<div class="nav-item-content">
<p class="nav-item-text">Back to Explore</p>
<p class="nav-item-subtitle">View as user</p>
</div>
</a>
</nav>
</aside>
<!-- Content -->
<main class="admin-content">
@yield('admin-content')
</main>
</div>
@endsection

View File

@ -415,15 +415,11 @@
<i class="bi bi-gear me-2"></i>Settings <i class="bi bi-gear me-2"></i>Settings
</a> </a>
<div class="dropdown-divider"></div> <div class="dropdown-divider"></div>
@if(Auth::user()->has_business ?? false) @if(Auth::user()->isSuperAdmin())
<a class="dropdown-item small" href="#"> <a class="dropdown-item small" href="{{ route('admin.platform.index') }}">
<i class="bi bi-building me-2"></i>Manage My Business <i class="bi bi-shield-check me-2"></i>Admin Panel
</a>
@endif
@if((Auth::user()->is_super_admin ?? false) || (Auth::user()->is_moderator ?? false))
<a class="dropdown-item small" href="#">
<i class="bi bi-shield me-2"></i>Admin Panel
</a> </a>
<div class="dropdown-divider"></div>
@endif @endif
@if((Auth::user()->has_business ?? false) || ((Auth::user()->is_super_admin ?? false) || (Auth::user()->is_moderator ?? false))) @if((Auth::user()->has_business ?? false) || ((Auth::user()->is_super_admin ?? false) || (Auth::user()->is_moderator ?? false)))
<div class="dropdown-divider"></div> <div class="dropdown-divider"></div>
@ -450,9 +446,71 @@
@yield('content') @yield('content')
</main> </main>
<!-- Toast Container -->
<div class="toast-container position-fixed top-0 end-0 p-3" style="z-index: 9999;">
@if(session('success'))
<div class="toast align-items-center text-white bg-success border-0" role="alert" aria-live="assertive" aria-atomic="true" id="successToast">
<div class="d-flex">
<div class="toast-body">
<i class="bi bi-check-circle me-2"></i>{{ session('success') }}
</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Close"></button>
</div>
</div>
@endif
@if(session('error'))
<div class="toast align-items-center text-white bg-danger border-0" role="alert" aria-live="assertive" aria-atomic="true" id="errorToast">
<div class="d-flex">
<div class="toast-body">
<i class="bi bi-exclamation-triangle me-2"></i>{{ session('error') }}
</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Close"></button>
</div>
</div>
@endif
@if(session('info'))
<div class="toast align-items-center text-white bg-info border-0" role="alert" aria-live="assertive" aria-atomic="true" id="infoToast">
<div class="d-flex">
<div class="toast-body">
<i class="bi bi-info-circle me-2"></i>{{ session('info') }}
</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Close"></button>
</div>
</div>
@endif
@if(session('warning'))
<div class="toast align-items-center text-dark bg-warning border-0" role="alert" aria-live="assertive" aria-atomic="true" id="warningToast">
<div class="d-flex">
<div class="toast-body">
<i class="bi bi-exclamation-triangle me-2"></i>{{ session('warning') }}
</div>
<button type="button" class="btn-close me-2 m-auto" data-bs-dismiss="toast" aria-label="Close"></button>
</div>
</div>
@endif
</div>
<!-- Bootstrap JS Bundle with Popper --> <!-- Bootstrap JS Bundle with Popper -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/js/bootstrap.bundle.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/js/bootstrap.bundle.min.js"></script>
<script>
// Auto-show and auto-hide toasts
document.addEventListener('DOMContentLoaded', function() {
const toasts = ['successToast', 'errorToast', 'infoToast', 'warningToast'];
toasts.forEach(function(toastId) {
const toastElement = document.getElementById(toastId);
if (toastElement) {
const toast = new bootstrap.Toast(toastElement, {
autohide: true,
delay: 3000
});
toast.show();
}
});
});
</script>
<!-- jQuery (required for Select2) --> <!-- jQuery (required for Select2) -->
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script> <script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>

View File

@ -80,6 +80,28 @@ Route::middleware(['auth'])->group(function () {
Route::get('/clubs/all', [ClubController::class, 'all'])->name('clubs.all'); Route::get('/clubs/all', [ClubController::class, 'all'])->name('clubs.all');
}); });
// Platform Admin routes (Super Admin only)
Route::middleware(['auth', 'verified', 'role:super-admin'])->prefix('admin')->name('admin.')->group(function () {
Route::get('/', [App\Http\Controllers\Admin\PlatformController::class, 'index'])->name('platform.index');
// All Clubs Management
Route::get('/clubs', [App\Http\Controllers\Admin\PlatformController::class, 'clubs'])->name('platform.clubs');
Route::get('/clubs/create', [App\Http\Controllers\Admin\PlatformController::class, 'createClub'])->name('platform.clubs.create');
Route::post('/clubs', [App\Http\Controllers\Admin\PlatformController::class, 'storeClub'])->name('platform.clubs.store');
Route::get('/clubs/{club}/edit', [App\Http\Controllers\Admin\PlatformController::class, 'editClub'])->name('platform.clubs.edit');
Route::put('/clubs/{club}', [App\Http\Controllers\Admin\PlatformController::class, 'updateClub'])->name('platform.clubs.update');
Route::delete('/clubs/{club}', [App\Http\Controllers\Admin\PlatformController::class, 'destroyClub'])->name('platform.clubs.destroy');
// All Members Management
Route::get('/members', [App\Http\Controllers\Admin\PlatformController::class, 'members'])->name('platform.members');
// Database Backup & Restore
Route::get('/backup', [App\Http\Controllers\Admin\PlatformController::class, 'backup'])->name('platform.backup');
Route::get('/backup/download', [App\Http\Controllers\Admin\PlatformController::class, 'downloadBackup'])->name('platform.backup.download');
Route::post('/backup/restore', [App\Http\Controllers\Admin\PlatformController::class, 'restoreBackup'])->name('platform.backup.restore');
Route::get('/backup/export-users', [App\Http\Controllers\Admin\PlatformController::class, 'exportAuthUsers'])->name('platform.backup.export-users');
});
// Family routes // Family routes
Route::middleware(['auth', 'verified'])->group(function () { Route::middleware(['auth', 'verified'])->group(function () {
Route::get('/profile', [FamilyController::class, 'profile'])->name('profile.show'); Route::get('/profile', [FamilyController::class, 'profile'])->name('profile.show');