Compare commits
84 Commits
main
...
feature/login
| Author | SHA1 | Date | |
|---|---|---|---|
| 9b4da6321d | |||
| c079677920 | |||
| ac76212819 | |||
| e249f08ecc | |||
| eee82363ed | |||
| f864e07b6d | |||
| eda904524f | |||
| e06b1db44b | |||
| 4255d187c9 | |||
| 36eec0a664 | |||
| 781a0f8aa7 | |||
| f8385185c9 | |||
| 98a56eb130 | |||
| 7af5de6048 | |||
| 91a598a5f9 | |||
| e81a285aa9 | |||
| c4e652e64c | |||
| ad5465198e | |||
| 7190cc99ad | |||
| 9c20a25111 | |||
| 939287d9b3 | |||
| b3549ff3eb | |||
| 041ea8fe77 | |||
| b59c71aaef | |||
| 12196570e8 | |||
| c48f7aa23d | |||
| 53fa37d599 | |||
| 7f059dce86 | |||
| d16db9c73d | |||
| 866d46bf21 | |||
| ce6c4d2cf3 | |||
| 05605ed1eb | |||
| 3aa9e04bd7 | |||
| 9370e2fb3c | |||
| 47a8892623 | |||
| f0a25b840c | |||
| 797689940c | |||
| 62d922627f | |||
| a1ad7e738b | |||
| 326e1f1028 | |||
| 54048bb4ff | |||
| 3a33c86346 | |||
| badc453389 | |||
| 48bdf21de2 | |||
| 25bb3d961e | |||
| 439eec9015 | |||
| 553d47ebd1 | |||
| b62926b9af | |||
| 8ca6508f72 | |||
| 9aee6d612c | |||
| bdd58e0d17 | |||
| 5e6ce46e00 | |||
| bffb3f3884 | |||
| e476c2768a | |||
| 7d9ba527a3 | |||
| 6056e33ef8 | |||
| 63764d0718 | |||
| af529a4e81 | |||
| 7a964785c7 | |||
| d4dbf849af | |||
| 1e050233b9 | |||
| e163d2f349 | |||
| 618dcef37f | |||
| e1fd70071b | |||
| 64027eb8f2 | |||
| 11a6568717 | |||
| b1beed4ae6 | |||
| d8f0e48bf3 | |||
| 1943f23406 | |||
| f594a45e84 | |||
| 89790539b7 | |||
| c993f848cb | |||
| 83a529b4c5 | |||
| 5565d15be5 | |||
| 66d0caa588 | |||
| fde9105834 | |||
| 7bdea785f9 | |||
| f1858f8f0f | |||
| 4e4d03d62a | |||
| d99541b7e4 | |||
| 8cbdd93e56 | |||
| 96eac88f36 | |||
| c5016489ac | |||
| 8a77324f89 |
@@ -4,7 +4,7 @@ APP_KEY=
|
|||||||
APP_DEBUG=true
|
APP_DEBUG=true
|
||||||
APP_URL=http://localhost
|
APP_URL=http://localhost
|
||||||
|
|
||||||
APP_LOCALE=en
|
APP_LOCALE=vi
|
||||||
APP_FALLBACK_LOCALE=en
|
APP_FALLBACK_LOCALE=en
|
||||||
APP_FAKER_LOCALE=en_US
|
APP_FAKER_LOCALE=en_US
|
||||||
|
|
||||||
@@ -63,3 +63,6 @@ AWS_BUCKET=
|
|||||||
AWS_USE_PATH_STYLE_ENDPOINT=false
|
AWS_USE_PATH_STYLE_ENDPOINT=false
|
||||||
|
|
||||||
VITE_APP_NAME="${APP_NAME}"
|
VITE_APP_NAME="${APP_NAME}"
|
||||||
|
|
||||||
|
CHATOPS_API_URL=http://10.1.12.160/aiapi/sent-message-user
|
||||||
|
CHATOPS_API_TOKEN=
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -52,3 +52,5 @@ yarn-debug.log*
|
|||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
*.tmp
|
*.tmp
|
||||||
*.temp
|
*.temp
|
||||||
|
Tasks/
|
||||||
|
**/.env.app
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Services\Admin\Contracts\AdminServiceInterface;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
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()
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$this->adminService->resetAllCards();
|
||||||
|
Log::info('Successfully reset all cards for active members to 0!');
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::error('Error occurred while executing monthly card reset command (cards:reset): ' . $e->getMessage());
|
||||||
|
return Command::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\DTOs;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Pagination\LengthAwarePaginator;
|
||||||
|
|
||||||
|
class DashboardDataDto
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public readonly LengthAwarePaginator $administrations,
|
||||||
|
public readonly string $selectedMonth,
|
||||||
|
public readonly User $user,
|
||||||
|
public readonly array $receivedRankers,
|
||||||
|
public readonly ?object $receivedCurrentUserRankItem,
|
||||||
|
public readonly array $sentRankers,
|
||||||
|
public readonly ?object $sentCurrentUserRankItem,
|
||||||
|
public readonly int $receivedCount,
|
||||||
|
public readonly int $sentCount,
|
||||||
|
public readonly int $currentRank
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert the DTO to an associative array for view rendering.
|
||||||
|
*/
|
||||||
|
public function toArray(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'administrations' => $this->administrations,
|
||||||
|
'selectedMonth' => $this->selectedMonth,
|
||||||
|
'user' => $this->user,
|
||||||
|
'receivedRankers' => $this->receivedRankers,
|
||||||
|
'receivedCurrentUserRankItem' => $this->receivedCurrentUserRankItem,
|
||||||
|
'sentRankers' => $this->sentRankers,
|
||||||
|
'sentCurrentUserRankItem' => $this->sentCurrentUserRankItem,
|
||||||
|
'receivedCount' => $this->receivedCount,
|
||||||
|
'sentCount' => $this->sentCount,
|
||||||
|
'currentRank' => $this->currentRank,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\DTOs;
|
||||||
|
|
||||||
|
use Carbon\Carbon;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class UserFilterDto
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public readonly string $selectedMonth,
|
||||||
|
public readonly ?string $search = null,
|
||||||
|
public readonly ?string $status = '1',
|
||||||
|
public readonly ?string $role = null,
|
||||||
|
public readonly ?string $department = null,
|
||||||
|
public readonly ?string $flagSend = null
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a DTO instance from an HTTP Request.
|
||||||
|
*/
|
||||||
|
public static function fromRequest(Request $request): self
|
||||||
|
{
|
||||||
|
return new self(
|
||||||
|
selectedMonth: (string) $request->input('month', Carbon::now()->format('Y-m')),
|
||||||
|
search: $request->input('search'),
|
||||||
|
status: $request->has('status') ? (string) $request->input('status') : '1',
|
||||||
|
role: $request->input('role'),
|
||||||
|
department: $request->input('department'),
|
||||||
|
flagSend: $request->input('flag_send')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Exceptions;
|
||||||
|
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
class ThankCardException extends RuntimeException
|
||||||
|
{
|
||||||
|
// Custom domain exception for thank card operations
|
||||||
|
}
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Admin;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\Admin\StoreUserRequest;
|
||||||
|
use App\Http\Requests\Admin\UpdateUserRequest;
|
||||||
|
use App\Http\Requests\Admin\UpdateAddCardRequest;
|
||||||
|
use App\DTOs\UserFilterDto;
|
||||||
|
use App\Services\Admin\Contracts\AdminServiceInterface;
|
||||||
|
use Carbon\Carbon;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
class AdminController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly AdminServiceInterface $adminService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display a listing of the users with statistics.
|
||||||
|
*/
|
||||||
|
public function index(Request $request): View|JsonResponse
|
||||||
|
{
|
||||||
|
$filters = UserFilterDto::fromRequest($request);
|
||||||
|
|
||||||
|
[
|
||||||
|
'users' => $users,
|
||||||
|
'topReceivedUser' => $topReceivedUser,
|
||||||
|
'topSentUser' => $topSentUser,
|
||||||
|
'topReceivedUsers' => $topReceivedUsers,
|
||||||
|
'topSentUsers' => $topSentUsers
|
||||||
|
] = $this->adminService->getUserListWithStats($filters);
|
||||||
|
|
||||||
|
if ($request->ajax() || $request->wantsJson()) {
|
||||||
|
return response()->json([
|
||||||
|
'title' => view('admin.users.partials.title', ['selectedMonth' => $filters->selectedMonth])->render(),
|
||||||
|
'stats' => view('admin.users.partials.stats', compact('topReceivedUser', 'topSentUser', 'topReceivedUsers', 'topSentUsers'))->render(),
|
||||||
|
'table' => view('admin.users.partials.table', compact('users'))->render(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return view('admin.users.index', compact(
|
||||||
|
'users',
|
||||||
|
'topReceivedUser',
|
||||||
|
'topSentUser',
|
||||||
|
'topReceivedUsers',
|
||||||
|
'topSentUsers'
|
||||||
|
))->with([
|
||||||
|
'selectedMonth' => $filters->selectedMonth,
|
||||||
|
'search' => $filters->search,
|
||||||
|
'status' => $filters->status,
|
||||||
|
'role' => $filters->role,
|
||||||
|
'department' => $filters->department,
|
||||||
|
'flagSend' => $filters->flagSend,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show the form for creating a new user.
|
||||||
|
*/
|
||||||
|
public function create(): View
|
||||||
|
{
|
||||||
|
return view('admin.users.form', ['mode' => 'create']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store a newly created user in storage.
|
||||||
|
*/
|
||||||
|
public function store(StoreUserRequest $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$this->adminService->createUser($request->validated());
|
||||||
|
|
||||||
|
Log::info('Admin created new user', ['admin_id' => auth()->id(), 'user_data' => $request->validated()]);
|
||||||
|
|
||||||
|
return redirect()->route('admin.users.index')->with('success', __('messages.user_create_success'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show the form for editing the specified user.
|
||||||
|
*/
|
||||||
|
public function edit(Request $request, string $msnv): View
|
||||||
|
{
|
||||||
|
$user = $this->adminService->getUserByMsnv($msnv);
|
||||||
|
$selectedMonth = (string) $request->input('month', Carbon::now()->format('Y-m'));
|
||||||
|
$administrations = $this->adminService->getUserTransactions($msnv, $selectedMonth);
|
||||||
|
$addCards = $this->adminService->getAddCardsHistory($user->msnv, $selectedMonth);
|
||||||
|
$admins = $this->adminService->getActiveAdmins();
|
||||||
|
|
||||||
|
return view('admin.users.form', compact('user', 'administrations', 'selectedMonth', 'addCards', 'admins'))->with('mode', 'edit');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the specified user in storage.
|
||||||
|
*/
|
||||||
|
public function update(UpdateUserRequest $request, string $msnv)
|
||||||
|
{
|
||||||
|
$user = $this->adminService->getUserByMsnv($msnv);
|
||||||
|
$validatedData = $request->validated();
|
||||||
|
|
||||||
|
$this->adminService->updateUser($user, $validatedData);
|
||||||
|
|
||||||
|
Log::info('Admin updated user', ['admin_id' => auth()->id(), 'target_msnv' => $msnv, 'updated_data' => $validatedData]);
|
||||||
|
|
||||||
|
$currentUser = auth()->user();
|
||||||
|
if ($currentUser && $user->id === $currentUser->id && isset($validatedData['status']) && $validatedData['status'] == config('constants.STATUS_INACTIVE')) {
|
||||||
|
auth()->logout();
|
||||||
|
$request->session()->invalidate();
|
||||||
|
$request->session()->regenerateToken();
|
||||||
|
|
||||||
|
if ($request->expectsJson() || $request->ajax() || $request->wantsJson()) {
|
||||||
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
|
'message' => 'Tài khoản của bạn đã chuyển sang trạng thái nghỉ việc. Bạn đã được đăng xuất.',
|
||||||
|
'redirect' => route('login'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect()->route('login')->with('success', 'Tài khoản của bạn đã chuyển sang trạng thái nghỉ việc. Bạn đã được đăng xuất.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($request->expectsJson()) {
|
||||||
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
|
'message' => __('messages.user_update_success'),
|
||||||
|
'card_balance' => $user->fresh()->card,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect()->back()->with('success', __('messages.user_update_success'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove (deactivate) the specified user from storage.
|
||||||
|
*/
|
||||||
|
public function destroy(string $msnv): RedirectResponse
|
||||||
|
{
|
||||||
|
$user = $this->adminService->getUserByMsnv($msnv);
|
||||||
|
|
||||||
|
$this->adminService->deactivateUser($msnv);
|
||||||
|
|
||||||
|
Log::info('Admin deactivated user', ['admin_id' => auth()->id(), 'target_msnv' => $msnv]);
|
||||||
|
|
||||||
|
$currentUser = auth()->user();
|
||||||
|
if ($currentUser && $user->id === $currentUser->id) {
|
||||||
|
auth()->logout();
|
||||||
|
request()->session()->invalidate();
|
||||||
|
request()->session()->regenerateToken();
|
||||||
|
|
||||||
|
return redirect()->route('login')->with('success', 'Tài khoản của bạn đã chuyển sang trạng thái nghỉ việc. Bạn đã được đăng xuất.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect()->route('admin.users.index')->with('success', __('messages.user_deactivate_success'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update card allocation history record.
|
||||||
|
*/
|
||||||
|
public function updateAddCard(UpdateAddCardRequest $request, int $id)
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$this->adminService->updateAddCard($id, $request->validated());
|
||||||
|
|
||||||
|
Log::info('Admin updated add card history', ['admin_id' => auth()->id(), 'history_id' => $id, 'updated_data' => $request->validated()]);
|
||||||
|
|
||||||
|
if ($request->expectsJson()) {
|
||||||
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
|
'message' => 'Cập nhật lịch sử cấp phát thẻ thành công.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
return redirect()->back()->with('success', 'Cập nhật lịch sử cấp phát thẻ thành công.');
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
if ($request->expectsJson()) {
|
||||||
|
return response()->json([
|
||||||
|
'success' => false,
|
||||||
|
'message' => $e->getMessage(),
|
||||||
|
], 422);
|
||||||
|
}
|
||||||
|
return redirect()->back()->withErrors(['error' => $e->getMessage()]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete card allocation history record.
|
||||||
|
*/
|
||||||
|
public function destroyAddCard(Request $request, int $id)
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$this->adminService->destroyAddCard($id);
|
||||||
|
|
||||||
|
Log::info('Admin deleted add card history', ['admin_id' => auth()->id(), 'history_id' => $id]);
|
||||||
|
|
||||||
|
if ($request->expectsJson()) {
|
||||||
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
|
'message' => 'Xóa lịch sử cấp phát thẻ thành công.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
return redirect()->back()->with('success', 'Xóa lịch sử cấp phát thẻ thành công.');
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
if ($request->expectsJson()) {
|
||||||
|
return response()->json([
|
||||||
|
'success' => false,
|
||||||
|
'message' => $e->getMessage(),
|
||||||
|
], 422);
|
||||||
|
}
|
||||||
|
return redirect()->back()->withErrors(['error' => $e->getMessage()]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Services\Auth\Contracts\AuthServiceInterface;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
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)) {
|
||||||
|
Log::warning('Brute-force login attempt blocked', ['email' => $email, 'ip' => $request->ip()]);
|
||||||
|
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') {
|
||||||
|
Log::warning('Login attempt by deactivated user', ['email' => $email, 'ip' => $request->ip()]);
|
||||||
|
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
|
||||||
|
Log::warning('Failed login attempt', ['email' => $email, 'ip' => $request->ip()]);
|
||||||
|
|
||||||
|
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'];
|
||||||
|
Log::info('User logged in successfully', ['user_id' => $user->id, 'email' => $user->mail, 'ip' => $request->ip()]);
|
||||||
|
|
||||||
|
if ($user->role != config('constants.ROLE_ADMIN') && $user->first_login == config('constants.FIRST_LOGIN_TRUE')) {
|
||||||
|
return redirect()->route('login')->with('dialog_first_login', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect($this->authService->getRedirectRouteForUser($user));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function logout(Request $request)
|
||||||
|
{
|
||||||
|
$userId = Auth::id();
|
||||||
|
$this->authService->logout($request);
|
||||||
|
Log::info('User logged out', ['user_id' => $userId, 'ip' => $request->ip()]);
|
||||||
|
return redirect('/login');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function forgotPassword(Request $request)
|
||||||
|
{
|
||||||
|
$validator = \Illuminate\Support\Facades\Validator::make($request->all(), [
|
||||||
|
'mail' => 'required|email|max:255',
|
||||||
|
], [
|
||||||
|
'mail.required' => config('constants.MSG_FORGOT_PWD_EMAIL_REQUIRED'),
|
||||||
|
'mail.email' => config('constants.MSG_FORGOT_PWD_EMAIL_INVALID'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($validator->fails()) {
|
||||||
|
return back()->withInput()->with('dialog_forgot_error', $validator->errors()->first());
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = $this->authService->forgotPassword($request->input('mail'));
|
||||||
|
|
||||||
|
if (!$result['success']) {
|
||||||
|
Log::warning('Forgot password requested for non-existent/invalid email', ['email' => $request->input('mail'), 'ip' => $request->ip()]);
|
||||||
|
return back()->withInput()->with('dialog_forgot_error', $result['error_msg']);
|
||||||
|
}
|
||||||
|
|
||||||
|
Log::info('Forgot password requested successfully', ['email' => $request->input('mail'), 'ip' => $request->ip()]);
|
||||||
|
return back()->with('dialog_forgot_success', $result['message']);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,291 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\User;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\User\SendThankCardRequest;
|
||||||
|
use App\Http\Requests\User\UpdateProfileRequest;
|
||||||
|
use App\Http\Requests\User\UpdatePasswordRequest;
|
||||||
|
use App\DTOs\DashboardDataDto;
|
||||||
|
use App\Exceptions\ThankCardException;
|
||||||
|
use App\Services\User\Contracts\UserServiceInterface;
|
||||||
|
use Carbon\Carbon;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
|
||||||
|
class UserController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly UserServiceInterface $userService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display the user dashboard with transaction history, stats, and rankings.
|
||||||
|
*/
|
||||||
|
public function index(Request $request): View
|
||||||
|
{
|
||||||
|
$selectedMonth = (string) $request->input('month', Carbon::now()->format('Y-m'));
|
||||||
|
$user = Auth::user();
|
||||||
|
|
||||||
|
$administrations = $this->userService->getDashboard($user, $selectedMonth);
|
||||||
|
|
||||||
|
$isAdmin = $user->role == config('constants.ROLE_ADMIN');
|
||||||
|
|
||||||
|
if ($isAdmin) {
|
||||||
|
$stats = $this->userService->getPersonalStats($user, $selectedMonth);
|
||||||
|
$receivedRanking = $this->userService->getRankingList('received', $selectedMonth, $user);
|
||||||
|
$sentRanking = $this->userService->getRankingList('sent', $selectedMonth, $user);
|
||||||
|
|
||||||
|
$receivedCount = (int) $stats['receivedCount'];
|
||||||
|
$sentCount = (int) $stats['sentCount'];
|
||||||
|
$currentRank = (int) $stats['currentRank'];
|
||||||
|
$receivedRankers = $receivedRanking['rankers'];
|
||||||
|
$receivedCurrentUserRankItem = $receivedRanking['currentUserRankItem'];
|
||||||
|
$sentRankers = $sentRanking['rankers'];
|
||||||
|
$sentCurrentUserRankItem = $sentRanking['currentUserRankItem'];
|
||||||
|
} else {
|
||||||
|
// For member, do NOT fetch ranking lists or calculate rank to ensure security and improve DB performance
|
||||||
|
$startOfMonth = Carbon::parse($selectedMonth)->startOfMonth();
|
||||||
|
$endOfMonth = Carbon::parse($selectedMonth)->endOfMonth();
|
||||||
|
|
||||||
|
$receivedCount = (int) \App\Models\Administration::where('msnv', $user->msnv)
|
||||||
|
->whereBetween('date', [$startOfMonth, $endOfMonth])
|
||||||
|
->sum('received');
|
||||||
|
|
||||||
|
$sentCount = (int) \App\Models\Administration::where('msnv', $user->msnv)
|
||||||
|
->whereBetween('date', [$startOfMonth, $endOfMonth])
|
||||||
|
->sum('sent');
|
||||||
|
|
||||||
|
$currentRank = 0;
|
||||||
|
$receivedRankers = [];
|
||||||
|
$receivedCurrentUserRankItem = null;
|
||||||
|
$sentRankers = [];
|
||||||
|
$sentCurrentUserRankItem = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$dashboardData = new DashboardDataDto(
|
||||||
|
administrations: $administrations,
|
||||||
|
selectedMonth: $selectedMonth,
|
||||||
|
user: $user,
|
||||||
|
receivedRankers: $receivedRankers,
|
||||||
|
receivedCurrentUserRankItem: $receivedCurrentUserRankItem,
|
||||||
|
sentRankers: $sentRankers,
|
||||||
|
sentCurrentUserRankItem: $sentCurrentUserRankItem,
|
||||||
|
receivedCount: $receivedCount,
|
||||||
|
sentCount: $sentCount,
|
||||||
|
currentRank: $currentRank
|
||||||
|
);
|
||||||
|
|
||||||
|
return view('user.dashboard', $dashboardData->toArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show the form to send thank cards.
|
||||||
|
*/
|
||||||
|
public function sendThankcards(): View
|
||||||
|
{
|
||||||
|
$users = $this->userService->getOtherActiveUsers(Auth::user());
|
||||||
|
return view('user.send', compact('users'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get statistics for a specific recipient.
|
||||||
|
*/
|
||||||
|
public function getRecipientStats(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$receiverMsnv = (string) $request->query('receiver');
|
||||||
|
if (empty($receiverMsnv)) {
|
||||||
|
return response()->json(['success' => false, 'message' => 'Receiver is required'], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$sender = Auth::user();
|
||||||
|
$sentCount = $this->userService->getSentCardsToUserThisMonth($sender, $receiverMsnv);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
|
'sent_count' => $sentCount,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function storeThankcards(SendThankCardRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$this->userService->sendThankcards(
|
||||||
|
Auth::user(),
|
||||||
|
(string) $request->input('receiver'),
|
||||||
|
(int) $request->input('amount'),
|
||||||
|
(int) $request->input('template_id', 1)
|
||||||
|
);
|
||||||
|
|
||||||
|
\Illuminate\Support\Facades\Log::info('User sent thank cards', [
|
||||||
|
'sender_id' => Auth::id(),
|
||||||
|
'receiver_msnv' => $request->input('receiver'),
|
||||||
|
'amount' => $request->input('amount'),
|
||||||
|
'template_id' => $request->input('template_id', 1)
|
||||||
|
]);
|
||||||
|
} catch (ThankCardException $e) {
|
||||||
|
return response()->json(['success' => false, 'message' => $e->getMessage()], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$receiverUser = \App\Models\User::where('msnv', $request->input('receiver'))->first();
|
||||||
|
|
||||||
|
// Send ChatOps notification from backend to avoid CORS issues
|
||||||
|
$chatOpsUrl = config('constants.CHATOPS_API_URL');
|
||||||
|
$chatOpsToken = config('constants.CHATOPS_API_TOKEN');
|
||||||
|
|
||||||
|
if ($chatOpsUrl && $chatOpsToken && $receiverUser) {
|
||||||
|
try {
|
||||||
|
\Illuminate\Support\Facades\Http::timeout(5)->post($chatOpsUrl, [
|
||||||
|
'message' => "💌 Bạn nhận được một Thank Card mới!\nNhanh chân đến khu vực nhận thư để nhận ngay nhé!",
|
||||||
|
'userEmail' => $receiverUser->mail,
|
||||||
|
'token' => $chatOpsToken,
|
||||||
|
]);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
\Illuminate\Support\Facades\Log::error('ChatOps API failed: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$sender = Auth::user();
|
||||||
|
$newRecipientSentCount = $this->userService->getSentCardsToUserThisMonth($sender, (string) $request->input('receiver'));
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
|
'message' => __('messages.thank_card_send_success'),
|
||||||
|
'receiver_email' => $receiverUser ? $receiverUser->mail : '',
|
||||||
|
'new_balance' => (int) $sender->card,
|
||||||
|
'new_recipient_sent_count' => $newRecipientSentCount,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// New page: list of ThankCards received by the current user
|
||||||
|
public function receivedCards(Request $request)
|
||||||
|
{
|
||||||
|
$selectedMonth = $request->input('month', Carbon::now()->format('Y-m'));
|
||||||
|
$user = Auth::user();
|
||||||
|
|
||||||
|
if ($request->ajax() || $request->wantsJson()) {
|
||||||
|
$offset = (int) $request->input('offset', 0);
|
||||||
|
$limit = 12;
|
||||||
|
$data = $this->userService->getReceivedPageData($user, $selectedMonth, $limit, $offset);
|
||||||
|
|
||||||
|
$html = view('user.partials.received_cards_feed', ['cards' => $data['cards']])->render();
|
||||||
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
|
'html' => $html,
|
||||||
|
'hasMore' => $data['hasMore'],
|
||||||
|
'totalCards' => $data['totalCards'],
|
||||||
|
'totalSenders' => $data['totalSenders']
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = $this->userService->getReceivedPageData($user, $selectedMonth, 12, 0);
|
||||||
|
$data['selectedMonth'] = $selectedMonth;
|
||||||
|
return view('user.received', $data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// New page: list of ThankCards sent by the current user
|
||||||
|
public function sentCards(Request $request)
|
||||||
|
{
|
||||||
|
$selectedMonth = $request->input('month', Carbon::now()->format('Y-m'));
|
||||||
|
$user = Auth::user();
|
||||||
|
|
||||||
|
if ($request->ajax() || $request->wantsJson()) {
|
||||||
|
$offset = (int) $request->input('offset', 0);
|
||||||
|
$limit = 12;
|
||||||
|
$data = $this->userService->getSentPageData($user, $selectedMonth, $limit, $offset);
|
||||||
|
|
||||||
|
$html = view('user.partials.sent_cards_feed', ['cards' => $data['cards']])->render();
|
||||||
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
|
'html' => $html,
|
||||||
|
'hasMore' => $data['hasMore'],
|
||||||
|
'totalCards' => $data['totalCards'],
|
||||||
|
'totalReceivers' => $data['totalReceivers']
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = $this->userService->getSentPageData($user, $selectedMonth, 12, 0);
|
||||||
|
$data['selectedMonth'] = $selectedMonth;
|
||||||
|
return view('user.sent', $data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// New page: ranking (both received and sent) with tabs
|
||||||
|
public function ranking(Request $request): \Illuminate\View\View
|
||||||
|
{
|
||||||
|
if (Auth::user()->role !== config('constants.ROLE_ADMIN')) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$selectedMonth = $request->input('month', Carbon::now()->format('Y-m'));
|
||||||
|
$user = Auth::user();
|
||||||
|
$receivedRanking = $this->userService->getRankingList('received', $selectedMonth, $user);
|
||||||
|
$sentRanking = $this->userService->getRankingList('sent', $selectedMonth, $user);
|
||||||
|
return view('user.ranking', [
|
||||||
|
'selectedMonth' => $selectedMonth,
|
||||||
|
'receivedRankers' => $receivedRanking['rankers'],
|
||||||
|
'receivedCurrentUserRankItem' => $receivedRanking['currentUserRankItem'],
|
||||||
|
'sentRankers' => $sentRanking['rankers'],
|
||||||
|
'sentCurrentUserRankItem' => $sentRanking['currentUserRankItem'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show the change password form.
|
||||||
|
*/
|
||||||
|
public function changePasswordForm()
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
return view('user.change_password');
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
\Illuminate\Support\Facades\Log::error('Error rendering change_password view: ' . $e->getMessage());
|
||||||
|
return response()->view('errors.500', [
|
||||||
|
'exception' => $e,
|
||||||
|
'allowLogout' => true
|
||||||
|
], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle user password change.
|
||||||
|
*/
|
||||||
|
public function updatePassword(UpdatePasswordRequest $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$user = Auth::user();
|
||||||
|
$this->userService->updatePassword($user, $request->input('password'));
|
||||||
|
|
||||||
|
\Illuminate\Support\Facades\Log::info('User updated password', ['user_id' => $user->id]);
|
||||||
|
|
||||||
|
$route = $user->role == config('constants.ROLE_ADMIN') ? 'admin.dashboard' : 'user.dashboard';
|
||||||
|
return redirect()->route($route)->with('success', __('messages.password_change_success'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show the edit profile page (avatar & password).
|
||||||
|
*/
|
||||||
|
public function editProfile(): View
|
||||||
|
{
|
||||||
|
return view('user.edit');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle avatar upload and optional password change.
|
||||||
|
*/
|
||||||
|
public function updateProfile(UpdateProfileRequest $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$user = Auth::user();
|
||||||
|
|
||||||
|
$this->userService->updateProfile(
|
||||||
|
$user,
|
||||||
|
$request->validated(),
|
||||||
|
$request->file('avatar')
|
||||||
|
);
|
||||||
|
|
||||||
|
\Illuminate\Support\Facades\Log::info('User updated profile', ['user_id' => $user->id]);
|
||||||
|
|
||||||
|
return redirect()->route('user.edit')->with('success', 'Cập nhật hồ sơ thành công');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 == config('constants.ROLE_ADMIN') && Auth::user()->status == config('constants.STATUS_ACTIVE')) {
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()->role != config('constants.ROLE_ADMIN') && Auth::user()->first_login == config('constants.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, [config('constants.ROLE_MEMBER'), config('constants.ROLE_ADMIN')]) && Auth::user()->status == config('constants.STATUS_ACTIVE')) {
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
return redirect()->route('login');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Admin;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class StoreUserRequest extends FormRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Determine if the user is authorized to make this request.
|
||||||
|
*/
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true; // Middleware handles authorization
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the validation rules that apply to the request.
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'msnv' => 'required|integer|unique:user,msnv',
|
||||||
|
'name' => 'required|string|max:255',
|
||||||
|
'mail' => 'required|email|unique:user,mail',
|
||||||
|
'departments' => 'required|string|in:' . implode(',', config('constants.DEPARTMENTS')),
|
||||||
|
'password' => 'required|string',
|
||||||
|
'role' => 'required|in:' . config('constants.ROLE_MEMBER') . ',' . config('constants.ROLE_ADMIN'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Admin;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class UpdateAddCardRequest extends FormRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Determine if the user is authorized to make this request.
|
||||||
|
*/
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the validation rules that apply to the request.
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'num_card' => 'required|integer|min:1',
|
||||||
|
'seller' => 'required|exists:user,msnv',
|
||||||
|
'date' => 'required|date',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Admin;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class UpdateUserRequest extends FormRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Determine if the user is authorized to make this request.
|
||||||
|
*/
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function prepareForValidation(): void
|
||||||
|
{
|
||||||
|
// Only set default flag_send to 0 if the route is for user update and flag_send is not present
|
||||||
|
if (($this->isMethod('put') || $this->isMethod('patch')) && !$this->has('flag_send')) {
|
||||||
|
$this->merge([
|
||||||
|
'flag_send' => config('constants.FLAG_SEND_DISABLED'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the validation rules that apply to the request.
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
$msnv = $this->route('msnv');
|
||||||
|
|
||||||
|
return [
|
||||||
|
'card' => 'nullable|integer|min:0',
|
||||||
|
'num_card' => 'nullable|integer|min:1',
|
||||||
|
'role' => 'nullable|in:' . config('constants.ROLE_MEMBER') . ',' . config('constants.ROLE_ADMIN'),
|
||||||
|
'status' => 'nullable|in:' . config('constants.STATUS_INACTIVE') . ',' . config('constants.STATUS_ACTIVE'),
|
||||||
|
'flag_send' => 'nullable|in:0,1',
|
||||||
|
'first_login' => 'nullable|in:' . config('constants.FIRST_LOGIN_FALSE') . ',' . config('constants.FIRST_LOGIN_TRUE'),
|
||||||
|
|
||||||
|
// Editable basic info
|
||||||
|
'name' => 'sometimes|required|string|max:255',
|
||||||
|
'mail' => 'sometimes|required|email|unique:user,mail,' . $msnv . ',msnv',
|
||||||
|
'departments' => 'sometimes|required|string|in:' . implode(',', config('constants.DEPARTMENTS')),
|
||||||
|
'avatar' => 'nullable|image|max:2048',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\User;
|
||||||
|
|
||||||
|
use App\Models\Administration;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
class SendThankCardRequest extends FormRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Determine if the user is authorized to make this request.
|
||||||
|
*/
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the validation rules that apply to the request.
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'receiver' => [
|
||||||
|
'required',
|
||||||
|
Rule::exists('user', 'msnv')->where('status', config('constants.STATUS_ACTIVE'))
|
||||||
|
],
|
||||||
|
'amount' => 'required|integer|min:1|max:' . Administration::MAX_SEND_CARD_PER_MONTH,
|
||||||
|
'template_id' => 'nullable|integer|in:1,2,3,4',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle a failed validation attempt.
|
||||||
|
*/
|
||||||
|
protected function failedValidation(\Illuminate\Contracts\Validation\Validator $validator)
|
||||||
|
{
|
||||||
|
throw new \Illuminate\Http\Exceptions\HttpResponseException(
|
||||||
|
response()->json([
|
||||||
|
'success' => false,
|
||||||
|
'errors' => $validator->errors()
|
||||||
|
], 422)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\User;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class UpdatePasswordRequest extends FormRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Determine if the user is authorized to make this request.
|
||||||
|
*/
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the validation rules that apply to the request.
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'password' => 'required|min:6|confirmed',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the custom validation error messages.
|
||||||
|
*/
|
||||||
|
public function messages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'password.required' => 'Mật khẩu mới không được để trống.',
|
||||||
|
'password.min' => 'Mật khẩu mới phải có ít nhất 6 ký tự.',
|
||||||
|
'password.confirmed' => 'Mật khẩu xác nhận không trùng khớp.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\User;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class UpdateProfileRequest extends FormRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Determine if the user is authorized to make this request.
|
||||||
|
*/
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the validation rules that apply to the request.
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'name' => 'required|string|max:255',
|
||||||
|
'avatar' => 'nullable|image|max:2048', // 2MB max
|
||||||
|
'current_password' => [
|
||||||
|
'nullable',
|
||||||
|
'required_with:new_password',
|
||||||
|
function ($attribute, $value, $fail) {
|
||||||
|
if ($value && md5($value) !== auth()->user()->pass) {
|
||||||
|
$fail('Mật khẩu hiện tại không chính xác.');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'new_password' => 'nullable|min:6|confirmed|different:current_password',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the error messages for the defined validation rules.
|
||||||
|
*/
|
||||||
|
public function messages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'name.required' => 'Họ và tên không được để trống.',
|
||||||
|
'name.string' => 'Họ và tên phải là chuỗi ký tự.',
|
||||||
|
'name.max' => 'Họ và tên không được vượt quá 255 ký tự.',
|
||||||
|
'avatar.image' => 'Ảnh đại diện phải là tệp ảnh.',
|
||||||
|
'avatar.max' => 'Ảnh đại diện không được vượt quá 2MB.',
|
||||||
|
'new_password.min' => 'Mật khẩu mới phải có ít nhất 6 ký tự.',
|
||||||
|
'new_password.confirmed' => 'Mật khẩu xác nhận không trùng khớp.',
|
||||||
|
'new_password.different' => 'Mật khẩu mới phải khác mật khẩu hiện tại.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Mail;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Mail\Mailable;
|
||||||
|
use Illuminate\Mail\Mailables\Content;
|
||||||
|
use Illuminate\Mail\Mailables\Envelope;
|
||||||
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
|
||||||
|
class ResetPasswordMail extends Mailable
|
||||||
|
{
|
||||||
|
use Queueable, SerializesModels;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
public User $user,
|
||||||
|
public string $newPassword
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function envelope(): Envelope
|
||||||
|
{
|
||||||
|
return new Envelope(
|
||||||
|
subject: '[ThankCard System] Mật khẩu mới của bạn',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function content(): Content
|
||||||
|
{
|
||||||
|
return new Content(
|
||||||
|
view: 'emails.reset_password',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function attachments(): array
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<?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',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function sellerUser()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class, 'seller', 'msnv');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<?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',
|
||||||
|
'template_id',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function senderUser()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class, 'sender', 'msnv');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function receiverUser()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class, 'receiver', 'msnv');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,48 +2,106 @@
|
|||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
// use Illuminate\Contracts\Auth\MustVerifyEmail;
|
|
||||||
use Database\Factories\UserFactory;
|
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||||
use Illuminate\Notifications\Notifiable;
|
use Illuminate\Notifications\Notifiable;
|
||||||
|
|
||||||
class User extends Authenticatable
|
class User extends Authenticatable
|
||||||
{
|
{
|
||||||
/** @use HasFactory<UserFactory> */
|
|
||||||
use HasFactory, Notifiable;
|
use HasFactory, Notifiable;
|
||||||
|
|
||||||
/**
|
|
||||||
* The attributes that are mass assignable.
|
protected $table = 'user';
|
||||||
*
|
public $timestamps = false;
|
||||||
* @var list<string>
|
protected $primaryKey = 'id';
|
||||||
*/
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
|
'msnv',
|
||||||
'name',
|
'name',
|
||||||
'email',
|
'mail',
|
||||||
'password',
|
'pass',
|
||||||
|
'departments',
|
||||||
|
'avatar',
|
||||||
|
'role',
|
||||||
|
'status',
|
||||||
|
'card',
|
||||||
|
'flag_send',
|
||||||
|
'first_login',
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
|
||||||
* The attributes that should be hidden for serialization.
|
|
||||||
*
|
|
||||||
* @var list<string>
|
|
||||||
*/
|
|
||||||
protected $hidden = [
|
protected $hidden = [
|
||||||
'password',
|
'pass',
|
||||||
'remember_token',
|
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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 [
|
return $this->pass;
|
||||||
'email_verified_at' => 'datetime',
|
}
|
||||||
'password' => 'hashed',
|
|
||||||
];
|
// 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');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the user's avatar URL.
|
||||||
|
* If the user doesn't have an avatar, returns the default avatar.
|
||||||
|
*
|
||||||
|
* @param string|null $value
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
public function getAvatarAttribute($value)
|
||||||
|
{
|
||||||
|
if (!$value || strtolower($value) === 'null') {
|
||||||
|
return asset('images/avatar.png');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return direct URL if it's already an absolute URL (for backward compatibility)
|
||||||
|
if (filter_var($value, FILTER_VALIDATE_URL)) {
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return asset($value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the user's department name.
|
||||||
|
*
|
||||||
|
* @return string|null
|
||||||
|
*/
|
||||||
|
public function getDepartmentNameAttribute()
|
||||||
|
{
|
||||||
|
$deptVal = $this->departments;
|
||||||
|
if (is_numeric($deptVal)) {
|
||||||
|
$idx = ((int)$deptVal) - 1;
|
||||||
|
return config('constants.DEPARTMENTS')[$idx] ?? $deptVal;
|
||||||
|
}
|
||||||
|
return $deptVal;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,23 +2,42 @@
|
|||||||
|
|
||||||
namespace App\Providers;
|
namespace App\Providers;
|
||||||
|
|
||||||
|
use App\Services\Admin\AdminService;
|
||||||
|
use App\Services\Admin\Contracts\AdminServiceInterface;
|
||||||
|
use App\Services\Auth\AuthService;
|
||||||
|
use App\Services\Auth\Contracts\AuthServiceInterface;
|
||||||
|
use App\Services\User\Contracts\UserServiceInterface;
|
||||||
|
use App\Services\User\UserService;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
use Illuminate\Support\ServiceProvider;
|
use Illuminate\Support\ServiceProvider;
|
||||||
|
|
||||||
class AppServiceProvider extends ServiceProvider
|
class AppServiceProvider extends ServiceProvider
|
||||||
{
|
{
|
||||||
/**
|
|
||||||
* Register any application services.
|
|
||||||
*/
|
|
||||||
public function register(): void
|
public function register(): void
|
||||||
{
|
{
|
||||||
//
|
$this->app->bind(AuthServiceInterface::class, AuthService::class);
|
||||||
|
$this->app->bind(AdminServiceInterface::class, AdminService::class);
|
||||||
|
$this->app->bind(UserServiceInterface::class, UserService::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Bootstrap any application services.
|
|
||||||
*/
|
|
||||||
public function boot(): void
|
public function boot(): void
|
||||||
{
|
{
|
||||||
//
|
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)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,335 @@
|
|||||||
|
<?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;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
use App\DTOs\UserFilterDto;
|
||||||
|
|
||||||
|
class AdminService implements AdminServiceInterface
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly \App\Services\User\Contracts\UserServiceInterface $userService
|
||||||
|
) {}
|
||||||
|
public function getUserListWithStats(
|
||||||
|
string|UserFilterDto $monthOrFilters,
|
||||||
|
?string $search = null,
|
||||||
|
?string $status = '1',
|
||||||
|
?string $role = null,
|
||||||
|
?string $department = null,
|
||||||
|
?string $flagSend = null
|
||||||
|
): array {
|
||||||
|
if ($monthOrFilters instanceof UserFilterDto) {
|
||||||
|
$filters = $monthOrFilters;
|
||||||
|
} else {
|
||||||
|
$filters = new UserFilterDto(
|
||||||
|
selectedMonth: $monthOrFilters,
|
||||||
|
search: $search,
|
||||||
|
status: $status,
|
||||||
|
role: $role,
|
||||||
|
department: $department,
|
||||||
|
flagSend: $flagSend
|
||||||
|
);
|
||||||
|
}
|
||||||
|
$selectedMonth = $filters->selectedMonth;
|
||||||
|
$startOfMonth = Carbon::parse($selectedMonth)->startOfMonth();
|
||||||
|
$endOfMonth = Carbon::parse($selectedMonth)->endOfMonth();
|
||||||
|
|
||||||
|
$usersQuery = User::select('user.*')
|
||||||
|
->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])
|
||||||
|
]);
|
||||||
|
|
||||||
|
$activeUsers = (clone $usersQuery)->where('status', config('constants.STATUS_ACTIVE'))->get();
|
||||||
|
|
||||||
|
$maxReceived = $activeUsers->max('total_received') ?? 0;
|
||||||
|
$maxSent = $activeUsers->max('total_sent') ?? 0;
|
||||||
|
|
||||||
|
$topReceivedUsers = $maxReceived > 0
|
||||||
|
? $activeUsers->filter(fn($u) => $u->total_received == $maxReceived)->values()
|
||||||
|
: collect();
|
||||||
|
|
||||||
|
$topSentUsers = $maxSent > 0
|
||||||
|
? $activeUsers->filter(fn($u) => $u->total_sent == $maxSent)->values()
|
||||||
|
: collect();
|
||||||
|
|
||||||
|
$topReceivedUser = $topReceivedUsers->first();
|
||||||
|
$topSentUser = $topSentUsers->first();
|
||||||
|
|
||||||
|
$status = $filters->status;
|
||||||
|
if (!is_null($status) && $status !== '') {
|
||||||
|
$usersQuery->where('status', intval($status));
|
||||||
|
}
|
||||||
|
|
||||||
|
$role = $filters->role;
|
||||||
|
if (!is_null($role) && $role !== '') {
|
||||||
|
$usersQuery->where('role', intval($role));
|
||||||
|
}
|
||||||
|
|
||||||
|
$department = $filters->department;
|
||||||
|
if (!is_null($department) && $department !== '') {
|
||||||
|
$usersQuery->where('departments', $department);
|
||||||
|
}
|
||||||
|
|
||||||
|
$flagSend = $filters->flagSend;
|
||||||
|
if (!is_null($flagSend) && $flagSend !== '') {
|
||||||
|
$usersQuery->where('flag_send', intval($flagSend));
|
||||||
|
}
|
||||||
|
|
||||||
|
$search = $filters->search;
|
||||||
|
if (!is_null($search) && trim($search) !== '') {
|
||||||
|
$search = trim($search);
|
||||||
|
$usersQuery->where(function ($q) use ($search) {
|
||||||
|
$q->whereRaw('LOWER(msnv) LIKE ?', ['%' . strtolower($search) . '%'])
|
||||||
|
->orWhereRaw('LOWER(name) LIKE ?', ['%' . strtolower($search) . '%'])
|
||||||
|
->orWhereRaw('LOWER(mail) LIKE ?', ['%' . strtolower($search) . '%']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
$users = $usersQuery->paginate(20)->withQueryString();
|
||||||
|
|
||||||
|
return compact('users', 'topReceivedUser', 'topSentUser', 'topReceivedUsers', 'topSentUsers');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getUserTransactions(string $msnv, string $selectedMonth): LengthAwarePaginator
|
||||||
|
{
|
||||||
|
$startOfMonth = Carbon::parse($selectedMonth)->startOfMonth();
|
||||||
|
$endOfMonth = Carbon::parse($selectedMonth)->endOfMonth();
|
||||||
|
|
||||||
|
return Administration::with(['senderUser', 'receiverUser'])
|
||||||
|
->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' => config('constants.STATUS_ACTIVE'),
|
||||||
|
'card' => 0,
|
||||||
|
'flag_send' => config('constants.FLAG_SEND_DISABLED'),
|
||||||
|
'first_login' => $data['role'] == config('constants.ROLE_ADMIN') ? config('constants.FIRST_LOGIN_FALSE') : config('constants.FIRST_LOGIN_TRUE'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updateUser(string|User $userOrMsnv, array $data): void
|
||||||
|
{
|
||||||
|
$user = $userOrMsnv instanceof User
|
||||||
|
? $userOrMsnv
|
||||||
|
: User::where('msnv', $userOrMsnv)->firstOrFail();
|
||||||
|
|
||||||
|
DB::transaction(function () use ($data, $user) {
|
||||||
|
// Update profile fields (name & avatar) via UserService
|
||||||
|
$this->userService->updateProfile($user, [
|
||||||
|
'name' => $data['name'] ?? null,
|
||||||
|
], request()->file('avatar'));
|
||||||
|
|
||||||
|
if (isset($data['mail'])) {
|
||||||
|
$user->mail = $data['mail'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($data['departments'])) {
|
||||||
|
$user->departments = $data['departments'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$newCard = $user->card;
|
||||||
|
if (isset($data['num_card'])) {
|
||||||
|
$newCard = $user->card + intval($data['num_card']);
|
||||||
|
} elseif (isset($data['card'])) {
|
||||||
|
$newCard = intval($data['card']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$oldCard = intval($user->card);
|
||||||
|
|
||||||
|
if ($newCard > $oldCard) {
|
||||||
|
$diff = $newCard - $oldCard;
|
||||||
|
$todayRecord = AddCard::where('buyer', $user->msnv)
|
||||||
|
->where('seller', Auth::user()->msnv)
|
||||||
|
->whereDate('date', Carbon::today())
|
||||||
|
->first();
|
||||||
|
if ($todayRecord) {
|
||||||
|
$todayRecord->num_card += $diff;
|
||||||
|
$todayRecord->save();
|
||||||
|
} else {
|
||||||
|
AddCard::create([
|
||||||
|
'buyer' => $user->msnv,
|
||||||
|
'num_card' => $diff,
|
||||||
|
'seller' => Auth::user()->msnv,
|
||||||
|
'date' => Carbon::today(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$user->card = $newCard;
|
||||||
|
$user->role = isset($data['role']) ? intval($data['role']) : $user->role;
|
||||||
|
$user->status = isset($data['status']) ? intval($data['status']) : $user->status;
|
||||||
|
$user->flag_send = isset($data['flag_send']) ? intval($data['flag_send']) : $user->flag_send;
|
||||||
|
$user->first_login = isset($data['first_login']) ? intval($data['first_login']) : $user->first_login;
|
||||||
|
$user->save();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function deactivateUser(string $msnv): void
|
||||||
|
{
|
||||||
|
$user = User::where('msnv', $msnv)->firstOrFail();
|
||||||
|
$user->status = config('constants.STATUS_INACTIVE');
|
||||||
|
$user->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function resetAllCards(): void
|
||||||
|
{
|
||||||
|
$activeUserCount = User::where('status', config('constants.STATUS_ACTIVE'))->count();
|
||||||
|
|
||||||
|
User::where('status', config('constants.STATUS_ACTIVE'))->update([
|
||||||
|
'card' => 0,
|
||||||
|
'flag_send' => config('constants.FLAG_SEND_DISABLED'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
Log::info("Monthly card reset executed successfully for {$activeUserCount} active accounts.");
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updateAddCard(int $id, array $data): void
|
||||||
|
{
|
||||||
|
$addCard = AddCard::findOrFail($id);
|
||||||
|
|
||||||
|
$currentMonth = Carbon::now()->format('Y-m');
|
||||||
|
$recordMonth = Carbon::parse($addCard->date)->format('Y-m');
|
||||||
|
|
||||||
|
if ($recordMonth !== $currentMonth) {
|
||||||
|
throw new \Exception('Không cho phép chỉnh sửa dữ liệu card của các tháng trước.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$newDateMonth = Carbon::parse($data['date'])->format('Y-m');
|
||||||
|
if ($newDateMonth !== $currentMonth) {
|
||||||
|
throw new \Exception('Chỉ được phép chỉnh sửa dữ liệu của tháng hiện tại.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = User::where('msnv', $addCard->buyer)->firstOrFail();
|
||||||
|
|
||||||
|
$startOfMonth = Carbon::now()->startOfMonth();
|
||||||
|
$endOfMonth = Carbon::now()->endOfMonth();
|
||||||
|
$totalSentThisMonth = Administration::where('msnv', $user->msnv)
|
||||||
|
->whereBetween('date', [$startOfMonth, $endOfMonth])
|
||||||
|
->sum('sent');
|
||||||
|
|
||||||
|
DB::transaction(function () use ($addCard, $user, $data, $totalSentThisMonth) {
|
||||||
|
$oldNumCard = intval($addCard->num_card);
|
||||||
|
$newNumCard = intval($data['num_card']);
|
||||||
|
$diff = $newNumCard - $oldNumCard;
|
||||||
|
$newCardBalance = $user->card + $diff;
|
||||||
|
if ($newCardBalance < 0) {
|
||||||
|
throw new \Exception('Số lượng card cập nhật không hợp lệ vì tổng số card của user không được nhỏ hơn 0.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($newCardBalance < $totalSentThisMonth) {
|
||||||
|
throw new \Exception('Số lượng card cập nhật không hợp lệ vì tổng số card của user không được nhỏ hơn số card đã gửi trong tháng (' . $totalSentThisMonth . ' card).');
|
||||||
|
}
|
||||||
|
|
||||||
|
$user->card += $diff;
|
||||||
|
$user->save();
|
||||||
|
|
||||||
|
$targetRecord = AddCard::where('buyer', $addCard->buyer)
|
||||||
|
->where('seller', $data['seller'])
|
||||||
|
->whereDate('date', $data['date'])
|
||||||
|
->where('id', '!=', $addCard->id)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($targetRecord) {
|
||||||
|
$targetRecord->num_card += $newNumCard;
|
||||||
|
$targetRecord->save();
|
||||||
|
$addCard->delete();
|
||||||
|
} else {
|
||||||
|
$addCard->num_card = $newNumCard;
|
||||||
|
$addCard->seller = $data['seller'];
|
||||||
|
$addCard->date = $data['date'];
|
||||||
|
$addCard->save();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroyAddCard(int $id): void
|
||||||
|
{
|
||||||
|
$addCard = AddCard::findOrFail($id);
|
||||||
|
|
||||||
|
$currentMonth = Carbon::now()->format('Y-m');
|
||||||
|
$recordMonth = Carbon::parse($addCard->date)->format('Y-m');
|
||||||
|
|
||||||
|
if ($recordMonth !== $currentMonth) {
|
||||||
|
throw new \Exception('Không cho phép xóa dữ liệu card của các tháng trước.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = User::where('msnv', $addCard->buyer)->firstOrFail();
|
||||||
|
|
||||||
|
$startOfMonth = Carbon::now()->startOfMonth();
|
||||||
|
$endOfMonth = Carbon::now()->endOfMonth();
|
||||||
|
$totalSentThisMonth = Administration::where('msnv', $user->msnv)
|
||||||
|
->whereBetween('date', [$startOfMonth, $endOfMonth])
|
||||||
|
->sum('sent');
|
||||||
|
|
||||||
|
DB::transaction(function () use ($addCard, $user, $totalSentThisMonth) {
|
||||||
|
$newCardBalance = $user->card - intval($addCard->num_card);
|
||||||
|
|
||||||
|
if ($newCardBalance < 0) {
|
||||||
|
throw new \Exception('Không thể xóa lịch sử cấp phát thẻ này vì tổng số card của user không được nhỏ hơn 0.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($newCardBalance < $totalSentThisMonth) {
|
||||||
|
throw new \Exception('Không thể xóa lịch sử cấp phát thẻ này vì tổng số card của user không được nhỏ hơn số card đã gửi trong tháng (' . $totalSentThisMonth . ' card).');
|
||||||
|
}
|
||||||
|
|
||||||
|
$user->card = $newCardBalance;
|
||||||
|
$user->save();
|
||||||
|
|
||||||
|
$addCard->delete();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getUserByMsnv(string $msnv): User
|
||||||
|
{
|
||||||
|
return User::where('msnv', $msnv)->firstOrFail();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getAddCardsHistory(string $buyerMsnv, ?string $selectedMonth = null): \Illuminate\Database\Eloquent\Collection
|
||||||
|
{
|
||||||
|
$query = AddCard::with('sellerUser')
|
||||||
|
->where('buyer', $buyerMsnv);
|
||||||
|
|
||||||
|
if ($selectedMonth) {
|
||||||
|
$startOfMonth = Carbon::parse($selectedMonth)->startOfMonth()->format('Y-m-d');
|
||||||
|
$endOfMonth = Carbon::parse($selectedMonth)->endOfMonth()->format('Y-m-d');
|
||||||
|
$query->whereBetween('date', [$startOfMonth, $endOfMonth]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $query->orderBy('date', 'desc')
|
||||||
|
->orderBy('id', 'desc')
|
||||||
|
->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getActiveAdmins(): \Illuminate\Database\Eloquent\Collection
|
||||||
|
{
|
||||||
|
return User::where('role', config('constants.ROLE_ADMIN'))
|
||||||
|
->where('status', config('constants.STATUS_ACTIVE'))
|
||||||
|
->get();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Admin\Contracts;
|
||||||
|
|
||||||
|
use Illuminate\Pagination\LengthAwarePaginator;
|
||||||
|
|
||||||
|
interface AdminServiceInterface
|
||||||
|
{
|
||||||
|
public function getUserListWithStats(
|
||||||
|
string|\App\DTOs\UserFilterDto $monthOrFilters,
|
||||||
|
?string $search = null,
|
||||||
|
?string $status = '1',
|
||||||
|
?string $role = null,
|
||||||
|
?string $department = null,
|
||||||
|
?string $flagSend = null
|
||||||
|
): array;
|
||||||
|
|
||||||
|
public function getUserTransactions(string $msnv, string $selectedMonth): LengthAwarePaginator;
|
||||||
|
|
||||||
|
public function createUser(array $data): void;
|
||||||
|
|
||||||
|
public function updateUser(string|\App\Models\User $userOrMsnv, array $data): void;
|
||||||
|
|
||||||
|
public function deactivateUser(string $msnv): void;
|
||||||
|
|
||||||
|
public function resetAllCards(): void;
|
||||||
|
|
||||||
|
public function updateAddCard(int $id, array $data): void;
|
||||||
|
|
||||||
|
public function destroyAddCard(int $id): void;
|
||||||
|
|
||||||
|
public function getUserByMsnv(string $msnv): \App\Models\User;
|
||||||
|
|
||||||
|
public function getAddCardsHistory(string $buyerMsnv, ?string $selectedMonth = null): \Illuminate\Database\Eloquent\Collection;
|
||||||
|
|
||||||
|
public function getActiveAdmins(): \Illuminate\Database\Eloquent\Collection;
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
<?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 != config('constants.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->role == config('constants.ROLE_ADMIN')) {
|
||||||
|
return route('admin.dashboard');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($user->first_login == config('constants.FIRST_LOGIN_TRUE')) {
|
||||||
|
return route('user.change_password');
|
||||||
|
}
|
||||||
|
|
||||||
|
return route('user.dashboard');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function forgotPassword(string $email): array
|
||||||
|
{
|
||||||
|
$email = \Illuminate\Support\Str::lower(trim($email));
|
||||||
|
$user = User::where('mail', $email)->first();
|
||||||
|
|
||||||
|
if (!$user) {
|
||||||
|
return ['success' => false, 'error_msg' => config('constants.MSG_FORGOT_PWD_EMAIL_NOT_FOUND')];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($user->status != config('constants.STATUS_ACTIVE')) {
|
||||||
|
return ['success' => false, 'error_msg' => config('constants.MSG_FORGOT_PWD_ACCOUNT_INACTIVE')];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate random password (8 characters)
|
||||||
|
$newPassword = \Illuminate\Support\Str::random(8);
|
||||||
|
|
||||||
|
// Update password with md5 and set first_login flag to 1
|
||||||
|
$user->pass = md5($newPassword);
|
||||||
|
$user->first_login = config('constants.FIRST_LOGIN_TRUE');
|
||||||
|
$user->save();
|
||||||
|
|
||||||
|
// Send Email
|
||||||
|
try {
|
||||||
|
\Illuminate\Support\Facades\Mail::to($user->mail)->send(new \App\Mail\ResetPasswordMail($user, $newPassword));
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
\Illuminate\Support\Facades\Log::error('Failed to send reset password email: ' . $e->getMessage());
|
||||||
|
return ['success' => false, 'error_msg' => config('constants.MSG_FORGOT_PWD_MAIL_FAILED')];
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['success' => true, 'message' => config('constants.MSG_FORGOT_PWD_SUCCESS')];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<?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;
|
||||||
|
|
||||||
|
public function forgotPassword(string $email): array;
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<?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, ?int $templateId = 1): 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;
|
||||||
|
|
||||||
|
public function updateProfile(User $user, array $data, ?\Illuminate\Http\UploadedFile $avatarFile = null): void;
|
||||||
|
|
||||||
|
public function getSentCardsToUserThisMonth(User $sender, string $receiverMsnv): int;
|
||||||
|
|
||||||
|
public function getReceivedPageData(User $user, string $selectedMonth, int $limit = 12, int $offset = 0): array;
|
||||||
|
|
||||||
|
public function getSentPageData(User $user, string $selectedMonth, int $limit = 12, int $offset = 0): array;
|
||||||
|
|
||||||
|
public function getReceivedFeed(User $user, string $selectedMonth, int $limit = 12, int $offset = 0): array;
|
||||||
|
|
||||||
|
public function getSentFeed(User $user, string $selectedMonth, int $limit = 12, int $offset = 0): array;
|
||||||
|
}
|
||||||
@@ -0,0 +1,351 @@
|
|||||||
|
<?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::with(['senderUser', 'receiverUser'])
|
||||||
|
->where('msnv', $user->msnv)
|
||||||
|
->whereBetween('date', [$startOfMonth, $endOfMonth])
|
||||||
|
->orderBy('date', 'desc')
|
||||||
|
->paginate(10)
|
||||||
|
->withQueryString();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getOtherActiveUsers(User $currentUser): Collection
|
||||||
|
{
|
||||||
|
return User::where('status', config('constants.STATUS_ACTIVE'))
|
||||||
|
->where('msnv', '!=', $currentUser->msnv)
|
||||||
|
->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function sendThankcards(User $sender, string $receiverMsnv, int $amount, ?int $templateId = 1): void
|
||||||
|
{
|
||||||
|
if ($sender->flag_send == config('constants.FLAG_SEND_DISABLED')) {
|
||||||
|
throw new \App\Exceptions\ThankCardException(__('messages.error.no_send_permission'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$receiver = User::where('msnv', $receiverMsnv)->first();
|
||||||
|
if (!$receiver || $receiver->status != config('constants.STATUS_ACTIVE')) {
|
||||||
|
throw new \App\Exceptions\ThankCardException(__('messages.error.receiver_invalid'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$startOfMonth = Carbon::now()->startOfMonth();
|
||||||
|
$endOfMonth = Carbon::now()->endOfMonth();
|
||||||
|
|
||||||
|
DB::transaction(function () use ($sender, $receiverMsnv, $amount, $startOfMonth, $endOfMonth, $templateId) {
|
||||||
|
// Lock sender record to prevent concurrent transaction modifications
|
||||||
|
$lockedSender = User::where('id', $sender->id)->lockForUpdate()->first();
|
||||||
|
|
||||||
|
if ($lockedSender->flag_send == config('constants.FLAG_SEND_DISABLED')) {
|
||||||
|
throw new \App\Exceptions\ThankCardException(__('messages.error.cannot_send_card'));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($lockedSender->card < $amount) {
|
||||||
|
throw new \App\Exceptions\ThankCardException(__('messages.error.cannot_send_card'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lock & check total sent cards to the receiver this month
|
||||||
|
$cardsSentToThisUserThisMonth = Administration::where('msnv', $lockedSender->msnv)
|
||||||
|
->where('receiver', $receiverMsnv)
|
||||||
|
->whereBetween('date', [$startOfMonth, $endOfMonth])
|
||||||
|
->lockForUpdate()
|
||||||
|
->sum('sent');
|
||||||
|
|
||||||
|
if ($cardsSentToThisUserThisMonth + $amount > Administration::MAX_SEND_CARD_PER_MONTH) {
|
||||||
|
throw new \App\Exceptions\ThankCardException(
|
||||||
|
__('messages.error.max_send_limit_template', [
|
||||||
|
'max' => Administration::MAX_SEND_CARD_PER_MONTH,
|
||||||
|
'sent' => $cardsSentToThisUserThisMonth
|
||||||
|
])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Administration::create([
|
||||||
|
'msnv' => $receiverMsnv,
|
||||||
|
'received' => $amount,
|
||||||
|
'sender' => $lockedSender->msnv,
|
||||||
|
'sent' => 0,
|
||||||
|
'receiver' => null,
|
||||||
|
'date' => Carbon::today(),
|
||||||
|
'template_id' => $templateId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Administration::create([
|
||||||
|
'msnv' => $lockedSender->msnv,
|
||||||
|
'received' => 0,
|
||||||
|
'sender' => null,
|
||||||
|
'sent' => $amount,
|
||||||
|
'receiver' => $receiverMsnv,
|
||||||
|
'date' => Carbon::today(),
|
||||||
|
'template_id' => $templateId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$lockedSender->card -= $amount;
|
||||||
|
if ($lockedSender->card <= 0) {
|
||||||
|
$lockedSender->card = 0;
|
||||||
|
$lockedSender->flag_send = config('constants.FLAG_SEND_DISABLED');
|
||||||
|
}
|
||||||
|
$lockedSender->save();
|
||||||
|
|
||||||
|
// Synchronize variables back to the original model instance
|
||||||
|
$sender->card = $lockedSender->card;
|
||||||
|
$sender->flag_send = $lockedSender->flag_send;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updatePassword(User $user, string $newPassword): void
|
||||||
|
{
|
||||||
|
$user->pass = md5($newPassword);
|
||||||
|
$user->first_login = config('constants.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', config('constants.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', config('constants.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Include every ranked user (no hard limit)
|
||||||
|
$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
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retrieve paginated list of received ThankCards for a user
|
||||||
|
public function getReceivedPageData(User $user, string $selectedMonth, int $limit = 12, int $offset = 0): array
|
||||||
|
{
|
||||||
|
$start = Carbon::parse($selectedMonth)->startOfMonth();
|
||||||
|
$end = Carbon::parse($selectedMonth)->endOfMonth();
|
||||||
|
|
||||||
|
$statsQuery = Administration::where('msnv', $user->msnv)
|
||||||
|
->whereBetween('date', [$start, $end])
|
||||||
|
->where('received', '>', 0);
|
||||||
|
|
||||||
|
$totalCards = (int) $statsQuery->sum('received');
|
||||||
|
$totalSenders = (int) $statsQuery->whereNotNull('sender')->distinct('sender')->count('sender');
|
||||||
|
|
||||||
|
$cards = Administration::with('senderUser')
|
||||||
|
->where('msnv', $user->msnv)
|
||||||
|
->whereBetween('date', [$start, $end])
|
||||||
|
->where('received', '>', 0)
|
||||||
|
->orderBy('date', 'desc')
|
||||||
|
->skip($offset)
|
||||||
|
->take($limit)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$hasMore = Administration::where('msnv', $user->msnv)
|
||||||
|
->whereBetween('date', [$start, $end])
|
||||||
|
->where('received', '>', 0)
|
||||||
|
->skip($offset + $limit)
|
||||||
|
->take(1)
|
||||||
|
->exists();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'cards' => $cards,
|
||||||
|
'hasMore' => $hasMore,
|
||||||
|
'totalCards' => $totalCards,
|
||||||
|
'totalSenders' => $totalSenders
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retrieve paginated list of sent ThankCards for a user
|
||||||
|
public function getSentPageData(User $user, string $selectedMonth, int $limit = 12, int $offset = 0): array
|
||||||
|
{
|
||||||
|
$start = Carbon::parse($selectedMonth)->startOfMonth();
|
||||||
|
$end = Carbon::parse($selectedMonth)->endOfMonth();
|
||||||
|
|
||||||
|
$statsQuery = Administration::where('msnv', $user->msnv)
|
||||||
|
->whereBetween('date', [$start, $end])
|
||||||
|
->where('sent', '>', 0);
|
||||||
|
|
||||||
|
$totalCards = (int) $statsQuery->sum('sent');
|
||||||
|
$totalReceivers = (int) $statsQuery->whereNotNull('receiver')->distinct('receiver')->count('receiver');
|
||||||
|
|
||||||
|
$cards = Administration::with('receiverUser')
|
||||||
|
->where('msnv', $user->msnv)
|
||||||
|
->whereBetween('date', [$start, $end])
|
||||||
|
->where('sent', '>', 0)
|
||||||
|
->orderBy('date', 'desc')
|
||||||
|
->skip($offset)
|
||||||
|
->take($limit)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$hasMore = Administration::where('msnv', $user->msnv)
|
||||||
|
->whereBetween('date', [$start, $end])
|
||||||
|
->where('sent', '>', 0)
|
||||||
|
->skip($offset + $limit)
|
||||||
|
->take(1)
|
||||||
|
->exists();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'cards' => $cards,
|
||||||
|
'hasMore' => $hasMore,
|
||||||
|
'totalCards' => $totalCards,
|
||||||
|
'totalReceivers' => $totalReceivers
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retrieve limited feed of received ThankCards for activity feed
|
||||||
|
public function getReceivedFeed(User $user, string $selectedMonth, int $limit = 12, int $offset = 0): array
|
||||||
|
{
|
||||||
|
return $this->getReceivedPageData($user, $selectedMonth, $limit, $offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retrieve limited feed of sent ThankCards for activity feed
|
||||||
|
public function getSentFeed(User $user, string $selectedMonth, int $limit = 12, int $offset = 0): array
|
||||||
|
{
|
||||||
|
return $this->getSentPageData($user, $selectedMonth, $limit, $offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updateProfile(User $user, array $data, ?\Illuminate\Http\UploadedFile $avatarFile = null): void
|
||||||
|
{
|
||||||
|
if (isset($data['name'])) {
|
||||||
|
$user->name = $data['name'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($avatarFile) {
|
||||||
|
$path = $avatarFile->store('avatars', 'public');
|
||||||
|
$user->avatar = 'storage/' . $path;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($data['new_password'])) {
|
||||||
|
$user->pass = md5($data['new_password']);
|
||||||
|
$user->first_login = config('constants.FIRST_LOGIN_FALSE');
|
||||||
|
}
|
||||||
|
|
||||||
|
$user->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getSentCardsToUserThisMonth(User $sender, string $receiverMsnv): int
|
||||||
|
{
|
||||||
|
$startOfMonth = Carbon::now()->startOfMonth();
|
||||||
|
$endOfMonth = Carbon::now()->endOfMonth();
|
||||||
|
|
||||||
|
return (int) Administration::where('msnv', $sender->msnv)
|
||||||
|
->where('receiver', $receiverMsnv)
|
||||||
|
->whereBetween('date', [$startOfMonth, $endOfMonth])
|
||||||
|
->sum('sent');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,16 +3,32 @@
|
|||||||
use Illuminate\Foundation\Application;
|
use Illuminate\Foundation\Application;
|
||||||
use Illuminate\Foundation\Configuration\Exceptions;
|
use Illuminate\Foundation\Configuration\Exceptions;
|
||||||
use Illuminate\Foundation\Configuration\Middleware;
|
use Illuminate\Foundation\Configuration\Middleware;
|
||||||
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
return Application::configure(basePath: dirname(__DIR__))
|
return Application::configure(basePath: dirname(__DIR__))
|
||||||
->withRouting(
|
->withRouting(
|
||||||
web: __DIR__.'/../routes/web.php',
|
web: __DIR__.'/../routes/web.php',
|
||||||
commands: __DIR__.'/../routes/console.php',
|
commands: __DIR__.'/../routes/console.php',
|
||||||
health: '/up',
|
health: '/up',
|
||||||
|
then: function () {
|
||||||
|
Route::middleware('web')
|
||||||
|
->group(base_path('routes/admin.php'));
|
||||||
|
},
|
||||||
)
|
)
|
||||||
->withMiddleware(function (Middleware $middleware): void {
|
->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 {
|
->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();
|
})->create();
|
||||||
|
|||||||
@@ -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!');
|
||||||
|
})();
|
||||||
@@ -78,7 +78,7 @@ return [
|
|||||||
|
|
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
'locale' => env('APP_LOCALE', 'en'),
|
'locale' => env('APP_LOCALE', 'vi'),
|
||||||
|
|
||||||
'fallback_locale' => env('APP_FALLBACK_LOCALE', 'en'),
|
'fallback_locale' => env('APP_FALLBACK_LOCALE', 'en'),
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
1 => [
|
||||||
|
'id' => 1,
|
||||||
|
'slug' => 'thu_cam_on',
|
||||||
|
'name' => 'Thư cảm ơn',
|
||||||
|
'label' => 'THANK CARD',
|
||||||
|
'icon' => '💌',
|
||||||
|
'deco_bg' => 'linear-gradient(135deg,#FFF5CC,#FFE8A0)',
|
||||||
|
'card_bg' => 'linear-gradient(145deg,#FFF9EC,#FFFDF7)',
|
||||||
|
'border_color' => '#FFE4A0',
|
||||||
|
'blob1' => '#FFD066',
|
||||||
|
'blob2' => '#FFAD00',
|
||||||
|
'accent_color' => '#B8860B',
|
||||||
|
'badge_bg' => 'rgba(255,176,0,0.15)',
|
||||||
|
'wave' => 'white',
|
||||||
|
'footer_border' => '#FFE4A0',
|
||||||
|
'thumb' => 'from-[#FFF9EC] to-[#FFF5DD]',
|
||||||
|
'thumb_accent' => '#FFD066',
|
||||||
|
],
|
||||||
|
2 => [
|
||||||
|
'id' => 2,
|
||||||
|
'slug' => 'sakura',
|
||||||
|
'name' => 'Sakura',
|
||||||
|
'label' => 'SAKURA CARD',
|
||||||
|
'icon' => '🌸',
|
||||||
|
'deco_bg' => 'linear-gradient(135deg,#FFE8F4,#FFD6EC)',
|
||||||
|
'card_bg' => 'linear-gradient(145deg,#FFF8FB,#FFF3F8)',
|
||||||
|
'border_color' => '#FFBCD9',
|
||||||
|
'blob1' => '#F9A8D4',
|
||||||
|
'blob2' => '#F472B6',
|
||||||
|
'accent_color' => '#be185d',
|
||||||
|
'badge_bg' => 'rgba(244,114,182,0.12)',
|
||||||
|
'wave' => 'white',
|
||||||
|
'footer_border' => '#FFBCD9',
|
||||||
|
'thumb' => 'from-[#FFF5F9] to-[#FFECF4]',
|
||||||
|
'thumb_accent' => '#F9A8D4',
|
||||||
|
],
|
||||||
|
3 => [
|
||||||
|
'id' => 3,
|
||||||
|
'slug' => 'appreciation',
|
||||||
|
'name' => 'Appreciation',
|
||||||
|
'label' => 'APPRECIATION',
|
||||||
|
'icon' => '⭐',
|
||||||
|
'deco_bg' => 'linear-gradient(135deg,#DBEAFE,#BFDBFE)',
|
||||||
|
'card_bg' => 'linear-gradient(145deg,#F0F8FF,#EAF3FF)',
|
||||||
|
'border_color' => '#BFDBFE',
|
||||||
|
'blob1' => '#93C5FD',
|
||||||
|
'blob2' => '#60A5FA',
|
||||||
|
'accent_color' => '#1d4ed8',
|
||||||
|
'badge_bg' => 'rgba(96,165,250,0.12)',
|
||||||
|
'wave' => 'white',
|
||||||
|
'footer_border' => '#BFDBFE',
|
||||||
|
'thumb' => 'from-[#EFF8FF] to-[#DBEAFE]',
|
||||||
|
'thumb_accent' => '#93C5FD',
|
||||||
|
],
|
||||||
|
4 => [
|
||||||
|
'id' => 4,
|
||||||
|
'slug' => 'celebration',
|
||||||
|
'name' => 'Celebration',
|
||||||
|
'label' => 'CELEBRATION',
|
||||||
|
'icon' => '🎉',
|
||||||
|
'deco_bg' => 'linear-gradient(135deg,#EDE9FE,#DDD6FE)',
|
||||||
|
'card_bg' => 'linear-gradient(145deg,#F8F5FF,#F3EFFF)',
|
||||||
|
'border_color' => '#DDD6FE',
|
||||||
|
'blob1' => '#C4B5FD',
|
||||||
|
'blob2' => '#A78BFA',
|
||||||
|
'accent_color' => '#6d28d9',
|
||||||
|
'badge_bg' => 'rgba(167,139,250,0.12)',
|
||||||
|
'wave' => 'white',
|
||||||
|
'footer_border' => '#DDD6FE',
|
||||||
|
'thumb' => 'from-[#F5F0FF] to-[#EDE9FE]',
|
||||||
|
'thumb_accent' => '#C4B5FD',
|
||||||
|
]
|
||||||
|
];
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
'ROLE_MEMBER' => 0,
|
||||||
|
'ROLE_ADMIN' => 1,
|
||||||
|
|
||||||
|
'STATUS_INACTIVE' => 0,
|
||||||
|
'STATUS_ACTIVE' => 1,
|
||||||
|
|
||||||
|
'FLAG_SEND_DISABLED' => 0,
|
||||||
|
'FLAG_SEND_ENABLED' => 1,
|
||||||
|
|
||||||
|
'FIRST_LOGIN_FALSE' => 0,
|
||||||
|
'FIRST_LOGIN_TRUE' => 1,
|
||||||
|
|
||||||
|
'TEMPLATE_THU_CAM_ON' => 1,
|
||||||
|
'TEMPLATE_SAKURA' => 2,
|
||||||
|
'TEMPLATE_APPRECIATION' => 3,
|
||||||
|
'TEMPLATE_CELEBRATION' => 4,
|
||||||
|
|
||||||
|
'DEPARTMENTS' => [
|
||||||
|
'BIZ-IID - Internet Infra Business Division / BIZ-IID - Sales Enterprise Team (HCM)',
|
||||||
|
'BIZ-ITOVN - Vietnam Business Division / BIZ-ITOVN - Ho Chi Minh',
|
||||||
|
'BIZ-SSD - Smart Solutions Business Division / BIZ-SSD - Ho Chi Minh',
|
||||||
|
'CoE - Center of Excellence / CoE - Data (Ho Chi Minh)',
|
||||||
|
'CoE - Center of Excellence / CoE - R&D (Ho Chi Minh)',
|
||||||
|
'CoE - Center of Excellence / CoE - UI/UX (Ho Chi Minh)',
|
||||||
|
'DL-ITOHCM - ITO Delivery Division (HCM Branch)',
|
||||||
|
'DL-ITOHCM - ITO Delivery Division (HCM Branch) / DL-ITOHCM - BrSE Team',
|
||||||
|
'DL-ITOHCM - ITO Delivery Division (HCM Branch) / DL-ITOHCM-DU1 - Delivery Unit 1',
|
||||||
|
'DL-ITOHCM - ITO Delivery Division (HCM Branch) / DL-ITOHCM-DU2 - Delivery Unit 2',
|
||||||
|
'DL-ITOHCM - ITO Delivery Division (HCM Branch) / DL-ITOHCM-DU3 - Delivery Unit 3',
|
||||||
|
'DL-ITOHCM - ITO Delivery Division (HCM Branch) / DL-ITOHCM-DU4 - Delivery Unit 4',
|
||||||
|
'DL-SSD - Smart Solutions Delivery Division / DL-SSD - Ho Chi Minh',
|
||||||
|
'FC-HRD - Human Resources Division / FC-HRD - Ho Chi Minh',
|
||||||
|
'FC-RMD - Risk Management Division / FC-RMD - Ho Chi Minh'
|
||||||
|
],
|
||||||
|
|
||||||
|
'CHATOPS_API_URL' => env('CHATOPS_API_URL', 'http://10.1.12.160/aiapi/sent-message-user'),
|
||||||
|
'CHATOPS_API_TOKEN' => env('CHATOPS_API_TOKEN', 'ifOgk13pXIa6p2q6vvJB'),
|
||||||
|
|
||||||
|
'MSG_FORGOT_PWD_EMAIL_REQUIRED' => 'Vui lòng nhập địa chỉ email.',
|
||||||
|
'MSG_FORGOT_PWD_EMAIL_INVALID' => 'Địa chỉ email không đúng định dạng.',
|
||||||
|
'MSG_FORGOT_PWD_EMAIL_NOT_FOUND' => 'Email không tồn tại trong hệ thống. Vui lòng kiểm tra lại.',
|
||||||
|
'MSG_FORGOT_PWD_ACCOUNT_INACTIVE' => 'Tài khoản của bạn đã bị khóa hoặc ngừng hoạt động. Vui lòng liên hệ quản trị viên.',
|
||||||
|
'MSG_FORGOT_PWD_MAIL_FAILED' => 'Không thể gửi email lúc này. Vui lòng thử lại sau.',
|
||||||
|
'MSG_FORGOT_PWD_SUCCESS' => 'Mật khẩu mới đã được gửi tới email của bạn. Vui lòng kiểm tra hòm thư!',
|
||||||
|
];
|
||||||
@@ -63,14 +63,16 @@ return [
|
|||||||
'path' => storage_path('logs/laravel.log'),
|
'path' => storage_path('logs/laravel.log'),
|
||||||
'level' => env('LOG_LEVEL', 'debug'),
|
'level' => env('LOG_LEVEL', 'debug'),
|
||||||
'replace_placeholders' => true,
|
'replace_placeholders' => true,
|
||||||
|
'permission' => 0666,
|
||||||
],
|
],
|
||||||
|
|
||||||
'daily' => [
|
'daily' => [
|
||||||
'driver' => 'daily',
|
'driver' => 'daily',
|
||||||
'path' => storage_path('logs/laravel.log'),
|
'path' => storage_path('logs/laravel.log'),
|
||||||
'level' => env('LOG_LEVEL', 'debug'),
|
'level' => env('LOG_LEVEL', 'debug'),
|
||||||
'days' => env('LOG_DAILY_DAYS', 14),
|
'days' => env('LOG_DAILY_DAYS', 60),
|
||||||
'replace_placeholders' => true,
|
'replace_placeholders' => true,
|
||||||
|
'permission' => 0666,
|
||||||
],
|
],
|
||||||
|
|
||||||
'slack' => [
|
'slack' => [
|
||||||
@@ -123,6 +125,13 @@ return [
|
|||||||
'handler' => NullHandler::class,
|
'handler' => NullHandler::class,
|
||||||
],
|
],
|
||||||
|
|
||||||
|
'query' => [
|
||||||
|
'driver' => 'single',
|
||||||
|
'path' => storage_path('logs/query.log'),
|
||||||
|
'level' => 'debug',
|
||||||
|
'permission' => 0666,
|
||||||
|
],
|
||||||
|
|
||||||
'emergency' => [
|
'emergency' => [
|
||||||
'path' => storage_path('logs/laravel.log'),
|
'path' => storage_path('logs/laravel.log'),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ return [
|
|||||||
|
|
||||||
'from' => [
|
'from' => [
|
||||||
'address' => env('MAIL_FROM_ADDRESS', 'hello@example.com'),
|
'address' => env('MAIL_FROM_ADDRESS', 'hello@example.com'),
|
||||||
'name' => env('MAIL_FROM_NAME', env('APP_NAME', 'Laravel')),
|
'name' => env('MAIL_FROM_NAME', 'Thanks Card'),
|
||||||
],
|
],
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -11,30 +11,22 @@ return new class extends Migration
|
|||||||
*/
|
*/
|
||||||
public function up(): void
|
public function up(): void
|
||||||
{
|
{
|
||||||
Schema::create('users', function (Blueprint $table) {
|
Schema::create('user', function (Blueprint $table) {
|
||||||
$table->id();
|
$table->increments('id');
|
||||||
|
$table->integer('msnv')->unique();
|
||||||
$table->string('name');
|
$table->string('name');
|
||||||
$table->string('email')->unique();
|
$table->string('mail');
|
||||||
$table->timestamp('email_verified_at')->nullable();
|
$table->string('pass');
|
||||||
$table->string('password');
|
$table->string('departments');
|
||||||
$table->rememberToken();
|
$table->string('avatar')->nullable();
|
||||||
$table->timestamps();
|
$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) {
|
// Only user table remains
|
||||||
$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();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -42,8 +34,6 @@ return new class extends Migration
|
|||||||
*/
|
*/
|
||||||
public function down(): void
|
public function down(): void
|
||||||
{
|
{
|
||||||
Schema::dropIfExists('users');
|
Schema::dropIfExists('user');
|
||||||
Schema::dropIfExists('password_reset_tokens');
|
|
||||||
Schema::dropIfExists('sessions');
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<?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::table('administration', function (Blueprint $table) {
|
||||||
|
$table->integer('template_id')->nullable()->default(1)->after('date');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('administration', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('template_id');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -15,11 +15,8 @@ class DatabaseSeeder extends Seeder
|
|||||||
*/
|
*/
|
||||||
public function run(): void
|
public function run(): void
|
||||||
{
|
{
|
||||||
// User::factory(10)->create();
|
$this->call([
|
||||||
|
MockDataSeeder::class,
|
||||||
User::factory()->create([
|
|
||||||
'name' => 'Test User',
|
|
||||||
'email' => 'test@example.com',
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
<?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');
|
||||||
|
$depts = config('constants.DEPARTMENTS');
|
||||||
|
|
||||||
|
// 1. CREATE USERS
|
||||||
|
// Admins
|
||||||
|
User::create([
|
||||||
|
'msnv' => 9999,
|
||||||
|
'name' => 'System Admin',
|
||||||
|
'mail' => 'admin@runsystem.net',
|
||||||
|
'pass' => $password,
|
||||||
|
'departments' => $depts[0],
|
||||||
|
'role' => config('constants.ROLE_ADMIN'),
|
||||||
|
'status' => config('constants.STATUS_ACTIVE'),
|
||||||
|
'card' => 0,
|
||||||
|
'flag_send' => config('constants.FLAG_SEND_ENABLED'),
|
||||||
|
'first_login' => config('constants.FIRST_LOGIN_FALSE'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
User::create([
|
||||||
|
'msnv' => 9998,
|
||||||
|
'name' => 'Second Admin',
|
||||||
|
'mail' => 'admin2@runsystem.net',
|
||||||
|
'pass' => $password,
|
||||||
|
'departments' => $depts[0],
|
||||||
|
'role' => config('constants.ROLE_ADMIN'),
|
||||||
|
'status' => config('constants.STATUS_ACTIVE'),
|
||||||
|
'card' => 0,
|
||||||
|
'flag_send' => config('constants.FLAG_SEND_ENABLED'),
|
||||||
|
'first_login' => config('constants.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' => config('constants.FLAG_SEND_ENABLED'), 'first_login' => config('constants.FIRST_LOGIN_FALSE')],
|
||||||
|
['msnv' => 1002, 'name' => 'Trần Thị Ngọc', 'mail' => 'tranthingoc@runsystem.net', 'card' => 3, 'flag' => config('constants.FLAG_SEND_ENABLED'), 'first_login' => config('constants.FIRST_LOGIN_FALSE')],
|
||||||
|
['msnv' => 1003, 'name' => 'Lê Kim Thư', 'mail' => 'lekiemthu@runsystem.net', 'card' => 6, 'flag' => config('constants.FLAG_SEND_ENABLED'), 'first_login' => config('constants.FIRST_LOGIN_TRUE')], // First-time login
|
||||||
|
['msnv' => 1004, 'name' => 'Phòng Nhân Sự', 'mail' => 'phongnhansu@runsystem.net', 'card' => 0, 'flag' => config('constants.FLAG_SEND_DISABLED'), 'first_login' => config('constants.FIRST_LOGIN_FALSE')], // Disabled sending permission
|
||||||
|
['msnv' => 1005, 'name' => 'Bản Giang', 'mail' => 'bangiang@runsystem.net', 'card' => 36, 'flag' => config('constants.FLAG_SEND_ENABLED'), 'first_login' => config('constants.FIRST_LOGIN_FALSE')],
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($users as $idx => $u) {
|
||||||
|
User::create([
|
||||||
|
'msnv' => $u['msnv'],
|
||||||
|
'name' => $u['name'],
|
||||||
|
'mail' => $u['mail'],
|
||||||
|
'pass' => $password,
|
||||||
|
'departments' => $depts[$idx % count($depts)],
|
||||||
|
'role' => config('constants.ROLE_MEMBER'),
|
||||||
|
'status' => config('constants.STATUS_ACTIVE'),
|
||||||
|
'card' => $u['card'],
|
||||||
|
'flag_send' => $u['flag'],
|
||||||
|
'first_login' => $u['first_login'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inactive User
|
||||||
|
User::create([
|
||||||
|
'msnv' => 9997,
|
||||||
|
'name' => 'Nghi Duy',
|
||||||
|
'mail' => 'nghiduy@runsystem.net',
|
||||||
|
'pass' => $password,
|
||||||
|
'departments' => $depts[1],
|
||||||
|
'role' => config('constants.ROLE_MEMBER'),
|
||||||
|
'status' => config('constants.STATUS_INACTIVE'),
|
||||||
|
'card' => 0,
|
||||||
|
'flag_send' => config('constants.FLAG_SEND_DISABLED'),
|
||||||
|
'first_login' => config('constants.FIRST_LOGIN_FALSE'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 2. CREATE MOCK TRANSACTIONS
|
||||||
|
// Nguyễn Văn A (1001) receives 3 cards from Trần Thị Ngọc (1002)
|
||||||
|
Administration::create([
|
||||||
|
'msnv' => '1001',
|
||||||
|
'received' => 3,
|
||||||
|
'sender' => '1002',
|
||||||
|
'sent' => 0,
|
||||||
|
'receiver' => null,
|
||||||
|
'date' => Carbon::now()->format('Y-m-d'),
|
||||||
|
]);
|
||||||
|
Administration::create([
|
||||||
|
'msnv' => '1002',
|
||||||
|
'received' => 0,
|
||||||
|
'sender' => null,
|
||||||
|
'sent' => 3,
|
||||||
|
'receiver' => '1001',
|
||||||
|
'date' => Carbon::now()->format('Y-m-d'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Nguyễn Văn A (1001) receives 2 cards from Bản Giang (1005)
|
||||||
|
Administration::create([
|
||||||
|
'msnv' => '1001',
|
||||||
|
'received' => 2,
|
||||||
|
'sender' => '1005',
|
||||||
|
'sent' => 0,
|
||||||
|
'receiver' => null,
|
||||||
|
'date' => Carbon::now()->subDays(2)->format('Y-m-d'),
|
||||||
|
]);
|
||||||
|
Administration::create([
|
||||||
|
'msnv' => '1005',
|
||||||
|
'received' => 0,
|
||||||
|
'sender' => null,
|
||||||
|
'sent' => 2,
|
||||||
|
'receiver' => '1001',
|
||||||
|
'date' => Carbon::now()->subDays(2)->format('Y-m-d'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Nguyễn Văn A (1001) sends 4 cards to Trần Thị Ngọc (1002)
|
||||||
|
Administration::create([
|
||||||
|
'msnv' => '1001',
|
||||||
|
'sent' => 4,
|
||||||
|
'receiver' => '1002',
|
||||||
|
'received' => 0,
|
||||||
|
'sender' => null,
|
||||||
|
'date' => Carbon::now()->subDays(1)->format('Y-m-d'),
|
||||||
|
]);
|
||||||
|
Administration::create([
|
||||||
|
'msnv' => '1002',
|
||||||
|
'sent' => 0,
|
||||||
|
'receiver' => null,
|
||||||
|
'received' => 4,
|
||||||
|
'sender' => '1001',
|
||||||
|
'date' => Carbon::now()->subDays(1)->format('Y-m-d'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
# 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, hot‑reload
|
||||||
|
```
|
||||||
|
|
||||||
|
**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 (hot‑reload)
|
||||||
|
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.
|
||||||
|
|
||||||
|
---
|
||||||
|
### 4.7 Khi cập nhật code trên server (reload)
|
||||||
|
|
||||||
|
Khi cập nhật mã nguồn (ví dụ `git pull`), cần thực hiện các lệnh sau để reload lại ứng dụng:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Pull latest code (run on host or inside container if repo mounted)
|
||||||
|
git pull origin main
|
||||||
|
|
||||||
|
# 2. Cài đặt/ cập nhật dependencies PHP
|
||||||
|
docker compose -f docker/docker-compose.yml exec app composer install --no-interaction --optimize-autoloader
|
||||||
|
|
||||||
|
# 3. Cài đặt/ cập nhật dependencies Node (nếu có thay đổi frontend)
|
||||||
|
docker compose -f docker/docker-compose.yml exec node npm install
|
||||||
|
|
||||||
|
# 4. Biên dịch lại frontend (production)
|
||||||
|
docker compose -f docker/docker-compose.yml exec node npm run build
|
||||||
|
|
||||||
|
# 5. Clear Laravel caches
|
||||||
|
docker compose -f docker/docker-compose.yml exec app php artisan config:clear
|
||||||
|
docker compose -f docker/docker-compose.yml exec app php artisan cache:clear
|
||||||
|
docker compose -f docker/docker-compose.yml exec app php artisan view:clear
|
||||||
|
docker compose -f docker/docker-compose.yml exec app php artisan route:clear
|
||||||
|
|
||||||
|
# 6. Restart containers để áp dụng thay đổi .env hoặc cấu hình mới
|
||||||
|
docker compose -f docker/docker-compose.yml restart app nginx node
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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
|
||||||
@@ -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"
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
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
|
||||||
|
- ./php/uploads.ini:/usr/local/etc/php/conf.d/uploads.ini
|
||||||
|
|
||||||
|
networks:
|
||||||
|
- thankcard-system-network
|
||||||
|
extra_hosts:
|
||||||
|
- "host.docker.internal:host-gateway"
|
||||||
|
|
||||||
|
nginx:
|
||||||
|
image: nginx:1.25-alpine
|
||||||
|
container_name: thankcard-system-nginx
|
||||||
|
ports:
|
||||||
|
- "80: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: tail -f /dev/null
|
||||||
|
ports:
|
||||||
|
- "5173:5173"
|
||||||
|
networks:
|
||||||
|
- thankcard-system-network
|
||||||
|
|
||||||
|
networks:
|
||||||
|
thankcard-system-network:
|
||||||
|
name: thankcard-system-network
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name localhost;
|
||||||
|
client_max_body_size 20M;
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 "$@"
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
upload_max_filesize = 20M
|
||||||
|
post_max_size = 20M
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
# Hướng dẫn thiết lập Cron Job cho Laravel trên Server (Ubuntu)
|
||||||
|
|
||||||
|
Để hệ thống tự động thực hiện các tác vụ định kỳ (bao gồm việc tự động reset thẻ vào lúc 23:59 ngày cuối tháng thông qua lệnh `cards:reset`), bạn cần cấu hình Cron Job trên hệ điều hành Ubuntu Server.
|
||||||
|
|
||||||
|
Dưới đây là hướng dẫn chi tiết cho cả hai trường hợp: **Chạy qua Docker Compose** (mặc định của dự án) và **Chạy trực tiếp trên Host (Không dùng Docker)**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## TRƯỜNG HỢP 1: Chạy qua Docker Compose (Khuyên dùng cho dự án này)
|
||||||
|
|
||||||
|
Vì dự án chạy trong container Docker, Cron Job trên server Ubuntu (máy Host) cần gọi lệnh của Laravel vào bên trong container `app`.
|
||||||
|
|
||||||
|
### Các bước thiết lập:
|
||||||
|
|
||||||
|
1. **Mở file cấu hình Crontab của Ubuntu:**
|
||||||
|
Mở terminal của Ubuntu Server và chạy lệnh:
|
||||||
|
```bash
|
||||||
|
crontab -e
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Thêm cấu hình chạy Laravel Scheduler:**
|
||||||
|
Thêm dòng dưới đây vào cuối file crontab:
|
||||||
|
```bash
|
||||||
|
* * * * * cd /đường-dẫn-đến-thư-mục-dự-án && docker compose -f docker/docker-compose.yml exec -T app php artisan schedule:run >> /dev/null 2>&1
|
||||||
|
```
|
||||||
|
*Lưu ý quan trọng:*
|
||||||
|
- Thay `/đường-dẫn-đến-thư-mục-dự-án` thành đường dẫn tuyệt đối đến dự án trên server (Ví dụ: `/var/www/thankcard-system`).
|
||||||
|
- Tham số **`-T`** (trong `exec -T`) là bắt buộc để tắt chế độ TTY (Tương tác nhập liệu), giúp cron chạy ngầm thành công mà không bị lỗi TTY error.
|
||||||
|
|
||||||
|
3. **Lưu và Thoát:**
|
||||||
|
- Nếu bạn dùng trình soạn thảo `nano`: Nhấn `Ctrl + O` -> nhấn `Enter` để lưu, sau đó bấm `Ctrl + X` để thoát.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## TRƯỜNG HỢP 2: Chạy trực tiếp trên Ubuntu Host (Nếu deploy dạng truyền thống)
|
||||||
|
|
||||||
|
Nếu bạn cấu hình chạy PHP và Web Server (Nginx/Apache) trực tiếp trên Ubuntu mà không qua Docker.
|
||||||
|
|
||||||
|
### Các bước thiết lập:
|
||||||
|
|
||||||
|
1. **Mở cấu hình Crontab:**
|
||||||
|
Truy cập terminal của Ubuntu Server dưới quyền của user chạy Web (thường là `www-data` hoặc user deploy) và chạy lệnh:
|
||||||
|
```bash
|
||||||
|
crontab -e
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Thêm cấu hình chạy Laravel Scheduler:**
|
||||||
|
Thêm dòng dưới đây vào cuối file crontab:
|
||||||
|
```bash
|
||||||
|
* * * * * cd /đường-dẫn-đến-thư-mục-dự-án && php artisan schedule:run >> /dev/null 2>&1
|
||||||
|
```
|
||||||
|
*Lưu ý:* Thay `/đường-dẫn-đến-thư-mục-dự-án` bằng đường dẫn tuyệt đối đến thư mục chứa dự án.
|
||||||
|
|
||||||
|
3. **Lưu và Thoát.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Hướng dẫn kiểm tra trạng thái Cron Job
|
||||||
|
|
||||||
|
1. **Xem danh sách cron job đang hoạt động:**
|
||||||
|
```bash
|
||||||
|
crontab -l
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Kiểm tra danh sách các tác vụ đã lên lịch trong Laravel:**
|
||||||
|
```bash
|
||||||
|
# Nếu dùng Docker:
|
||||||
|
docker compose -f docker/docker-compose.yml exec -T app php artisan schedule:list
|
||||||
|
|
||||||
|
# Nếu không dùng Docker:
|
||||||
|
php artisan schedule:list
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Chạy thử scheduler ngay lập tức bằng tay để kiểm thử:**
|
||||||
|
```bash
|
||||||
|
# Nếu dùng Docker:
|
||||||
|
docker compose -f docker/docker-compose.yml exec -T app php artisan schedule:run
|
||||||
|
|
||||||
|
# Nếu không dùng Docker:
|
||||||
|
php artisan schedule:run
|
||||||
|
```
|
||||||
@@ -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.*
|
||||||
|
After Width: | Height: | Size: 114 KiB |
|
After Width: | Height: | Size: 122 KiB |
|
After Width: | Height: | Size: 122 KiB |
|
After Width: | Height: | Size: 122 KiB |
|
After Width: | Height: | Size: 115 KiB |
|
After Width: | Height: | Size: 122 KiB |
|
After Width: | Height: | Size: 118 KiB |
|
After Width: | Height: | Size: 104 KiB |
|
After Width: | Height: | Size: 127 KiB |
|
After Width: | Height: | Size: 121 KiB |
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 129 KiB |
|
After Width: | Height: | Size: 124 KiB |
|
After Width: | Height: | Size: 130 KiB |
|
After Width: | Height: | Size: 121 KiB |
@@ -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.
|
||||||
@@ -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',
|
||||||
|
];
|
||||||
@@ -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',
|
||||||
|
];
|
||||||
@@ -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',
|
||||||
|
];
|
||||||
@@ -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' => [
|
||||||
|
'cannot_send_card' => "Bạn không thể gửi Thank Card.\nCó thể bạn đã sử dụng hết số lượng Thank Card\nhoặc chưa được cấp quyền gửi.\nVui lòng liên hệ Admin để được hỗ trợ.",
|
||||||
|
'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.',
|
||||||
|
],
|
||||||
|
'confirm_send_card' => "Đừng quên hoàn thành Thank Card của bạn nhé!\nChúng mình sẽ gửi thông báo đến người nhận\nđể họ đến khu vực nhận thư.",
|
||||||
|
];
|
||||||
@@ -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',
|
||||||
|
],
|
||||||
|
];
|
||||||
@@ -13,5 +13,10 @@
|
|||||||
"laravel-vite-plugin": "^2.0.0",
|
"laravel-vite-plugin": "^2.0.0",
|
||||||
"tailwindcss": "^4.0.0",
|
"tailwindcss": "^4.0.0",
|
||||||
"vite": "^7.0.7"
|
"vite": "^7.0.7"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@fontsource/roboto": "^5.2.10",
|
||||||
|
"puppeteer": "^25.2.1",
|
||||||
|
"tom-select": "^2.2.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,19 +18,19 @@
|
|||||||
</include>
|
</include>
|
||||||
</source>
|
</source>
|
||||||
<php>
|
<php>
|
||||||
<env name="APP_ENV" value="testing"/>
|
<env name="APP_ENV" value="testing" force="true"/>
|
||||||
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
|
<env name="APP_MAINTENANCE_DRIVER" value="file" force="true"/>
|
||||||
<env name="BCRYPT_ROUNDS" value="4"/>
|
<env name="BCRYPT_ROUNDS" value="4" force="true"/>
|
||||||
<env name="BROADCAST_CONNECTION" value="null"/>
|
<env name="BROADCAST_CONNECTION" value="null" force="true"/>
|
||||||
<env name="CACHE_STORE" value="array"/>
|
<env name="CACHE_STORE" value="array" force="true"/>
|
||||||
<env name="DB_CONNECTION" value="sqlite"/>
|
<env name="DB_CONNECTION" value="sqlite" force="true"/>
|
||||||
<env name="DB_DATABASE" value=":memory:"/>
|
<env name="DB_DATABASE" value=":memory:" force="true"/>
|
||||||
<env name="DB_URL" value=""/>
|
<env name="DB_URL" value="" force="true"/>
|
||||||
<env name="MAIL_MAILER" value="array"/>
|
<env name="MAIL_MAILER" value="array" force="true"/>
|
||||||
<env name="QUEUE_CONNECTION" value="sync"/>
|
<env name="QUEUE_CONNECTION" value="sync" force="true"/>
|
||||||
<env name="SESSION_DRIVER" value="array"/>
|
<env name="SESSION_DRIVER" value="array" force="true"/>
|
||||||
<env name="PULSE_ENABLED" value="false"/>
|
<env name="PULSE_ENABLED" value="false" force="true"/>
|
||||||
<env name="TELESCOPE_ENABLED" value="false"/>
|
<env name="TELESCOPE_ENABLED" value="false" force="true"/>
|
||||||
<env name="NIGHTWATCH_ENABLED" value="false"/>
|
<env name="NIGHTWATCH_ENABLED" value="false" force="true"/>
|
||||||
</php>
|
</php>
|
||||||
</phpunit>
|
</phpunit>
|
||||||
|
|||||||
|
After Width: | Height: | Size: 392 KiB |