28 Commits

Author SHA1 Message Date
antv 63764d0718 feature: make style like main page 2026-07-07 11:21:21 +07:00
antv af529a4e81 feature: update message for login feature 2026-07-06 21:02:52 +07:00
antv 7a964785c7 style: main page - stretch columns to align leaderboard height 2026-07-06 17:04:55 +07:00
antv d4dbf849af style: main page - add cursor-pointer to ranking tab buttons 2026-07-06 16:59:30 +07:00
antv 1e050233b9 feature: main page - always show current user at the bottom of ranking 2026-07-06 16:54:41 +07:00
antv e163d2f349 feature: main page - ranking list 2026-07-06 16:44:03 +07:00
antv 618dcef37f feature: main page - code be 2026-07-06 16:07:35 +07:00
antv e1fd70071b feature: main page 2026-07-06 15:10:52 +07:00
antv 64027eb8f2 feature: main page 2026-07-06 15:06:14 +07:00
antv 11a6568717 feature: main page 2026-07-06 14:37:32 +07:00
antv b1beed4ae6 feature: use md5 2026-07-06 14:00:07 +07:00
antv d8f0e48bf3 feature: my page 2026-07-03 16:54:43 +07:00
antv 1943f23406 feat: edit migration 2026-07-03 09:10:54 +07:00
antv f594a45e84 m 2026-07-02 17:02:02 +07:00
antv 89790539b7 feature: login and my page 2026-07-02 15:42:47 +07:00
antv c993f848cb fix https 2026-07-02 10:22:47 +07:00
antv-admin 83a529b4c5 feat: add phpmyadmin 2026-07-01 23:02:56 +07:00
antv-admin 5565d15be5 new 2026-07-01 22:43:28 +07:00
antv-admin 66d0caa588 confi: docker 2026-07-01 22:36:46 +07:00
antv-admin fde9105834 conf: docker 2026-07-01 22:05:48 +07:00
antv-admin 7bdea785f9 conf: docker 2026-07-01 20:46:08 +07:00
antv f1858f8f0f reuse form 2026-07-01 16:57:34 +07:00
antv 4e4d03d62a style: fix nested border-radius mathematics on login screen to resolve corner gaps 2026-06-30 16:18:42 +07:00
antv d99541b7e4 fix: resolve login button overflow and improve form layout spacing 2026-06-30 16:16:56 +07:00
antv 8cbdd93e56 feature: add error pages 2026-06-30 15:31:40 +07:00
antv 96eac88f36 feature: add docker 2026-06-30 14:03:13 +07:00
antv c5016489ac feature: keep sidebar and add service layer 2026-06-30 11:59:34 +07:00
antv 8a77324f89 all feature 2026-06-29 11:55:33 +07:00
182 changed files with 9311 additions and 206 deletions
+1 -1
View File
@@ -4,7 +4,7 @@ APP_KEY=
APP_DEBUG=true
APP_URL=http://localhost
APP_LOCALE=en
APP_LOCALE=vi
APP_FALLBACK_LOCALE=en
APP_FAKER_LOCALE=en_US
+10
View File
@@ -0,0 +1,10 @@
APP_ENV=testing
APP_KEY=base64:93hwWdQyCR3816s/FI231k5H909nxIHcTUeomm7WXVA=
APP_DEBUG=true
DB_CONNECTION=sqlite
DB_DATABASE=:memory:
CACHE_STORE=array
SESSION_DRIVER=array
QUEUE_CONNECTION=sync
+2
View File
@@ -52,3 +52,5 @@ yarn-debug.log*
yarn-error.log*
*.tmp
*.temp
Tasks/
**/.env.app
@@ -0,0 +1,25 @@
<?php
namespace App\Console\Commands;
use App\Services\Admin\Contracts\AdminServiceInterface;
use Illuminate\Console\Command;
class ResetCardsCommand extends Command
{
protected $signature = 'cards:reset';
protected $description = 'Reset all system cards to 0 at the beginning of the month';
public function __construct(
private AdminServiceInterface $adminService
) {
parent::__construct();
}
public function handle()
{
$this->adminService->resetAllCards();
$this->info('Successfully reset all cards for active members to 0!');
}
}
@@ -0,0 +1,93 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Services\Admin\Contracts\AdminServiceInterface;
use Carbon\Carbon;
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'));
$search = $request->input('search');
$flagSend = $request->input('flag_send');
['users' => $users, 'topReceivedUser' => $topReceivedUser, 'topSentUser' => $topSentUser]
= $this->adminService->getUserListWithStats($selectedMonth, $search, $flagSend);
if ($request->ajax() || $request->wantsJson()) {
return response()->json([
'title' => view('admin.users.partials.title', compact('selectedMonth'))->render(),
'stats' => view('admin.users.partials.stats', compact('topReceivedUser', 'topSentUser'))->render(),
'table' => view('admin.users.partials.table', compact('users'))->render(),
]);
}
return view('admin.users.index', compact('users', 'selectedMonth', 'topReceivedUser', 'topSentUser', 'search', 'flagSend'));
}
public function create()
{
return view('admin.users.create');
}
public function store(Request $request)
{
$request->validate([
'msnv' => 'required|integer|unique:user,msnv',
'name' => 'required|string|max:255',
'mail' => 'required|email|unique:user,mail',
'departments' => 'required|integer|min:1|max:5',
'password' => 'required|string',
'role' => 'required|in:' . User::ROLE_MEMBER . ',' . User::ROLE_ADMIN,
]);
$this->adminService->createUser($request->only('msnv', 'name', 'mail', 'departments', 'password', 'role'));
return redirect()->route('admin.users.index')->with('success', __('messages.user_create_success'));
}
public function edit(Request $request, $msnv)
{
$user = User::where('msnv', $msnv)->firstOrFail();
$selectedMonth = $request->input('month', Carbon::now()->format('Y-m'));
$administrations = $this->adminService->getUserTransactions($msnv, $selectedMonth);
return view('admin.users.edit', compact('user', 'administrations', 'selectedMonth'));
}
public function update(Request $request, $msnv)
{
$request->validate([
'num_card' => 'nullable|integer|min:1',
'flag_send' => 'nullable|boolean',
]);
$this->adminService->updateUser($msnv, $request->only('num_card', 'flag_send'));
return redirect()->back()->with('success', __('messages.user_update_success'));
}
public function destroy($msnv)
{
$this->adminService->deactivateUser($msnv);
return redirect()->route('admin.users.index')->with('success', __('messages.user_deactivate_success'));
}
public function resetCards()
{
$this->adminService->resetAllCards();
return redirect()->back()->with('success', __('messages.cards_reset_success'));
}
}
+77
View File
@@ -0,0 +1,77 @@
<?php
namespace App\Http\Controllers;
use App\Services\Auth\Contracts\AuthServiceInterface;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class AuthController extends Controller
{
public function __construct(
private AuthServiceInterface $authService
) {}
public function showLoginForm()
{
if (Auth::check() && !session('dialog_first_login')) {
return redirect($this->authService->getRedirectRouteForUser(Auth::user()));
}
return view('login');
}
public function login(Request $request)
{
// Custom validation check: treat all validation errors as "Invalid email/password"
$validator = \Illuminate\Support\Facades\Validator::make($request->all(), [
'mail' => 'required|email|max:255',
'password' => 'required|string|max:100',
]);
if ($validator->fails()) {
return back()->withInput($request->only('mail'))->withErrors([
'mail' => 'Thông tin đăng nhập không đúng. Vui lòng nhập lại',
]);
}
$credentials = $validator->validated();
$email = \Illuminate\Support\Str::lower($credentials['mail']);
$throttleKey = 'login-attempts:' . $email;
// Brute-force check (lock after 5 failed attempts)
if (\Illuminate\Support\Facades\RateLimiter::tooManyAttempts($throttleKey, 5)) {
return back()->withInput($request->only('mail'))->with('dialog_error_brute_force', 'Tài khoản của bạn tạm thời bị khóa do nhập sai mật khẩu quá 5 lần. Vui lòng thử lại sau 15 phút.');
}
$result = $this->authService->attemptLogin($credentials, $request);
if (!$result['success']) {
if ($result['error_key'] === 'permission') {
return back()->withInput($request->only('mail'))->with('dialog_error_permission', $result['error_msg']);
}
// For invalid password / email (counts towards brute-force attempts)
\Illuminate\Support\Facades\RateLimiter::hit($throttleKey, 900); // 15 minutes lockout
return back()->withInput($request->only('mail'))->withErrors([
'mail' => $result['error_msg'],
]);
}
// Clear rate limiter on success
\Illuminate\Support\Facades\RateLimiter::clear($throttleKey);
$user = $result['user'];
if ($user->first_login == \App\Models\User::FIRST_LOGIN_TRUE) {
return redirect()->route('login')->with('dialog_first_login', true);
}
return redirect($this->authService->getRedirectRouteForUser($user));
}
public function logout(Request $request)
{
$this->authService->logout($request);
return redirect('/login');
}
}
@@ -0,0 +1,86 @@
<?php
namespace App\Http\Controllers\User;
use App\Http\Controllers\Controller;
use App\Models\Administration;
use App\Services\User\Contracts\UserServiceInterface;
use Carbon\Carbon;
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'));
$user = Auth::user();
$administrations = $this->userService->getDashboard($user, $selectedMonth);
$stats = $this->userService->getPersonalStats($user, $selectedMonth);
$receivedRanking = $this->userService->getRankingList('received', $selectedMonth, $user);
$sentRanking = $this->userService->getRankingList('sent', $selectedMonth, $user);
return view('user.dashboard', array_merge([
'administrations' => $administrations,
'selectedMonth' => $selectedMonth,
'user' => $user,
'receivedRankers' => $receivedRanking['rankers'],
'receivedCurrentUserRankItem' => $receivedRanking['currentUserRankItem'],
'sentRankers' => $sentRanking['rankers'],
'sentCurrentUserRankItem' => $sentRanking['currentUserRankItem']
], $stats));
}
public function sendThankcards()
{
$users = $this->userService->getOtherActiveUsers(Auth::user());
return view('user.send', compact('users'));
}
public function storeThankcards(Request $request)
{
$request->validate([
'receiver' => [
'required',
\Illuminate\Validation\Rule::exists('user', 'msnv')->where('status', \App\Models\User::STATUS_ACTIVE)
],
'amount' => 'required|integer|min:1|max:' . Administration::MAX_SEND_CARD_PER_MONTH,
]);
try {
$this->userService->sendThankcards(
Auth::user(),
$request->receiver,
(int) $request->amount
);
} catch (\RuntimeException $e) {
return response()->json(['success' => false, 'message' => $e->getMessage()], 422);
}
return response()->json(['success' => true, 'message' => __('messages.thank_card_send_success')]);
}
public function changePasswordForm()
{
return view('user.change_password');
}
public function updatePassword(Request $request)
{
$request->validate([
'password' => 'required|min:6|confirmed',
]);
$user = Auth::user();
$this->userService->updatePassword($user, $request->password);
$route = $user->role == \App\Models\User::ROLE_ADMIN ? 'admin.dashboard' : 'user.dashboard';
return redirect()->route($route)->with('success', __('messages.password_change_success'));
}
}
@@ -0,0 +1,20 @@
<?php
namespace App\Http\Middleware\Admin;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
use Illuminate\Support\Facades\Auth;
use App\Models\User;
class AdminMiddleware
{
public function handle(Request $request, Closure $next): Response
{
if (Auth::check() && Auth::user()->role == User::ROLE_ADMIN && Auth::user()->status == User::STATUS_ACTIVE) {
return $next($request);
}
return redirect()->route('login');
}
}
@@ -0,0 +1,22 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
use Illuminate\Support\Facades\Auth;
use App\Models\User;
class ForceChangePasswordMiddleware
{
public function handle(Request $request, Closure $next): Response
{
if (Auth::check() && Auth::user()->first_login == User::FIRST_LOGIN_TRUE) {
if (!$request->routeIs('user.change_password') && !$request->routeIs('user.update_password') && !$request->routeIs('logout')) {
return redirect()->route('user.change_password');
}
}
return $next($request);
}
}
@@ -0,0 +1,20 @@
<?php
namespace App\Http\Middleware\User;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
use Illuminate\Support\Facades\Auth;
use App\Models\User;
class MemberMiddleware
{
public function handle(Request $request, Closure $next): Response
{
if (Auth::check() && in_array(Auth::user()->role, [User::ROLE_MEMBER, User::ROLE_ADMIN]) && Auth::user()->status == User::STATUS_ACTIVE) {
return $next($request);
}
return redirect()->route('login');
}
}
+22
View File
@@ -0,0 +1,22 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class AddCard extends Model
{
use HasFactory;
protected $table = 'add_card';
public $timestamps = false;
protected $primaryKey = 'id';
protected $fillable = [
'buyer',
'num_card',
'seller',
'date',
];
}
+36
View File
@@ -0,0 +1,36 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Administration extends Model
{
use HasFactory;
const MAX_SEND_CARD_PER_MONTH = 5;
protected $table = 'administration';
public $timestamps = false;
protected $fillable = [
'msnv',
'received',
'sender',
'sent',
'receiver',
'date',
];
public function senderUser()
{
return $this->belongsTo(User::class, 'sender', 'msnv');
}
public function receiverUser()
{
return $this->belongsTo(User::class, 'receiver', 'msnv');
}
}
+57 -24
View File
@@ -2,48 +2,81 @@
namespace App\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail;
use Database\Factories\UserFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
class User extends Authenticatable
{
/** @use HasFactory<UserFactory> */
use HasFactory, Notifiable;
/**
* The attributes that are mass assignable.
*
* @var list<string>
*/
const ROLE_MEMBER = 0;
const ROLE_ADMIN = 1;
const STATUS_INACTIVE = 0;
const STATUS_ACTIVE = 1;
const FLAG_SEND_DISABLED = 0;
const FLAG_SEND_ENABLED = 1;
const FIRST_LOGIN_FALSE = 0;
const FIRST_LOGIN_TRUE = 1;
protected $table = 'user';
public $timestamps = false;
protected $primaryKey = 'id';
protected $fillable = [
'msnv',
'name',
'email',
'password',
'mail',
'pass',
'departments',
'avatar',
'role',
'status',
'card',
'flag_send',
'first_login',
];
/**
* The attributes that should be hidden for serialization.
*
* @var list<string>
*/
protected $hidden = [
'password',
'remember_token',
'pass',
];
/**
* Get the attributes that should be cast.
* Get the password for the user.
*
* @return array<string, string>
* @return string
*/
protected function casts(): array
public function getAuthPassword()
{
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
];
return $this->pass;
}
// Disable remember token support in database
public function getRememberToken()
{
return null;
}
public function setRememberToken($value)
{
}
public function getRememberTokenName()
{
return '';
}
public function transactions()
{
return $this->hasMany(Administration::class, 'msnv', 'msnv');
}
public function cardPurchases()
{
return $this->hasMany(AddCard::class, 'buyer', 'msnv');
}
}
+27 -8
View File
@@ -2,23 +2,42 @@
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\Facades\DB;
use Illuminate\Support\Facades\Log;
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
{
//
if (str_starts_with(config('app.url'), 'https://') || (isset($_SERVER['HTTP_X_FORWARDED_PROTO']) && $_SERVER['HTTP_X_FORWARDED_PROTO'] === 'https')) {
\Illuminate\Support\Facades\URL::forceScheme('https');
}
if (config('app.debug')) {
DB::listen(function ($query) {
Log::channel('query')->info(
sprintf(
"[%s ms] %s | Bindings: %s",
$query->time,
$query->sql,
json_encode($query->bindings)
)
);
});
}
}
}
+112
View File
@@ -0,0 +1,112 @@
<?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, ?string $search = null, ?string $flagSend = null): array
{
$startOfMonth = Carbon::parse($selectedMonth)->startOfMonth();
$endOfMonth = Carbon::parse($selectedMonth)->endOfMonth();
$usersQuery = User::select('user.*')
->where('status', User::STATUS_ACTIVE)
->addSelect([
'total_received' => Administration::selectRaw('COALESCE(SUM(received), 0)')
->whereColumn('msnv', 'user.msnv')
->whereBetween('date', [$startOfMonth, $endOfMonth]),
'total_sent' => Administration::selectRaw('COALESCE(SUM(sent), 0)')
->whereColumn('msnv', 'user.msnv')
->whereBetween('date', [$startOfMonth, $endOfMonth])
]);
$topReceivedUser = (clone $usersQuery)->orderByDesc('total_received')->first();
$topSentUser = (clone $usersQuery)->orderByDesc('total_sent')->first();
if (!is_null($search) && trim($search) !== '') {
$search = trim($search);
$usersQuery->where(function ($q) use ($search) {
$q->whereRaw('LOWER(msnv) LIKE ?', ['%' . strtolower($search) . '%'])
->orWhereRaw('LOWER(mail) LIKE ?', ['%' . strtolower($search) . '%']);
});
}
if (!is_null($flagSend) && $flagSend !== '') {
$usersQuery->where('flag_send', intval($flagSend));
}
$users = $usersQuery->paginate(10)->withQueryString();
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('msnv', $msnv)
->whereBetween('date', [$startOfMonth, $endOfMonth])
->orderBy('date', 'desc')
->paginate(10)
->withQueryString();
}
public function createUser(array $data): void
{
User::create([
'msnv' => $data['msnv'],
'name' => $data['name'],
'mail' => $data['mail'],
'pass' => md5($data['password']),
'departments' => $data['departments'],
'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, ?string $search = null, ?string $flagSend = null): 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;
}
+50
View File
@@ -0,0 +1,50 @@
<?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
{
// Manual MD5 password check
$user = User::where('mail', $credentials['mail'])->first();
if (!$user || $user->pass !== md5($credentials['password'])) {
return ['success' => false, 'error_key' => 'mail', 'error_msg' => 'Thông tin đăng nhập không đúng. Vui lòng nhập lại'];
}
// Log the user in manually
Auth::login($user);
$request->session()->regenerate();
if ($user->status != User::STATUS_ACTIVE) {
Auth::logout();
return ['success' => false, 'error_key' => 'permission', 'error_msg' => 'Bạn không có quyền truy cập. Vui lòng liên hệ quản trị viên'];
}
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,21 @@
<?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;
public function getPersonalStats(User $user, string $selectedMonth): array;
public function getRankingList(string $type, string $selectedMonth, User $currentUser): array;
}
+215
View File
@@ -0,0 +1,215 @@
<?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'));
}
$receiver = User::where('msnv', $receiverMsnv)->first();
if (!$receiver || $receiver->status != User::STATUS_ACTIVE) {
throw new \RuntimeException(__('messages.error.receiver_invalid'));
}
$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 = md5($newPassword);
$user->first_login = User::FIRST_LOGIN_FALSE;
$user->save();
}
public function getPersonalStats(User $user, string $selectedMonth): array
{
$startOfMonth = Carbon::parse($selectedMonth)->startOfMonth();
$endOfMonth = Carbon::parse($selectedMonth)->endOfMonth();
$receivedCount = (int) Administration::where('msnv', $user->msnv)
->whereBetween('date', [$startOfMonth, $endOfMonth])
->sum('received');
$sentCount = (int) Administration::where('msnv', $user->msnv)
->whereBetween('date', [$startOfMonth, $endOfMonth])
->sum('sent');
// Calculate current rank (based on received)
$rankings = User::where('status', User::STATUS_ACTIVE)
->addSelect([
'score' => Administration::selectRaw('COALESCE(SUM(received), 0)')
->whereColumn('msnv', 'user.msnv')
->whereBetween('date', [$startOfMonth, $endOfMonth])
])
->get()
->filter(fn($u) => $u->score > 0)
->sortBy([
['score', 'desc'],
['msnv', 'asc']
])
->values();
$currentRank = 0;
$prevScore = null;
$rank = 1;
$denseRank = 0;
foreach ($rankings as $item) {
$score = (int) $item->score;
if ($score !== $prevScore) {
$denseRank = $rank;
$prevScore = $score;
}
if ($item->msnv == $user->msnv) {
$currentRank = $denseRank;
break;
}
$rank++;
}
return [
'receivedCount' => $receivedCount,
'sentCount' => $sentCount,
'currentRank' => $currentRank
];
}
public function getRankingList(string $type, string $selectedMonth, User $currentUser): array
{
$startOfMonth = Carbon::parse($selectedMonth)->startOfMonth();
$endOfMonth = Carbon::parse($selectedMonth)->endOfMonth();
$column = $type === 'sent' ? 'sent' : 'received';
$allActiveUsers = User::where('status', User::STATUS_ACTIVE)
->addSelect([
'score' => Administration::selectRaw('COALESCE(SUM(' . $column . '), 0)')
->whereColumn('msnv', 'user.msnv')
->whereBetween('date', [$startOfMonth, $endOfMonth])
])
->get();
$rankedUsers = $allActiveUsers->filter(fn($u) => $u->score > 0)
->sortBy([
['score', 'desc'],
['msnv', 'asc']
])
->values();
$prevScore = null;
$rank = 1;
$denseRank = 0;
$rankers = [];
$currentUserRankItem = null;
foreach ($rankedUsers as $index => $userItem) {
$score = (int) $userItem->score;
if ($score !== $prevScore) {
$denseRank = $rank;
$prevScore = $score;
}
$userItem->rank = $denseRank;
$userItem->score = $score;
if ($userItem->msnv == $currentUser->msnv) {
$currentUserRankItem = $userItem;
}
if ($index < 4) {
$rankers[] = $userItem;
}
$rank++;
}
if (!$currentUserRankItem) {
$currentUserModel = $allActiveUsers->first(fn($u) => $u->msnv == $currentUser->msnv);
if ($currentUserModel) {
$currentUserRankItem = clone $currentUserModel;
$currentUserRankItem->score = 0;
$currentUserRankItem->rank = 0;
}
}
return [
'rankers' => $rankers,
'currentUserRankItem' => $currentUserRankItem
];
}
}
+18 -2
View File
@@ -3,16 +3,32 @@
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Support\Facades\Route;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
commands: __DIR__.'/../routes/console.php',
health: '/up',
then: function () {
Route::middleware('web')
->group(base_path('routes/admin.php'));
},
)
->withMiddleware(function (Middleware $middleware): void {
//
$middleware->trustProxies(at: '*');
$middleware->alias([
'admin' => \App\Http\Middleware\Admin\AdminMiddleware::class,
'member' => \App\Http\Middleware\User\MemberMiddleware::class,
'force_change_password' => \App\Http\Middleware\ForceChangePasswordMiddleware::class,
]);
})
->withExceptions(function (Exceptions $exceptions): void {
//
$exceptions->render(function (\Illuminate\Auth\AuthenticationException $e, \Illuminate\Http\Request $request) {
if ($request->expectsJson()) {
return response()->json(['message' => $e->getMessage()], 401);
}
return redirect()->guest(route('login'))->with('session_expired', true);
});
})->create();
Regular → Executable
View File
+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!');
})();
+1 -1
View File
@@ -78,7 +78,7 @@ return [
|
*/
'locale' => env('APP_LOCALE', 'en'),
'locale' => env('APP_LOCALE', 'vi'),
'fallback_locale' => env('APP_FALLBACK_LOCALE', 'en'),
+9
View File
@@ -63,6 +63,7 @@ return [
'path' => storage_path('logs/laravel.log'),
'level' => env('LOG_LEVEL', 'debug'),
'replace_placeholders' => true,
'permission' => 0666,
],
'daily' => [
@@ -71,6 +72,7 @@ return [
'level' => env('LOG_LEVEL', 'debug'),
'days' => env('LOG_DAILY_DAYS', 14),
'replace_placeholders' => true,
'permission' => 0666,
],
'slack' => [
@@ -123,6 +125,13 @@ return [
'handler' => NullHandler::class,
],
'query' => [
'driver' => 'single',
'path' => storage_path('logs/query.log'),
'level' => 'debug',
'permission' => 0666,
],
'emergency' => [
'path' => storage_path('logs/laravel.log'),
],
@@ -11,30 +11,22 @@ return new class extends Migration
*/
public function up(): void
{
Schema::create('users', function (Blueprint $table) {
$table->id();
Schema::create('user', function (Blueprint $table) {
$table->increments('id');
$table->integer('msnv')->unique();
$table->string('name');
$table->string('email')->unique();
$table->timestamp('email_verified_at')->nullable();
$table->string('password');
$table->rememberToken();
$table->timestamps();
$table->string('mail');
$table->string('pass');
$table->string('departments');
$table->string('avatar')->nullable();
$table->tinyInteger('role')->default(0)->comment('0: member, default / 1: admin');
$table->tinyInteger('status')->default(1)->comment('0: đã nghỉ / 1: đang làm, default');
$table->integer('card')->default(0);
$table->tinyInteger('flag_send')->default(0)->comment('0: không được gửi card / 1: được gửi card');
$table->tinyInteger('first_login')->default(1)->comment('0: không là lần đầu / 1: lần đầu, default');
});
Schema::create('password_reset_tokens', function (Blueprint $table) {
$table->string('email')->primary();
$table->string('token');
$table->timestamp('created_at')->nullable();
});
Schema::create('sessions', function (Blueprint $table) {
$table->string('id')->primary();
$table->foreignId('user_id')->nullable()->index();
$table->string('ip_address', 45)->nullable();
$table->text('user_agent')->nullable();
$table->longText('payload');
$table->integer('last_activity')->index();
});
// Only user table remains
}
/**
@@ -42,8 +34,6 @@ return new class extends Migration
*/
public function down(): void
{
Schema::dropIfExists('users');
Schema::dropIfExists('password_reset_tokens');
Schema::dropIfExists('sessions');
Schema::dropIfExists('user');
}
};
@@ -1,35 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('cache', function (Blueprint $table) {
$table->string('key')->primary();
$table->mediumText('value');
$table->integer('expiration')->index();
});
Schema::create('cache_locks', function (Blueprint $table) {
$table->string('key')->primary();
$table->string('owner');
$table->integer('expiration')->index();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('cache');
Schema::dropIfExists('cache_locks');
}
};
@@ -1,57 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('jobs', function (Blueprint $table) {
$table->id();
$table->string('queue')->index();
$table->longText('payload');
$table->unsignedTinyInteger('attempts');
$table->unsignedInteger('reserved_at')->nullable();
$table->unsignedInteger('available_at');
$table->unsignedInteger('created_at');
});
Schema::create('job_batches', function (Blueprint $table) {
$table->string('id')->primary();
$table->string('name');
$table->integer('total_jobs');
$table->integer('pending_jobs');
$table->integer('failed_jobs');
$table->longText('failed_job_ids');
$table->mediumText('options')->nullable();
$table->integer('cancelled_at')->nullable();
$table->integer('created_at');
$table->integer('finished_at')->nullable();
});
Schema::create('failed_jobs', function (Blueprint $table) {
$table->id();
$table->string('uuid')->unique();
$table->text('connection');
$table->text('queue');
$table->longText('payload');
$table->longText('exception');
$table->timestamp('failed_at')->useCurrent();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('jobs');
Schema::dropIfExists('job_batches');
Schema::dropIfExists('failed_jobs');
}
};
@@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('add_card', function (Blueprint $table) {
$table->increments('id');
$table->integer('buyer');
$table->integer('num_card');
$table->integer('seller');
$table->date('date');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('add_card');
}
};
@@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('administration', function (Blueprint $table) {
$table->increments('id');
$table->integer('msnv');
$table->integer('received')->nullable();
$table->integer('sender')->nullable();
$table->integer('sent')->nullable();
$table->integer('receiver')->nullable();
$table->date('date')->nullable();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('administration');
}
};
+2 -5
View File
@@ -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,
]);
}
}
+199
View File
@@ -0,0 +1,199 @@
<?php
namespace Database\Seeders;
use Illuminate\Database\Seeder;
use App\Models\User;
use App\Models\Administration;
use App\Models\AddCard;
use Illuminate\Support\Facades\Hash;
use Carbon\Carbon;
use Illuminate\Support\Facades\DB;
class MockDataSeeder extends Seeder
{
/**
* Run the database seeds.
*/
public function run(): void
{
// Clear old data to avoid duplicates when running again
DB::statement('SET FOREIGN_KEY_CHECKS=0;');
User::truncate();
Administration::truncate();
AddCard::truncate();
DB::statement('SET FOREIGN_KEY_CHECKS=1;');
// The default password is now set to 1234@Dcba according to the design requirements
$password = md5('1234@Dcba');
// 1. CREATE USERS
// Admins
User::create([
'msnv' => 9999,
'name' => 'System Admin',
'mail' => 'admin@runsystem.net',
'pass' => $password,
'departments' => 1,
'role' => User::ROLE_ADMIN,
'status' => User::STATUS_ACTIVE,
'card' => 0,
'flag_send' => User::FLAG_SEND_ENABLED,
'first_login' => User::FIRST_LOGIN_FALSE,
]);
User::create([
'msnv' => 9998,
'name' => 'Second Admin',
'mail' => 'admin2@runsystem.net',
'pass' => $password,
'departments' => 1,
'role' => User::ROLE_ADMIN,
'status' => User::STATUS_ACTIVE,
'card' => 0,
'flag_send' => User::FLAG_SEND_ENABLED,
'first_login' => User::FIRST_LOGIN_FALSE,
]);
// Active Users (Initialize with specific users needed for logs/transactions)
$users = [
['msnv' => 1001, 'name' => 'Nguyễn Văn A', 'mail' => 'nguyenvana@runsystem.net', 'card' => 10, 'flag' => User::FLAG_SEND_ENABLED, 'first_login' => User::FIRST_LOGIN_FALSE],
['msnv' => 1002, 'name' => 'Trần Thị Ngọc', 'mail' => 'tranthingoc@runsystem.net', 'card' => 3, 'flag' => User::FLAG_SEND_ENABLED, 'first_login' => User::FIRST_LOGIN_FALSE],
['msnv' => 1003, 'name' => 'Lê Kim Thư', 'mail' => 'lekiemthu@runsystem.net', 'card' => 6, 'flag' => User::FLAG_SEND_ENABLED, 'first_login' => User::FIRST_LOGIN_TRUE], // First-time login
['msnv' => 1004, 'name' => 'Phòng Nhân Sự', 'mail' => 'phongnhansu@runsystem.net', 'card' => 0, 'flag' => User::FLAG_SEND_DISABLED, 'first_login' => User::FIRST_LOGIN_FALSE], // Disabled sending permission
['msnv' => 1005, 'name' => 'Bản Giang', 'mail' => 'bangiang@runsystem.net', 'card' => 36, 'flag' => User::FLAG_SEND_ENABLED, 'first_login' => User::FIRST_LOGIN_FALSE],
];
// Generate additional users to reach exactly 100 member users
$firstNames = ['Nguyễn', 'Trần', 'Lê', 'Phạm', 'Hoàng', 'Huỳnh', 'Phan', 'Vũ', 'Võ', 'Đặng'];
$middleNames = ['Văn', 'Thị', 'Minh', 'Đức', 'Quốc', 'Thanh', 'Hữu', 'Ngọc', 'Quỳnh', 'Thu'];
$lastNames = ['Anh', 'Bình', 'Chi', 'Dương', 'Em', 'Giang', 'Huy', 'Khánh', 'Lâm', 'Nam', 'Oanh', 'Phúc', 'Quang', 'Sơn', 'Trang', 'Vy'];
for ($i = 6; $i <= 100; $i++) {
$randomName = $firstNames[array_rand($firstNames)] . ' ' . $middleNames[array_rand($middleNames)] . ' ' . $lastNames[array_rand($lastNames)];
$users[] = [
'msnv' => 1000 + $i,
'name' => $randomName,
'mail' => 'dev' . str_pad($i, 3, '0', STR_PAD_LEFT) . '@runsystem.net',
'card' => rand(0, 30),
'flag' => rand(0, 5) > 0 ? User::FLAG_SEND_ENABLED : User::FLAG_SEND_DISABLED,
'first_login' => rand(0, 4) == 0 ? User::FIRST_LOGIN_TRUE : User::FIRST_LOGIN_FALSE
];
}
foreach ($users as $u) {
User::create([
'msnv' => $u['msnv'],
'name' => $u['name'],
'mail' => $u['mail'],
'pass' => $password,
'departments' => rand(1, 5),
'role' => User::ROLE_MEMBER,
'status' => User::STATUS_ACTIVE,
'card' => $u['card'],
'flag_send' => $u['flag'],
'first_login' => $u['first_login'],
]);
// For other users, if they have cards, create a matching purchase log in June 2026
if (!in_array($u['msnv'], [1001, 1002, 1003, 1005])) {
if ($u['card'] > 0) {
AddCard::create([
'buyer' => $u['msnv'],
'num_card' => $u['card'],
'seller' => 9999,
'date' => '2026-06-01'
]);
}
}
}
// Inactive User
User::create([
'msnv' => 9997,
'name' => 'Nghi Duy',
'mail' => 'nghiduy@runsystem.net',
'pass' => $password,
'departments' => 2,
'role' => User::ROLE_MEMBER,
'status' => User::STATUS_INACTIVE,
'card' => 0,
'flag_send' => User::FLAG_SEND_DISABLED,
'first_login' => User::FIRST_LOGIN_FALSE,
]);
// 2. CREATE CARD ADDITION LOGS (add_card table)
// Manual purchase history for specific users:
$addCardLogs = [
// 1001: total 30 cards bought
['buyer' => 1001, 'num_card' => 15, 'date' => '2026-06-01'],
['buyer' => 1001, 'num_card' => 10, 'date' => '2026-07-01'],
['buyer' => 1001, 'num_card' => 5, 'date' => '2026-08-01'],
// 1002: total 15 cards bought
['buyer' => 1002, 'num_card' => 10, 'date' => '2026-06-02'],
['buyer' => 1002, 'num_card' => 5, 'date' => '2026-07-01'],
// 1003: total 15 cards bought
['buyer' => 1003, 'num_card' => 10, 'date' => '2026-06-03'],
['buyer' => 1003, 'num_card' => 5, 'date' => '2026-08-02'],
// 1005: total 50 cards bought
['buyer' => 1005, 'num_card' => 50, 'date' => '2026-06-04'],
];
foreach ($addCardLogs as $log) {
AddCard::create([
'buyer' => $log['buyer'],
'num_card' => $log['num_card'],
'seller' => 9999,
'date' => $log['date']
]);
}
// 3. CREATE CARD SENDING HISTORY (administration table)
$transactions = [
// June 2026 transactions
['sender' => 1001, 'receiver' => 1005, 'amount' => 5, 'date' => '2026-06-05'],
['sender' => 1001, 'receiver' => 1002, 'amount' => 3, 'date' => '2026-06-12'],
['sender' => 1002, 'receiver' => 1004, 'amount' => 4, 'date' => '2026-06-10'],
['sender' => 1003, 'receiver' => 1001, 'amount' => 2, 'date' => '2026-06-15'],
['sender' => 1005, 'receiver' => 1002, 'amount' => 5, 'date' => '2026-06-20'],
// July 2026 transactions
['sender' => 1001, 'receiver' => 1002, 'amount' => 5, 'date' => '2026-07-02'],
['sender' => 1001, 'receiver' => 1003, 'amount' => 3, 'date' => '2026-07-10'],
['sender' => 1002, 'receiver' => 1001, 'amount' => 5, 'date' => '2026-07-15'],
['sender' => 1003, 'receiver' => 1004, 'amount' => 4, 'date' => '2026-07-08'],
['sender' => 1005, 'receiver' => 1002, 'amount' => 4, 'date' => '2026-07-12'],
// August 2026 transactions
['sender' => 1001, 'receiver' => 1005, 'amount' => 4, 'date' => '2026-08-05'],
['sender' => 1002, 'receiver' => 1003, 'amount' => 3, 'date' => '2026-08-12'],
['sender' => 1003, 'receiver' => 1001, 'amount' => 3, 'date' => '2026-08-10'],
['sender' => 1005, 'receiver' => 1004, 'amount' => 5, 'date' => '2026-08-15'],
];
foreach ($transactions as $t) {
// Sender record
Administration::create([
'msnv' => $t['sender'],
'received' => 0,
'sender' => null,
'sent' => $t['amount'],
'receiver' => $t['receiver'],
'date' => $t['date']
]);
// Receiver record
Administration::create([
'msnv' => $t['receiver'],
'received' => $t['amount'],
'sender' => $t['sender'],
'sent' => 0,
'receiver' => null,
'date' => $t['date']
]);
}
}
}
+17
View File
@@ -0,0 +1,17 @@
FROM php:8.3-fpm
WORKDIR /var/www/html
RUN apt-get update && apt-get install -y \
git unzip zip curl \
libpng-dev libjpeg-dev libfreetype6-dev \
libonig-dev libxml2-dev libzip-dev \
&& docker-php-ext-install pdo_mysql mbstring exif pcntl bcmath gd zip
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
COPY docker/php/entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"]
CMD ["php-fpm"]
+160
View File
@@ -0,0 +1,160 @@
# Laravel Docker Setup - thankcard-system
## 1. Cấu trúc thư mục
```txt
thankcard-system/
├─ .env
├─ artisan
├─ composer.json
├─ public/
├─ storage/
├─ bootstrap/
├─ doc/
└─ docker/
├─ docker-compose.yml
├─ Dockerfile
├─ README.md
├─ env/
│ └─ .env.app
├─ nginx/
│ └─ default.conf
└─ php/
└─ entrypoint.sh
```
---
## 2. Khởi chạy dự án (Docker)
Đứng tại thư mục root của dự án `thankcard-system` và chạy:
```bash
docker compose -f docker/docker-compose.yml up -d --build
```
Hoặc di chuyển vào thư mục `docker/` rồi chạy:
```bash
cd docker
docker compose up -d --build
```
Sau khi các container chạy thành công, truy cập hệ thống tại trình duyệt:
```txt
http://localhost:8888
```
---
## 3. Cơ chế cấu hình môi trường (.env)
- File `.env` chính của Laravel vẫn nằm ở thư mục root để phục vụ máy local.
- Khi chạy trong container Docker, hệ thống sẽ sử dụng file **`docker/env/.env.app`** để ghi đè các cấu hình cần thiết (ví dụ: `DB_HOST=host.docker.internal` giúp PHP container kết nối ra cơ sở dữ liệu trên máy thật).
---
## 4. Các lệnh cần chạy thủ công khi cập nhật code
### 4.1. Khi thêm/cập nhật thư viện mới (`composer.json`)
Khi cập nhật hoặc cài đặt gói package PHP mới, bạn cần chạy lệnh cài đặt **bên trong container**:
```bash
docker compose -f docker/docker-compose.yml exec app composer install
```
### 4.2. Khi cập nhật giao diện (CSS / JS / Tailwind classes mới)
Cập nhật giao diện cần biên dịch lại tài nguyên frontend. Bạn có 2 cách:
**1️⃣ Chạy trên máy host (đề xuất)**
```bash
npm install # một lần, nếu chưa có các package
npm run dev # Vite dev server, hotreload
```
**2️⃣ Chạy trong Docker (sử dụng container `node`)**
```bash
# Cài đặt các package (chỉ chạy 1 lần)
docker compose -f docker/docker-compose.yml exec node npm install
# Chế độ phát triển (hotreload)
docker compose -f docker/docker-compose.yml exec node npm run dev
# Hoặc biên dịch bản production
docker compose -f docker/docker-compose.yml exec node npm run build
```
Container `node` đã expose port **5173**, cho phép truy cập Vite dev server tại `http://localhost:5173`. Khi `npm run dev` đang chạy, mọi thay đổi CSS/JS sẽ tự động cập nhật trong container `app` nhờ volume mount.
### 4.3. Khi cập nhật Database (Migrations)
Để cập nhật cơ sở dữ liệu khi có migrations mới:
```bash
docker compose -f docker/docker-compose.yml exec app php artisan migrate
```
### 4.4. Khi chạy dữ liệu mẫu (Seeders)
Để tạo dữ liệu mẫu (seed) vào cơ sở dữ liệu:
```bash
docker compose -f docker/docker-compose.yml exec app php artisan db:seed
```
Nếu chỉ muốn chạy một file seeder cố định (ví dụ: `UserSeeder`):
```bash
docker compose -f docker/docker-compose.yml exec app php artisan db:seed --class=UserSeeder
```
Hoặc để vừa chạy lệnh migrate vừa tự động seed luôn dữ liệu:
```bash
docker compose -f docker/docker-compose.yml exec app php artisan migrate --seed
```
### 4.5. Khi chỉnh sửa file `.env` hoặc `.env.app`
Bạn cần khởi động lại container để cập nhật biến môi trường mới và tiến hành xóa cache cấu hình:
```bash
# 1. Restart container app
docker compose -f docker/docker-compose.yml restart app
# 2. Xóa cache cấu hình trong container
docker compose -f docker/docker-compose.yml exec app php artisan config:clear
```
### 4.6. Mở công cụ quản lý Database (phpMyAdmin)
Dự án có sẵn một file cấu hình riêng để mở giao diện quản lý MySQL (đã được trỏ sẵn vào database trên máy bạn):
```bash
docker compose -f docker/docker-compose.phpmyadmin.yml up -d
```
Sau đó truy cập: `http://localhost:8889` để vào phpMyAdmin.
---
## 5. Các lệnh tiện ích khác
- **Truy cập vào Bash của container app:**
```bash
docker compose -f docker/docker-compose.yml exec app bash
```
- **Xem log Laravel thời gian thực:**
```bash
docker compose -f docker/docker-compose.yml exec app tail -f storage/logs/laravel.log
```
- **Dọn dẹp toàn bộ cache của Laravel:**
```bash
docker compose -f docker/docker-compose.yml exec app php artisan optimize:clear
```
- **Dừng các container:**
```bash
docker compose -f docker/docker-compose.yml down
```
docker compose -f docker/docker-compose.yml exec app php artisan key:generate
+11
View File
@@ -0,0 +1,11 @@
services:
phpmyadmin:
image: phpmyadmin:latest
container_name: thankcard-system-phpmyadmin
ports:
- "8889:80"
environment:
PMA_HOST: host.docker.internal
PMA_PORT: 3306
extra_hosts:
- "host.docker.internal:host-gateway"
+44
View File
@@ -0,0 +1,44 @@
services:
app:
build:
context: ..
dockerfile: docker/Dockerfile
image: thankcard-system-app
container_name: thankcard-system-app
working_dir: /var/www/html
volumes:
- ..:/var/www/html
networks:
- thankcard-system-network
extra_hosts:
- "host.docker.internal:host-gateway"
nginx:
image: nginx:1.25-alpine
container_name: thankcard-system-nginx
ports:
- "8888:80"
volumes:
- ..:/var/www/html
- ./nginx/default.conf:/etc/nginx/conf.d/default.conf
depends_on:
- app
networks:
- thankcard-system-network
node:
image: node:20-alpine
container_name: thankcard-system-node
working_dir: /var/www/html
volumes:
- ..:/var/www/html
command: sh -c "npm install && npm run build"
ports:
- "5173:5173"
networks:
- thankcard-system-network
networks:
thankcard-system-network:
name: thankcard-system-network
+23
View File
@@ -0,0 +1,23 @@
server {
listen 80;
server_name localhost;
root /var/www/html/public;
index index.php index.html;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.php$ {
fastcgi_pass app:9000;
fastcgi_index index.php;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
fastcgi_param DOCUMENT_ROOT $realpath_root;
}
location ~ /\.ht {
deny all;
}
}
+24
View File
@@ -0,0 +1,24 @@
#!/bin/sh
set -e
cd /var/www/html
# Luôn chạy composer install để đảm bảo vendor được cập nhật
composer install --no-interaction --prefer-dist
mkdir -p storage/logs \
storage/framework/cache \
storage/framework/sessions \
storage/framework/views \
bootstrap/cache
touch storage/logs/laravel.log storage/logs/query.log
chmod 666 storage/logs/laravel.log storage/logs/query.log
chmod -R 777 storage bootstrap/cache
# Tạo symlink cho storage nếu chưa có (để public file ảnh, upload,...)
if [ ! -L "public/storage" ]; then
php artisan storage:link
fi
exec "$@"
+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 | Inline (Error) | Thông tin đăng nhập không đúng. Vui lòng nhập lại | [case_01_login_failed.png](case_01_login_failed.png) |
| **02** | Authorization | Access denied / disabled | Error Dialog | Bạn không có quyền truy cập. Vui lòng liên hệ quản trị viên | [case_02_access_denied.png](case_02_access_denied.png) |
| **03** | Authentication | First login | Info Dialog | Vui lòng đổi mật khẩu trước khi 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

