chore: introduce role management functionality with DTOs and Livewire components
- Added Role and User DTOs for dynamic role handling. - Implemented table and badge components for displaying role data. - Registered a Blade directive for scoped slots in `ScopeServiceProvider`. - Updated dashboard views to include role management section.
This commit is contained in:
parent
4a5436a3b4
commit
105123bb74
303
README.md
Normal file
303
README.md
Normal file
@ -0,0 +1,303 @@
|
|||||||
|
# Company SSO — Admin Panel
|
||||||
|
|
||||||
|
A single sign-on (SSO) administration panel built with **Laravel 13**, **Livewire 4**, and **Flux UI 2**. It provides user management, authentication flows (including two-factor authentication), profile & security settings, and a dashboard — all rendered server-side with reactive Livewire components.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
- [Tech Stack](#tech-stack)
|
||||||
|
- [Requirements](#requirements)
|
||||||
|
- [Installation](#installation)
|
||||||
|
- [Running the Application](#running-the-application)
|
||||||
|
- [Available Commands](#available-commands)
|
||||||
|
- [Project Structure](#project-structure)
|
||||||
|
- [Authentication Features](#authentication-features)
|
||||||
|
- [Testing](#testing)
|
||||||
|
- [Code Style](#code-style)
|
||||||
|
- [Libraries & Documentation](#libraries--documentation)
|
||||||
|
- [License](#license)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
| Layer | Technology | Version |
|
||||||
|
| ---------- | --------------------------- | ------- |
|
||||||
|
| Framework | Laravel | 13.x |
|
||||||
|
| PHP | PHP | 8.3+ |
|
||||||
|
| Frontend | Livewire | 4.x |
|
||||||
|
| CSS | Tailwind CSS | 4.x |
|
||||||
|
| Bundler | Vite | 8.x |
|
||||||
|
| Auth | Laravel Fortify | 1.x |
|
||||||
|
| Icons | Blade Lucide Icons | 1.x |
|
||||||
|
| DTOs | Spatie Laravel Data | 4.x |
|
||||||
|
| Testing | Pest | 4.x |
|
||||||
|
| Linting | Laravel Pint | 1.x |
|
||||||
|
| Database | SQLite (default) / MySQL | — |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- **PHP** ≥ 8.3
|
||||||
|
- **Composer** ≥ 2.x
|
||||||
|
- **Node.js** ≥ 20.x & **npm** ≥ 10.x
|
||||||
|
- **SQLite** (default) or any Laravel-supported database
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
### 1. Clone the repository
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone <repository-url> sentientgeeks_admin_sso
|
||||||
|
cd sentientgeeks_admin_sso
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Install PHP dependencies
|
||||||
|
|
||||||
|
```bash
|
||||||
|
composer install
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Configure environment
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
php artisan key:generate
|
||||||
|
```
|
||||||
|
|
||||||
|
Edit `.env` to configure your database, mail, and other services as needed. The default configuration uses **SQLite** — no extra setup required.
|
||||||
|
|
||||||
|
### 4. Create the database
|
||||||
|
|
||||||
|
For SQLite (default):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
touch database/database.sqlite
|
||||||
|
```
|
||||||
|
|
||||||
|
For MySQL/PostgreSQL, update the `DB_*` variables in `.env` accordingly.
|
||||||
|
|
||||||
|
### 5. Run migrations
|
||||||
|
|
||||||
|
```bash
|
||||||
|
php artisan migrate
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. Install Node dependencies & build assets
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
### Quick Setup (all-in-one)
|
||||||
|
|
||||||
|
Alternatively, run the bundled setup script that performs steps 2–6 automatically:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
composer setup
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Running the Application
|
||||||
|
|
||||||
|
### Development (recommended)
|
||||||
|
|
||||||
|
Start **all** development services concurrently — web server, queue worker, log tail, and Vite dev server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
composer run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
This launches:
|
||||||
|
|
||||||
|
| Service | URL / Info |
|
||||||
|
| ------------- | ---------------------------------- |
|
||||||
|
| Laravel server | `http://localhost:8000` |
|
||||||
|
| Queue worker | Listens with `--tries=1` |
|
||||||
|
| Pail (logs) | Real-time log streaming |
|
||||||
|
| Vite | HMR & asset compilation |
|
||||||
|
|
||||||
|
### Production build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
php artisan serve
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Available Commands
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
| --------------------- | -------------------------------------------------------- |
|
||||||
|
| `composer setup` | Full project setup (install, env, migrate, build) |
|
||||||
|
| `composer run dev` | Start all dev services concurrently |
|
||||||
|
| `composer run lint` | Fix code style with Pint |
|
||||||
|
| `composer run lint:check` | Check code style without modifying files |
|
||||||
|
| `composer run test` | Clear config, lint-check, and run all tests |
|
||||||
|
| `npm run dev` | Start Vite dev server with HMR |
|
||||||
|
| `npm run build` | Build production assets |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Livewire Commands
|
||||||
|
```bash
|
||||||
|
# Create a new Livewire component, follow dot (.) notation for sub-folders in name
|
||||||
|
php artisan make:livewire <name>
|
||||||
|
|
||||||
|
# Create a page component (full-page Livewire component)
|
||||||
|
php artisan make:livewire pages::<name> --page
|
||||||
|
```
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
├── app/
|
||||||
|
│ ├── Actions/Fortify/ # Auth action classes (create user, reset password)
|
||||||
|
│ ├── Concerns/ # Shared traits (validation rules)
|
||||||
|
│ ├── Data/Ui/ # Spatie Data DTOs for UI (Connected Services)
|
||||||
|
│ ├── Enums/ # ConnectionStatus, ConnectionTechTypes
|
||||||
|
│ ├── Http/Controllers/ # HTTP controllers
|
||||||
|
│ ├── Livewire/
|
||||||
|
│ │ ├── Actions/ # Livewire action classes (Logout)
|
||||||
|
│ │ └── Settings/ # Settings components (Profile, Security, Appearance, 2FA)
|
||||||
|
│ ├── Models/ # Eloquent models (User)
|
||||||
|
│ └── Providers/ # Service providers (App, Fortify)
|
||||||
|
├── config/ # Application configuration
|
||||||
|
├── database/
|
||||||
|
│ ├── factories/ # Model factories
|
||||||
|
│ ├── migrations/ # Database migrations
|
||||||
|
│ └── seeders/ # Database seeders
|
||||||
|
├── resources/
|
||||||
|
│ ├── css/ # Tailwind CSS entry point
|
||||||
|
│ ├── js/ # JavaScript entry point
|
||||||
|
│ └── views/
|
||||||
|
│ ├── components/ # Blade components (sidebar, topbar, logos)
|
||||||
|
│ ├── flux/ # Flux UI component overrides
|
||||||
|
│ ├── layouts/ # Application layouts
|
||||||
|
│ ├── livewire/
|
||||||
|
│ │ ├── auth/ # Auth views (login, register, 2FA, etc.)
|
||||||
|
│ │ └── settings/ # Settings views (profile, security, appearance)
|
||||||
|
│ ├── pages/ # Full-page Livewire components (dashboard)
|
||||||
|
│ └── partials/ # Reusable view partials
|
||||||
|
├── routes/
|
||||||
|
│ ├── web.php # Main web routes
|
||||||
|
│ ├── settings.php # Settings routes (profile, security, appearance)
|
||||||
|
│ └── console.php # Artisan console routes
|
||||||
|
├── tests/
|
||||||
|
│ ├── Feature/ # Feature tests (Auth, Settings, Dashboard)
|
||||||
|
│ └── Unit/ # Unit tests
|
||||||
|
├── vite.config.js # Vite + Tailwind CSS v4 + Laravel plugin config
|
||||||
|
├── composer.json
|
||||||
|
└── package.json
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Authentication Features
|
||||||
|
|
||||||
|
Powered by **Laravel Fortify**, the application supports:
|
||||||
|
|
||||||
|
- **Registration** — New user sign-up
|
||||||
|
- **Login / Logout** — Session-based authentication with rate limiting
|
||||||
|
- **Password Reset** — Forgot password & reset via email
|
||||||
|
- **Email Verification** — Verify email address flow
|
||||||
|
- **Two-Factor Authentication (2FA)** — TOTP-based with QR codes and recovery codes
|
||||||
|
- **Password Confirmation** — Re-confirm password before sensitive actions
|
||||||
|
- **Profile Management** — Update name, email
|
||||||
|
- **Account Deletion** — Self-service account removal
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
This project uses **Pest** for testing.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run all tests
|
||||||
|
php artisan test --compact
|
||||||
|
|
||||||
|
# Run a specific test file
|
||||||
|
php artisan test --compact --filter=LoginTest
|
||||||
|
|
||||||
|
# Full CI check (lint + test)
|
||||||
|
composer run test
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Code Style
|
||||||
|
|
||||||
|
Code style is enforced with **Laravel Pint**. After modifying PHP files:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Auto-fix formatting
|
||||||
|
vendor/bin/pint --parallel
|
||||||
|
|
||||||
|
# Check without modifying
|
||||||
|
vendor/bin/pint --test
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Libraries & Documentation
|
||||||
|
|
||||||
|
### Core Framework
|
||||||
|
|
||||||
|
| Library | Description | Docs |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| **Laravel 13** | PHP web application framework | [laravel.com/docs](https://laravel.com/docs) |
|
||||||
|
| **PHP 8.3+** | Server-side language | [php.net/docs](https://www.php.net/docs.php) |
|
||||||
|
|
||||||
|
### Frontend & UI
|
||||||
|
|
||||||
|
| Library | Description | Docs |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| **Livewire 4** | Full-stack reactive components for Laravel | [livewire.laravel.com/docs](https://livewire.laravel.com/docs) |
|
||||||
|
| **Tailwind CSS 4** | Utility-first CSS framework | [tailwindcss.com/docs](https://tailwindcss.com/docs) |
|
||||||
|
| **Alpine.js** | Lightweight JS framework (bundled with Livewire) | [alpinejs.dev](https://alpinejs.dev/) |
|
||||||
|
| **Blade Lucide Icons** | Lucide icon set for Blade templates | [github.com/mallardduck/blade-lucide-icons](https://github.com/mallardduck/blade-lucide-icons) |
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
|
||||||
|
| Library | Description | Docs |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| **Laravel Fortify 1** | Backend authentication scaffolding | [laravel.com/docs/fortify](https://laravel.com/docs/fortify) |
|
||||||
|
|
||||||
|
### Data & Architecture
|
||||||
|
|
||||||
|
| Library | Description | Docs |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| **Spatie Laravel Data 4** | Typed data transfer objects | [spatie.be/docs/laravel-data](https://spatie.be/docs/laravel-data) |
|
||||||
|
| **Tailwind Merge Laravel** | Intelligent Tailwind class merging in PHP | [github.com/gehrisandro/tailwind-merge-laravel](https://github.com/gehrisandro/tailwind-merge-laravel) |
|
||||||
|
|
||||||
|
### Build Tools
|
||||||
|
|
||||||
|
| Library | Description | Docs |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| **Vite 8** | Next-generation frontend build tool | [vite.dev/guide](https://vite.dev/guide/) |
|
||||||
|
| **Laravel Vite Plugin 3** | Laravel integration for Vite | [laravel.com/docs/vite](https://laravel.com/docs/vite) |
|
||||||
|
|
||||||
|
### Development & Testing
|
||||||
|
|
||||||
|
| Library | Description | Docs |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| **Pest 4** | Elegant PHP testing framework | [pestphp.com/docs](https://pestphp.com/docs) |
|
||||||
|
| **Laravel Pint 1** | Opinionated PHP code style fixer | [laravel.com/docs/pint](https://laravel.com/docs/pint) |
|
||||||
|
| **Laravel Sail 1** | Docker development environment | [laravel.com/docs/sail](https://laravel.com/docs/sail) |
|
||||||
|
| **Laravel Pail 1** | Real-time log viewer | [laravel.com/docs/pail](https://laravel.com/docs/pail) |
|
||||||
|
| **Laravel Tinker 3** | REPL for Laravel | [laravel.com/docs/artisan#tinker](https://laravel.com/docs/artisan#tinker) |
|
||||||
|
| **FakerPHP** | Fake data generator for testing | [fakerphp.org](https://fakerphp.org/) |
|
||||||
|
| **Mockery** | Mock object framework for PHP | [docs.mockery.io](https://docs.mockery.io/) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
This project is proprietary software. All rights reserved.
|
||||||
22
app/Data/Ui/ManageRoles/RoleData.php
Normal file
22
app/Data/Ui/ManageRoles/RoleData.php
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Data\Ui\ManageRoles;
|
||||||
|
|
||||||
|
use App\Data\Users\UsersData;
|
||||||
|
use App\Enums\RolesStatus;
|
||||||
|
use Livewire\Wireable;
|
||||||
|
use Spatie\LaravelData\Concerns\WireableData;
|
||||||
|
use Spatie\LaravelData\Data;
|
||||||
|
|
||||||
|
final class RoleData extends Data implements Wireable
|
||||||
|
{
|
||||||
|
use WireableData;
|
||||||
|
public function __construct(
|
||||||
|
public string $name,
|
||||||
|
public UsersData $users,
|
||||||
|
public ServiceCollection $services,
|
||||||
|
public RolesStatus $status,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
12
app/Data/Ui/ManageRoles/RolesCollection.php
Normal file
12
app/Data/Ui/ManageRoles/RolesCollection.php
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Data\Ui\ManageRoles;
|
||||||
|
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends Collection<int, RoleData>
|
||||||
|
*/
|
||||||
|
final class RolesCollection extends Collection {}
|
||||||
17
app/Data/Ui/ManageRoles/RolesData.php
Normal file
17
app/Data/Ui/ManageRoles/RolesData.php
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Data\Ui\ManageRoles;
|
||||||
|
|
||||||
|
use Livewire\Wireable;
|
||||||
|
use Spatie\LaravelData\Concerns\WireableData;
|
||||||
|
use Spatie\LaravelData\Data;
|
||||||
|
|
||||||
|
final class RolesData extends Data implements Wireable
|
||||||
|
{
|
||||||
|
use WireableData;
|
||||||
|
public function __construct(
|
||||||
|
public RolesCollection $roles,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
12
app/Data/Ui/ManageRoles/ServiceCollection.php
Normal file
12
app/Data/Ui/ManageRoles/ServiceCollection.php
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Data\Ui\ManageRoles;
|
||||||
|
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends Collection<int, ServiceData>
|
||||||
|
*/
|
||||||
|
final class ServiceCollection extends Collection {}
|
||||||
18
app/Data/Ui/ManageRoles/ServiceData.php
Normal file
18
app/Data/Ui/ManageRoles/ServiceData.php
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Data\Ui\ManageRoles;
|
||||||
|
|
||||||
|
use Livewire\Wireable;
|
||||||
|
use Spatie\LaravelData\Concerns\WireableData;
|
||||||
|
use Spatie\LaravelData\Data;
|
||||||
|
|
||||||
|
final class ServiceData extends Data implements Wireable
|
||||||
|
{
|
||||||
|
use WireableData;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
public string $name,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
12
app/Data/Users/UserCollection.php
Normal file
12
app/Data/Users/UserCollection.php
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Data\Users;
|
||||||
|
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends Collection<int, UserData>
|
||||||
|
*/
|
||||||
|
final class UserCollection extends Collection {}
|
||||||
16
app/Data/Users/UserData.php
Normal file
16
app/Data/Users/UserData.php
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Data\Users;
|
||||||
|
|
||||||
|
use Spatie\LaravelData\Data;
|
||||||
|
|
||||||
|
final class UserData extends Data
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public string $name,
|
||||||
|
public string $email,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
}
|
||||||
14
app/Data/Users/UsersData.php
Normal file
14
app/Data/Users/UsersData.php
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Data\Users;
|
||||||
|
|
||||||
|
use Spatie\LaravelData\Data;
|
||||||
|
|
||||||
|
final class UsersData extends Data
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public UserCollection $users,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
11
app/Enums/RolesStatus.php
Normal file
11
app/Enums/RolesStatus.php
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Enums;
|
||||||
|
|
||||||
|
enum RolesStatus: string
|
||||||
|
{
|
||||||
|
case Active = 'active';
|
||||||
|
case Inactive = 'inactive';
|
||||||
|
}
|
||||||
55
app/Providers/ScopeServiceProvider.php
Normal file
55
app/Providers/ScopeServiceProvider.php
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Providers;
|
||||||
|
|
||||||
|
use Illuminate\Support\Arr;
|
||||||
|
use Illuminate\Support\Facades\Blade;
|
||||||
|
use Illuminate\Support\ServiceProvider;
|
||||||
|
|
||||||
|
final class ScopeServiceProvider extends ServiceProvider
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Bootstrap services.
|
||||||
|
*/
|
||||||
|
public function boot(): void
|
||||||
|
{
|
||||||
|
$this->registerScopeDirective();
|
||||||
|
}
|
||||||
|
public function registerScopeDirective(): void
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* All credits from this blade directive goes to Konrad Kalemba.
|
||||||
|
* Just copied and modified for my very specific use case.
|
||||||
|
*
|
||||||
|
* https://github.com/konradkalemba/blade-components-scoped-slots
|
||||||
|
*/
|
||||||
|
Blade::directive('scope', function ($expression) {
|
||||||
|
// Split the expression by `top-level` commas (not in parentheses)
|
||||||
|
$directiveArguments = preg_split("/,(?![^\(\(]*[\)\)])/", $expression);
|
||||||
|
$directiveArguments = array_map('trim', $directiveArguments);
|
||||||
|
|
||||||
|
[$name, $functionArguments] = $directiveArguments;
|
||||||
|
|
||||||
|
// Build function "uses" to inject extra external variables
|
||||||
|
$uses = Arr::except(array_flip($directiveArguments), [$name, $functionArguments]);
|
||||||
|
$uses = array_flip($uses);
|
||||||
|
$uses[] = '$__env';
|
||||||
|
$uses[] = '$__bladeCompiler';
|
||||||
|
$uses = implode(',', $uses);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Slot names can`t contains dot , eg: `user.city`.
|
||||||
|
* So we convert `user.city` to `user___city`
|
||||||
|
*
|
||||||
|
* Later, on component it will be replaced back.
|
||||||
|
*/
|
||||||
|
$name = str_replace('.', '___', $name);
|
||||||
|
|
||||||
|
return "<?php \$__bladeCompiler = \$__bladeCompiler ?? null; \$__env->slot({$name}, function({$functionArguments}) use ({$uses}) { \$loop = !empty(\$__env->getLoopStack()) ? (object) \$__env->getLoopStack()[0] : null; ?>";
|
||||||
|
});
|
||||||
|
|
||||||
|
Blade::directive('endscope', fn() => '<?php }); ?>');
|
||||||
|
}
|
||||||
|
}
|
||||||
269
app/View/Components/Table.php
Normal file
269
app/View/Components/Table.php
Normal file
@ -0,0 +1,269 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\View\Components;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Contracts\View\View;
|
||||||
|
use Illuminate\Support\Arr;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Illuminate\View\Component;
|
||||||
|
|
||||||
|
final class Table extends Component
|
||||||
|
{
|
||||||
|
public string $uuid;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
public array $headers,
|
||||||
|
public mixed $rows, // Supports Arrays, standard Collections, and Spatie PaginatedDataCollections
|
||||||
|
public ?string $id = null,
|
||||||
|
public bool $striped = false,
|
||||||
|
public bool $noHeaders = false,
|
||||||
|
public ?string $link = null,
|
||||||
|
public bool $withPagination = false,
|
||||||
|
public array $rowDecoration = [],
|
||||||
|
public array $cellDecoration = [],
|
||||||
|
public bool $showEmptyText = false,
|
||||||
|
public string $emptyText = 'No records found.',
|
||||||
|
public string $containerClass = 'relative overflow-x-auto rounded-2xl -pb-2',
|
||||||
|
public bool $noHover = false,
|
||||||
|
|
||||||
|
// Slots
|
||||||
|
public mixed $actions = null,
|
||||||
|
public mixed $cell = null,
|
||||||
|
public mixed $empty = null,
|
||||||
|
public mixed $footer = null,
|
||||||
|
) {
|
||||||
|
// Temp save closures to prevent Livewire/PHP 8.5 serialization crashes
|
||||||
|
$rowDecoration = $this->rowDecoration;
|
||||||
|
$cellDecoration = $this->cellDecoration;
|
||||||
|
$headers = $this->headers;
|
||||||
|
|
||||||
|
// Remove closures from serialization
|
||||||
|
unset($this->rowDecoration, $this->cellDecoration, $this->headers);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// Serialize a unique ID for component tracking
|
||||||
|
$this->uuid = "table-" . md5(serialize($this)) . $id;
|
||||||
|
|
||||||
|
// Restore closures
|
||||||
|
$this->rowDecoration = $rowDecoration;
|
||||||
|
$this->cellDecoration = $cellDecoration;
|
||||||
|
$this->headers = $headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if header is hidden
|
||||||
|
public function isHidden(mixed $header): bool
|
||||||
|
{
|
||||||
|
return $header['hidden'] ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format header contents
|
||||||
|
public function format(mixed $row, mixed $field, mixed $header): mixed
|
||||||
|
{
|
||||||
|
$format = $header['format'] ?? null;
|
||||||
|
|
||||||
|
if ( ! $format) {
|
||||||
|
return $field;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_callable($format)) {
|
||||||
|
return $format($row, $field);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('currency' === $format[0]) {
|
||||||
|
return ($format[2] ?? '') . number_format((float) $field, ...mb_str_split($format[1]));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('date' === $format[0] && $field) {
|
||||||
|
return Carbon::parse($field)->translatedFormat($format[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $field;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if link should be shown in cell
|
||||||
|
public function hasLink(mixed $header): bool
|
||||||
|
{
|
||||||
|
return $this->link && empty($header['disableLink']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build row link
|
||||||
|
public function redirectLink(mixed $row): string
|
||||||
|
{
|
||||||
|
$link = $this->link;
|
||||||
|
|
||||||
|
// Transform from `route()` pattern
|
||||||
|
$link = Str::of($link)->replace('%5B', '{')->replace('%5D', '}');
|
||||||
|
|
||||||
|
// Extract tokens like {id}, {city.name} ...
|
||||||
|
$tokens = Str::of($link)->matchAll('/\{(.*?)\}/');
|
||||||
|
|
||||||
|
// Replace tokens with actual row values
|
||||||
|
$tokens->each(function (string $token) use ($row, &$link): void {
|
||||||
|
$link = Str::of($link)->replace("{" . $token . "}", data_get($row, $token))->toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
return $link;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rowClasses(mixed $row): ?string
|
||||||
|
{
|
||||||
|
$classes = [];
|
||||||
|
|
||||||
|
foreach ($this->rowDecoration as $class => $condition) {
|
||||||
|
if ($condition($row)) {
|
||||||
|
$classes[] = $class;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Arr::join($classes, ' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function cellClasses(mixed $row, array $header): ?string
|
||||||
|
{
|
||||||
|
$classes = Str::of($header['class'] ?? '')->explode(' ')->all();
|
||||||
|
|
||||||
|
foreach ($this->cellDecoration[$header['key']] ?? [] as $class => $condition) {
|
||||||
|
if ($condition($row)) {
|
||||||
|
$classes[] = $class;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Arr::join($classes, ' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render(): View|Closure|string
|
||||||
|
{
|
||||||
|
return <<<'HTML'
|
||||||
|
<div class="w-full">
|
||||||
|
<div class="{{ $containerClass }}">
|
||||||
|
<table
|
||||||
|
{{
|
||||||
|
$attributes
|
||||||
|
->whereDoesntStartWith('wire:model')
|
||||||
|
->class([
|
||||||
|
'w-full text-sm text-left rtl:text-right text-gray-500 dark:text-gray-400',
|
||||||
|
])
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<thead @class([
|
||||||
|
"text-xs text-gray-700 uppercase bg-gray-50 border-b border-gray-200",
|
||||||
|
"hidden" => $noHeaders
|
||||||
|
])>
|
||||||
|
<tr>
|
||||||
|
@foreach($headers as $header)
|
||||||
|
@php
|
||||||
|
if($isHidden($header)) continue;
|
||||||
|
|
||||||
|
# Scoped slot's name like `user.city` are compiled to `user___city` through `@scope / @endscope`.
|
||||||
|
# So we use current `$header` key to find that slot on context.
|
||||||
|
$temp_key = str_replace('.', '___', $header['key'])
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<th scope="col" class="px-6 py-4 {{ $header['class'] ?? '' }}">
|
||||||
|
{{ isset(${"header_".$temp_key}) ? ${"header_".$temp_key}($header) : $header['label'] }}
|
||||||
|
</th>
|
||||||
|
@endforeach
|
||||||
|
|
||||||
|
@if($actions)
|
||||||
|
<th scope="col" class="px-6 py-3 w-1 whitespace-nowrap">
|
||||||
|
<span class="sr-only">Actions</span>
|
||||||
|
</th>
|
||||||
|
@endif
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
|
||||||
|
<tbody>
|
||||||
|
@foreach($rows as $k => $row)
|
||||||
|
<tr
|
||||||
|
wire:key="{{ $uuid }}-{{ $k }}"
|
||||||
|
@class([
|
||||||
|
'bg-white border-b',
|
||||||
|
$rowClasses($row),
|
||||||
|
'even:bg-gray-50 ' => $striped,
|
||||||
|
'hover:bg-gray-50' => !$noHover,
|
||||||
|
'cursor-pointer' => $attributes->has('@row-click') || $link
|
||||||
|
])
|
||||||
|
@if($attributes->has('@row-click'))
|
||||||
|
@click="$dispatch('row-click', {{ json_encode($row) }});"
|
||||||
|
@endif
|
||||||
|
>
|
||||||
|
@foreach($headers as $header)
|
||||||
|
@php
|
||||||
|
if($isHidden($header)) continue;
|
||||||
|
$temp_key = str_replace('.', '___', $header['key'])
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
@if(isset(${"cell_".$temp_key}))
|
||||||
|
<td @class(["px-6 py-4", $cellClasses($row, $header), "p-0" => $hasLink($header)])>
|
||||||
|
@if($hasLink($header))
|
||||||
|
<a href="{{ $redirectLink($row) }}" wire:navigate class="block px-6 py-4">
|
||||||
|
@endif
|
||||||
|
|
||||||
|
{{ ${"cell_".$temp_key}($row) }}
|
||||||
|
|
||||||
|
@if($hasLink($header))
|
||||||
|
</a>
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
|
@else
|
||||||
|
<td @class(["px-6 py-4", $cellClasses($row, $header), "p-0" => $hasLink($header)])>
|
||||||
|
@if($hasLink($header))
|
||||||
|
<a href="{{ $redirectLink($row) }}" wire:navigate class="block px-6 py-4 text-inherit">
|
||||||
|
@endif
|
||||||
|
|
||||||
|
{{ $format($row, data_get($row, $header['key']), $header) }}
|
||||||
|
|
||||||
|
@if($hasLink($header))
|
||||||
|
</a>
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
|
@endif
|
||||||
|
@endforeach
|
||||||
|
|
||||||
|
@if($actions)
|
||||||
|
<td class="px-6 py-4 text-right whitespace-nowrap">
|
||||||
|
{{ $actions($row) }}
|
||||||
|
</td>
|
||||||
|
@endif
|
||||||
|
</tr>
|
||||||
|
@endforeach
|
||||||
|
</tbody>
|
||||||
|
|
||||||
|
@isset ($footer)
|
||||||
|
<tfoot @class([
|
||||||
|
"text-xs text-gray-700 uppercase bg-gray-50 dark:bg-gray-700 dark:text-gray-400",
|
||||||
|
$footer->attributes->get('class') ?? ''
|
||||||
|
])>
|
||||||
|
{{ $footer }}
|
||||||
|
</tfoot>
|
||||||
|
@endisset
|
||||||
|
</table>
|
||||||
|
|
||||||
|
@if(count($rows) === 0)
|
||||||
|
@if($showEmptyText)
|
||||||
|
<div class="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||||
|
{{ $emptyText }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
@if($empty)
|
||||||
|
<div class="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||||
|
{{ $empty }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if($withPagination && method_exists($rows, 'links'))
|
||||||
|
<div class="mt-4 px-2">
|
||||||
|
{{ $rows->links() }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
HTML;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -2,10 +2,8 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
use App\Providers\AppServiceProvider;
|
|
||||||
use App\Providers\FortifyServiceProvider;
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
AppServiceProvider::class,
|
App\Providers\AppServiceProvider::class,
|
||||||
FortifyServiceProvider::class,
|
App\Providers\FortifyServiceProvider::class,
|
||||||
|
App\Providers\ScopeServiceProvider::class,
|
||||||
];
|
];
|
||||||
|
|||||||
@ -2,29 +2,24 @@
|
|||||||
|
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
|
|
||||||
new class extends Component
|
new class extends Component {
|
||||||
{
|
|
||||||
public string $selectedTab = 'all-tab';
|
public string $selectedTab = 'all-tab';
|
||||||
};
|
};
|
||||||
?>
|
?>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<x-shared.card class="p-0">
|
<x-shared.card title="Connected Services" icon="lucide-grid-2x2-plus" class="p-0">
|
||||||
<div class="p-4 flex justify-between border-b border-gray-200">
|
<x-slot:actions>
|
||||||
<h2 class="font-medium flex items-center gap-x-2">
|
|
||||||
<x-lucide-grid-2x2-plus class="w-5 h-5"/>
|
|
||||||
{{ __('Connected Services') }}
|
|
||||||
</h2>
|
|
||||||
<x-shared.button>
|
<x-shared.button>
|
||||||
<div class="flex items-center gap-x-2">
|
<div class="flex items-center gap-x-2">
|
||||||
<x-lucide-plus class="w-5 h-5"/>
|
<x-lucide-plus class="w-5 h-5"/>
|
||||||
{{ __('Add service') }}
|
{{ __('Add service') }}
|
||||||
</div>
|
</div>
|
||||||
</x-shared.button>
|
</x-shared.button>
|
||||||
</div>
|
</x-slot:actions>
|
||||||
<x-shared.tabs.tabs wire:model="selectedTab" class="px-4">
|
<x-shared.tabs.tabs wire:model="selectedTab" class="px-4">
|
||||||
<x-shared.tabs.tab name="all-tab" label="All" class="">
|
<x-shared.tabs.tab name="all-tab" label="All" class="">
|
||||||
<livewire:connected-services.all-connected-services />
|
<livewire:dashboard.connected-services.all/>
|
||||||
</x-shared.tabs.tab>
|
</x-shared.tabs.tab>
|
||||||
<x-shared.tabs.tab name="oidc-tab" label="OIDC">
|
<x-shared.tabs.tab name="oidc-tab" label="OIDC">
|
||||||
<div>Tricks</div>
|
<div>Tricks</div>
|
||||||
134
resources/views/components/dashboard/roles/⚡manager.blade.php
Normal file
134
resources/views/components/dashboard/roles/⚡manager.blade.php
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Data\Ui\ManageRoles\RoleData;
|
||||||
|
use App\Data\Ui\ManageRoles\RolesCollection;
|
||||||
|
use App\Data\Ui\ManageRoles\RolesData;
|
||||||
|
use App\Data\Ui\ManageRoles\ServiceCollection;
|
||||||
|
use App\Data\Ui\ManageRoles\ServiceData;
|
||||||
|
use App\Data\Users\UserCollection;
|
||||||
|
use App\Data\Users\UserData;
|
||||||
|
use App\Data\Users\UsersData;
|
||||||
|
use Livewire\Component;
|
||||||
|
|
||||||
|
new class extends Component {
|
||||||
|
/**
|
||||||
|
* @var array<int, array{
|
||||||
|
* key: string,
|
||||||
|
* label: string,
|
||||||
|
* format: array{0: string, 1: string}|null
|
||||||
|
* }>
|
||||||
|
*/
|
||||||
|
public array $header;
|
||||||
|
public RolesData $rolesData;
|
||||||
|
|
||||||
|
public function mount(): void
|
||||||
|
{
|
||||||
|
$this->header = [
|
||||||
|
[
|
||||||
|
'key' => 'name',
|
||||||
|
'label' => 'Role Name',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'key' => 'users',
|
||||||
|
'label' => 'User Assigned',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'key' => 'services',
|
||||||
|
'label' => 'Services',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'key' => 'status',
|
||||||
|
'label' => 'Status',
|
||||||
|
]
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->rolesData = new RolesData(
|
||||||
|
roles: new RolesCollection([
|
||||||
|
new RoleData(
|
||||||
|
name: 'Super Admin',
|
||||||
|
users: new UsersData(
|
||||||
|
users: new UserCollection([
|
||||||
|
new UserData(
|
||||||
|
name: 'Kushal Saha',
|
||||||
|
email: 'kushal@example.com'
|
||||||
|
),
|
||||||
|
new UserData(
|
||||||
|
name: 'Test Example',
|
||||||
|
email: 'test@example.com'
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
),
|
||||||
|
services: new ServiceCollection(
|
||||||
|
[
|
||||||
|
new ServiceData(
|
||||||
|
name: 'Teams'
|
||||||
|
)
|
||||||
|
]
|
||||||
|
),
|
||||||
|
status: \App\Enums\RolesStatus::Active
|
||||||
|
),
|
||||||
|
new RoleData(
|
||||||
|
name: 'Super Admin',
|
||||||
|
users: new UsersData(
|
||||||
|
users: new UserCollection([
|
||||||
|
new UserData(
|
||||||
|
name: 'Kushal Saha',
|
||||||
|
email: 'kushal@example.com'
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
),
|
||||||
|
services: new ServiceCollection(
|
||||||
|
[
|
||||||
|
new ServiceData(
|
||||||
|
name: 'Teams'
|
||||||
|
),
|
||||||
|
new ServiceData(
|
||||||
|
name: 'Outlook'
|
||||||
|
)
|
||||||
|
]
|
||||||
|
),
|
||||||
|
status: \App\Enums\RolesStatus::Active
|
||||||
|
),
|
||||||
|
])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
?>
|
||||||
|
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<x-shared.card title="Roles" icon="lucide-shield-ellipsis" class="p-0">
|
||||||
|
<x-slot:actions>
|
||||||
|
<x-shared.button>
|
||||||
|
<div class="flex items-center gap-x-2">
|
||||||
|
<x-lucide-plus class="w-5 h-5"/>
|
||||||
|
{{ __('Add role') }}
|
||||||
|
</div>
|
||||||
|
</x-shared.button>
|
||||||
|
</x-slot:actions>
|
||||||
|
<x-table :headers="$header" :rows="$rolesData->roles">
|
||||||
|
@scope('cell_users', $row)
|
||||||
|
@foreach($row->users->users as $user)
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center justify-center w-7 h-7 rounded-full bg-gray-200 text-gray-800 font-bold text-[10px] tracking-tight">
|
||||||
|
{{implode('', array_map(fn($w) => strtoupper($w[0]), explode(' ', $user->name)))}}
|
||||||
|
</span>
|
||||||
|
@endforeach
|
||||||
|
@endscope
|
||||||
|
@scope('cell_status', $row)
|
||||||
|
<x-shared.badge class="bg-lime-200 text-lime-800 font-bold">
|
||||||
|
{{ ucfirst($row->status->name) }}
|
||||||
|
</x-shared.badge>
|
||||||
|
@endscope
|
||||||
|
@scope('cell_services', $row)
|
||||||
|
@foreach($row->services as $service)
|
||||||
|
<x-shared.badge class="bg-indigo-200 text-indigo-800 font-bold">
|
||||||
|
{{$service->name}}
|
||||||
|
</x-shared.badge>
|
||||||
|
@endforeach
|
||||||
|
@endscope
|
||||||
|
</x-table>
|
||||||
|
</x-shared.card>
|
||||||
|
</div>
|
||||||
3
resources/views/components/shared/badge.blade.php
Normal file
3
resources/views/components/shared/badge.blade.php
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
<span
|
||||||
|
{{$attributes->twMerge(['class'=> 'px-4 py-1 rounded-full bg-gray-200'])}}
|
||||||
|
>{{ $slot }}</span>
|
||||||
@ -1,3 +1,19 @@
|
|||||||
|
@props(['title', 'icon', 'actions'])
|
||||||
<div {{$attributes->twMerge('rounded-xl border border-gray-200 p-4 shadow-xs bg-white')}}>
|
<div {{$attributes->twMerge('rounded-xl border border-gray-200 p-4 shadow-xs bg-white')}}>
|
||||||
|
@isset($title)
|
||||||
|
<div class="p-4 flex justify-between border-b border-gray-200">
|
||||||
|
<h2 class="font-medium flex items-center gap-x-2">
|
||||||
|
@isset($icon)
|
||||||
|
<span class="w-5 h-5">
|
||||||
|
@svg($icon)
|
||||||
|
</span>
|
||||||
|
@endisset
|
||||||
|
{{ __($title) }}
|
||||||
|
</h2>
|
||||||
|
@isset($actions)
|
||||||
|
{{$actions}}
|
||||||
|
@endisset
|
||||||
|
</div>
|
||||||
|
@endisset
|
||||||
{{$slot}}
|
{{$slot}}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
'selected' => null,
|
'selected' => null,
|
||||||
'labelClass' => 'px-4 py-3 cursor-pointer',
|
'labelClass' => 'px-4 py-3 cursor-pointer',
|
||||||
'activeClass' => 'font-medium border-b border-b-gray-400',
|
'activeClass' => 'font-medium border-b border-b-gray-400',
|
||||||
'labelDivClass' => 'border-b border-b-base-content/10 flex overflow-x-auto',
|
'labelDivClass' => 'border-b bg-gray-50 border-b-base-content/10 flex overflow-x-auto',
|
||||||
'tabsClass' => 'relative w-full',
|
'tabsClass' => 'relative w-full',
|
||||||
])
|
])
|
||||||
|
|
||||||
|
|||||||
@ -4,11 +4,13 @@
|
|||||||
@include('partials.head')
|
@include('partials.head')
|
||||||
</head>
|
</head>
|
||||||
<body class="min-h-screen bg-gray-50 antialiased">
|
<body class="min-h-screen bg-gray-50 antialiased">
|
||||||
<div class="flex h-full">
|
<div class="flex h-screen">
|
||||||
<livewire:dashboard-sidebar :active="$activeNav ?? 'dashboard'"/>
|
<livewire:dashboard-sidebar :active="$activeNav ?? 'dashboard'"/>
|
||||||
|
|
||||||
<div class="flex flex-col flex-1 min-w-0 overflow-hidden">
|
<div class="flex flex-col flex-1 min-w-0 overflow-hidden">
|
||||||
<x-dashbord-topbar/>
|
<x-dashbord-topbar/>
|
||||||
<main class="">
|
|
||||||
|
<main class="flex-1 overflow-y-auto p-4">
|
||||||
{{ $slot }}
|
{{ $slot }}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -15,6 +15,7 @@ class extends Component {
|
|||||||
|
|
||||||
<div class="flex h-full w-full flex-1 flex-col gap-4 rounded-xl">
|
<div class="flex h-full w-full flex-1 flex-col gap-4 rounded-xl">
|
||||||
<x-dashboard-stats />
|
<x-dashboard-stats />
|
||||||
<livewire:connected-services />
|
<livewire:dashboard.connected-services />
|
||||||
|
<livewire:dashboard.roles.manager />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user