diff --git a/.gitignore b/.gitignore index a288e6f..22bd45e 100644 --- a/.gitignore +++ b/.gitignore @@ -52,3 +52,4 @@ yarn-debug.log* yarn-error.log* *.tmp *.temp +Tasks/ \ No newline at end of file diff --git a/app/Console/Commands/ResetCardsCommand.php b/app/Console/Commands/ResetCardsCommand.php index ff20612..d3c3db6 100644 --- a/app/Console/Commands/ResetCardsCommand.php +++ b/app/Console/Commands/ResetCardsCommand.php @@ -2,32 +2,24 @@ namespace App\Console\Commands; +use App\Services\Admin\Contracts\AdminServiceInterface; use Illuminate\Console\Command; -use App\Models\User; class ResetCardsCommand extends Command { - /** - * The name and signature of the console command. - * - * @var string - */ protected $signature = 'cards:reset'; - /** - * The console command description. - * - * @var string - */ - protected $description = 'Reset toàn bộ thẻ của hệ thống về 0 vào đầu tháng'; + protected $description = 'Reset all system cards to 0 at the beginning of the month'; + + public function __construct( + private AdminServiceInterface $adminService + ) { + parent::__construct(); + } - /** - * Execute the console command. - */ public function handle() { - User::where('status', User::STATUS_ACTIVE)->update(['card' => 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!'); + $this->adminService->resetAllCards(); + $this->info('Successfully reset all cards for active members to 0!'); } } diff --git a/app/Http/Controllers/AdminController.php b/app/Http/Controllers/AdminController.php index 56484bf..21881c7 100644 --- a/app/Http/Controllers/AdminController.php +++ b/app/Http/Controllers/AdminController.php @@ -2,53 +2,23 @@ namespace App\Http\Controllers; -use Illuminate\Http\Request; use App\Models\User; -use App\Models\Administration; -use App\Models\AddCard; +use App\Services\Admin\Contracts\AdminServiceInterface; use Carbon\Carbon; -use Illuminate\Support\Facades\Hash; -use Illuminate\Support\Facades\DB; +use Illuminate\Http\Request; class AdminController extends Controller { + public function __construct( + private AdminServiceInterface $adminService + ) {} + public function index(Request $request) { $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 - $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(); - - // 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; - }); + ['users' => $users, 'topReceivedUser' => $topReceivedUser, 'topSentUser' => $topSentUser] + = $this->adminService->getUserListWithStats($selectedMonth); return view('admin.users.index', compact('users', 'selectedMonth', 'topReceivedUser', 'topSentUser')); } @@ -61,83 +31,50 @@ class AdminController extends Controller public function store(Request $request) { $request->validate([ - 'msnv' => 'required|unique:user,msnv', - 'mail' => 'required|email|unique:user,mail', + 'msnv' => 'required|unique:user,msnv', + 'mail' => 'required|email|unique:user,mail', 'password' => 'required|min:6', - 'role' => 'required|in:' . User::ROLE_MEMBER . ',' . User::ROLE_ADMIN + 'role' => 'required|in:' . User::ROLE_MEMBER . ',' . User::ROLE_ADMIN, ]); - User::create([ - '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 - ]); + $this->adminService->createUser($request->only('msnv', 'mail', 'password', 'role')); - 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) { - $user = User::where('msnv', $msnv)->firstOrFail(); - + $user = User::where('msnv', $msnv)->firstOrFail(); $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) { - $q->where('sender', $msnv)->orWhere('receiver', $msnv); - }) - ->whereBetween('date', [$startOfMonth, $endOfMonth]) - ->orderBy('date', 'desc') - ->get(); + $administrations = $this->adminService->getUserTransactions($msnv, $selectedMonth); return view('admin.users.edit', compact('user', 'administrations', 'selectedMonth')); } public function update(Request $request, $msnv) { - $user = User::where('msnv', $msnv)->firstOrFail(); - $request->validate([ - 'num_card' => 'nullable|integer|min:1', - 'flag_send' => 'nullable|boolean' + 'num_card' => 'nullable|integer|min:1', + 'flag_send' => 'nullable|boolean', ]); - DB::transaction(function () use ($request, $user) { - 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; - } + $this->adminService->updateUser($msnv, $request->only('num_card', 'flag_send')); - $user->flag_send = $request->has('flag_send') ? User::FLAG_SEND_ENABLED : User::FLAG_SEND_DISABLED; - $user->save(); - }); - - return redirect()->back()->with('success', 'Cập nhật thành công!'); + return redirect()->back()->with('success', __('messages.user_update_success')); } public function destroy($msnv) { - $user = User::where('msnv', $msnv)->firstOrFail(); - $user->status = User::STATUS_INACTIVE; - $user->save(); + $this->adminService->deactivateUser($msnv); - 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() { - User::where('status', User::STATUS_ACTIVE)->update(['card' => 0]); - return redirect()->back()->with('success', 'Reset thẻ toàn hệ thống thành công!'); + $this->adminService->resetAllCards(); + + return redirect()->back()->with('success', __('messages.cards_reset_success')); } } diff --git a/app/Http/Controllers/AuthController.php b/app/Http/Controllers/AuthController.php index 9689506..3c78520 100644 --- a/app/Http/Controllers/AuthController.php +++ b/app/Http/Controllers/AuthController.php @@ -2,16 +2,20 @@ namespace App\Http\Controllers; +use App\Services\Auth\Contracts\AuthServiceInterface; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; -use App\Models\User; class AuthController extends Controller { + public function __construct( + private AuthServiceInterface $authService + ) {} + public function showLoginForm() { if (Auth::check()) { - return $this->redirectBasedOnRole(Auth::user()); + return redirect($this->authService->getRedirectRouteForUser(Auth::user())); } return view('login'); } @@ -19,48 +23,24 @@ class AuthController extends Controller public function login(Request $request) { $credentials = $request->validate([ - 'mail' => 'required|email', - 'password' => 'required' + 'mail' => 'required|email|max:255', + 'password' => 'required|string|max:100', ]); - if (Auth::attempt(['mail' => $credentials['mail'], 'password' => $credentials['password']])) { - $request->session()->regenerate(); - - $user = Auth::user(); - - if ($user->status != User::STATUS_ACTIVE) { - 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); + $result = $this->authService->attemptLogin($credentials, $request); + + if (!$result['success']) { + return back()->withInput($request->only('mail'))->withErrors([ + $result['error_key'] => $result['error_msg'], + ]); } - return back()->withErrors([ - 'mail' => 'Email hoặc mật khẩu không chính xác.', - ]); + return redirect($this->authService->getRedirectRouteForUser($result['user'])); } public function logout(Request $request) { - Auth::logout(); - $request->session()->invalidate(); - $request->session()->regenerateToken(); + $this->authService->logout($request); 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'); - } } diff --git a/app/Http/Controllers/UserController.php b/app/Http/Controllers/UserController.php index 5efb8dd..589e89c 100644 --- a/app/Http/Controllers/UserController.php +++ b/app/Http/Controllers/UserController.php @@ -2,36 +2,31 @@ namespace App\Http\Controllers; -use Illuminate\Http\Request; -use App\Models\User; use App\Models\Administration; +use App\Services\User\Contracts\UserServiceInterface; use Carbon\Carbon; -use Illuminate\Support\Facades\Hash; -use Illuminate\Support\Facades\DB; +use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; class UserController extends Controller { + public function __construct( + private UserServiceInterface $userService + ) {} + public function index(Request $request) { $selectedMonth = $request->input('month', Carbon::now()->format('Y-m')); - $startOfMonth = Carbon::parse($selectedMonth)->startOfMonth(); - $endOfMonth = Carbon::parse($selectedMonth)->endOfMonth(); + $user = Auth::user(); - $user = Auth::user(); - - // 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(); + $administrations = $this->userService->getDashboard($user, $selectedMonth); return view('user.dashboard', compact('administrations', 'selectedMonth', 'user')); } 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')); } @@ -39,76 +34,20 @@ class UserController extends Controller { $request->validate([ '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(); - $receiverMsnv = $request->receiver; - $amount = $request->amount; - - if ($sender->flag_send == User::FLAG_SEND_DISABLED) { - return response()->json([ - 'success' => false, - 'message' => 'Bạn hiện không có quyền gửi Thank Card.' - ], 422); + try { + $this->userService->sendThankcards( + Auth::user(), + $request->receiver, + (int) $request->amount + ); + } catch (\RuntimeException $e) { + return response()->json(['success' => false, 'message' => $e->getMessage()], 422); } - $startOfMonth = Carbon::now()->startOfMonth(); - $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!' - ]); + return response()->json(['success' => true, 'message' => __('messages.thank_card_send_success')]); } public function changePasswordForm() @@ -119,18 +58,13 @@ class UserController extends Controller public function updatePassword(Request $request) { $request->validate([ - 'password' => 'required|min:6|confirmed' + 'password' => 'required|min:6|confirmed', ]); $user = Auth::user(); - $user->pass = Hash::make($request->password); - $user->first_login = User::FIRST_LOGIN_FALSE; - $user->save(); + $this->userService->updatePassword($user, $request->password); - if ($user->role == User::ROLE_ADMIN) { - return redirect()->route('admin.dashboard')->with('success', 'Đổi mật khẩu thành công!'); - } - - return redirect()->route('user.dashboard')->with('success', 'Đổi mật khẩu thành công!'); + $route = $user->role == \App\Models\User::ROLE_ADMIN ? 'admin.dashboard' : 'user.dashboard'; + return redirect()->route($route)->with('success', __('messages.password_change_success')); } } diff --git a/app/Models/AddCard.php b/app/Models/AddCard.php index 82c8130..de3931f 100644 --- a/app/Models/AddCard.php +++ b/app/Models/AddCard.php @@ -10,6 +10,9 @@ class AddCard extends Model use HasFactory; protected $table = 'add_card'; + public $timestamps = false; + protected $primaryKey = null; + public $incrementing = false; protected $fillable = [ 'buyer', diff --git a/app/Models/Administration.php b/app/Models/Administration.php index 4a07667..5555e33 100644 --- a/app/Models/Administration.php +++ b/app/Models/Administration.php @@ -13,6 +13,7 @@ class Administration extends Model protected $table = 'administration'; + public $timestamps = false; protected $fillable = [ 'msnv', diff --git a/app/Models/User.php b/app/Models/User.php index ae8a169..52b40d5 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -24,6 +24,7 @@ class User extends Authenticatable protected $table = 'user'; + public $timestamps = false; protected $primaryKey = 'msnv'; public $incrementing = false; protected $keyType = 'string'; @@ -41,7 +42,6 @@ class User extends Authenticatable protected $hidden = [ 'pass', - 'remember_token', ]; /** @@ -53,4 +53,19 @@ class User extends Authenticatable { return $this->pass; } + + // Disable remember token support in database + public function getRememberToken() + { + return null; + } + + public function setRememberToken($value) + { + } + + public function getRememberTokenName() + { + return ''; + } } diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 452e6b6..03bbaae 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,21 +2,23 @@ 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; class AppServiceProvider extends ServiceProvider { - /** - * Register any application services. - */ 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 { // diff --git a/app/Services/Admin/AdminService.php b/app/Services/Admin/AdminService.php new file mode 100644 index 0000000..32e72c6 --- /dev/null +++ b/app/Services/Admin/AdminService.php @@ -0,0 +1,113 @@ +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]); + } +} \ No newline at end of file diff --git a/app/Services/Admin/Contracts/AdminServiceInterface.php b/app/Services/Admin/Contracts/AdminServiceInterface.php new file mode 100644 index 0000000..414b212 --- /dev/null +++ b/app/Services/Admin/Contracts/AdminServiceInterface.php @@ -0,0 +1,20 @@ + $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'); + } +} \ No newline at end of file diff --git a/app/Services/Auth/Contracts/AuthServiceInterface.php b/app/Services/Auth/Contracts/AuthServiceInterface.php new file mode 100644 index 0000000..b11b29a --- /dev/null +++ b/app/Services/Auth/Contracts/AuthServiceInterface.php @@ -0,0 +1,15 @@ +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(); + } +} \ No newline at end of file diff --git a/capture-screenshots.js b/capture-screenshots.js new file mode 100644 index 0000000..3be9bce --- /dev/null +++ b/capture-screenshots.js @@ -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!'); +})(); diff --git a/database/migrations/0001_01_01_000000_create_users_table.php b/database/migrations/0001_01_01_000000_create_users_table.php index 6ff6793..41867cc 100644 --- a/database/migrations/0001_01_01_000000_create_users_table.php +++ b/database/migrations/0001_01_01_000000_create_users_table.php @@ -20,8 +20,6 @@ return new class extends Migration $table->integer('card')->nullable(); $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->rememberToken(); - $table->timestamps(); }); // Only user table remains diff --git a/database/migrations/2026_06_27_132957_create_add_cards_table.php b/database/migrations/2026_06_27_132957_create_add_cards_table.php index ea4e835..b13aa39 100644 --- a/database/migrations/2026_06_27_132957_create_add_cards_table.php +++ b/database/migrations/2026_06_27_132957_create_add_cards_table.php @@ -12,12 +12,10 @@ return new class extends Migration public function up(): void { Schema::create('add_card', function (Blueprint $table) { - $table->id(); $table->string('buyer'); $table->integer('num_card'); $table->string('seller'); $table->date('date')->nullable(); - $table->timestamps(); }); } diff --git a/database/migrations/2026_06_27_132957_create_administrations_table.php b/database/migrations/2026_06_27_132957_create_administrations_table.php index 8dbdd99..22b2720 100644 --- a/database/migrations/2026_06_27_132957_create_administrations_table.php +++ b/database/migrations/2026_06_27_132957_create_administrations_table.php @@ -19,7 +19,6 @@ return new class extends Migration $table->integer('sent')->nullable(); $table->string('receiver')->nullable(); $table->date('date')->nullable(); - $table->timestamps(); }); } diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 6b901f8..35c8f06 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -15,11 +15,8 @@ class DatabaseSeeder extends Seeder */ public function run(): void { - // User::factory(10)->create(); - - User::factory()->create([ - 'name' => 'Test User', - 'email' => 'test@example.com', + $this->call([ + MockDataSeeder::class, ]); } } diff --git a/docs/notification-review/README.md b/docs/notification-review/README.md new file mode 100644 index 0000000..7059e25 --- /dev/null +++ b/docs/notification-review/README.md @@ -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.* diff --git a/docs/notification-review/case_01_login_failed.png b/docs/notification-review/case_01_login_failed.png new file mode 100644 index 0000000..13d8933 Binary files /dev/null and b/docs/notification-review/case_01_login_failed.png differ diff --git a/docs/notification-review/case_02_access_denied.png b/docs/notification-review/case_02_access_denied.png new file mode 100644 index 0000000..92299e1 Binary files /dev/null and b/docs/notification-review/case_02_access_denied.png differ diff --git a/docs/notification-review/case_03_first_login.png b/docs/notification-review/case_03_first_login.png new file mode 100644 index 0000000..fb10ff3 Binary files /dev/null and b/docs/notification-review/case_03_first_login.png differ diff --git a/docs/notification-review/case_04_delete_user_confirmation.png b/docs/notification-review/case_04_delete_user_confirmation.png new file mode 100644 index 0000000..60d8074 Binary files /dev/null and b/docs/notification-review/case_04_delete_user_confirmation.png differ diff --git a/docs/notification-review/case_05_create_user_success.png b/docs/notification-review/case_05_create_user_success.png new file mode 100644 index 0000000..dd13a3d Binary files /dev/null and b/docs/notification-review/case_05_create_user_success.png differ diff --git a/docs/notification-review/case_06_email_already_exists.png b/docs/notification-review/case_06_email_already_exists.png new file mode 100644 index 0000000..44ccc1c Binary files /dev/null and b/docs/notification-review/case_06_email_already_exists.png differ diff --git a/docs/notification-review/case_07_employee_id_already_exists.png b/docs/notification-review/case_07_employee_id_already_exists.png new file mode 100644 index 0000000..89dfff7 Binary files /dev/null and b/docs/notification-review/case_07_employee_id_already_exists.png differ diff --git a/docs/notification-review/case_08_no_data_found.png b/docs/notification-review/case_08_no_data_found.png new file mode 100644 index 0000000..0cc8388 Binary files /dev/null and b/docs/notification-review/case_08_no_data_found.png differ diff --git a/docs/notification-review/case_09_not_enough_thankcards.png b/docs/notification-review/case_09_not_enough_thankcards.png new file mode 100644 index 0000000..404cfd0 Binary files /dev/null and b/docs/notification-review/case_09_not_enough_thankcards.png differ diff --git a/docs/notification-review/case_10_max_5_thankcards_per_month.png b/docs/notification-review/case_10_max_5_thankcards_per_month.png new file mode 100644 index 0000000..a71a500 Binary files /dev/null and b/docs/notification-review/case_10_max_5_thankcards_per_month.png differ diff --git a/docs/notification-review/case_11_send_thankcards_confirmation.png b/docs/notification-review/case_11_send_thankcards_confirmation.png new file mode 100644 index 0000000..83241ab Binary files /dev/null and b/docs/notification-review/case_11_send_thankcards_confirmation.png differ diff --git a/docs/notification-review/case_12_send_thankcards_success.png b/docs/notification-review/case_12_send_thankcards_success.png new file mode 100644 index 0000000..b0ea6ba Binary files /dev/null and b/docs/notification-review/case_12_send_thankcards_success.png differ diff --git a/docs/notification-review/case_13_password_confirmation_mismatch.png b/docs/notification-review/case_13_password_confirmation_mismatch.png new file mode 100644 index 0000000..fab2493 Binary files /dev/null and b/docs/notification-review/case_13_password_confirmation_mismatch.png differ diff --git a/docs/notification-review/case_14_change_password_success.png b/docs/notification-review/case_14_change_password_success.png new file mode 100644 index 0000000..7453787 Binary files /dev/null and b/docs/notification-review/case_14_change_password_success.png differ diff --git a/docs/notification-review/case_15_generic_system_error.png b/docs/notification-review/case_15_generic_system_error.png new file mode 100644 index 0000000..3441e0e Binary files /dev/null and b/docs/notification-review/case_15_generic_system_error.png differ diff --git a/lang/en/auth.php b/lang/en/auth.php deleted file mode 100644 index 919a7c5..0000000 --- a/lang/en/auth.php +++ /dev/null @@ -1,12 +0,0 @@ - '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', -]; diff --git a/lang/en/layout.php b/lang/en/layout.php deleted file mode 100644 index 80bf044..0000000 --- a/lang/en/layout.php +++ /dev/null @@ -1,12 +0,0 @@ - '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', -]; diff --git a/lang/vi/auth.php b/lang/vi/auth.php index 8d137cf..a1ac389 100644 --- a/lang/vi/auth.php +++ b/lang/vi/auth.php @@ -9,4 +9,6 @@ return [ 'forgot_password' => 'Quên mật khẩu?', 'system_name' => 'Hệ thống Thank Card', '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.', ]; diff --git a/lang/vi/messages.php b/lang/vi/messages.php new file mode 100644 index 0000000..e20033e --- /dev/null +++ b/lang/vi/messages.php @@ -0,0 +1,16 @@ + 'Đổ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.', + ] +]; diff --git a/lang/vi/validation.php b/lang/vi/validation.php new file mode 100644 index 0000000..9747c58 --- /dev/null +++ b/lang/vi/validation.php @@ -0,0 +1,34 @@ + ':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', + ], +]; diff --git a/resources/css/app.css b/resources/css/app.css index 94b3f57..0aa9be9 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -57,6 +57,7 @@ @source '../../storage/framework/views/*.php'; @source '../**/*.blade.php'; @source '../**/*.js'; +@source inline('bg-primary-light/35 ring-primary/20 border-primary ring-2 animate-pulse'); @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'; @@ -100,7 +101,7 @@ } .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 { @@ -185,7 +186,7 @@ } .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 */ diff --git a/resources/views/admin/users/edit.blade.php b/resources/views/admin/users/edit.blade.php index 05a8c55..7cfb10a 100644 --- a/resources/views/admin/users/edit.blade.php +++ b/resources/views/admin/users/edit.blade.php @@ -40,7 +40,7 @@
@@ -110,6 +110,11 @@ + @if($administrations->hasPages()) +
+ {{ $administrations->links() }} +
+ @endif diff --git a/resources/views/components/confirm-dialog.blade.php b/resources/views/components/confirm-dialog.blade.php new file mode 100644 index 0000000..fe963aa --- /dev/null +++ b/resources/views/components/confirm-dialog.blade.php @@ -0,0 +1,32 @@ +@props(['id', 'title' => 'Xác nhận', 'message' => '', 'confirmText' => 'Đồng ý', 'cancelText' => 'Hủy', 'type' => 'danger']) + + diff --git a/resources/views/components/empty-state.blade.php b/resources/views/components/empty-state.blade.php new file mode 100644 index 0000000..c9d4df3 --- /dev/null +++ b/resources/views/components/empty-state.blade.php @@ -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' => '']) + +
+
+ + + + +
+

{{ $title }}

+

{{ $message }}

+ @if($actionText) + + @endif +
diff --git a/resources/views/components/error-dialog.blade.php b/resources/views/components/error-dialog.blade.php new file mode 100644 index 0000000..0a5a6a7 --- /dev/null +++ b/resources/views/components/error-dialog.blade.php @@ -0,0 +1,22 @@ +@props(['id', 'title' => 'Đã xảy ra lỗi', 'message' => '', 'buttonText' => 'Đóng']) + + diff --git a/resources/views/components/info-dialog.blade.php b/resources/views/components/info-dialog.blade.php new file mode 100644 index 0000000..c3ddba0 --- /dev/null +++ b/resources/views/components/info-dialog.blade.php @@ -0,0 +1,22 @@ +@props(['id', 'title' => 'Thông tin', 'message' => '', 'buttonText' => 'Đóng']) + + diff --git a/resources/views/components/success-dialog.blade.php b/resources/views/components/success-dialog.blade.php new file mode 100644 index 0000000..e419181 --- /dev/null +++ b/resources/views/components/success-dialog.blade.php @@ -0,0 +1,22 @@ +@props(['id', 'title' => 'Thành công', 'message' => '', 'buttonText' => 'Xác nhận']) + + diff --git a/resources/views/components/toast.blade.php b/resources/views/components/toast.blade.php new file mode 100644 index 0000000..4a1703c --- /dev/null +++ b/resources/views/components/toast.blade.php @@ -0,0 +1,20 @@ +@props(['id' => 'toast', 'type' => 'success', 'message' => '']) + + diff --git a/resources/views/components/warning-dialog.blade.php b/resources/views/components/warning-dialog.blade.php new file mode 100644 index 0000000..7f41211 --- /dev/null +++ b/resources/views/components/warning-dialog.blade.php @@ -0,0 +1,22 @@ +@props(['id', 'title' => 'Cảnh báo', 'message' => '', 'buttonText' => 'Đã hiểu']) + + diff --git a/resources/views/layouts/app.blade.php b/resources/views/layouts/app.blade.php index 95f99e3..ff8128a 100644 --- a/resources/views/layouts/app.blade.php +++ b/resources/views/layouts/app.blade.php @@ -12,7 +12,7 @@