This commit is contained in:
antv
2026-07-02 17:02:02 +07:00
parent 89790539b7
commit f594a45e84
17 changed files with 200 additions and 122 deletions
@@ -42,13 +42,15 @@ class AdminController extends Controller
public function store(Request $request) public function store(Request $request)
{ {
$request->validate([ $request->validate([
'msnv' => 'required|unique:user,msnv', 'msnv' => 'required|integer|unique:user,msnv',
'name' => 'required|string|max:255',
'mail' => 'required|email|unique:user,mail', 'mail' => 'required|email|unique:user,mail',
'password' => 'required|min:6', 'departments' => 'required|integer|min:1|max:5',
'password' => 'required|string',
'role' => 'required|in:' . User::ROLE_MEMBER . ',' . User::ROLE_ADMIN, 'role' => 'required|in:' . User::ROLE_MEMBER . ',' . User::ROLE_ADMIN,
]); ]);
$this->adminService->createUser($request->only('msnv', 'mail', 'password', 'role')); $this->adminService->createUser($request->only('msnv', 'name', 'mail', 'departments', 'password', 'role'));
return redirect()->route('admin.users.index')->with('success', __('messages.user_create_success')); return redirect()->route('admin.users.index')->with('success', __('messages.user_create_success'));
} }
+4 -1
View File
@@ -34,7 +34,10 @@ class UserController extends Controller
public function storeThankcards(Request $request) public function storeThankcards(Request $request)
{ {
$request->validate([ $request->validate([
'receiver' => 'required|exists:user,msnv', 'receiver' => [
'required',
\Illuminate\Validation\Rule::exists('user', 'msnv')->where('status', \App\Models\User::STATUS_ACTIVE)
],
'amount' => 'required|integer|min:1|max:' . Administration::MAX_SEND_CARD_PER_MONTH, 'amount' => 'required|integer|min:1|max:' . Administration::MAX_SEND_CARD_PER_MONTH,
]); ]);
+1 -2
View File
@@ -11,8 +11,7 @@ class AddCard extends Model
protected $table = 'add_card'; protected $table = 'add_card';
public $timestamps = false; public $timestamps = false;
protected $primaryKey = null; protected $primaryKey = 'id';
public $incrementing = false;
protected $fillable = [ protected $fillable = [
'buyer', 'buyer',
+4 -3
View File
@@ -25,14 +25,15 @@ class User extends Authenticatable
protected $table = 'user'; protected $table = 'user';
public $timestamps = false; public $timestamps = false;
protected $primaryKey = 'msnv'; protected $primaryKey = 'id';
public $incrementing = false;
protected $keyType = 'string';
protected $fillable = [ protected $fillable = [
'msnv', 'msnv',
'name',
'mail', 'mail',
'pass', 'pass',
'departments',
'avatar',
'role', 'role',
'status', 'status',
'card', 'card',
+4 -2
View File
@@ -66,8 +66,10 @@ class AdminService implements AdminServiceInterface
{ {
User::create([ User::create([
'msnv' => $data['msnv'], 'msnv' => $data['msnv'],
'name' => $data['name'],
'mail' => $data['mail'], 'mail' => $data['mail'],
'pass' => Hash::make($data['password']), 'pass' => Hash::make($data['password']),
'departments' => $data['departments'],
'role' => $data['role'], 'role' => $data['role'],
'status' => User::STATUS_ACTIVE, 'status' => User::STATUS_ACTIVE,
'card' => 0, 'card' => 0,
@@ -83,9 +85,9 @@ class AdminService implements AdminServiceInterface
DB::transaction(function () use ($data, $user) { DB::transaction(function () use ($data, $user) {
if (!empty($data['num_card'])) { if (!empty($data['num_card'])) {
AddCard::create([ AddCard::create([
'buyer' => abs(crc32($user->msnv)) % 1000000, 'buyer' => $user->msnv,
'num_card' => $data['num_card'], 'num_card' => $data['num_card'],
'seller' => abs(crc32(Auth::user()->msnv)) % 1000000, 'seller' => Auth::user()->msnv,
'date' => Carbon::today(), 'date' => Carbon::today(),
]); ]);
$user->card += $data['num_card']; $user->card += $data['num_card'];
+5 -4
View File
@@ -11,14 +11,15 @@ class AuthService implements AuthServiceInterface
{ {
public function attemptLogin(array $credentials, Request $request): array public function attemptLogin(array $credentials, Request $request): array
{ {
if (!Auth::attempt(['mail' => $credentials['mail'], 'password' => $credentials['password']])) { // 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' => __('auth.failed')]; return ['success' => false, 'error_key' => 'mail', 'error_msg' => __('auth.failed')];
} }
// Log the user in manually
Auth::login($user);
$request->session()->regenerate(); $request->session()->regenerate();
$user = Auth::user();
if ($user->status != User::STATUS_ACTIVE) { if ($user->status != User::STATUS_ACTIVE) {
Auth::logout(); Auth::logout();
return ['success' => false, 'error_key' => 'mail', 'error_msg' => __('auth.inactive')]; return ['success' => false, 'error_key' => 'mail', 'error_msg' => __('auth.inactive')];
+5
View File
@@ -38,6 +38,11 @@ class UserService implements UserServiceInterface
throw new \RuntimeException(__('messages.error.no_send_permission')); throw new \RuntimeException(__('messages.error.no_send_permission'));
} }
$receiver = User::where('msnv', $receiverMsnv)->first();
if (!$receiver || $receiver->status != User::STATUS_ACTIVE) {
throw new \RuntimeException(__('messages.error.receiver_invalid'));
}
$startOfMonth = Carbon::now()->startOfMonth(); $startOfMonth = Carbon::now()->startOfMonth();
$endOfMonth = Carbon::now()->endOfMonth(); $endOfMonth = Carbon::now()->endOfMonth();
@@ -12,14 +12,18 @@ return new class extends Migration
public function up(): void public function up(): void
{ {
Schema::create('user', function (Blueprint $table) { Schema::create('user', function (Blueprint $table) {
$table->string('msnv')->primary(); $table->increments('id');
$table->integer('msnv')->unique();
$table->string('name');
$table->string('mail'); $table->string('mail');
$table->string('pass'); $table->string('pass');
$table->tinyInteger('role')->default(0)->comment('0: member / 1: admin'); $table->integer('departments')->default(1);
$table->tinyInteger('status')->default(1)->comment('0: đã nghỉ / 1: đang làm'); $table->string('avatar')->nullable();
$table->integer('card')->nullable(); $table->tinyInteger('role')->default(0)->comment('0: member, default / 1: admin');
$table->tinyInteger('flag_send')->default(0)->comment('0: không được gửi / 1: được gửi'); $table->tinyInteger('status')->default(1)->comment('0: đã nghỉ / 1: đang làm, default');
$table->tinyInteger('first_login')->default(1)->comment('0: không là lần đầu / 1: lần đầu'); $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');
}); });
// Only user table remains // Only user table remains
@@ -12,10 +12,11 @@ return new class extends Migration
public function up(): void public function up(): void
{ {
Schema::create('add_card', function (Blueprint $table) { Schema::create('add_card', function (Blueprint $table) {
$table->increments('id');
$table->integer('buyer'); $table->integer('buyer');
$table->integer('num_card'); $table->integer('num_card');
$table->integer('seller'); $table->integer('seller');
$table->date('date')->nullable(); $table->date('date');
}); });
} }
@@ -12,12 +12,12 @@ return new class extends Migration
public function up(): void public function up(): void
{ {
Schema::create('administration', function (Blueprint $table) { Schema::create('administration', function (Blueprint $table) {
$table->id(); $table->increments('id');
$table->string('msnv'); $table->integer('msnv');
$table->integer('received')->nullable(); $table->integer('received')->nullable();
$table->string('sender')->nullable(); $table->integer('sender')->nullable();
$table->integer('sent')->nullable(); $table->integer('sent')->nullable();
$table->string('receiver')->nullable(); $table->integer('receiver')->nullable();
$table->date('date')->nullable(); $table->date('date')->nullable();
}); });
} }
+58 -44
View File
@@ -24,14 +24,17 @@ class MockDataSeeder extends Seeder
AddCard::truncate(); AddCard::truncate();
DB::statement('SET FOREIGN_KEY_CHECKS=1;'); DB::statement('SET FOREIGN_KEY_CHECKS=1;');
$password = Hash::make('123456'); // The default password is now set to 1234@Dcba according to the design requirements
$password = md5('1234@Dcba');
// 1. CREATE USERS // 1. CREATE USERS
// Admins // Admins
User::create([ User::create([
'msnv' => 'ADMIN', 'msnv' => 9999,
'name' => 'System Admin',
'mail' => 'admin@runsystem.net', 'mail' => 'admin@runsystem.net',
'pass' => $password, 'pass' => $password,
'departments' => 1,
'role' => User::ROLE_ADMIN, 'role' => User::ROLE_ADMIN,
'status' => User::STATUS_ACTIVE, 'status' => User::STATUS_ACTIVE,
'card' => 0, 'card' => 0,
@@ -40,9 +43,11 @@ class MockDataSeeder extends Seeder
]); ]);
User::create([ User::create([
'msnv' => 'ADMIN2', 'msnv' => 9998,
'name' => 'Second Admin',
'mail' => 'admin2@runsystem.net', 'mail' => 'admin2@runsystem.net',
'pass' => $password, 'pass' => $password,
'departments' => 1,
'role' => User::ROLE_ADMIN, 'role' => User::ROLE_ADMIN,
'status' => User::STATUS_ACTIVE, 'status' => User::STATUS_ACTIVE,
'card' => 0, 'card' => 0,
@@ -52,19 +57,24 @@ class MockDataSeeder extends Seeder
// Active Users (Initialize with specific users needed for logs/transactions) // Active Users (Initialize with specific users needed for logs/transactions)
$users = [ $users = [
['msnv' => 'DEV001', 'mail' => 'nguyenvana@runsystem.net', 'card' => 10, 'flag' => User::FLAG_SEND_ENABLED, 'first_login' => User::FIRST_LOGIN_FALSE], ['msnv' => 1001, 'name' => 'Nguyễn Văn A', 'mail' => 'nguyenvana@runsystem.net', 'card' => 10, 'flag' => User::FLAG_SEND_ENABLED, 'first_login' => User::FIRST_LOGIN_FALSE],
['msnv' => 'DEV002', 'mail' => 'tranthingoc@runsystem.net', 'card' => 3, 'flag' => User::FLAG_SEND_ENABLED, 'first_login' => User::FIRST_LOGIN_FALSE], ['msnv' => 1002, 'name' => 'Trần Thị Ngọc', 'mail' => 'tranthingoc@runsystem.net', 'card' => 3, 'flag' => User::FLAG_SEND_ENABLED, 'first_login' => User::FIRST_LOGIN_FALSE],
['msnv' => 'TEST01', 'mail' => 'lekiemthu@runsystem.net', 'card' => 6, 'flag' => User::FLAG_SEND_ENABLED, 'first_login' => User::FIRST_LOGIN_TRUE], // First-time login ['msnv' => 1003, 'name' => 'Lê Kim Thư', 'mail' => 'lekiemthu@runsystem.net', 'card' => 6, 'flag' => User::FLAG_SEND_ENABLED, 'first_login' => User::FIRST_LOGIN_TRUE], // First-time login
['msnv' => 'HR001', 'mail' => 'phongnhansu@runsystem.net', 'card' => 0, 'flag' => User::FLAG_SEND_DISABLED, 'first_login' => User::FIRST_LOGIN_FALSE], // Disabled sending permission ['msnv' => 1004, 'name' => 'Phòng Nhân Sự', 'mail' => 'phongnhansu@runsystem.net', 'card' => 0, 'flag' => User::FLAG_SEND_DISABLED, 'first_login' => User::FIRST_LOGIN_FALSE], // Disabled sending permission
['msnv' => 'SALE01', 'mail' => 'bangiang@runsystem.net', 'card' => 36, 'flag' => User::FLAG_SEND_ENABLED, 'first_login' => User::FIRST_LOGIN_FALSE], ['msnv' => 1005, 'name' => 'Bản Giang', 'mail' => 'bangiang@runsystem.net', 'card' => 36, 'flag' => User::FLAG_SEND_ENABLED, 'first_login' => User::FIRST_LOGIN_FALSE],
]; ];
// Generate additional users to reach exactly 100 member users // Generate additional users to reach exactly 100 member users
for ($i = 3; $i <= 97; $i++) { $firstNames = ['Nguyễn', 'Trần', 'Lê', 'Phạm', 'Hoàng', 'Huỳnh', 'Phan', 'Vũ', 'Võ', 'Đặng'];
$numStr = str_pad($i, 3, '0', STR_PAD_LEFT); $middleNames = ['Văn', 'Thị', 'Minh', 'Đức', 'Quốc', 'Thanh', 'Hữu', 'Ngọc', 'Quỳnh', 'Thu'];
$lastNames = ['Anh', 'Bình', 'Chi', 'Dương', 'Em', 'Giang', 'Huy', 'Khánh', 'Lâm', 'Nam', 'Oanh', 'Phúc', 'Quang', 'Sơn', 'Trang', 'Vy'];
for ($i = 6; $i <= 100; $i++) {
$randomName = $firstNames[array_rand($firstNames)] . ' ' . $middleNames[array_rand($middleNames)] . ' ' . $lastNames[array_rand($lastNames)];
$users[] = [ $users[] = [
'msnv' => 'DEV' . $numStr, 'msnv' => 1000 + $i,
'mail' => 'dev' . $numStr . '@runsystem.net', 'name' => $randomName,
'mail' => 'dev' . str_pad($i, 3, '0', STR_PAD_LEFT) . '@runsystem.net',
'card' => rand(0, 30), 'card' => rand(0, 30),
'flag' => rand(0, 5) > 0 ? User::FLAG_SEND_ENABLED : User::FLAG_SEND_DISABLED, 'flag' => rand(0, 5) > 0 ? User::FLAG_SEND_ENABLED : User::FLAG_SEND_DISABLED,
'first_login' => rand(0, 4) == 0 ? User::FIRST_LOGIN_TRUE : User::FIRST_LOGIN_FALSE 'first_login' => rand(0, 4) == 0 ? User::FIRST_LOGIN_TRUE : User::FIRST_LOGIN_FALSE
@@ -74,8 +84,10 @@ class MockDataSeeder extends Seeder
foreach ($users as $u) { foreach ($users as $u) {
User::create([ User::create([
'msnv' => $u['msnv'], 'msnv' => $u['msnv'],
'name' => $u['name'],
'mail' => $u['mail'], 'mail' => $u['mail'],
'pass' => $password, 'pass' => $password,
'departments' => rand(1, 5),
'role' => User::ROLE_MEMBER, 'role' => User::ROLE_MEMBER,
'status' => User::STATUS_ACTIVE, 'status' => User::STATUS_ACTIVE,
'card' => $u['card'], 'card' => $u['card'],
@@ -84,12 +96,12 @@ class MockDataSeeder extends Seeder
]); ]);
// For other users, if they have cards, create a matching purchase log in June 2026 // For other users, if they have cards, create a matching purchase log in June 2026
if (!in_array($u['msnv'], ['DEV001', 'DEV002', 'TEST01', 'SALE01'])) { if (!in_array($u['msnv'], [1001, 1002, 1003, 1005])) {
if ($u['card'] > 0) { if ($u['card'] > 0) {
AddCard::create([ AddCard::create([
'buyer' => abs(crc32($u['msnv'])) % 1000000, 'buyer' => $u['msnv'],
'num_card' => $u['card'], 'num_card' => $u['card'],
'seller' => abs(crc32('ADMIN')) % 1000000, 'seller' => 9999,
'date' => '2026-06-01' 'date' => '2026-06-01'
]); ]);
} }
@@ -98,9 +110,11 @@ class MockDataSeeder extends Seeder
// Inactive User // Inactive User
User::create([ User::create([
'msnv' => 'OLD001', 'msnv' => 9997,
'name' => 'Nghi Duy',
'mail' => 'nghiduy@runsystem.net', 'mail' => 'nghiduy@runsystem.net',
'pass' => $password, 'pass' => $password,
'departments' => 2,
'role' => User::ROLE_MEMBER, 'role' => User::ROLE_MEMBER,
'status' => User::STATUS_INACTIVE, 'status' => User::STATUS_INACTIVE,
'card' => 0, 'card' => 0,
@@ -111,28 +125,28 @@ class MockDataSeeder extends Seeder
// 2. CREATE CARD ADDITION LOGS (add_card table) // 2. CREATE CARD ADDITION LOGS (add_card table)
// Manual purchase history for specific users: // Manual purchase history for specific users:
$addCardLogs = [ $addCardLogs = [
// DEV001: total 30 cards bought // 1001: total 30 cards bought
['buyer' => 'DEV001', 'num_card' => 15, 'date' => '2026-06-01'], ['buyer' => 1001, 'num_card' => 15, 'date' => '2026-06-01'],
['buyer' => 'DEV001', 'num_card' => 10, 'date' => '2026-07-01'], ['buyer' => 1001, 'num_card' => 10, 'date' => '2026-07-01'],
['buyer' => 'DEV001', 'num_card' => 5, 'date' => '2026-08-01'], ['buyer' => 1001, 'num_card' => 5, 'date' => '2026-08-01'],
// DEV002: total 15 cards bought // 1002: total 15 cards bought
['buyer' => 'DEV002', 'num_card' => 10, 'date' => '2026-06-02'], ['buyer' => 1002, 'num_card' => 10, 'date' => '2026-06-02'],
['buyer' => 'DEV002', 'num_card' => 5, 'date' => '2026-07-01'], ['buyer' => 1002, 'num_card' => 5, 'date' => '2026-07-01'],
// TEST01: total 15 cards bought // 1003: total 15 cards bought
['buyer' => 'TEST01', 'num_card' => 10, 'date' => '2026-06-03'], ['buyer' => 1003, 'num_card' => 10, 'date' => '2026-06-03'],
['buyer' => 'TEST01', 'num_card' => 5, 'date' => '2026-08-02'], ['buyer' => 1003, 'num_card' => 5, 'date' => '2026-08-02'],
// SALE01: total 50 cards bought // 1005: total 50 cards bought
['buyer' => 'SALE01', 'num_card' => 50, 'date' => '2026-06-04'], ['buyer' => 1005, 'num_card' => 50, 'date' => '2026-06-04'],
]; ];
foreach ($addCardLogs as $log) { foreach ($addCardLogs as $log) {
AddCard::create([ AddCard::create([
'buyer' => abs(crc32($log['buyer'])) % 1000000, 'buyer' => $log['buyer'],
'num_card' => $log['num_card'], 'num_card' => $log['num_card'],
'seller' => abs(crc32('ADMIN')) % 1000000, 'seller' => 9999,
'date' => $log['date'] 'date' => $log['date']
]); ]);
} }
@@ -140,24 +154,24 @@ class MockDataSeeder extends Seeder
// 3. CREATE CARD SENDING HISTORY (administration table) // 3. CREATE CARD SENDING HISTORY (administration table)
$transactions = [ $transactions = [
// June 2026 transactions // June 2026 transactions
['sender' => 'DEV001', 'receiver' => 'SALE01', 'amount' => 5, 'date' => '2026-06-05'], ['sender' => 1001, 'receiver' => 1005, 'amount' => 5, 'date' => '2026-06-05'],
['sender' => 'DEV001', 'receiver' => 'DEV002', 'amount' => 3, 'date' => '2026-06-12'], ['sender' => 1001, 'receiver' => 1002, 'amount' => 3, 'date' => '2026-06-12'],
['sender' => 'DEV002', 'receiver' => 'HR001', 'amount' => 4, 'date' => '2026-06-10'], ['sender' => 1002, 'receiver' => 1004, 'amount' => 4, 'date' => '2026-06-10'],
['sender' => 'TEST01', 'receiver' => 'DEV001', 'amount' => 2, 'date' => '2026-06-15'], ['sender' => 1003, 'receiver' => 1001, 'amount' => 2, 'date' => '2026-06-15'],
['sender' => 'SALE01', 'receiver' => 'DEV002', 'amount' => 5, 'date' => '2026-06-20'], ['sender' => 1005, 'receiver' => 1002, 'amount' => 5, 'date' => '2026-06-20'],
// July 2026 transactions // July 2026 transactions
['sender' => 'DEV001', 'receiver' => 'DEV002', 'amount' => 5, 'date' => '2026-07-02'], ['sender' => 1001, 'receiver' => 1002, 'amount' => 5, 'date' => '2026-07-02'],
['sender' => 'DEV001', 'receiver' => 'TEST01', 'amount' => 3, 'date' => '2026-07-10'], ['sender' => 1001, 'receiver' => 1003, 'amount' => 3, 'date' => '2026-07-10'],
['sender' => 'DEV002', 'receiver' => 'DEV001', 'amount' => 5, 'date' => '2026-07-15'], ['sender' => 1002, 'receiver' => 1001, 'amount' => 5, 'date' => '2026-07-15'],
['sender' => 'TEST01', 'receiver' => 'HR001', 'amount' => 4, 'date' => '2026-07-08'], ['sender' => 1003, 'receiver' => 1004, 'amount' => 4, 'date' => '2026-07-08'],
['sender' => 'SALE01', 'receiver' => 'DEV002', 'amount' => 4, 'date' => '2026-07-12'], ['sender' => 1005, 'receiver' => 1002, 'amount' => 4, 'date' => '2026-07-12'],
// August 2026 transactions // August 2026 transactions
['sender' => 'DEV001', 'receiver' => 'SALE01', 'amount' => 4, 'date' => '2026-08-05'], ['sender' => 1001, 'receiver' => 1005, 'amount' => 4, 'date' => '2026-08-05'],
['sender' => 'DEV002', 'receiver' => 'TEST01', 'amount' => 3, 'date' => '2026-08-12'], ['sender' => 1002, 'receiver' => 1003, 'amount' => 3, 'date' => '2026-08-12'],
['sender' => 'TEST01', 'receiver' => 'DEV001', 'amount' => 3, 'date' => '2026-08-10'], ['sender' => 1003, 'receiver' => 1001, 'amount' => 3, 'date' => '2026-08-10'],
['sender' => 'SALE01', 'receiver' => 'HR001', 'amount' => 5, 'date' => '2026-08-15'], ['sender' => 1005, 'receiver' => 1004, 'amount' => 5, 'date' => '2026-08-15'],
]; ];
foreach ($transactions as $t) { foreach ($transactions as $t) {
+20 -9
View File
@@ -67,16 +67,27 @@ 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) ### 4.2. Khi cập nhật giao diện (CSS / JS / Tailwind classes mới)
Do Docker container không cài Node.js, bạn cần chạy các lệnh compile frontend trực tiếp **ở máy thật (host)**: 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:
- **Chạy chế độ phát triển (Hot Reload):** **1️⃣ Chạy trên máy host (đề xuất)**
```bash ```bash
npm run dev npm install # một lần, nếu chưa có các package
``` npm run dev # Vite dev server, hotreload
- **Build production tĩnh:** ```
```bash
npm run build **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) ### 4.3. Khi cập nhật Database (Migrations)
+2
View File
@@ -34,6 +34,8 @@ services:
volumes: volumes:
- ..:/var/www/html - ..:/var/www/html
command: sh -c "npm install && npm run build" command: sh -c "npm install && npm run build"
ports:
- "5173:5173"
networks: networks:
- thankcard-system-network - thankcard-system-network
+1
View File
@@ -12,5 +12,6 @@ return [
'no_send_permission' => 'Bạn hiện không có quyền gửi Thank Card.', 'no_send_permission' => 'Bạn hiện không có quyền gửi Thank Card.',
'not_enough_cards' => 'Số lượng card của bạn không đủ. Vui lòng liên hệ Admin.', 'not_enough_cards' => 'Số lượng card của bạn không đủ. Vui lòng liên hệ Admin.',
'max_send_limit_template' => 'Một tháng bạn chỉ được gửi tối đa :max card cho một người. Bạn đã gửi :sent card cho nhân viên này.', '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.',
] ]
]; ];
+29 -4
View File
@@ -69,11 +69,24 @@
<path stroke-linecap="round" stroke-linejoin="round" d="M15 9h3.75M15 12h3.75M15 15h3.75M4.5 19.5h15a2.25 2.25 0 002.25-2.25V6.75A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25v10.5A2.25 2.25 0 004.5 19.5zm6-10.125a1.875 1.875 0 11-3.75 0 1.875 1.875 0 013.75 0zm1.294 6.336a6.721 6.721 0 01-3.17.789 6.721 6.721 0 01-3.168-.789 3.376 3.376 0 016.338 0z" /> <path stroke-linecap="round" stroke-linejoin="round" d="M15 9h3.75M15 12h3.75M15 15h3.75M4.5 19.5h15a2.25 2.25 0 002.25-2.25V6.75A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25v10.5A2.25 2.25 0 004.5 19.5zm6-10.125a1.875 1.875 0 11-3.75 0 1.875 1.875 0 013.75 0zm1.294 6.336a6.721 6.721 0 01-3.17.789 6.721 6.721 0 01-3.168-.789 3.376 3.376 0 016.338 0z" />
</svg> </svg>
</span> </span>
<input type="text" name="msnv" id="msnv" class="form-input form-input-with-icon bg-white" required value="{{ old('msnv') }}" placeholder="VD: RUN-0123"> <input type="number" name="msnv" id="msnv" class="form-input form-input-with-icon bg-white" required value="{{ old('msnv') }}" placeholder="VD: 1001">
</div> </div>
@error('msnv')<span class="error-text">{{ $message }}</span>@enderror @error('msnv')<span class="error-text">{{ $message }}</span>@enderror
</div> </div>
<div class="form-group">
<label class="form-label" for="name">Họ tên <span class="text-red-500">*</span></label>
<div class="form-input-group">
<span class="form-input-icon">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.501 20.118a7.5 7.5 0 0 1 14.998 0A17.933 17.933 0 0 1 12 21.75c-2.676 0-5.216-.584-7.499-1.632Z" />
</svg>
</span>
<input type="text" name="name" id="name" class="form-input form-input-with-icon bg-white" required value="{{ old('name') }}" placeholder="VD: Nguyễn Văn A">
</div>
@error('name')<span class="error-text">{{ $message }}</span>@enderror
</div>
<div class="form-group"> <div class="form-group">
<label class="form-label" for="mail">Email <span class="text-red-500">*</span></label> <label class="form-label" for="mail">Email <span class="text-red-500">*</span></label>
<div class="form-input-group"> <div class="form-input-group">
@@ -82,11 +95,23 @@
<path stroke-linecap="round" stroke-linejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75" /> <path stroke-linecap="round" stroke-linejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75" />
</svg> </svg>
</span> </span>
<input type="email" name="mail" id="mail" class="form-input form-input-with-icon bg-white" required value="{{ old('mail') }}" placeholder="VD: nguyenva@runsystem.net"> <input type="email" name="mail" id="mail" class="form-input form-input-with-icon bg-white" required value="{{ old('mail') }}" placeholder="VD: nguyenvana@runsystem.net">
</div> </div>
@error('mail')<span class="error-text">{{ $message }}</span>@enderror @error('mail')<span class="error-text">{{ $message }}</span>@enderror
</div> </div>
<div class="form-group">
<label class="form-label" for="departments">Phòng ban <span class="text-red-500">*</span></label>
<select name="departments" id="departments" class="form-input bg-white" required>
<option value="1" {{ old('departments') == '1' ? 'selected' : '' }}>Phòng Phát Triển (Dev)</option>
<option value="2" {{ old('departments') == '2' ? 'selected' : '' }}>Phòng Nhân Sự (HR)</option>
<option value="3" {{ old('departments') == '3' ? 'selected' : '' }}>Phòng Kinh Doanh (Sale)</option>
<option value="4" {{ old('departments') == '4' ? 'selected' : '' }}>Ban Giám Đốc (Board)</option>
<option value="5" {{ old('departments') == '5' ? 'selected' : '' }}>Phòng Marketing</option>
</select>
@error('departments')<span class="error-text">{{ $message }}</span>@enderror
</div>
<div class="form-group"> <div class="form-group">
<label class="form-label" for="password">Mật khẩu khởi tạo <span class="text-red-500">*</span></label> <label class="form-label" for="password">Mật khẩu khởi tạo <span class="text-red-500">*</span></label>
<div class="form-input-group"> <div class="form-input-group">
@@ -95,9 +120,9 @@
<path stroke-linecap="round" stroke-linejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 10-9 0V10.5m-2.812 10.5h14.625c.621 0 1.125-.504 1.125-1.125V11.25c0-.621-.504-1.125-1.125-1.125H3.75c-.621 0-1.125.504-1.125 1.125v7.875c0 .621.504 1.125 1.125 1.125z" /> <path stroke-linecap="round" stroke-linejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 10-9 0V10.5m-2.812 10.5h14.625c.621 0 1.125-.504 1.125-1.125V11.25c0-.621-.504-1.125-1.125-1.125H3.75c-.621 0-1.125.504-1.125 1.125v7.875c0 .621.504 1.125 1.125 1.125z" />
</svg> </svg>
</span> </span>
<input type="text" name="password" id="password" class="form-input form-input-with-icon bg-white" required placeholder="Nhập mật khẩu cho user..."> <input type="text" name="password" id="password" class="form-input form-input-with-icon bg-gray-50" readonly required value="1234@Dcba">
</div> </div>
<span class="helper-text">Lưu ý: User sẽ bị buộc phải đổi mật khẩu này lần đăng nhập đầu tiên.</span> <span class="helper-text">Mật khẩu mặc định cho tất cả user mới tạo.</span>
@error('password')<span class="error-text">{{ $message }}</span>@enderror @error('password')<span class="error-text">{{ $message }}</span>@enderror
</div> </div>
+6 -6
View File
@@ -53,9 +53,9 @@
</div> </div>
<!-- Row 3: Thanks Grid & Ranking --> <!-- Row 3: Thanks Grid & Ranking -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6"> <div class="grid grid-cols-1 lg:grid-cols-3 gap-6 items-start">
<!-- Recent Thanks (Left 2/3) --> <!-- Recent Thanks (Left 2/3) -->
<div class="col-span-1 lg:col-span-2 flex flex-col"> <div class="col-span-1 lg:col-span-2 flex flex-col flex-1">
<div class="flex items-center justify-between mb-4 pl-1 pr-1"> <div class="flex items-center justify-between mb-4 pl-1 pr-1">
<h3 class="text-[17px] font-extrabold text-[#1a2b49]">Thanks gần đây</h3> <h3 class="text-[17px] font-extrabold text-[#1a2b49]">Thanks gần đây</h3>
<a href="{{ route('user.received') }}" class="text-[13px] font-bold text-[#3462f7] hover:text-blue-800">Xem tất cả</a> <a href="{{ route('user.received') }}" class="text-[13px] font-bold text-[#3462f7] hover:text-blue-800">Xem tất cả</a>
@@ -96,22 +96,22 @@
</div> </div>
<!-- Ranking and Tip (Right 1/3) --> <!-- Ranking and Tip (Right 1/3) -->
<div class="col-span-1 flex flex-col"> <div class="col-span-1 flex flex-col w-full max-w-[420px] min-w-[380px] flex-1">
<div class="flex items-center justify-between mb-3 pl-1 pr-1"> <div class="flex items-center justify-between mb-3 pl-1 pr-1">
<h3 class="text-[17px] font-extrabold text-[#1a2b49]">Bảng xếp hạng</h3> <h3 class="text-[17px] font-extrabold text-[#1a2b49]">Bảng xếp hạng</h3>
<a href="{{ route('user.ranking') }}" class="text-[13px] font-bold text-[#3462f7] hover:text-blue-800">Xem tất cả</a> <a href="{{ route('user.ranking') }}" class="text-[13px] font-bold text-[#3462f7] hover:text-blue-800">Xem tất cả</a>
</div> </div>
<!-- Tabs Toggle --> <!-- Tabs Toggle -->
<div class="inline-flex bg-[#F5F7FB] rounded-xl p-1 mb-4 w-fit"> <div class="inline-flex bg-[#F5F7FB] rounded-xl p-1 mb-4 w-fit space-x-2">
<button id="tab-received" onclick="switchRankingTab('received')" class="flex items-center justify-center gap-2 w-[135px] py-2 rounded-[10px] transition-all bg-white shadow-[0_2px_4px_rgba(0,0,0,0.04)] text-[#3462f7] font-bold text-[14px]"> <button id="tab-received" onclick="switchRankingTab('received')" class="flex items-center justify-center gap-2 w-[120px] py-2 rounded-[10px] transition-all bg-white text-[#3462f7] font-bold text-[14px]">
<svg class="w-[18px] h-[18px]" fill="currentColor" viewBox="0 0 24 24"> <svg class="w-[18px] h-[18px]" fill="currentColor" viewBox="0 0 24 24">
<path d="M17.5 4h-11c-1.38 0-2.5 1.12-2.5 2.5V8c0 2.21 1.79 4 4 4h.34c1.17 2.19 3.52 3.56 6.16 3.56s4.99-1.37 6.16-3.56h.34c2.21 0 4-1.79 4-4v-1.5c0-1.38-1.12-2.5-2.5-2.5zm-11 6c-.55 0-1-.45-1-1V6.5c0-.28.22-.5.5-.5h2v4H6.5zm11 0h-2v-4h2c.28 0 .5.22.5.5V9c0 .55-.45 1-1 1zM11 16h2v4h-2v-4zm-2 4h6v2H9v-2z"/> <path d="M17.5 4h-11c-1.38 0-2.5 1.12-2.5 2.5V8c0 2.21 1.79 4 4 4h.34c1.17 2.19 3.52 3.56 6.16 3.56s4.99-1.37 6.16-3.56h.34c2.21 0 4-1.79 4-4v-1.5c0-1.38-1.12-2.5-2.5-2.5zm-11 6c-.55 0-1-.45-1-1V6.5c0-.28.22-.5.5-.5h2v4H6.5zm11 0h-2v-4h2c.28 0 .5.22.5.5V9c0 .55-.45 1-1 1zM11 16h2v4h-2v-4zm-2 4h6v2H9v-2z"/>
<path fill="#ffffff" d="M12 11.5L10 7h4z"/> <path fill="#ffffff" d="M12 11.5L10 7h4z"/>
</svg> </svg>
TOP Nhận TOP Nhận
</button> </button>
<button id="tab-sent" onclick="switchRankingTab('sent')" class="flex items-center justify-center gap-2 w-[135px] py-2 rounded-[10px] transition-all text-[#64748b] hover:text-gray-800 font-bold text-[14px]"> <button id="tab-sent" onclick="switchRankingTab('sent')" class="flex items-center justify-center gap-2 w-[120px] py-2 rounded-[10px] transition-all text-[#64748b] hover:text-gray-800 font-bold text-[14px]">
<svg class="w-[18px] h-[18px]" fill="none" stroke="currentColor" stroke-width="2.2" viewBox="0 0 24 24" stroke-linecap="round" stroke-linejoin="round"> <svg class="w-[18px] h-[18px]" fill="none" stroke="currentColor" stroke-width="2.2" viewBox="0 0 24 24" stroke-linecap="round" stroke-linejoin="round">
<path d="M22 2L11 13"></path> <path d="M22 2L11 13"></path>
<path d="M22 2L15 22L11 13L2 9L22 2Z"></path> <path d="M22 2L15 22L11 13L2 9L22 2Z"></path>
+38 -31
View File
@@ -25,7 +25,8 @@ class AdminUserListStatsTest extends TestCase
{ {
// 1. Create mock users // 1. Create mock users
$userA = User::create([ $userA = User::create([
'msnv' => 'USER_A', 'msnv' => 1001,
'name' => 'User A',
'mail' => 'usera@example.com', 'mail' => 'usera@example.com',
'pass' => bcrypt('password'), 'pass' => bcrypt('password'),
'role' => User::ROLE_MEMBER, 'role' => User::ROLE_MEMBER,
@@ -36,7 +37,8 @@ class AdminUserListStatsTest extends TestCase
]); ]);
$userB = User::create([ $userB = User::create([
'msnv' => 'USER_B', 'msnv' => 1002,
'name' => 'User B',
'mail' => 'userb@example.com', 'mail' => 'userb@example.com',
'pass' => bcrypt('password'), 'pass' => bcrypt('password'),
'role' => User::ROLE_MEMBER, 'role' => User::ROLE_MEMBER,
@@ -52,18 +54,18 @@ class AdminUserListStatsTest extends TestCase
// 2. Create mock transaction logs in administration table // 2. Create mock transaction logs in administration table
// A sends 3 cards to B // A sends 3 cards to B
Administration::create([ Administration::create([
'msnv' => 'USER_A', 'msnv' => 1001,
'received' => 0, 'received' => 0,
'sender' => null, 'sender' => null,
'sent' => 3, 'sent' => 3,
'receiver' => 'USER_B', 'receiver' => 1002,
'date' => $testDate, 'date' => $testDate,
]); ]);
Administration::create([ Administration::create([
'msnv' => 'USER_B', 'msnv' => 1002,
'received' => 3, 'received' => 3,
'sender' => 'USER_A', 'sender' => 1001,
'sent' => 0, 'sent' => 0,
'receiver' => null, 'receiver' => null,
'date' => $testDate, 'date' => $testDate,
@@ -77,8 +79,8 @@ class AdminUserListStatsTest extends TestCase
$topSent = $stats['topSentUser']; $topSent = $stats['topSentUser'];
// Find users in the paginated collection // Find users in the paginated collection
$userAResult = collect($users->items())->firstWhere('msnv', 'USER_A'); $userAResult = collect($users->items())->firstWhere('msnv', 1001);
$userBResult = collect($users->items())->firstWhere('msnv', 'USER_B'); $userBResult = collect($users->items())->firstWhere('msnv', 1002);
$this->assertNotNull($userAResult); $this->assertNotNull($userAResult);
$this->assertNotNull($userBResult); $this->assertNotNull($userBResult);
@@ -91,17 +93,18 @@ class AdminUserListStatsTest extends TestCase
$this->assertEquals(3, $userBResult->total_received); $this->assertEquals(3, $userBResult->total_received);
// Assert top users // Assert top users
$this->assertEquals('USER_B', $topReceived->msnv); $this->assertEquals(1002, $topReceived->msnv);
$this->assertEquals(3, $topReceived->total_received); $this->assertEquals(3, $topReceived->total_received);
$this->assertEquals('USER_A', $topSent->msnv); $this->assertEquals(1001, $topSent->msnv);
$this->assertEquals(3, $topSent->total_sent); $this->assertEquals(3, $topSent->total_sent);
} }
public function test_user_transactions_history_displays_correct_records(): void public function test_user_transactions_history_displays_correct_records(): void
{ {
$userA = User::create([ $userA = User::create([
'msnv' => 'USER_A', 'msnv' => 1001,
'name' => 'User A',
'mail' => 'usera@example.com', 'mail' => 'usera@example.com',
'pass' => bcrypt('password'), 'pass' => bcrypt('password'),
'role' => User::ROLE_MEMBER, 'role' => User::ROLE_MEMBER,
@@ -116,25 +119,25 @@ class AdminUserListStatsTest extends TestCase
// 1. Create a send record // 1. Create a send record
Administration::create([ Administration::create([
'msnv' => 'USER_A', 'msnv' => 1001,
'received' => 0, 'received' => 0,
'sender' => null, 'sender' => null,
'sent' => 3, 'sent' => 3,
'receiver' => 'USER_B', 'receiver' => 1002,
'date' => $testDate, 'date' => $testDate,
]); ]);
// 2. Create a receive record // 2. Create a receive record
Administration::create([ Administration::create([
'msnv' => 'USER_A', 'msnv' => 1001,
'received' => 5, 'received' => 5,
'sender' => 'USER_C', 'sender' => 1003,
'sent' => 0, 'sent' => 0,
'receiver' => null, 'receiver' => null,
'date' => $testDate, 'date' => $testDate,
]); ]);
$transactions = $this->adminService->getUserTransactions('USER_A', $selectedMonth); $transactions = $this->adminService->getUserTransactions(1001, $selectedMonth);
$this->assertCount(2, $transactions->items()); $this->assertCount(2, $transactions->items());
@@ -145,10 +148,10 @@ class AdminUserListStatsTest extends TestCase
$this->assertNotNull($sendTx); $this->assertNotNull($sendTx);
$this->assertNotNull($receiveTx); $this->assertNotNull($receiveTx);
$this->assertEquals('USER_B', $sendTx->receiver); $this->assertEquals(1002, $sendTx->receiver);
$this->assertNull($sendTx->sender); $this->assertNull($sendTx->sender);
$this->assertEquals('USER_C', $receiveTx->sender); $this->assertEquals(1003, $receiveTx->sender);
$this->assertNull($receiveTx->receiver); $this->assertNull($receiveTx->receiver);
} }
@@ -156,7 +159,8 @@ class AdminUserListStatsTest extends TestCase
{ {
// 1. Create mock users // 1. Create mock users
User::create([ User::create([
'msnv' => 'DEV_ALPHA', 'msnv' => 2001,
'name' => 'DEV ALPHA',
'mail' => 'alpha@example.com', 'mail' => 'alpha@example.com',
'pass' => bcrypt('password'), 'pass' => bcrypt('password'),
'role' => User::ROLE_MEMBER, 'role' => User::ROLE_MEMBER,
@@ -167,7 +171,8 @@ class AdminUserListStatsTest extends TestCase
]); ]);
User::create([ User::create([
'msnv' => 'DEV_BETA', 'msnv' => 2002,
'name' => 'DEV BETA',
'mail' => 'beta@example.com', 'mail' => 'beta@example.com',
'pass' => bcrypt('password'), 'pass' => bcrypt('password'),
'role' => User::ROLE_MEMBER, 'role' => User::ROLE_MEMBER,
@@ -177,26 +182,27 @@ class AdminUserListStatsTest extends TestCase
'first_login' => User::FIRST_LOGIN_FALSE, 'first_login' => User::FIRST_LOGIN_FALSE,
]); ]);
// 2. Fetch statistics via AdminService with search for 'ALPHA' // 2. Fetch statistics via AdminService with search for '2001' (msnv)
$stats = $this->adminService->getUserListWithStats('2026-07', 'ALPHA'); $stats = $this->adminService->getUserListWithStats('2026-07', '2001');
$users = $stats['users']; $users = $stats['users'];
$this->assertCount(1, $users->items()); $this->assertCount(1, $users->items());
$this->assertEquals('DEV_ALPHA', $users->items()[0]->msnv); $this->assertEquals(2001, $users->items()[0]->msnv);
// 3. Fetch statistics via AdminService with search for 'beta@example.com' // 3. Fetch statistics via AdminService with search for 'beta@example.com' (mail)
$stats2 = $this->adminService->getUserListWithStats('2026-07', 'beta@example.com'); $stats2 = $this->adminService->getUserListWithStats('2026-07', 'beta@example.com');
$users2 = $stats2['users']; $users2 = $stats2['users'];
$this->assertCount(1, $users2->items()); $this->assertCount(1, $users2->items());
$this->assertEquals('DEV_BETA', $users2->items()[0]->msnv); $this->assertEquals(2002, $users2->items()[0]->msnv);
} }
public function test_user_list_stats_filters_by_flag_send(): void public function test_user_list_stats_filters_by_flag_send(): void
{ {
// 1. Create mock users // 1. Create mock users
User::create([ User::create([
'msnv' => 'DEV_ENABLED', 'msnv' => 3001,
'name' => 'DEV ENABLED',
'mail' => 'enabled@example.com', 'mail' => 'enabled@example.com',
'pass' => bcrypt('password'), 'pass' => bcrypt('password'),
'role' => User::ROLE_MEMBER, 'role' => User::ROLE_MEMBER,
@@ -207,7 +213,8 @@ class AdminUserListStatsTest extends TestCase
]); ]);
User::create([ User::create([
'msnv' => 'DEV_DISABLED', 'msnv' => 3002,
'name' => 'DEV DISABLED',
'mail' => 'disabled@example.com', 'mail' => 'disabled@example.com',
'pass' => bcrypt('password'), 'pass' => bcrypt('password'),
'role' => User::ROLE_MEMBER, 'role' => User::ROLE_MEMBER,
@@ -220,13 +227,13 @@ class AdminUserListStatsTest extends TestCase
// 2. Fetch statistics via AdminService with flag_send = '1' // 2. Fetch statistics via AdminService with flag_send = '1'
$stats = $this->adminService->getUserListWithStats('2026-07', null, '1'); $stats = $this->adminService->getUserListWithStats('2026-07', null, '1');
$users = collect($stats['users']->items()); $users = collect($stats['users']->items());
$this->assertTrue($users->contains('msnv', 'DEV_ENABLED')); $this->assertTrue($users->contains('msnv', 3001));
$this->assertFalse($users->contains('msnv', 'DEV_DISABLED')); $this->assertFalse($users->contains('msnv', 3002));
// 3. Fetch statistics via AdminService with flag_send = '0' // 3. Fetch statistics via AdminService with flag_send = '0'
$stats2 = $this->adminService->getUserListWithStats('2026-07', null, '0'); $stats2 = $this->adminService->getUserListWithStats('2026-07', null, '0');
$users2 = collect($stats2['users']->items()); $users2 = collect($stats2['users']->items());
$this->assertTrue($users2->contains('msnv', 'DEV_DISABLED')); $this->assertTrue($users2->contains('msnv', 3002));
$this->assertFalse($users2->contains('msnv', 'DEV_ENABLED')); $this->assertFalse($users2->contains('msnv', 3001));
} }
} }