created admin panel with some of its content
This commit is contained in:
parent
2ee4b76599
commit
b16351c416
143
ADMIN_ACCESS_GUIDE.md
Normal file
143
ADMIN_ACCESS_GUIDE.md
Normal 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.
|
||||
228
ADMIN_LAYOUT_UPDATE_SUMMARY.md
Normal file
228
ADMIN_LAYOUT_UPDATE_SUMMARY.md
Normal 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
295
ADMIN_PANEL_PROGRESS.md
Normal 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
|
||||
117
EXPLORE_LOCATION_SORTING_UPDATE.md
Normal file
117
EXPLORE_LOCATION_SORTING_UPDATE.md
Normal 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
425
TESTING_ADMIN_PANEL.md
Normal 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
371
TODO_ADMIN_PANEL.md
Normal 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
|
||||
53
app/Console/Commands/MakeSuperAdmin.php
Normal file
53
app/Console/Commands/MakeSuperAdmin.php
Normal 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;
|
||||
}
|
||||
}
|
||||
338
app/Http/Controllers/Admin/PlatformController.php
Normal file
338
app/Http/Controllers/Admin/PlatformController.php
Normal 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 . '"',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@ -104,12 +104,30 @@ class ClubController extends Controller
|
||||
|
||||
/**
|
||||
* 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')
|
||||
->get()
|
||||
->map(function ($club) {
|
||||
$userLat = $request->input('latitude');
|
||||
$userLng = $request->input('longitude');
|
||||
|
||||
$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 [
|
||||
'id' => $club->id,
|
||||
'club_name' => $club->club_name,
|
||||
@ -117,14 +135,57 @@ class ClubController extends Controller
|
||||
'logo' => $club->logo,
|
||||
'gps_lat' => $club->gps_lat ? (float) $club->gps_lat : 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',
|
||||
];
|
||||
});
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'clubs' => $clubs,
|
||||
'total' => $clubs->count(),
|
||||
'clubs' => $clubsData,
|
||||
'total' => $clubsData->count(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
35
app/Http/Middleware/CheckPermission.php
Normal file
35
app/Http/Middleware/CheckPermission.php
Normal 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.');
|
||||
}
|
||||
}
|
||||
35
app/Http/Middleware/CheckRole.php
Normal file
35
app/Http/Middleware/CheckRole.php
Normal 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.');
|
||||
}
|
||||
}
|
||||
72
app/Models/ClubActivity.php
Normal file
72
app/Models/ClubActivity.php
Normal 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();
|
||||
}
|
||||
}
|
||||
104
app/Models/ClubBankAccount.php
Normal file
104
app/Models/ClubBankAccount.php
Normal 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);
|
||||
}
|
||||
}
|
||||
62
app/Models/ClubFacility.php
Normal file
62
app/Models/ClubFacility.php
Normal 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');
|
||||
}
|
||||
}
|
||||
57
app/Models/ClubGalleryImage.php
Normal file
57
app/Models/ClubGalleryImage.php
Normal 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');
|
||||
}
|
||||
}
|
||||
71
app/Models/ClubInstructor.php
Normal file
71
app/Models/ClubInstructor.php
Normal 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();
|
||||
}
|
||||
}
|
||||
112
app/Models/ClubMemberSubscription.php
Normal file
112
app/Models/ClubMemberSubscription.php
Normal 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);
|
||||
}
|
||||
}
|
||||
95
app/Models/ClubMessage.php
Normal file
95
app/Models/ClubMessage.php
Normal 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);
|
||||
}
|
||||
}
|
||||
90
app/Models/ClubPackage.php
Normal file
90
app/Models/ClubPackage.php
Normal 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
74
app/Models/ClubReview.php
Normal 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);
|
||||
}
|
||||
}
|
||||
49
app/Models/ClubSocialLink.php
Normal file
49
app/Models/ClubSocialLink.php
Normal 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);
|
||||
}
|
||||
}
|
||||
95
app/Models/ClubTransaction.php
Normal file
95
app/Models/ClubTransaction.php
Normal 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
32
app/Models/Permission.php
Normal 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
50
app/Models/Role.php
Normal 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();
|
||||
}
|
||||
}
|
||||
@ -7,10 +7,11 @@ use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class Tenant extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
use HasFactory, SoftDeletes;
|
||||
|
||||
/**
|
||||
* The attributes that are mass assignable.
|
||||
@ -22,8 +23,25 @@ class Tenant extends Model
|
||||
'club_name',
|
||||
'slug',
|
||||
'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_long',
|
||||
'settings',
|
||||
];
|
||||
|
||||
/**
|
||||
@ -34,6 +52,10 @@ class Tenant extends Model
|
||||
protected $casts = [
|
||||
'gps_lat' => '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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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}");
|
||||
}
|
||||
}
|
||||
|
||||
@ -258,6 +258,154 @@ class User extends Authenticatable
|
||||
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.
|
||||
* Override to prevent sending the default Laravel notification.
|
||||
|
||||
@ -12,7 +12,10 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||
health: '/up',
|
||||
)
|
||||
->withMiddleware(function (Middleware $middleware): void {
|
||||
//
|
||||
$middleware->alias([
|
||||
'role' => \App\Http\Middleware\CheckRole::class,
|
||||
'permission' => \App\Http\Middleware\CheckPermission::class,
|
||||
]);
|
||||
})
|
||||
->withExceptions(function (Exceptions $exceptions): void {
|
||||
//
|
||||
|
||||
@ -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');
|
||||
}
|
||||
};
|
||||
@ -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',
|
||||
]);
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -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');
|
||||
}
|
||||
};
|
||||
@ -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');
|
||||
}
|
||||
};
|
||||
@ -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');
|
||||
}
|
||||
};
|
||||
@ -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');
|
||||
}
|
||||
};
|
||||
@ -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');
|
||||
}
|
||||
};
|
||||
@ -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');
|
||||
}
|
||||
};
|
||||
@ -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');
|
||||
}
|
||||
};
|
||||
@ -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');
|
||||
}
|
||||
};
|
||||
@ -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');
|
||||
}
|
||||
};
|
||||
@ -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');
|
||||
}
|
||||
};
|
||||
@ -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');
|
||||
}
|
||||
};
|
||||
@ -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');
|
||||
}
|
||||
};
|
||||
123
database/seeders/RolePermissionSeeder.php
Normal file
123
database/seeders/RolePermissionSeeder.php
Normal 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!');
|
||||
}
|
||||
}
|
||||
195
resources/views/admin/platform/backup.blade.php
Normal file
195
resources/views/admin/platform/backup.blade.php
Normal 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
|
||||
198
resources/views/admin/platform/clubs.blade.php
Normal file
198
resources/views/admin/platform/clubs.blade.php
Normal 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
|
||||
187
resources/views/admin/platform/create-club.blade.php
Normal file
187
resources/views/admin/platform/create-club.blade.php
Normal 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
|
||||
187
resources/views/admin/platform/edit-club.blade.php
Normal file
187
resources/views/admin/platform/edit-club.blade.php
Normal 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
|
||||
185
resources/views/admin/platform/index.blade.php
Normal file
185
resources/views/admin/platform/index.blade.php
Normal 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
|
||||
281
resources/views/admin/platform/members.blade.php
Normal file
281
resources/views/admin/platform/members.blade.php
Normal 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
|
||||
@ -84,12 +84,16 @@
|
||||
|
||||
<!-- Clubs Grid -->
|
||||
<div class="row justify-content-center" id="clubsGrid" style="display: none;">
|
||||
<div class="col-lg-10">
|
||||
<div class="row g-4" id="clubsContainer">
|
||||
<div class="col-12">
|
||||
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-4" id="clubsContainer">
|
||||
<!-- Club cards will be inserted here -->
|
||||
</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;">
|
||||
<i class="bi bi-inbox" style="font-size: 4rem; color: #dee2e6;"></i>
|
||||
<h4 class="mt-3 text-muted">No Results Found</h4>
|
||||
@ -98,7 +102,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Map Modal -->
|
||||
@ -114,17 +117,17 @@
|
||||
<div class="modal-body p-0">
|
||||
<div id="map" style="height: 600px; width: 100%;"></div>
|
||||
</div>
|
||||
<div class="modal-footer border-0 bg-light">
|
||||
<div class="w-100 d-flex justify-content-between align-items-center">
|
||||
<small class="text-muted">
|
||||
<i class="bi bi-geo-alt-fill me-1"></i>
|
||||
<span id="modalLocationCoordinates">Drag the marker to set your location</span>
|
||||
</small>
|
||||
<button type="button" class="btn btn-primary" id="applyLocationBtn">
|
||||
<i class="bi bi-check-circle me-2"></i>Apply Location
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer border-0 bg-light">
|
||||
<div class="w-100 d-flex justify-content-between align-items-center">
|
||||
<small class="text-muted">
|
||||
<i class="bi bi-geo-alt-fill me-1"></i>
|
||||
<span id="modalLocationCoordinates">Drag the marker to set your location</span>
|
||||
</small>
|
||||
<button type="button" class="btn btn-primary" id="applyLocationBtn">
|
||||
<i class="bi bi-check-circle me-2"></i>Apply Location
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -155,56 +158,20 @@
|
||||
}
|
||||
|
||||
.club-card {
|
||||
transition: all 0.3s ease;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
transition: all 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
.club-card:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 8px 20px rgba(0,0,0,0.15);
|
||||
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-img {
|
||||
height: 200px;
|
||||
object-fit: cover;
|
||||
width: 100%;
|
||||
.club-card:hover .club-cover-img {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.club-badge {
|
||||
position: absolute;
|
||||
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));
|
||||
.club-card:hover .club-title {
|
||||
color: #dc3545 !important;
|
||||
}
|
||||
|
||||
/* Pulsing animation for location marker */
|
||||
@ -259,6 +226,7 @@
|
||||
|
||||
<script>
|
||||
let map;
|
||||
let pageMap;
|
||||
let userMarker;
|
||||
let searchRadiusCircle;
|
||||
let userLocation = null;
|
||||
@ -300,7 +268,9 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
this.classList.add('active', 'btn-primary');
|
||||
|
||||
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();
|
||||
} else if (userLocation) {
|
||||
fetchNearbyClubs(userLocation.latitude, userLocation.longitude);
|
||||
@ -364,9 +334,6 @@ function startWatchingLocation() {
|
||||
// Fetch nearby clubs
|
||||
fetchNearbyClubs(userLocation.latitude, userLocation.longitude);
|
||||
|
||||
// Initialize page map
|
||||
initPageMap(userLocation.latitude, userLocation.longitude);
|
||||
|
||||
// Stop watching after first successful location
|
||||
if (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)}`;
|
||||
}
|
||||
|
||||
// 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: '© 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
|
||||
function initMap(lat, lng) {
|
||||
@ -478,14 +409,6 @@ function initMap(lat, 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);
|
||||
}
|
||||
|
||||
@ -525,7 +448,13 @@ function fetchAllClubs() {
|
||||
document.getElementById('loadingSpinner').style.display = 'block';
|
||||
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',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@ -565,99 +494,94 @@ function displayClubs(clubs) {
|
||||
|
||||
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 => {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'col-md-6 col-lg-4';
|
||||
card.className = 'col';
|
||||
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="relative h-64 overflow-hidden">
|
||||
<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="absolute bottom-3 right-3 z-10">
|
||||
<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">
|
||||
<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;">
|
||||
<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;">
|
||||
|
||||
<!-- 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 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 class="p-6 px-5 pb-5 pt-2 space-y-3">
|
||||
<div>
|
||||
<div class="flex items-center gap-1 text-sm text-brand-red mb-1">
|
||||
<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">
|
||||
|
||||
<!-- 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>
|
||||
|
||||
<!-- 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>
|
||||
</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 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">
|
||||
<rect width="16" height="20" x="4" y="2" rx="2" ry="2"></rect>
|
||||
<path d="M9 22v-4h6v4"></path>
|
||||
<path d="M8 6h.01"></path>
|
||||
<path d="M16 6h.01"></path>
|
||||
<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>
|
||||
|
||||
<!-- Address/Owner -->
|
||||
<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>${club.owner_name || 'N/A'}</span>
|
||||
<span class="text-truncate">${club.owner_name || 'N/A'}</span>
|
||||
</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">
|
||||
<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">
|
||||
<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="text-lg font-bold text-center">0</p>
|
||||
<p class="text-[10px] text-muted-foreground text-center font-medium">Members</p>
|
||||
|
||||
<!-- Stats Grid -->
|
||||
<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>
|
||||
<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;">13</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">
|
||||
<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">
|
||||
<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>
|
||||
<path d="M12 22V12"></path>
|
||||
<path d="m3.3 7 7.703 4.734a2 2 0 0 0 1.994 0L20.7 7"></path>
|
||||
<path d="m7.5 4.27 9 5.15"></path>
|
||||
</svg>
|
||||
<p class="text-lg font-bold text-center">0</p>
|
||||
<p class="text-[10px] text-muted-foreground text-center font-medium">Packages</p>
|
||||
<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="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;">0</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">
|
||||
<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">
|
||||
<path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"></path>
|
||||
<circle cx="12" cy="7" r="4"></circle>
|
||||
</svg>
|
||||
<p class="text-lg font-bold text-center">0</p>
|
||||
<p class="text-[10px] text-muted-foreground text-center font-medium">Trainers</p>
|
||||
<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="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;">0</p>
|
||||
<p class="text-muted mb-0">Trainers</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<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>
|
||||
<circle cx="9" cy="7" r="4"></circle>
|
||||
<line x1="19" x2="19" y1="8" y2="14"></line>
|
||||
@ -665,9 +589,7 @@ function displayClubs(clubs) {
|
||||
</svg>
|
||||
Join Club
|
||||
</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">
|
||||
View Details
|
||||
</button>
|
||||
<button class="btn btn-outline-danger flex-fill fw-semibold" style="font-size: 0.875rem;">View Details</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
165
resources/views/layouts/admin.blade.php
Normal file
165
resources/views/layouts/admin.blade.php
Normal 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
|
||||
@ -415,15 +415,11 @@
|
||||
<i class="bi bi-gear me-2"></i>Settings
|
||||
</a>
|
||||
<div class="dropdown-divider"></div>
|
||||
@if(Auth::user()->has_business ?? false)
|
||||
<a class="dropdown-item small" href="#">
|
||||
<i class="bi bi-building me-2"></i>Manage My Business
|
||||
</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
|
||||
@if(Auth::user()->isSuperAdmin())
|
||||
<a class="dropdown-item small" href="{{ route('admin.platform.index') }}">
|
||||
<i class="bi bi-shield-check me-2"></i>Admin Panel
|
||||
</a>
|
||||
<div class="dropdown-divider"></div>
|
||||
@endif
|
||||
@if((Auth::user()->has_business ?? false) || ((Auth::user()->is_super_admin ?? false) || (Auth::user()->is_moderator ?? false)))
|
||||
<div class="dropdown-divider"></div>
|
||||
@ -450,9 +446,71 @@
|
||||
@yield('content')
|
||||
</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 -->
|
||||
<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) -->
|
||||
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
|
||||
|
||||
|
||||
@ -80,6 +80,28 @@ Route::middleware(['auth'])->group(function () {
|
||||
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
|
||||
Route::middleware(['auth', 'verified'])->group(function () {
|
||||
Route::get('/profile', [FamilyController::class, 'profile'])->name('profile.show');
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user