+124
View File
@@ -0,0 +1,124 @@
# Tài liệu cấu hình và sử dụng System Error Pages
Tài liệu này hướng dẫn chi tiết cách hoạt động, các trường hợp sử dụng, cách kích hoạt (trigger) và kiểm thử các trang lỗi hệ thống trong dự án **thankcard-system**.
---
## 1. Danh sách các trang lỗi & Tình huống sử dụng
| Status Code | Tên lỗi | Tình huống sử dụng | Key Localization (`errors.php`) |
| :--- | :--- | :--- | :--- |
| **401** | Unauthorized | Người dùng chưa đăng nhập nhưng cố truy cập tài nguyên yêu cầu xác thực. | `errors.401_title` / `errors.401_message` |
| **403** | Forbidden | Người dùng đã đăng nhập nhưng không có quyền hạn thực hiện hành động này (ví dụ: User thường cố truy cập chức năng Admin). | `errors.403_title` / `errors.403_message` |
| **404** | Not Found | Đường dẫn (URL) không tồn tại, hoặc khi tìm dữ liệu bằng Model binding/Query không thấy mà muốn trả về lỗi. | `errors.404_title` / `errors.404_message` |
| **419** | Session Expired | Phiên làm việc hết hạn hoặc token CSRF không hợp lệ/hết hạn khi gửi Form (POST/PUT/DELETE). | `errors.419_title` / `errors.419_message` |
| **429** | Too Many Requests | Người dùng/IP gửi quá nhiều yêu cầu trong thời gian ngắn (vượt giới hạn Rate Limit của Middleware). | `errors.429_title` / `errors.429_message` |
| **500** | Internal Error | Lỗi xảy ra từ phía máy chủ hoặc do code PHP bị lỗi crash, kết nối cơ sở dữ liệu thất bại... | `errors.500_title` / `errors.500_message` |
| **503** | Service Unavailable | Hệ thống đang được bảo trì khi chạy lệnh `php artisan down`. | `errors.503_title` / `errors.503_message` |
---
## 2. Cách kích hoạt (Trigger) trong Laravel
Trong Laravel, bạn có thể trigger các trang lỗi này bằng nhiều cách khác nhau tùy thuộc vào ngữ cảnh.
### 2.1. Sử dụng hàm `abort()` (Khuyên dùng trong Controller/Service)
Hàm `abort()` sẽ ném ra một `HttpException` và Laravel sẽ tự động bắt lấy để render trang lỗi tương ứng nằm trong `resources/views/errors/{status}.blade.php`.
```php
// Ví dụ kiểm tra quyền trong Controller
if ($user->role !== 'admin') {
abort(403, 'Bạn không có quyền quản trị viên.');
}
// Ví dụ không tìm thấy tài nguyên
$card = Card::find($id);
if (!$card) {
abort(404, 'Không tìm thấy thẻ cảm ơn.');
}
```
### 2.2. Sử dụng lệnh bảo trì hệ thống (Maintenance Mode)
Kích hoạt lỗi **503** bằng cách đưa ứng dụng vào trạng thái bảo trì:
```bash
php artisan down
```
Để tắt chế độ bảo trì và đưa hệ thống hoạt động bình thường:
```bash
php artisan up
```
### 2.3. Sử dụng Middleware (Rate Limiting cho lỗi 429)
Laravel tích hợp sẵn middleware `throttle`. Bạn có thể áp dụng nó trong file Routes (`routes/web.php` hoặc `routes/api.php`):
```php
Route::middleware('throttle:60,1')->group(function () {
Route::get('/send-card', [UserController::class, 'send']);
});
```
*(Nếu một IP gọi quá 60 lần/phút, Laravel sẽ tự động ném ra lỗi 429 và render trang 429)*
---
## 3. Ví dụ triển khai thực tế
### Trong Controller/Service:
```php
namespace App\Http\Controllers;
use App\Services\User\Contracts\UserServiceInterface;
use Illuminate\Http\Request;
class UserController extends Controller
{
protected $userService;
public function __construct(UserServiceInterface $userService)
{
$this->userService = $userService;
}
public function show($id)
{
$user = $this->userService->findUserById($id);
if (!$user) {
// Tự động render view errors/404.blade.php
abort(404);
}
return view('user.profile', compact('user'));
}
}
```
---
## 4. Checklist kiểm thử các trang lỗi
Để chắc chắn các trang lỗi hoạt động chính xác và hiển thị đẹp mắt, hãy tiến hành kiểm thử theo checklist sau:
- [ ] **Test 401 Unauthorized:**
- Tạm thời gỡ bỏ middleware auth ở một route và ném `abort(401)`.
- Kiểm tra xem có hiển thị nút **"Đi đến trang Đăng Nhập"** hay không.
- [ ] **Test 403 Forbidden:**
- Đăng nhập tài khoản User thường, sau đó truy cập một route dành riêng cho Admin hoặc ném `abort(403)`.
- Kiểm tra xem nút **"Quay lại Trang Chủ"** có hoạt động chính xác không.
- [ ] **Test 404 Not Found:**
- Truy cập một đường dẫn ngẫu nhiên không khai báo trong route (ví dụ: `http://localhost:8888/path-does-not-exist`).
- Kiểm tra giao diện và nút **"Quay lại Trang Chủ"**.
- [ ] **Test 419 Session Expired:**
- Mở một trang có form POST, xóa trường `_token` ẩn (CSRF) trong thẻ `<form>` bằng Inspect Element của trình duyệt rồi bấm Submit.
- Hoặc ném `abort(419)`.
- Kiểm tra xem có hiển thị nút **"Tải lại trang"** hay không.
- [ ] **Test 429 Too Many Requests:**
- Cấu hình tạm thời middleware `throttle:3,1` trên route chính và bấm F5 liên tục 4 lần.
- Hoặc gọi `abort(429)`.
- Kiểm tra thông báo lỗi.
- [ ] **Test 500 Internal Server Error:**
- Thêm một dòng code bị lỗi cú pháp cố ý trong Controller (ví dụ: gọi hàm không tồn tại `$this->nonExistentMethod()`).
- Hoặc gọi `abort(500)`.
- Đảm bảo giao diện trang 500 hiển thị thân thiện, không làm lộ stack trace kỹ thuật của code PHP.
- [ ] **Test 503 Maintenance:**
- Chạy lệnh `php artisan down` trong container Docker.
- Tải lại trình duyệt để xem trang bảo trì 503 và nút **"Thử lại"**.
- Chạy `php artisan up` để đưa website trở lại bình thường.
+14
View File
@@ -0,0 +1,14 @@
<?php
return [
'login' => 'Đăng nhập',
'email' => 'Email',
'email_placeholder' => 'Nhập email của bạn',
'password' => 'Mật khẩu',
'password_placeholder' => 'Nhập mật khẩu',
'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' => 'Thông tin đăng nhập không đúng. Vui lòng nhập lại',
'inactive' => 'Bạn không có quyền truy cập. Vui lòng liên hệ quản trị viên',
];
+30
View File
@@ -0,0 +1,30 @@
<?php
return [
'401_title' => 'Chưa đăng nhập',
'401_message' => 'Bạn cần đăng nhập tài khoản trước khi<br>truy cập vào trang này.',
'403_title' => 'Không có quyền truy cập',
'403_message' => 'Bạn không được phân quyền để truy cập<br>vào tài nguyên hoặc hành động này.',
'404_title' => 'Không tìm thấy trang',
'404_message' => 'Trang bạn đang tìm kiếm không tồn tại hoặc<br>đã được di chuyển khỏi hệ thống.',
'419_title' => 'Phiên làm việc hết hạn',
'419_message' => 'Trang đã hết hạn bảo mật do lâu không tương tác.<br>Vui lòng tải lại trang và thử lại.',
'429_title' => 'Yêu cầu quá giới hạn',
'429_message' => 'Bạn đang gửi quá nhiều yêu cầu đến máy chủ.<br>Vui lòng đợi một lát rồi thử lại.',
'500_title' => 'Lỗi máy chủ nội bộ',
'500_message' => 'Hệ thống đã gặp sự cố đột xuất máy chủ.<br>Chúng tôi đang kiểm tra và khắc phục sớm nhất.',
'503_title' => 'Hệ thống đang bảo trì',
'503_message' => 'Hệ thống hiện đang được bảo trì nâng cấp định kỳ.<br>Xin vui lòng quay lại sau ít phút.',
'back_to_home' => 'Về trang chủ',
'back_to_login' => 'Đi đến Đăng Nhập',
'back' => 'Quay lại',
'retry' => 'Thử lại',
'reload' => 'Tải lại trang',
];
+12
View File
@@ -0,0 +1,12 @@
<?php
return [
'manage_users' => 'Quản lý User',
'add_user' => 'Thêm User',
'my_page' => 'My Page',
'send_card' => 'Gửi Thank Card',
'change_password' => 'Đổi mật khẩu',
'logout' => 'Đăng xuất',
'have_questions' => 'Bạn có thắc mắc?',
'feedback' => 'Phản hồi',
];
+17
View File
@@ -0,0 +1,17 @@
<?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.',
'receiver_invalid' => 'Người nhận không tồn tại hoặc đã nghỉ việc.',
]
];
+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',
],
];
+2771
View File
File diff suppressed because it is too large Load Diff
+4
View File
@@ -13,5 +13,9 @@
"laravel-vite-plugin": "^2.0.0",
"tailwindcss": "^4.0.0",
"vite": "^7.0.7"
},
"dependencies": {
"@fontsource/roboto": "^5.2.10",
"puppeteer": "^25.2.1"
}
}
+14 -14
View File
@@ -18,19 +18,19 @@
</include>
</source>
<php>
<env name="APP_ENV" value="testing"/>
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
<env name="BCRYPT_ROUNDS" value="4"/>
<env name="BROADCAST_CONNECTION" value="null"/>
<env name="CACHE_STORE" value="array"/>
<env name="DB_CONNECTION" value="sqlite"/>
<env name="DB_DATABASE" value=":memory:"/>
<env name="DB_URL" value=""/>
<env name="MAIL_MAILER" value="array"/>
<env name="QUEUE_CONNECTION" value="sync"/>
<env name="SESSION_DRIVER" value="array"/>
<env name="PULSE_ENABLED" value="false"/>
<env name="TELESCOPE_ENABLED" value="false"/>
<env name="NIGHTWATCH_ENABLED" value="false"/>
<env name="APP_ENV" value="testing" force="true"/>
<env name="APP_MAINTENANCE_DRIVER" value="file" force="true"/>
<env name="BCRYPT_ROUNDS" value="4" force="true"/>
<env name="BROADCAST_CONNECTION" value="null" force="true"/>
<env name="CACHE_STORE" value="array" force="true"/>
<env name="DB_CONNECTION" value="sqlite" force="true"/>
<env name="DB_DATABASE" value=":memory:" force="true"/>
<env name="DB_URL" value="" force="true"/>
<env name="MAIL_MAILER" value="array" force="true"/>
<env name="QUEUE_CONNECTION" value="sync" force="true"/>
<env name="SESSION_DRIVER" value="array" force="true"/>
<env name="PULSE_ENABLED" value="false" force="true"/>
<env name="TELESCOPE_ENABLED" value="false" force="true"/>
<env name="NIGHTWATCH_ENABLED" value="false" force="true"/>
</php>
</phpunit>
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 390 KiB

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="#1a4f8b">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>

