updates for admin area

This commit is contained in:
Mark Gregory
2026-09-12 21:18:19 +01:00
parent ab3134de13
commit 5e2f4a1fa7
42 changed files with 894 additions and 210 deletions
+32
View File
@@ -0,0 +1,32 @@
# BeatBook Admin CRUD
This revision replaces the read-only/placeholder administration sections with persistent CRUD management.
## CRUD resources
- Users
- Clients
- DJs
- Businesses
- Bookings
- Payments
- Reviews
- Messages
- Notifications
- Support tickets
- Saved reports
- Content pages
- Roles
- Permissions
- Privacy/legal documents
- Application settings
## New persistence
Migration `2026_09_12_000004_create_admin_management_tables.php` adds tables for payments, messages, admin notifications, support tickets, saved reports, content pages, legal documents and application settings.
Run `php artisan migrate` on an existing installation. Do not use `migrate:fresh` unless you intentionally want to erase existing data.
Published Privacy, Cookie and Terms records are used by the corresponding public legal pages. Published Content records are available at `/pages/{slug}`.
Notification records are currently managed content; creating a record does not yet dispatch email/push notifications. Saved reports are definitions; a later reporting engine can execute/export them.
@@ -3,53 +3,17 @@
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\DjProfile;
use App\Models\Review;
use App\Models\User;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Cache;
use Spatie\Permission\Models\Role;
use Spatie\Permission\PermissionRegistrar;
class ActionController extends Controller
{
public function userRole(Request $request, User $user): RedirectResponse
{
$data = $request->validate(['role' => ['required', 'in:super-admin,admin,dj,client,moderator,support']]);
$role = Role::firstOrCreate(['name' => $data['role'], 'guard_name' => 'web']);
$user->syncRoles([$role]);
app(PermissionRegistrar::class)->forgetCachedPermissions();
return back()->with('success', "{$user->name}'s role has been updated.");
}
public function toggleDjPublished(DjProfile $djProfile): RedirectResponse
{
$djProfile->update(['is_published' => ! $djProfile->is_published]);
return back()->with('success', 'DJ publication status updated.');
}
public function toggleDjFeatured(DjProfile $djProfile): RedirectResponse
{
$djProfile->update(['is_featured' => ! $djProfile->is_featured]);
return back()->with('success', 'DJ featured status updated.');
}
public function reviewStatus(Request $request, Review $review): RedirectResponse
{
$data = $request->validate(['status' => ['required', 'in:pending,published,rejected']]);
$review->update($data);
return back()->with('success', 'Review moderation status updated.');
}
public function clearCaches(): RedirectResponse
{
Cache::flush();
Artisan::call('optimize:clear');
return back()->with('success', 'Application cache cleared.');
return back()->with('success', 'Application caches cleared.');
}
}
@@ -0,0 +1,310 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\AdminNotification;
use App\Models\AppSetting;
use App\Models\Booking;
use App\Models\Business;
use App\Models\ContentPage;
use App\Models\DjProfile;
use App\Models\LegalDocument;
use App\Models\Message;
use App\Models\Payment;
use App\Models\Review;
use App\Models\SavedReport;
use App\Models\SupportTicket;
use App\Models\User;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Illuminate\View\View;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;
use Spatie\Permission\PermissionRegistrar;
class CrudController extends Controller
{
public function index(Request $request, string $resource): View
{
$definition = $this->definition($resource);
$query = $this->query($resource, $definition);
if ($search = trim((string) $request->query('q'))) {
$query->where(function (Builder $query) use ($definition, $search) {
foreach ($definition['search'] ?? [] as $column) {
$query->orWhere($column, 'like', '%'.$search.'%');
}
});
}
$records = $query->latest($definition['order_by'] ?? 'created_at')->paginate(20)->withQueryString();
return view('admin.crud.index', compact('resource', 'definition', 'records'));
}
public function create(string $resource): View
{
$definition = $this->definition($resource);
$record = null;
$options = $this->options($resource);
return view('admin.crud.form', compact('resource', 'definition', 'record', 'options'));
}
public function store(Request $request, string $resource): RedirectResponse
{
$definition = $this->definition($resource);
$data = $request->validate($this->rules($resource, null));
$record = $this->persist($resource, new $definition['model'], $data, true);
return redirect()->route('admin.resources.index', $resource)
->with('success', $definition['singular'].' created successfully.');
}
public function edit(string $resource, string $id): View
{
$definition = $this->definition($resource);
$record = $this->find($resource, $definition, $id);
$options = $this->options($resource);
return view('admin.crud.form', compact('resource', 'definition', 'record', 'options'));
}
public function update(Request $request, string $resource, string $id): RedirectResponse
{
$definition = $this->definition($resource);
$record = $this->find($resource, $definition, $id);
$data = $request->validate($this->rules($resource, $record));
$this->persist($resource, $record, $data, false);
return redirect()->route('admin.resources.index', $resource)
->with('success', $definition['singular'].' updated successfully.');
}
public function destroy(Request $request, string $resource, string $id): RedirectResponse
{
$definition = $this->definition($resource);
$record = $this->find($resource, $definition, $id);
if ($resource === 'users' && $record->is($request->user())) {
return back()->with('error', 'You cannot delete your own administrator account.');
}
if ($resource === 'roles' && in_array($record->name, ['admin', 'super-admin'], true)) {
return back()->with('error', 'The core administrator roles cannot be deleted.');
}
$record->delete();
return redirect()->route('admin.resources.index', $resource)
->with('success', $definition['singular'].' deleted.');
}
private function definition(string $resource): array
{
$definitions = [
'users' => [
'title' => 'Users', 'singular' => 'User', 'model' => User::class, 'search' => ['name', 'email'], 'with' => ['roles'],
'columns' => ['name' => 'Name', 'email' => 'Email', 'roles_text' => 'Role', 'created_at' => 'Joined'],
'fields' => [
'name' => ['label' => 'Name', 'type' => 'text'], 'email' => ['label' => 'Email', 'type' => 'email'], 'phone' => ['label' => 'Phone', 'type' => 'text'],
'role' => ['label' => 'Role', 'type' => 'select', 'options' => 'roles', 'nullable' => true], 'password' => ['label' => 'Password', 'type' => 'password', 'help' => 'Leave blank when editing to keep the current password.'],
'marketing_opt_in' => ['label' => 'Marketing opt-in', 'type' => 'checkbox'],
],
],
'clients' => [
'title' => 'Clients', 'singular' => 'Client', 'model' => User::class, 'search' => ['name', 'email'], 'with' => ['roles'], 'scope' => 'client',
'columns' => ['name' => 'Name', 'email' => 'Email', 'phone' => 'Phone', 'created_at' => 'Joined'],
'fields' => ['name' => ['label' => 'Name', 'type' => 'text'], 'email' => ['label' => 'Email', 'type' => 'email'], 'phone' => ['label' => 'Phone', 'type' => 'text'], 'password' => ['label' => 'Password', 'type' => 'password'], 'marketing_opt_in' => ['label' => 'Marketing opt-in', 'type' => 'checkbox']],
],
'djs' => [
'title' => 'DJs', 'singular' => 'DJ profile', 'model' => DjProfile::class, 'search' => ['stage_name', 'city', 'headline'], 'with' => ['user', 'business'],
'columns' => ['stage_name' => 'Stage name', 'user.name' => 'Account', 'city' => 'City', 'price_from' => 'From', 'is_published' => 'Published', 'is_featured' => 'Featured'],
'fields' => ['user_id' => ['label' => 'User', 'type' => 'select', 'options' => 'users'], 'business_id' => ['label' => 'Business', 'type' => 'select', 'options' => 'businesses', 'nullable' => true], 'stage_name' => ['label' => 'Stage name', 'type' => 'text'], 'slug' => ['label' => 'Slug', 'type' => 'text'], 'headline' => ['label' => 'Headline', 'type' => 'text'], 'bio' => ['label' => 'Biography', 'type' => 'textarea'], 'city' => ['label' => 'City', 'type' => 'text'], 'service_radius_miles' => ['label' => 'Service radius (miles)', 'type' => 'number'], 'price_from' => ['label' => 'Price from (£)', 'type' => 'number', 'step' => '0.01'], 'is_published' => ['label' => 'Published', 'type' => 'checkbox'], 'is_featured' => ['label' => 'Featured', 'type' => 'checkbox']],
],
'businesses' => [
'title' => 'Businesses', 'singular' => 'Business', 'model' => Business::class, 'search' => ['name', 'email', 'status'],
'columns' => ['name' => 'Name', 'email' => 'Email', 'phone' => 'Phone', 'status' => 'Status', 'verified_at' => 'Verified'],
'fields' => ['name' => ['label' => 'Name', 'type' => 'text'], 'slug' => ['label' => 'Slug', 'type' => 'text'], 'description' => ['label' => 'Description', 'type' => 'textarea'], 'email' => ['label' => 'Email', 'type' => 'email'], 'phone' => ['label' => 'Phone', 'type' => 'text'], 'website' => ['label' => 'Website', 'type' => 'url'], 'status' => ['label' => 'Status', 'type' => 'select', 'values' => ['active' => 'Active', 'suspended' => 'Suspended', 'pending' => 'Pending']], 'verified_at' => ['label' => 'Verified at', 'type' => 'datetime-local']],
],
'bookings' => [
'title' => 'Bookings', 'singular' => 'Booking', 'model' => Booking::class, 'search' => ['status'], 'with' => ['client', 'djProfile'], 'order_by' => 'event_date',
'columns' => ['client.name' => 'Client', 'djProfile.stage_name' => 'DJ', 'event_date' => 'Event date', 'total' => 'Total', 'deposit' => 'Deposit', 'status' => 'Status'],
'fields' => ['client_id' => ['label' => 'Client', 'type' => 'select', 'options' => 'users'], 'dj_profile_id' => ['label' => 'DJ', 'type' => 'select', 'options' => 'djs'], 'event_date' => ['label' => 'Event date', 'type' => 'date'], 'total' => ['label' => 'Total (£)', 'type' => 'number', 'step' => '0.01'], 'deposit' => ['label' => 'Deposit (£)', 'type' => 'number', 'step' => '0.01'], 'status' => ['label' => 'Status', 'type' => 'select', 'values' => ['pending' => 'Pending', 'confirmed' => 'Confirmed', 'completed' => 'Completed', 'cancelled' => 'Cancelled']], 'paid_at' => ['label' => 'Paid at', 'type' => 'datetime-local']],
],
'payments' => [
'title' => 'Payments', 'singular' => 'Payment', 'model' => Payment::class, 'search' => ['reference', 'provider', 'status'], 'with' => ['user', 'booking'],
'columns' => ['reference' => 'Reference', 'user.name' => 'User', 'amount' => 'Amount', 'provider' => 'Provider', 'status' => 'Status', 'paid_at' => 'Paid'],
'fields' => ['booking_id' => ['label' => 'Booking', 'type' => 'select', 'options' => 'bookings', 'nullable' => true], 'user_id' => ['label' => 'User', 'type' => 'select', 'options' => 'users', 'nullable' => true], 'amount' => ['label' => 'Amount (£)', 'type' => 'number', 'step' => '0.01'], 'provider' => ['label' => 'Provider', 'type' => 'select', 'values' => ['manual' => 'Manual', 'stripe' => 'Stripe', 'bank' => 'Bank transfer']], 'reference' => ['label' => 'Reference', 'type' => 'text'], 'status' => ['label' => 'Status', 'type' => 'select', 'values' => ['pending' => 'Pending', 'paid' => 'Paid', 'failed' => 'Failed', 'refunded' => 'Refunded']], 'paid_at' => ['label' => 'Paid at', 'type' => 'datetime-local'], 'notes' => ['label' => 'Notes', 'type' => 'textarea']],
],
'reviews' => [
'title' => 'Reviews', 'singular' => 'Review', 'model' => Review::class, 'search' => ['title', 'body', 'status'], 'with' => ['user', 'djProfile'],
'columns' => ['user.name' => 'User', 'djProfile.stage_name' => 'DJ', 'rating' => 'Rating', 'title' => 'Title', 'status' => 'Status'],
'fields' => ['booking_id' => ['label' => 'Booking', 'type' => 'select', 'options' => 'bookings'], 'user_id' => ['label' => 'User', 'type' => 'select', 'options' => 'users'], 'dj_profile_id' => ['label' => 'DJ', 'type' => 'select', 'options' => 'djs'], 'rating' => ['label' => 'Rating', 'type' => 'number', 'min' => 1, 'max' => 5], 'title' => ['label' => 'Title', 'type' => 'text'], 'body' => ['label' => 'Review', 'type' => 'textarea'], 'status' => ['label' => 'Status', 'type' => 'select', 'values' => ['pending' => 'Pending', 'published' => 'Published', 'rejected' => 'Rejected']], 'dj_reply' => ['label' => 'DJ reply', 'type' => 'textarea']],
],
'messages' => [
'title' => 'Messages', 'singular' => 'Message', 'model' => Message::class, 'search' => ['subject', 'body', 'status'], 'with' => ['fromUser', 'toUser'],
'columns' => ['subject' => 'Subject', 'fromUser.name' => 'From', 'toUser.name' => 'To', 'status' => 'Status', 'created_at' => 'Created'],
'fields' => ['from_user_id' => ['label' => 'From user', 'type' => 'select', 'options' => 'users', 'nullable' => true], 'to_user_id' => ['label' => 'To user', 'type' => 'select', 'options' => 'users', 'nullable' => true], 'subject' => ['label' => 'Subject', 'type' => 'text'], 'body' => ['label' => 'Message', 'type' => 'textarea'], 'status' => ['label' => 'Status', 'type' => 'select', 'values' => ['unread' => 'Unread', 'read' => 'Read', 'archived' => 'Archived']], 'read_at' => ['label' => 'Read at', 'type' => 'datetime-local']],
],
'notifications' => [
'title' => 'Notifications', 'singular' => 'Notification', 'model' => AdminNotification::class, 'search' => ['title', 'body', 'audience', 'status'],
'columns' => ['title' => 'Title', 'audience' => 'Audience', 'status' => 'Status', 'published_at' => 'Published'],
'fields' => ['title' => ['label' => 'Title', 'type' => 'text'], 'body' => ['label' => 'Message', 'type' => 'textarea'], 'audience' => ['label' => 'Audience', 'type' => 'select', 'values' => ['all' => 'Everyone', 'clients' => 'Clients', 'djs' => 'DJs', 'admins' => 'Admins']], 'status' => ['label' => 'Status', 'type' => 'select', 'values' => ['draft' => 'Draft', 'published' => 'Published', 'archived' => 'Archived']], 'published_at' => ['label' => 'Published at', 'type' => 'datetime-local']],
],
'support' => [
'title' => 'Support tickets', 'singular' => 'Support ticket', 'model' => SupportTicket::class, 'search' => ['subject', 'category', 'status', 'priority'], 'with' => ['user'],
'columns' => ['subject' => 'Subject', 'user.name' => 'User', 'category' => 'Category', 'priority' => 'Priority', 'status' => 'Status'],
'fields' => ['user_id' => ['label' => 'User', 'type' => 'select', 'options' => 'users', 'nullable' => true], 'subject' => ['label' => 'Subject', 'type' => 'text'], 'category' => ['label' => 'Category', 'type' => 'select', 'values' => ['general' => 'General', 'account' => 'Account', 'booking' => 'Booking', 'payment' => 'Payment', 'technical' => 'Technical']], 'priority' => ['label' => 'Priority', 'type' => 'select', 'values' => ['low' => 'Low', 'normal' => 'Normal', 'high' => 'High', 'urgent' => 'Urgent']], 'status' => ['label' => 'Status', 'type' => 'select', 'values' => ['open' => 'Open', 'in-progress' => 'In progress', 'resolved' => 'Resolved', 'closed' => 'Closed']], 'description' => ['label' => 'Description', 'type' => 'textarea'], 'resolution' => ['label' => 'Resolution', 'type' => 'textarea']],
],
'reports' => [
'title' => 'Saved reports', 'singular' => 'Report', 'model' => SavedReport::class, 'search' => ['name', 'type', 'description'],
'columns' => ['name' => 'Name', 'type' => 'Type', 'is_active' => 'Active', 'updated_at' => 'Updated'],
'fields' => ['name' => ['label' => 'Name', 'type' => 'text'], 'type' => ['label' => 'Type', 'type' => 'select', 'values' => ['overview' => 'Overview', 'bookings' => 'Bookings', 'revenue' => 'Revenue', 'users' => 'Users', 'djs' => 'DJs', 'reviews' => 'Reviews']], 'description' => ['label' => 'Description', 'type' => 'textarea'], 'filters' => ['label' => 'Filters (JSON)', 'type' => 'textarea'], 'is_active' => ['label' => 'Active', 'type' => 'checkbox']],
],
'content' => [
'title' => 'Content', 'singular' => 'Content page', 'model' => ContentPage::class, 'search' => ['title', 'slug', 'status'],
'columns' => ['title' => 'Title', 'slug' => 'Slug', 'status' => 'Status', 'published_at' => 'Published'],
'fields' => ['title' => ['label' => 'Title', 'type' => 'text'], 'slug' => ['label' => 'Slug', 'type' => 'text'], 'excerpt' => ['label' => 'Excerpt', 'type' => 'textarea'], 'body' => ['label' => 'Body', 'type' => 'textarea', 'large' => true], 'status' => ['label' => 'Status', 'type' => 'select', 'values' => ['draft' => 'Draft', 'published' => 'Published', 'archived' => 'Archived']], 'published_at' => ['label' => 'Published at', 'type' => 'datetime-local']],
],
'roles' => [
'title' => 'Roles & permissions', 'singular' => 'Role', 'model' => Role::class, 'search' => ['name'], 'with_count' => ['users'],
'columns' => ['name' => 'Role', 'users_count' => 'Users', 'created_at' => 'Created'],
'fields' => ['name' => ['label' => 'Role name', 'type' => 'text']],
],
'permissions' => [
'title' => 'Permissions', 'singular' => 'Permission', 'model' => Permission::class, 'search' => ['name'],
'columns' => ['name' => 'Permission', 'created_at' => 'Created', 'updated_at' => 'Updated'],
'fields' => ['name' => ['label' => 'Permission name', 'type' => 'text']],
],
'privacy' => [
'title' => 'Privacy & legal', 'singular' => 'Legal document', 'model' => LegalDocument::class, 'search' => ['type', 'title', 'version', 'status'],
'columns' => ['type' => 'Type', 'title' => 'Title', 'version' => 'Version', 'status' => 'Status', 'published_at' => 'Published'],
'fields' => ['type' => ['label' => 'Document type', 'type' => 'select', 'values' => ['privacy' => 'Privacy policy', 'cookies' => 'Cookie policy', 'terms' => 'Terms & conditions', 'dpa' => 'Data processing']], 'title' => ['label' => 'Title', 'type' => 'text'], 'version' => ['label' => 'Version', 'type' => 'text'], 'body' => ['label' => 'Document', 'type' => 'textarea', 'large' => true], 'status' => ['label' => 'Status', 'type' => 'select', 'values' => ['draft' => 'Draft', 'published' => 'Published', 'archived' => 'Archived']], 'published_at' => ['label' => 'Published at', 'type' => 'datetime-local']],
],
'settings' => [
'title' => 'Settings', 'singular' => 'Setting', 'model' => AppSetting::class, 'search' => ['key', 'label', 'group'],
'columns' => ['label' => 'Setting', 'key' => 'Key', 'value' => 'Value', 'group' => 'Group', 'type' => 'Type'],
'fields' => ['label' => ['label' => 'Label', 'type' => 'text'], 'key' => ['label' => 'Key', 'type' => 'text'], 'value' => ['label' => 'Value', 'type' => 'textarea'], 'type' => ['label' => 'Type', 'type' => 'select', 'values' => ['text' => 'Text', 'number' => 'Number', 'boolean' => 'Boolean', 'email' => 'Email', 'url' => 'URL', 'json' => 'JSON']], 'group' => ['label' => 'Group', 'type' => 'text'], 'description' => ['label' => 'Description', 'type' => 'textarea']],
],
];
abort_unless(isset($definitions[$resource]), 404);
return $definitions[$resource];
}
private function query(string $resource, array $definition): Builder
{
/** @var Builder $query */
$query = $definition['model']::query();
if (! empty($definition['with'])) {
$query->with($definition['with']);
}
if (! empty($definition['with_count'])) {
$query->withCount($definition['with_count']);
}
if (($definition['scope'] ?? null) === 'client') {
$query->whereHas('roles', fn (Builder $q) => $q->where('name', 'client'));
}
return $query;
}
private function find(string $resource, array $definition, string $id): Model
{
$query = $this->query($resource, $definition);
return $query->findOrFail($id);
}
private function rules(string $resource, ?Model $record): array
{
$id = $record?->getKey();
return match ($resource) {
'users', 'clients' => ['name' => ['required', 'string', 'max:255'], 'email' => ['required', 'email', 'max:255', Rule::unique('users')->ignore($id)], 'phone' => ['nullable', 'string', 'max:50'], 'password' => [$record ? 'nullable' : 'required', 'string', 'min:8'], 'role' => ['nullable', 'string', 'max:50'], 'marketing_opt_in' => ['nullable', 'boolean']],
'djs' => ['user_id' => ['required', 'exists:users,id'], 'business_id' => ['nullable', 'exists:businesses,id'], 'stage_name' => ['required', 'string', 'max:255'], 'slug' => ['nullable', 'string', 'max:255', Rule::unique('dj_profiles')->ignore($id)], 'headline' => ['nullable', 'string', 'max:255'], 'bio' => ['nullable', 'string'], 'city' => ['required', 'string', 'max:255'], 'service_radius_miles' => ['required', 'integer', 'min:0', 'max:500'], 'price_from' => ['nullable', 'numeric', 'min:0'], 'is_published' => ['nullable', 'boolean'], 'is_featured' => ['nullable', 'boolean']],
'businesses' => ['name' => ['required', 'string', 'max:255'], 'slug' => ['nullable', 'string', 'max:255', Rule::unique('businesses')->ignore($id)], 'description' => ['nullable', 'string'], 'email' => ['nullable', 'email'], 'phone' => ['nullable', 'string', 'max:50'], 'website' => ['nullable', 'url'], 'status' => ['required', Rule::in(['active', 'suspended', 'pending'])], 'verified_at' => ['nullable', 'date']],
'bookings' => ['client_id' => ['required', 'exists:users,id'], 'dj_profile_id' => ['required', 'exists:dj_profiles,id'], 'event_date' => ['required', 'date'], 'total' => ['required', 'numeric', 'min:0'], 'deposit' => ['required', 'numeric', 'min:0'], 'status' => ['required', Rule::in(['pending', 'confirmed', 'completed', 'cancelled'])], 'paid_at' => ['nullable', 'date']],
'payments' => ['booking_id' => ['nullable', 'exists:bookings,id'], 'user_id' => ['nullable', 'exists:users,id'], 'amount' => ['required', 'numeric', 'min:0'], 'provider' => ['required', Rule::in(['manual', 'stripe', 'bank'])], 'reference' => ['nullable', 'string', 'max:255'], 'status' => ['required', Rule::in(['pending', 'paid', 'failed', 'refunded'])], 'paid_at' => ['nullable', 'date'], 'notes' => ['nullable', 'string']],
'reviews' => ['booking_id' => ['required', 'exists:bookings,id', Rule::unique('reviews')->ignore($id)], 'user_id' => ['required', 'exists:users,id'], 'dj_profile_id' => ['required', 'exists:dj_profiles,id'], 'rating' => ['required', 'integer', 'between:1,5'], 'title' => ['nullable', 'string', 'max:255'], 'body' => ['required', 'string'], 'status' => ['required', Rule::in(['pending', 'published', 'rejected'])], 'dj_reply' => ['nullable', 'string']],
'messages' => ['from_user_id' => ['nullable', 'exists:users,id'], 'to_user_id' => ['nullable', 'exists:users,id'], 'subject' => ['required', 'string', 'max:255'], 'body' => ['required', 'string'], 'status' => ['required', Rule::in(['unread', 'read', 'archived'])], 'read_at' => ['nullable', 'date']],
'notifications' => ['title' => ['required', 'string', 'max:255'], 'body' => ['required', 'string'], 'audience' => ['required', Rule::in(['all', 'clients', 'djs', 'admins'])], 'status' => ['required', Rule::in(['draft', 'published', 'archived'])], 'published_at' => ['nullable', 'date']],
'support' => ['user_id' => ['nullable', 'exists:users,id'], 'subject' => ['required', 'string', 'max:255'], 'category' => ['required', 'string', 'max:50'], 'priority' => ['required', Rule::in(['low', 'normal', 'high', 'urgent'])], 'status' => ['required', Rule::in(['open', 'in-progress', 'resolved', 'closed'])], 'description' => ['required', 'string'], 'resolution' => ['nullable', 'string']],
'reports' => ['name' => ['required', 'string', 'max:255'], 'type' => ['required', 'string', 'max:50'], 'description' => ['nullable', 'string'], 'filters' => ['nullable', 'json'], 'is_active' => ['nullable', 'boolean']],
'content' => ['title' => ['required', 'string', 'max:255'], 'slug' => ['nullable', 'string', 'max:255', Rule::unique('content_pages')->ignore($id)], 'excerpt' => ['nullable', 'string'], 'body' => ['nullable', 'string'], 'status' => ['required', Rule::in(['draft', 'published', 'archived'])], 'published_at' => ['nullable', 'date']],
'roles' => ['name' => ['required', 'string', 'max:125', Rule::unique('roles')->where('guard_name', 'web')->ignore($id)]],
'permissions' => ['name' => ['required', 'string', 'max:125', Rule::unique('permissions')->where('guard_name', 'web')->ignore($id)]],
'privacy' => ['type' => ['required', 'string', 'max:50'], 'title' => ['required', 'string', 'max:255'], 'version' => ['required', 'string', 'max:50'], 'body' => ['required', 'string'], 'status' => ['required', Rule::in(['draft', 'published', 'archived'])], 'published_at' => ['nullable', 'date']],
'settings' => ['label' => ['required', 'string', 'max:255'], 'key' => ['required', 'string', 'max:255', Rule::unique('app_settings')->ignore($id)], 'value' => ['nullable', 'string'], 'type' => ['required', 'string', 'max:30'], 'group' => ['required', 'string', 'max:100'], 'description' => ['nullable', 'string']],
default => abort(404),
};
}
private function persist(string $resource, Model $record, array $data, bool $creating): Model
{
foreach (['marketing_opt_in', 'is_published', 'is_featured', 'is_active'] as $boolean) {
if (array_key_exists($boolean, $data)) {
$data[$boolean] = (bool) $data[$boolean];
}
}
if (isset($data['slug']) && blank($data['slug'])) {
$data['slug'] = Str::slug($data['stage_name'] ?? $data['name'] ?? $data['title'] ?? Str::random(8));
}
if ($resource === 'reports' && isset($data['filters'])) {
$data['filters'] = blank($data['filters']) ? null : json_decode($data['filters'], true, flags: JSON_THROW_ON_ERROR);
}
if (in_array($resource, ['users', 'clients'], true)) {
$role = $resource === 'clients' ? 'client' : ($data['role'] ?? null);
unset($data['role']);
if (blank($data['password'] ?? null)) {
unset($data['password']);
} else {
$data['password'] = Hash::make($data['password']);
}
$record->fill($data)->save();
if ($role) {
$roleModel = Role::firstOrCreate(['name' => $role, 'guard_name' => 'web']);
$record->syncRoles([$roleModel]);
app(PermissionRegistrar::class)->forgetCachedPermissions();
}
return $record;
}
if (in_array($resource, ['roles', 'permissions'], true)) {
$record->fill(['name' => $data['name'], 'guard_name' => 'web'])->save();
app(PermissionRegistrar::class)->forgetCachedPermissions();
return $record;
}
$record->fill($data)->save();
return $record;
}
private function options(string $resource): array
{
$roles = Role::query()->orderBy('name')->pluck('name', 'name')->all();
foreach (['client', 'dj', 'support', 'moderator', 'admin', 'super-admin'] as $role) {
$roles[$role] ??= Str::headline($role);
}
return [
'roles' => $roles,
'users' => User::query()->orderBy('name')->pluck('name', 'id')->all(),
'businesses' => Business::query()->orderBy('name')->pluck('name', 'id')->all(),
'djs' => DjProfile::query()->orderBy('stage_name')->pluck('stage_name', 'id')->all(),
'bookings' => Booking::query()->with(['client', 'djProfile'])->latest('event_date')->get()->mapWithKeys(fn (Booking $b) => [$b->id => ($b->client?->name ?? 'Client').' · '.($b->djProfile?->stage_name ?? 'DJ').' · '.$b->event_date?->format('j M Y')])->all(),
];
}
}
@@ -1,59 +0,0 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\Booking;
use App\Models\Business;
use App\Models\DjProfile;
use App\Models\Enquiry;
use App\Models\Review;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\View\View;
use Spatie\Permission\Models\Role;
class SectionController extends Controller
{
private const SECTIONS = [
'users', 'djs', 'clients', 'bookings', 'payments', 'reviews', 'messages',
'notifications', 'support', 'reports', 'content', 'roles', 'privacy', 'settings',
];
public function __invoke(Request $request, string $section): View
{
abort_unless(in_array($section, self::SECTIONS, true), 404);
$data = [
'section' => $section,
'counts' => [
'users' => User::count(),
'djs' => DjProfile::count(),
'businesses' => Business::count(),
'clients' => User::whereHas('roles', fn ($query) => $query->where('name', 'client'))->count(),
'bookings' => Booking::count(),
'enquiries' => Enquiry::count(),
'reviews' => Review::count(),
],
];
$data += match ($section) {
'users' => ['users' => User::with('roles')->latest()->paginate(20)],
'djs' => ['djs' => DjProfile::with(['user', 'business'])->latest()->paginate(20)],
'clients' => ['clients' => User::whereHas('roles', fn ($query) => $query->where('name', 'client'))->withCount(['enquiries', 'bookings'])->latest()->paginate(20)],
'bookings' => ['bookings' => Booking::with(['client', 'djProfile'])->latest('event_date')->paginate(20)],
'payments' => ['bookings' => Booking::with(['client', 'djProfile'])->latest()->paginate(20)],
'reviews' => ['reviews' => Review::with(['user', 'djProfile'])->latest()->paginate(20)],
'roles' => ['roles' => Role::withCount('users')->orderBy('name')->get()],
'reports' => ['report' => [
'booking_value' => Booking::sum('total'),
'deposits' => Booking::sum('deposit'),
'published_djs' => DjProfile::where('is_published', true)->count(),
'pending_reviews' => Review::where('status', 'pending')->count(),
]],
default => [],
};
return view("admin.sections.{$section}", $data);
}
}
@@ -0,0 +1,16 @@
<?php
namespace App\Http\Controllers;
use App\Models\ContentPage;
use Illuminate\View\View;
class ContentPageController extends Controller
{
public function show(ContentPage $page): View
{
abort_unless($page->status === 'published' && ($page->published_at === null || $page->published_at->isPast()), 404);
return view('content.show', compact('page'));
}
}
+47
View File
@@ -0,0 +1,47 @@
<?php
namespace App\Http\Controllers;
use App\Models\LegalDocument;
use Illuminate\View\View;
class LegalController extends Controller
{
public function privacy(): View
{
return $this->show('privacy');
}
public function cookies(): View
{
return $this->show('cookies');
}
public function terms(): View
{
return $this->show('terms');
}
private function show(string $type): View
{
abort_unless(in_array($type, ['privacy', 'cookies', 'terms'], true), 404);
$document = LegalDocument::query()
->where('type', $type)
->where('status', 'published')
->where(fn ($query) => $query->whereNull('published_at')->orWhere('published_at', '<=', now()))
->latest('published_at')
->latest('updated_at')
->first();
$fallbacks = [
'privacy' => ['Privacy Policy', 'BeatBook is designed for UK GDPR compliance, data minimisation, access/export and deletion workflows. A published privacy policy can be maintained from the administration area.'],
'cookies' => ['Cookie Policy', 'Necessary cookies operate the service. Analytics and marketing cookies require consent. A published cookie policy can be maintained from the administration area.'],
'terms' => ['Terms of Service', 'Marketplace, DJ, client, cancellation, payment, refund and acceptable-use terms can be maintained from the administration area.'],
];
[$fallbackTitle, $fallbackBody] = $fallbacks[$type];
return view('legal.page', compact('document', 'fallbackTitle', 'fallbackBody', 'type'));
}
}
+15
View File
@@ -0,0 +1,15 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class AdminNotification extends Model
{
protected $fillable = ['title', 'body', 'audience', 'status', 'published_at'];
protected function casts(): array
{
return ['published_at' => 'datetime'];
}
}
+10
View File
@@ -0,0 +1,10 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class AppSetting extends Model
{
protected $fillable = ['key', 'label', 'value', 'type', 'group', 'description'];
}
+15
View File
@@ -0,0 +1,15 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class ContentPage extends Model
{
protected $fillable = ['title', 'slug', 'excerpt', 'body', 'status', 'published_at'];
protected function casts(): array
{
return ['published_at' => 'datetime'];
}
}
+15
View File
@@ -0,0 +1,15 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class LegalDocument extends Model
{
protected $fillable = ['type', 'title', 'version', 'body', 'status', 'published_at'];
protected function casts(): array
{
return ['published_at' => 'datetime'];
}
}
+25
View File
@@ -0,0 +1,25 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Message extends Model
{
protected $fillable = ['from_user_id', 'to_user_id', 'subject', 'body', 'status', 'read_at'];
protected function casts(): array
{
return ['read_at' => 'datetime'];
}
public function fromUser()
{
return $this->belongsTo(User::class, 'from_user_id');
}
public function toUser()
{
return $this->belongsTo(User::class, 'to_user_id');
}
}
+28
View File
@@ -0,0 +1,28 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Concerns\HasUlids;
use Illuminate\Database\Eloquent\Model;
class Payment extends Model
{
use HasUlids;
protected $fillable = ['booking_id', 'user_id', 'amount', 'provider', 'reference', 'status', 'paid_at', 'notes'];
protected function casts(): array
{
return ['amount' => 'decimal:2', 'paid_at' => 'datetime'];
}
public function booking()
{
return $this->belongsTo(Booking::class);
}
public function user()
{
return $this->belongsTo(User::class);
}
}
+15
View File
@@ -0,0 +1,15 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class SavedReport extends Model
{
protected $fillable = ['name', 'type', 'description', 'filters', 'is_active'];
protected function casts(): array
{
return ['filters' => 'array', 'is_active' => 'boolean'];
}
}
+18
View File
@@ -0,0 +1,18 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Concerns\HasUlids;
use Illuminate\Database\Eloquent\Model;
class SupportTicket extends Model
{
use HasUlids;
protected $fillable = ['user_id', 'subject', 'category', 'priority', 'status', 'description', 'resolution'];
public function user()
{
return $this->belongsTo(User::class);
}
}
@@ -0,0 +1,107 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('payments', function (Blueprint $table) {
$table->ulid('id')->primary();
$table->foreignUlid('booking_id')->nullable()->constrained('bookings')->nullOnDelete();
$table->foreignId('user_id')->nullable()->constrained()->nullOnDelete();
$table->decimal('amount', 10, 2);
$table->string('provider')->default('manual');
$table->string('reference')->nullable()->index();
$table->string('status')->default('pending')->index();
$table->timestamp('paid_at')->nullable();
$table->text('notes')->nullable();
$table->timestamps();
});
Schema::create('messages', function (Blueprint $table) {
$table->id();
$table->foreignId('from_user_id')->nullable()->constrained('users')->nullOnDelete();
$table->foreignId('to_user_id')->nullable()->constrained('users')->nullOnDelete();
$table->string('subject');
$table->text('body');
$table->string('status')->default('unread')->index();
$table->timestamp('read_at')->nullable();
$table->timestamps();
});
Schema::create('admin_notifications', function (Blueprint $table) {
$table->id();
$table->string('title');
$table->text('body');
$table->string('audience')->default('all')->index();
$table->string('status')->default('draft')->index();
$table->timestamp('published_at')->nullable();
$table->timestamps();
});
Schema::create('support_tickets', function (Blueprint $table) {
$table->ulid('id')->primary();
$table->foreignId('user_id')->nullable()->constrained()->nullOnDelete();
$table->string('subject');
$table->string('category')->default('general');
$table->string('priority')->default('normal')->index();
$table->string('status')->default('open')->index();
$table->text('description');
$table->text('resolution')->nullable();
$table->timestamps();
});
Schema::create('saved_reports', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('type')->default('overview');
$table->text('description')->nullable();
$table->json('filters')->nullable();
$table->boolean('is_active')->default(true);
$table->timestamps();
});
Schema::create('content_pages', function (Blueprint $table) {
$table->id();
$table->string('title');
$table->string('slug')->unique();
$table->text('excerpt')->nullable();
$table->longText('body')->nullable();
$table->string('status')->default('draft')->index();
$table->timestamp('published_at')->nullable();
$table->timestamps();
});
Schema::create('legal_documents', function (Blueprint $table) {
$table->id();
$table->string('type')->index();
$table->string('title');
$table->string('version')->default('1.0');
$table->longText('body');
$table->string('status')->default('draft')->index();
$table->timestamp('published_at')->nullable();
$table->timestamps();
});
Schema::create('app_settings', function (Blueprint $table) {
$table->id();
$table->string('key')->unique();
$table->string('label');
$table->text('value')->nullable();
$table->string('type')->default('text');
$table->string('group')->default('general')->index();
$table->text('description')->nullable();
$table->timestamps();
});
}
public function down(): void
{
foreach (['app_settings', 'legal_documents', 'content_pages', 'saved_reports', 'support_tickets', 'admin_notifications', 'messages', 'payments'] as $table) {
Schema::dropIfExists($table);
}
}
};
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1,6 +1,6 @@
{
"resources/css/app.css": {
"file": "assets/app-B873fQy4.css",
"file": "assets/app-DbTvU8vz.css",
"src": "resources/css/app.css",
"isEntry": true,
"name": "app",
+7
View File
@@ -14,3 +14,10 @@
@media(max-width:1100px){.dj-cards{grid-template-columns:repeat(3,minmax(0,1fr))}.steps{grid-template-columns:repeat(2,1fr)}.profile-grid{grid-template-columns:minmax(0,1fr) 330px}.admin-stats{grid-template-columns:repeat(2,1fr)}.role-grid{grid-template-columns:repeat(2,1fr)}}
@media(max-width:900px){.shell{width:min(100% - 28px,1240px)}.site-header{height:68px}.nav{height:68px}.mobile-menu-button{display:block}.nav-panel{display:none;position:absolute;top:68px;left:14px;right:14px;margin:0;padding:14px;flex-direction:column;align-items:stretch;gap:12px;background:#0e1425;border:1px solid var(--line);border-radius:14px;box-shadow:var(--shadow)}.nav-panel.open{display:flex}.navlinks,.nav-actions{align-items:stretch;flex-direction:column;gap:2px}.navlinks a,.nav-actions>a,.nav-actions .text-button{padding:11px 10px;text-align:left}.nav-actions{padding-top:8px;border-top:1px solid var(--line)}.nav-actions .button{width:100%}.hero{min-height:auto}.hero-copy{padding:74px 0}.searchbox,.filterbar{grid-template-columns:1fr 1fr}.searchbox .button,.filterbar .button{grid-column:1/-1}.dj-cards{grid-template-columns:repeat(2,minmax(0,1fr))}.split-feature{grid-template-columns:1fr;gap:36px}.profile-panel{grid-template-columns:100px 1fr}.profile-price{grid-column:1/-1;padding:18px 0 0;border-left:0;border-top:1px solid #e2e8f0;display:grid;grid-template-columns:auto auto 1fr auto;gap:8px;align-items:center}.profile-price .button{margin:0}.profile-grid{grid-template-columns:1fr}.enquiry-card{position:static}.account-shell{grid-template-columns:1fr}.account-sidebar{position:static}.account-nav{display:flex;gap:4px;overflow-x:auto}.account-nav a{white-space:nowrap}.dashboard-grid{grid-template-columns:1fr}.admin-shell{display:block}.admin-sidebar{position:fixed;left:0;top:0;width:270px;transform:translateX(-105%);transition:transform .2s ease}.admin-sidebar.open{transform:none}.admin-overlay{position:fixed;inset:0;background:rgba(11,16,32,.55);z-index:70}.admin-overlay.open{display:block}.admin-menu,.admin-close{display:inline-grid;place-items:center}.admin-topbar{padding:0 18px}.admin-page{padding:22px 18px}.admin-flash{margin:14px 18px 0}.admin-grid{grid-template-columns:1fr}.settings-grid{grid-template-columns:1fr}}
@media(max-width:640px){.shell{width:min(100% - 22px,1240px)}.brand{font-size:21px}.hero-shade{background:linear-gradient(180deg,rgba(7,9,20,.78),rgba(7,9,20,.97))}.hero-copy{padding:58px 0}.hero h1{font-size:44px}.hero-copy>p{font-size:16px}.searchbox,.filterbar{grid-template-columns:1fr;padding:8px}.searchbox .button,.filterbar .button{grid-column:auto}.hero-proof{gap:9px 16px;font-size:11px}.section{padding:58px 0}.section-head,.directory-heading,.page-heading,.pricing-box{align-items:flex-start;flex-direction:column}.section-head .button{width:100%}.dj-cards{grid-template-columns:1fr}.card-img{aspect-ratio:16/10}.steps{grid-template-columns:1fr}.feature-list{grid-template-columns:1fr}.pricing-box{padding:28px}.pricing-box .button{width:100%}.footer-grid{grid-template-columns:1fr;gap:28px}.cookie{left:10px;right:10px;bottom:10px;flex-direction:column;align-items:stretch}.cookie-actions{display:grid;grid-template-columns:1fr 1fr}.profile-hero{height:230px}.profile-shell{width:100%}.profile-panel{margin:-34px 11px 0;padding:18px;grid-template-columns:72px 1fr;gap:14px;border-radius:17px}.avatar{width:72px;height:72px}.profile-title h1{font-size:27px}.profile-summary p{font-size:13px}.profile-price{grid-template-columns:auto 1fr}.profile-price strong{font-size:30px}.profile-price small{display:none}.profile-price .button{grid-column:1/-1}.profile-grid{padding:28px 11px 0}.directory-page{padding:44px 0 60px}.directory-heading h1{font-size:42px}.account-page{padding:36px 0 58px}.account-person{display:none}.account-sidebar{border-radius:12px}.account-stats{grid-template-columns:1fr 1fr}.account-stat:last-child{grid-column:1/-1}.panel{padding:16px}.admin-topbar .button{display:none}.admin-page{padding:18px 12px}.admin-stats{grid-template-columns:1fr 1fr;gap:10px}.admin-stat{padding:15px}.admin-stat strong{font-size:24px}.settings-list>div{grid-template-columns:1fr}.role-grid{grid-template-columns:1fr}.inline-controls{align-items:stretch;flex-direction:column}.inline-controls select,.inline-controls .admin-button{width:100%}.auth-card{padding:23px}.auth-page{padding:38px 12px}}
/* Admin CRUD */
.crud-heading{gap:18px}.crud-heading .admin-button{flex:0 0 auto}.crud-toolbar{display:flex;align-items:center;justify-content:space-between;gap:16px;margin-bottom:16px;padding:14px 16px}.crud-search{display:flex;gap:8px;align-items:center;flex:1}.crud-search input{width:min(420px,100%);height:38px;padding:0 12px;border:1px solid #d8deea;border-radius:9px;background:#fff;color:#172033;font:inherit;font-size:13px}.record-count{font-size:12px;font-weight:800;color:var(--admin-muted);white-space:nowrap}.admin-button.primary{background:#6d28d9}.danger-button{display:inline-flex;align-items:center;justify-content:center;min-height:33px;padding:0 11px;border:0;border-radius:8px;background:#fee2e2;color:#b91c1c;font:inherit;font-size:11px;font-weight:900;cursor:pointer}.row-actions{display:flex;align-items:center;gap:7px;justify-content:flex-end}.row-actions form{margin:0}.actions-col{text-align:right!important}.crud-table td:last-child{text-align:right}.crud-pagination{margin-top:16px}.crud-form-card{max-width:980px}.crud-form-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:17px}.crud-field{display:grid;gap:7px}.crud-field>span,.crud-check strong{font-size:12px;font-weight:900;color:#263248}.crud-field input,.crud-field select,.crud-field textarea{width:100%;padding:10px 11px;border:1px solid #d5dce7;border-radius:9px;background:#fff;color:#172033;font:inherit;font-size:13px;outline:none}.crud-field input:focus,.crud-field select:focus,.crud-field textarea:focus{border-color:#8b5cf6;box-shadow:0 0 0 3px rgba(139,92,246,.10)}.crud-field textarea{resize:vertical;line-height:1.55}.crud-field small{color:var(--admin-muted);font-size:11px}.crud-field .field-error{color:#b91c1c}.full-field{grid-column:1/-1}.crud-check{display:flex;align-items:center;gap:9px;padding:11px 12px;border:1px solid #e0e5ee;border-radius:9px;background:#fafbfe}.crud-check input[type=checkbox]{width:18px;height:18px}.crud-form-actions{display:flex;align-items:center;gap:9px;padding-top:20px;margin-top:20px;border-top:1px solid var(--admin-line)}.admin-error{max-width:980px;margin-bottom:16px;padding:14px 16px;border:1px solid #fecaca;border-radius:10px;background:#fff1f2;color:#991b1b;font-size:13px}.admin-error ul{margin:8px 0 0 18px}.admin-flash.error{background:#fff1f2;color:#991b1b;border-color:#fecaca}
@media(max-width:760px){.crud-toolbar,.crud-search{align-items:stretch;flex-direction:column}.crud-toolbar .record-count{align-self:flex-start}.crud-search input{width:100%}.crud-form-grid{grid-template-columns:1fr}.full-field{grid-column:auto}.crud-heading .admin-button,.crud-heading .toggle-button{width:100%}.crud-form-actions{align-items:stretch;flex-direction:column}.crud-form-actions>*{width:100%}.crud-table{min-width:760px!important}}
.crud-heading-actions{display:flex;align-items:center;gap:8px;flex-wrap:wrap}@media(max-width:640px){.crud-heading-actions{width:100%;align-items:stretch;flex-direction:column}}
.legal-shell{max-width:850px}.legal-shell h1{font-size:clamp(38px,6vw,64px);margin:10px 0 8px}.legal-meta{color:var(--muted);font-size:12px;margin-bottom:30px}.legal-copy{color:#cbd5e1;font-size:17px;line-height:1.85;white-space:normal}
+55
View File
@@ -0,0 +1,55 @@
@extends('layouts.admin')
@section('title', ($record ? 'Edit ' : 'Add ').$definition['singular'])
@section('page-title', $definition['title'])
@section('content')
<div class="page-heading admin-heading crud-heading">
<div><div class="eyebrow">{{ $record ? 'Edit record' : 'New record' }}</div><h1>{{ $record ? 'Edit '.$definition['singular'] : 'Add '.$definition['singular'] }}</h1><p>Complete the fields below and save your changes.</p></div>
<a class="toggle-button" href="{{ route('admin.resources.index', $resource) }}">← Back to {{ strtolower($definition['title']) }}</a>
</div>
@if($errors->any())
<div class="admin-error"><strong>Please correct the following:</strong><ul>@foreach($errors->all() as $error)<li>{{ $error }}</li>@endforeach</ul></div>
@endif
<div class="admin-card crud-form-card">
<form method="post" action="{{ $record ? route('admin.resources.update', [$resource, $record->getKey()]) : route('admin.resources.store', $resource) }}" class="crud-form">
@csrf
@if($record) @method('PUT') @endif
<div class="crud-form-grid">
@foreach($definition['fields'] as $name => $field)
@php
$raw = old($name);
if ($raw === null && $record) {
if ($name === 'role' && method_exists($record, 'getRoleNames')) $raw = $record->getRoleNames()->first();
elseif ($name === 'filters' && is_array($record->filters ?? null)) $raw = json_encode($record->filters, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
else $raw = data_get($record, $name);
}
if ($raw instanceof \Carbon\CarbonInterface && ($field['type'] ?? '') === 'datetime-local') $raw = $raw->format('Y-m-d\TH:i');
if ($raw instanceof \Carbon\CarbonInterface && ($field['type'] ?? '') === 'date') $raw = $raw->format('Y-m-d');
@endphp
@if(($field['type'] ?? 'text') === 'checkbox')
<label class="crud-check full-field"><input type="hidden" name="{{ $name }}" value="0"><input type="checkbox" name="{{ $name }}" value="1" @checked((bool)$raw)><span><strong>{{ $field['label'] }}</strong></span></label>
@else
<label class="crud-field {{ !empty($field['large']) || ($field['type'] ?? '') === 'textarea' ? 'full-field' : '' }}">
<span>{{ $field['label'] }}</span>
@if(($field['type'] ?? '') === 'select')
<select name="{{ $name }}">
@if(!empty($field['nullable']))<option value="">— None —</option>@endif
@php $selectOptions = isset($field['options']) ? ($options[$field['options']] ?? []) : ($field['values'] ?? []); @endphp
@foreach($selectOptions as $value => $label)<option value="{{ $value }}" @selected((string)$raw === (string)$value)>{{ $label }}</option>@endforeach
</select>
@elseif(($field['type'] ?? '') === 'textarea')
<textarea name="{{ $name }}" rows="{{ !empty($field['large']) ? 14 : 5 }}">{{ $raw }}</textarea>
@else
<input type="{{ $field['type'] ?? 'text' }}" name="{{ $name }}" value="{{ ($field['type'] ?? '') === 'password' ? '' : $raw }}" @if(isset($field['step'])) step="{{ $field['step'] }}" @endif @if(isset($field['min'])) min="{{ $field['min'] }}" @endif @if(isset($field['max'])) max="{{ $field['max'] }}" @endif>
@endif
@if(!empty($field['help']))<small>{{ $field['help'] }}</small>@endif
@error($name)<small class="field-error">{{ $message }}</small>@enderror
</label>
@endif
@endforeach
</div>
<div class="crud-form-actions"><button class="admin-button primary" type="submit">{{ $record ? 'Save changes' : 'Create '.$definition['singular'] }}</button><a class="toggle-button" href="{{ route('admin.resources.index', $resource) }}">Cancel</a></div>
</form>
</div>
@endsection
@@ -0,0 +1,62 @@
@extends('layouts.admin')
@section('title', $definition['title'])
@section('page-title', $definition['title'])
@section('content')
<div class="page-heading admin-heading crud-heading">
<div>
<div class="eyebrow">Management</div>
<h1>{{ $definition['title'] }}</h1>
<p>Create, edit and remove {{ strtolower($definition['title']) }} records.</p>
</div>
<div class="crud-heading-actions">
@if($resource === 'roles')<a class="toggle-button" href="{{ route('admin.resources.index', 'permissions') }}">Manage permissions</a>@endif
@if($resource === 'permissions')<a class="toggle-button" href="{{ route('admin.resources.index', 'roles') }}">Manage roles</a>@endif
@if($resource === 'settings')<form method="post" action="{{ route('admin.settings.clear-cache') }}">@csrf<button class="toggle-button" type="submit">Clear application cache</button></form>@endif
<a class="admin-button primary" href="{{ route('admin.resources.create', $resource) }}">+ Add {{ strtolower($definition['singular']) }}</a>
</div>
</div>
<div class="admin-card crud-toolbar">
<form method="get" action="{{ route('admin.resources.index', $resource) }}" class="crud-search">
<input type="search" name="q" value="{{ request('q') }}" placeholder="Search {{ strtolower($definition['title']) }}…">
<button class="admin-button" type="submit">Search</button>
@if(request('q'))<a class="toggle-button" href="{{ route('admin.resources.index', $resource) }}">Clear</a>@endif
</form>
<span class="record-count">{{ number_format($records->total()) }} records</span>
</div>
<div class="admin-card table-card">
<div class="responsive-table">
<table class="crud-table">
<thead><tr>@foreach($definition['columns'] as $label)<th>{{ $label }}</th>@endforeach<th class="actions-col">Actions</th></tr></thead>
<tbody>
@forelse($records as $record)
<tr>
@foreach($definition['columns'] as $path => $label)
@php
$value = $path === 'roles_text' ? $record->getRoleNames()->join(', ') : data_get($record, $path);
if ($value instanceof \Carbon\CarbonInterface) $value = $value->format('j M Y H:i');
elseif (is_bool($value)) $value = $value ? 'Yes' : 'No';
elseif (in_array($path, ['amount','total','deposit','price_from'], true) && $value !== null) $value = '£'.number_format((float)$value, 2);
@endphp
<td data-label="{{ $label }}">{{ filled($value) ? $value : '—' }}</td>
@endforeach
<td data-label="Actions">
<div class="row-actions">
<a class="toggle-button" href="{{ route('admin.resources.edit', [$resource, $record->getKey()]) }}">Edit</a>
<form method="post" action="{{ route('admin.resources.destroy', [$resource, $record->getKey()]) }}" onsubmit="return confirm('Delete this {{ strtolower($definition['singular']) }}? This cannot be undone.')">
@csrf @method('DELETE')
<button class="danger-button" type="submit">Delete</button>
</form>
</div>
</td>
</tr>
@empty
<tr><td colspan="{{ count($definition['columns']) + 1 }}"><div class="feature-empty"><div class="feature-icon">+</div><h2>No {{ strtolower($definition['title']) }} yet</h2><p>Create the first record to start managing this area.</p><a class="admin-button primary" href="{{ route('admin.resources.create', $resource) }}">Add {{ strtolower($definition['singular']) }}</a></div></td></tr>
@endforelse
</tbody>
</table>
</div>
</div>
<div class="crud-pagination">{{ $records->links() }}</div>
@endsection
+4 -4
View File
@@ -3,10 +3,10 @@
@section('page-title','Dashboard')
@section('content')
<div class="page-heading admin-heading"><div><div class="eyebrow">Marketplace control centre</div><h1>Dashboard</h1><p>Live overview of users, DJs, bookings and moderation.</p></div></div>
<div class="admin-stats">@foreach($stats as $key=>$value)<a class="admin-stat" href="{{ $key === 'pending reviews' ? route('admin.section','reviews') : ($key === 'djs' ? route('admin.section','djs') : ($key === 'bookings' ? route('admin.section','bookings') : route('admin.section','users'))) }}"><span>{{ ucfirst($key) }}</span><strong>{{ number_format($value) }}</strong><small>Open section →</small></a>@endforeach</div>
<div class="admin-stats">@foreach($stats as $key=>$value)<a class="admin-stat" href="{{ $key === 'pending reviews' ? route('admin.resources.index','reviews') : ($key === 'djs' ? route('admin.resources.index','djs') : ($key === 'bookings' ? route('admin.resources.index','bookings') : route('admin.resources.index','users'))) }}"><span>{{ ucfirst($key) }}</span><strong>{{ number_format($value) }}</strong><small>Open section →</small></a>@endforeach</div>
<div class="admin-grid">
<section class="admin-card"><div class="panel-head"><h2>Newest users</h2><a href="{{ route('admin.section','users') }}">Manage</a></div>@forelse($recentUsers as $user)<div class="admin-list-row"><div><strong>{{ $user->name }}</strong><span>{{ $user->email }}</span></div><span>{{ $user->created_at?->diffForHumans() }}</span></div>@empty<div class="empty-state">No users yet.</div>@endforelse</section>
<section class="admin-card"><div class="panel-head"><h2>Latest enquiries</h2><a href="{{ route('admin.section','clients') }}">Clients</a></div>@forelse($recentEnquiries as $item)<div class="admin-list-row"><div><strong>{{ $item->user?->name }}</strong><span>{{ $item->djProfile?->stage_name }} · {{ $item->event_type }}</span></div><span class="status-pill">{{ ucfirst($item->status) }}</span></div>@empty<div class="empty-state">No enquiries yet.</div>@endforelse</section>
<section class="admin-card admin-card-wide"><div class="panel-head"><h2>Recent bookings</h2><a href="{{ route('admin.section','bookings') }}">View all</a></div><div class="responsive-table"><table><thead><tr><th>Client</th><th>DJ</th><th>Date</th><th>Total</th><th>Status</th></tr></thead><tbody>@forelse($recentBookings as $item)<tr><td>{{ $item->client?->name }}</td><td>{{ $item->djProfile?->stage_name }}</td><td>{{ optional($item->event_date)->format('j M Y') }}</td><td>£{{ number_format((float)$item->total,2) }}</td><td><span class="status-pill">{{ ucfirst($item->status) }}</span></td></tr>@empty<tr><td colspan="5">No bookings yet.</td></tr>@endforelse</tbody></table></div></section>
<section class="admin-card"><div class="panel-head"><h2>Newest users</h2><a href="{{ route('admin.resources.index','users') }}">Manage</a></div>@forelse($recentUsers as $user)<div class="admin-list-row"><div><strong>{{ $user->name }}</strong><span>{{ $user->email }}</span></div><span>{{ $user->created_at?->diffForHumans() }}</span></div>@empty<div class="empty-state">No users yet.</div>@endforelse</section>
<section class="admin-card"><div class="panel-head"><h2>Latest enquiries</h2><a href="{{ route('admin.resources.index','clients') }}">Clients</a></div>@forelse($recentEnquiries as $item)<div class="admin-list-row"><div><strong>{{ $item->user?->name }}</strong><span>{{ $item->djProfile?->stage_name }} · {{ $item->event_type }}</span></div><span class="status-pill">{{ ucfirst($item->status) }}</span></div>@empty<div class="empty-state">No enquiries yet.</div>@endforelse</section>
<section class="admin-card admin-card-wide"><div class="panel-head"><h2>Recent bookings</h2><a href="{{ route('admin.resources.index','bookings') }}">View all</a></div><div class="responsive-table"><table><thead><tr><th>Client</th><th>DJ</th><th>Date</th><th>Total</th><th>Status</th></tr></thead><tbody>@forelse($recentBookings as $item)<tr><td>{{ $item->client?->name }}</td><td>{{ $item->djProfile?->stage_name }}</td><td>{{ optional($item->event_date)->format('j M Y') }}</td><td>£{{ number_format((float)$item->total,2) }}</td><td><span class="status-pill">{{ ucfirst($item->status) }}</span></td></tr>@empty<tr><td colspan="5">No bookings yet.</td></tr>@endforelse</tbody></table></div></section>
</div>
@endsection
-38
View File
@@ -1,38 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>{{ ucwords(str_replace('-', ' ', $section)) }} — BeatBook Admin</title>
@vite(['resources/css/app.css'])
</head>
<body>
<div class="admin">
<aside class="sidebar">
<a class="brand" href="{{ route('admin.dashboard') }}"><b>◉</b> BeatBook</a>
<p class="muted">Administration</p>
<a href="{{ route('admin.dashboard') }}">Dashboard</a>
@foreach([
'users' => 'Users', 'djs' => 'DJs & Businesses', 'clients' => 'Clients',
'bookings' => 'Bookings', 'payments' => 'Payments', 'reviews' => 'Reviews',
'messages' => 'Messages', 'notifications' => 'Notifications', 'support' => 'Support',
'reports' => 'Reports', 'content' => 'Content', 'roles' => 'Roles & Permissions',
'privacy' => 'Privacy', 'settings' => 'Settings'
] as $key => $label)
<a href="{{ route('admin.section', $key) }}">{{ $label }}</a>
@endforeach
<a href="{{ route('home') }}">Back to site</a>
</aside>
<main class="admin-main">
<div class="eyebrow">Administration</div>
<h1>{{ ucwords(str_replace('-', ' ', $section)) }}</h1>
<p style="color:#64748b">This BeatBook administration section is connected and ready for its management workflow.</p>
<div class="stats">
@foreach($counts as $label => $value)
<div class="stat"><span>{{ ucfirst($label) }}</span><strong>{{ number_format($value) }}</strong></div>
@endforeach
</div>
</main>
</div>
</body>
</html>
@@ -1,4 +0,0 @@
@extends('layouts.admin') @section('title','Bookings') @section('page-title','Bookings') @section('content')
<div class="page-heading admin-heading"><div><div class="eyebrow">Events</div><h1>Bookings</h1><p>All marketplace bookings and event values.</p></div><span class="metric-chip">{{ number_format($counts['bookings']) }} bookings</span></div>
<div class="admin-card table-card"><div class="responsive-table"><table><thead><tr><th>Client</th><th>DJ</th><th>Event date</th><th>Total</th><th>Deposit</th><th>Status</th></tr></thead><tbody>@forelse($bookings as $item)<tr><td>{{ $item->client?->name }}</td><td>{{ $item->djProfile?->stage_name }}</td><td>{{ optional($item->event_date)->format('j M Y') }}</td><td>£{{ number_format((float)$item->total,2) }}</td><td>£{{ number_format((float)$item->deposit,2) }}</td><td><span class="status-pill">{{ ucfirst($item->status) }}</span></td></tr>@empty<tr><td colspan="6">No bookings yet.</td></tr>@endforelse</tbody></table></div></div>{{ $bookings->links() }}
@endsection
@@ -1,4 +0,0 @@
@extends('layouts.admin') @section('title','Clients') @section('page-title','Clients') @section('content')
<div class="page-heading admin-heading"><div><div class="eyebrow">Customers</div><h1>Clients</h1><p>Review client activity across enquiries and bookings.</p></div><span class="metric-chip">{{ number_format($counts['clients']) }} clients</span></div>
<div class="admin-card table-card"><div class="responsive-table"><table><thead><tr><th>Client</th><th>Phone</th><th>Enquiries</th><th>Bookings</th><th>Joined</th></tr></thead><tbody>@forelse($clients as $client)<tr><td><strong>{{ $client->name }}</strong><small>{{ $client->email }}</small></td><td>{{ $client->phone ?: '—' }}</td><td>{{ $client->enquiries_count }}</td><td>{{ $client->bookings_count }}</td><td>{{ $client->created_at?->format('j M Y') }}</td></tr>@empty<tr><td colspan="5">No clients yet.</td></tr>@endforelse</tbody></table></div></div>{{ $clients->links() }}
@endsection
@@ -1,4 +0,0 @@
@extends('layouts.admin') @section('title','Content') @section('page-title','Content') @section('content')
<div class="page-heading admin-heading"><div><div class="eyebrow">Website</div><h1>Content</h1><p>Public-site content and marketplace presentation.</p></div></div>
<div class="settings-grid"><a class="admin-card action-card" href="{{ route('home') }}"><h2>Homepage</h2><p>Review hero, featured DJs, how it works and DJ proposition.</p><span>Open page →</span></a><a class="admin-card action-card" href="{{ route('djs.index') }}"><h2>DJ directory</h2><p>Review public listings, filtering and profile presentation.</p><span>Open directory →</span></a><a class="admin-card action-card" href="{{ route('privacy') }}"><h2>Legal content</h2><p>Review privacy, cookie and terms content.</p><span>Open privacy →</span></a></div>
@endsection
@@ -1,4 +0,0 @@
@extends('layouts.admin') @section('title','DJs & businesses') @section('page-title','DJs & businesses') @section('content')
<div class="page-heading admin-heading"><div><div class="eyebrow">Marketplace supply</div><h1>DJs & businesses</h1><p>Control visibility and featured placement.</p></div><span class="metric-chip">{{ number_format($counts['djs']) }} profiles</span></div>
<div class="admin-card table-card"><div class="responsive-table"><table><thead><tr><th>DJ</th><th>Owner</th><th>City</th><th>Price</th><th>Published</th><th>Featured</th></tr></thead><tbody>@forelse($djs as $dj)<tr><td><strong>{{ $dj->stage_name }}</strong><small>{{ $dj->business?->name ?: 'Independent' }}</small></td><td>{{ $dj->user?->email }}</td><td>{{ $dj->city }}</td><td>{{ $dj->price_from ? '£'.number_format((float)$dj->price_from) : '—' }}</td><td><form method="post" action="{{ route('admin.djs.published',$dj) }}">@csrf @method('PATCH')<button class="toggle-button {{ $dj->is_published ? 'on' : '' }}" type="submit">{{ $dj->is_published ? 'Published' : 'Hidden' }}</button></form></td><td><form method="post" action="{{ route('admin.djs.featured',$dj) }}">@csrf @method('PATCH')<button class="toggle-button {{ $dj->is_featured ? 'on' : '' }}" type="submit">{{ $dj->is_featured ? 'Featured' : 'Standard' }}</button></form></td></tr>@empty<tr><td colspan="6">No DJ profiles yet.</td></tr>@endforelse</tbody></table></div></div>{{ $djs->links() }}
@endsection
@@ -1,4 +0,0 @@
@extends('layouts.admin') @section('title','Messages') @section('page-title','Messages') @section('content')
<div class="page-heading admin-heading"><div><div class="eyebrow">Communication</div><h1>Messages</h1><p>Marketplace messaging will appear here once conversations are created.</p></div></div>
<div class="admin-card"><div class="feature-empty"><div class="feature-icon">✉</div><h2>No conversations yet</h2><p>Enquiry messaging can be added here without exposing private contact details between clients and DJs.</p><a class="admin-button" href="{{ route('admin.section','clients') }}">View clients</a></div></div>
@endsection
@@ -1,4 +0,0 @@
@extends('layouts.admin') @section('title','Notifications') @section('page-title','Notifications') @section('content')
<div class="page-heading admin-heading"><div><div class="eyebrow">Delivery</div><h1>Notifications</h1><p>System alerts and delivery health.</p></div></div>
<div class="settings-grid"><div class="admin-card"><h2>Notification channels</h2><div class="settings-list"><div><strong>Email</strong><span>Laravel mail channel</span><span class="status-pill">Configured</span></div><div><strong>Database</strong><span>In-app notifications</span><span class="status-pill">Ready</span></div></div></div><div class="admin-card"><h2>Recommended events</h2><p class="admin-muted">Booking confirmation, new enquiry, review request, payment receipt and support replies can be delivered from this area.</p></div></div>
@endsection
@@ -1,4 +0,0 @@
@extends('layouts.admin') @section('title','Payments') @section('page-title','Payments') @section('content')
<div class="page-heading admin-heading"><div><div class="eyebrow">Finance</div><h1>Payments</h1><p>Payment state and booking value overview.</p></div></div>
<div class="admin-card table-card"><div class="responsive-table"><table><thead><tr><th>Booking</th><th>Client</th><th>DJ</th><th>Total</th><th>Deposit</th><th>Payment</th></tr></thead><tbody>@forelse($bookings as $item)<tr><td>{{ Str::limit($item->id,10,'…') }}</td><td>{{ $item->client?->name }}</td><td>{{ $item->djProfile?->stage_name }}</td><td>£{{ number_format((float)$item->total,2) }}</td><td>£{{ number_format((float)$item->deposit,2) }}</td><td><span class="status-pill">{{ $item->paid_at ? 'Paid' : 'Outstanding' }}</span></td></tr>@empty<tr><td colspan="6">No payment records yet.</td></tr>@endforelse</tbody></table></div></div>{{ $bookings->links() }}
@endsection
@@ -1,4 +0,0 @@
@extends('layouts.admin') @section('title','Privacy') @section('page-title','Privacy') @section('content')
<div class="page-heading admin-heading"><div><div class="eyebrow">Compliance</div><h1>Privacy</h1><p>Quick access to the public legal policies and privacy controls.</p></div></div>
<div class="settings-grid"><a class="admin-card action-card" href="{{ route('privacy') }}"><h2>Privacy policy</h2><p>Review the current public privacy policy.</p><span>Open policy →</span></a><a class="admin-card action-card" href="{{ route('cookies') }}"><h2>Cookie policy</h2><p>Review necessary, analytics and marketing cookie information.</p><span>Open policy →</span></a><a class="admin-card action-card" href="{{ route('terms') }}"><h2>Terms</h2><p>Review marketplace terms shown to users.</p><span>Open terms →</span></a></div>
@endsection
@@ -1,4 +0,0 @@
@extends('layouts.admin') @section('title','Reports') @section('page-title','Reports') @section('content')
<div class="page-heading admin-heading"><div><div class="eyebrow">Performance</div><h1>Reports</h1><p>Marketplace totals calculated from live BeatBook data.</p></div></div>
<div class="admin-stats"><div class="admin-stat"><span>Booking value</span><strong>£{{ number_format((float)$report['booking_value'],2) }}</strong></div><div class="admin-stat"><span>Deposits</span><strong>£{{ number_format((float)$report['deposits'],2) }}</strong></div><div class="admin-stat"><span>Published DJs</span><strong>{{ $report['published_djs'] }}</strong></div><div class="admin-stat"><span>Pending reviews</span><strong>{{ $report['pending_reviews'] }}</strong></div></div>
@endsection
@@ -1,4 +0,0 @@
@extends('layouts.admin') @section('title','Reviews') @section('page-title','Reviews') @section('content')
<div class="page-heading admin-heading"><div><div class="eyebrow">Trust & safety</div><h1>Reviews</h1><p>Moderate feedback before it appears publicly.</p></div><span class="metric-chip">{{ number_format($counts['reviews']) }} reviews</span></div>
<div class="admin-card table-card"><div class="responsive-table"><table><thead><tr><th>Reviewer</th><th>DJ</th><th>Rating</th><th>Review</th><th>Status</th><th>Moderate</th></tr></thead><tbody>@forelse($reviews as $review)<tr><td>{{ $review->user?->name }}</td><td>{{ $review->djProfile?->stage_name }}</td><td>★ {{ $review->rating }}/5</td><td><strong>{{ $review->title ?: 'Review' }}</strong><small>{{ Str::limit($review->body,80) }}</small></td><td><span class="status-pill">{{ ucfirst($review->status) }}</span></td><td><form class="inline-controls" method="post" action="{{ route('admin.reviews.status',$review) }}">@csrf @method('PATCH')<select name="status"><option value="pending" @selected($review->status==='pending')>Pending</option><option value="published" @selected($review->status==='published')>Published</option><option value="rejected" @selected($review->status==='rejected')>Rejected</option></select><button class="admin-button">Save</button></form></td></tr>@empty<tr><td colspan="6">No reviews yet.</td></tr>@endforelse</tbody></table></div></div>{{ $reviews->links() }}
@endsection
@@ -1,4 +0,0 @@
@extends('layouts.admin') @section('title','Roles & permissions') @section('page-title','Roles & permissions') @section('content')
<div class="page-heading admin-heading"><div><div class="eyebrow">Access control</div><h1>Roles & permissions</h1><p>Roles currently available to BeatBook accounts.</p></div></div>
<div class="role-grid">@foreach($roles as $role)<div class="admin-card"><span class="role-badge">{{ $role->name }}</span><strong class="role-count">{{ $role->users_count }}</strong><p>{{ Str::plural('user',$role->users_count) }}</p></div>@endforeach</div>
@endsection
@@ -1,4 +0,0 @@
@extends('layouts.admin') @section('title','Settings') @section('page-title','Settings') @section('content')
<div class="page-heading admin-heading"><div><div class="eyebrow">Application</div><h1>Settings</h1><p>Operational controls for the BeatBook installation.</p></div></div>
<div class="settings-grid"><div class="admin-card"><h2>Environment</h2><div class="settings-list"><div><strong>Application</strong><span>{{ config('app.name') }}</span></div><div><strong>Environment</strong><span>{{ app()->environment() }}</span></div><div><strong>URL</strong><span>{{ config('app.url') }}</span></div></div></div><div class="admin-card"><h2>Maintenance</h2><p class="admin-muted">Clear application cache after configuration or content changes.</p><form method="post" action="{{ route('admin.settings.clear-cache') }}">@csrf<button class="admin-button" type="submit">Clear application cache</button></form></div></div>
@endsection
@@ -1,4 +0,0 @@
@extends('layouts.admin') @section('title','Support') @section('page-title','Support') @section('content')
<div class="page-heading admin-heading"><div><div class="eyebrow">Customer care</div><h1>Support</h1><p>Central area for customer and DJ support cases.</p></div></div>
<div class="admin-card"><div class="feature-empty"><div class="feature-icon">?</div><h2>No open support cases</h2><p>The support queue is ready for a ticket model and inbound support form when you enable that workflow.</p></div></div>
@endsection
@@ -1,4 +0,0 @@
@extends('layouts.admin') @section('title','Users') @section('page-title','Users') @section('content')
<div class="page-heading admin-heading"><div><div class="eyebrow">People</div><h1>Users</h1><p>Manage accounts and assign marketplace roles.</p></div><span class="metric-chip">{{ number_format($counts['users']) }} users</span></div>
<div class="admin-card table-card"><div class="responsive-table"><table><thead><tr><th>User</th><th>Role</th><th>Joined</th><th>Change role</th></tr></thead><tbody>@foreach($users as $user)<tr><td><strong>{{ $user->name }}</strong><small>{{ $user->email }}</small></td><td>{{ $user->getRoleNames()->join(', ') ?: 'No role' }}</td><td>{{ $user->created_at?->format('j M Y') }}</td><td><form class="inline-controls" method="post" action="{{ route('admin.users.role',$user) }}">@csrf @method('PATCH')<select name="role">@foreach(['client','dj','support','moderator','admin','super-admin'] as $role)<option value="{{ $role }}" @selected($user->hasRole($role))>{{ ucfirst($role) }}</option>@endforeach</select><button class="admin-button" type="submit">Save</button></form></td></tr>@endforeach</tbody></table></div></div>{{ $users->links() }}
@endsection
+9
View File
@@ -0,0 +1,9 @@
@extends('layouts.app')
@section('title', $page->title)
@section('content')
<main class="section legal-page"><div class="shell legal-shell">
<div class="eyebrow">BeatBook</div><h1>{{ $page->title }}</h1>
@if($page->excerpt)<p class="legal-meta">{{ $page->excerpt }}</p>@endif
<div class="legal-copy">{!! nl2br(e($page->body ?? '')) !!}</div>
</div></main>
@endsection
+3 -2
View File
@@ -18,12 +18,12 @@
<nav class="admin-nav">
<a class="{{ request()->routeIs('admin.dashboard') ? 'active' : '' }}" href="{{ route('admin.dashboard') }}">Overview</a>
@foreach([
'users' => 'Users', 'djs' => 'DJs & businesses', 'clients' => 'Clients', 'bookings' => 'Bookings',
'users' => 'Users', 'clients' => 'Clients', 'djs' => 'DJs', 'businesses' => 'Businesses', 'bookings' => 'Bookings',
'payments' => 'Payments', 'reviews' => 'Reviews', 'messages' => 'Messages', 'notifications' => 'Notifications',
'support' => 'Support', 'reports' => 'Reports', 'content' => 'Content', 'roles' => 'Roles & permissions',
'privacy' => 'Privacy', 'settings' => 'Settings'
] as $key => $label)
<a class="{{ request()->is('admin/'.$key) ? 'active' : '' }}" href="{{ route('admin.section', $key) }}">{{ $label }}</a>
<a class="{{ request()->is('admin/'.$key) || request()->is('admin/'.$key.'/*') ? 'active' : '' }}" href="{{ route('admin.resources.index', $key) }}">{{ $label }}</a>
@endforeach
</nav>
<div class="admin-sidebar-foot">
@@ -40,6 +40,7 @@
<a class="button small" href="{{ route('home') }}">Public site</a>
</header>
@if(session('success'))<div class="admin-flash">{{ session('success') }}</div>@endif
@if(session('error'))<div class="admin-flash error">{{ session('error') }}</div>@endif
<div class="admin-page">@yield('content')</div>
</main>
</div>
+10
View File
@@ -0,0 +1,10 @@
@extends('layouts.app')
@section('title', $document?->title ?? $fallbackTitle)
@section('content')
<main class="section legal-page"><div class="shell legal-shell">
<div class="eyebrow">Legal</div>
<h1>{{ $document?->title ?? $fallbackTitle }}</h1>
@if($document)<p class="legal-meta">Version {{ $document->version }} · Updated {{ $document->updated_at?->format('j F Y') }}</p>@endif
<div class="legal-copy">{!! nl2br(e($document?->body ?? $fallbackBody)) !!}</div>
</div></main>
@endsection
+14 -9
View File
@@ -3,21 +3,24 @@
use App\Http\Controllers\Account\DashboardController as AccountDashboardController;
use App\Http\Controllers\Account\PageController as AccountPageController;
use App\Http\Controllers\Admin\ActionController as AdminActionController;
use App\Http\Controllers\Admin\CrudController;
use App\Http\Controllers\Admin\DashboardController;
use App\Http\Controllers\Admin\SectionController;
use App\Http\Controllers\Auth\AuthenticatedSessionController;
use App\Http\Controllers\Auth\NewPasswordController;
use App\Http\Controllers\Auth\PasswordResetLinkController;
use App\Http\Controllers\Auth\RegisteredUserController;
use App\Http\Controllers\ContentPageController;
use App\Http\Controllers\DjController;
use App\Http\Controllers\EnquiryController;
use App\Http\Controllers\HomeController;
use App\Http\Controllers\LegalController;
use App\Http\Controllers\ReviewController;
use Illuminate\Support\Facades\Route;
Route::get('/', [HomeController::class, 'index'])->name('home');
Route::get('/djs', [DjController::class, 'index'])->name('djs.index');
Route::get('/dj/{djProfile:slug}', [DjController::class, 'show'])->name('djs.show');
Route::get('/pages/{page:slug}', [ContentPageController::class, 'show'])->name('content.show');
Route::middleware('guest')->group(function () {
Route::get('/login', [AuthenticatedSessionController::class, 'create'])->name('login');
@@ -44,14 +47,16 @@ Route::middleware('auth')->group(function () {
Route::middleware(['auth', 'role:admin|super-admin'])->prefix('admin')->name('admin.')->group(function () {
Route::get('/', DashboardController::class)->name('dashboard');
Route::get('/{section}', SectionController::class)->name('section');
Route::patch('/users/{user}/role', [AdminActionController::class, 'userRole'])->name('users.role');
Route::patch('/djs/{djProfile}/published', [AdminActionController::class, 'toggleDjPublished'])->name('djs.published');
Route::patch('/djs/{djProfile}/featured', [AdminActionController::class, 'toggleDjFeatured'])->name('djs.featured');
Route::patch('/reviews/{review}/status', [AdminActionController::class, 'reviewStatus'])->name('reviews.status');
Route::post('/settings/clear-cache', [AdminActionController::class, 'clearCaches'])->name('settings.clear-cache');
Route::get('/{resource}', [CrudController::class, 'index'])->name('resources.index');
Route::get('/{resource}/create', [CrudController::class, 'create'])->name('resources.create');
Route::post('/{resource}', [CrudController::class, 'store'])->name('resources.store');
Route::get('/{resource}/{id}/edit', [CrudController::class, 'edit'])->name('resources.edit');
Route::put('/{resource}/{id}', [CrudController::class, 'update'])->name('resources.update');
Route::delete('/{resource}/{id}', [CrudController::class, 'destroy'])->name('resources.destroy');
});
Route::view('/privacy', 'legal.privacy')->name('privacy');
Route::view('/cookies', 'legal.cookies')->name('cookies');
Route::view('/terms', 'legal.terms')->name('terms');
Route::get('/privacy', [LegalController::class, 'privacy'])->name('privacy');
Route::get('/cookies', [LegalController::class, 'cookies'])->name('cookies');
Route::get('/terms', [LegalController::class, 'terms'])->name('terms');
+2 -1
View File
@@ -26,11 +26,12 @@ class AccountAndAdminRoutesTest extends TestCase
$admin = User::factory()->create();
$admin->assignRole('admin');
$sections = ['users', 'djs', 'clients', 'bookings', 'payments', 'reviews', 'messages', 'notifications', 'support', 'reports', 'content', 'roles', 'privacy', 'settings'];
$sections = ['users', 'clients', 'djs', 'businesses', 'bookings', 'payments', 'reviews', 'messages', 'notifications', 'support', 'reports', 'content', 'roles', 'privacy', 'settings'];
$this->actingAs($admin)->get('/admin')->assertSuccessful();
foreach ($sections as $section) {
$this->actingAs($admin)->get('/admin/'.$section)->assertSuccessful();
$this->actingAs($admin)->get('/admin/'.$section.'/create')->assertSuccessful();
}
}
}
+70
View File
@@ -0,0 +1,70 @@
<?php
namespace Tests\Feature;
use App\Models\AppSetting;
use App\Models\User;
use Illuminate\Foundation\Http\Middleware\PreventRequestForgery;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Spatie\Permission\Models\Role;
use Tests\TestCase;
class AdminCrudTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
$this->withoutMiddleware(PreventRequestForgery::class);
Role::create(['name' => 'admin', 'guard_name' => 'web']);
}
public function test_admin_can_create_edit_and_delete_setting(): void
{
$admin = User::factory()->create();
$admin->assignRole('admin');
$this->actingAs($admin)->post('/admin/settings', [
'label' => 'Marketplace name',
'key' => 'marketplace.name',
'value' => 'BeatBook',
'type' => 'text',
'group' => 'general',
'description' => 'Displayed marketplace name.',
])->assertRedirect('/admin/settings');
$setting = AppSetting::firstOrFail();
$this->assertSame('BeatBook', $setting->value);
$this->actingAs($admin)->put('/admin/settings/'.$setting->id, [
'label' => 'Marketplace name',
'key' => 'marketplace.name',
'value' => 'BeatBook UK',
'type' => 'text',
'group' => 'general',
'description' => 'Displayed marketplace name.',
])->assertRedirect('/admin/settings');
$this->assertDatabaseHas('app_settings', ['id' => $setting->id, 'value' => 'BeatBook UK']);
$this->actingAs($admin)->delete('/admin/settings/'.$setting->id)->assertRedirect('/admin/settings');
$this->assertDatabaseMissing('app_settings', ['id' => $setting->id]);
}
public function test_admin_can_create_client(): void
{
$admin = User::factory()->create();
$admin->assignRole('admin');
$this->actingAs($admin)->post('/admin/clients', [
'name' => 'New Client',
'email' => 'new-client@example.test',
'phone' => '01234567890',
'password' => 'password12345',
'marketing_opt_in' => '0',
])->assertRedirect('/admin/clients');
$client = User::where('email', 'new-client@example.test')->firstOrFail();
$this->assertTrue($client->hasRole('client'));
}
}