feature(admin-panel): add admin dashboard

This commit is contained in:
kusowl 2026-01-27 17:15:04 +05:30
parent 4fd98957cb
commit 91a11c8f56
18 changed files with 352 additions and 34 deletions

View File

@ -0,0 +1,22 @@
<?php
namespace App\Actions;
use App\Enums\UserTypes;
use App\Models\Deal;
use App\Models\User;
final readonly class GetAdminStatsAction
{
/**
* @return array<string, int>
*/
public function execute(): array
{
return [
'listings' => Deal::count(),
'customers' => User::where('role', UserTypes::User->value)->count(),
'brokers' => User::where('role', UserTypes::Broker->value)->count(),
];
}
}

View File

@ -0,0 +1,15 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Actions\GetAdminStatsAction;
use App\Http\Controllers\Controller;
class AdminDashboardController extends Controller
{
public function __invoke(GetAdminStatsAction $action)
{
return view('dashboards.admin.index')
->with('stats', $action->execute());
}
}

14
app/Models/Admin.php Normal file
View File

@ -0,0 +1,14 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphOne;
class Admin extends Model
{
public function user(): MorphOne
{
return $this->morphOne(User::class, 'role');
}
}

View File

@ -57,6 +57,16 @@ public function isBroker(): bool
return $this->type instanceof Broker; return $this->type instanceof Broker;
} }
public function isAdmin(): bool
{
return $this->type instanceof Admin;
}
public function isCustomer(): bool
{
return $this->type instanceof Customer;
}
public function deals(): HasMany public function deals(): HasMany
{ {
return $this->hasMany(Deal::class); return $this->hasMany(Deal::class);
@ -80,11 +90,6 @@ public function recentSearches(): HasMany
return $this->hasMany(RecentSearch::class); return $this->hasMany(RecentSearch::class);
} }
public function isCustomer(): bool
{
return $this->type instanceof Customer;
}
public function dealsInteractions(): HasManyThrough public function dealsInteractions(): HasManyThrough
{ {
return $this->hasManyThrough(Interaction::class, Deal::class); return $this->hasManyThrough(Interaction::class, Deal::class);

View File

@ -0,0 +1,21 @@
<?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('admins', function (Blueprint $table) {
$table->id();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('admins');
}
};

View File

@ -0,0 +1,25 @@
<?php
namespace Database\Seeders;
use App\Enums\UserStatus;
use App\Enums\UserTypes;
use App\Models\Admin;
use Illuminate\Database\Seeder;
class AdminUserSeeder extends Seeder
{
public function run(): void
{
$data = [
'name' => 'Admin',
'email' => 'admin@dealhub.com',
'password' => 'password',
'status' => UserStatus::Active->value,
'role' => UserTypes::Admin->value,
];
$admin = Admin::firstOrCreate();
$admin->user()->updateOrCreate($data);
}
}

View File

@ -2,7 +2,6 @@
namespace Database\Seeders; namespace Database\Seeders;
use App\Models\User;
use Illuminate\Database\Console\Seeds\WithoutModelEvents; use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder; use Illuminate\Database\Seeder;
@ -13,13 +12,5 @@ class DatabaseSeeder extends Seeder
/** /**
* Seed the application's database. * Seed the application's database.
*/ */
public function run(): void public function run(): void {}
{
// User::factory(10)->create();
User::factory()->create([
'name' => 'Test User',
'email' => 'test@example.com',
]);
}
} }

View File

@ -0,0 +1,26 @@
@props(['title' => ''])
<x-layout :title="$title">
<div class="flex h-screen overflow-x-hidden">
<x-dashboard.admin.sidebar class="hidden shrink-0 md:block md:h-screen"
active-class="bg-linear-120 from-[#1a55ed] to-[#9b1cff] text-white"/>
<section
class=" flex flex-col space-y-4 md:space-y-8 bg-[#F9FAFB] overflow-y-auto overflow-x-hidden h-screen w-full">
{{$heading ?? ''}}
@session('success')
<div class="wrapper">
<x-ui.alert variant="success">{{$value}}</x-ui.alert>
</div>
@endsession
@session('error')
<div class="wrapper">
<x-ui.alert variant="error">{{$value}}</x-ui.alert>
</div>
@endsession
{{$slot}}
</section>
</div>
</x-layout>