After

Width:  |  Height:  |  Size: 281 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="#1a4f8b">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>

After

Width:  |  Height:  |  Size: 371 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="#1a4f8b">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2" 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>

After

Width:  |  Height:  |  Size: 313 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="#1a4f8b">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>

After

Width:  |  Height:  |  Size: 224 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="#1a4f8b">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>

After

Width:  |  Height:  |  Size: 207 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="#1a4f8b">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4" />
</svg>

After

Width:  |  Height:  |  Size: 323 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="#1a4f8b">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>

After

Width:  |  Height:  |  Size: 780 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 333 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 553 KiB

+107
View File
@@ -0,0 +1,107 @@
<?php
// 1. Update app/Models/User.php
$content = file_get_contents('app/Models/User.php');
$constants = "
const ROLE_MEMBER = 0;
const ROLE_ADMIN = 1;
const STATUS_INACTIVE = 0;
const STATUS_ACTIVE = 1;
const FLAG_SEND_DISABLED = 0;
const FLAG_SEND_ENABLED = 1;
const FIRST_LOGIN_FALSE = 0;
const FIRST_LOGIN_TRUE = 1;
";
$content = str_replace('use HasFactory, Notifiable;', "use HasFactory, Notifiable;\n" . $constants, $content);
file_put_contents('app/Models/User.php', $content);
// 2. Update app/Models/Administration.php
$content = file_get_contents('app/Models/Administration.php');
$constants = "
const MAX_SEND_CARD_PER_MONTH = 5;
";
$content = str_replace('use HasFactory;', "use HasFactory;\n" . $constants, $content);
file_put_contents('app/Models/Administration.php', $content);
// 3. Update app/Http/Middleware/AdminMiddleware.php
$content = file_get_contents('app/Http/Middleware/AdminMiddleware.php');
$content = str_replace('use Illuminate\Support\Facades\Auth;', "use Illuminate\Support\Facades\Auth;\nuse App\Models\User;", $content);
$content = str_replace('Auth::user()->role == 1', 'Auth::user()->role == User::ROLE_ADMIN', $content);
$content = str_replace('Auth::user()->status == 1', 'Auth::user()->status == User::STATUS_ACTIVE', $content);
file_put_contents('app/Http/Middleware/AdminMiddleware.php', $content);
// 4. Update app/Http/Middleware/MemberMiddleware.php
$content = file_get_contents('app/Http/Middleware/MemberMiddleware.php');
$content = str_replace('use Illuminate\Support\Facades\Auth;', "use Illuminate\Support\Facades\Auth;\nuse App\Models\User;", $content);
$content = str_replace('Auth::user()->role == 0', 'Auth::user()->role == User::ROLE_MEMBER', $content);
$content = str_replace('Auth::user()->status == 1', 'Auth::user()->status == User::STATUS_ACTIVE', $content);
file_put_contents('app/Http/Middleware/MemberMiddleware.php', $content);
// 5. Update app/Http/Middleware/ForceChangePasswordMiddleware.php
$content = file_get_contents('app/Http/Middleware/ForceChangePasswordMiddleware.php');
$content = str_replace('use Illuminate\Support\Facades\Auth;', "use Illuminate\Support\Facades\Auth;\nuse App\Models\User;", $content);
$content = str_replace('Auth::user()->first_login == 1', 'Auth::user()->first_login == User::FIRST_LOGIN_TRUE', $content);
file_put_contents('app/Http/Middleware/ForceChangePasswordMiddleware.php', $content);
// 6. Update app/Http/Controllers/AuthController.php
$content = file_get_contents('app/Http/Controllers/AuthController.php');
$content = str_replace('use Illuminate\Support\Facades\Auth;', "use Illuminate\Support\Facades\Auth;\nuse App\Models\User;", $content);
$content = str_replace('$user->status != 1', '$user->status != User::STATUS_ACTIVE', $content);
$content = str_replace('$user->first_login == 1', '$user->first_login == User::FIRST_LOGIN_TRUE', $content);
$content = str_replace('$user->role == 1', '$user->role == User::ROLE_ADMIN', $content);
file_put_contents('app/Http/Controllers/AuthController.php', $content);
// 7. Update app/Http/Controllers/AdminController.php
$content = file_get_contents('app/Http/Controllers/AdminController.php');
$content = str_replace("where('status', 1)", "where('status', User::STATUS_ACTIVE)", $content);
$content = str_replace("'role' => 'required|in:0,1'", "'role' => 'required|in:' . User::ROLE_MEMBER . ',' . User::ROLE_ADMIN", $content);
$content = str_replace("'status' => 1", "'status' => User::STATUS_ACTIVE", $content);
$content = str_replace("'flag_send' => 0", "'flag_send' => User::FLAG_SEND_DISABLED", $content);
$content = str_replace("'first_login' => 1", "'first_login' => User::FIRST_LOGIN_TRUE", $content);
$content = str_replace("\$request->has('flag_send') ? 1 : 0", "\$request->has('flag_send') ? User::FLAG_SEND_ENABLED : User::FLAG_SEND_DISABLED", $content);
$content = str_replace("\$user->status = 0", "\$user->status = User::STATUS_INACTIVE", $content);
file_put_contents('app/Http/Controllers/AdminController.php', $content);
// 8. Update app/Http/Controllers/UserController.php
$content = file_get_contents('app/Http/Controllers/UserController.php');
$content = str_replace("where('status', 1)", "where('status', User::STATUS_ACTIVE)", $content);
$content = str_replace("max:5'", "max:' . Administration::MAX_SEND_CARD_PER_MONTH", $content);
$content = str_replace("\$sender->flag_send == 0", "\$sender->flag_send == User::FLAG_SEND_DISABLED", $content);
$content = str_replace("> 5)", "> Administration::MAX_SEND_CARD_PER_MONTH)", $content);
$content = str_replace("tối đa 5 card", 'tối đa " . Administration::MAX_SEND_CARD_PER_MONTH . " card', $content);
$content = str_replace("\$user->first_login = 0", "\$user->first_login = User::FIRST_LOGIN_FALSE", $content);
$content = str_replace("\$user->role == 1", "\$user->role == User::ROLE_ADMIN", $content);
file_put_contents('app/Http/Controllers/UserController.php', $content);
// 9. Update app/Console/Commands/ResetCardsCommand.php
$content = file_get_contents('app/Console/Commands/ResetCardsCommand.php');
$content = str_replace("where('status', 1)", "where('status', User::STATUS_ACTIVE)", $content);
file_put_contents('app/Console/Commands/ResetCardsCommand.php', $content);
// 10. Update resources/views/layouts/app.blade.php
$content = file_get_contents('resources/views/layouts/app.blade.php');
$content = str_replace("Auth::user()->role == 1", "Auth::user()->role == \App\Models\User::ROLE_ADMIN", $content);
file_put_contents('resources/views/layouts/app.blade.php', $content);
// 11. Update resources/views/admin/users/create.blade.php
$content = file_get_contents('resources/views/admin/users/create.blade.php');
$content = str_replace('value="0"', 'value="{{ \App\Models\User::ROLE_MEMBER }}"', $content);
$content = str_replace('value="1"', 'value="{{ \App\Models\User::ROLE_ADMIN }}"', $content);
$content = str_replace("old('role') == '0'", "old('role') == \App\Models\User::ROLE_MEMBER", $content);
$content = str_replace("old('role') == '1'", "old('role') == \App\Models\User::ROLE_ADMIN", $content);
file_put_contents('resources/views/admin/users/create.blade.php', $content);
// 12. Update resources/views/admin/users/edit.blade.php
$content = file_get_contents('resources/views/admin/users/edit.blade.php');
$content = str_replace("\$user->role == 1", "\$user->role == \App\Models\User::ROLE_ADMIN", $content);
file_put_contents('resources/views/admin/users/edit.blade.php', $content);
// 13. Update resources/views/user/change_password.blade.php
$content = file_get_contents('resources/views/user/change_password.blade.php');
$content = str_replace("Auth::user()->first_login == 1", "Auth::user()->first_login == \App\Models\User::FIRST_LOGIN_TRUE", $content);
file_put_contents('resources/views/user/change_password.blade.php', $content);
echo "Refactored successfully!";
+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-full h-full">
<path stroke-linecap="round" stroke-linejoin="round" d="M3.98 8.223A10.477 10.477 0 001.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.45 10.45 0 0112 4.5c4.756 0 8.773 3.162 10.065 7.498a10.523 10.523 0 01-4.293 5.774M6.228 6.228L3 3m3.228 3.228l3.65 3.65m7.894 7.894L21 21m-3.228-3.228l-3.65-3.65m0 0a3 3 0 10-4.243-4.243m4.242 4.242L9.88 9.88" />
</svg>

After

Width:  |  Height:  |  Size: 531 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-full h-full">
<path stroke-linecap="round" stroke-linejoin="round" d="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z" />
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>

After

Width:  |  Height:  |  Size: 485 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg>

After

Width:  |  Height:  |  Size: 201 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>

After

Width:  |  Height:  |  Size: 200 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="18" x2="21" y2="18"/></svg>

After

Width:  |  Height:  |  Size: 277 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="18" x2="21" y2="18"/></svg>

After

Width:  |  Height:  |  Size: 277 B

Some files were not shown because too many files have changed in this diff Show More