Compare commits

84 Commits

Author SHA1 Message Date
antv 9b4da6321d m 2026-08-04 21:41:38 +07:00
antv c079677920 m 2026-08-04 21:26:02 +07:00
antv ac76212819 feat: avatar validation 2026-08-04 21:06:10 +07:00
antv e249f08ecc m 2026-08-04 19:58:50 +07:00
antv-admin eee82363ed m 2026-08-02 18:13:42 +07:00
antv-admin f864e07b6d m 2026-08-02 15:36:04 +07:00
antv-admin eda904524f Add fallback mechanism and logout button on 500 error page to prevent lock in change-password 2026-08-01 23:02:12 +07:00
antv-admin e06b1db44b Fix syntax error unclosed @if in change_password.blade.php 2026-08-01 22:58:04 +07:00
antv-admin 4255d187c9 fix 2026-08-01 22:54:56 +07:00
antv-admin 36eec0a664 feat: forgot password
config: msg
2026-08-01 14:40:04 +07:00
antv 781a0f8aa7 m 2026-07-21 10:16:06 +07:00
antv f8385185c9 m 2026-07-17 14:43:54 +07:00
antv 98a56eb130 m 2026-07-17 14:19:34 +07:00
antv 7af5de6048 m 2026-07-17 13:57:02 +07:00
antv 91a598a5f9 m 2026-07-17 13:42:00 +07:00
antv e81a285aa9 m 2026-07-17 00:16:42 +07:00
antv c4e652e64c m 2026-07-17 00:11:23 +07:00
antv ad5465198e m 2026-07-16 16:03:22 +07:00
antv 7190cc99ad m 2026-07-16 16:02:23 +07:00
antv 9c20a25111 m 2026-07-16 16:01:15 +07:00
antv 939287d9b3 Fix layout overlap in card amount select box by adjusting padding 2026-07-15 22:47:22 +07:00
antv b3549ff3eb Change close button label to OK in non-confirm modal 2026-07-15 22:44:30 +07:00
antv 041ea8fe77 Consolidate duplicate send-card error translations into messages.error.cannot_send_card 2026-07-15 22:43:16 +07:00
antv b59c71aaef Align client-side validation error popup content and condition with specifications 2026-07-15 22:39:42 +07:00
antv 12196570e8 Render validation error newlines correctly on UI using whitespace-pre-line 2026-07-15 22:36:30 +07:00
antv c48f7aa23d Address Race Condition in sendThankcards using pessimistic locking (lockForUpdate) 2026-07-15 22:35:50 +07:00
antv 53fa37d599 Sync thank card sending logic and validation with specifications 2026-07-15 22:32:24 +07:00
antv 7f059dce86 m 2026-07-15 22:23:49 +07:00
antv d16db9c73d m 2026-07-15 22:05:39 +07:00
antv 866d46bf21 m 2026-07-15 10:04:06 +07:00
antv ce6c4d2cf3 m 2026-07-14 17:04:29 +07:00
antv 05605ed1eb feature: send thankscard page 2026-07-14 16:23:04 +07:00
antv 3aa9e04bd7 update 2026-07-14 11:13:27 +07:00
antv 9370e2fb3c update 2026-07-14 10:46:34 +07:00
antv 47a8892623 select box 2026-07-13 16:28:44 +07:00
antv f0a25b840c fix: node build 2026-07-13 10:50:22 +07:00
antv 797689940c conf: docker 2026-07-13 10:43:05 +07:00
antv 62d922627f update ui 2026-07-10 16:36:12 +07:00
antv a1ad7e738b feature: form edit 2026-07-10 08:16:20 +07:00
antv 326e1f1028 refactor: refactor controllers, services, validation and introduce DTOs, FormRequests and domain exceptions 2026-07-09 10:27:47 +07:00
antv 54048bb4ff feat: display sender/receiver name, email, department in transaction history 2026-07-09 09:48:47 +07:00
antv 3a33c86346 feat: render card purchase history and fix validation in admin edit user form 2026-07-09 09:44:16 +07:00
antv badc453389 feat: add client-side onerror fallback to default avatar 2026-07-09 09:10:15 +07:00
antv 48bdf21de2 fix: robustly handle string NULL in avatar accessor 2026-07-09 09:09:05 +07:00
antv 25bb3d961e feature: update avatar 2026-07-09 09:05:57 +07:00
antv 439eec9015 fix: restrict profile width and change logout modal to dropdown popup below avatar 2026-07-08 16:01:15 +07:00
antv 553d47ebd1 fix: apply responsive hiding and fixed-width truncation for name and department in header profile 2026-07-08 15:58:52 +07:00
antv b62926b9af fix: display full name and department next to avatar on all screens 2026-07-08 15:56:13 +07:00
antv 8ca6508f72 feat: move profile avatar from sidebar to top-right header next to send thankcard button 2026-07-08 15:53:19 +07:00
antv 9aee6d612c fix: fall back to default public avatar image if user has no avatar 2026-07-08 15:50:44 +07:00
antv bdd58e0d17 fix: display correct resolved user department in sidebar profile card 2026-07-08 15:48:05 +07:00
antv 5e6ce46e00 fix: restore exact original styling for PC viewports with md:max-w-none 2026-07-08 15:34:35 +07:00
antv bffb3f3884 fix: make hero banner responsive on mobile viewports 2026-07-08 15:33:14 +07:00
antv e476c2768a feature: user list 2026-07-08 15:29:13 +07:00
antv 7d9ba527a3 feature: remove change password for admin 2026-07-08 13:45:23 +07:00
antv 6056e33ef8 feature: change password 2026-07-08 13:03:48 +07:00
antv 63764d0718 feature: make style like main page 2026-07-07 11:21:21 +07:00
antv af529a4e81 feature: update message for login feature 2026-07-06 21:02:52 +07:00
antv 7a964785c7 style: main page - stretch columns to align leaderboard height 2026-07-06 17:04:55 +07:00
antv d4dbf849af style: main page - add cursor-pointer to ranking tab buttons 2026-07-06 16:59:30 +07:00
antv 1e050233b9 feature: main page - always show current user at the bottom of ranking 2026-07-06 16:54:41 +07:00
antv e163d2f349 feature: main page - ranking list 2026-07-06 16:44:03 +07:00
antv 618dcef37f feature: main page - code be 2026-07-06 16:07:35 +07:00
antv e1fd70071b feature: main page 2026-07-06 15:10:52 +07:00
antv 64027eb8f2 feature: main page 2026-07-06 15:06:14 +07:00
antv 11a6568717 feature: main page 2026-07-06 14:37:32 +07:00
antv b1beed4ae6 feature: use md5 2026-07-06 14:00:07 +07:00
antv d8f0e48bf3 feature: my page 2026-07-03 16:54:43 +07:00
antv 1943f23406 feat: edit migration 2026-07-03 09:10:54 +07:00
antv f594a45e84 m 2026-07-02 17:02:02 +07:00
antv 89790539b7 feature: login and my page 2026-07-02 15:42:47 +07:00
antv c993f848cb fix https 2026-07-02 10:22:47 +07:00
antv-admin 83a529b4c5 feat: add phpmyadmin 2026-07-01 23:02:56 +07:00
antv-admin 5565d15be5 new 2026-07-01 22:43:28 +07:00
antv-admin 66d0caa588 confi: docker 2026-07-01 22:36:46 +07:00
antv-admin fde9105834 conf: docker 2026-07-01 22:05:48 +07:00
antv-admin 7bdea785f9 conf: docker 2026-07-01 20:46:08 +07:00
antv f1858f8f0f reuse form 2026-07-01 16:57:34 +07:00
antv 4e4d03d62a style: fix nested border-radius mathematics on login screen to resolve corner gaps 2026-06-30 16:18:42 +07:00
antv d99541b7e4 fix: resolve login button overflow and improve form layout spacing 2026-06-30 16:16:56 +07:00
antv 8cbdd93e56 feature: add error pages 2026-06-30 15:31:40 +07:00
antv 96eac88f36 feature: add docker 2026-06-30 14:03:13 +07:00
antv c5016489ac feature: keep sidebar and add service layer 2026-06-30 11:59:34 +07:00
antv 8a77324f89 all feature 2026-06-29 11:55:33 +07:00
222 changed files with 14466 additions and 208 deletions
+4 -1
View File
@@ -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=
+10
View File
@@ -0,0 +1,10 @@
APP_ENV=testing
APP_KEY=base64:93hwWdQyCR3816s/FI231k5H909nxIHcTUeomm7WXVA=
APP_DEBUG=true
DB_CONNECTION=sqlite
DB_DATABASE=:memory:
CACHE_STORE=array
SESSION_DRIVER=array
QUEUE_CONNECTION=sync
+2
View File
@@ -52,3 +52,5 @@ yarn-debug.log*
yarn-error.log* 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;
}
}
+41
View File
@@ -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,
];
}
}
+33
View File
@@ -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')
);
}
}
+10
View File
@@ -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()]);
}
}
}
+109
View File
@@ -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.',
];
}
}
+39
View File
@@ -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 [];
}
}
+27
View File
@@ -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');
}
}
+37
View File
@@ -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');
}
}
+82 -24
View File
@@ -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;
} }
} }
+27 -8
View File
@@ -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)
)
);
});
}
} }
} }
+335
View File
@@ -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;
}
+82
View File
@@ -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;
}
+351
View File
@@ -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');
}
}
+18 -2
View File
@@ -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();
Regular → Executable
View File
+58
View File
@@ -0,0 +1,58 @@
import puppeteer from 'puppeteer';
import fs from 'fs';
import path from 'path';
(async () => {
const dir = './docs/notification-review';
if (!fs.existsSync(dir)){
fs.mkdirSync(dir, { recursive: true });
}
console.log('Launching browser...');
const browser = await puppeteer.launch({
headless: true,
args: ['--no-sandbox', '--disable-setuid-sandbox']
});
const page = await browser.newPage();
await page.setViewport({ width: 1280, height: 800 });
console.log('Navigating to showcase...');
await page.goto('http://localhost:8000/notification-showcase', { waitUntil: 'networkidle2' });
const cases = [
{ id: 1, name: 'login_failed' },
{ id: 2, name: 'access_denied' },
{ id: 3, name: 'first_login' },
{ id: 4, name: 'delete_user_confirmation' },
{ id: 5, name: 'create_user_success' },
{ id: 6, name: 'email_already_exists' },
{ id: 7, name: 'employee_id_already_exists' },
{ id: 8, name: 'no_data_found' },
{ id: 9, name: 'not_enough_thankcards' },
{ id: 10, name: 'max_5_thankcards_per_month' },
{ id: 11, name: 'send_thankcards_confirmation' },
{ id: 12, name: 'send_thankcards_success' },
{ id: 13, name: 'password_confirmation_mismatch' },
{ id: 14, name: 'change_password_success' },
{ id: 15, name: 'generic_system_error' }
];
for (const c of cases) {
console.log(`Triggering Case ${c.id}: ${c.name}...`);
// Click the button
await page.click(`#case-btn-${c.id}`);
// Wait for animation
await new Promise(resolve => setTimeout(resolve, 800));
// Save screenshot
const filename = `case_${String(c.id).padStart(2, '0')}_${c.name}.png`;
const filepath = path.join(dir, filename);
await page.screenshot({ path: filepath });
console.log(`Saved screenshot to ${filepath}`);
}
await browser.close();
console.log('Finished capturing all 15 screenshots!');
})();
+1 -1
View File
@@ -78,7 +78,7 @@ return [
| |
*/ */
'locale' => env('APP_LOCALE', 'en'), 'locale' => env('APP_LOCALE', 'vi'),
'fallback_locale' => env('APP_FALLBACK_LOCALE', 'en'), 'fallback_locale' => env('APP_FALLBACK_LOCALE', 'en'),
+76
View File
@@ -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',
]
];
+48
View File
@@ -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ư!',
];
+10 -1
View File
@@ -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'),
], ],
+1 -1
View File
@@ -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');
});
}
};
+2 -5
View File
@@ -15,11 +15,8 @@ class DatabaseSeeder extends Seeder
*/ */
public function run(): void public function run(): void
{ {
// User::factory(10)->create(); $this->call([
MockDataSeeder::class,
User::factory()->create([
'name' => 'Test User',
'email' => 'test@example.com',
]); ]);
} }
} }
+152
View File
@@ -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'),
]);
}
}
+17
View File
@@ -0,0 +1,17 @@
FROM php:8.3-fpm
WORKDIR /var/www/html
RUN apt-get update && apt-get install -y \
git unzip zip curl \
libpng-dev libjpeg-dev libfreetype6-dev \
libonig-dev libxml2-dev libzip-dev \
&& docker-php-ext-install pdo_mysql mbstring exif pcntl bcmath gd zip
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
COPY docker/php/entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"]
CMD ["php-fpm"]
+188
View File
@@ -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, hotreload
```
**2️⃣ Chạy trong Docker (sử dụng container `node`)**
```bash
# Cài đặt các package (chỉ chạy 1 lần)
docker compose -f docker/docker-compose.yml exec node npm install
# Chế độ phát triển (hotreload)
docker compose -f docker/docker-compose.yml exec node npm run dev
# Hoặc biên dịch bản production
docker compose -f docker/docker-compose.yml exec node npm run build
```
Container `node` đã expose port **5173**, cho phép truy cập Vite dev server tại `http://localhost:5173`. Khi `npm run dev` đang chạy, mọi thay đổi CSS/JS sẽ tự động cập nhật trong container `app` nhờ volume mount.
### 4.3. Khi cập nhật Database (Migrations)
Để cập nhật cơ sở dữ liệu khi có migrations mới:
```bash
docker compose -f docker/docker-compose.yml exec app php artisan migrate
```
### 4.4. Khi chạy dữ liệu mẫu (Seeders)
Để tạo dữ liệu mẫu (seed) vào cơ sở dữ liệu:
```bash
docker compose -f docker/docker-compose.yml exec app php artisan db:seed
```
Nếu chỉ muốn chạy một file seeder cố định (ví dụ: `UserSeeder`):
```bash
docker compose -f docker/docker-compose.yml exec app php artisan db:seed --class=UserSeeder
```
Hoặc để vừa chạy lệnh migrate vừa tự động seed luôn dữ liệu:
```bash
docker compose -f docker/docker-compose.yml exec app php artisan migrate --seed
```
### 4.5. Khi chỉnh sửa file `.env` hoặc `.env.app`
Bạn cần khởi động lại container để cập nhật biến môi trường mới và tiến hành xóa cache cấu hình:
```bash
# 1. Restart container app
docker compose -f docker/docker-compose.yml restart app
# 2. Xóa cache cấu hình trong container
docker compose -f docker/docker-compose.yml exec app php artisan config:clear
```
### 4.6. Mở công cụ quản lý Database (phpMyAdmin)
Dự án có sẵn một file cấu hình riêng để mở giao diện quản lý MySQL (đã được trỏ sẵn vào database trên máy bạn):
```bash
docker compose -f docker/docker-compose.phpmyadmin.yml up -d
```
Sau đó truy cập: `http://localhost:8889` để vào phpMyAdmin.
---
### 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
+11
View File
@@ -0,0 +1,11 @@
services:
phpmyadmin:
image: phpmyadmin:latest
container_name: thankcard-system-phpmyadmin
ports:
- "8889:80"
environment:
PMA_HOST: host.docker.internal
PMA_PORT: 3306
extra_hosts:
- "host.docker.internal:host-gateway"
+45
View File
@@ -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
+24
View File
@@ -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;
}
}
+24
View File
@@ -0,0 +1,24 @@
#!/bin/sh
set -e
cd /var/www/html
# Luôn chạy composer install để đảm bảo vendor được cập nhật
composer install --no-interaction --prefer-dist
mkdir -p storage/logs \
storage/framework/cache \
storage/framework/sessions \
storage/framework/views \
bootstrap/cache
touch storage/logs/laravel.log storage/logs/query.log
chmod 666 storage/logs/laravel.log storage/logs/query.log
chmod -R 777 storage bootstrap/cache
# Tạo symlink cho storage nếu chưa có (để public file ảnh, upload,...)
if [ ! -L "public/storage" ]; then
php artisan storage:link
fi
exec "$@"
+2
View File
@@ -0,0 +1,2 @@
upload_max_filesize = 20M
post_max_size = 20M
+81
View File
@@ -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
```
+39
View File
@@ -0,0 +1,39 @@
# Notification & Popup Review List
This document lists all the standard dialogs, popups, toasts, and empty states implemented for the Thankcard System. These screenshots are captured automatically for Business Analyst (BA) and Quality Assurance (QA) review and approval.
## Common Components Implemented
The following reusable/common components are created and used across all display cases:
1. **Confirm Dialog**: Dialog prompting for confirmation before an action is executed (danger and primary types).
2. **Error Dialog**: Informing users of errors or validation failures.
3. **Success Dialog**: Standard dialog to show successful actions.
4. **Warning Dialog**: Warnings related to constraints or user balance.
5. **Info Dialog**: Friendly dialog for guidance (e.g., first login, change password required).
6. **Toast**: Slide-in non-blocking notification for quick feedback.
7. **Empty State**: Centered inline placeholder for lists containing no data.
---
## Review Case Matrix
| Case ID | Feature | Display Case | Notification Type | Message | Screenshot Path |
| :--- | :--- | :--- | :--- | :--- | :--- |
| **01** | Authentication | Login failed | Inline (Error) | Thông tin đăng nhập không đúng. Vui lòng nhập lại | [case_01_login_failed.png](case_01_login_failed.png) |
| **02** | Authorization | Access denied / disabled | Error Dialog | Bạn không có quyền truy cập. Vui lòng liên hệ quản trị viên | [case_02_access_denied.png](case_02_access_denied.png) |
| **03** | Authentication | First login | Info Dialog | Vui lòng đổi mật khẩu trước khi tiếp tục sử dụng hệ thống. | [case_03_first_login.png](case_03_first_login.png) |
| **04** | User Management | Delete user confirmation | Confirm Dialog (Danger) | Bạn có chắc chắn muốn xóa tài khoản của nhân viên Nguyễn Văn A (MSNV: NV0001)? Hành động này không thể hoàn tác và tất cả dữ liệu liên quan sẽ bị xóa. | [case_04_delete_user_confirmation.png](case_04_delete_user_confirmation.png) |
| **05** | User Management | Create user success | Success Dialog | Tài khoản nhân viên mới đã được tạo thành công trong hệ thống. Email kích hoạt đã được gửi tới người dùng. | [case_05_create_user_success.png](case_05_create_user_success.png) |
| **06** | User Management | Email already exists | Error Dialog | Địa chỉ email 'nguyenvana@runsystem.net' đã được đăng ký bởi một nhân viên khác. Vui lòng sử dụng địa chỉ email khác. | [case_06_email_already_exists.png](case_06_email_already_exists.png) |
| **07** | User Management | Employee ID already exists | Error Dialog | Mã nhân viên (MSNV) 'NV0001' đã được gán cho một tài khoản khác trong hệ thống. Vui lòng kiểm tra lại. | [case_07_employee_id_already_exists.png](case_07_employee_id_already_exists.png) |
| **08** | General | No data found | Empty State | Không tìm thấy người dùng hoặc giao dịch nào phù hợp với bộ lọc tìm kiếm hiện tại của bạn. Vui lòng thử lại với từ khóa khác. | [case_08_no_data_found.png](case_08_no_data_found.png) |
| **09** | Thankcard Sending | Not enough thankcards | Warning Dialog | Số dư Thankcard hiện tại của bạn là 0. Bạn không thể gửi thêm thẻ cho đến khi số dư được đặt lại vào đầu tháng tới. | [case_09_not_enough_thankcards.png](case_09_not_enough_thankcards.png) |
| **10** | Thankcard Sending | Maximum 5 thankcards per user/month | Warning Dialog | Bạn đã đạt đến hạn mức gửi tối đa 5 Thankcard trong tháng này. Vui lòng đợi đến tháng sau để tiếp tục gửi lời cảm ơn. | [case_10_max_5_thankcards_per_month.png](case_10_max_5_thankcards_per_month.png) |
| **11** | Thankcard Sending | Send thankcards confirmation | Confirm Dialog (Primary) | Bạn đang thực hiện gửi 1 Thankcard tới Trần Thị B với lời nhắn: 'Cảm ơn bạn đã hỗ trợ dự án'. Bạn có chắc chắn muốn gửi? | [case_11_send_thankcards_confirmation.png](case_11_send_thankcards_confirmation.png) |
| **12** | Thankcard Sending | Send thankcards success | Success Dialog | Thankcard của bạn đã được gửi thành công đến Trần Thị B. Lượt gửi còn lại trong tháng của bạn: 4/5. | [case_12_send_thankcards_success.png](case_12_send_thankcards_success.png) |
| **13** | Password Management | Password confirmation mismatch | Error Dialog | Mật khẩu xác nhận nhập lại không khớp với mật khẩu mới. Vui lòng nhập lại chính xác cả hai trường. | [case_13_password_confirmation_mismatch.png](case_13_password_confirmation_mismatch.png) |
| **14** | Password Management | Change password success | Success Dialog | Mật khẩu của bạn đã được cập nhật thành công. Vui lòng sử dụng mật khẩu mới cho các lần đăng nhập tiếp theo. | [case_14_change_password_success.png](case_14_change_password_success.png) |
| **15** | General | Generic system error | Error Dialog | Đã xảy ra sự cố kết nối với máy chủ (Mã lỗi: 500 - Internal Server Error). Vui lòng thử lại sau hoặc báo cáo cho đội ngũ kỹ thuật. | [case_15_generic_system_error.png](case_15_generic_system_error.png) |
---
*Note: All screenshots were automatically captured under `1280x800` viewport to ensure precise rendering of transitions and CSS styling.*
Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

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

After

Width:  |  Height:  |  Size: 392 KiB

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