View File

@ -0,0 +1,45 @@
<nav class="flex justify-between items-center wrapper py-6 shadow">
<div class="logo flex space-x-2 items-center">
<x-logo class="md:hidden" />
<div class="">
<a href="" class="font-bold text-2xl">Dashboard</a>
<p class="text-sm text-accent-600">Manage Users, Brokers and Reports</p>
</div>
</div>
<!-- mobile menu btn-->
<x-ui.button class="md:hidden" id="openBtn">
<x-heroicon-o-bars-3 class="w-8 0"/>
</x-ui.button>
</nav>
<!-- mobile menu btn-->
<div id='mobileMenu' class="fixed top-0 left-0 z-100 h-screen w-full translate-x-full bg-gray-200 p-10 text-xl transition-transform duration-500 ease-in-out overflow-y-auto">
<div class="flex justify-between mb-8">
<x-logo/>
<x-ui.button id="closeBtn">
<x-heroicon-o-x-mark class="w-8 ml-auto"/>
</x-ui.button>
</div>
<div class="nav-links mb-10">
<ul class="flex flex-col space-y-8 text-accent-600">
<a href="{{route('broker.profile.show', auth()->user())}}" class="ui-btn flex border space-x-3 border-gray-200 items-center">
<x-heroicon-o-user class="w-6"/>
<p>Profile</p>
</a>
<a href="{{route('broker.deals.create')}}" class="ui-btn ui-btn-neutral flex border space-x-3 items-center">
<x-heroicon-o-plus class="w-6"/>
<p>Create Deal</p>
</a>
<form method="post" action="{{route('logout')}}">
@csrf
@method('delete')
<x-ui.button class="flex space-x-3">
<x-heroicon-o-arrow-right-start-on-rectangle class="w-6"/>
<p>Logout</p>
</x-ui.button>
</form>
</ul>
</div>
</div>

View File

