mirror of
https://github.com/NNTmux/newznab-tmux.git
synced 2026-08-29 01:01:24 +00:00
Add admin pages to manage invites
This commit is contained in:
@@ -0,0 +1,273 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\BasePageController;
|
||||
use App\Models\Invitation;
|
||||
use App\Models\User;
|
||||
use App\Services\InvitationService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
|
||||
class AdminInvitationController extends BasePageController
|
||||
{
|
||||
protected InvitationService $invitationService;
|
||||
|
||||
public function __construct(InvitationService $invitationService)
|
||||
{
|
||||
parent::__construct();
|
||||
$this->invitationService = $invitationService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Display all invitations statistics and management page
|
||||
*/
|
||||
public function index(Request $request): void
|
||||
{
|
||||
$this->setAdminPrefs();
|
||||
|
||||
$meta_title = $title = 'Invitation Management';
|
||||
|
||||
// Get filter parameters
|
||||
$status = $request->get('status', '');
|
||||
$invited_by = $request->get('invited_by', '');
|
||||
$email = $request->get('email', '');
|
||||
$orderBy = $request->get('ob', 'created_at_desc');
|
||||
$page = $request->get('page', 1);
|
||||
|
||||
// Build query
|
||||
$query = Invitation::with(['invitedBy', 'usedBy']);
|
||||
|
||||
// Apply filters
|
||||
if ($status) {
|
||||
switch ($status) {
|
||||
case 'pending':
|
||||
$query->valid(); // Active, not expired, not used
|
||||
break;
|
||||
case 'used':
|
||||
$query->used(); // Has used_at timestamp
|
||||
break;
|
||||
case 'expired':
|
||||
$query->expired(); // expires_at is in the past
|
||||
break;
|
||||
case 'cancelled':
|
||||
$query->where('is_active', false)
|
||||
->whereNull('used_at'); // Inactive but not used = cancelled
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($invited_by) {
|
||||
$user = User::where('username', 'like', '%' . $invited_by . '%')->first();
|
||||
if ($user) {
|
||||
$query->where('invited_by', $user->id);
|
||||
}
|
||||
}
|
||||
|
||||
if ($email) {
|
||||
$query->where('email', 'like', '%' . $email . '%');
|
||||
}
|
||||
|
||||
// Apply ordering
|
||||
switch ($orderBy) {
|
||||
case 'created_at_asc':
|
||||
$query->orderBy('created_at', 'asc');
|
||||
break;
|
||||
case 'created_at_desc':
|
||||
$query->orderBy('created_at', 'desc');
|
||||
break;
|
||||
case 'expires_at_asc':
|
||||
$query->orderBy('expires_at', 'asc');
|
||||
break;
|
||||
case 'expires_at_desc':
|
||||
$query->orderBy('expires_at', 'desc');
|
||||
break;
|
||||
case 'email_asc':
|
||||
$query->orderBy('email', 'asc');
|
||||
break;
|
||||
case 'email_desc':
|
||||
$query->orderBy('email', 'desc');
|
||||
break;
|
||||
default:
|
||||
$query->orderBy('created_at', 'desc');
|
||||
}
|
||||
|
||||
// Paginate results
|
||||
$invitations = $query->paginate(config('nntmux.items_per_page', 25));
|
||||
|
||||
// Get overall statistics
|
||||
$stats = $this->getOverallStats();
|
||||
|
||||
// Get user statistics (top inviters)
|
||||
$topInviters = $this->getTopInviters();
|
||||
|
||||
$this->smarty->assign([
|
||||
'invitations' => $invitations,
|
||||
'stats' => $stats,
|
||||
'topInviters' => $topInviters,
|
||||
'status' => $status,
|
||||
'invited_by' => $invited_by,
|
||||
'email' => $email,
|
||||
'orderBy' => $orderBy,
|
||||
'statusOptions' => [
|
||||
'' => 'All',
|
||||
'pending' => 'Pending',
|
||||
'used' => 'Used',
|
||||
'expired' => 'Expired',
|
||||
'cancelled' => 'Cancelled'
|
||||
]
|
||||
]);
|
||||
|
||||
$content = $this->smarty->fetch('admin-invitation-list.tpl');
|
||||
$this->smarty->assign(compact('title', 'meta_title', 'content'));
|
||||
|
||||
$this->adminrender();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get overall invitation statistics
|
||||
*/
|
||||
private function getOverallStats(): array
|
||||
{
|
||||
return [
|
||||
'total' => Invitation::count(),
|
||||
'pending' => Invitation::valid()->count(),
|
||||
'used' => Invitation::used()->count(),
|
||||
'expired' => Invitation::expired()->count(),
|
||||
'cancelled' => Invitation::where('is_active', false)->whereNull('used_at')->count(),
|
||||
'today' => Invitation::whereDate('created_at', today())->count(),
|
||||
'this_week' => Invitation::whereBetween('created_at', [
|
||||
now()->startOfWeek(),
|
||||
now()->endOfWeek()
|
||||
])->count(),
|
||||
'this_month' => Invitation::whereMonth('created_at', now()->month)
|
||||
->whereYear('created_at', now()->year)
|
||||
->count(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get top inviters statistics
|
||||
*/
|
||||
private function getTopInviters(int $limit = 10): array
|
||||
{
|
||||
return User::select('users.*')
|
||||
->selectRaw('COUNT(invitations.id) as total_invitations')
|
||||
->selectRaw('COUNT(CASE WHEN invitations.used_at IS NOT NULL THEN 1 END) as successful_invitations')
|
||||
->leftJoin('invitations', 'users.id', '=', 'invitations.invited_by')
|
||||
->groupBy('users.id')
|
||||
->having('total_invitations', '>', 0)
|
||||
->orderBy('total_invitations', 'desc')
|
||||
->limit($limit)
|
||||
->get()
|
||||
->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel an invitation
|
||||
*/
|
||||
public function cancel(Request $request): RedirectResponse
|
||||
{
|
||||
try {
|
||||
$id = $request->route('id');
|
||||
$this->invitationService->cancelInvitation($id);
|
||||
|
||||
return redirect()->back()->with('success', 'Invitation cancelled successfully');
|
||||
} catch (\Exception $e) {
|
||||
return redirect()->back()->with('error', 'Failed to cancel invitation: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resend an invitation
|
||||
*/
|
||||
public function resend(Request $request): RedirectResponse
|
||||
{
|
||||
try {
|
||||
$id = $request->route('id');
|
||||
$this->invitationService->resendInvitation($id);
|
||||
|
||||
return redirect()->back()->with('success', 'Invitation resent successfully');
|
||||
} catch (\Exception $e) {
|
||||
return redirect()->back()->with('error', 'Failed to resend invitation: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup expired invitations
|
||||
*/
|
||||
public function cleanup(Request $request): RedirectResponse
|
||||
{
|
||||
try {
|
||||
$cleanedCount = $this->invitationService->cleanupExpiredInvitations();
|
||||
|
||||
return redirect()->back()->with('success', "Cleaned up {$cleanedCount} expired invitations");
|
||||
} catch (\Exception $e) {
|
||||
return redirect()->back()->with('error', 'Failed to cleanup invitations: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* View detailed invitation
|
||||
*/
|
||||
public function show(Request $request): void
|
||||
{
|
||||
$this->setAdminPrefs();
|
||||
|
||||
$id = $request->route('id');
|
||||
$invitation = Invitation::with(['invitedBy', 'usedBy'])->findOrFail($id);
|
||||
|
||||
$meta_title = $title = 'Invitation Details';
|
||||
|
||||
$this->smarty->assign([
|
||||
'invitation' => $invitation,
|
||||
]);
|
||||
|
||||
$content = $this->smarty->fetch('admin-invitation-show.tpl');
|
||||
$this->smarty->assign(compact('title', 'meta_title', 'content'));
|
||||
|
||||
$this->adminrender();
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk actions on invitations
|
||||
*/
|
||||
public function bulkAction(Request $request): RedirectResponse
|
||||
{
|
||||
$action = $request->get('bulk_action');
|
||||
$invitationIds = $request->get('invitation_ids', []);
|
||||
|
||||
if (empty($invitationIds)) {
|
||||
return redirect()->back()->with('error', 'No invitations selected');
|
||||
}
|
||||
|
||||
try {
|
||||
$count = 0;
|
||||
switch ($action) {
|
||||
case 'cancel':
|
||||
foreach ($invitationIds as $id) {
|
||||
$this->invitationService->cancelInvitation($id);
|
||||
$count++;
|
||||
}
|
||||
return redirect()->back()->with('success', "Cancelled {$count} invitations");
|
||||
|
||||
case 'resend':
|
||||
foreach ($invitationIds as $id) {
|
||||
$this->invitationService->resendInvitation($id);
|
||||
$count++;
|
||||
}
|
||||
return redirect()->back()->with('success', "Resent {$count} invitations");
|
||||
|
||||
case 'delete':
|
||||
Invitation::whereIn('id', $invitationIds)->delete();
|
||||
$count = count($invitationIds);
|
||||
return redirect()->back()->with('success', "Deleted {$count} invitations");
|
||||
|
||||
default:
|
||||
return redirect()->back()->with('error', 'Invalid bulk action');
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
return redirect()->back()->with('error', 'Bulk action failed: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -225,7 +225,7 @@ class User extends Authenticatable
|
||||
|
||||
public function invitation(): HasMany
|
||||
{
|
||||
return $this->hasMany(Invitation::class, 'users_id');
|
||||
return $this->hasMany(Invitation::class, 'invited_by');
|
||||
}
|
||||
|
||||
public function failedRelease(): HasMany
|
||||
|
||||
@@ -0,0 +1,443 @@
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<h4 class="mb-0">{$title}</h4>
|
||||
<div class="d-flex gap-2">
|
||||
<form method="POST" action="{{url("/admin/invitations/cleanup")}}" style="display: inline;">
|
||||
{{csrf_field()}}
|
||||
<button type="submit" class="btn btn-outline-warning btn-sm confirm_action"
|
||||
data-message="Are you sure you want to cleanup all expired invitations?">
|
||||
<i class="fa fa-broom me-1"></i>Cleanup Expired
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-body">
|
||||
<!-- Statistics Cards -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-lg-3 col-md-6 mb-3">
|
||||
<div class="card bg-primary text-white">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between">
|
||||
<div>
|
||||
<h4 class="mb-0">{$stats.total}</h4>
|
||||
<p class="mb-0">Total Invitations</p>
|
||||
</div>
|
||||
<div class="align-self-center">
|
||||
<i class="fa fa-envelope fa-2x"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-3 col-md-6 mb-3">
|
||||
<div class="card bg-warning text-white">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between">
|
||||
<div>
|
||||
<h4 class="mb-0">{$stats.pending}</h4>
|
||||
<p class="mb-0">Pending</p>
|
||||
</div>
|
||||
<div class="align-self-center">
|
||||
<i class="fa fa-clock-o fa-2x"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-3 col-md-6 mb-3">
|
||||
<div class="card bg-success text-white">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between">
|
||||
<div>
|
||||
<h4 class="mb-0">{$stats.used}</h4>
|
||||
<p class="mb-0">Used</p>
|
||||
</div>
|
||||
<div class="align-self-center">
|
||||
<i class="fa fa-check fa-2x"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-3 col-md-6 mb-3">
|
||||
<div class="card bg-danger text-white">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between">
|
||||
<div>
|
||||
<h4 class="mb-0">{$stats.expired}</h4>
|
||||
<p class="mb-0">Expired</p>
|
||||
</div>
|
||||
<div class="align-self-center">
|
||||
<i class="fa fa-times fa-2x"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Time-based Statistics -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-4">
|
||||
<div class="card border-info">
|
||||
<div class="card-body text-center">
|
||||
<h5 class="card-title text-info">Today</h5>
|
||||
<h3>{$stats.today}</h3>
|
||||
<small class="text-muted">Invitations sent today</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="card border-info">
|
||||
<div class="card-body text-center">
|
||||
<h5 class="card-title text-info">This Week</h5>
|
||||
<h3>{$stats.this_week}</h3>
|
||||
<small class="text-muted">Invitations sent this week</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="card border-info">
|
||||
<div class="card-body text-center">
|
||||
<h5 class="card-title text-info">This Month</h5>
|
||||
<h3>{$stats.this_month}</h3>
|
||||
<small class="text-muted">Invitations sent this month</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Top Inviters -->
|
||||
{if $topInviters|@count > 0}
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0"><i class="fa fa-trophy me-2"></i>Top Inviters</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>User</th>
|
||||
<th>Total Sent</th>
|
||||
<th>Successful</th>
|
||||
<th>Success Rate</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{foreach $topInviters as $inviter}
|
||||
<tr>
|
||||
<td>
|
||||
<a href="{{url("/admin/user-edit?id={$inviter.id}")}}" class="text-decoration-none">
|
||||
{$inviter.username}
|
||||
</a>
|
||||
</td>
|
||||
<td>{$inviter.total_invitations}</td>
|
||||
<td>{$inviter.successful_invitations}</td>
|
||||
<td>
|
||||
{if $inviter.total_invitations > 0}
|
||||
{math equation="round((x/y)*100, 1)" x=$inviter.successful_invitations y=$inviter.total_invitations}%
|
||||
{else}
|
||||
0%
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
{/foreach}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Filters -->
|
||||
<form method="GET" action="{{url("/admin/invitations")}}" class="mb-4">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0"><i class="fa fa-filter me-2"></i>Filters</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-3">
|
||||
<label for="status" class="form-label">Status</label>
|
||||
<select name="status" id="status" class="form-select">
|
||||
{html_options options=$statusOptions selected=$status}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label for="invited_by" class="form-label">Invited By</label>
|
||||
<input type="text" name="invited_by" id="invited_by" value="{$invited_by}"
|
||||
class="form-control" placeholder="Username">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label for="email" class="form-label">Email</label>
|
||||
<input type="text" name="email" id="email" value="{$email}"
|
||||
class="form-control" placeholder="Email address">
|
||||
</div>
|
||||
<div class="col-md-3 d-flex align-items-end">
|
||||
<button type="submit" class="btn btn-primary me-2">
|
||||
<i class="fa fa-search me-1"></i>Filter
|
||||
</button>
|
||||
<a href="{{url("/admin/invitations")}}" class="btn btn-outline-secondary">
|
||||
<i class="fa fa-times me-1"></i>Clear
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Bulk Actions -->
|
||||
<form method="POST" action="{{url("/admin/invitations/bulk")}}" id="bulkForm">
|
||||
{{csrf_field()}}
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<select name="bulk_action" class="form-select" style="width: auto;">
|
||||
<option value="">Bulk Actions</option>
|
||||
<option value="cancel">Cancel Selected</option>
|
||||
<option value="resend">Resend Selected</option>
|
||||
<option value="delete">Delete Selected</option>
|
||||
</select>
|
||||
<button type="submit" class="btn btn-outline-primary btn-sm" id="bulkSubmit" disabled>
|
||||
Apply
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<small class="text-muted">
|
||||
Showing {$invitations->firstItem()} to {$invitations->lastItem()} of {$invitations->total()} invitations
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Invitations Table -->
|
||||
{if $invitations->count() > 0}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-hover align-middle">
|
||||
<thead class="thead-light">
|
||||
<tr>
|
||||
<th style="width: 30px;">
|
||||
<input type="checkbox" id="selectAll" class="form-check-input">
|
||||
</th>
|
||||
<th>Email</th>
|
||||
<th>Invited By</th>
|
||||
<th>Status</th>
|
||||
<th>Created</th>
|
||||
<th>Expires</th>
|
||||
<th>Used By</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{foreach $invitations as $invitation}
|
||||
<tr>
|
||||
<td>
|
||||
<input type="checkbox" name="invitation_ids[]" value="{$invitation->id}"
|
||||
class="form-check-input invitation-checkbox">
|
||||
</td>
|
||||
<td>
|
||||
<div class="d-flex align-items-center">
|
||||
<i class="fa fa-envelope text-muted me-2"></i>
|
||||
<span>{$invitation->email}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
{if $invitation->invitedBy}
|
||||
<a href="{{url("/admin/user-edit?id={$invitation->invitedBy->id}")}}"
|
||||
class="text-decoration-none">
|
||||
{$invitation->invitedBy->username}
|
||||
</a>
|
||||
{else}
|
||||
<span class="text-muted">Unknown</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td>
|
||||
{if $invitation->used_at}
|
||||
<span class="badge bg-success">Used</span>
|
||||
{elseif $invitation->expires_at|strtotime < $smarty.now}
|
||||
<span class="badge bg-danger">Expired</span>
|
||||
{elseif !$invitation->is_active}
|
||||
<span class="badge bg-secondary">Cancelled</span>
|
||||
{else}
|
||||
<span class="badge bg-warning text-dark">Pending</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td>
|
||||
<small title="{$invitation->created_at}">
|
||||
{$invitation->created_at|date_format:"%m/%d/%Y %H:%M"}
|
||||
</small>
|
||||
</td>
|
||||
<td>
|
||||
<small title="{$invitation->expires_at}"
|
||||
class="{if $invitation->expires_at|strtotime < $smarty.now}text-danger{/if}">
|
||||
{$invitation->expires_at|date_format:"%m/%d/%Y %H:%M"}
|
||||
</small>
|
||||
</td>
|
||||
<td>
|
||||
{if $invitation->usedBy}
|
||||
<a href="{{url("/admin/user-edit?id={$invitation->usedBy->id}")}}"
|
||||
class="text-decoration-none">
|
||||
{$invitation->usedBy->username}
|
||||
</a>
|
||||
<br><small class="text-muted">{$invitation->used_at|date_format:"%m/%d/%Y %H:%M"}</small>
|
||||
{else}
|
||||
<span class="text-muted">-</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td>
|
||||
<div class="btn-group btn-group-sm">
|
||||
<a href="{{url("/admin/invitations/{$invitation->id}")}}"
|
||||
class="btn btn-outline-info btn-sm" title="View Details">
|
||||
<i class="fa fa-eye"></i>
|
||||
</a>
|
||||
{if !$invitation->used_at && $invitation->expires_at|strtotime >= $smarty.now && $invitation->is_active}
|
||||
<form method="POST" action="{{url("/admin/invitations/{$invitation->id}/resend")}}" style="display: inline;">
|
||||
{{csrf_field()}}
|
||||
<button type="submit" class="btn btn-outline-warning btn-sm" title="Resend">
|
||||
<i class="fa fa-repeat"></i>
|
||||
</button>
|
||||
</form>
|
||||
<form method="POST" action="{{url("/admin/invitations/{$invitation->id}/cancel")}}" style="display: inline;">
|
||||
{{csrf_field()}}
|
||||
<button type="submit" class="btn btn-outline-danger btn-sm confirm_action"
|
||||
title="Cancel" data-message="Are you sure you want to cancel this invitation?">
|
||||
<i class="fa fa-times"></i>
|
||||
</button>
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{/foreach}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Pagination -->
|
||||
{if $invitations->hasPages()}
|
||||
<div class="d-flex justify-content-center mt-4">
|
||||
{$invitations->onEachSide(5)->links()}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{else}
|
||||
<div class="alert alert-info">
|
||||
<i class="fa fa-info-circle me-2"></i>No invitations found matching your criteria.
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
{literal}
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Select all checkbox functionality
|
||||
const selectAllCheckbox = document.getElementById('selectAll');
|
||||
const invitationCheckboxes = document.querySelectorAll('.invitation-checkbox');
|
||||
const bulkSubmit = document.getElementById('bulkSubmit');
|
||||
const bulkAction = document.querySelector('select[name="bulk_action"]');
|
||||
|
||||
function updateBulkSubmitState() {
|
||||
const selectedCheckboxes = document.querySelectorAll('.invitation-checkbox:checked');
|
||||
const hasSelection = selectedCheckboxes.length > 0;
|
||||
const hasAction = bulkAction.value !== '';
|
||||
|
||||
bulkSubmit.disabled = !(hasSelection && hasAction);
|
||||
}
|
||||
|
||||
selectAllCheckbox.addEventListener('change', function() {
|
||||
invitationCheckboxes.forEach(checkbox => {
|
||||
checkbox.checked = this.checked;
|
||||
});
|
||||
updateBulkSubmitState();
|
||||
});
|
||||
|
||||
invitationCheckboxes.forEach(checkbox => {
|
||||
checkbox.addEventListener('change', function() {
|
||||
const checkedCount = document.querySelectorAll('.invitation-checkbox:checked').length;
|
||||
selectAllCheckbox.checked = checkedCount === invitationCheckboxes.length;
|
||||
selectAllCheckbox.indeterminate = checkedCount > 0 && checkedCount < invitationCheckboxes.length;
|
||||
updateBulkSubmitState();
|
||||
});
|
||||
});
|
||||
|
||||
bulkAction.addEventListener('change', updateBulkSubmitState);
|
||||
|
||||
// Confirmation dialogs
|
||||
document.querySelectorAll('.confirm_action').forEach(function(element) {
|
||||
element.addEventListener('click', function(e) {
|
||||
const message = this.getAttribute('data-message') || 'Are you sure you want to perform this action?';
|
||||
if (!confirm(message)) {
|
||||
e.preventDefault();
|
||||
return false;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Bulk form confirmation
|
||||
document.getElementById('bulkForm').addEventListener('submit', function(e) {
|
||||
const selectedCount = document.querySelectorAll('.invitation-checkbox:checked').length;
|
||||
const action = bulkAction.value;
|
||||
|
||||
if (selectedCount === 0) {
|
||||
e.preventDefault();
|
||||
alert('Please select at least one invitation.');
|
||||
return false;
|
||||
}
|
||||
|
||||
let message = `Are you sure you want to ${action} ${selectedCount} invitation(s)?`;
|
||||
if (!confirm(message)) {
|
||||
e.preventDefault();
|
||||
return false;
|
||||
}
|
||||
});
|
||||
});
|
||||
{/literal}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
{literal}
|
||||
.card {
|
||||
box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
|
||||
.table th {
|
||||
border-top: none;
|
||||
font-weight: 600;
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
.btn-group-sm > .btn {
|
||||
padding: 0.25rem 0.5rem;
|
||||
}
|
||||
|
||||
.badge {
|
||||
font-size: 0.75em;
|
||||
}
|
||||
|
||||
/* Custom checkbox styling */
|
||||
.form-check-input:checked {
|
||||
background-color: #0d6efd;
|
||||
border-color: #0d6efd;
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 767.98px) {
|
||||
.table-responsive {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.btn-group-sm > .btn {
|
||||
padding: 0.125rem 0.25rem;
|
||||
}
|
||||
}
|
||||
{/literal}
|
||||
</style>
|
||||
@@ -0,0 +1,319 @@
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<h4 class="mb-0">{$title}</h4>
|
||||
<a href="{{url("/admin/invitations")}}" class="btn btn-outline-secondary">
|
||||
<i class="fa fa-arrow-left me-2"></i>Back to Invitations
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<!-- Invitation Details -->
|
||||
<div class="col-lg-8">
|
||||
<div class="card mb-4">
|
||||
<div class="card-header bg-light">
|
||||
<h5 class="mb-0"><i class="fa fa-envelope me-2"></i>Invitation Details</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<dl class="row">
|
||||
<dt class="col-sm-3">Email:</dt>
|
||||
<dd class="col-sm-9">
|
||||
<i class="fa fa-envelope text-muted me-2"></i>
|
||||
{$invitation->email}
|
||||
</dd>
|
||||
|
||||
<dt class="col-sm-3">Token:</dt>
|
||||
<dd class="col-sm-9">
|
||||
<code class="bg-light p-2 rounded">{$invitation->token}</code>
|
||||
<button class="btn btn-sm btn-outline-secondary ms-2" onclick="copyToClipboard('{$invitation->token}')">
|
||||
<i class="fa fa-copy"></i>
|
||||
</button>
|
||||
</dd>
|
||||
|
||||
<dt class="col-sm-3">Invitation URL:</dt>
|
||||
<dd class="col-sm-9">
|
||||
{assign var="inviteUrl" value="{url("/invitation/{$invitation->token}")}"}
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control" value="{$inviteUrl}" readonly id="inviteUrl">
|
||||
<button class="btn btn-outline-secondary" type="button" onclick="copyToClipboard('{$inviteUrl}')">
|
||||
<i class="fa fa-copy"></i>
|
||||
</button>
|
||||
</div>
|
||||
</dd>
|
||||
|
||||
<dt class="col-sm-3">Status:</dt>
|
||||
<dd class="col-sm-9">
|
||||
{if $invitation->used_at}
|
||||
<span class="badge bg-success fs-6">
|
||||
<i class="fa fa-check me-1"></i>Used
|
||||
</span>
|
||||
{elseif $invitation->expires_at|strtotime < $smarty.now}
|
||||
<span class="badge bg-danger fs-6">
|
||||
<i class="fa fa-times me-1"></i>Expired
|
||||
</span>
|
||||
{elseif !$invitation->is_active}
|
||||
<span class="badge bg-secondary fs-6">
|
||||
<i class="fa fa-ban me-1"></i>Cancelled
|
||||
</span>
|
||||
{else}
|
||||
<span class="badge bg-warning text-dark fs-6">
|
||||
<i class="fa fa-clock-o me-1"></i>Pending
|
||||
</span>
|
||||
{/if}
|
||||
</dd>
|
||||
|
||||
<dt class="col-sm-3">Created:</dt>
|
||||
<dd class="col-sm-9">
|
||||
<i class="fa fa-calendar text-muted me-2"></i>
|
||||
{$invitation->created_at|date_format:"%B %d, %Y at %H:%M"}
|
||||
</dd>
|
||||
|
||||
<dt class="col-sm-3">Expires:</dt>
|
||||
<dd class="col-sm-9">
|
||||
<i class="fa fa-calendar-times-o text-muted me-2"></i>
|
||||
<span class="{if $invitation->expires_at|strtotime < $smarty.now}text-danger{/if}">
|
||||
{$invitation->expires_at|date_format:"%B %d, %Y at %H:%M"}
|
||||
</span>
|
||||
{if $invitation->expires_at|strtotime < $smarty.now}
|
||||
<small class="text-danger">(Expired)</small>
|
||||
{/if}
|
||||
</dd>
|
||||
|
||||
{if $invitation->used_at}
|
||||
<dt class="col-sm-3">Used:</dt>
|
||||
<dd class="col-sm-9">
|
||||
<i class="fa fa-check-circle text-success me-2"></i>
|
||||
{$invitation->used_at|date_format:"%B %d, %Y at %H:%M"}
|
||||
</dd>
|
||||
{/if}
|
||||
|
||||
{if $invitation->metadata && $invitation->metadata != '[]'}
|
||||
<dt class="col-sm-3">Metadata:</dt>
|
||||
<dd class="col-sm-9">
|
||||
<pre class="bg-light p-2 rounded">{$invitation->metadata|json_encode:JSON_PRETTY_PRINT}</pre>
|
||||
</dd>
|
||||
{/if}
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- User Information -->
|
||||
<div class="col-lg-4">
|
||||
<!-- Invited By -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header bg-light">
|
||||
<h5 class="mb-0"><i class="fa fa-user me-2"></i>Invited By</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{if $invitation->invitedBy}
|
||||
<div class="d-flex align-items-center mb-3">
|
||||
<div class="me-3">
|
||||
<i class="fa fa-user-circle fa-3x text-muted"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h6 class="mb-1">
|
||||
<a href="{{url("/admin/user-edit?id={$invitation->invitedBy->id}")}}"
|
||||
class="text-decoration-none">
|
||||
{$invitation->invitedBy->username}
|
||||
</a>
|
||||
</h6>
|
||||
<small class="text-muted">{$invitation->invitedBy->email}</small>
|
||||
<br>
|
||||
<small class="text-muted">
|
||||
Role: <span class="badge bg-secondary">{$invitation->invitedBy->role->name}</span>
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row text-center">
|
||||
<div class="col-6">
|
||||
<div class="border-end">
|
||||
<strong class="d-block">{$invitation->invitedBy->invites}</strong>
|
||||
<small class="text-muted">Remaining Invites</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<strong class="d-block">{$invitation->invitedBy->invitation|@count}</strong>
|
||||
<small class="text-muted">Total Sent</small>
|
||||
</div>
|
||||
</div>
|
||||
{else}
|
||||
<div class="text-center py-3">
|
||||
<i class="fa fa-user-times fa-3x text-muted mb-2"></i>
|
||||
<p class="text-muted mb-0">Inviter information not available</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Used By -->
|
||||
{if $invitation->usedBy}
|
||||
<div class="card mb-4">
|
||||
<div class="card-header bg-light">
|
||||
<h5 class="mb-0"><i class="fa fa-user-check me-2"></i>Used By</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="d-flex align-items-center mb-3">
|
||||
<div class="me-3">
|
||||
<i class="fa fa-user-circle fa-3x text-success"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h6 class="mb-1">
|
||||
<a href="{{url("/admin/user-edit?id={$invitation->usedBy->id}")}}"
|
||||
class="text-decoration-none">
|
||||
{$invitation->usedBy->username}
|
||||
</a>
|
||||
</h6>
|
||||
<small class="text-muted">{$invitation->usedBy->email}</small>
|
||||
<br>
|
||||
<small class="text-muted">
|
||||
Role: <span class="badge bg-secondary">{$invitation->usedBy->role->name}</span>
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row text-center">
|
||||
<div class="col-6">
|
||||
<div class="border-end">
|
||||
<strong class="d-block">{$invitation->usedBy->grabs}</strong>
|
||||
<small class="text-muted">Grabs</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<strong class="d-block">
|
||||
{if $invitation->usedBy->created_at}
|
||||
{$invitation->usedBy->created_at|date_format:"%m/%d/%Y"}
|
||||
{else}
|
||||
-
|
||||
{/if}
|
||||
</strong>
|
||||
<small class="text-muted">Joined</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="card">
|
||||
<div class="card-header bg-light">
|
||||
<h5 class="mb-0"><i class="fa fa-cogs me-2"></i>Actions</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="d-grid gap-2">
|
||||
{if !$invitation->used_at && $invitation->expires_at|strtotime >= $smarty.now && $invitation->is_active}
|
||||
<form method="POST" action="{{url("/admin/invitations/{$invitation->id}/resend")}}">
|
||||
{{csrf_field()}}
|
||||
<button type="submit" class="btn btn-warning w-100">
|
||||
<i class="fa fa-repeat me-2"></i>Resend Invitation
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<form method="POST" action="{{url("/admin/invitations/{$invitation->id}/cancel")}}">
|
||||
{{csrf_field()}}
|
||||
<button type="submit" class="btn btn-danger w-100 confirm_action"
|
||||
data-message="Are you sure you want to cancel this invitation?">
|
||||
<i class="fa fa-times me-2"></i>Cancel Invitation
|
||||
</button>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
<a href="{{url("/invitation/{$invitation->token}")}}"
|
||||
class="btn btn-outline-info w-100" target="_blank">
|
||||
<i class="fa fa-external-link me-2"></i>View Public Page
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
{literal}
|
||||
function copyToClipboard(text) {
|
||||
navigator.clipboard.writeText(text).then(function() {
|
||||
// Show success feedback
|
||||
const toast = document.createElement('div');
|
||||
toast.className = 'toast align-items-center text-white bg-success border-0 position-fixed';
|
||||
toast.style.cssText = 'top: 20px; right: 20px; z-index: 9999;';
|
||||
toast.setAttribute('role', 'alert');
|
||||
toast.innerHTML = `
|
||||
<div class="d-flex">
|
||||
<div class="toast-body">
|
||||
<i class="fa fa-check me-2"></i>Copied to clipboard!
|
||||
</div>
|
||||
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(toast);
|
||||
const bsToast = new bootstrap.Toast(toast);
|
||||
bsToast.show();
|
||||
|
||||
toast.addEventListener('hidden.bs.toast', function() {
|
||||
document.body.removeChild(toast);
|
||||
});
|
||||
}).catch(function(err) {
|
||||
console.error('Failed to copy text: ', err);
|
||||
alert('Failed to copy to clipboard');
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Confirmation dialogs
|
||||
document.querySelectorAll('.confirm_action').forEach(function(element) {
|
||||
element.addEventListener('click', function(e) {
|
||||
const message = this.getAttribute('data-message') || 'Are you sure you want to perform this action?';
|
||||
if (!confirm(message)) {
|
||||
e.preventDefault();
|
||||
return false;
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
{/literal}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
{literal}
|
||||
.card {
|
||||
box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
|
||||
.badge.fs-6 {
|
||||
font-size: 0.9rem !important;
|
||||
}
|
||||
|
||||
.border-end {
|
||||
border-right: 1px solid #dee2e6 !important;
|
||||
}
|
||||
|
||||
code {
|
||||
font-size: 0.875em;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
pre {
|
||||
font-size: 0.8rem;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 767.98px) {
|
||||
.row.text-center .col-6 {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.border-end {
|
||||
border-right: none !important;
|
||||
border-bottom: 1px solid #dee2e6 !important;
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
}
|
||||
{/literal}
|
||||
</style>
|
||||
@@ -236,6 +236,9 @@
|
||||
<a href="{{url("/admin/role-add")}}" class="list-group-item list-group-item-action bg-dark text-white">
|
||||
<span class="menu-collapsed">Add User Roles</span>
|
||||
</a>
|
||||
<a href="{{url("/admin/invitations")}}" class="list-group-item list-group-item-action bg-dark text-white">
|
||||
<span class="menu-collapsed">Manage Invitations</span>
|
||||
</a>
|
||||
<a href="{{url("/admin/deleted-users")}}" class="list-group-item list-group-item-action bg-dark text-white">
|
||||
<span class="menu-collapsed">Deleted Users</span>
|
||||
</a>
|
||||
|
||||
@@ -242,4 +242,13 @@ Route::get('/invitation/{token}', [InvitationController::class, 'show'])->name('
|
||||
// Admin invitation cleanup
|
||||
Route::post('/admin/invitations/cleanup', [InvitationController::class, 'cleanup'])->name('admin.invitations.cleanup')->middleware('role:Admin');
|
||||
|
||||
// Admin invitation management routes
|
||||
Route::middleware(['role:Admin'])->prefix('admin/invitations')->name('admin.invitations.')->group(function () {
|
||||
Route::get('/', [App\Http\Controllers\Admin\AdminInvitationController::class, 'index'])->name('index');
|
||||
Route::get('/{id}', [App\Http\Controllers\Admin\AdminInvitationController::class, 'show'])->name('show');
|
||||
Route::post('/{id}/resend', [App\Http\Controllers\Admin\AdminInvitationController::class, 'resend'])->name('resend');
|
||||
Route::post('/{id}/cancel', [App\Http\Controllers\Admin\AdminInvitationController::class, 'cancel'])->name('cancel');
|
||||
Route::post('/bulk', [App\Http\Controllers\Admin\AdminInvitationController::class, 'bulkAction'])->name('bulk');
|
||||
});
|
||||
|
||||
Route::post('btcpay/webhook', [BtcPaymentController::class, 'btcPayCallback'])->name('btcpay.webhook');
|
||||
|
||||
Reference in New Issue
Block a user