Compare commits
2 Commits
352d54a788
...
7a18eb6588
| Author | SHA1 | Date | |
|---|---|---|---|
| 7a18eb6588 | |||
| c1fc28087e |
120
INSTALLATION_SUMMARY.md
Normal file
120
INSTALLATION_SUMMARY.md
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
# Laravel Image Cropper Installation Summary
|
||||||
|
|
||||||
|
## Package Installed
|
||||||
|
- **Package**: `takeone/cropper` from https://git.innovator.bh/ghassan/laravel-image-cropper
|
||||||
|
- **Version**: dev-main
|
||||||
|
- **Installation Date**: January 26, 2026
|
||||||
|
|
||||||
|
## Changes Made
|
||||||
|
|
||||||
|
### 1. composer.json
|
||||||
|
- Added VCS repository for the package
|
||||||
|
- Installed `takeone/cropper:@dev`
|
||||||
|
|
||||||
|
### 2. resources/views/layouts/app.blade.php
|
||||||
|
- Added `@stack('modals')` before closing `</body>` tag
|
||||||
|
- This allows the cropper modal to be injected into the page
|
||||||
|
|
||||||
|
### 3. resources/views/family/profile-edit.blade.php
|
||||||
|
- Replaced the old `<x-image-upload-modal>` component with `<x-takeone-cropper>`
|
||||||
|
- Configured cropper with:
|
||||||
|
- **ID**: `profile_picture`
|
||||||
|
- **Width**: 300px
|
||||||
|
- **Height**: 400px (portrait rectangle - 3:4 ratio)
|
||||||
|
- **Shape**: `square` (rectangle viewport)
|
||||||
|
- **Folder**: `images/profiles`
|
||||||
|
- **Filename**: `profile_{user_id}`
|
||||||
|
- **Upload URL**: Custom route `profile.upload-picture`
|
||||||
|
- Added image display logic to show current profile picture or placeholder
|
||||||
|
|
||||||
|
### 4. app/Http/Controllers/FamilyController.php
|
||||||
|
- Updated `uploadProfilePicture()` method to handle base64 image data from cropper
|
||||||
|
- Method now:
|
||||||
|
- Accepts base64 image data instead of file upload
|
||||||
|
- Decodes and saves the cropped image
|
||||||
|
- Updates user's `profile_picture` field in database
|
||||||
|
- Returns JSON response with success status and image path
|
||||||
|
|
||||||
|
### 5. resources/views/vendor/takeone/components/widget.blade.php
|
||||||
|
- Published and customized the package's widget component
|
||||||
|
- Added support for custom `uploadUrl` parameter
|
||||||
|
- Added page reload after successful upload to display new image
|
||||||
|
- Improved error handling with detailed error messages
|
||||||
|
|
||||||
|
### 6. Storage
|
||||||
|
- Ran `php artisan storage:link` to link public storage
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
1. User clicks "Change Photo" button on profile edit page
|
||||||
|
2. Modal popup appears with file selector
|
||||||
|
3. User selects an image file
|
||||||
|
4. Cropme.js library loads the image in a cropping interface
|
||||||
|
5. User can:
|
||||||
|
- Zoom in/out using slider
|
||||||
|
- Rotate image using slider
|
||||||
|
- Pan/move image within the viewport
|
||||||
|
6. Cropping viewport is set to 300x400px (portrait rectangle)
|
||||||
|
7. User clicks "Crop & Save Image"
|
||||||
|
8. Image is cropped to base64 format
|
||||||
|
9. AJAX POST request sent to `/profile/upload-picture`
|
||||||
|
10. FamilyController processes the base64 image:
|
||||||
|
- Decodes base64 data
|
||||||
|
- Saves to `storage/app/public/images/profiles/profile_{user_id}.{ext}`
|
||||||
|
- Deletes old profile picture if exists
|
||||||
|
- Updates user's `profile_picture` field
|
||||||
|
11. Page reloads to display the new profile picture
|
||||||
|
|
||||||
|
## File Locations
|
||||||
|
|
||||||
|
- **Uploaded Images**: `storage/app/public/images/profiles/`
|
||||||
|
- **Public Access**: `public/storage/images/profiles/` (via symlink)
|
||||||
|
- **Database Field**: `users.profile_picture`
|
||||||
|
|
||||||
|
## Portrait Rectangle Configuration
|
||||||
|
|
||||||
|
The cropper is configured for portrait orientation:
|
||||||
|
- **Aspect Ratio**: 3:4 (300px width × 400px height)
|
||||||
|
- **Shape**: `square` (rectangle viewport, not circular)
|
||||||
|
- **Viewport**: Displays as a rectangle overlay on the image
|
||||||
|
|
||||||
|
## Testing Checklist
|
||||||
|
|
||||||
|
- [x] Package installed successfully
|
||||||
|
- [x] Storage linked
|
||||||
|
- [x] Modal stack added to layout
|
||||||
|
- [x] Cropper component integrated
|
||||||
|
- [x] Custom upload route configured
|
||||||
|
- [x] Controller method updated
|
||||||
|
- [ ] Test image upload
|
||||||
|
- [ ] Verify cropped image saves correctly
|
||||||
|
- [ ] Confirm image displays after upload
|
||||||
|
- [ ] Test portrait rectangle cropping
|
||||||
|
- [ ] Verify old images are deleted
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
1. Navigate to http://localhost:8000/profile/edit
|
||||||
|
2. Click "Change Photo" button
|
||||||
|
3. Select an image
|
||||||
|
4. Crop in portrait rectangle shape
|
||||||
|
5. Save and verify the image appears correctly
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
If the cropper doesn't appear:
|
||||||
|
- Check browser console for JavaScript errors
|
||||||
|
- Verify `@stack('modals')` is in layout
|
||||||
|
- Ensure jQuery is loaded before the cropper script
|
||||||
|
|
||||||
|
If upload fails:
|
||||||
|
- Check `storage/app/public/images/profiles/` directory exists
|
||||||
|
- Verify storage is linked: `php artisan storage:link`
|
||||||
|
- Check file permissions on storage directory
|
||||||
|
- Review Laravel logs in `storage/logs/`
|
||||||
|
|
||||||
|
## Package Documentation
|
||||||
|
|
||||||
|
For more details, see:
|
||||||
|
- Package README: `vendor/takeone/cropper/README.md`
|
||||||
|
- Package Example: `vendor/takeone/cropper/EXAMPLE.md`
|
||||||
251
TESTING_GUIDE.md
Normal file
251
TESTING_GUIDE.md
Normal file
@ -0,0 +1,251 @@
|
|||||||
|
# Laravel Image Cropper - Testing Guide
|
||||||
|
|
||||||
|
## Installation Complete ✅
|
||||||
|
|
||||||
|
The Laravel Image Cropper package has been successfully installed and integrated into your project.
|
||||||
|
|
||||||
|
## What Was Installed
|
||||||
|
|
||||||
|
1. **Package**: `takeone/cropper:@dev`
|
||||||
|
2. **Cropper Library**: Cropme.js (lightweight image cropping)
|
||||||
|
3. **Portrait Rectangle Configuration**: 300px × 400px (3:4 ratio)
|
||||||
|
4. **Storage Directory**: `storage/app/public/images/profiles/`
|
||||||
|
|
||||||
|
## Files Modified
|
||||||
|
|
||||||
|
### 1. composer.json
|
||||||
|
- Added VCS repository
|
||||||
|
- Added package dependency
|
||||||
|
|
||||||
|
### 2. resources/views/layouts/app.blade.php
|
||||||
|
- Added `@stack('modals')` for modal injection
|
||||||
|
|
||||||
|
### 3. resources/views/family/profile-edit.blade.php
|
||||||
|
- Replaced old image upload modal with `<x-takeone-cropper>` component
|
||||||
|
- Configured for portrait rectangle cropping
|
||||||
|
- Added image display with fallback to default avatar
|
||||||
|
|
||||||
|
### 4. app/Http/Controllers/FamilyController.php
|
||||||
|
- Updated `uploadProfilePicture()` method to handle base64 images
|
||||||
|
- Saves cropped images to `storage/app/public/images/profiles/`
|
||||||
|
- Updates user's `profile_picture` field in database
|
||||||
|
|
||||||
|
### 5. resources/views/vendor/takeone/components/widget.blade.php
|
||||||
|
- Published and customized widget component
|
||||||
|
- Added custom upload URL support
|
||||||
|
- Added page reload after successful upload
|
||||||
|
- Improved error handling
|
||||||
|
|
||||||
|
## How to Test
|
||||||
|
|
||||||
|
### Step 1: Start the Development Server
|
||||||
|
```bash
|
||||||
|
php artisan serve
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2: Navigate to Profile Edit Page
|
||||||
|
Open your browser and go to:
|
||||||
|
```
|
||||||
|
http://localhost:8000/profile/edit
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 3: Test the Cropper
|
||||||
|
|
||||||
|
1. **Click "Change Photo" button**
|
||||||
|
- A modal should popup with a file selector
|
||||||
|
|
||||||
|
2. **Select an Image**
|
||||||
|
- Click "Choose File" and select any image from your computer
|
||||||
|
- The image should load in the cropping interface
|
||||||
|
|
||||||
|
3. **Crop the Image**
|
||||||
|
- You'll see a **portrait rectangle** overlay (300×400px)
|
||||||
|
- Use the **Zoom Level** slider to zoom in/out
|
||||||
|
- Use the **Rotation** slider to rotate the image
|
||||||
|
- Drag the image to position it within the rectangle
|
||||||
|
|
||||||
|
4. **Save the Image**
|
||||||
|
- Click "Crop & Save Image" button
|
||||||
|
- Button should show "Uploading..." while processing
|
||||||
|
- You should see "Saved successfully!" alert
|
||||||
|
- Modal should close automatically
|
||||||
|
- Page should reload
|
||||||
|
- Your new profile picture should appear in the profile picture box
|
||||||
|
|
||||||
|
### Step 4: Verify the Upload
|
||||||
|
|
||||||
|
Check that the image was saved:
|
||||||
|
```bash
|
||||||
|
dir storage\app\public\images\profiles\
|
||||||
|
```
|
||||||
|
|
||||||
|
You should see a file named `profile_{user_id}.png`
|
||||||
|
|
||||||
|
### Step 5: Verify Database Update
|
||||||
|
|
||||||
|
The `users` table should have the `profile_picture` field updated with:
|
||||||
|
```
|
||||||
|
images/profiles/profile_{user_id}.png
|
||||||
|
```
|
||||||
|
|
||||||
|
## Expected Behavior
|
||||||
|
|
||||||
|
### ✅ Success Indicators
|
||||||
|
- Modal opens when clicking "Change Photo"
|
||||||
|
- Image loads in cropper after selection
|
||||||
|
- Portrait rectangle viewport is visible (taller than wide)
|
||||||
|
- Zoom and rotation sliders work smoothly
|
||||||
|
- Image can be dragged/positioned
|
||||||
|
- "Crop & Save Image" button uploads successfully
|
||||||
|
- Success alert appears
|
||||||
|
- Page reloads automatically
|
||||||
|
- New profile picture displays in the profile box
|
||||||
|
|
||||||
|
### ❌ Potential Issues
|
||||||
|
|
||||||
|
**Modal doesn't appear:**
|
||||||
|
- Check browser console (F12) for JavaScript errors
|
||||||
|
- Verify `@stack('modals')` is in `resources/views/layouts/app.blade.php`
|
||||||
|
- Ensure jQuery and Bootstrap are loaded
|
||||||
|
|
||||||
|
**Upload fails:**
|
||||||
|
- Check `storage/app/public/images/profiles/` directory exists
|
||||||
|
- Verify storage link: `php artisan storage:link`
|
||||||
|
- Check file permissions on storage directory
|
||||||
|
- Review Laravel logs: `storage/logs/laravel.log`
|
||||||
|
|
||||||
|
**Image doesn't display after upload:**
|
||||||
|
- Verify storage is linked
|
||||||
|
- Check the `profile_picture` field in database
|
||||||
|
- Ensure the file exists in `public/storage/images/profiles/`
|
||||||
|
- Clear browser cache
|
||||||
|
|
||||||
|
**Cropper shows square instead of rectangle:**
|
||||||
|
- Verify the component has `width="300"` and `height="400"`
|
||||||
|
- Check that `shape="square"` (not "circle")
|
||||||
|
|
||||||
|
## Package Bug Note
|
||||||
|
|
||||||
|
⚠️ **Known Issue**: The package's service provider has a namespace bug in the route registration. This doesn't affect functionality because we're using our own custom route (`profile.upload-picture`) instead of the package's default route.
|
||||||
|
|
||||||
|
The error you might see when running `php artisan route:list`:
|
||||||
|
```
|
||||||
|
Class "takeone\cropper\Http\Controllers\ImageController" does not exist
|
||||||
|
```
|
||||||
|
|
||||||
|
This can be safely ignored as we're not using that route.
|
||||||
|
|
||||||
|
## Customization Options
|
||||||
|
|
||||||
|
### Change Aspect Ratio
|
||||||
|
|
||||||
|
Edit `resources/views/family/profile-edit.blade.php`:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!-- Current: 3:4 portrait -->
|
||||||
|
<x-takeone-cropper
|
||||||
|
width="300"
|
||||||
|
height="400"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Square: 1:1 -->
|
||||||
|
<x-takeone-cropper
|
||||||
|
width="300"
|
||||||
|
height="300"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Wide rectangle: 16:9 -->
|
||||||
|
<x-takeone-cropper
|
||||||
|
width="400"
|
||||||
|
height="225"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Tall portrait: 2:3 -->
|
||||||
|
<x-takeone-cropper
|
||||||
|
width="300"
|
||||||
|
height="450"
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Change to Circular Crop
|
||||||
|
|
||||||
|
```html
|
||||||
|
<x-takeone-cropper
|
||||||
|
width="300"
|
||||||
|
height="300"
|
||||||
|
shape="circle"
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Change Storage Folder
|
||||||
|
|
||||||
|
```html
|
||||||
|
<x-takeone-cropper
|
||||||
|
folder="avatars"
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Custom Filename Pattern
|
||||||
|
|
||||||
|
```html
|
||||||
|
<x-takeone-cropper
|
||||||
|
filename="user_{{ auth()->id() }}_{{ time() }}"
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
takeone/
|
||||||
|
├── storage/
|
||||||
|
│ └── app/
|
||||||
|
│ └── public/
|
||||||
|
│ └── images/
|
||||||
|
│ └── profiles/ ← Uploaded images here
|
||||||
|
│ └── profile_1.png
|
||||||
|
├── public/
|
||||||
|
│ └── storage/ ← Symlink to storage/app/public
|
||||||
|
│ └── images/
|
||||||
|
│ └── profiles/
|
||||||
|
│ └── profile_1.png ← Publicly accessible
|
||||||
|
├── resources/
|
||||||
|
│ └── views/
|
||||||
|
│ ├── family/
|
||||||
|
│ │ └── profile-edit.blade.php ← Profile edit page
|
||||||
|
│ ├── layouts/
|
||||||
|
│ │ └── app.blade.php ← Main layout with @stack('modals')
|
||||||
|
│ └── vendor/
|
||||||
|
│ └── takeone/
|
||||||
|
│ └── components/
|
||||||
|
│ └── widget.blade.php ← Customized cropper widget
|
||||||
|
└── app/
|
||||||
|
└── Http/
|
||||||
|
└── Controllers/
|
||||||
|
└── FamilyController.php ← Upload handler
|
||||||
|
```
|
||||||
|
|
||||||
|
## Support
|
||||||
|
|
||||||
|
If you encounter any issues:
|
||||||
|
|
||||||
|
1. Check the browser console (F12) for JavaScript errors
|
||||||
|
2. Review Laravel logs: `storage/logs/laravel.log`
|
||||||
|
3. Verify all files were modified correctly
|
||||||
|
4. Ensure storage permissions are correct
|
||||||
|
5. Clear all caches: `php artisan config:clear && php artisan route:clear && php artisan view:clear`
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
1. Test the cropper functionality
|
||||||
|
2. Upload a test image
|
||||||
|
3. Verify it displays correctly
|
||||||
|
4. Customize the aspect ratio if needed
|
||||||
|
5. Add additional validation if required
|
||||||
|
6. Consider adding image optimization/compression
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Installation Date**: January 26, 2026
|
||||||
|
**Package Version**: dev-main
|
||||||
|
**Laravel Version**: 12.0
|
||||||
|
**Status**: ✅ Ready for Testing
|
||||||
28
TODO_affiliations.md
Normal file
28
TODO_affiliations.md
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
# TODO: Implement Affiliations Tab
|
||||||
|
|
||||||
|
## Backend Implementation
|
||||||
|
- [x] Create ClubAffiliation model and migration
|
||||||
|
- [x] Create SkillAcquisition model and migration
|
||||||
|
- [x] Create AffiliationMedia model and migration
|
||||||
|
- [x] Update User model with clubAffiliations relationship
|
||||||
|
- [x] Update FamilyController::profile() to fetch affiliations data and summary stats
|
||||||
|
- [x] Add club_affiliation_id to TournamentEvent model and migration
|
||||||
|
|
||||||
|
## Frontend Implementation
|
||||||
|
- [x] Update show.blade.php affiliations tab content
|
||||||
|
- [x] Implement horizontal timeline with clickable nodes
|
||||||
|
- [x] Add dynamic skills wheel using Chart.js Polar Area
|
||||||
|
- [x] Create affiliation details panel
|
||||||
|
- [x] Add summary stats above timeline
|
||||||
|
- [x] Ensure responsive design (desktop side-by-side, mobile stacked)
|
||||||
|
- [x] Add Alpine.js for timeline interactions
|
||||||
|
- [x] Implement keyboard navigation and accessibility
|
||||||
|
- [x] Add club affiliation column to tournament table
|
||||||
|
|
||||||
|
## Testing & Deployment
|
||||||
|
- [x] Run migrations to create database tables
|
||||||
|
- [x] Add sample data for demonstration
|
||||||
|
- [x] Add calculated duration display to timeline cards (as badges)
|
||||||
|
- [x] Test timeline navigation and skills wheel updates
|
||||||
|
- [x] Verify responsive design on different screen sizes
|
||||||
|
- [x] Add "Add Tournament Participation" button with modal functionality
|
||||||
@ -9,6 +9,7 @@ use App\Models\Invoice;
|
|||||||
use App\Models\TournamentEvent;
|
use App\Models\TournamentEvent;
|
||||||
use App\Models\Goal;
|
use App\Models\Goal;
|
||||||
use App\Models\Attendance;
|
use App\Models\Attendance;
|
||||||
|
use App\Models\ClubAffiliation;
|
||||||
use App\Services\FamilyService;
|
use App\Services\FamilyService;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
@ -60,7 +61,7 @@ class FamilyController extends Controller
|
|||||||
|
|
||||||
// Fetch tournament data
|
// Fetch tournament data
|
||||||
$tournamentEvents = $user->tournamentEvents()
|
$tournamentEvents = $user->tournamentEvents()
|
||||||
->with(['performanceResults', 'notesMedia'])
|
->with(['performanceResults', 'notesMedia', 'clubAffiliation'])
|
||||||
->orderBy('date', 'desc')
|
->orderBy('date', 'desc')
|
||||||
->get();
|
->get();
|
||||||
|
|
||||||
@ -88,6 +89,24 @@ class FamilyController extends Controller
|
|||||||
$totalSessions = $attendanceRecords->count();
|
$totalSessions = $attendanceRecords->count();
|
||||||
$attendanceRate = $totalSessions > 0 ? round(($sessionsCompleted / $totalSessions) * 100, 1) : 0;
|
$attendanceRate = $totalSessions > 0 ? round(($sessionsCompleted / $totalSessions) * 100, 1) : 0;
|
||||||
|
|
||||||
|
// Fetch affiliations data
|
||||||
|
$clubAffiliations = $user->clubAffiliations()
|
||||||
|
->with(['skillAcquisitions', 'affiliationMedia'])
|
||||||
|
->orderBy('start_date', 'desc')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
// Add icon_class to media items for JavaScript
|
||||||
|
$clubAffiliations->each(function($affiliation) {
|
||||||
|
$affiliation->affiliationMedia->each(function($media) {
|
||||||
|
$media->icon_class = $media->icon_class;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Calculate summary stats
|
||||||
|
$totalAffiliations = $clubAffiliations->count();
|
||||||
|
$distinctSkills = $clubAffiliations->flatMap->skillAcquisitions->pluck('skill_name')->unique()->count();
|
||||||
|
$totalMembershipDuration = $clubAffiliations->sum('duration_in_months');
|
||||||
|
|
||||||
// Pass user directly and a flag to indicate it's the current user's profile
|
// Pass user directly and a flag to indicate it's the current user's profile
|
||||||
return view('family.show', [
|
return view('family.show', [
|
||||||
'relationship' => (object)[
|
'relationship' => (object)[
|
||||||
@ -111,6 +130,10 @@ class FamilyController extends Controller
|
|||||||
'sessionsCompleted' => $sessionsCompleted,
|
'sessionsCompleted' => $sessionsCompleted,
|
||||||
'noShows' => $noShows,
|
'noShows' => $noShows,
|
||||||
'attendanceRate' => $attendanceRate,
|
'attendanceRate' => $attendanceRate,
|
||||||
|
'clubAffiliations' => $clubAffiliations,
|
||||||
|
'totalAffiliations' => $totalAffiliations,
|
||||||
|
'distinctSkills' => $distinctSkills,
|
||||||
|
'totalMembershipDuration' => $totalMembershipDuration,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -135,39 +158,44 @@ class FamilyController extends Controller
|
|||||||
public function uploadProfilePicture(Request $request)
|
public function uploadProfilePicture(Request $request)
|
||||||
{
|
{
|
||||||
$request->validate([
|
$request->validate([
|
||||||
'image' => 'required|image|mimes:jpeg,png,jpg,gif|max:5120', // 5MB max
|
'image' => 'required',
|
||||||
|
'folder' => 'required|string',
|
||||||
|
'filename' => 'required|string',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
try {
|
||||||
$user = Auth::user();
|
$user = Auth::user();
|
||||||
|
|
||||||
if ($request->hasFile('image')) {
|
// Handle base64 image from cropper
|
||||||
$image = $request->file('image');
|
$imageData = $request->image;
|
||||||
|
$imageParts = explode(";base64,", $imageData);
|
||||||
|
$imageTypeAux = explode("image/", $imageParts[0]);
|
||||||
|
$extension = $imageTypeAux[1];
|
||||||
|
$imageBinary = base64_decode($imageParts[1]);
|
||||||
|
|
||||||
// Generate unique filename
|
$folder = trim($request->folder, '/');
|
||||||
$filename = 'profile_' . $user->id . '_' . time() . '.' . $image->getClientOriginalExtension();
|
$fileName = $request->filename . '.' . $extension;
|
||||||
|
$fullPath = $folder . '/' . $fileName;
|
||||||
// Store in public/images/profiles
|
|
||||||
$path = $image->storeAs('images/profiles', $filename, 'public');
|
|
||||||
|
|
||||||
// Delete old profile picture if exists
|
// Delete old profile picture if exists
|
||||||
if ($user->profile_picture && \Storage::disk('public')->exists($user->profile_picture)) {
|
if ($user->profile_picture && \Storage::disk('public')->exists($user->profile_picture)) {
|
||||||
\Storage::disk('public')->delete($user->profile_picture);
|
\Storage::disk('public')->delete($user->profile_picture);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update user
|
// Store in the public disk (storage/app/public)
|
||||||
$user->update(['profile_picture' => $path]);
|
\Storage::disk('public')->put($fullPath, $imageBinary);
|
||||||
|
|
||||||
|
// Update user's profile_picture field
|
||||||
|
$user->update(['profile_picture' => $fullPath]);
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'success' => true,
|
'success' => true,
|
||||||
'message' => 'Profile picture uploaded successfully.',
|
'path' => $fullPath,
|
||||||
'path' => $path,
|
'url' => asset('storage/' . $fullPath)
|
||||||
]);
|
]);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return response()->json(['success' => false, 'message' => $e->getMessage()], 500);
|
||||||
}
|
}
|
||||||
|
|
||||||
return response()->json([
|
|
||||||
'success' => false,
|
|
||||||
'message' => 'No image file provided.',
|
|
||||||
], 400);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -280,7 +308,7 @@ class FamilyController extends Controller
|
|||||||
|
|
||||||
// Fetch tournament data for the dependent
|
// Fetch tournament data for the dependent
|
||||||
$tournamentEvents = $relationship->dependent->tournamentEvents()
|
$tournamentEvents = $relationship->dependent->tournamentEvents()
|
||||||
->with(['performanceResults', 'notesMedia'])
|
->with(['performanceResults', 'notesMedia', 'clubAffiliation'])
|
||||||
->orderBy('date', 'desc')
|
->orderBy('date', 'desc')
|
||||||
->get();
|
->get();
|
||||||
|
|
||||||
@ -308,7 +336,46 @@ class FamilyController extends Controller
|
|||||||
$totalSessions = $attendanceRecords->count();
|
$totalSessions = $attendanceRecords->count();
|
||||||
$attendanceRate = $totalSessions > 0 ? round(($sessionsCompleted / $totalSessions) * 100, 1) : 0;
|
$attendanceRate = $totalSessions > 0 ? round(($sessionsCompleted / $totalSessions) * 100, 1) : 0;
|
||||||
|
|
||||||
return view('family.show', compact('relationship', 'latestHealthRecord', 'healthRecords', 'comparisonRecords', 'invoices', 'tournamentEvents', 'awardCounts', 'sports', 'goals', 'activeGoalsCount', 'completedGoalsCount', 'successRate', 'attendanceRecords', 'sessionsCompleted', 'noShows', 'attendanceRate'));
|
// Fetch affiliations data for the dependent
|
||||||
|
$clubAffiliations = $relationship->dependent->clubAffiliations()
|
||||||
|
->with(['skillAcquisitions', 'affiliationMedia'])
|
||||||
|
->orderBy('start_date', 'desc')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
// Add icon_class to media items for JavaScript
|
||||||
|
$clubAffiliations->each(function($affiliation) {
|
||||||
|
$affiliation->affiliationMedia->each(function($media) {
|
||||||
|
$media->icon_class = $media->icon_class;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Calculate summary stats
|
||||||
|
$totalAffiliations = $clubAffiliations->count();
|
||||||
|
$distinctSkills = $clubAffiliations->flatMap->skillAcquisitions->pluck('skill_name')->unique()->count();
|
||||||
|
$totalMembershipDuration = $clubAffiliations->sum('duration_in_months');
|
||||||
|
|
||||||
|
return view('family.show', [
|
||||||
|
'relationship' => $relationship,
|
||||||
|
'latestHealthRecord' => $latestHealthRecord,
|
||||||
|
'healthRecords' => $healthRecords,
|
||||||
|
'comparisonRecords' => $comparisonRecords,
|
||||||
|
'invoices' => $invoices,
|
||||||
|
'tournamentEvents' => $tournamentEvents,
|
||||||
|
'awardCounts' => $awardCounts,
|
||||||
|
'sports' => $sports,
|
||||||
|
'goals' => $goals,
|
||||||
|
'activeGoalsCount' => $activeGoalsCount,
|
||||||
|
'completedGoalsCount' => $completedGoalsCount,
|
||||||
|
'successRate' => $successRate,
|
||||||
|
'attendanceRecords' => $attendanceRecords,
|
||||||
|
'sessionsCompleted' => $sessionsCompleted,
|
||||||
|
'noShows' => $noShows,
|
||||||
|
'attendanceRate' => $attendanceRate,
|
||||||
|
'clubAffiliations' => $clubAffiliations,
|
||||||
|
'totalAffiliations' => $totalAffiliations,
|
||||||
|
'distinctSkills' => $distinctSkills,
|
||||||
|
'totalMembershipDuration' => $totalMembershipDuration,
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -634,6 +701,81 @@ class FamilyController extends Controller
|
|||||||
return response()->json(['success' => true, 'message' => 'Goal updated successfully']);
|
return response()->json(['success' => true, 'message' => 'Goal updated successfully']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store a new tournament participation record.
|
||||||
|
*
|
||||||
|
* @param \Illuminate\Http\Request $request
|
||||||
|
* @param int $id
|
||||||
|
* @return \Illuminate\Http\JsonResponse
|
||||||
|
*/
|
||||||
|
public function storeTournament(Request $request, $id)
|
||||||
|
{
|
||||||
|
$user = Auth::user();
|
||||||
|
|
||||||
|
// Check if user is authorized to add tournament for this dependent
|
||||||
|
if ($user->id !== (int)$id) {
|
||||||
|
$relationship = UserRelationship::where('guardian_user_id', $user->id)
|
||||||
|
->where('dependent_user_id', $id)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (!$relationship) {
|
||||||
|
return response()->json(['success' => false, 'message' => 'Unauthorized'], 403);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate the request
|
||||||
|
$validated = $request->validate([
|
||||||
|
'title' => 'required|string|max:255',
|
||||||
|
'type' => 'required|in:championship,tournament,competition,exhibition',
|
||||||
|
'sport' => 'required|string|max:100',
|
||||||
|
'date' => 'required|date',
|
||||||
|
'time' => 'nullable|date_format:H:i',
|
||||||
|
'location' => 'nullable|string|max:255',
|
||||||
|
'participants_count' => 'nullable|integer|min:1',
|
||||||
|
'club_affiliation_id' => 'nullable|exists:club_affiliations,id',
|
||||||
|
'performance_results' => 'nullable|array',
|
||||||
|
'performance_results.*.medal_type' => 'nullable|in:special,1st,2nd,3rd',
|
||||||
|
'performance_results.*.points' => 'nullable|numeric|min:0',
|
||||||
|
'performance_results.*.description' => 'nullable|string|max:500',
|
||||||
|
'notes_media' => 'nullable|array',
|
||||||
|
'notes_media.*.note_text' => 'nullable|string|max:1000',
|
||||||
|
'notes_media.*.media_link' => 'nullable|url',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Create the tournament event
|
||||||
|
$tournament = TournamentEvent::create([
|
||||||
|
'user_id' => $id,
|
||||||
|
'club_affiliation_id' => $validated['club_affiliation_id'] ?? null,
|
||||||
|
'title' => $validated['title'],
|
||||||
|
'type' => $validated['type'],
|
||||||
|
'sport' => $validated['sport'],
|
||||||
|
'date' => $validated['date'],
|
||||||
|
'time' => $validated['time'],
|
||||||
|
'location' => $validated['location'],
|
||||||
|
'participants_count' => $validated['participants_count'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Create performance results
|
||||||
|
if (isset($validated['performance_results'])) {
|
||||||
|
foreach ($validated['performance_results'] as $resultData) {
|
||||||
|
if (!empty($resultData['medal_type'])) {
|
||||||
|
$tournament->performanceResults()->create($resultData);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create notes and media
|
||||||
|
if (isset($validated['notes_media'])) {
|
||||||
|
foreach ($validated['notes_media'] as $noteData) {
|
||||||
|
if (!empty($noteData['note_text']) || !empty($noteData['media_link'])) {
|
||||||
|
$tournament->notesMedia()->create($noteData);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json(['success' => true, 'message' => 'Tournament record added successfully']);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Remove the specified family member from storage.
|
* Remove the specified family member from storage.
|
||||||
*
|
*
|
||||||
|
|||||||
51
app/Models/AffiliationMedia.php
Normal file
51
app/Models/AffiliationMedia.php
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class AffiliationMedia extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = [
|
||||||
|
'club_affiliation_id',
|
||||||
|
'media_type',
|
||||||
|
'media_url',
|
||||||
|
'title',
|
||||||
|
'description',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the club affiliation that owns the media.
|
||||||
|
*/
|
||||||
|
public function clubAffiliation(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(ClubAffiliation::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the full URL for the media.
|
||||||
|
*/
|
||||||
|
public function getFullUrlAttribute(): string
|
||||||
|
{
|
||||||
|
if (filter_var($this->media_url, FILTER_VALIDATE_URL)) {
|
||||||
|
return $this->media_url;
|
||||||
|
}
|
||||||
|
|
||||||
|
return asset('storage/' . $this->media_url);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get icon class for media type.
|
||||||
|
*/
|
||||||
|
public function getIconClassAttribute(): string
|
||||||
|
{
|
||||||
|
return match($this->media_type) {
|
||||||
|
'certificate' => 'bi-file-earmark-text',
|
||||||
|
'photo' => 'bi-image',
|
||||||
|
'video' => 'bi-play-circle',
|
||||||
|
'document' => 'bi-file-text',
|
||||||
|
default => 'bi-file',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
93
app/Models/ClubAffiliation.php
Normal file
93
app/Models/ClubAffiliation.php
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
|
class ClubAffiliation extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = [
|
||||||
|
'member_id',
|
||||||
|
'club_name',
|
||||||
|
'logo',
|
||||||
|
'start_date',
|
||||||
|
'end_date',
|
||||||
|
'location',
|
||||||
|
'coaches',
|
||||||
|
'description',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'start_date' => 'date',
|
||||||
|
'end_date' => 'date',
|
||||||
|
'coaches' => 'array',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the member that owns the affiliation.
|
||||||
|
*/
|
||||||
|
public function member(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class, 'member_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the skills acquired during this affiliation.
|
||||||
|
*/
|
||||||
|
public function skillAcquisitions(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(SkillAcquisition::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the media associated with this affiliation.
|
||||||
|
*/
|
||||||
|
public function affiliationMedia(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(AffiliationMedia::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the duration of the affiliation in months.
|
||||||
|
*/
|
||||||
|
public function getDurationInMonthsAttribute(): int
|
||||||
|
{
|
||||||
|
$endDate = $this->end_date ?? now();
|
||||||
|
return $this->start_date->diffInMonths($endDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get formatted date range.
|
||||||
|
*/
|
||||||
|
public function getDateRangeAttribute(): string
|
||||||
|
{
|
||||||
|
$start = $this->start_date->format('M Y');
|
||||||
|
$end = $this->end_date ? $this->end_date->format('M Y') : 'Present';
|
||||||
|
return $start . ' – ' . $end;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get detailed formatted duration (years, months, days).
|
||||||
|
*/
|
||||||
|
public function getFormattedDurationAttribute(): string
|
||||||
|
{
|
||||||
|
$endDate = $this->end_date ?? now();
|
||||||
|
$diff = $this->start_date->diff($endDate);
|
||||||
|
|
||||||
|
$parts = [];
|
||||||
|
|
||||||
|
if ($diff->y > 0) {
|
||||||
|
$parts[] = $diff->y . ' year' . ($diff->y > 1 ? 's' : '');
|
||||||
|
}
|
||||||
|
if ($diff->m > 0) {
|
||||||
|
$parts[] = $diff->m . ' month' . ($diff->m > 1 ? 's' : '');
|
||||||
|
}
|
||||||
|
if ($diff->d > 0) {
|
||||||
|
$parts[] = $diff->d . ' day' . ($diff->d > 1 ? 's' : '');
|
||||||
|
}
|
||||||
|
|
||||||
|
return implode(' ', $parts) ?: 'Same day';
|
||||||
|
}
|
||||||
|
}
|
||||||
64
app/Models/SkillAcquisition.php
Normal file
64
app/Models/SkillAcquisition.php
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class SkillAcquisition extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = [
|
||||||
|
'club_affiliation_id',
|
||||||
|
'skill_name',
|
||||||
|
'icon',
|
||||||
|
'duration_months',
|
||||||
|
'proficiency_level',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'duration_months' => 'integer',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the club affiliation that owns the skill acquisition.
|
||||||
|
*/
|
||||||
|
public function clubAffiliation(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(ClubAffiliation::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get formatted duration.
|
||||||
|
*/
|
||||||
|
public function getFormattedDurationAttribute(): string
|
||||||
|
{
|
||||||
|
$months = $this->duration_months;
|
||||||
|
if ($months < 12) {
|
||||||
|
return $months . ' month' . ($months > 1 ? 's' : '');
|
||||||
|
}
|
||||||
|
|
||||||
|
$years = floor($months / 12);
|
||||||
|
$remainingMonths = $months % 12;
|
||||||
|
|
||||||
|
$result = $years . ' year' . ($years > 1 ? 's' : '');
|
||||||
|
if ($remainingMonths > 0) {
|
||||||
|
$result .= ' ' . $remainingMonths . ' month' . ($remainingMonths > 1 ? 's' : '');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get proficiency level color for UI.
|
||||||
|
*/
|
||||||
|
public function getProficiencyColorAttribute(): string
|
||||||
|
{
|
||||||
|
return match($this->proficiency_level) {
|
||||||
|
'beginner' => 'text-blue-500',
|
||||||
|
'intermediate' => 'text-yellow-500',
|
||||||
|
'advanced' => 'text-orange-500',
|
||||||
|
'expert' => 'text-red-500',
|
||||||
|
default => 'text-gray-500',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -10,6 +10,7 @@ class TournamentEvent extends Model
|
|||||||
{
|
{
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'user_id',
|
'user_id',
|
||||||
|
'club_affiliation_id',
|
||||||
'title',
|
'title',
|
||||||
'type',
|
'type',
|
||||||
'sport',
|
'sport',
|
||||||
@ -29,6 +30,11 @@ class TournamentEvent extends Model
|
|||||||
return $this->belongsTo(User::class);
|
return $this->belongsTo(User::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function clubAffiliation(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(ClubAffiliation::class);
|
||||||
|
}
|
||||||
|
|
||||||
public function performanceResults(): HasMany
|
public function performanceResults(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(PerformanceResult::class);
|
return $this->hasMany(PerformanceResult::class);
|
||||||
|
|||||||
@ -250,6 +250,14 @@ class User extends Authenticatable
|
|||||||
return $this->hasMany(Attendance::class, 'member_id');
|
return $this->hasMany(Attendance::class, 'member_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the club affiliations for the user.
|
||||||
|
*/
|
||||||
|
public function clubAffiliations(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(ClubAffiliation::class, 'member_id');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send the email verification notification.
|
* Send the email verification notification.
|
||||||
* Override to prevent sending the default Laravel notification.
|
* Override to prevent sending the default Laravel notification.
|
||||||
|
|||||||
@ -5,11 +5,18 @@
|
|||||||
"description": "The skeleton application for the Laravel framework.",
|
"description": "The skeleton application for the Laravel framework.",
|
||||||
"keywords": ["laravel", "framework"],
|
"keywords": ["laravel", "framework"],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"repositories": [
|
||||||
|
{
|
||||||
|
"type": "vcs",
|
||||||
|
"url": "https://git.innovator.bh/ghassan/laravel-image-cropper"
|
||||||
|
}
|
||||||
|
],
|
||||||
"require": {
|
"require": {
|
||||||
"php": "^8.2",
|
"php": "^8.2",
|
||||||
"laravel/framework": "^12.0",
|
"laravel/framework": "^12.0",
|
||||||
"laravel/sanctum": "^4.0",
|
"laravel/sanctum": "^4.0",
|
||||||
"laravel/tinker": "^2.10.1"
|
"laravel/tinker": "^2.10.1",
|
||||||
|
"takeone/cropper": "@dev"
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
"fakerphp/faker": "^1.23",
|
"fakerphp/faker": "^1.23",
|
||||||
|
|||||||
46
composer.lock
generated
46
composer.lock
generated
@ -4,7 +4,7 @@
|
|||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "d3c16cb86c42230c6c023d9a5d9bcf42",
|
"content-hash": "d81f57cbd65389eb9d9702b5825564c1",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "brick/math",
|
"name": "brick/math",
|
||||||
@ -5854,6 +5854,44 @@
|
|||||||
],
|
],
|
||||||
"time": "2025-12-18T07:04:31+00:00"
|
"time": "2025-12-18T07:04:31+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "takeone/cropper",
|
||||||
|
"version": "dev-main",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://git.innovator.bh/ghassan/laravel-image-cropper",
|
||||||
|
"reference": "155876bd2165271116f7d8ea3cf3afddd9511ec9"
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"laravel/framework": "^11.0|^12.0",
|
||||||
|
"php": "^8.2"
|
||||||
|
},
|
||||||
|
"default-branch": true,
|
||||||
|
"type": "library",
|
||||||
|
"extra": {
|
||||||
|
"laravel": {
|
||||||
|
"providers": [
|
||||||
|
"Takeone\\Cropper\\CropperServiceProvider"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Takeone\\Cropper\\": "src/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Ghassan",
|
||||||
|
"email": "ghassan.yousif.83@gmail.com"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "A professional image cropping component for Laravel using Bootstrap and Cropme.",
|
||||||
|
"time": "2026-01-25T11:58:48+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "tijsverkoyen/css-to-inline-styles",
|
"name": "tijsverkoyen/css-to-inline-styles",
|
||||||
"version": "v2.4.0",
|
"version": "v2.4.0",
|
||||||
@ -8434,12 +8472,14 @@
|
|||||||
],
|
],
|
||||||
"aliases": [],
|
"aliases": [],
|
||||||
"minimum-stability": "stable",
|
"minimum-stability": "stable",
|
||||||
"stability-flags": {},
|
"stability-flags": {
|
||||||
|
"takeone/cropper": 20
|
||||||
|
},
|
||||||
"prefer-stable": true,
|
"prefer-stable": true,
|
||||||
"prefer-lowest": false,
|
"prefer-lowest": false,
|
||||||
"platform": {
|
"platform": {
|
||||||
"php": "^8.2"
|
"php": "^8.2"
|
||||||
},
|
},
|
||||||
"platform-dev": {},
|
"platform-dev": [],
|
||||||
"plugin-api-version": "2.6.0"
|
"plugin-api-version": "2.6.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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_affiliations', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('member_id')->constrained('users')->onDelete('cascade');
|
||||||
|
$table->string('club_name');
|
||||||
|
$table->string('logo')->nullable();
|
||||||
|
$table->date('start_date');
|
||||||
|
$table->date('end_date')->nullable();
|
||||||
|
$table->string('location')->nullable();
|
||||||
|
$table->json('coaches')->nullable(); // Array of coach names
|
||||||
|
$table->text('description')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->index(['member_id', 'start_date']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('club_affiliations');
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -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('skill_acquisitions', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('club_affiliation_id')->constrained('club_affiliations')->onDelete('cascade');
|
||||||
|
$table->string('skill_name');
|
||||||
|
$table->string('icon')->nullable(); // FontAwesome or Heroicons class
|
||||||
|
$table->integer('duration_months');
|
||||||
|
$table->enum('proficiency_level', ['beginner', 'intermediate', 'advanced', 'expert'])->default('beginner');
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->index(['club_affiliation_id', 'skill_name']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('skill_acquisitions');
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -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('affiliation_media', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('club_affiliation_id')->constrained('club_affiliations')->onDelete('cascade');
|
||||||
|
$table->enum('media_type', ['certificate', 'photo', 'video', 'document']);
|
||||||
|
$table->string('media_url');
|
||||||
|
$table->string('title')->nullable();
|
||||||
|
$table->text('description')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->index(['club_affiliation_id', 'media_type']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('affiliation_media');
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -0,0 +1,28 @@
|
|||||||
|
<?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('tournament_events', function (Blueprint $table) {
|
||||||
|
$table->foreignId('club_affiliation_id')->nullable()->constrained('club_affiliations')->onDelete('set null');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('tournament_events', function (Blueprint $table) {
|
||||||
|
//
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
160
database/seeders/AffiliationSeeder.php
Normal file
160
database/seeders/AffiliationSeeder.php
Normal file
@ -0,0 +1,160 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Seeders;
|
||||||
|
|
||||||
|
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
|
||||||
|
use Illuminate\Database\Seeder;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\ClubAffiliation;
|
||||||
|
use App\Models\SkillAcquisition;
|
||||||
|
use App\Models\AffiliationMedia;
|
||||||
|
use App\Models\TournamentEvent;
|
||||||
|
use Carbon\Carbon;
|
||||||
|
|
||||||
|
class AffiliationSeeder extends Seeder
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the database seeds.
|
||||||
|
*/
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
// Get the first user or create a sample one
|
||||||
|
$user = User::first();
|
||||||
|
if (!$user) {
|
||||||
|
return; // No users to seed affiliations for
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create additional clubs if they don't exist
|
||||||
|
$clubs = [
|
||||||
|
[
|
||||||
|
'club_name' => 'Elite Boxing Club',
|
||||||
|
'slug' => 'elite-boxing-club',
|
||||||
|
'gps_lat' => 25.276987,
|
||||||
|
'gps_long' => 55.296249,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'club_name' => 'Zen Martial Arts Academy',
|
||||||
|
'slug' => 'zen-martial-arts-academy',
|
||||||
|
'gps_lat' => 25.286987,
|
||||||
|
'gps_long' => 55.306249,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'club_name' => 'Power Fitness Gym',
|
||||||
|
'slug' => 'power-fitness-gym',
|
||||||
|
'gps_lat' => 25.266987,
|
||||||
|
'gps_long' => 55.286249,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
$createdClubs = [];
|
||||||
|
foreach ($clubs as $clubData) {
|
||||||
|
$club = Tenant::firstOrCreate(
|
||||||
|
['slug' => $clubData['slug']],
|
||||||
|
array_merge($clubData, ['owner_user_id' => $user->id])
|
||||||
|
);
|
||||||
|
$createdClubs[] = $club;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sample club affiliations
|
||||||
|
$affiliations = [
|
||||||
|
[
|
||||||
|
'club_name' => 'Elite Boxing Club',
|
||||||
|
'start_date' => Carbon::parse('2020-01-15'),
|
||||||
|
'end_date' => Carbon::parse('2021-12-31'),
|
||||||
|
'location' => 'Downtown Fitness Center',
|
||||||
|
'coaches' => ['Coach Mike Johnson', 'Coach Sarah Davis'],
|
||||||
|
'description' => 'Premier boxing training facility focusing on technique and conditioning.',
|
||||||
|
'logo' => 'https://via.placeholder.com/100x100/FF6B6B/FFFFFF?text=EBC',
|
||||||
|
'skills' => [
|
||||||
|
['skill_name' => 'Boxing', 'icon' => 'fas fa-fist-raised', 'duration_months' => 18, 'proficiency_level' => 'advanced'],
|
||||||
|
['skill_name' => 'Fitness Training', 'icon' => 'fas fa-dumbbell', 'duration_months' => 12, 'proficiency_level' => 'intermediate'],
|
||||||
|
['skill_name' => 'Footwork', 'icon' => 'fas fa-shoe-prints', 'duration_months' => 15, 'proficiency_level' => 'advanced'],
|
||||||
|
],
|
||||||
|
'media' => [
|
||||||
|
['media_type' => 'certificate', 'title' => 'Boxing Certification', 'media_url' => 'https://via.placeholder.com/300x200/4ECDC4/FFFFFF?text=Boxing+Cert', 'description' => 'Advanced Boxing Certificate'],
|
||||||
|
['media_type' => 'photo', 'title' => 'Championship Photo', 'media_url' => 'https://via.placeholder.com/300x200/45B7D1/FFFFFF?text=Championship', 'description' => 'Regional Championship 2021'],
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'club_name' => 'Zen Martial Arts Academy',
|
||||||
|
'start_date' => Carbon::parse('2018-03-01'),
|
||||||
|
'end_date' => Carbon::parse('2020-01-10'),
|
||||||
|
'location' => 'East Side Dojo',
|
||||||
|
'coaches' => ['Master Chen Wei', 'Instructor Lisa Park'],
|
||||||
|
'description' => 'Traditional martial arts academy specializing in multiple disciplines.',
|
||||||
|
'logo' => 'https://via.placeholder.com/100x100/96CEB4/FFFFFF?text=ZMA',
|
||||||
|
'skills' => [
|
||||||
|
['skill_name' => 'Taekwondo', 'icon' => 'fas fa-hand-rock', 'duration_months' => 20, 'proficiency_level' => 'expert'],
|
||||||
|
['skill_name' => 'Karate', 'icon' => 'fas fa-fist-raised', 'duration_months' => 16, 'proficiency_level' => 'advanced'],
|
||||||
|
['skill_name' => 'Self Defense', 'icon' => 'fas fa-shield-alt', 'duration_months' => 18, 'proficiency_level' => 'advanced'],
|
||||||
|
],
|
||||||
|
'media' => [
|
||||||
|
['media_type' => 'certificate', 'title' => 'Black Belt Certificate', 'media_url' => 'https://via.placeholder.com/300x200/FECA57/FFFFFF?text=Black+Belt', 'description' => 'Taekwondo Black Belt Certification'],
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'club_name' => 'Power Fitness Gym',
|
||||||
|
'start_date' => Carbon::parse('2022-06-01'),
|
||||||
|
'end_date' => null, // Current affiliation
|
||||||
|
'location' => 'West End Sports Complex',
|
||||||
|
'coaches' => ['Trainer Alex Rodriguez', 'Trainer Emma Wilson'],
|
||||||
|
'description' => 'Modern fitness center with comprehensive training programs.',
|
||||||
|
'logo' => 'https://via.placeholder.com/100x100/FFEAA7/000000?text=PFG',
|
||||||
|
'skills' => [
|
||||||
|
['skill_name' => 'Weight Training', 'icon' => 'fas fa-dumbbell', 'duration_months' => 8, 'proficiency_level' => 'intermediate'],
|
||||||
|
['skill_name' => 'Cardio Fitness', 'icon' => 'fas fa-heartbeat', 'duration_months' => 6, 'proficiency_level' => 'beginner'],
|
||||||
|
['skill_name' => 'Nutrition', 'icon' => 'fas fa-apple-alt', 'duration_months' => 4, 'proficiency_level' => 'beginner'],
|
||||||
|
],
|
||||||
|
'media' => [
|
||||||
|
['media_type' => 'photo', 'title' => 'Gym Progress Photo', 'media_url' => 'https://via.placeholder.com/300x200/DD5E89/FFFFFF?text=Progress', 'description' => 'Before and after transformation'],
|
||||||
|
]
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
$createdAffiliations = [];
|
||||||
|
foreach ($affiliations as $affiliationData) {
|
||||||
|
$skills = $affiliationData['skills'];
|
||||||
|
$media = $affiliationData['media'];
|
||||||
|
unset($affiliationData['skills'], $affiliationData['media']);
|
||||||
|
|
||||||
|
$affiliationData['member_id'] = $user->id;
|
||||||
|
|
||||||
|
$affiliation = ClubAffiliation::create($affiliationData);
|
||||||
|
$createdAffiliations[] = $affiliation;
|
||||||
|
|
||||||
|
// Create skills
|
||||||
|
foreach ($skills as $skillData) {
|
||||||
|
$affiliation->skillAcquisitions()->create($skillData);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create media
|
||||||
|
foreach ($media as $mediaData) {
|
||||||
|
$affiliation->affiliationMedia()->create($mediaData);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Link some tournament events to affiliations
|
||||||
|
$tournamentEvents = TournamentEvent::where('user_id', $user->id)->get();
|
||||||
|
|
||||||
|
if ($tournamentEvents->count() > 0 && count($createdAffiliations) > 0) {
|
||||||
|
// Link first tournament to Elite Boxing Club affiliation
|
||||||
|
$boxingAffiliation = collect($createdAffiliations)->firstWhere('club_name', 'Elite Boxing Club');
|
||||||
|
if ($boxingAffiliation) {
|
||||||
|
$boxingEvents = $tournamentEvents->where('sport', 'Boxing');
|
||||||
|
foreach ($boxingEvents as $event) {
|
||||||
|
$event->update(['club_affiliation_id' => $boxingAffiliation->id]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Link martial arts events to Zen Martial Arts Academy
|
||||||
|
$martialArtsAffiliation = collect($createdAffiliations)->firstWhere('club_name', 'Zen Martial Arts Academy');
|
||||||
|
if ($martialArtsAffiliation) {
|
||||||
|
$martialArtsEvents = $tournamentEvents->whereIn('sport', ['Taekwondo', 'Karate', 'Martial Arts']);
|
||||||
|
foreach ($martialArtsEvents as $event) {
|
||||||
|
$event->update(['club_affiliation_id' => $martialArtsAffiliation->id]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
2
public/storage/.gitignore
vendored
Normal file
2
public/storage/.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
*
|
||||||
|
!.gitignore
|
||||||
@ -285,15 +285,6 @@
|
|||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<!-- Flag Icons CSS -->
|
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/flag-icons@6.6.6/css/flag-icons.min.css">
|
|
||||||
|
|
||||||
<!-- Select2 CSS (for nationality dropdown) -->
|
|
||||||
<link href="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/css/select2.min.css" rel="stylesheet" />
|
|
||||||
|
|
||||||
<!-- Flatpickr CSS -->
|
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/flatpickr/dist/flatpickr.min.css">
|
|
||||||
|
|
||||||
<div class="login-page">
|
<div class="login-page">
|
||||||
<div class="login-box">
|
<div class="login-box">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
@ -311,11 +302,11 @@
|
|||||||
|
|
||||||
<!-- Full Name -->
|
<!-- Full Name -->
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
|
<label for="full_name" class="form-label">Full Name</label>
|
||||||
<input id="full_name" type="text"
|
<input id="full_name" type="text"
|
||||||
class="form-control @error('full_name') is-invalid @enderror"
|
class="form-control @error('full_name') is-invalid @enderror"
|
||||||
name="full_name"
|
name="full_name"
|
||||||
value="{{ old('full_name') }}"
|
value="{{ old('full_name') }}"
|
||||||
placeholder="Full Name"
|
|
||||||
required autocomplete="name">
|
required autocomplete="name">
|
||||||
@error('full_name')
|
@error('full_name')
|
||||||
<span class="invalid-feedback" role="alert">
|
<span class="invalid-feedback" role="alert">
|
||||||
@ -326,11 +317,11 @@
|
|||||||
|
|
||||||
<!-- Email Address -->
|
<!-- Email Address -->
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
|
<label for="email" class="form-label">Email Address</label>
|
||||||
<input id="email" type="email"
|
<input id="email" type="email"
|
||||||
class="form-control @error('email') is-invalid @enderror"
|
class="form-control @error('email') is-invalid @enderror"
|
||||||
name="email"
|
name="email"
|
||||||
value="{{ old('email') }}"
|
value="{{ old('email') }}"
|
||||||
placeholder="Email Address"
|
|
||||||
required autocomplete="email">
|
required autocomplete="email">
|
||||||
@error('email')
|
@error('email')
|
||||||
<span class="invalid-feedback" role="alert">
|
<span class="invalid-feedback" role="alert">
|
||||||
@ -341,10 +332,10 @@
|
|||||||
|
|
||||||
<!-- Password -->
|
<!-- Password -->
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
|
<label for="password" class="form-label">Password</label>
|
||||||
<input id="password" type="password"
|
<input id="password" type="password"
|
||||||
class="form-control @error('password') is-invalid @enderror"
|
class="form-control @error('password') is-invalid @enderror"
|
||||||
name="password"
|
name="password"
|
||||||
placeholder="Password"
|
|
||||||
required autocomplete="new-password">
|
required autocomplete="new-password">
|
||||||
@error('password')
|
@error('password')
|
||||||
<span class="invalid-feedback" role="alert">
|
<span class="invalid-feedback" role="alert">
|
||||||
@ -355,15 +346,16 @@
|
|||||||
|
|
||||||
<!-- Confirm Password -->
|
<!-- Confirm Password -->
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
|
<label for="password-confirm" class="form-label">Confirm Password</label>
|
||||||
<input id="password-confirm" type="password"
|
<input id="password-confirm" type="password"
|
||||||
class="form-control"
|
class="form-control"
|
||||||
name="password_confirmation"
|
name="password_confirmation"
|
||||||
placeholder="Confirm Password"
|
|
||||||
required autocomplete="new-password">
|
required autocomplete="new-password">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Mobile Number with Country Code -->
|
<!-- Mobile Number with Country Code -->
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
|
<label for="mobile_number" class="form-label">Mobile Number</label>
|
||||||
<x-country-code-dropdown
|
<x-country-code-dropdown
|
||||||
name="country_code"
|
name="country_code"
|
||||||
id="country_code"
|
id="country_code"
|
||||||
@ -374,7 +366,6 @@
|
|||||||
class="form-control @error('mobile_number') is-invalid @enderror"
|
class="form-control @error('mobile_number') is-invalid @enderror"
|
||||||
name="mobile_number"
|
name="mobile_number"
|
||||||
value="{{ old('mobile_number') }}"
|
value="{{ old('mobile_number') }}"
|
||||||
placeholder="Mobile Number"
|
|
||||||
required autocomplete="tel">
|
required autocomplete="tel">
|
||||||
</x-country-code-dropdown>
|
</x-country-code-dropdown>
|
||||||
@error('mobile_number')
|
@error('mobile_number')
|
||||||
@ -386,11 +377,12 @@
|
|||||||
|
|
||||||
<!-- Gender -->
|
<!-- Gender -->
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
|
<label for="gender" class="form-label">Gender</label>
|
||||||
<select id="gender" class="form-select @error('gender') is-invalid @enderror"
|
<select id="gender" class="form-select @error('gender') is-invalid @enderror"
|
||||||
name="gender" required>
|
name="gender" required>
|
||||||
<option value="">Select Gender</option>
|
<option value="">Select Gender</option>
|
||||||
<option value="m" {{ old('gender') == 'm' ? 'selected' : '' }}>Male</option>
|
<option value="m" {{ old('gender') == 'm' ? 'selected' : '' }}>♂️ Male</option>
|
||||||
<option value="f" {{ old('gender') == 'f' ? 'selected' : '' }}>Female</option>
|
<option value="f" {{ old('gender') == 'f' ? 'selected' : '' }}>♀️ Female</option>
|
||||||
</select>
|
</select>
|
||||||
@error('gender')
|
@error('gender')
|
||||||
<span class="invalid-feedback" role="alert">
|
<span class="invalid-feedback" role="alert">
|
||||||
@ -401,15 +393,54 @@
|
|||||||
|
|
||||||
<!-- Birthdate -->
|
<!-- Birthdate -->
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<input id="birthdate" type="text"
|
<label for="birthdate" class="form-label">Birthdate</label>
|
||||||
class="flatpickr-input @error('birthdate') is-invalid @enderror"
|
<div class="row g-2">
|
||||||
name="birthdate"
|
<div class="col-4">
|
||||||
value="{{ old('birthdate') }}"
|
<select id="birth_day" class="form-select @error('birthdate') is-invalid @enderror" required>
|
||||||
placeholder="Birthdate"
|
<option value="">Day</option>
|
||||||
required
|
@for($day = 1; $day <= 31; $day++)
|
||||||
readonly>
|
<option value="{{ str_pad($day, 2, '0', STR_PAD_LEFT) }}" {{ old('birth_day') == str_pad($day, 2, '0', STR_PAD_LEFT) ? 'selected' : '' }}>
|
||||||
|
{{ $day }}
|
||||||
|
</option>
|
||||||
|
@endfor
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-4">
|
||||||
|
<select id="birth_month" class="form-select @error('birthdate') is-invalid @enderror" required>
|
||||||
|
<option value="">Month</option>
|
||||||
|
<option value="01" {{ old('birth_month') == '01' ? 'selected' : '' }}>January</option>
|
||||||
|
<option value="02" {{ old('birth_month') == '02' ? 'selected' : '' }}>February</option>
|
||||||
|
<option value="03" {{ old('birth_month') == '03' ? 'selected' : '' }}>March</option>
|
||||||
|
<option value="04" {{ old('birth_month') == '04' ? 'selected' : '' }}>April</option>
|
||||||
|
<option value="05" {{ old('birth_month') == '05' ? 'selected' : '' }}>May</option>
|
||||||
|
<option value="06" {{ old('birth_month') == '06' ? 'selected' : '' }}>June</option>
|
||||||
|
<option value="07" {{ old('birth_month') == '07' ? 'selected' : '' }}>July</option>
|
||||||
|
<option value="08" {{ old('birth_month') == '08' ? 'selected' : '' }}>August</option>
|
||||||
|
<option value="09" {{ old('birth_month') == '09' ? 'selected' : '' }}>September</option>
|
||||||
|
<option value="10" {{ old('birth_month') == '10' ? 'selected' : '' }}>October</option>
|
||||||
|
<option value="11" {{ old('birth_month') == '11' ? 'selected' : '' }}>November</option>
|
||||||
|
<option value="12" {{ old('birth_month') == '12' ? 'selected' : '' }}>December</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-4">
|
||||||
|
<select id="birth_year" class="form-select @error('birthdate') is-invalid @enderror" required>
|
||||||
|
<option value="">Year</option>
|
||||||
|
@php
|
||||||
|
$currentYear = date('Y');
|
||||||
|
$startYear = $currentYear - 10; // Start from 10 years ago
|
||||||
|
$endYear = 1900;
|
||||||
|
@endphp
|
||||||
|
@for($year = $startYear; $year >= $endYear; $year--)
|
||||||
|
<option value="{{ $year }}" {{ old('birth_year') == $year ? 'selected' : '' }}>
|
||||||
|
{{ $year }}
|
||||||
|
</option>
|
||||||
|
@endfor
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<input type="hidden" id="birthdate" name="birthdate" value="{{ old('birthdate') }}">
|
||||||
@error('birthdate')
|
@error('birthdate')
|
||||||
<span class="invalid-feedback" role="alert">
|
<span class="invalid-feedback d-block" role="alert">
|
||||||
<strong>{{ $message }}</strong>
|
<strong>{{ $message }}</strong>
|
||||||
</span>
|
</span>
|
||||||
@enderror
|
@enderror
|
||||||
@ -417,7 +448,7 @@
|
|||||||
|
|
||||||
<!-- Nationality -->
|
<!-- Nationality -->
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<x-country-dropdown
|
<x-nationality-dropdown
|
||||||
name="nationality"
|
name="nationality"
|
||||||
id="nationality"
|
id="nationality"
|
||||||
:value="old('nationality')"
|
:value="old('nationality')"
|
||||||
@ -434,38 +465,40 @@
|
|||||||
<!-- /.login-box -->
|
<!-- /.login-box -->
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- jQuery (required for Select2) -->
|
|
||||||
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
|
|
||||||
|
|
||||||
<!-- Select2 JS -->
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/js/select2.min.js"></script>
|
|
||||||
|
|
||||||
<!-- Flatpickr JS -->
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/flatpickr"></script>
|
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
// Initialize Flatpickr for birthdate
|
// Combine birth date dropdowns into hidden field
|
||||||
flatpickr('#birthdate', {
|
const birthDay = document.getElementById('birth_day');
|
||||||
dateFormat: 'Y-m-d',
|
const birthMonth = document.getElementById('birth_month');
|
||||||
maxDate: 'today',
|
const birthYear = document.getElementById('birth_year');
|
||||||
yearRange: [1900, new Date().getFullYear()],
|
const birthdateHidden = document.getElementById('birthdate');
|
||||||
disableMobile: true,
|
|
||||||
showMonths: 1,
|
|
||||||
clickOpens: true,
|
|
||||||
onReady: function(selectedDates, dateStr, instance) {
|
|
||||||
const calendar = instance.calendarContainer;
|
|
||||||
calendar.style.fontSize = '14px';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Form submission handler
|
function updateBirthdate() {
|
||||||
$('#registrationForm').on('submit', function(e) {
|
const day = birthDay.value;
|
||||||
console.log('Form submitting...');
|
const month = birthMonth.value;
|
||||||
console.log('Country code:', $('#country_code').val());
|
const year = birthYear.value;
|
||||||
console.log('Nationality:', $('#nationality').val());
|
|
||||||
console.log('Mobile number:', $('#mobile_number').val());
|
if (day && month && year) {
|
||||||
});
|
birthdateHidden.value = `${year}-${month}-${day}`;
|
||||||
|
} else {
|
||||||
|
birthdateHidden.value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update hidden field when any dropdown changes
|
||||||
|
birthDay.addEventListener('change', updateBirthdate);
|
||||||
|
birthMonth.addEventListener('change', updateBirthdate);
|
||||||
|
birthYear.addEventListener('change', updateBirthdate);
|
||||||
|
|
||||||
|
// Initialize from old value if exists
|
||||||
|
if (birthdateHidden.value) {
|
||||||
|
const parts = birthdateHidden.value.split('-');
|
||||||
|
if (parts.length === 3) {
|
||||||
|
birthYear.value = parts[0];
|
||||||
|
birthMonth.value = parts[1];
|
||||||
|
birthDay.value = parts[2];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Error handler
|
// Error handler
|
||||||
window.onerror = function(message, source, lineno, colno, error) {
|
window.onerror = function(message, source, lineno, colno, error) {
|
||||||
@ -475,8 +508,16 @@
|
|||||||
console.error('Error:', error);
|
console.error('Error:', error);
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Form submission handler
|
||||||
|
document.getElementById('registrationForm').addEventListener('submit', function(e) {
|
||||||
|
console.log('Form submitting...');
|
||||||
|
console.log('Country code:', document.getElementById('country_code').value);
|
||||||
|
console.log('Nationality:', document.getElementById('nationality').value);
|
||||||
|
console.log('Mobile number:', document.getElementById('mobile_number').value);
|
||||||
|
console.log('Birthdate:', document.getElementById('birthdate').value);
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@stack('styles')
|
@stack('styles')
|
||||||
@stack('scripts')
|
|
||||||
@endsection
|
@endsection
|
||||||
|
|||||||
@ -162,8 +162,8 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize all country code dropdowns on the page
|
// Initialize only country code dropdowns (not nationality dropdowns)
|
||||||
document.querySelectorAll('[id$="List"]').forEach(function(listElement) {
|
document.querySelectorAll('[id$="country_codeList"]').forEach(function(listElement) {
|
||||||
const componentId = listElement.id.replace('List', '');
|
const componentId = listElement.id.replace('List', '');
|
||||||
initializeCountryDropdown(componentId, countries);
|
initializeCountryDropdown(componentId, countries);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,115 +1,167 @@
|
|||||||
@props(['name' => 'nationality', 'id' => 'nationality', 'value' => '', 'required' => false, 'error' => null])
|
@props(['name' => 'nationality', 'id' => 'nationality', 'value' => '', 'required' => false, 'error' => null, 'label' => 'Nationality'])
|
||||||
|
|
||||||
<select id="{{ $id }}"
|
<label for="{{ $id }}" class="form-label">{{ $label }}</label>
|
||||||
class="form-select nationality-select @error($name) is-invalid @enderror"
|
<div class="dropdown w-100" onclick="event.stopPropagation()">
|
||||||
name="{{ $name }}"
|
<button class="form-select dropdown-toggle d-flex align-items-center justify-content-between @error($name) is-invalid @enderror"
|
||||||
{{ $required ? 'required' : '' }}>
|
type="button"
|
||||||
<option value="">Select Nationality</option>
|
id="{{ $id }}Dropdown"
|
||||||
</select>
|
data-bs-toggle="dropdown"
|
||||||
|
data-bs-auto-close="outside"
|
||||||
|
aria-expanded="false"
|
||||||
|
style="text-align: left; background-color: rgba(255,255,255,0.8);">
|
||||||
|
<span class="d-flex align-items-center">
|
||||||
|
<span id="{{ $id }}SelectedFlag"></span>
|
||||||
|
<span class="country-label" id="{{ $id }}SelectedCountry">Select Nationality</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="dropdown-menu p-2 w-100" aria-labelledby="{{ $id }}Dropdown" onclick="event.stopPropagation()">
|
||||||
|
<input type="text"
|
||||||
|
class="form-control form-control-sm mb-2"
|
||||||
|
placeholder="Search country..."
|
||||||
|
id="{{ $id }}Search"
|
||||||
|
onmousedown="event.stopPropagation()"
|
||||||
|
onfocus="event.stopPropagation()"
|
||||||
|
oninput="event.stopPropagation()"
|
||||||
|
onkeydown="event.stopPropagation()"
|
||||||
|
onkeyup="event.stopPropagation()">
|
||||||
|
|
||||||
|
<div class="country-list" id="{{ $id }}List" style="max-height: 300px; overflow-y: auto;">
|
||||||
|
<!-- Countries will be populated by JavaScript -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input type="hidden" id="{{ $id }}" name="{{ $name }}" value="{{ $value }}" {{ $required ? 'required' : '' }}>
|
||||||
|
</div>
|
||||||
|
|
||||||
@if($error)
|
@if($error)
|
||||||
<span class="invalid-feedback" role="alert">
|
<span class="invalid-feedback d-block" role="alert">
|
||||||
<strong>{{ $error }}</strong>
|
<strong>{{ $error }}</strong>
|
||||||
</span>
|
</span>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
@once
|
@once
|
||||||
|
@push('styles')
|
||||||
|
<style>
|
||||||
|
.country-dropdown-btn {
|
||||||
|
min-width: 150px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.country-list {
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-item {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-item:hover {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
@endpush
|
||||||
|
|
||||||
@push('scripts')
|
@push('scripts')
|
||||||
<script>
|
<script>
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
// Country data for nationality
|
// Load countries from JSON file
|
||||||
const countries = [
|
fetch('/data/countries.json')
|
||||||
{ name: 'United States', flagCode: 'us' },
|
.then(response => response.json())
|
||||||
{ name: 'Canada', flagCode: 'ca' },
|
.then(countries => {
|
||||||
{ name: 'United Kingdom', flagCode: 'gb' },
|
// Initialize only nationality dropdowns (not country code dropdowns)
|
||||||
{ name: 'United Arab Emirates', flagCode: 'ae' },
|
document.querySelectorAll('[id$="nationalityList"]').forEach(function(listElement) {
|
||||||
{ name: 'Saudi Arabia', flagCode: 'sa' },
|
const componentId = listElement.id.replace('List', '');
|
||||||
{ name: 'Qatar', flagCode: 'qa' },
|
initializeNationalityDropdown(componentId, countries);
|
||||||
{ name: 'Kuwait', flagCode: 'kw' },
|
});
|
||||||
{ name: 'Bahrain', flagCode: 'bh' },
|
})
|
||||||
{ name: 'Oman', flagCode: 'om' },
|
.catch(error => console.error('Error loading countries:', error));
|
||||||
{ name: 'Egypt', flagCode: 'eg' },
|
|
||||||
{ name: 'India', flagCode: 'in' },
|
|
||||||
{ name: 'Pakistan', flagCode: 'pk' },
|
|
||||||
{ name: 'Bangladesh', flagCode: 'bd' },
|
|
||||||
{ name: 'Malaysia', flagCode: 'my' },
|
|
||||||
{ name: 'Singapore', flagCode: 'sg' },
|
|
||||||
{ name: 'Japan', flagCode: 'jp' },
|
|
||||||
{ name: 'China', flagCode: 'cn' },
|
|
||||||
{ name: 'South Korea', flagCode: 'kr' },
|
|
||||||
{ name: 'Australia', flagCode: 'au' },
|
|
||||||
{ name: 'Germany', flagCode: 'de' },
|
|
||||||
{ name: 'France', flagCode: 'fr' },
|
|
||||||
{ name: 'Italy', flagCode: 'it' },
|
|
||||||
{ name: 'Spain', flagCode: 'es' },
|
|
||||||
{ name: 'Netherlands', flagCode: 'nl' },
|
|
||||||
{ name: 'Sweden', flagCode: 'se' },
|
|
||||||
{ name: 'Norway', flagCode: 'no' },
|
|
||||||
{ name: 'Denmark', flagCode: 'dk' },
|
|
||||||
{ name: 'Finland', flagCode: 'fi' },
|
|
||||||
{ name: 'Switzerland', flagCode: 'ch' },
|
|
||||||
{ name: 'Austria', flagCode: 'at' },
|
|
||||||
{ name: 'Poland', flagCode: 'pl' },
|
|
||||||
{ name: 'Czech Republic', flagCode: 'cz' },
|
|
||||||
{ name: 'Hungary', flagCode: 'hu' },
|
|
||||||
{ name: 'Romania', flagCode: 'ro' },
|
|
||||||
{ name: 'Greece', flagCode: 'gr' },
|
|
||||||
{ name: 'Turkey', flagCode: 'tr' },
|
|
||||||
{ name: 'Russia', flagCode: 'ru' },
|
|
||||||
{ name: 'Brazil', flagCode: 'br' },
|
|
||||||
{ name: 'Mexico', flagCode: 'mx' },
|
|
||||||
{ name: 'Argentina', flagCode: 'ar' },
|
|
||||||
{ name: 'Chile', flagCode: 'cl' },
|
|
||||||
{ name: 'Colombia', flagCode: 'co' },
|
|
||||||
{ name: 'South Africa', flagCode: 'za' },
|
|
||||||
{ name: 'Nigeria', flagCode: 'ng' },
|
|
||||||
{ name: 'Kenya', flagCode: 'ke' },
|
|
||||||
{ name: 'Sri Lanka', flagCode: 'lk' },
|
|
||||||
{ name: 'Vietnam', flagCode: 'vn' },
|
|
||||||
{ name: 'Thailand', flagCode: 'th' },
|
|
||||||
{ name: 'Indonesia', flagCode: 'id' },
|
|
||||||
{ name: 'Philippines', flagCode: 'ph' },
|
|
||||||
{ name: 'New Zealand', flagCode: 'nz' },
|
|
||||||
{ name: 'Portugal', flagCode: 'pt' },
|
|
||||||
{ name: 'Ireland', flagCode: 'ie' },
|
|
||||||
{ name: 'Israel', flagCode: 'il' },
|
|
||||||
{ name: 'Jordan', flagCode: 'jo' },
|
|
||||||
{ name: 'Lebanon', flagCode: 'lb' },
|
|
||||||
{ name: 'Iraq', flagCode: 'iq' },
|
|
||||||
];
|
|
||||||
|
|
||||||
// Initialize all nationality dropdowns on the page
|
function initializeNationalityDropdown(componentId, countries) {
|
||||||
document.querySelectorAll('.nationality-select').forEach(function(selectElement) {
|
const countryList = document.getElementById(componentId + 'List');
|
||||||
if (typeof $ !== 'undefined' && $.fn.select2) {
|
if (!countryList) return;
|
||||||
const $select = $(selectElement);
|
|
||||||
|
|
||||||
$select.select2({
|
// Clear existing items
|
||||||
data: countries.map(country => ({
|
countryList.innerHTML = '';
|
||||||
id: country.name,
|
|
||||||
text: country.name,
|
// Populate country dropdown
|
||||||
flagCode: country.flagCode
|
countries.forEach(country => {
|
||||||
})),
|
const button = document.createElement('button');
|
||||||
templateResult: function(data) {
|
button.className = 'dropdown-item d-flex align-items-center';
|
||||||
if (!data.id) return data.text;
|
button.type = 'button';
|
||||||
return $(`<span><span class="fi fi-${data.flagCode} me-2"></span> ${data.text}</span>`);
|
button.setAttribute('data-country-name', country.name);
|
||||||
},
|
button.setAttribute('data-flag', country.flag);
|
||||||
templateSelection: function(data) {
|
button.setAttribute('data-search', country.name.toLowerCase());
|
||||||
if (!data.id) return data.text;
|
|
||||||
return $(`<span><span class="fi fi-${data.flagCode} me-2"></span> ${data.text}</span>`);
|
// Convert flag code to emoji
|
||||||
},
|
const flagEmoji = country.iso2
|
||||||
placeholder: 'Select Nationality',
|
.toUpperCase()
|
||||||
allowClear: true,
|
.split('')
|
||||||
width: '100%'
|
.map(char => String.fromCodePoint(127397 + char.charCodeAt(0)))
|
||||||
|
.join('');
|
||||||
|
|
||||||
|
button.innerHTML = `
|
||||||
|
<span class="me-2">${flagEmoji}</span>
|
||||||
|
<span>${country.name}</span>
|
||||||
|
`;
|
||||||
|
button.addEventListener('click', function() {
|
||||||
|
selectNationality(componentId, country.name, flagEmoji);
|
||||||
|
});
|
||||||
|
countryList.appendChild(button);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Restore value if provided
|
// Search functionality
|
||||||
const initialValue = selectElement.getAttribute('data-value') || '{{ $value }}';
|
const searchInput = document.getElementById(componentId + 'Search');
|
||||||
if (initialValue) {
|
if (searchInput) {
|
||||||
$select.val(initialValue).trigger('change');
|
searchInput.addEventListener('input', function(e) {
|
||||||
}
|
const searchTerm = e.target.value.toLowerCase();
|
||||||
|
const items = countryList.querySelectorAll('.dropdown-item');
|
||||||
|
items.forEach(item => {
|
||||||
|
const searchText = item.getAttribute('data-search') || '';
|
||||||
|
if (searchText.includes(searchTerm)) {
|
||||||
|
item.classList.remove('d-none');
|
||||||
|
} else {
|
||||||
|
item.classList.add('d-none');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set initial value if provided
|
||||||
|
const hiddenInput = document.getElementById(componentId);
|
||||||
|
if (hiddenInput && hiddenInput.value) {
|
||||||
|
const initialCountry = countries.find(c => c.name === hiddenInput.value);
|
||||||
|
if (initialCountry) {
|
||||||
|
const flagEmoji = initialCountry.iso2
|
||||||
|
.toUpperCase()
|
||||||
|
.split('')
|
||||||
|
.map(char => String.fromCodePoint(127397 + char.charCodeAt(0)))
|
||||||
|
.join('');
|
||||||
|
selectNationality(componentId, initialCountry.name, flagEmoji);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectNationality(componentId, name, flag) {
|
||||||
|
const flagElement = document.getElementById(componentId + 'SelectedFlag');
|
||||||
|
const countryElement = document.getElementById(componentId + 'SelectedCountry');
|
||||||
|
const hiddenInput = document.getElementById(componentId);
|
||||||
|
|
||||||
|
if (flagElement) flagElement.textContent = flag + ' ';
|
||||||
|
if (countryElement) countryElement.textContent = name;
|
||||||
|
if (hiddenInput) hiddenInput.value = name;
|
||||||
|
|
||||||
|
// Close the dropdown after selection
|
||||||
|
const dropdownButton = document.getElementById(componentId + 'Dropdown');
|
||||||
|
if (dropdownButton) {
|
||||||
|
const dropdown = bootstrap.Dropdown.getInstance(dropdownButton);
|
||||||
|
if (dropdown) dropdown.hide();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
@endpush
|
@endpush
|
||||||
@endonce
|
@endonce
|
||||||
|
|||||||
@ -12,14 +12,40 @@
|
|||||||
<!-- Profile Picture Section -->
|
<!-- Profile Picture Section -->
|
||||||
<div class="mb-4 text-center">
|
<div class="mb-4 text-center">
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<img src="{{ $user->profile_picture ? asset('storage/' . $user->profile_picture) : asset('images/default-avatar.png') }}"
|
@if($user->profile_picture && file_exists(public_path('storage/' . $user->profile_picture)))
|
||||||
|
<img src="{{ asset('storage/' . $user->profile_picture) }}"
|
||||||
alt="Profile Picture"
|
alt="Profile Picture"
|
||||||
class="rounded-circle"
|
style="width: 300px; height: 400px; object-fit: cover; border: 3px solid #dee2e6; border-radius: 8px;">
|
||||||
style="width: 120px; height: 120px; object-fit: cover; border: 3px solid #dee2e6;">
|
@elseif(file_exists(public_path('storage/images/profiles/profile_' . $user->id . '.png')))
|
||||||
|
<img src="{{ asset('storage/images/profiles/profile_' . $user->id . '.png') }}"
|
||||||
|
alt="Profile Picture"
|
||||||
|
style="width: 300px; height: 400px; object-fit: cover; border: 3px solid #dee2e6; border-radius: 8px;">
|
||||||
|
@elseif(file_exists(public_path('storage/images/profiles/profile_' . $user->id . '.jpg')))
|
||||||
|
<img src="{{ asset('storage/images/profiles/profile_' . $user->id . '.jpg') }}"
|
||||||
|
alt="Profile Picture"
|
||||||
|
style="width: 300px; height: 400px; object-fit: cover; border: 3px solid #dee2e6; border-radius: 8px;">
|
||||||
|
@elseif(file_exists(public_path('storage/images/profiles/profile_' . $user->id . '.jpeg')))
|
||||||
|
<img src="{{ asset('storage/images/profiles/profile_' . $user->id . '.jpeg') }}"
|
||||||
|
alt="Profile Picture"
|
||||||
|
style="width: 300px; height: 400px; object-fit: cover; border: 3px solid #dee2e6; border-radius: 8px;">
|
||||||
|
@else
|
||||||
|
<div style="width: 300px; height: 400px; background-color: #f0f0f0; border: 3px solid #dee2e6; border-radius: 8px; display: flex; align-items: center; justify-content: center; margin: 0 auto;">
|
||||||
|
<div class="text-center">
|
||||||
|
<i class="bi bi-person-circle" style="font-size: 100px; color: #dee2e6;"></i>
|
||||||
|
<p class="text-muted mt-2">No profile picture</p>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" class="btn btn-outline-primary btn-sm" data-bs-toggle="modal" data-bs-target="#profilePictureModal">
|
</div>
|
||||||
<i class="fas fa-camera"></i> Change Profile Picture
|
@endif
|
||||||
</button>
|
</div>
|
||||||
|
<x-takeone-cropper
|
||||||
|
id="profile_picture"
|
||||||
|
width="300"
|
||||||
|
height="400"
|
||||||
|
shape="square"
|
||||||
|
folder="images/profiles"
|
||||||
|
filename="profile_{{ $user->id }}"
|
||||||
|
uploadUrl="{{ route('profile.upload-picture') }}"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form method="POST" action="{{ route('profile.update') }}">
|
<form method="POST" action="{{ route('profile.update') }}">
|
||||||
@ -196,14 +222,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Profile Picture Upload Modal -->
|
|
||||||
<x-image-upload-modal
|
|
||||||
id="profilePictureModal"
|
|
||||||
aspectRatio="1"
|
|
||||||
maxSizeMB="1"
|
|
||||||
title="Upload Profile Picture"
|
|
||||||
uploadUrl="{{ route('profile.upload-picture') }}"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@push('scripts')
|
@push('scripts')
|
||||||
|
|||||||
@ -42,8 +42,8 @@
|
|||||||
<div class="d-flex">
|
<div class="d-flex">
|
||||||
<!-- Profile Picture -->
|
<!-- Profile Picture -->
|
||||||
<div style="width: 180px; min-height: 250px; border-radius: 0.375rem 0 0 0.375rem;">
|
<div style="width: 180px; min-height: 250px; border-radius: 0.375rem 0 0 0.375rem;">
|
||||||
@if($relationship->dependent->media_gallery[0] ?? false)
|
@if($relationship->dependent->profile_picture)
|
||||||
<img src="{{ $relationship->dependent->media_gallery[0] }}" alt="{{ $relationship->dependent->full_name }}" class="w-100 h-100" style="object-fit: cover; border-radius: 0.375rem 0 0 0.375rem;">
|
<img src="{{ asset('storage/' . $relationship->dependent->profile_picture) }}" alt="{{ $relationship->dependent->full_name }}" class="w-100 h-100" style="object-fit: cover; border-radius: 0.375rem 0 0 0.375rem;">
|
||||||
@else
|
@else
|
||||||
<div class="w-100 h-100 d-flex align-items-center justify-content-center text-white fw-bold" style="font-size: 3rem; background: linear-gradient(135deg, {{ $relationship->dependent->gender == 'm' ? '#0d6efd 0%, #0a58ca 100%' : '#d63384 0%, #a61e4d 100%' }}); border-radius: 0.375rem 0 0 0.375rem;">
|
<div class="w-100 h-100 d-flex align-items-center justify-content-center text-white fw-bold" style="font-size: 3rem; background: linear-gradient(135deg, {{ $relationship->dependent->gender == 'm' ? '#0d6efd 0%, #0a58ca 100%' : '#d63384 0%, #a61e4d 100%' }}); border-radius: 0.375rem 0 0 0.375rem;">
|
||||||
{{ strtoupper(substr($relationship->dependent->full_name, 0, 1)) }}
|
{{ strtoupper(substr($relationship->dependent->full_name, 0, 1)) }}
|
||||||
@ -66,7 +66,7 @@
|
|||||||
<li><a class="dropdown-item" href="#"><i class="bi bi-calendar-check me-2"></i>Add Attendance Record</a></li>
|
<li><a class="dropdown-item" href="#"><i class="bi bi-calendar-check me-2"></i>Add Attendance Record</a></li>
|
||||||
<li><a class="dropdown-item" href="#"><i class="bi bi-calendar-event me-2"></i>Add Event Participation</a></li>
|
<li><a class="dropdown-item" href="#"><i class="bi bi-calendar-event me-2"></i>Add Event Participation</a></li>
|
||||||
<li><a class="dropdown-item" href="#" data-bs-target="#healthUpdateModal"><i class="bi bi-heart-pulse me-2"></i>Add Health Update</a></li>
|
<li><a class="dropdown-item" href="#" data-bs-target="#healthUpdateModal"><i class="bi bi-heart-pulse me-2"></i>Add Health Update</a></li>
|
||||||
<li><a class="dropdown-item" href="#"><i class="bi bi-award me-2"></i>Add Tournament Participation</a></li>
|
<li><a class="dropdown-item" href="#" data-bs-toggle="modal" data-bs-target="#tournamentParticipationModal"><i class="bi bi-award me-2"></i>Add Tournament Participation</a></li>
|
||||||
<li><a class="dropdown-item" href="@if($relationship->relationship_type == 'self'){{ route('profile.edit') }}@else{{ route('family.edit', $relationship->dependent->id) }}@endif">
|
<li><a class="dropdown-item" href="@if($relationship->relationship_type == 'self'){{ route('profile.edit') }}@else{{ route('family.edit', $relationship->dependent->id) }}@endif">
|
||||||
<i class="bi bi-pencil me-2"></i>Edit Info
|
<i class="bi bi-pencil me-2"></i>Edit Info
|
||||||
</a></li>
|
</a></li>
|
||||||
@ -86,12 +86,12 @@
|
|||||||
|
|
||||||
<!-- Achievement Badges -->
|
<!-- Achievement Badges -->
|
||||||
<div class="d-flex gap-2 mb-3 flex-wrap">
|
<div class="d-flex gap-2 mb-3 flex-wrap">
|
||||||
<a href="#" class="border bg-white rounded px-2 py-1 text-decoration-none" style="font-size: 1rem;">🏆 <span class="fw-semibold text-dark">3</span></a>
|
<a href="#" class="border bg-white rounded px-2 py-1 text-decoration-none achievement-badge" style="font-size: 1rem;" data-medal-type="special" onclick="filterTournamentsByMedal('special')">🏆 <span class="fw-semibold text-dark">{{ $awardCounts['special'] }}</span></a>
|
||||||
<a href="#" class="border bg-white rounded px-2 py-1 text-decoration-none" style="font-size: 1rem;">🥇 <span class="fw-semibold text-dark">4</span></a>
|
<a href="#" class="border bg-white rounded px-2 py-1 text-decoration-none achievement-badge" style="font-size: 1rem;" data-medal-type="1st" onclick="filterTournamentsByMedal('1st')">🥇 <span class="fw-semibold text-dark">{{ $awardCounts['1st'] }}</span></a>
|
||||||
<a href="#" class="border bg-white rounded px-2 py-1 text-decoration-none" style="font-size: 1rem;">🥈 <span class="fw-semibold text-dark">6</span></a>
|
<a href="#" class="border bg-white rounded px-2 py-1 text-decoration-none achievement-badge" style="font-size: 1rem;" data-medal-type="2nd" onclick="filterTournamentsByMedal('2nd')">🥈 <span class="fw-semibold text-dark">{{ $awardCounts['2nd'] }}</span></a>
|
||||||
<a href="#" class="border bg-white rounded px-2 py-1 text-decoration-none" style="font-size: 1rem;">🥉 <span class="fw-semibold text-dark">3</span></a>
|
<a href="#" class="border bg-white rounded px-2 py-1 text-decoration-none achievement-badge" style="font-size: 1rem;" data-medal-type="3rd" onclick="filterTournamentsByMedal('3rd')">🥉 <span class="fw-semibold text-dark">{{ $awardCounts['3rd'] }}</span></a>
|
||||||
<a href="#goals" class="border bg-white rounded px-2 py-1 text-decoration-none" style="font-size: 1rem;" onclick="document.getElementById('goals-tab').click();">🎯 <span class="fw-semibold text-dark">8</span></a>
|
<a href="#goals" class="border bg-white rounded px-2 py-1 text-decoration-none" style="font-size: 1rem;" onclick="document.getElementById('goals-tab').click();">🎯 <span class="fw-semibold text-dark">{{ $activeGoalsCount + $completedGoalsCount }}</span></a>
|
||||||
<a href="#" class="border bg-white rounded px-2 py-1 text-decoration-none" style="font-size: 1rem;">⭐ <span class="fw-semibold text-dark">12</span></a>
|
<a href="#" class="border bg-white rounded px-2 py-1 text-decoration-none" style="font-size: 1rem;">⭐ <span class="fw-semibold text-dark">{{ $totalAffiliations }}</span></a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Status Badges -->
|
<!-- Status Badges -->
|
||||||
@ -990,8 +990,120 @@
|
|||||||
<div class="tab-pane fade" id="affiliations" role="tabpanel">
|
<div class="tab-pane fade" id="affiliations" role="tabpanel">
|
||||||
<div class="card shadow-sm border-0">
|
<div class="card shadow-sm border-0">
|
||||||
<div class="card-body p-4">
|
<div class="card-body p-4">
|
||||||
<h5 class="fw-bold mb-3"><i class="bi bi-diagram-3 me-2"></i>Affiliations & Badges</h5>
|
<h5 class="fw-bold mb-3"><i class="bi bi-diagram-3 me-2"></i>Affiliations & Skills</h5>
|
||||||
<p class="text-muted">Affiliation system coming soon...</p>
|
|
||||||
|
@if($clubAffiliations->count() > 0)
|
||||||
|
<!-- Summary Stats -->
|
||||||
|
<div class="row g-3 mb-4">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="card shadow-sm bg-primary text-white">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<i class="bi bi-building display-4 mb-2"></i>
|
||||||
|
<h4 class="fw-bold mb-1">{{ $totalAffiliations }}</h4>
|
||||||
|
<small>Total Affiliations</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="card shadow-sm bg-success text-white">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<i class="bi bi-star display-4 mb-2"></i>
|
||||||
|
<h4 class="fw-bold mb-1">{{ $distinctSkills }}</h4>
|
||||||
|
<small>Distinct Skills</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="card shadow-sm bg-info text-white">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<i class="bi bi-calendar-check display-4 mb-2"></i>
|
||||||
|
<h4 class="fw-bold mb-1">{{ floor($totalMembershipDuration / 12) }}y {{ $totalMembershipDuration % 12 }}m</h4>
|
||||||
|
<small>Total Membership</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Timeline and Skills Wheel Container -->
|
||||||
|
<div class="row">
|
||||||
|
<!-- Timeline -->
|
||||||
|
<div class="col-lg-6 mb-4">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-header bg-light">
|
||||||
|
<h6 class="card-title mb-0"><i class="bi bi-timeline me-2"></i>Club Affiliations Timeline</h6>
|
||||||
|
</div>
|
||||||
|
<div class="card-body" style="max-height: 600px; overflow-y: auto;">
|
||||||
|
<div class="timeline">
|
||||||
|
@foreach($clubAffiliations as $index => $affiliation)
|
||||||
|
<div class="timeline-item mb-4 position-relative" data-affiliation-id="{{ $affiliation->id }}">
|
||||||
|
<div class="timeline-marker bg-primary"></div>
|
||||||
|
<div class="timeline-content card border {{ $index === 0 ? 'border-primary' : '' }} affiliation-card" style="cursor: pointer;" data-affiliation-id="{{ $affiliation->id }}">
|
||||||
|
<div class="card-body p-3">
|
||||||
|
<div class="d-flex align-items-center mb-2">
|
||||||
|
@if($affiliation->logo)
|
||||||
|
<img src="{{ asset('storage/' . $affiliation->logo) }}" alt="{{ $affiliation->club_name }}" class="me-3 rounded" style="width: 40px; height: 40px; object-fit: cover;">
|
||||||
|
@else
|
||||||
|
<div class="bg-primary text-white rounded d-flex align-items-center justify-content-center me-3" style="width: 40px; height: 40px;">
|
||||||
|
<i class="bi bi-building"></i>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
<div class="flex-grow-1">
|
||||||
|
<h6 class="mb-0 fw-bold">{{ $affiliation->club_name }}</h6>
|
||||||
|
<small class="text-muted">{{ $affiliation->date_range }}</small>
|
||||||
|
<br>
|
||||||
|
<span class="badge bg-info text-dark small">{{ $affiliation->formatted_duration }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@if($affiliation->location)
|
||||||
|
<div class="text-muted small mb-1">
|
||||||
|
<i class="bi bi-geo-alt me-1"></i>{{ $affiliation->location }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
<div class="text-muted small">
|
||||||
|
<i class="bi bi-star me-1"></i>{{ $affiliation->skillAcquisitions->count() }} skills acquired
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Skills Wheel and Details -->
|
||||||
|
<div class="col-lg-6">
|
||||||
|
<!-- Skills Wheel -->
|
||||||
|
<div class="card shadow-sm mb-4">
|
||||||
|
<div class="card-header bg-light">
|
||||||
|
<h6 class="card-title mb-0"><i class="bi bi-pie-chart me-2"></i>Skills Wheel</h6>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="text-center">
|
||||||
|
<canvas id="skillsChart" width="300" height="300"></canvas>
|
||||||
|
<p id="noSkillsMessage" class="text-muted mt-3 d-none">Select an affiliation to view skills</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Affiliation Details -->
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-header bg-light">
|
||||||
|
<h6 class="card-title mb-0"><i class="bi bi-info-circle me-2"></i>Affiliation Details</h6>
|
||||||
|
</div>
|
||||||
|
<div class="card-body" id="affiliationDetails">
|
||||||
|
<p class="text-muted">Select an affiliation from the timeline to view details</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<div class="text-center py-5">
|
||||||
|
<i class="bi bi-diagram-3 text-muted" style="font-size: 3rem;"></i>
|
||||||
|
<h5 class="text-muted mt-3 mb-2">No Affiliations Yet</h5>
|
||||||
|
<p class="text-muted mb-0">Club affiliations and skills will appear here once added</p>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -1069,6 +1181,7 @@
|
|||||||
<thead class="table-light">
|
<thead class="table-light">
|
||||||
<tr>
|
<tr>
|
||||||
<th class="text-muted small fw-semibold">Tournament Details</th>
|
<th class="text-muted small fw-semibold">Tournament Details</th>
|
||||||
|
<th class="text-muted small fw-semibold">Club Affiliation</th>
|
||||||
<th class="text-muted small fw-semibold">Performance & Result</th>
|
<th class="text-muted small fw-semibold">Performance & Result</th>
|
||||||
<th class="text-muted small fw-semibold">Notes & Media</th>
|
<th class="text-muted small fw-semibold">Notes & Media</th>
|
||||||
</tr>
|
</tr>
|
||||||
@ -1095,6 +1208,16 @@
|
|||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
<td>
|
||||||
|
@if($event->clubAffiliation)
|
||||||
|
<div>
|
||||||
|
<div class="small fw-semibold">{{ $event->clubAffiliation->club_name }}</div>
|
||||||
|
<div class="text-muted small">{{ $event->clubAffiliation->location }}</div>
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<span class="text-muted small">Individual</span>
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
<td>
|
<td>
|
||||||
@if($event->performanceResults->count() > 0)
|
@if($event->performanceResults->count() > 0)
|
||||||
@foreach($event->performanceResults as $result)
|
@foreach($event->performanceResults as $result)
|
||||||
@ -1297,10 +1420,215 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Tournament Participation Modal -->
|
||||||
|
<div class="modal fade" id="tournamentParticipationModal" tabindex="-1" aria-labelledby="tournamentParticipationModalLabel" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-xl">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title" id="tournamentParticipationModalLabel">Add Tournament Participation</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<form id="tournamentParticipationForm" method="POST" action="{{ route('family.store-tournament', $relationship->dependent->id) }}">
|
||||||
|
@csrf
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="row g-3">
|
||||||
|
<!-- Tournament Details -->
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="tournament_title" class="form-label">Tournament Title <span class="text-danger">*</span></label>
|
||||||
|
<input type="text" class="form-control" id="tournament_title" name="title" required>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="tournament_type" class="form-label">Type <span class="text-danger">*</span></label>
|
||||||
|
<select class="form-select" id="tournament_type" name="type" required>
|
||||||
|
<option value="">Select Type</option>
|
||||||
|
<option value="championship">Championship</option>
|
||||||
|
<option value="tournament">Tournament</option>
|
||||||
|
<option value="competition">Competition</option>
|
||||||
|
<option value="exhibition">Exhibition</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="tournament_sport" class="form-label">Sport <span class="text-danger">*</span></label>
|
||||||
|
<select class="form-select" id="tournament_sport" name="sport" required>
|
||||||
|
<option value="">Select Sport</option>
|
||||||
|
<option value="Boxing">Boxing</option>
|
||||||
|
<option value="Taekwondo">Taekwondo</option>
|
||||||
|
<option value="Karate">Karate</option>
|
||||||
|
<option value="Martial Arts">Martial Arts</option>
|
||||||
|
<option value="Fitness">Fitness</option>
|
||||||
|
<option value="Weightlifting">Weightlifting</option>
|
||||||
|
<option value="Other">Other</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="tournament_date" class="form-label">Date <span class="text-danger">*</span></label>
|
||||||
|
<input type="date" class="form-control" id="tournament_date" name="date" required>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="tournament_time" class="form-label">Time</label>
|
||||||
|
<input type="time" class="form-control" id="tournament_time" name="time">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="tournament_location" class="form-label">Location</label>
|
||||||
|
<input type="text" class="form-control" id="tournament_location" name="location" placeholder="Venue name or address">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="participants_count" class="form-label">Number of Participants</label>
|
||||||
|
<input type="number" class="form-control" id="participants_count" name="participants_count" min="1">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="club_affiliation_id" class="form-label">Club Affiliation</label>
|
||||||
|
<select class="form-select" id="club_affiliation_id" name="club_affiliation_id">
|
||||||
|
<option value="">Select Club (Optional)</option>
|
||||||
|
@foreach($clubAffiliations ?? [] as $affiliation)
|
||||||
|
<option value="{{ $affiliation->id }}">{{ $affiliation->club_name }}</option>
|
||||||
|
@endforeach
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Performance Results Section -->
|
||||||
|
<div class="col-12">
|
||||||
|
<hr>
|
||||||
|
<h6 class="mb-3">Performance Results</h6>
|
||||||
|
<div id="performanceResultsContainer">
|
||||||
|
<div class="performance-result-item mb-3 p-3 border rounded">
|
||||||
|
<div class="row g-2">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label">Medal Type</label>
|
||||||
|
<select class="form-select medal-type" name="performance_results[0][medal_type]">
|
||||||
|
<option value="">Select Medal</option>
|
||||||
|
<option value="special">Special Award</option>
|
||||||
|
<option value="1st">1st Place</option>
|
||||||
|
<option value="2nd">2nd Place</option>
|
||||||
|
<option value="3rd">3rd Place</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label">Points</label>
|
||||||
|
<input type="number" class="form-control" name="performance_results[0][points]" min="0" step="0.1">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label">Description</label>
|
||||||
|
<input type="text" class="form-control" name="performance_results[0][description]" placeholder="Optional description">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-1 d-flex align-items-end">
|
||||||
|
<button type="button" class="btn btn-outline-danger btn-sm remove-result" style="display: none;">
|
||||||
|
<i class="bi bi-trash"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn btn-outline-primary btn-sm" id="addPerformanceResult">
|
||||||
|
<i class="bi bi-plus me-1"></i>Add Another Result
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Notes & Media Section -->
|
||||||
|
<div class="col-12">
|
||||||
|
<hr>
|
||||||
|
<h6 class="mb-3">Notes & Media</h6>
|
||||||
|
<div id="notesMediaContainer">
|
||||||
|
<div class="notes-media-item mb-3 p-3 border rounded">
|
||||||
|
<div class="row g-2">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Note Text</label>
|
||||||
|
<textarea class="form-control" name="notes_media[0][note_text]" rows="2" placeholder="Optional notes about the tournament"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-5">
|
||||||
|
<label class="form-label">Media Link</label>
|
||||||
|
<input type="url" class="form-control" name="notes_media[0][media_link]" placeholder="https://example.com/photo.jpg">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-1 d-flex align-items-end">
|
||||||
|
<button type="button" class="btn btn-outline-danger btn-sm remove-note" style="display: none;">
|
||||||
|
<i class="bi bi-trash"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn btn-outline-primary btn-sm" id="addNotesMedia">
|
||||||
|
<i class="bi bi-plus me-1"></i>Add Another Note/Media
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</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-primary">Save Tournament Record</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.history-row:hover .edit-record-btn {
|
.history-row:hover .edit-record-btn {
|
||||||
opacity: 1 !important;
|
opacity: 1 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Timeline Styles */
|
||||||
|
.timeline {
|
||||||
|
position: relative;
|
||||||
|
padding-left: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 15px;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 2px;
|
||||||
|
background: #e9ecef;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-item {
|
||||||
|
position: relative;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-marker {
|
||||||
|
position: absolute;
|
||||||
|
left: -22px;
|
||||||
|
top: 20px;
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #6c757d;
|
||||||
|
border: 2px solid #fff;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-marker.bg-primary {
|
||||||
|
background: #0d6efd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-content {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-content:hover {
|
||||||
|
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.affiliation-card {
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.affiliation-card:hover {
|
||||||
|
box-shadow: 0 4px 12px rgba(0,0,0,0.15) !important;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.affiliation-card.border-primary {
|
||||||
|
border-color: #0d6efd !important;
|
||||||
|
box-shadow: 0 0 0 0.2rem rgba(13, 110, 253, 0.25) !important;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
@ -1666,9 +1994,11 @@
|
|||||||
const tournamentsTable = document.getElementById('tournamentsTable');
|
const tournamentsTable = document.getElementById('tournamentsTable');
|
||||||
const awardCards = document.getElementById('awardCards');
|
const awardCards = document.getElementById('awardCards');
|
||||||
|
|
||||||
if (sportFilter && tournamentsTable) {
|
// Global variables for current filters
|
||||||
sportFilter.addEventListener('change', function() {
|
let currentSportFilter = 'all';
|
||||||
const selectedSport = this.value;
|
let currentMedalFilter = 'all';
|
||||||
|
|
||||||
|
function applyTournamentFilters() {
|
||||||
const rows = tournamentsTable.querySelectorAll('tbody tr');
|
const rows = tournamentsTable.querySelectorAll('tbody tr');
|
||||||
|
|
||||||
let visibleRows = 0;
|
let visibleRows = 0;
|
||||||
@ -1676,12 +2006,34 @@
|
|||||||
|
|
||||||
rows.forEach(row => {
|
rows.forEach(row => {
|
||||||
const sport = row.getAttribute('data-sport');
|
const sport = row.getAttribute('data-sport');
|
||||||
if (selectedSport === 'all' || sport === selectedSport) {
|
const performanceCell = row.querySelector('td:nth-child(3)');
|
||||||
|
let hasMatchingMedal = false;
|
||||||
|
|
||||||
|
if (performanceCell) {
|
||||||
|
const badges = performanceCell.querySelectorAll('.badge');
|
||||||
|
badges.forEach(badge => {
|
||||||
|
if (currentMedalFilter === 'all') {
|
||||||
|
hasMatchingMedal = true;
|
||||||
|
} else if (currentMedalFilter === 'special' && badge.textContent.includes('Special Award')) {
|
||||||
|
hasMatchingMedal = true;
|
||||||
|
} else if (currentMedalFilter === '1st' && badge.textContent.includes('1st Place')) {
|
||||||
|
hasMatchingMedal = true;
|
||||||
|
} else if (currentMedalFilter === '2nd' && badge.textContent.includes('2nd Place')) {
|
||||||
|
hasMatchingMedal = true;
|
||||||
|
} else if (currentMedalFilter === '3rd' && badge.textContent.includes('3rd Place')) {
|
||||||
|
hasMatchingMedal = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const sportMatch = currentSportFilter === 'all' || sport === currentSportFilter;
|
||||||
|
const medalMatch = currentMedalFilter === 'all' || hasMatchingMedal;
|
||||||
|
|
||||||
|
if (sportMatch && medalMatch) {
|
||||||
row.style.display = '';
|
row.style.display = '';
|
||||||
visibleRows++;
|
visibleRows++;
|
||||||
|
|
||||||
// Count awards in visible rows
|
// Count awards in visible rows
|
||||||
const performanceCell = row.querySelector('td:nth-child(2)');
|
|
||||||
if (performanceCell) {
|
if (performanceCell) {
|
||||||
const badges = performanceCell.querySelectorAll('.badge');
|
const badges = performanceCell.querySelectorAll('.badge');
|
||||||
badges.forEach(badge => {
|
badges.forEach(badge => {
|
||||||
@ -1708,9 +2060,37 @@
|
|||||||
} else {
|
} else {
|
||||||
awardCards.style.display = '';
|
awardCards.style.display = '';
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sportFilter && tournamentsTable) {
|
||||||
|
sportFilter.addEventListener('change', function() {
|
||||||
|
currentSportFilter = this.value;
|
||||||
|
applyTournamentFilters();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Function to filter tournaments by medal type (called from achievement badges)
|
||||||
|
window.filterTournamentsByMedal = function(medalType) {
|
||||||
|
// Switch to tournaments tab
|
||||||
|
const tournamentsTab = document.getElementById('tournaments-tab');
|
||||||
|
if (tournamentsTab) {
|
||||||
|
const tab = new bootstrap.Tab(tournamentsTab);
|
||||||
|
tab.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set medal filter
|
||||||
|
currentMedalFilter = medalType;
|
||||||
|
currentSportFilter = 'all'; // Reset sport filter
|
||||||
|
|
||||||
|
// Reset sport filter dropdown
|
||||||
|
if (sportFilter) {
|
||||||
|
sportFilter.value = 'all';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply filters
|
||||||
|
applyTournamentFilters();
|
||||||
|
};
|
||||||
|
|
||||||
// Goals filtering functionality
|
// Goals filtering functionality
|
||||||
const goalFilterCards = document.querySelectorAll('.goal-filter-card');
|
const goalFilterCards = document.querySelectorAll('.goal-filter-card');
|
||||||
const goalsContainer = document.querySelector('.row.g-4'); // Container with goal cards
|
const goalsContainer = document.querySelector('.row.g-4'); // Container with goal cards
|
||||||
@ -1870,4 +2250,389 @@
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<!-- Chart.js for Skills Wheel -->
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||||
|
|
||||||
|
<!-- Affiliations Tab JavaScript -->
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
// Affiliations data
|
||||||
|
const affiliationsData = @json($clubAffiliations);
|
||||||
|
|
||||||
|
let skillsChart = null;
|
||||||
|
let selectedAffiliationId = null;
|
||||||
|
|
||||||
|
// Initialize affiliations functionality
|
||||||
|
function initAffiliations() {
|
||||||
|
// Set up timeline click handlers
|
||||||
|
document.querySelectorAll('.affiliation-card').forEach(card => {
|
||||||
|
card.addEventListener('click', function() {
|
||||||
|
const affiliationId = this.getAttribute('data-affiliation-id');
|
||||||
|
selectAffiliation(affiliationId);
|
||||||
|
|
||||||
|
// Update visual selection
|
||||||
|
document.querySelectorAll('.affiliation-card').forEach(c => {
|
||||||
|
c.classList.remove('border-primary');
|
||||||
|
});
|
||||||
|
this.classList.add('border-primary');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Select first affiliation by default if available
|
||||||
|
if (affiliationsData.length > 0) {
|
||||||
|
selectAffiliation(affiliationsData[0].id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectAffiliation(affiliationId) {
|
||||||
|
selectedAffiliationId = affiliationId;
|
||||||
|
const affiliation = affiliationsData.find(a => a.id == affiliationId);
|
||||||
|
|
||||||
|
if (!affiliation) return;
|
||||||
|
|
||||||
|
// Update skills chart
|
||||||
|
updateSkillsChart(affiliation.skill_acquisitions || []);
|
||||||
|
|
||||||
|
// Update affiliation details
|
||||||
|
updateAffiliationDetails(affiliation);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSkillsChart(skills) {
|
||||||
|
const ctx = document.getElementById('skillsChart').getContext('2d');
|
||||||
|
const noSkillsMessage = document.getElementById('noSkillsMessage');
|
||||||
|
|
||||||
|
if (skills.length === 0) {
|
||||||
|
if (skillsChart) {
|
||||||
|
skillsChart.destroy();
|
||||||
|
skillsChart = null;
|
||||||
|
}
|
||||||
|
document.getElementById('skillsChart').style.display = 'none';
|
||||||
|
noSkillsMessage.classList.remove('d-none');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('skillsChart').style.display = 'block';
|
||||||
|
noSkillsMessage.classList.add('d-none');
|
||||||
|
|
||||||
|
// Prepare data for polar area chart
|
||||||
|
const labels = skills.map(skill => skill.skill_name);
|
||||||
|
const data = skills.map(skill => skill.duration_months);
|
||||||
|
const backgroundColors = [
|
||||||
|
'rgba(54, 162, 235, 0.8)',
|
||||||
|
'rgba(255, 99, 132, 0.8)',
|
||||||
|
'rgba(255, 205, 86, 0.8)',
|
||||||
|
'rgba(75, 192, 192, 0.8)',
|
||||||
|
'rgba(153, 102, 255, 0.8)',
|
||||||
|
'rgba(255, 159, 64, 0.8)',
|
||||||
|
'rgba(199, 199, 199, 0.8)',
|
||||||
|
'rgba(83, 102, 255, 0.8)',
|
||||||
|
'rgba(255, 99, 255, 0.8)',
|
||||||
|
'rgba(99, 255, 132, 0.8)'
|
||||||
|
];
|
||||||
|
|
||||||
|
if (skillsChart) {
|
||||||
|
skillsChart.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
skillsChart = new Chart(ctx, {
|
||||||
|
type: 'polarArea',
|
||||||
|
data: {
|
||||||
|
labels: labels,
|
||||||
|
datasets: [{
|
||||||
|
data: data,
|
||||||
|
backgroundColor: backgroundColors.slice(0, skills.length),
|
||||||
|
borderWidth: 2,
|
||||||
|
borderColor: backgroundColors.slice(0, skills.length).map(color => color.replace('0.8', '1')),
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
position: 'bottom',
|
||||||
|
labels: {
|
||||||
|
padding: 20,
|
||||||
|
usePointStyle: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
callbacks: {
|
||||||
|
label: function(context) {
|
||||||
|
const skill = skills[context.dataIndex];
|
||||||
|
const duration = skill.formatted_duration || `${skill.duration_months} months`;
|
||||||
|
return `${context.label}: ${duration}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
r: {
|
||||||
|
beginAtZero: true,
|
||||||
|
ticks: {
|
||||||
|
display: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
animation: {
|
||||||
|
animateScale: true,
|
||||||
|
animateRotate: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateAffiliationDetails(affiliation) {
|
||||||
|
const detailsContainer = document.getElementById('affiliationDetails');
|
||||||
|
|
||||||
|
let html = `
|
||||||
|
<div class="d-flex align-items-center mb-3">
|
||||||
|
${affiliation.logo ?
|
||||||
|
`<img src="${affiliation.logo}" alt="${affiliation.club_name}" class="me-3 rounded" style="width: 50px; height: 50px; object-fit: cover;">` :
|
||||||
|
`<div class="bg-primary text-white rounded d-flex align-items-center justify-content-center me-3" style="width: 50px; height: 50px;">
|
||||||
|
<i class="bi bi-building"></i>
|
||||||
|
</div>`
|
||||||
|
}
|
||||||
|
<div>
|
||||||
|
<h5 class="mb-1">${affiliation.club_name}</h5>
|
||||||
|
<p class="text-muted mb-0">${affiliation.date_range}</p>
|
||||||
|
<span class="badge bg-info text-dark small">${affiliation.formatted_duration}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (affiliation.location) {
|
||||||
|
html += `<p class="mb-2"><i class="bi bi-geo-alt me-2"></i><strong>Location:</strong> ${affiliation.location}</p>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (affiliation.description) {
|
||||||
|
html += `<p class="mb-2"><strong>Description:</strong> ${affiliation.description}</p>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (affiliation.coaches && affiliation.coaches.length > 0) {
|
||||||
|
html += `<p class="mb-2"><strong>Coaches:</strong> ${affiliation.coaches.join(', ')}</p>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (affiliation.affiliation_media && affiliation.affiliation_media.length > 0) {
|
||||||
|
html += `<div class="mt-3"><strong>Media & Certificates:</strong></div>`;
|
||||||
|
html += `<div class="row g-2 mt-1">`;
|
||||||
|
|
||||||
|
affiliation.affiliation_media.forEach(media => {
|
||||||
|
const iconClass = media.icon_class || 'bi-file';
|
||||||
|
html += `
|
||||||
|
<div class="col-6">
|
||||||
|
<a href="${media.full_url}" target="_blank" class="btn btn-outline-secondary btn-sm w-100">
|
||||||
|
<i class="bi ${iconClass} me-1"></i>${media.title || media.media_type}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
|
||||||
|
html += `</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
detailsContainer.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize when affiliations tab is shown
|
||||||
|
const affiliationsTab = document.getElementById('affiliations-tab');
|
||||||
|
if (affiliationsTab) {
|
||||||
|
affiliationsTab.addEventListener('shown.bs.tab', function() {
|
||||||
|
initAffiliations();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize immediately if affiliations tab is active
|
||||||
|
if (document.getElementById('affiliations').classList.contains('show')) {
|
||||||
|
initAffiliations();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Tournament Participation Modal Functionality
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
let performanceResultIndex = 1;
|
||||||
|
let notesMediaIndex = 1;
|
||||||
|
|
||||||
|
// Add Performance Result
|
||||||
|
document.getElementById('addPerformanceResult').addEventListener('click', function() {
|
||||||
|
const container = document.getElementById('performanceResultsContainer');
|
||||||
|
const newItem = document.createElement('div');
|
||||||
|
newItem.className = 'performance-result-item mb-3 p-3 border rounded';
|
||||||
|
newItem.innerHTML = `
|
||||||
|
<div class="row g-2">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label">Medal Type</label>
|
||||||
|
<select class="form-select medal-type" name="performance_results[${performanceResultIndex}][medal_type]">
|
||||||
|
<option value="">Select Medal</option>
|
||||||
|
<option value="special">Special Award</option>
|
||||||
|
<option value="1st">1st Place</option>
|
||||||
|
<option value="2nd">2nd Place</option>
|
||||||
|
<option value="3rd">3rd Place</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label">Points</label>
|
||||||
|
<input type="number" class="form-control" name="performance_results[${performanceResultIndex}][points]" min="0" step="0.1">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label">Description</label>
|
||||||
|
<input type="text" class="form-control" name="performance_results[${performanceResultIndex}][description]" placeholder="Optional description">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-1 d-flex align-items-end">
|
||||||
|
<button type="button" class="btn btn-outline-danger btn-sm remove-result">
|
||||||
|
<i class="bi bi-trash"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
container.appendChild(newItem);
|
||||||
|
performanceResultIndex++;
|
||||||
|
|
||||||
|
// Show remove buttons if more than one result
|
||||||
|
updateRemoveButtons('performance-result-item', 'remove-result');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add Notes & Media
|
||||||
|
document.getElementById('addNotesMedia').addEventListener('click', function() {
|
||||||
|
const container = document.getElementById('notesMediaContainer');
|
||||||
|
const newItem = document.createElement('div');
|
||||||
|
newItem.className = 'notes-media-item mb-3 p-3 border rounded';
|
||||||
|
newItem.innerHTML = `
|
||||||
|
<div class="row g-2">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Note Text</label>
|
||||||
|
<textarea class="form-control" name="notes_media[${notesMediaIndex}][note_text]" rows="2" placeholder="Optional notes about the tournament"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-5">
|
||||||
|
<label class="form-label">Media Link</label>
|
||||||
|
<input type="url" class="form-control" name="notes_media[${notesMediaIndex}][media_link]" placeholder="https://example.com/photo.jpg">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-1 d-flex align-items-end">
|
||||||
|
<button type="button" class="btn btn-outline-danger btn-sm remove-note">
|
||||||
|
<i class="bi bi-trash"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
container.appendChild(newItem);
|
||||||
|
notesMediaIndex++;
|
||||||
|
|
||||||
|
// Show remove buttons if more than one note
|
||||||
|
updateRemoveButtons('notes-media-item', 'remove-note');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Remove Performance Result
|
||||||
|
document.addEventListener('click', function(e) {
|
||||||
|
if (e.target.closest('.remove-result')) {
|
||||||
|
e.target.closest('.performance-result-item').remove();
|
||||||
|
updateRemoveButtons('performance-result-item', 'remove-result');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Remove Notes & Media
|
||||||
|
document.addEventListener('click', function(e) {
|
||||||
|
if (e.target.closest('.remove-note')) {
|
||||||
|
e.target.closest('.notes-media-item').remove();
|
||||||
|
updateRemoveButtons('notes-media-item', 'remove-note');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function updateRemoveButtons(itemClass, buttonClass) {
|
||||||
|
const items = document.querySelectorAll('.' + itemClass);
|
||||||
|
const buttons = document.querySelectorAll('.' + buttonClass);
|
||||||
|
|
||||||
|
if (items.length > 1) {
|
||||||
|
buttons.forEach(button => button.style.display = 'block');
|
||||||
|
} else {
|
||||||
|
buttons.forEach(button => button.style.display = 'none');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset modal when opened
|
||||||
|
document.getElementById('tournamentParticipationModal').addEventListener('show.bs.modal', function() {
|
||||||
|
// Reset form
|
||||||
|
document.getElementById('tournamentParticipationForm').reset();
|
||||||
|
|
||||||
|
// Reset dynamic content
|
||||||
|
const performanceContainer = document.getElementById('performanceResultsContainer');
|
||||||
|
const notesContainer = document.getElementById('notesMediaContainer');
|
||||||
|
|
||||||
|
// Keep only the first item in each container
|
||||||
|
const performanceItems = performanceContainer.querySelectorAll('.performance-result-item');
|
||||||
|
const notesItems = notesContainer.querySelectorAll('.notes-media-item');
|
||||||
|
|
||||||
|
for (let i = 1; i < performanceItems.length; i++) {
|
||||||
|
performanceItems[i].remove();
|
||||||
|
}
|
||||||
|
for (let i = 1; i < notesItems.length; i++) {
|
||||||
|
notesItems[i].remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset indices
|
||||||
|
performanceResultIndex = 1;
|
||||||
|
notesMediaIndex = 1;
|
||||||
|
|
||||||
|
// Hide remove buttons
|
||||||
|
document.querySelectorAll('.remove-result, .remove-note').forEach(button => {
|
||||||
|
button.style.display = 'none';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle form submission
|
||||||
|
document.getElementById('tournamentParticipationForm').addEventListener('submit', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const formData = new FormData(this);
|
||||||
|
|
||||||
|
fetch(this.action, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
headers: {
|
||||||
|
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute('content')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.success) {
|
||||||
|
// Close modal
|
||||||
|
const modal = bootstrap.Modal.getInstance(document.getElementById('tournamentParticipationModal'));
|
||||||
|
modal.hide();
|
||||||
|
|
||||||
|
// Show success message
|
||||||
|
showAlert('Tournament record added successfully!', 'success');
|
||||||
|
|
||||||
|
// Reload page to show new data
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.reload();
|
||||||
|
}, 1500);
|
||||||
|
} else {
|
||||||
|
showAlert('Error adding tournament record: ' + (data.message || 'Unknown error'), 'danger');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error:', error);
|
||||||
|
showAlert('Error adding tournament record. Please try again.', 'danger');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function showAlert(message, type) {
|
||||||
|
const alertDiv = document.createElement('div');
|
||||||
|
alertDiv.className = `alert alert-${type} alert-dismissible fade show position-fixed`;
|
||||||
|
alertDiv.style.cssText = 'top: 20px; right: 20px; z-index: 9999; min-width: 300px;';
|
||||||
|
alertDiv.innerHTML = `
|
||||||
|
${message}
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
|
`;
|
||||||
|
document.body.appendChild(alertDiv);
|
||||||
|
|
||||||
|
// Auto remove after 5 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
if (alertDiv.parentNode) {
|
||||||
|
alertDiv.remove();
|
||||||
|
}
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@endsection
|
@endsection
|
||||||
|
|||||||
@ -447,5 +447,8 @@
|
|||||||
@vite(['resources/js/app.js'])
|
@vite(['resources/js/app.js'])
|
||||||
|
|
||||||
@stack('scripts')
|
@stack('scripts')
|
||||||
|
|
||||||
|
<!-- Modals Stack (for cropper and other modals) -->
|
||||||
|
@stack('modals')
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
156
resources/views/vendor/takeone/components/widget.blade.php
vendored
Normal file
156
resources/views/vendor/takeone/components/widget.blade.php
vendored
Normal file
@ -0,0 +1,156 @@
|
|||||||
|
@php
|
||||||
|
$id = $attributes->get('id');
|
||||||
|
$width = $attributes->get('width', 300);
|
||||||
|
$height = $attributes->get('height', 300);
|
||||||
|
$shape = $attributes->get('shape', 'circle');
|
||||||
|
$folder = $attributes->get('folder', 'uploads');
|
||||||
|
$filename = $attributes->get('filename', 'cropped_' . time());
|
||||||
|
$uploadUrl = $attributes->get('uploadUrl', route('profile.upload-picture'));
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
@once
|
||||||
|
<link rel="stylesheet" href="https://unpkg.com/cropme@1.4.1/dist/cropme.min.css">
|
||||||
|
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
|
||||||
|
<script src="https://unpkg.com/cropme@1.4.1/dist/cropme.min.js"></script>
|
||||||
|
<style>
|
||||||
|
.modal-content-clean { border: none; border-radius: 15px; overflow: hidden; }
|
||||||
|
.cropme-wrapper { overflow: hidden !important; border-radius: 8px; }
|
||||||
|
.cropme-slider { display: none !important; }
|
||||||
|
.takeone-canvas {
|
||||||
|
height: 400px;
|
||||||
|
background: #111;
|
||||||
|
border-radius: 8px;
|
||||||
|
position: relative;
|
||||||
|
border: 1px solid #222;
|
||||||
|
}
|
||||||
|
.custom-slider-label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: bold;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #6c757d;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
.form-range::-webkit-slider-thumb { background: #198754; }
|
||||||
|
.form-range::-moz-range-thumb { background: #198754; }
|
||||||
|
</style>
|
||||||
|
@endonce
|
||||||
|
|
||||||
|
<button type="button" class="btn btn-success px-4 fw-bold shadow-sm" data-bs-toggle="modal" data-bs-target="#cropperModal_{{ $id }}">
|
||||||
|
Change Photo
|
||||||
|
</button>
|
||||||
|
|
||||||
|
@push('modals')
|
||||||
|
<div class="modal fade" id="cropperModal_{{ $id }}" tabindex="-1" aria-hidden="true" data-bs-backdrop="static">
|
||||||
|
<div class="modal-dialog modal-dialog-centered modal-lg">
|
||||||
|
<div class="modal-content modal-content-clean shadow-lg">
|
||||||
|
<div class="modal-body p-4 text-start">
|
||||||
|
<div class="mb-3 d-flex align-items-center">
|
||||||
|
<input type="file" id="input_{{ $id }}" class="form-control form-control-sm" accept="image/*">
|
||||||
|
<button type="button" class="btn-close ms-2" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="box_{{ $id }}" class="takeone-canvas"></div>
|
||||||
|
|
||||||
|
<div class="row mt-4">
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label class="custom-slider-label d-block mb-2">Zoom Level</label>
|
||||||
|
<input type="range" class="form-range" id="zoom_{{ $id }}" min="0" max="100" step="1" value="0">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label class="custom-slider-label d-block mb-2">Rotation</label>
|
||||||
|
<input type="range" class="form-range" id="rot_{{ $id }}" min="-180" max="180" step="1" value="0">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-grid gap-2 mt-2">
|
||||||
|
<button type="button" class="btn btn-success btn-lg fw-bold py-3" id="save_{{ $id }}">
|
||||||
|
Crop & Save Image
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
$(function() {
|
||||||
|
let cropper_{{ $id }} = null;
|
||||||
|
const el_{{ $id }} = document.getElementById("box_{{ $id }}");
|
||||||
|
const zoomMin_{{ $id }} = 0.01;
|
||||||
|
const zoomMax_{{ $id }} = 3;
|
||||||
|
|
||||||
|
function applyTransform_{{ $id }}(instance) {
|
||||||
|
if (!instance.properties.image) return;
|
||||||
|
const p = instance.properties;
|
||||||
|
const t = `translate3d(${p.x}px, ${p.y}px, 0) scale(${p.scale}) rotate(${p.deg}deg)`;
|
||||||
|
p.image.style.transform = t;
|
||||||
|
}
|
||||||
|
|
||||||
|
$('#input_{{ $id }}').on('change', function() {
|
||||||
|
if (this.files && this.files[0]) {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = function(event) {
|
||||||
|
if (cropper_{{ $id }}) cropper_{{ $id }}.destroy();
|
||||||
|
|
||||||
|
cropper_{{ $id }} = new Cropme(el_{{ $id }}, {
|
||||||
|
container: { width: '100%', height: 400 },
|
||||||
|
viewport: {
|
||||||
|
width: {{ $width }},
|
||||||
|
height: {{ $height }},
|
||||||
|
type: '{{ $shape }}',
|
||||||
|
border: { enable: true, width: 2, color: '#fff' }
|
||||||
|
},
|
||||||
|
transformOrigin: 'viewport',
|
||||||
|
zoom: { min: zoomMin_{{ $id }}, max: zoomMax_{{ $id }}, enable: true, mouseWheel: true, slider: false },
|
||||||
|
rotation: { enable: true, slider: false }
|
||||||
|
});
|
||||||
|
|
||||||
|
cropper_{{ $id }}.bind({ url: event.target.result }).then(() => {
|
||||||
|
$('#zoom_{{ $id }}').val(0);
|
||||||
|
$('#rot_{{ $id }}').val(0);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(this.files[0]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$('#zoom_{{ $id }}').on('input', function() {
|
||||||
|
if (!cropper_{{ $id }} || !cropper_{{ $id }}.properties.image) return;
|
||||||
|
const p = parseFloat($(this).val());
|
||||||
|
const scale = zoomMin_{{ $id }} + (zoomMax_{{ $id }} - zoomMin_{{ $id }}) * (p / 100);
|
||||||
|
cropper_{{ $id }}.properties.scale = Math.min(Math.max(scale, zoomMin_{{ $id }}), zoomMax_{{ $id }});
|
||||||
|
applyTransform_{{ $id }}(cropper_{{ $id }});
|
||||||
|
});
|
||||||
|
|
||||||
|
$('#rot_{{ $id }}').on('input', function() {
|
||||||
|
if (cropper_{{ $id }}) {
|
||||||
|
cropper_{{ $id }}.rotate(parseInt($(this).val(), 10));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$('#save_{{ $id }}').on('click', function() {
|
||||||
|
if (!cropper_{{ $id }}) return;
|
||||||
|
const btn = $(this);
|
||||||
|
btn.prop('disabled', true).text('Uploading...');
|
||||||
|
|
||||||
|
cropper_{{ $id }}.crop({ type: 'base64' }).then(base64 => {
|
||||||
|
$.post("{{ $uploadUrl }}", {
|
||||||
|
_token: "{{ csrf_token() }}",
|
||||||
|
image: base64,
|
||||||
|
folder: '{{ $folder }}',
|
||||||
|
filename: '{{ $filename }}'
|
||||||
|
}).done((res) => {
|
||||||
|
alert('Saved successfully!');
|
||||||
|
$('#cropperModal_{{ $id }}').modal('hide');
|
||||||
|
// Reload page to show new image
|
||||||
|
location.reload();
|
||||||
|
}).fail((err) => {
|
||||||
|
alert('Upload failed: ' + (err.responseJSON?.message || 'Unknown error'));
|
||||||
|
}).always(() => {
|
||||||
|
btn.prop('disabled', false).text('Crop & Save Image');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
@endpush
|
||||||
@ -95,6 +95,7 @@ Route::middleware(['auth', 'verified'])->group(function () {
|
|||||||
Route::post('/family/{id}/health', [FamilyController::class, 'storeHealth'])->name('family.store-health');
|
Route::post('/family/{id}/health', [FamilyController::class, 'storeHealth'])->name('family.store-health');
|
||||||
Route::put('/family/{id}/health/{recordId}', [FamilyController::class, 'updateHealth'])->name('family.update-health');
|
Route::put('/family/{id}/health/{recordId}', [FamilyController::class, 'updateHealth'])->name('family.update-health');
|
||||||
Route::put('/family/goal/{goalId}', [FamilyController::class, 'updateGoal'])->name('family.update-goal');
|
Route::put('/family/goal/{goalId}', [FamilyController::class, 'updateGoal'])->name('family.update-goal');
|
||||||
|
Route::post('/family/{id}/tournament', [FamilyController::class, 'storeTournament'])->name('family.store-tournament');
|
||||||
Route::post('/family/{id}/upload-picture', [FamilyController::class, 'uploadFamilyMemberPicture'])->name('family.upload-picture');
|
Route::post('/family/{id}/upload-picture', [FamilyController::class, 'uploadFamilyMemberPicture'])->name('family.upload-picture');
|
||||||
Route::delete('/family/{id}', [FamilyController::class, 'destroy'])->name('family.destroy');
|
Route::delete('/family/{id}', [FamilyController::class, 'destroy'])->name('family.destroy');
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user