@ -0,0 +1,70 @@
@props(['activeClass' => 'bg-gray-100 text-gray-900'])
<div
id="sidebarWrapper" {{$attributes->merge([ 'class' => 'border-r border-r-gray-300 transition-all duration-300 ease-in-out w-64 relative'])}}>
<div class="hidden md:flex h-screen items-center">
<div id="sidebar"
class="flex flex-col p-4 pt-6 justify-between font-medium h-full w-full overflow-hidden transition-all duration-300 ease-in-out">
<div class="">
<div class="flex space-x-4 border-b border-b-gray-300 pb-6">
<x-logo/>
<a href="{{route('home')}}" class="whitespace-nowrap">
<p class="text-2xl font-bold">DealHub</p>
<p class="text-accent-600 text-sm">Admin Panel</p>
</a>
</div>
<div class="pt-6 flex flex-col space-y-3 whitespace-nowrap">
<x-dashboard.broker.sidebar.item :link="route('admin.dashboard')">
<x-heroicon-o-squares-2x2 class="min-w-5 w-5"/>
<p class="sidebar-text transition-opacity duration-300 ease-in-out ">Dashboard</p>
</x-dashboard.broker.sidebar.item>
<x-dashboard.broker.sidebar.item :active="\Illuminate\Support\Facades\Route::is('admin.customers.*')" :link="route('admin.customers.index')">
<x-heroicon-o-users class="min-w-5 w-5"/>
<p class="sidebar-text transition-opacity duration-300 ease-in-out ">Manage Customers</p>
</x-dashboard.broker.sidebar.item>
<x-dashboard.broker.sidebar.item :link="route('broker.deals.index')">
<svg class="w-5 min-w-5" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" fill="#000000" stroke="#000000" stroke-width="0.2"><g id="SVGRepo_bgCarrier" stroke-width="0"></g><g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"></g><g id="SVGRepo_iconCarrier"><path d="M512.536516 506.562145l-112.606908 197.063113 112.606908 225.21484 112.607932-225.21484z" fill="#000000"></path><path d="M680.856096 476.172205c36.824036-40.482378 59.488761-94.060249 59.488761-152.967444 0-125.613323-102.193994-227.807318-227.807317-227.807318s-227.807318 102.193994-227.807318 227.807318c0 58.906171 22.664726 112.485066 59.488761 152.967444-58.595933 32.817572-187.285008 138.139536-187.285008 430.442414 0 12.273313 9.951141 22.225479 22.225479 22.225479 12.273313 0 22.225479-9.951141 22.225479-22.225479 0-304.35708 145.515606-384.465868 178.379253-398.671253 37.433247 26.981426 83.210136 43.069736 132.773354 43.069737 49.564241 0 95.342154-16.08831 132.775401-43.070761 32.907674 14.226887 178.376182 94.379701 178.376182 398.673301 0 12.273313 9.951141 22.225479 22.225479 22.225479s22.225479-9.951141 22.225478-22.225479c-0.001024-292.303902-128.690099-397.625866-187.283984-430.443438zM329.179132 323.204761c0-101.098437 82.258947-183.357384 183.357384-183.357384s183.357384 82.258947 183.357384 183.357384-82.258947 183.357384-183.357384 183.357384-183.357384-82.258947-183.357384-183.357384z" fill="#020b07"></path></g></svg>
<p class="sidebar-text transition-opacity duration-300 ease-in-out">Manage Brokers</p>
</x-dashboard.broker.sidebar.item>
<x-dashboard.broker.sidebar.item :link="route('broker.deals.index')">
<x-heroicon-o-document-text class="w-5 min-w-5"/>
<p class="sidebar-text transition-opacity duration-300 ease-in-out">Manage Reports</p>
</x-dashboard.broker.sidebar.item>
</div>
</div>
<div class="">
<x-dashboard.broker.sidebar.item :link="route('broker.profile.show', auth()->user()->id)">
<x-heroicon-o-user class="w-5 min-w-5"/>
<p class="sidebar-text transition-opacity duration-300 ease-in-out">Profile</p>
</x-dashboard.broker.sidebar.item>
<form method="post" action="{{route('logout')}}">
@csrf
@method('delete')
<button
class="py-3 pl-3 border border-white hover:bg-red-50 hover:border-red-200 rounded-xl w-full mt-4 transition-all">
<div class="flex space-x-3 items-center text-red-500">
<x-heroicon-o-arrow-right-start-on-rectangle class="w-5 min-w-5"/>
<p class="sidebar-text transition-opacity duration-300 ease-in-out">Logout</p>
</div>
</button>
</form>
</div>
</div>
{{-- Toggle Button --}}
<div
class="text-gray-500 cursor-pointer hover:text-gray-900 rounded-full p-1.5 bg-white border border-gray-300 absolute -right-3.5 top-21">
<x-heroicon-c-chevron-left id="closeSidebarBtn" class="w-4"/>
<x-heroicon-c-chevron-right id="openSidebarBtn" class="w-4 hidden"/>
</div>
</div>
</div>
@vite(['resources/js/sidebar.js'])

View File

