feature: keep sidebar and add service layer

This commit is contained in:
antv
2026-06-30 11:59:34 +07:00
parent 8a77324f89
commit c5016489ac
54 changed files with 1181 additions and 284 deletions
+1
View File
@@ -52,3 +52,4 @@ yarn-debug.log*
yarn-error.log* yarn-error.log*
*.tmp *.tmp
*.temp *.temp
Tasks/
+10 -18
View File
@@ -2,32 +2,24 @@
namespace App\Console\Commands; namespace App\Console\Commands;
use App\Services\Admin\Contracts\AdminServiceInterface;
use Illuminate\Console\Command; use Illuminate\Console\Command;
use App\Models\User;
class ResetCardsCommand extends Command class ResetCardsCommand extends Command
{ {
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'cards:reset'; protected $signature = 'cards:reset';
/** protected $description = 'Reset all system cards to 0 at the beginning of the month';
* The console command description.
* public function __construct(
* @var string private AdminServiceInterface $adminService
*/ ) {
protected $description = 'Reset toàn bộ thẻ của hệ thống về 0 vào đầu tháng'; parent::__construct();
}
/**
* Execute the console command.
*/
public function handle() public function handle()
{ {
User::where('status', User::STATUS_ACTIVE)->update(['card' => 0]); $this->adminService->resetAllCards();
$this->info('Successfully reset all cards for active members to 0!');
$this->info('Đã reset toàn bộ số lượng thẻ của nhân viên đang hoạt động về 0 thành công!');
} }
} }
+24 -87
View File
@@ -2,53 +2,23 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use Illuminate\Http\Request;
use App\Models\User; use App\Models\User;
use App\Models\Administration; use App\Services\Admin\Contracts\AdminServiceInterface;
use App\Models\AddCard;
use Carbon\Carbon; use Carbon\Carbon;
use Illuminate\Support\Facades\Hash; use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class AdminController extends Controller class AdminController extends Controller
{ {
public function __construct(
private AdminServiceInterface $adminService
) {}
public function index(Request $request) public function index(Request $request)
{ {
$selectedMonth = $request->input('month', Carbon::now()->format('Y-m')); $selectedMonth = $request->input('month', Carbon::now()->format('Y-m'));
$startOfMonth = Carbon::parse($selectedMonth)->startOfMonth();
$endOfMonth = Carbon::parse($selectedMonth)->endOfMonth();
// 1. Calculate top stats from all active users ['users' => $users, 'topReceivedUser' => $topReceivedUser, 'topSentUser' => $topSentUser]
$allActiveUsers = User::where('status', User::STATUS_ACTIVE)->get()->map(function($user) use ($startOfMonth, $endOfMonth) { = $this->adminService->getUserListWithStats($selectedMonth);
$user->total_received = Administration::where('receiver', $user->msnv)
->whereBetween('date', [$startOfMonth, $endOfMonth])
->sum('received');
$user->total_sent = Administration::where('sender', $user->msnv)
->whereBetween('date', [$startOfMonth, $endOfMonth])
->sum('sent');
return $user;
});
$topReceivedUser = $allActiveUsers->sortByDesc('total_received')->first();
$topSentUser = $allActiveUsers->sortByDesc('total_sent')->first();
// 2. Paginate users for the list table (10 per page)
$users = User::where('status', User::STATUS_ACTIVE)->paginate(10)->withQueryString();
// 3. Compute stats only for the paginated users (to display in the table)
$users->getCollection()->transform(function($user) use ($startOfMonth, $endOfMonth) {
$user->total_received = Administration::where('receiver', $user->msnv)
->whereBetween('date', [$startOfMonth, $endOfMonth])
->sum('received');
$user->total_sent = Administration::where('sender', $user->msnv)
->whereBetween('date', [$startOfMonth, $endOfMonth])
->sum('sent');
return $user;
});
return view('admin.users.index', compact('users', 'selectedMonth', 'topReceivedUser', 'topSentUser')); return view('admin.users.index', compact('users', 'selectedMonth', 'topReceivedUser', 'topSentUser'));
} }
@@ -61,83 +31,50 @@ class AdminController extends Controller
public function store(Request $request) public function store(Request $request)
{ {
$request->validate([ $request->validate([
'msnv' => 'required|unique:user,msnv', 'msnv' => 'required|unique:user,msnv',
'mail' => 'required|email|unique:user,mail', 'mail' => 'required|email|unique:user,mail',
'password' => 'required|min:6', 'password' => 'required|min:6',
'role' => 'required|in:' . User::ROLE_MEMBER . ',' . User::ROLE_ADMIN 'role' => 'required|in:' . User::ROLE_MEMBER . ',' . User::ROLE_ADMIN,
]); ]);
User::create([ $this->adminService->createUser($request->only('msnv', 'mail', 'password', 'role'));
'msnv' => $request->msnv,
'mail' => $request->mail,
'pass' => Hash::make($request->password),
'role' => $request->role,
'status' => User::STATUS_ACTIVE,
'card' => 0,
'flag_send' => User::FLAG_SEND_DISABLED,
'first_login' => User::FIRST_LOGIN_TRUE
]);
return redirect()->route('admin.users.index')->with('success', 'Tạo user thành công!'); return redirect()->route('admin.users.index')->with('success', __('messages.user_create_success'));
} }
public function edit(Request $request, $msnv) public function edit(Request $request, $msnv)
{ {
$user = User::where('msnv', $msnv)->firstOrFail(); $user = User::where('msnv', $msnv)->firstOrFail();
$selectedMonth = $request->input('month', Carbon::now()->format('Y-m')); $selectedMonth = $request->input('month', Carbon::now()->format('Y-m'));
$startOfMonth = Carbon::parse($selectedMonth)->startOfMonth();
$endOfMonth = Carbon::parse($selectedMonth)->endOfMonth();
$administrations = Administration::where(function($q) use ($msnv) { $administrations = $this->adminService->getUserTransactions($msnv, $selectedMonth);
$q->where('sender', $msnv)->orWhere('receiver', $msnv);
})
->whereBetween('date', [$startOfMonth, $endOfMonth])
->orderBy('date', 'desc')
->get();
return view('admin.users.edit', compact('user', 'administrations', 'selectedMonth')); return view('admin.users.edit', compact('user', 'administrations', 'selectedMonth'));
} }
public function update(Request $request, $msnv) public function update(Request $request, $msnv)
{ {
$user = User::where('msnv', $msnv)->firstOrFail();
$request->validate([ $request->validate([
'num_card' => 'nullable|integer|min:1', 'num_card' => 'nullable|integer|min:1',
'flag_send' => 'nullable|boolean' 'flag_send' => 'nullable|boolean',
]); ]);
DB::transaction(function () use ($request, $user) { $this->adminService->updateUser($msnv, $request->only('num_card', 'flag_send'));
if ($request->filled('num_card')) {
AddCard::create([
'buyer' => $user->msnv,
'num_card' => $request->num_card,
'seller' => auth()->user()->msnv,
'date' => Carbon::today()
]);
$user->card += $request->num_card;
}
$user->flag_send = $request->has('flag_send') ? User::FLAG_SEND_ENABLED : User::FLAG_SEND_DISABLED; return redirect()->back()->with('success', __('messages.user_update_success'));
$user->save();
});
return redirect()->back()->with('success', 'Cập nhật thành công!');
} }
public function destroy($msnv) public function destroy($msnv)
{ {
$user = User::where('msnv', $msnv)->firstOrFail(); $this->adminService->deactivateUser($msnv);
$user->status = User::STATUS_INACTIVE;
$user->save();
return redirect()->route('admin.users.index')->with('success', 'Đã đánh dấu nhân viên nghỉ việc thành công!'); return redirect()->route('admin.users.index')->with('success', __('messages.user_deactivate_success'));
} }
public function resetCards() public function resetCards()
{ {
User::where('status', User::STATUS_ACTIVE)->update(['card' => 0]); $this->adminService->resetAllCards();
return redirect()->back()->with('success', 'Reset thẻ toàn hệ thống thành công!');
return redirect()->back()->with('success', __('messages.cards_reset_success'));
} }
} }
+15 -35
View File
@@ -2,16 +2,20 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Services\Auth\Contracts\AuthServiceInterface;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use App\Models\User;
class AuthController extends Controller class AuthController extends Controller
{ {
public function __construct(
private AuthServiceInterface $authService
) {}
public function showLoginForm() public function showLoginForm()
{ {
if (Auth::check()) { if (Auth::check()) {
return $this->redirectBasedOnRole(Auth::user()); return redirect($this->authService->getRedirectRouteForUser(Auth::user()));
} }
return view('login'); return view('login');
} }
@@ -19,48 +23,24 @@ class AuthController extends Controller
public function login(Request $request) public function login(Request $request)
{ {
$credentials = $request->validate([ $credentials = $request->validate([
'mail' => 'required|email', 'mail' => 'required|email|max:255',
'password' => 'required' 'password' => 'required|string|max:100',
]); ]);
if (Auth::attempt(['mail' => $credentials['mail'], 'password' => $credentials['password']])) { $result = $this->authService->attemptLogin($credentials, $request);
$request->session()->regenerate();
$user = Auth::user(); if (!$result['success']) {
return back()->withInput($request->only('mail'))->withErrors([
if ($user->status != User::STATUS_ACTIVE) { $result['error_key'] => $result['error_msg'],
Auth::logout(); ]);
return back()->withErrors([
'mail' => 'Tài khoản của bạn đã bị khóa hoặc bạn đã nghỉ việc.',
]);
}
return $this->redirectBasedOnRole($user);
} }
return back()->withErrors([ return redirect($this->authService->getRedirectRouteForUser($result['user']));
'mail' => 'Email hoặc mật khẩu không chính xác.',
]);
} }
public function logout(Request $request) public function logout(Request $request)
{ {
Auth::logout(); $this->authService->logout($request);
$request->session()->invalidate();
$request->session()->regenerateToken();
return redirect('/login'); return redirect('/login');
} }
private function redirectBasedOnRole($user)
{
if ($user->first_login == User::FIRST_LOGIN_TRUE) {
return redirect()->route('user.change_password');
}
if ($user->role == User::ROLE_ADMIN) {
return redirect()->route('admin.dashboard');
}
return redirect()->route('user.dashboard');
}
} }
+23 -89
View File
@@ -2,36 +2,31 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use Illuminate\Http\Request;
use App\Models\User;
use App\Models\Administration; use App\Models\Administration;
use App\Services\User\Contracts\UserServiceInterface;
use Carbon\Carbon; use Carbon\Carbon;
use Illuminate\Support\Facades\Hash; use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
class UserController extends Controller class UserController extends Controller
{ {
public function __construct(
private UserServiceInterface $userService
) {}
public function index(Request $request) public function index(Request $request)
{ {
$selectedMonth = $request->input('month', Carbon::now()->format('Y-m')); $selectedMonth = $request->input('month', Carbon::now()->format('Y-m'));
$startOfMonth = Carbon::parse($selectedMonth)->startOfMonth(); $user = Auth::user();
$endOfMonth = Carbon::parse($selectedMonth)->endOfMonth();
$user = Auth::user(); $administrations = $this->userService->getDashboard($user, $selectedMonth);
// Lấy tất cả card nhận và gửi của user này
$administrations = Administration::where('msnv', $user->msnv)
->whereBetween('date', [$startOfMonth, $endOfMonth])
->orderBy('date', 'desc')
->get();
return view('user.dashboard', compact('administrations', 'selectedMonth', 'user')); return view('user.dashboard', compact('administrations', 'selectedMonth', 'user'));
} }
public function sendThankcards() public function sendThankcards()
{ {
$users = User::where('status', User::STATUS_ACTIVE)->where('msnv', '!=', Auth::user()->msnv)->get(); $users = $this->userService->getOtherActiveUsers(Auth::user());
return view('user.send', compact('users')); return view('user.send', compact('users'));
} }
@@ -39,76 +34,20 @@ class UserController extends Controller
{ {
$request->validate([ $request->validate([
'receiver' => 'required|exists:user,msnv', 'receiver' => 'required|exists:user,msnv',
'amount' => 'required|integer|min:1|max:' . Administration::MAX_SEND_CARD_PER_MONTH 'amount' => 'required|integer|min:1|max:' . Administration::MAX_SEND_CARD_PER_MONTH,
]); ]);
$sender = Auth::user(); try {
$receiverMsnv = $request->receiver; $this->userService->sendThankcards(
$amount = $request->amount; Auth::user(),
$request->receiver,
if ($sender->flag_send == User::FLAG_SEND_DISABLED) { (int) $request->amount
return response()->json([ );
'success' => false, } catch (\RuntimeException $e) {
'message' => 'Bạn hiện không có quyền gửi Thank Card.' return response()->json(['success' => false, 'message' => $e->getMessage()], 422);
], 422);
} }
$startOfMonth = Carbon::now()->startOfMonth(); return response()->json(['success' => true, 'message' => __('messages.thank_card_send_success')]);
$endOfMonth = Carbon::now()->endOfMonth();
// Tính tổng số lượng card ĐÃ gửi cho người này trong tháng
$cardsSentToThisUserThisMonth = Administration::where('msnv', $sender->msnv)
->where('receiver', $receiverMsnv)
->whereBetween('date', [$startOfMonth, $endOfMonth])
->sum('sent');
if ($cardsSentToThisUserThisMonth + $amount > Administration::MAX_SEND_CARD_PER_MONTH) {
return response()->json([
'success' => false,
'message' => "Một tháng bạn chỉ được gửi tối đa " . Administration::MAX_SEND_CARD_PER_MONTH . " card cho một người. Bạn đã gửi {$cardsSentToThisUserThisMonth} card cho nhân viên này."
], 422);
}
// Validate số lượng thẻ còn lại
if ($sender->card < $amount) {
return response()->json([
'success' => false,
'message' => 'Số lượng card của bạn không đủ. Vui lòng liên hệ Admin.'
], 422);
}
DB::transaction(function () use ($sender, $receiverMsnv, $amount) {
// 1. Insert thông tin người nhận
Administration::create([
'msnv' => $receiverMsnv,
'received' => $amount,
'sender' => $sender->msnv,
'sent' => 0,
'receiver' => null,
'date' => Carbon::today()
]);
// 2. Insert thông tin người gửi
Administration::create([
'msnv' => $sender->msnv,
'received' => 0,
'sender' => null,
'sent' => $amount,
'receiver' => $receiverMsnv,
'date' => Carbon::today()
]);
// 3. Trừ số card của người gửi
$sender->card -= $amount;
$sender->save();
// TODO: Bắn tin nhắn CO tới người nhận (Chatwork/System Notification)
});
return response()->json([
'success' => true,
'message' => 'Đã gửi Thank card thành công!'
]);
} }
public function changePasswordForm() public function changePasswordForm()
@@ -119,18 +58,13 @@ class UserController extends Controller
public function updatePassword(Request $request) public function updatePassword(Request $request)
{ {
$request->validate([ $request->validate([
'password' => 'required|min:6|confirmed' 'password' => 'required|min:6|confirmed',
]); ]);
$user = Auth::user(); $user = Auth::user();
$user->pass = Hash::make($request->password); $this->userService->updatePassword($user, $request->password);
$user->first_login = User::FIRST_LOGIN_FALSE;
$user->save();
if ($user->role == User::ROLE_ADMIN) { $route = $user->role == \App\Models\User::ROLE_ADMIN ? 'admin.dashboard' : 'user.dashboard';
return redirect()->route('admin.dashboard')->with('success', 'Đổi mật khẩu thành công!'); return redirect()->route($route)->with('success', __('messages.password_change_success'));
}
return redirect()->route('user.dashboard')->with('success', 'Đổi mật khẩu thành công!');
} }
} }
+3
View File
@@ -10,6 +10,9 @@ class AddCard extends Model
use HasFactory; use HasFactory;
protected $table = 'add_card'; protected $table = 'add_card';
public $timestamps = false;
protected $primaryKey = null;
public $incrementing = false;
protected $fillable = [ protected $fillable = [
'buyer', 'buyer',
+1
View File
@@ -13,6 +13,7 @@ class Administration extends Model
protected $table = 'administration'; protected $table = 'administration';
public $timestamps = false;
protected $fillable = [ protected $fillable = [
'msnv', 'msnv',
+16 -1
View File
@@ -24,6 +24,7 @@ class User extends Authenticatable
protected $table = 'user'; protected $table = 'user';
public $timestamps = false;
protected $primaryKey = 'msnv'; protected $primaryKey = 'msnv';
public $incrementing = false; public $incrementing = false;
protected $keyType = 'string'; protected $keyType = 'string';
@@ -41,7 +42,6 @@ class User extends Authenticatable
protected $hidden = [ protected $hidden = [
'pass', 'pass',
'remember_token',
]; ];
/** /**
@@ -53,4 +53,19 @@ class User extends Authenticatable
{ {
return $this->pass; return $this->pass;
} }
// Disable remember token support in database
public function getRememberToken()
{
return null;
}
public function setRememberToken($value)
{
}
public function getRememberTokenName()
{
return '';
}
} }
+9 -7
View File
@@ -2,21 +2,23 @@
namespace App\Providers; namespace App\Providers;
use App\Services\Admin\AdminService;
use App\Services\Admin\Contracts\AdminServiceInterface;
use App\Services\Auth\AuthService;
use App\Services\Auth\Contracts\AuthServiceInterface;
use App\Services\User\Contracts\UserServiceInterface;
use App\Services\User\UserService;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider class AppServiceProvider extends ServiceProvider
{ {
/**
* Register any application services.
*/
public function register(): void public function register(): void
{ {
// $this->app->bind(AuthServiceInterface::class, AuthService::class);
$this->app->bind(AdminServiceInterface::class, AdminService::class);
$this->app->bind(UserServiceInterface::class, UserService::class);
} }
/**
* Bootstrap any application services.
*/
public function boot(): void public function boot(): void
{ {
// //
+113
View File
@@ -0,0 +1,113 @@
<?php
namespace App\Services\Admin;
use App\Models\AddCard;
use App\Models\Administration;
use App\Models\User;
use App\Services\Admin\Contracts\AdminServiceInterface;
use Carbon\Carbon;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
class AdminService implements AdminServiceInterface
{
public function getUserListWithStats(string $selectedMonth): array
{
$startOfMonth = Carbon::parse($selectedMonth)->startOfMonth();
$endOfMonth = Carbon::parse($selectedMonth)->endOfMonth();
$allActiveUsers = User::where('status', User::STATUS_ACTIVE)->get()->map(function ($user) use ($startOfMonth, $endOfMonth) {
$user->total_received = Administration::where('receiver', $user->msnv)
->whereBetween('date', [$startOfMonth, $endOfMonth])
->sum('received');
$user->total_sent = Administration::where('sender', $user->msnv)
->whereBetween('date', [$startOfMonth, $endOfMonth])
->sum('sent');
return $user;
});
$topReceivedUser = $allActiveUsers->sortByDesc('total_received')->first();
$topSentUser = $allActiveUsers->sortByDesc('total_sent')->first();
$users = User::where('status', User::STATUS_ACTIVE)->paginate(10)->withQueryString();
$users->getCollection()->transform(function ($user) use ($startOfMonth, $endOfMonth) {
$user->total_received = Administration::where('receiver', $user->msnv)
->whereBetween('date', [$startOfMonth, $endOfMonth])
->sum('received');
$user->total_sent = Administration::where('sender', $user->msnv)
->whereBetween('date', [$startOfMonth, $endOfMonth])
->sum('sent');
return $user;
});
return compact('users', 'topReceivedUser', 'topSentUser');
}
public function getUserTransactions(string $msnv, string $selectedMonth): LengthAwarePaginator
{
$startOfMonth = Carbon::parse($selectedMonth)->startOfMonth();
$endOfMonth = Carbon::parse($selectedMonth)->endOfMonth();
return Administration::where(function ($q) use ($msnv) {
$q->where('sender', $msnv)->orWhere('receiver', $msnv);
})
->whereBetween('date', [$startOfMonth, $endOfMonth])
->orderBy('date', 'desc')
->paginate(10)
->withQueryString();
}
public function createUser(array $data): void
{
User::create([
'msnv' => $data['msnv'],
'mail' => $data['mail'],
'pass' => Hash::make($data['password']),
'role' => $data['role'],
'status' => User::STATUS_ACTIVE,
'card' => 0,
'flag_send' => User::FLAG_SEND_DISABLED,
'first_login' => User::FIRST_LOGIN_TRUE,
]);
}
public function updateUser(string $msnv, array $data): void
{
$user = User::where('msnv', $msnv)->firstOrFail();
DB::transaction(function () use ($data, $user) {
if (!empty($data['num_card'])) {
AddCard::create([
'buyer' => $user->msnv,
'num_card' => $data['num_card'],
'seller' => Auth::user()->msnv,
'date' => Carbon::today(),
]);
$user->card += $data['num_card'];
}
$user->flag_send = isset($data['flag_send']) ? User::FLAG_SEND_ENABLED : User::FLAG_SEND_DISABLED;
$user->save();
});
}
public function deactivateUser(string $msnv): void
{
$user = User::where('msnv', $msnv)->firstOrFail();
$user->status = User::STATUS_INACTIVE;
$user->save();
}
public function resetAllCards(): void
{
User::where('status', User::STATUS_ACTIVE)->update(['card' => 0]);
}
}
@@ -0,0 +1,20 @@
<?php
namespace App\Services\Admin\Contracts;
use Illuminate\Pagination\LengthAwarePaginator;
interface AdminServiceInterface
{
public function getUserListWithStats(string $selectedMonth): array;
public function getUserTransactions(string $msnv, string $selectedMonth): LengthAwarePaginator;
public function createUser(array $data): void;
public function updateUser(string $msnv, array $data): void;
public function deactivateUser(string $msnv): void;
public function resetAllCards(): void;
}
+49
View File
@@ -0,0 +1,49 @@
<?php
namespace App\Services\Auth;
use App\Models\User;
use App\Services\Auth\Contracts\AuthServiceInterface;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class AuthService implements AuthServiceInterface
{
public function attemptLogin(array $credentials, Request $request): array
{
if (!Auth::attempt(['mail' => $credentials['mail'], 'password' => $credentials['password']])) {
return ['success' => false, 'error_key' => 'mail', 'error_msg' => __('auth.failed')];
}
$request->session()->regenerate();
$user = Auth::user();
if ($user->status != User::STATUS_ACTIVE) {
Auth::logout();
return ['success' => false, 'error_key' => 'mail', 'error_msg' => __('auth.inactive')];
}
return ['success' => true, 'user' => $user];
}
public function logout(Request $request): void
{
Auth::logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
}
public function getRedirectRouteForUser(User $user): string
{
if ($user->first_login == User::FIRST_LOGIN_TRUE) {
return route('user.change_password');
}
if ($user->role == User::ROLE_ADMIN) {
return route('admin.dashboard');
}
return route('user.dashboard');
}
}
@@ -0,0 +1,15 @@
<?php
namespace App\Services\Auth\Contracts;
use App\Models\User;
use Illuminate\Http\Request;
interface AuthServiceInterface
{
public function attemptLogin(array $credentials, Request $request): array;
public function logout(Request $request): void;
public function getRedirectRouteForUser(User $user): string;
}
@@ -0,0 +1,17 @@
<?php
namespace App\Services\User\Contracts;
use App\Models\User;
use Illuminate\Pagination\LengthAwarePaginator;
interface UserServiceInterface
{
public function getDashboard(User $user, string $selectedMonth): LengthAwarePaginator;
public function getOtherActiveUsers(User $currentUser): \Illuminate\Database\Eloquent\Collection;
public function sendThankcards(User $sender, string $receiverMsnv, int $amount): void;
public function updatePassword(User $user, string $newPassword): void;
}
+92
View File
@@ -0,0 +1,92 @@
<?php
namespace App\Services\User;
use App\Models\Administration;
use App\Models\User;
use App\Services\User\Contracts\UserServiceInterface;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
class UserService implements UserServiceInterface
{
public function getDashboard(User $user, string $selectedMonth): LengthAwarePaginator
{
$startOfMonth = Carbon::parse($selectedMonth)->startOfMonth();
$endOfMonth = Carbon::parse($selectedMonth)->endOfMonth();
return Administration::where('msnv', $user->msnv)
->whereBetween('date', [$startOfMonth, $endOfMonth])
->orderBy('date', 'desc')
->paginate(10)
->withQueryString();
}
public function getOtherActiveUsers(User $currentUser): Collection
{
return User::where('status', User::STATUS_ACTIVE)
->where('msnv', '!=', $currentUser->msnv)
->get();
}
public function sendThankcards(User $sender, string $receiverMsnv, int $amount): void
{
if ($sender->flag_send == User::FLAG_SEND_DISABLED) {
throw new \RuntimeException(__('messages.error.no_send_permission'));
}
$startOfMonth = Carbon::now()->startOfMonth();
$endOfMonth = Carbon::now()->endOfMonth();
$cardsSentToThisUserThisMonth = Administration::where('msnv', $sender->msnv)
->where('receiver', $receiverMsnv)
->whereBetween('date', [$startOfMonth, $endOfMonth])
->sum('sent');
if ($cardsSentToThisUserThisMonth + $amount > Administration::MAX_SEND_CARD_PER_MONTH) {
throw new \RuntimeException(
__('messages.error.max_send_limit_template', [
'max' => Administration::MAX_SEND_CARD_PER_MONTH,
'sent' => $cardsSentToThisUserThisMonth
])
);
}
if ($sender->card < $amount) {
throw new \RuntimeException(__('messages.error.not_enough_cards'));
}
DB::transaction(function () use ($sender, $receiverMsnv, $amount) {
Administration::create([
'msnv' => $receiverMsnv,
'received' => $amount,
'sender' => $sender->msnv,
'sent' => 0,
'receiver' => null,
'date' => Carbon::today(),
]);
Administration::create([
'msnv' => $sender->msnv,
'received' => 0,
'sender' => null,
'sent' => $amount,
'receiver' => $receiverMsnv,
'date' => Carbon::today(),
]);
$sender->card -= $amount;
$sender->save();
});
}
public function updatePassword(User $user, string $newPassword): void
{
$user->pass = Hash::make($newPassword);
$user->first_login = User::FIRST_LOGIN_FALSE;
$user->save();
}
}
+58
View File
@@ -0,0 +1,58 @@
import puppeteer from 'puppeteer';
import fs from 'fs';
import path from 'path';
(async () => {
const dir = './docs/notification-review';
if (!fs.existsSync(dir)){
fs.mkdirSync(dir, { recursive: true });
}
console.log('Launching browser...');
const browser = await puppeteer.launch({
headless: true,
args: ['--no-sandbox', '--disable-setuid-sandbox']
});
const page = await browser.newPage();
await page.setViewport({ width: 1280, height: 800 });
console.log('Navigating to showcase...');
await page.goto('http://localhost:8000/notification-showcase', { waitUntil: 'networkidle2' });
const cases = [
{ id: 1, name: 'login_failed' },
{ id: 2, name: 'access_denied' },
{ id: 3, name: 'first_login' },
{ id: 4, name: 'delete_user_confirmation' },
{ id: 5, name: 'create_user_success' },
{ id: 6, name: 'email_already_exists' },
{ id: 7, name: 'employee_id_already_exists' },
{ id: 8, name: 'no_data_found' },
{ id: 9, name: 'not_enough_thankcards' },
{ id: 10, name: 'max_5_thankcards_per_month' },
{ id: 11, name: 'send_thankcards_confirmation' },
{ id: 12, name: 'send_thankcards_success' },
{ id: 13, name: 'password_confirmation_mismatch' },
{ id: 14, name: 'change_password_success' },
{ id: 15, name: 'generic_system_error' }
];
for (const c of cases) {
console.log(`Triggering Case ${c.id}: ${c.name}...`);
// Click the button
await page.click(`#case-btn-${c.id}`);
// Wait for animation
await new Promise(resolve => setTimeout(resolve, 800));
// Save screenshot
const filename = `case_${String(c.id).padStart(2, '0')}_${c.name}.png`;
const filepath = path.join(dir, filename);
await page.screenshot({ path: filepath });
console.log(`Saved screenshot to ${filepath}`);
}
await browser.close();
console.log('Finished capturing all 15 screenshots!');
})();
@@ -20,8 +20,6 @@ return new class extends Migration
$table->integer('card')->nullable(); $table->integer('card')->nullable();
$table->tinyInteger('flag_send')->default(0)->comment('0: không được gửi / 1: được gửi'); $table->tinyInteger('flag_send')->default(0)->comment('0: không được gửi / 1: được gửi');
$table->tinyInteger('first_login')->default(1)->comment('0: không là lần đầu / 1: lần đầu'); $table->tinyInteger('first_login')->default(1)->comment('0: không là lần đầu / 1: lần đầu');
$table->rememberToken();
$table->timestamps();
}); });
// Only user table remains // Only user table remains
@@ -12,12 +12,10 @@ return new class extends Migration
public function up(): void public function up(): void
{ {
Schema::create('add_card', function (Blueprint $table) { Schema::create('add_card', function (Blueprint $table) {
$table->id();
$table->string('buyer'); $table->string('buyer');
$table->integer('num_card'); $table->integer('num_card');
$table->string('seller'); $table->string('seller');
$table->date('date')->nullable(); $table->date('date')->nullable();
$table->timestamps();
}); });
} }
@@ -19,7 +19,6 @@ return new class extends Migration
$table->integer('sent')->nullable(); $table->integer('sent')->nullable();
$table->string('receiver')->nullable(); $table->string('receiver')->nullable();
$table->date('date')->nullable(); $table->date('date')->nullable();
$table->timestamps();
}); });
} }
+2 -5
View File
@@ -15,11 +15,8 @@ class DatabaseSeeder extends Seeder
*/ */
public function run(): void public function run(): void
{ {
// User::factory(10)->create(); $this->call([
MockDataSeeder::class,
User::factory()->create([
'name' => 'Test User',
'email' => 'test@example.com',
]); ]);
} }
} }
+39
View File
@@ -0,0 +1,39 @@
# Notification & Popup Review List
This document lists all the standard dialogs, popups, toasts, and empty states implemented for the Thankcard System. These screenshots are captured automatically for Business Analyst (BA) and Quality Assurance (QA) review and approval.
## Common Components Implemented
The following reusable/common components are created and used across all display cases:
1. **Confirm Dialog**: Dialog prompting for confirmation before an action is executed (danger and primary types).
2. **Error Dialog**: Informing users of errors or validation failures.
3. **Success Dialog**: Standard dialog to show successful actions.
4. **Warning Dialog**: Warnings related to constraints or user balance.
5. **Info Dialog**: Friendly dialog for guidance (e.g., first login, change password required).
6. **Toast**: Slide-in non-blocking notification for quick feedback.
7. **Empty State**: Centered inline placeholder for lists containing no data.
---
## Review Case Matrix
| Case ID | Feature | Display Case | Notification Type | Message | Screenshot Path |
| :--- | :--- | :--- | :--- | :--- | :--- |
| **01** | Authentication | Login failed | Toast (Error) | Đăng nhập thất bại. Vui lòng kiểm tra lại Email hoặc Mật khẩu. | [case_01_login_failed.png](case_01_login_failed.png) |
| **02** | Authorization | Access denied | Error Dialog | Tài khoản của bạn không có quyền truy cập vào trang quản trị này. Vui lòng liên hệ với quản trị viên nếu đây là một sự nhầm lẫn. | [case_02_access_denied.png](case_02_access_denied.png) |
| **03** | Authentication | First login (change password required) | Info Dialog | Chào mừng bạn đến với hệ thống Thankcard! Đây là lần đầu tiên bạn đăng nhập, vui lòng đổi mật khẩu mới để tiếp tục sử dụng hệ thống. | [case_03_first_login.png](case_03_first_login.png) |
| **04** | User Management | Delete user confirmation | Confirm Dialog (Danger) | Bạn có chắc chắn muốn xóa tài khoản của nhân viên Nguyễn Văn A (MSNV: NV0001)? Hành động này không thể hoàn tác và tất cả dữ liệu liên quan sẽ bị xóa. | [case_04_delete_user_confirmation.png](case_04_delete_user_confirmation.png) |
| **05** | User Management | Create user success | Success Dialog | Tài khoản nhân viên mới đã được tạo thành công trong hệ thống. Email kích hoạt đã được gửi tới người dùng. | [case_05_create_user_success.png](case_05_create_user_success.png) |
| **06** | User Management | Email already exists | Error Dialog | Địa chỉ email 'nguyenvana@runsystem.net' đã được đăng ký bởi một nhân viên khác. Vui lòng sử dụng địa chỉ email khác. | [case_06_email_already_exists.png](case_06_email_already_exists.png) |
| **07** | User Management | Employee ID already exists | Error Dialog | Mã nhân viên (MSNV) 'NV0001' đã được gán cho một tài khoản khác trong hệ thống. Vui lòng kiểm tra lại. | [case_07_employee_id_already_exists.png](case_07_employee_id_already_exists.png) |
| **08** | General | No data found | Empty State | Không tìm thấy người dùng hoặc giao dịch nào phù hợp với bộ lọc tìm kiếm hiện tại của bạn. Vui lòng thử lại với từ khóa khác. | [case_08_no_data_found.png](case_08_no_data_found.png) |
| **09** | Thankcard Sending | Not enough thankcards | Warning Dialog | Số dư Thankcard hiện tại của bạn là 0. Bạn không thể gửi thêm thẻ cho đến khi số dư được đặt lại vào đầu tháng tới. | [case_09_not_enough_thankcards.png](case_09_not_enough_thankcards.png) |
| **10** | Thankcard Sending | Maximum 5 thankcards per user/month | Warning Dialog | Bạn đã đạt đến hạn mức gửi tối đa 5 Thankcard trong tháng này. Vui lòng đợi đến tháng sau để tiếp tục gửi lời cảm ơn. | [case_10_max_5_thankcards_per_month.png](case_10_max_5_thankcards_per_month.png) |
| **11** | Thankcard Sending | Send thankcards confirmation | Confirm Dialog (Primary) | Bạn đang thực hiện gửi 1 Thankcard tới Trần Thị B với lời nhắn: 'Cảm ơn bạn đã hỗ trợ dự án'. Bạn có chắc chắn muốn gửi? | [case_11_send_thankcards_confirmation.png](case_11_send_thankcards_confirmation.png) |
| **12** | Thankcard Sending | Send thankcards success | Success Dialog | Thankcard của bạn đã được gửi thành công đến Trần Thị B. Lượt gửi còn lại trong tháng của bạn: 4/5. | [case_12_send_thankcards_success.png](case_12_send_thankcards_success.png) |
| **13** | Password Management | Password confirmation mismatch | Error Dialog | Mật khẩu xác nhận nhập lại không khớp với mật khẩu mới. Vui lòng nhập lại chính xác cả hai trường. | [case_13_password_confirmation_mismatch.png](case_13_password_confirmation_mismatch.png) |
| **14** | Password Management | Change password success | Success Dialog | Mật khẩu của bạn đã được cập nhật thành công. Vui lòng sử dụng mật khẩu mới cho các lần đăng nhập tiếp theo. | [case_14_change_password_success.png](case_14_change_password_success.png) |
| **15** | General | Generic system error | Error Dialog | Đã xảy ra sự cố kết nối với máy chủ (Mã lỗi: 500 - Internal Server Error). Vui lòng thử lại sau hoặc báo cáo cho đội ngũ kỹ thuật. | [case_15_generic_system_error.png](case_15_generic_system_error.png) |
---
*Note: All screenshots were automatically captured under `1280x800` viewport to ensure precise rendering of transitions and CSS styling.*
Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