@ -0,0 +1,20 @@
@props(['stats' => []])
<div class="grid grid-cols-1 md:grid-cols-3 wrapper gap-y-4 gap-x-8">
<x-dashboard.stats-card title="Total Listings" :score="$stats['listings'] ?? 0">
<x-icon-square variant="blue">
<x-heroicon-o-document-text class=" w-6 lg:w-8" />
</x-icon-square>
</x-dashboard.stats-card>
<x-dashboard.stats-card title="Total Customers" :score="$stats['customers'] ?? 0">
<x-icon-square variant="purple">
<x-heroicon-o-users class=" w-6 lg:w-8" />
</x-icon-square>
</x-dashboard.stats-card>
<x-dashboard.stats-card title="Total Brokers" :score="$stats['brokers'] ?? 0">
<x-icon-square variant="pink">
<svg class="w-5 lg:w-8 fill-pink-700 stroke-pink-700" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" ><g id="SVGRepo_bgCarrier" stroke-width="0"></g><g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"></g><g id="SVGRepo_iconCarrier"><path d="M512.536516 506.562145l-112.606908 197.063113 112.606908 225.21484 112.607932-225.21484z" ></path><path d="M680.856096 476.172205c36.824036-40.482378 59.488761-94.060249 59.488761-152.967444 0-125.613323-102.193994-227.807318-227.807317-227.807318s-227.807318 102.193994-227.807318 227.807318c0 58.906171 22.664726 112.485066 59.488761 152.967444-58.595933 32.817572-187.285008 138.139536-187.285008 430.442414 0 12.273313 9.951141 22.225479 22.225479 22.225479 12.273313 0 22.225479-9.951141 22.225479-22.225479 0-304.35708 145.515606-384.465868 178.379253-398.671253 37.433247 26.981426 83.210136 43.069736 132.773354 43.069737 49.564241 0 95.342154-16.08831 132.775401-43.070761 32.907674 14.226887 178.376182 94.379701 178.376182 398.673301 0 12.273313 9.951141 22.225479 22.225479 22.225479s22.225479-9.951141 22.225478-22.225479c-0.001024-292.303902-128.690099-397.625866-187.283984-430.443438zM329.179132 323.204761c0-101.098437 82.258947-183.357384 183.357384-183.357384s183.357384 82.258947 183.357384 183.357384-82.258947 183.357384-183.357384 183.357384-183.357384-82.258947-183.357384-183.357384z"></path></g></svg>
</x-icon-square>
</x-dashboard.stats-card>
</div>

View File

@ -1,5 +1,10 @@
@props([ 'link' => '']) @props([ 'link' => '', 'active' => false])
@php
if (!$active){
$active = url()->current() === $link;
}
@endphp
@aware(['activeClass' => 'bg-gray-100 text-gray-900']) @aware(['activeClass' => 'bg-gray-100 text-gray-900'])
<a href="{{$link}}" {{$attributes->class(["flex space-x-3 items-center pl-3 py-3 rounded-xl hover:bg-gray-100 border border-transparent ease-in-out transition-all duration-300 active:scale-80 hover:border-gray-300", $activeClass => url()->current() == $link])}} > <a href="{{$link}}" {{$attributes->class(["flex space-x-3 items-center pl-3 py-3 rounded-xl hover:bg-gray-100 border border-transparent ease-in-out transition-all duration-300 active:scale-80 hover:border-gray-300", $activeClass => $active])}} >
{{$slot}} {{$slot}}
</a> </a>

View File

@ -1,6 +1,6 @@
@props(['stats' => []]) @props(['stats' => []])
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 wrapper gap-y-4 gap-x-8"> <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 wrapper gap-y-4 gap-x-8">
<x-dashboard.stats-card title="My Listings" :score="$stats['listings']"> <x-dashboard.stats-card title="My Listings" :score="$stats['listings'] ?? 0">
<x-icon-square variant="blue"> <x-icon-square variant="blue">
<x-heroicon-o-document-text class="w-8" /> <x-heroicon-o-document-text class="w-8" />
</x-icon-square> </x-icon-square>

View File

@ -1,12 +1,14 @@
@props(['title' => '', 'score' => '']) @props(['title' => '', 'score' => ''])
<x-dashboard.card> <x-dashboard.card>
<article class="flex justify-between"> <article class="h-full flex flex-col">
<div class=""> <div class="">
<h3 class="mb-2 text-sm text-accent-600">{{$title}}</h3> <h3 class="mb-2 text-sm text-accent-600">{{$title}}</h3>
<p class="text-4xl font-bold">{{$score}}</p>
</div> </div>
<div class=""> <div class="flex items-center justify-between">
{{$slot}} <p class="text-4xl font-bold">{{$score}}</p>
<div class="">
{{$slot}}
</div>
</div> </div>
</article> </article>
</x-dashboard.card> </x-dashboard.card>

View File