-12
View File
@@ -1,12 +0,0 @@
<?php
return [
'login' => 'Login',
'email' => 'Email',
'email_placeholder' => 'Enter your email',
'password' => 'Password',
'password_placeholder' => 'Enter your password',
'forgot_password' => 'Forgot password?',
'system_name' => 'Thank Card System',
'developed_by' => 'Developed by GMO-Z.com RUNSYSTEM',
];
-12
View File
@@ -1,12 +0,0 @@
<?php
return [
'manage_users' => 'Manage Users',
'add_user' => 'Add User',
'my_page' => 'My Page',
'send_card' => 'Send Thank Card',
'change_password' => 'Change Password',
'logout' => 'Logout',
'have_questions' => 'Have questions?',
'feedback' => 'Feedback',
];
+2
View File
@@ -9,4 +9,6 @@ return [
'forgot_password' => 'Quên mật khẩu?', 'forgot_password' => 'Quên mật khẩu?',
'system_name' => 'Hệ thống Thank Card', 'system_name' => 'Hệ thống Thank Card',
'developed_by' => 'Được phát triển bởi GMO-Z.com RUNSYSTEM', 'developed_by' => 'Được phát triển bởi GMO-Z.com RUNSYSTEM',
'failed' => 'Email hoặc mật khẩu không chính xác.',
'inactive' => 'Tài khoản của bạn đã bị khóa hoặc bạn đã nghỉ việc.',
]; ];
+16
View File
@@ -0,0 +1,16 @@
<?php
return [
'password_change_success' => 'Đổi mật khẩu thành công!',
'user_create_success' => 'Tạo user thành công!',
'user_update_success' => 'Cập nhật thành công!',
'user_deactivate_success' => 'Đã đánh dấu nhân viên nghỉ việc thành công!',
'cards_reset_success' => 'Reset thẻ toàn hệ thống thành công!',
'thank_card_send_success' => 'Đã gửi Thank card thành công!',
'error' => [
'no_send_permission' => 'Bạn hiện không có quyền gửi Thank Card.',
'not_enough_cards' => 'Số lượng card của bạn không đủ. Vui lòng liên hệ Admin.',
'max_send_limit_template' => 'Một tháng bạn chỉ được gửi tối đa :max card cho một người. Bạn đã gửi :sent card cho nhân viên này.',
]
];
+34
View File
@@ -0,0 +1,34 @@
<?php
return [
'required' => ':attribute không được để trống.',
'email' => ':attribute không đúng định dạng.',
'max' => [
'numeric' => ':attribute không được lớn hơn :max.',
'file' => ':attribute không được lớn hơn :max kilobytes.',
'string' => ':attribute không được vượt quá :max ký tự.',
'array' => ':attribute không được có nhiều hơn :max phần tử.',
],
'min' => [
'numeric' => ':attribute phải tối thiểu là :min.',
'file' => ':attribute phải tối thiểu là :min kilobytes.',
'string' => ':attribute phải có ít nhất :min ký tự.',
'array' => ':attribute phải có ít nhất :min phần tử.',
],
'unique' => ':attribute đã tồn tại trong hệ thống.',
'confirmed' => 'Xác nhận :attribute không khớp.',
'integer' => ':attribute phải là một số nguyên.',
'exists' => ':attribute không tồn tại.',
'boolean' => ':attribute phải là true hoặc false.',
'attributes' => [
'mail' => 'Email',
'password' => 'Mật khẩu',
'msnv' => 'Mã số nhân viên',
'role' => 'Vai trò',
'num_card' => 'Số lượng thẻ',
'flag_send' => 'Quyền gửi',
'receiver' => 'Người nhận',
'amount' => 'Số lượng',
],
];
+3 -2
View File
@@ -57,6 +57,7 @@
@source '../../storage/framework/views/*.php'; @source '../../storage/framework/views/*.php';
@source '../**/*.blade.php'; @source '../**/*.blade.php';
@source '../**/*.js'; @source '../**/*.js';
@source inline('bg-primary-light/35 ring-primary/20 border-primary ring-2 animate-pulse');
@theme { @theme {
--font-sans: 'SVN-Poppins', 'SVN-Poppins-SemiBold', 'SVN-Poppins-Regular', 'SVN-Poppins-Medium', 'Roboto', 'Instrument Sans', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; --font-sans: 'SVN-Poppins', 'SVN-Poppins-SemiBold', 'SVN-Poppins-Regular', 'SVN-Poppins-Medium', 'Roboto', 'Instrument Sans', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
@@ -100,7 +101,7 @@
} }
.sidebar { .sidebar {
@apply w-60 bg-bg-sidebar border-r border-border-light flex flex-col justify-between shrink-0 shadow-sidebar transition-all duration-300; @apply w-60 h-screen sticky top-0 bg-bg-sidebar border-r border-border-light flex flex-col justify-between shrink-0 shadow-sidebar transition-all duration-300 overflow-hidden;
} }
.main-content { .main-content {
@@ -185,7 +186,7 @@
} }
.form-label { .form-label {
@apply block text-[16px] font-[600] text-text-dark mb-2; /* 8px label gap */ @apply block text-[14px] font-[600] text-text-dark mb-2; /* 8px label gap */
} }
/* Standard Input & Select */ /* Standard Input & Select */
+6 -1
View File
@@ -40,7 +40,7 @@
<div class="form-group mb-0"> <div class="form-group mb-0">
<label class="flex items-center gap-3 cursor-pointer p-4 border border-[#d9dfe7] rounded-lg hover:bg-gray-50 hover:border-gray-400 transition-colors shadow-sm"> <label class="flex items-center gap-3 cursor-pointer p-4 border border-[#d9dfe7] rounded-lg hover:bg-gray-50 hover:border-gray-400 transition-colors shadow-sm">
<input type="checkbox" name="flag_send" id="flag_send" value="1" class="form-input !w-5 !h-5 !min-h-0" {{ $user->flag_send ? 'checked' : '' }}> <input type="checkbox" name="flag_send" id="flag_send" value="1" class="form-input !w-5 !h-5 !min-h-0" {{ $user->flag_send ? 'checked' : '' }}>
<span class="text-[16px] font-[600] text-text-dark">Bật quyền cho phép User gửi thẻ</span> <span class="text-[14px] font-[600] text-text-dark">Bật quyền cho phép User gửi thẻ</span>
</label> </label>
</div> </div>
</div> </div>
@@ -110,6 +110,11 @@
</tbody> </tbody>
</table> </table>
</div> </div>
@if($administrations->hasPages())
<div class="px-6 py-4 border-t border-border-light bg-gray-50/30">
{{ $administrations->links() }}
</div>
@endif
</div> </div>
</div> </div>
</div> </div>
@@ -0,0 +1,32 @@
@props(['id', 'title' => 'Xác nhận', 'message' => '', 'confirmText' => 'Đồng ý', 'cancelText' => 'Hủy', 'type' => 'danger'])
<div id="{{ $id }}" class="fixed inset-0 z-[100] hidden flex-col items-center justify-center outline-none opacity-0 transition-opacity duration-300" aria-labelledby="modal-title-{{ $id }}" role="dialog" aria-modal="true">
<div class="absolute inset-0 bg-slate-900/60 backdrop-blur-sm cursor-pointer" onclick="closeModal('{{ $id }}')"></div>
<div class="modal-panel bg-white rounded-2xl shadow-2xl w-full sm:max-w-md overflow-hidden z-50 relative transform scale-95 translate-y-4 transition-all duration-300 m-4 flex flex-col">
<div class="p-6 flex flex-col items-center text-center">
<!-- Icon based on type -->
<div class="w-14 h-14 rounded-full flex items-center justify-center mb-4 {{ $type === 'danger' ? 'bg-red-50 text-red-500' : 'bg-blue-50 text-blue-500' }}">
@if($type === 'danger')
<svg class="w-8 h-8" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
@else
<svg class="w-8 h-8" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
@endif
</div>
<h3 id="modal-title-{{ $id }}" class="text-xl font-bold text-slate-800 mb-2">{{ $title }}</h3>
<p class="text-slate-500 text-sm leading-relaxed">{{ $message }}</p>
</div>
<div class="px-6 py-4 bg-slate-50 flex gap-3 border-t border-slate-100 justify-end">
<button type="button" onclick="closeModal('{{ $id }}')" class="px-4 py-2 bg-white hover:bg-slate-100 border border-slate-200 text-slate-700 text-sm font-semibold rounded-lg transition-all duration-200 cursor-pointer">
{{ $cancelText }}
</button>
<button type="button" onclick="closeModal('{{ $id }}')" class="px-4 py-2 text-white text-sm font-semibold rounded-lg transition-all duration-200 cursor-pointer {{ $type === 'danger' ? 'bg-red-600 hover:bg-red-700' : 'bg-primary hover:bg-primary-hover' }}">
{{ $confirmText }}
</button>
</div>
</div>
</div>
@@ -0,0 +1,17 @@
@props(['title' => 'Không có dữ liệu', 'message' => 'Không tìm thấy dữ liệu nào phù hợp với yêu cầu.', 'actionText' => '', 'actionId' => ''])
<div class="flex flex-col items-center justify-center p-8 bg-white border border-slate-100 rounded-2xl shadow-sm text-center max-w-lg mx-auto my-6">
<div class="w-48 h-48 mb-6 flex items-center justify-center text-slate-300">
<!-- SVG illustration of empty search/mailbox -->
<svg class="w-32 h-32 text-slate-300 animate-pulse" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M20.25 7.5l-.625 10.632a2.25 2.25 0 01-2.247 2.118H6.622a2.25 2.25 0 01-2.247-2.118L3.75 7.5m6 4.125l2.25 2.25m0 0l2.25 2.25M12 13.875l2.25-2.25M12 13.875l-2.25-2.25M3.375 7.5h17.25c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125H3.375c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z" />
</svg>
</div>
<h3 class="text-lg font-bold text-slate-800 mb-2">{{ $title }}</h3>
<p class="text-sm text-slate-500 max-w-sm mb-6 leading-relaxed">{{ $message }}</p>
@if($actionText)
<button type="button" @if($actionId) id="{{ $actionId }}" @endif class="btn-primary px-6 py-2 text-sm font-semibold rounded-lg shadow-sm cursor-pointer transition-all duration-200">
{{ $actionText }}
</button>
@endif
</div>
@@ -0,0 +1,22 @@
@props(['id', 'title' => 'Đã xảy ra lỗi', 'message' => '', 'buttonText' => 'Đóng'])
<div id="{{ $id }}" class="fixed inset-0 z-[100] hidden flex-col items-center justify-center outline-none opacity-0 transition-opacity duration-300" aria-labelledby="modal-title-{{ $id }}" role="dialog" aria-modal="true">
<div class="absolute inset-0 bg-slate-900/60 backdrop-blur-sm cursor-pointer" onclick="closeModal('{{ $id }}')"></div>
<div class="modal-panel bg-white rounded-2xl shadow-2xl w-full sm:max-w-md overflow-hidden z-50 relative transform scale-95 translate-y-4 transition-all duration-300 m-4 flex flex-col">
<div class="p-6 flex flex-col items-center text-center">
<div class="w-14 h-14 rounded-full bg-red-50 text-red-500 flex items-center justify-center mb-4 animate-bounce">
<svg class="w-8 h-8" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</div>
<h3 id="modal-title-{{ $id }}" class="text-xl font-bold text-slate-800 mb-2">{{ $title }}</h3>
<p class="text-slate-500 text-sm leading-relaxed">{{ $message }}</p>
</div>
<div class="px-6 py-4 bg-slate-50 flex justify-center border-t border-slate-100">
<button type="button" onclick="closeModal('{{ $id }}')" class="w-full sm:w-auto px-6 py-2 bg-red-600 hover:bg-red-700 text-white text-sm font-semibold rounded-lg shadow-sm transition-all duration-200 cursor-pointer text-center">
{{ $buttonText }}
</button>
</div>
</div>
</div>
@@ -0,0 +1,22 @@
@props(['id', 'title' => 'Thông tin', 'message' => '', 'buttonText' => 'Đóng'])
<div id="{{ $id }}" class="fixed inset-0 z-[100] hidden flex-col items-center justify-center outline-none opacity-0 transition-opacity duration-300" aria-labelledby="modal-title-{{ $id }}" role="dialog" aria-modal="true">
<div class="absolute inset-0 bg-slate-900/60 backdrop-blur-sm cursor-pointer" onclick="closeModal('{{ $id }}')"></div>
<div class="modal-panel bg-white rounded-2xl shadow-2xl w-full sm:max-w-md overflow-hidden z-50 relative transform scale-95 translate-y-4 transition-all duration-300 m-4 flex flex-col">
<div class="p-6 flex flex-col items-center text-center">
<div class="w-14 h-14 rounded-full bg-blue-50 text-blue-500 flex items-center justify-center mb-4">
<svg class="w-8 h-8" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<h3 id="modal-title-{{ $id }}" class="text-xl font-bold text-slate-800 mb-2">{{ $title }}</h3>
<p class="text-slate-500 text-sm leading-relaxed">{{ $message }}</p>
</div>
<div class="px-6 py-4 bg-slate-50 flex justify-center border-t border-slate-100">
<button type="button" onclick="closeModal('{{ $id }}')" class="w-full sm:w-auto px-6 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-semibold rounded-lg shadow-sm transition-all duration-200 cursor-pointer text-center">
{{ $buttonText }}
</button>
</div>
</div>
</div>
@@ -0,0 +1,22 @@
@props(['id', 'title' => 'Thành công', 'message' => '', 'buttonText' => 'Xác nhận'])
<div id="{{ $id }}" class="fixed inset-0 z-[100] hidden flex-col items-center justify-center outline-none opacity-0 transition-opacity duration-300" aria-labelledby="modal-title-{{ $id }}" role="dialog" aria-modal="true">
<div class="absolute inset-0 bg-slate-900/60 backdrop-blur-sm cursor-pointer" onclick="closeModal('{{ $id }}')"></div>
<div class="modal-panel bg-white rounded-2xl shadow-2xl w-full sm:max-w-md overflow-hidden z-50 relative transform scale-95 translate-y-4 transition-all duration-300 m-4 flex flex-col">
<div class="p-6 flex flex-col items-center text-center">
<div class="w-14 h-14 rounded-full bg-emerald-50 text-emerald-500 flex items-center justify-center mb-4 scale-110">
<svg class="w-8 h-8 animate-pulse" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
</svg>
</div>
<h3 id="modal-title-{{ $id }}" class="text-xl font-bold text-slate-800 mb-2">{{ $title }}</h3>
<p class="text-slate-500 text-sm leading-relaxed">{{ $message }}</p>
</div>
<div class="px-6 py-4 bg-slate-50 flex justify-center border-t border-slate-100">
<button type="button" onclick="closeModal('{{ $id }}')" class="w-full sm:w-auto px-6 py-2 bg-emerald-600 hover:bg-emerald-700 text-white text-sm font-semibold rounded-lg shadow-sm transition-all duration-200 cursor-pointer text-center">
{{ $buttonText }}
</button>
</div>
</div>
</div>
@@ -0,0 +1,20 @@
@props(['id' => 'toast', 'type' => 'success', 'message' => ''])
<div id="{{ $id }}" class="fixed top-4 right-4 z-[999] flex items-center w-full max-w-sm p-4 space-x-3 text-slate-500 bg-white rounded-xl shadow-xl border border-slate-100 hidden opacity-0 transform translate-y-[-10px] transition-all duration-300" role="alert">
<div class="inline-flex items-center justify-center shrink-0 w-9 h-9 rounded-lg {{ $type === 'success' ? 'bg-emerald-50 text-emerald-500' : ($type === 'error' ? 'bg-red-50 text-red-500' : ($type === 'warning' ? 'bg-amber-50 text-amber-500' : 'bg-blue-50 text-blue-500')) }}">
@if($type === 'success')
<svg class="w-5.5 h-5.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" /></svg>
@elseif($type === 'error')
<svg class="w-5.5 h-5.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" /></svg>
@elseif($type === 'warning')
<svg class="w-5.5 h-5.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg>
@else
<svg class="w-5.5 h-5.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
@endif
</div>
<div class="text-sm font-semibold text-slate-800 flex-1">{{ $message }}</div>
<button type="button" onclick="closeToast('{{ $id }}')" class="text-slate-400 hover:text-slate-600 rounded-lg p-1 hover:bg-slate-50 inline-flex h-8 w-8 justify-center items-center cursor-pointer transition-colors">
<span class="sr-only">Close</span>
<svg class="w-4 h-4" fill="none" viewBox="0 0 14 14" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="m1 1 6 6m0 0 6 6M7 7l6-6M7 7l-6 6"/></svg>
</button>
</div>
@@ -0,0 +1,22 @@
@props(['id', 'title' => 'Cảnh báo', 'message' => '', 'buttonText' => 'Đã hiểu'])
<div id="{{ $id }}" class="fixed inset-0 z-[100] hidden flex-col items-center justify-center outline-none opacity-0 transition-opacity duration-300" aria-labelledby="modal-title-{{ $id }}" role="dialog" aria-modal="true">
<div class="absolute inset-0 bg-slate-900/60 backdrop-blur-sm cursor-pointer" onclick="closeModal('{{ $id }}')"></div>
<div class="modal-panel bg-white rounded-2xl shadow-2xl w-full sm:max-w-md overflow-hidden z-50 relative transform scale-95 translate-y-4 transition-all duration-300 m-4 flex flex-col">
<div class="p-6 flex flex-col items-center text-center">
<div class="w-14 h-14 rounded-full bg-amber-50 text-amber-500 flex items-center justify-center mb-4">
<svg class="w-8 h-8 animate-bounce" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
</div>
<h3 id="modal-title-{{ $id }}" class="text-xl font-bold text-slate-800 mb-2">{{ $title }}</h3>
<p class="text-slate-500 text-sm leading-relaxed">{{ $message }}</p>
</div>
<div class="px-6 py-4 bg-slate-50 flex justify-center border-t border-slate-100">
<button type="button" onclick="closeModal('{{ $id }}')" class="w-full sm:w-auto px-6 py-2 bg-amber-500 hover:bg-amber-600 text-white text-sm font-semibold rounded-lg shadow-sm transition-all duration-200 cursor-pointer text-center">
{{ $buttonText }}
</button>
</div>
</div>
</div>
+3 -9
View File
@@ -12,7 +12,7 @@
<div class="app-layout"> <div class="app-layout">
<aside class="sidebar"> <aside class="sidebar">
<div class="flex flex-col h-full justify-between"> <div class="flex flex-col h-full justify-between">
<div> <div class="flex-1 flex flex-col min-h-0 overflow-hidden">
<div class="h-16 flex items-center justify-between px-6 border-b border-border-light"> <div class="h-16 flex items-center justify-between px-6 border-b border-border-light">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<img src="{{ asset('images/logo.png') }}" alt="GMO-Z.com RUNSYSTEM" class="h-8 w-auto object-contain"> <img src="{{ asset('images/logo.png') }}" alt="GMO-Z.com RUNSYSTEM" class="h-8 w-auto object-contain">
@@ -24,7 +24,7 @@
</button> </button>
</div> </div>
<nav class="p-4 space-y-1"> <nav class="p-4 space-y-1 overflow-y-auto flex-1">
@if(Auth::check() && Auth::user()->role == \App\Models\User::ROLE_ADMIN) @if(Auth::check() && Auth::user()->role == \App\Models\User::ROLE_ADMIN)
<!-- ADMIN SIDEBAR --> <!-- ADMIN SIDEBAR -->
<a href="{{ route('admin.users.index') }}" class="sidebar-link {{ request()->routeIs('admin.users.index') ? 'sidebar-link-active' : '' }}"> <a href="{{ route('admin.users.index') }}" class="sidebar-link {{ request()->routeIs('admin.users.index') ? 'sidebar-link-active' : '' }}">
@@ -64,13 +64,7 @@
</nav> </nav>
</div> </div>
<div> <div class="shrink-0">
<div class="mx-4 my-2 p-4 rounded-lg bg-primary/5 border border-primary/10 flex flex-col items-center text-center">
<img src="https://illustrations.popsy.co/blue/document-delivery.svg" alt="Support" class="w-24 h-24 object-contain mb-2">
<h4 class="text-text-dark font-semibold text-xs mb-1">{{ __('layout.have_questions') }}</h4>
<a href="#" class="btn-primary py-1.5 px-4 text-xs w-full mt-2 inline-block">{{ __('layout.feedback') }}</a>
</div>
<div class="px-6 py-4 border-t border-border-light text-center"> <div class="px-6 py-4 border-t border-border-light text-center">
<span class="text-text-muted text-[10px]">&copy; 2026 GMO-Z.com RUNSYSTEM</span> <span class="text-text-muted text-[10px]">&copy; 2026 GMO-Z.com RUNSYSTEM</span>
</div> </div>
@@ -0,0 +1,443 @@
@extends('layouts.app')
@section('title', 'Notification Showcase')
@section('content')
<div class="grid grid-cols-1 lg:grid-cols-12 gap-6 h-[calc(100vh-8rem)]">
<!-- Left Panel: Case Selection (lg:col-span-4) -->
<div class="lg:col-span-4 bg-white rounded-2xl border border-slate-200 shadow-sm flex flex-col min-h-0 overflow-hidden">
<div class="p-5 border-b border-slate-100 bg-slate-50/50">
<h2 class="text-lg font-bold text-slate-800">Review Cases (15 cases)</h2>
<p class="text-xs text-slate-500 mt-1">Select a case to trigger its notification. Used for QA & BA approval.</p>
</div>
<div class="flex-1 overflow-y-auto p-4 space-y-2" id="cases-list">
<!-- Case 1 -->
<button onclick="triggerCase(1)" id="case-btn-1" class="case-btn w-full text-left p-3.5 rounded-xl border border-slate-100 hover:border-slate-300 hover:bg-slate-50 transition-all duration-200 flex flex-col gap-1 cursor-pointer">
<div class="flex items-center justify-between">
<span class="text-xs font-bold text-slate-400">Case 01</span>
<span class="px-2 py-0.5 text-[10px] font-bold bg-amber-50 text-amber-600 rounded-full border border-amber-100 uppercase">Toast (Error)</span>
</div>
<span class="text-sm font-bold text-slate-700">Login failed</span>
</button>
<!-- Case 2 -->
<button onclick="triggerCase(2)" id="case-btn-2" class="case-btn w-full text-left p-3.5 rounded-xl border border-slate-100 hover:border-slate-300 hover:bg-slate-50 transition-all duration-200 flex flex-col gap-1 cursor-pointer">
<div class="flex items-center justify-between">
<span class="text-xs font-bold text-slate-400">Case 02</span>
<span class="px-2 py-0.5 text-[10px] font-bold bg-red-50 text-red-600 rounded-full border border-red-100 uppercase">Error Dialog</span>
</div>
<span class="text-sm font-bold text-slate-700">Access denied</span>
</button>
<!-- Case 3 -->
<button onclick="triggerCase(3)" id="case-btn-3" class="case-btn w-full text-left p-3.5 rounded-xl border border-slate-100 hover:border-slate-300 hover:bg-slate-50 transition-all duration-200 flex flex-col gap-1 cursor-pointer">
<div class="flex items-center justify-between">
<span class="text-xs font-bold text-slate-400">Case 03</span>
<span class="px-2 py-0.5 text-[10px] font-bold bg-blue-50 text-blue-600 rounded-full border border-blue-100 uppercase">Info Dialog</span>
</div>
<span class="text-sm font-bold text-slate-700">First login (change password)</span>
</button>
<!-- Case 4 -->
<button onclick="triggerCase(4)" id="case-btn-4" class="case-btn w-full text-left p-3.5 rounded-xl border border-slate-100 hover:border-slate-300 hover:bg-slate-50 transition-all duration-200 flex flex-col gap-1 cursor-pointer">
<div class="flex items-center justify-between">
<span class="text-xs font-bold text-slate-400">Case 04</span>
<span class="px-2 py-0.5 text-[10px] font-bold bg-purple-50 text-purple-600 rounded-full border border-purple-100 uppercase">Confirm Dialog</span>
</div>
<span class="text-sm font-bold text-slate-700">Delete user confirmation</span>
</button>
<!-- Case 5 -->
<button onclick="triggerCase(5)" id="case-btn-5" class="case-btn w-full text-left p-3.5 rounded-xl border border-slate-100 hover:border-slate-300 hover:bg-slate-50 transition-all duration-200 flex flex-col gap-1 cursor-pointer">
<div class="flex items-center justify-between">
<span class="text-xs font-bold text-slate-400">Case 05</span>
<span class="px-2 py-0.5 text-[10px] font-bold bg-emerald-50 text-emerald-600 rounded-full border border-emerald-100 uppercase">Success Dialog</span>
</div>
<span class="text-sm font-bold text-slate-700">Create user success</span>
</button>
<!-- Case 6 -->
<button onclick="triggerCase(6)" id="case-btn-6" class="case-btn w-full text-left p-3.5 rounded-xl border border-slate-100 hover:border-slate-300 hover:bg-slate-50 transition-all duration-200 flex flex-col gap-1 cursor-pointer">
<div class="flex items-center justify-between">
<span class="text-xs font-bold text-slate-400">Case 06</span>
<span class="px-2 py-0.5 text-[10px] font-bold bg-red-50 text-red-600 rounded-full border border-red-100 uppercase">Error Dialog</span>
</div>
<span class="text-sm font-bold text-slate-700">Email already exists</span>
</button>
<!-- Case 7 -->
<button onclick="triggerCase(7)" id="case-btn-7" class="case-btn w-full text-left p-3.5 rounded-xl border border-slate-100 hover:border-slate-300 hover:bg-slate-50 transition-all duration-200 flex flex-col gap-1 cursor-pointer">
<div class="flex items-center justify-between">
<span class="text-xs font-bold text-slate-400">Case 07</span>
<span class="px-2 py-0.5 text-[10px] font-bold bg-red-50 text-red-600 rounded-full border border-red-100 uppercase">Error Dialog</span>
</div>
<span class="text-sm font-bold text-slate-700">Employee ID already exists</span>
</button>
<!-- Case 8 -->
<button onclick="triggerCase(8)" id="case-btn-8" class="case-btn w-full text-left p-3.5 rounded-xl border border-slate-100 hover:border-slate-300 hover:bg-slate-50 transition-all duration-200 flex flex-col gap-1 cursor-pointer">
<div class="flex items-center justify-between">
<span class="text-xs font-bold text-slate-400">Case 08</span>
<span class="px-2 py-0.5 text-[10px] font-bold bg-slate-50 text-slate-600 rounded-full border border-slate-200 uppercase">Empty State</span>
</div>
<span class="text-sm font-bold text-slate-700">No data found</span>
</button>
<!-- Case 9 -->
<button onclick="triggerCase(9)" id="case-btn-9" class="case-btn w-full text-left p-3.5 rounded-xl border border-slate-100 hover:border-slate-300 hover:bg-slate-50 transition-all duration-200 flex flex-col gap-1 cursor-pointer">
<div class="flex items-center justify-between">
<span class="text-xs font-bold text-slate-400">Case 09</span>
<span class="px-2 py-0.5 text-[10px] font-bold bg-amber-50 text-amber-600 rounded-full border border-amber-100 uppercase">Warning Dialog</span>
</div>
<span class="text-sm font-bold text-slate-700">Not enough thankcards</span>
</button>
<!-- Case 10 -->
<button onclick="triggerCase(10)" id="case-btn-10" class="case-btn w-full text-left p-3.5 rounded-xl border border-slate-100 hover:border-slate-300 hover:bg-slate-50 transition-all duration-200 flex flex-col gap-1 cursor-pointer">
<div class="flex items-center justify-between">
<span class="text-xs font-bold text-slate-400">Case 10</span>
<span class="px-2 py-0.5 text-[10px] font-bold bg-amber-50 text-amber-600 rounded-full border border-amber-100 uppercase">Warning Dialog</span>
</div>
<span class="text-sm font-bold text-slate-700">Max 5 thankcards/month</span>
</button>
<!-- Case 11 -->
<button onclick="triggerCase(11)" id="case-btn-11" class="case-btn w-full text-left p-3.5 rounded-xl border border-slate-100 hover:border-slate-300 hover:bg-slate-50 transition-all duration-200 flex flex-col gap-1 cursor-pointer">
<div class="flex items-center justify-between">
<span class="text-xs font-bold text-slate-400">Case 11</span>
<span class="px-2 py-0.5 text-[10px] font-bold bg-purple-50 text-purple-600 rounded-full border border-purple-100 uppercase">Confirm Dialog</span>
</div>
<span class="text-sm font-bold text-slate-700">Send thankcards confirmation</span>
</button>
<!-- Case 12 -->
<button onclick="triggerCase(12)" id="case-btn-12" class="case-btn w-full text-left p-3.5 rounded-xl border border-slate-100 hover:border-slate-300 hover:bg-slate-50 transition-all duration-200 flex flex-col gap-1 cursor-pointer">
<div class="flex items-center justify-between">
<span class="text-xs font-bold text-slate-400">Case 12</span>
<span class="px-2 py-0.5 text-[10px] font-bold bg-emerald-50 text-emerald-600 rounded-full border border-emerald-100 uppercase">Success Dialog</span>
</div>
<span class="text-sm font-bold text-slate-700">Send thankcards success</span>
</button>
<!-- Case 13 -->
<button onclick="triggerCase(13)" id="case-btn-13" class="case-btn w-full text-left p-3.5 rounded-xl border border-slate-100 hover:border-slate-300 hover:bg-slate-50 transition-all duration-200 flex flex-col gap-1 cursor-pointer">
<div class="flex items-center justify-between">
<span class="text-xs font-bold text-slate-400">Case 13</span>
<span class="px-2 py-0.5 text-[10px] font-bold bg-red-50 text-red-600 rounded-full border border-red-100 uppercase">Error Dialog</span>
</div>
<span class="text-sm font-bold text-slate-700">Password confirmation mismatch</span>
</button>
<!-- Case 14 -->
<button onclick="triggerCase(14)" id="case-btn-14" class="case-btn w-full text-left p-3.5 rounded-xl border border-slate-100 hover:border-slate-300 hover:bg-slate-50 transition-all duration-200 flex flex-col gap-1 cursor-pointer">
<div class="flex items-center justify-between">
<span class="text-xs font-bold text-slate-400">Case 14</span>
<span class="px-2 py-0.5 text-[10px] font-bold bg-emerald-50 text-emerald-600 rounded-full border border-emerald-100 uppercase">Success Dialog</span>
</div>
<span class="text-sm font-bold text-slate-700">Change password success</span>
</button>
<!-- Case 15 -->
<button onclick="triggerCase(15)" id="case-btn-15" class="case-btn w-full text-left p-3.5 rounded-xl border border-slate-100 hover:border-slate-300 hover:bg-slate-50 transition-all duration-200 flex flex-col gap-1 cursor-pointer">
<div class="flex items-center justify-between">
<span class="text-xs font-bold text-slate-400">Case 15</span>
<span class="px-2 py-0.5 text-[10px] font-bold bg-red-50 text-red-600 rounded-full border border-red-100 uppercase">Error Dialog</span>
</div>
<span class="text-sm font-bold text-slate-700">Generic system error</span>
</button>
</div>
</div>
<!-- Right Panel: Live Browser Preview (lg:col-span-8) -->
<div class="lg:col-span-8 bg-slate-100 rounded-2xl border border-slate-200 overflow-hidden flex flex-col relative" id="preview-container">
<!-- Mock Top Bar -->
<div class="bg-white border-b border-slate-200 px-6 py-4 flex items-center justify-between z-10">
<div class="flex items-center gap-2">
<div class="flex gap-1.5">
<span class="w-3.5 h-3.5 rounded-full bg-red-400 block"></span>
<span class="w-3.5 h-3.5 rounded-full bg-amber-400 block"></span>
<span class="w-3.5 h-3.5 rounded-full bg-emerald-400 block"></span>
</div>
<span class="text-slate-400 text-xs font-medium ml-4">Browser Mockup - Preview Viewport</span>
</div>
<div class="flex items-center gap-4 text-xs font-bold text-slate-700" id="current-case-label">
Active Case: None selected
</div>
</div>
<!-- Mock Workspace Page (this acts as the realistic background when modals appear) -->
<div class="flex-1 p-6 relative overflow-y-auto flex flex-col" id="mock-workspace">
<!-- Standard User Table Mockup -->
<div id="mock-table-view" class="bg-white rounded-xl shadow-sm border border-slate-200 flex-1 flex flex-col overflow-hidden">
<div class="p-5 border-b border-slate-100 flex items-center justify-between">
<div>
<h3 class="font-bold text-slate-800 text-base">Danh sách nhân viên (Mock User Table)</h3>
<p class="text-xs text-slate-500 mt-0.5">Hiển thị danh sách nhân viên trong hệ thống Thankcard.</p>
</div>
<button class="btn-primary py-1.5 px-4 text-xs font-semibold rounded-lg shadow-sm">Thêm nhân viên</button>
</div>
<div class="overflow-x-auto flex-1">
<table class="w-full text-left text-sm text-slate-600 border-collapse">
<thead>
<tr class="bg-slate-50 text-slate-500 font-semibold border-b border-slate-100">
<th class="p-4">MSNV</th>
<th class="p-4">Họ tên</th>
<th class="p-4">Email</th>
<th class="p-4">Trạng thái</th>
<th class="p-4 text-right">Thao tác</th>
</tr>
</thead>
<tbody>
<tr class="border-b border-slate-100 hover:bg-slate-50/50">
<td class="p-4 font-bold text-slate-700">NV0001</td>
<td class="p-4 font-semibold text-slate-800">Nguyễn Văn A</td>
<td class="p-4">nguyenvana@runsystem.net</td>
<td class="p-4">
<span class="inline-flex px-2 py-0.5 text-xs font-semibold rounded-full bg-emerald-50 text-emerald-600 border border-emerald-100">Hoạt động</span>
</td>
<td class="p-4 text-right flex justify-end gap-2">
<button class="p-1 text-slate-400 hover:text-primary transition-colors cursor-pointer">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"/></svg>
</button>
<button class="p-1 text-slate-400 hover:text-red-500 transition-colors cursor-pointer">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /></svg>
</button>
</td>
</tr>
<tr class="border-b border-slate-100 hover:bg-slate-50/50">
<td class="p-4 font-bold text-slate-700">NV0002</td>
<td class="p-4 font-semibold text-slate-800">Trần Thị B</td>
<td class="p-4">tranthib@runsystem.net</td>
<td class="p-4">
<span class="inline-flex px-2 py-0.5 text-xs font-semibold rounded-full bg-emerald-50 text-emerald-600 border border-emerald-100">Hoạt động</span>
</td>
<td class="p-4 text-right flex justify-end gap-2">
<button class="p-1 text-slate-400 hover:text-primary transition-colors cursor-pointer">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"/></svg>
</button>
<button class="p-1 text-slate-400 hover:text-red-500 transition-colors cursor-pointer">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /></svg>
</button>
</td>
</tr>
<tr class="hover:bg-slate-50/50">
<td class="p-4 font-bold text-slate-700">NV0003</td>
<td class="p-4 font-semibold text-slate-800"> Văn C</td>
<td class="p-4">levanc@runsystem.net</td>
<td class="p-4">
<span class="inline-flex px-2 py-0.5 text-xs font-semibold rounded-full bg-slate-100 text-slate-600 border border-slate-200">Khóa</span>
</td>
<td class="p-4 text-right flex justify-end gap-2">
<button class="p-1 text-slate-400 hover:text-primary transition-colors cursor-pointer">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"/></svg>
</button>
<button class="p-1 text-slate-400 hover:text-red-500 transition-colors cursor-pointer">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /></svg>
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Empty State Case container (visible only when case 8 is active) -->
<div id="mock-empty-state-view" class="hidden flex-1 items-center justify-center">
<x-empty-state
title="Không tìm thấy dữ liệu"
message="Không tìm thấy người dùng hoặc giao dịch nào phù hợp với bộ lọc tìm kiếm hiện tại của bạn. Vui lòng thử lại với từ khóa khác."
actionText="Tải lại dữ liệu"
actionId="reload-table-btn"
/>
</div>
</div>
</div>
</div>
<!-- All Case Modals/Toasts rendered as Reusable components -->
<!-- Case 1: Login failed -->
<x-toast id="toast-case-1" type="error" message="Đăng nhập thất bại. Vui lòng kiểm tra lại Email hoặc Mật khẩu." />
<!-- Case 2: Access denied -->
<x-error-dialog id="modal-case-2" title="Truy cập bị từ chối" message="Tài khoản của bạn không có quyền truy cập vào trang quản trị này. Vui lòng liên hệ với quản trị viên nếu đây là một sự nhầm lẫn." buttonText="Đồng ý" />
<!-- Case 3: First login (change password required) -->
<x-info-dialog id="modal-case-3" title="Yêu cầu đổi mật khẩu" message="Chào mừng bạn đến với hệ thống Thankcard! Đây là lần đầu tiên bạn đăng nhập, vui lòng đổi mật khẩu mới để tiếp tục sử dụng hệ thống." buttonText="Đổi mật khẩu ngay" />
<!-- Case 4: Delete user confirmation -->
<x-confirm-dialog id="modal-case-4" title="Xóa người dùng" message="Bạn có chắc chắn muốn xóa tài khoản của nhân viên Nguyễn Văn A (MSNV: NV0001)? Hành động này không thể hoàn tác và tất cả dữ liệu liên quan sẽ bị xóa." confirmText="Xóa vĩnh viễn" cancelText="Hủy bỏ" type="danger" />
<!-- Case 5: Create user success -->
<x-success-dialog id="modal-case-5" title="Tạo tài khoản thành công" message="Tài khoản nhân viên mới đã được tạo thành công trong hệ thống. Email kích hoạt đã được gửi tới người dùng." buttonText="Hoàn tất" />
<!-- Case 6: Email already exists -->
<x-error-dialog id="modal-case-6" title="Email đã tồn tại" message="Địa chỉ email 'nguyenvana@runsystem.net' đã được đăng ký bởi một nhân viên khác. Vui lòng sử dụng địa chỉ email khác." buttonText="Nhập lại" />
<!-- Case 7: Employee ID already exists -->
<x-error-dialog id="modal-case-7" title="Mã nhân viên đã tồn tại" message="Mã nhân viên (MSNV) 'NV0001' đã được gán cho một tài khoản khác trong hệ thống. Vui lòng kiểm tra lại." buttonText="Nhập lại" />
<!-- Case 9: Not enough thankcards -->
<x-warning-dialog id="modal-case-9" title="Không đủ lượt gửi" message="Số dư Thankcard hiện tại của bạn là 0. Bạn không thể gửi thêm thẻ cho đến khi số dư được đặt lại vào đầu tháng tới." buttonText="Đóng" />
<!-- Case 10: Maximum 5 thankcards per user/month -->
<x-warning-dialog id="modal-case-10" title="Giới hạn gửi thẻ" message="Bạn đã đạt đến hạn mức gửi tối đa 5 Thankcard trong tháng này. Vui lòng đợi đến tháng sau để tiếp tục gửi lời cảm ơn." buttonText="Đã hiểu" />
<!-- Case 11: Send thankcards confirmation -->
<x-confirm-dialog id="modal-case-11" title="Xác nhận gửi Thankcard" message="Bạn đang thực hiện gửi 1 Thankcard tới Trần Thị B với lời nhắn: 'Cảm ơn bạn đã hỗ trợ dự án'. Bạn có chắc chắn muốn gửi?" confirmText="Gửi ngay" cancelText="Xem lại" type="primary" />
<!-- Case 12: Send thankcards success -->
<x-success-dialog id="modal-case-12" title="Gửi thẻ thành công" message="Thankcard của bạn đã được gửi thành công đến Trần Thị B. Lượt gửi còn lại trong tháng của bạn: 4/5." buttonText="Tuyệt vời" />
<!-- Case 13: Password confirmation mismatch -->
<x-error-dialog id="modal-case-13" title="Mật khẩu không khớp" message="Mật khẩu xác nhận nhập lại không khớp với mật khẩu mới. Vui lòng nhập lại chính xác cả hai trường." buttonText="Thử lại" />
<!-- Case 14: Change password success -->
<x-success-dialog id="modal-case-14" title="Đổi mật khẩu thành công" message="Mật khẩu của bạn đã được cập nhật thành công. Vui lòng sử dụng mật khẩu mới cho các lần đăng nhập tiếp theo." buttonText="Đăng nhập lại" />
<!-- Case 15: Generic system error -->
<x-error-dialog id="modal-case-15" title="Lỗi hệ thống" message="Đã xảy ra sự cố kết nối với máy chủ (Mã lỗi: 500 - Internal Server Error). Vui lòng thử lại sau hoặc báo cáo cho đội ngũ kỹ thuật." buttonText="Đóng" />
@endsection
@push('scripts')
<script>
const casesMetadata = {
1: { name: "Case 1: Login failed", type: "toast", targetId: "toast-case-1" },
2: { name: "Case 2: Access denied", type: "modal", targetId: "modal-case-2" },
3: { name: "Case 3: First login (change password)", type: "modal", targetId: "modal-case-3" },
4: { name: "Case 4: Delete user confirmation", type: "modal", targetId: "modal-case-4" },
5: { name: "Case 5: Create user success", type: "modal", targetId: "modal-case-5" },
6: { name: "Case 6: Email already exists", type: "modal", targetId: "modal-case-6" },
7: { name: "Case 7: Employee ID already exists", type: "modal", targetId: "modal-case-7" },
8: { name: "Case 8: No data found", type: "empty-state", targetId: "mock-empty-state-view" },
9: { name: "Case 9: Not enough thankcards", type: "modal", targetId: "modal-case-9" },
10: { name: "Case 10: Maximum 5 thankcards per user/month", type: "modal", targetId: "modal-case-10" },
11: { name: "Case 11: Send thankcards confirmation", type: "modal", targetId: "modal-case-11" },
12: { name: "Case 12: Send thankcards success", type: "modal", targetId: "modal-case-12" },
13: { name: "Case 13: Password confirmation mismatch", type: "modal", targetId: "modal-case-13" },
14: { name: "Case 14: Change password success", type: "modal", targetId: "modal-case-14" },
15: { name: "Case 15: Generic system error", type: "modal", targetId: "modal-case-15" }
};
let activeCaseId = null;
window.showToast = function(id) {
document.querySelectorAll('[role="alert"]').forEach(el => {
el.classList.add('hidden');
el.classList.remove('flex', 'opacity-100', 'translate-y-0');
el.classList.add('opacity-0', 'translate-y-[-10px]');
});
const toast = document.getElementById(id);
if (!toast) return;
toast.classList.remove('hidden');
toast.classList.add('flex');
requestAnimationFrame(() => {
toast.classList.remove('opacity-0', 'translate-y-[-10px]');
toast.classList.add('opacity-100', 'translate-y-0');
});
};
window.closeToast = function(id) {
const toast = document.getElementById(id);
if (!toast) return;
toast.classList.remove('opacity-100', 'translate-y-0');
toast.classList.add('opacity-0', 'translate-y-[-10px]');
setTimeout(() => {
toast.classList.add('hidden');
toast.classList.remove('flex');
}, 300);
};
let _isTransitioning = false;
function resetAllViews() {
let hadOpenItem = false;
Object.keys(casesMetadata).forEach(id => {
const meta = casesMetadata[id];
const el = document.getElementById(meta.targetId);
if (el && !el.classList.contains('hidden')) {
hadOpenItem = true;
if (meta.type === 'modal') {
closeModal(meta.targetId);
} else if (meta.type === 'toast') {
closeToast(meta.targetId);
}
}
});
document.getElementById('mock-table-view').classList.remove('hidden');
document.getElementById('mock-empty-state-view').classList.add('hidden');
document.getElementById('mock-empty-state-view').classList.remove('flex');
document.querySelectorAll('.case-btn').forEach(btn => {
btn.classList.remove('border-primary', 'bg-primary-light/35', 'ring-2', 'ring-primary/20');
btn.classList.add('border-slate-100');
});
return new Promise(resolve => setTimeout(resolve, hadOpenItem ? 310 : 0));
}
window.triggerCase = async function(caseId) {
if (_isTransitioning) return;
_isTransitioning = true;
await resetAllViews();
const meta = casesMetadata[caseId];
if (!meta) { _isTransitioning = false; return; }
activeCaseId = caseId;
const btn = document.getElementById(`case-btn-${caseId}`);
if (btn) {
btn.classList.remove('border-slate-100');
btn.classList.add('border-primary', 'bg-primary-light/35', 'ring-2', 'ring-primary/20');
btn.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
document.getElementById('current-case-label').innerText = `Active Case: ${meta.name}`;
if (meta.type === 'modal') {
openModal(meta.targetId);
} else if (meta.type === 'toast') {
showToast(meta.targetId);
} else if (meta.type === 'empty-state') {
document.getElementById('mock-table-view').classList.add('hidden');
document.getElementById('mock-empty-state-view').classList.remove('hidden');
document.getElementById('mock-empty-state-view').classList.add('flex');
}
setTimeout(() => { _isTransitioning = false; }, 350);
};
document.addEventListener('DOMContentLoaded', () => {
const firstBtn = document.getElementById('case-btn-1');
if (firstBtn) {
firstBtn.classList.remove('border-slate-100');
firstBtn.classList.add('border-primary', 'bg-primary-light/35', 'ring-2', 'ring-primary/20');
}
document.getElementById('current-case-label').innerText =
`Active Case: ${casesMetadata[1].name}`;
setTimeout(() => { triggerCase(1); }, 400);
const reloadBtn = document.getElementById('reload-table-btn');
if (reloadBtn) {
reloadBtn.addEventListener('click', async () => {
await resetAllViews();
document.getElementById('current-case-label').innerText = `Active Case: Mock Table Reloaded`;
});
}
});
</script>
@endpush
+5
View File
@@ -73,5 +73,10 @@
</tbody> </tbody>
</table> </table>
</div> </div>
@if($administrations->hasPages())
<div class="px-6 py-4 border-t border-border-light bg-gray-50/30">
{{ $administrations->links() }}
</div>
@endif
</div> </div>
@endsection @endsection
+4
View File
@@ -20,6 +20,10 @@ Route::get('/test-modal', function() {
Route::get('/login', [AuthController::class, 'showLoginForm'])->name('login'); Route::get('/login', [AuthController::class, 'showLoginForm'])->name('login');
Route::post('/login', [AuthController::class, 'login']); Route::post('/login', [AuthController::class, 'login']);
Route::get('/notification-showcase', function () {
return view('notification-showcase');
})->name('notification.showcase');
Route::middleware('auth')->group(function () { Route::middleware('auth')->group(function () {
Route::post('/logout', [AuthController::class, 'logout'])->name('logout'); Route::post('/logout', [AuthController::class, 'logout'])->name('logout');