@ -0,0 +1,48 @@
<x-dashboard.admin.layout title="Customers">
<x-slot:heading>
<x-dashboard.page-heading
title="Manage Customers"
description="Edit, Delete and Login as Customer"
/>
</x-slot:heading>
<div class="flex items-center justify-center px-4 pb-4 pt-0 md:px-8 md:pb-8">
<x-dashboard.card class="w-full">
<h3 class="text-md font-bold mb-4">Customer details</h3>
<x-ui.table>
<x-ui.table.head>
<th>Name</th>
<th>Email</th>
<th>Location</th>
</x-ui.table.head>
@forelse($customers as $customer)
<x-ui.table.row>
<td class="text-sm px-4 text-center">{{$customer->user->name}}</td>
<td class="text-sm px-4 text-center">{{$customer->user->email}}</td>
<td class="text-sm px-4 text-center">{{$customer->location}}</td>
<x-slot:actions>
<div class="flex items-center justify-center space-x-5 py-1 px-2">
<x-ui.button-sm :link="route('admin.customers.edit', $customer)" variant="ghost">
<x-heroicon-o-pencil-square class="w-4"/>
</x-ui.button-sm>
<form action="{{route('admin.customers.destroy', $customer)}}"
onsubmit="return confirm('Are you sure to delete this ?')" method="post"
class=" h-full items-center flex justify-center">
@csrf
<x-ui.button-sm variant="red">
<x-heroicon-o-trash class="w-4"/>
</x-ui.button-sm>
</form>
</div>
</x-slot:actions>
</x-ui.table.row>
@empty
<x-ui.table.row>
<td colspan="2" class="text-center text-sm text-accent-600 py-2">No Deals found</td>
</x-ui.table.row>
@endforelse
</x-ui.table>
</x-dashboard.card>
</div>
</x-dashboard.admin.layout>

View File

@ -1,3 +1,6 @@
<x-layout title="Admin Dashboard"> <x-dashboard.admin.layout title="Admin Dashboard">
<x-slot:heading>
</x-layout> <x-dashboard.admin.navbar/>
</x-slot:heading>
<x-dashboard.admin.stats :stats="$stats" />
</x-dashboard.admin.layout>

View File

@ -1,26 +1,18 @@
<?php <?php
use App\Enums\UserTypes;
use App\Http\Controllers\ExplorePageController; use App\Http\Controllers\ExplorePageController;
use App\Http\Controllers\HomeController; use App\Http\Controllers\HomeController;
use App\Http\Middleware\HasRole;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
require __DIR__.'/web/auth.php'; require __DIR__.'/web/auth.php';
require __DIR__.'/web/broker.php'; require __DIR__.'/web/broker.php';
require __DIR__.'/web/interaction.php'; require __DIR__.'/web/interaction.php';
require __DIR__.'/web/customer.php'; require __DIR__.'/web/customer.php';
require __DIR__.'/web/admin.php';
Route::get('/', HomeController::class)->name('home'); Route::get('/', HomeController::class)->name('home');
Route::get('/explore', ExplorePageController::class)->name('explore'); Route::get('/explore', ExplorePageController::class)->name('explore');
Route::middleware('auth')->group(function () {
Route::view('/admin/dashboard', 'dashboards.admin.index')
->middleware(HasRole::class.':'.UserTypes::Admin->value)
->name('admin.dashboard');
});
/** /**
* This routes are accessed by JS XHR requests, and is loaded here cause * This routes are accessed by JS XHR requests, and is loaded here cause
* we do not want to use sanctum for web requests * we do not want to use sanctum for web requests

14
routes/web/admin.php Normal file
View File

@ -0,0 +1,14 @@
<?php
use App\Enums\UserTypes;
use App\Http\Controllers\Admin\AdminDashboardController;
use App\Http\Controllers\Admin\CustomerController;
use App\Http\Middleware\HasRole;
Route::prefix('/admin')
->name('admin.')
->middleware([HasRole::class.':'.UserTypes::Admin->value, 'auth'])
->group(function () {
Route::get('dashboard', AdminDashboardController::class)->name('dashboard');
Route::resource('customers', CustomerController::class)->except('show', 'create', 'store');
});