diff --git a/.env.testing b/.env.testing
new file mode 100644
index 0000000..234106d
--- /dev/null
+++ b/.env.testing
@@ -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
diff --git a/app/Http/Controllers/AdminController.php b/app/Http/Controllers/AdminController.php
index 21881c7..d48343f 100644
--- a/app/Http/Controllers/AdminController.php
+++ b/app/Http/Controllers/AdminController.php
@@ -16,11 +16,21 @@ class AdminController extends Controller
public function index(Request $request)
{
$selectedMonth = $request->input('month', Carbon::now()->format('Y-m'));
+ $search = $request->input('search');
+ $flagSend = $request->input('flag_send');
['users' => $users, 'topReceivedUser' => $topReceivedUser, 'topSentUser' => $topSentUser]
- = $this->adminService->getUserListWithStats($selectedMonth);
+ = $this->adminService->getUserListWithStats($selectedMonth, $search, $flagSend);
- return view('admin.users.index', compact('users', 'selectedMonth', 'topReceivedUser', 'topSentUser'));
+ if ($request->ajax() || $request->wantsJson()) {
+ return response()->json([
+ 'title' => view('admin.users.partials.title', compact('selectedMonth'))->render(),
+ 'stats' => view('admin.users.partials.stats', compact('topReceivedUser', 'topSentUser'))->render(),
+ 'table' => view('admin.users.partials.table', compact('users'))->render(),
+ ]);
+ }
+
+ return view('admin.users.index', compact('users', 'selectedMonth', 'topReceivedUser', 'topSentUser', 'search', 'flagSend'));
}
public function create()
diff --git a/app/Services/Admin/AdminService.php b/app/Services/Admin/AdminService.php
index f6d25c4..433fad2 100644
--- a/app/Services/Admin/AdminService.php
+++ b/app/Services/Admin/AdminService.php
@@ -14,39 +14,38 @@ use Illuminate\Support\Facades\Hash;
class AdminService implements AdminServiceInterface
{
- public function getUserListWithStats(string $selectedMonth): array
+ public function getUserListWithStats(string $selectedMonth, ?string $search = null, ?string $flagSend = null): array
{
$startOfMonth = Carbon::parse($selectedMonth)->startOfMonth();
$endOfMonth = Carbon::parse($selectedMonth)->endOfMonth();
- $allActiveUsers = User::where('status', User::STATUS_ACTIVE)->get()->map(function ($user) use ($startOfMonth, $endOfMonth) {
- $user->total_received = Administration::where('receiver', $user->msnv)
- ->whereBetween('date', [$startOfMonth, $endOfMonth])
- ->sum('received');
+ $usersQuery = User::select('user.*')
+ ->where('status', User::STATUS_ACTIVE)
+ ->addSelect([
+ 'total_received' => Administration::selectRaw('COALESCE(SUM(received), 0)')
+ ->whereColumn('msnv', 'user.msnv')
+ ->whereBetween('date', [$startOfMonth, $endOfMonth]),
+ 'total_sent' => Administration::selectRaw('COALESCE(SUM(sent), 0)')
+ ->whereColumn('msnv', 'user.msnv')
+ ->whereBetween('date', [$startOfMonth, $endOfMonth])
+ ]);
- $user->total_sent = Administration::where('sender', $user->msnv)
- ->whereBetween('date', [$startOfMonth, $endOfMonth])
- ->sum('sent');
+ $topReceivedUser = (clone $usersQuery)->orderByDesc('total_received')->first();
+ $topSentUser = (clone $usersQuery)->orderByDesc('total_sent')->first();
- return $user;
- });
+ if (!is_null($search) && trim($search) !== '') {
+ $search = trim($search);
+ $usersQuery->where(function ($q) use ($search) {
+ $q->whereRaw('LOWER(msnv) LIKE ?', ['%' . strtolower($search) . '%'])
+ ->orWhereRaw('LOWER(mail) LIKE ?', ['%' . strtolower($search) . '%']);
+ });
+ }
- $topReceivedUser = $allActiveUsers->sortByDesc('total_received')->first();
- $topSentUser = $allActiveUsers->sortByDesc('total_sent')->first();
+ if (!is_null($flagSend) && $flagSend !== '') {
+ $usersQuery->where('flag_send', intval($flagSend));
+ }
- $users = User::where('status', User::STATUS_ACTIVE)->paginate(10)->withQueryString();
-
- $users->getCollection()->transform(function ($user) use ($startOfMonth, $endOfMonth) {
- $user->total_received = Administration::where('receiver', $user->msnv)
- ->whereBetween('date', [$startOfMonth, $endOfMonth])
- ->sum('received');
-
- $user->total_sent = Administration::where('sender', $user->msnv)
- ->whereBetween('date', [$startOfMonth, $endOfMonth])
- ->sum('sent');
-
- return $user;
- });
+ $users = $usersQuery->paginate(10)->withQueryString();
return compact('users', 'topReceivedUser', 'topSentUser');
}
@@ -56,9 +55,7 @@ class AdminService implements AdminServiceInterface
$startOfMonth = Carbon::parse($selectedMonth)->startOfMonth();
$endOfMonth = Carbon::parse($selectedMonth)->endOfMonth();
- return Administration::where(function ($q) use ($msnv) {
- $q->where('sender', $msnv)->orWhere('receiver', $msnv);
- })
+ return Administration::where('msnv', $msnv)
->whereBetween('date', [$startOfMonth, $endOfMonth])
->orderBy('date', 'desc')
->paginate(10)
diff --git a/app/Services/Admin/Contracts/AdminServiceInterface.php b/app/Services/Admin/Contracts/AdminServiceInterface.php
index 414b212..22dd5b8 100644
--- a/app/Services/Admin/Contracts/AdminServiceInterface.php
+++ b/app/Services/Admin/Contracts/AdminServiceInterface.php
@@ -6,7 +6,7 @@ use Illuminate\Pagination\LengthAwarePaginator;
interface AdminServiceInterface
{
- public function getUserListWithStats(string $selectedMonth): array;
+ public function getUserListWithStats(string $selectedMonth, ?string $search = null, ?string $flagSend = null): array;
public function getUserTransactions(string $msnv, string $selectedMonth): LengthAwarePaginator;
diff --git a/database/seeders/MockDataSeeder.php b/database/seeders/MockDataSeeder.php
index 4784867..b2bb92c 100644
--- a/database/seeders/MockDataSeeder.php
+++ b/database/seeders/MockDataSeeder.php
@@ -52,11 +52,11 @@ class MockDataSeeder extends Seeder
// Active Users (Initialize with specific users needed for logs/transactions)
$users = [
- ['msnv' => 'DEV001', 'mail' => 'nguyenvana@runsystem.net', 'card' => 15, 'flag' => User::FLAG_SEND_ENABLED, 'first_login' => User::FIRST_LOGIN_FALSE],
+ ['msnv' => 'DEV001', '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' => 'TEST01', 'mail' => 'lekiemthu@runsystem.net', 'card' => 8, 'flag' => User::FLAG_SEND_ENABLED, 'first_login' => User::FIRST_LOGIN_TRUE], // First-time login
+ ['msnv' => 'TEST01', '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' => 'SALE01', 'mail' => 'bangiang@runsystem.net', 'card' => 50, 'flag' => User::FLAG_SEND_ENABLED, 'first_login' => User::FIRST_LOGIN_FALSE],
+ ['msnv' => 'SALE01', '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
@@ -82,6 +82,18 @@ class MockDataSeeder extends Seeder
'flag_send' => $u['flag'],
'first_login' => $u['first_login'],
]);
+
+ // For other users, if they have cards, create a matching purchase log in June 2026
+ if (!in_array($u['msnv'], ['DEV001', 'DEV002', 'TEST01', 'SALE01'])) {
+ if ($u['card'] > 0) {
+ AddCard::create([
+ 'buyer' => abs(crc32($u['msnv'])) % 1000000,
+ 'num_card' => $u['card'],
+ 'seller' => abs(crc32('ADMIN')) % 1000000,
+ 'date' => '2026-06-01'
+ ]);
+ }
+ }
}
// Inactive User
@@ -97,18 +109,26 @@ class MockDataSeeder extends Seeder
]);
// 2. CREATE CARD ADDITION LOGS (add_card table)
- // Get the first admin (id = 1) as the loader
- $adminId = 1;
- $now = Carbon::now();
-
+ // Manual purchase history for specific users:
$addCardLogs = [
- ['buyer' => 'DEV001', 'num_card' => 20, 'date' => clone $now],
- ['buyer' => 'DEV002', 'num_card' => 10, 'date' => clone $now],
- ['buyer' => 'SALE01', 'num_card' => 50, 'date' => (clone $now)->subDays(2)],
- ['buyer' => 'TEST01', 'num_card' => 10, 'date' => (clone $now)->subMonth()], // Last month
+ // DEV001: total 30 cards bought
+ ['buyer' => 'DEV001', 'num_card' => 15, 'date' => '2026-06-01'],
+ ['buyer' => 'DEV001', 'num_card' => 10, 'date' => '2026-07-01'],
+ ['buyer' => 'DEV001', 'num_card' => 5, 'date' => '2026-08-01'],
+
+ // DEV002: total 15 cards bought
+ ['buyer' => 'DEV002', 'num_card' => 10, 'date' => '2026-06-02'],
+ ['buyer' => 'DEV002', 'num_card' => 5, 'date' => '2026-07-01'],
+
+ // TEST01: total 15 cards bought
+ ['buyer' => 'TEST01', 'num_card' => 10, 'date' => '2026-06-03'],
+ ['buyer' => 'TEST01', 'num_card' => 5, 'date' => '2026-08-02'],
+
+ // SALE01: total 50 cards bought
+ ['buyer' => 'SALE01', 'num_card' => 50, 'date' => '2026-06-04'],
];
- foreach ($addCardLogs as $index => $log) {
+ foreach ($addCardLogs as $log) {
AddCard::create([
'buyer' => abs(crc32($log['buyer'])) % 1000000,
'num_card' => $log['num_card'],
@@ -118,28 +138,26 @@ class MockDataSeeder extends Seeder
}
// 3. CREATE CARD SENDING HISTORY (administration table)
- // Create transactions to populate Top Received / Top Sent for the month
$transactions = [
- // Current month transactions
- ['sender' => 'DEV001', 'receiver' => 'DEV002', 'amount' => 5, 'date' => clone $now],
- ['sender' => 'DEV001', 'receiver' => 'TEST01', 'amount' => 3, 'date' => (clone $now)->subDays(1)],
- ['sender' => 'SALE01', 'receiver' => 'DEV002', 'amount' => 4, 'date' => (clone $now)->subDays(2)],
- ['sender' => 'TEST01', 'receiver' => 'HR001', 'amount' => 2, 'date' => (clone $now)->subDays(3)],
- ['sender' => 'DEV002', 'receiver' => 'DEV001', 'amount' => 1, 'date' => clone $now],
-
- // Last month transactions
- ['sender' => 'DEV001', 'receiver' => 'SALE01', 'amount' => 5, 'date' => (clone $now)->subMonth()],
- ['sender' => 'HR001', 'receiver' => 'DEV001', 'amount' => 5, 'date' => (clone $now)->subMonth()->subDays(5)],
-
- // July 2026 transactions (Diverse Mock Data)
- ['sender' => 'DEV001', 'receiver' => 'SALE01', 'amount' => 4, 'date' => Carbon::create(2026, 7, 5)],
- ['sender' => 'SALE01', 'receiver' => 'TEST01', 'amount' => 2, 'date' => Carbon::create(2026, 7, 12)],
- ['sender' => 'DEV002', 'receiver' => 'HR001', 'amount' => 1, 'date' => Carbon::create(2026, 7, 18)],
- ['sender' => 'TEST01', 'receiver' => 'DEV001', 'amount' => 3, 'date' => Carbon::create(2026, 7, 22)],
- ['sender' => 'HR001', 'receiver' => 'DEV002', 'amount' => 5, 'date' => Carbon::create(2026, 7, 28)],
- ['sender' => 'SALE01', 'receiver' => 'DEV001', 'amount' => 2, 'date' => Carbon::create(2026, 7, 30)],
- ['sender' => 'DEV001', 'receiver' => 'DEV002', 'amount' => 3, 'date' => Carbon::create(2026, 7, 15)],
- ['sender' => 'TEST01', 'receiver' => 'SALE01', 'amount' => 4, 'date' => Carbon::create(2026, 7, 8)],
+ // June 2026 transactions
+ ['sender' => 'DEV001', 'receiver' => 'SALE01', 'amount' => 5, 'date' => '2026-06-05'],
+ ['sender' => 'DEV001', 'receiver' => 'DEV002', 'amount' => 3, 'date' => '2026-06-12'],
+ ['sender' => 'DEV002', 'receiver' => 'HR001', 'amount' => 4, 'date' => '2026-06-10'],
+ ['sender' => 'TEST01', 'receiver' => 'DEV001', 'amount' => 2, 'date' => '2026-06-15'],
+ ['sender' => 'SALE01', 'receiver' => 'DEV002', 'amount' => 5, 'date' => '2026-06-20'],
+
+ // July 2026 transactions
+ ['sender' => 'DEV001', 'receiver' => 'DEV002', 'amount' => 5, 'date' => '2026-07-02'],
+ ['sender' => 'DEV001', 'receiver' => 'TEST01', 'amount' => 3, 'date' => '2026-07-10'],
+ ['sender' => 'DEV002', 'receiver' => 'DEV001', 'amount' => 5, 'date' => '2026-07-15'],
+ ['sender' => 'TEST01', 'receiver' => 'HR001', 'amount' => 4, 'date' => '2026-07-08'],
+ ['sender' => 'SALE01', 'receiver' => 'DEV002', 'amount' => 4, 'date' => '2026-07-12'],
+
+ // August 2026 transactions
+ ['sender' => 'DEV001', 'receiver' => 'SALE01', 'amount' => 4, 'date' => '2026-08-05'],
+ ['sender' => 'DEV002', 'receiver' => 'TEST01', 'amount' => 3, 'date' => '2026-08-12'],
+ ['sender' => 'TEST01', 'receiver' => 'DEV001', 'amount' => 3, 'date' => '2026-08-10'],
+ ['sender' => 'SALE01', 'receiver' => 'HR001', 'amount' => 5, 'date' => '2026-08-15'],
];
foreach ($transactions as $t) {
@@ -150,7 +168,7 @@ class MockDataSeeder extends Seeder
'sender' => null,
'sent' => $t['amount'],
'receiver' => $t['receiver'],
- 'date' => $t['date']->format('Y-m-d')
+ 'date' => $t['date']
]);
// Receiver record
@@ -160,7 +178,7 @@ class MockDataSeeder extends Seeder
'sender' => $t['sender'],
'sent' => 0,
'receiver' => null,
- 'date' => $t['date']->format('Y-m-d')
+ 'date' => $t['date']
]);
}
}
diff --git a/phpunit.xml b/phpunit.xml
index e7f0a48..9280b2f 100644
--- a/phpunit.xml
+++ b/phpunit.xml
@@ -18,19 +18,19 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/resources/assets/icons/icn-chevron-left.svg b/resources/assets/icons/icn-chevron-left.svg
new file mode 100644
index 0000000..1f1a8f0
--- /dev/null
+++ b/resources/assets/icons/icn-chevron-left.svg
@@ -0,0 +1 @@
+
diff --git a/resources/assets/icons/icn-chevron-right.svg b/resources/assets/icons/icn-chevron-right.svg
new file mode 100644
index 0000000..497fe66
--- /dev/null
+++ b/resources/assets/icons/icn-chevron-right.svg
@@ -0,0 +1 @@
+
diff --git a/resources/assets/icons/icn-close.svg b/resources/assets/icons/icn-close.svg
new file mode 100644
index 0000000..db7fbb9
--- /dev/null
+++ b/resources/assets/icons/icn-close.svg
@@ -0,0 +1 @@
+
diff --git a/resources/assets/icons/icn-menu.svg b/resources/assets/icons/icn-menu.svg
new file mode 100644
index 0000000..db7fbb9
--- /dev/null
+++ b/resources/assets/icons/icn-menu.svg
@@ -0,0 +1 @@
+
diff --git a/resources/css/app.css b/resources/css/app.css
index db67d17..2b93ce1 100644
--- a/resources/css/app.css
+++ b/resources/css/app.css
@@ -218,7 +218,7 @@ select.form-input {
}
.form-actions {
- @apply mt-2 flex flex-col sm:flex-row justify-end gap-4;
+ @apply mt-2 flex flex-col sm:flex-row justify-end gap-4 mb-4;
}
.form-footer {
@@ -249,3 +249,15 @@ select.form-input {
.notification-badge {
@apply absolute -top-1 -right-1 bg-secondary text-white text-[10px] font-bold w-4.5 h-4.5 rounded-full flex items-center justify-center;
}
+
+.table-header-cell {
+ @apply px-6 py-4 text-left text-xs font-bold text-text-light uppercase tracking-wider;
+}
+
+.table-body-cell {
+ @apply px-6 py-3 whitespace-nowrap;
+}
+
+.table-action-link {
+ @apply hover:text-primary-hover font-semibold underline underline-offset-2;
+}
diff --git a/resources/js/AjaxTable.js b/resources/js/AjaxTable.js
new file mode 100644
index 0000000..8c438d1
--- /dev/null
+++ b/resources/js/AjaxTable.js
@@ -0,0 +1,223 @@
+export class AjaxTable {
+ constructor(options = {}) {
+ this.formId = options.formId || 'filterForm';
+ this.tableContainerSelector = options.tableContainerSelector || '#userTableContainer';
+ this.spinnerId = options.spinnerId || 'loadingSpinner';
+ this.searchInputId = options.searchInputId || 'searchInput';
+ this.btnClearSearchId = options.btnClearSearchId || 'btnClearSearch';
+ this.btnClearFiltersId = options.btnClearFiltersId || 'btnClearFilters';
+ this.flagSendFilterId = options.flagSendFilterId || 'flagSendFilter';
+ this.monthFilterId = options.monthFilterId || 'monthFilter';
+ this.skeletonColumns = options.skeletonColumns || 7;
+
+ this.isLoading = false;
+ this.searchDebounceTimer = null;
+
+ this.init();
+ }
+
+ init() {
+ this.form = document.getElementById(this.formId);
+ this.spinner = document.getElementById(this.spinnerId);
+ this.searchInput = document.getElementById(this.searchInputId);
+ this.btnClearSearch = document.getElementById(this.btnClearSearchId);
+ this.btnClearFilters = document.getElementById(this.btnClearFiltersId);
+ this.flagSendFilter = document.getElementById(this.flagSendFilterId);
+ this.monthFilter = document.getElementById(this.monthFilterId);
+
+ if (this.form) {
+ this.form.addEventListener('submit', (e) => {
+ e.preventDefault();
+ this.submitForm();
+ });
+ }
+
+ if (this.searchInput) {
+ this.searchInput.addEventListener('input', () => {
+ this.toggleClearSearchBtn();
+ clearTimeout(this.searchDebounceTimer);
+ this.searchDebounceTimer = setTimeout(() => {
+ this.submitForm();
+ }, 500);
+ });
+ }
+
+ if (this.btnClearSearch) {
+ this.btnClearSearch.addEventListener('click', () => {
+ if (this.searchInput) {
+ this.searchInput.value = '';
+ this.toggleClearSearchBtn();
+ this.submitForm();
+ }
+ });
+ }
+
+ if (this.flagSendFilter) {
+ this.flagSendFilter.addEventListener('change', () => {
+ this.submitForm();
+ });
+ }
+
+ if (this.btnClearFilters) {
+ this.btnClearFilters.addEventListener('click', () => {
+ if (this.searchInput) this.searchInput.value = '';
+ if (this.flagSendFilter) this.flagSendFilter.value = '';
+ if (this.monthFilter) {
+ this.monthFilter.value = new Date().toISOString().slice(0, 7);
+ }
+ this.toggleClearSearchBtn();
+ this.submitForm();
+ });
+ }
+
+ // Intercept pagination links
+ document.addEventListener('click', (e) => {
+ if (this.isLoading) {
+ e.preventDefault();
+ return;
+ }
+ const link = e.target.closest(`${this.tableContainerSelector} nav a`);
+ if (link) {
+ e.preventDefault();
+ this.loadUrl(link.href);
+ }
+ });
+
+ // Browser history handling
+ window.addEventListener('popstate', () => {
+ this.loadUrl(window.location.href, false);
+ });
+
+ this.toggleClearSearchBtn();
+ }
+
+ toggleClearSearchBtn() {
+ if (this.searchInput && this.btnClearSearch) {
+ if (this.searchInput.value.trim() !== '') {
+ this.btnClearSearch.classList.remove('hidden');
+ } else {
+ this.btnClearSearch.classList.add('hidden');
+ }
+ }
+ }
+
+ submitForm() {
+ if (!this.form) return;
+ const url = this.form.action + '?' + new URLSearchParams(new FormData(this.form)).toString();
+ this.loadUrl(url);
+ }
+
+ getSkeletonHtml() {
+ let html = '';
+ for (let i = 0; i < 10; i++) {
+ html += `
+
+ |
+ |
+ |
+ |
+ |
+ |
+ |
+
`;
+ }
+ return html;
+ }
+
+ async loadUrl(url, updateHistory = true) {
+ if (this.isLoading) return;
+ this.isLoading = true;
+
+ if (this.spinner) {
+ this.spinner.classList.remove('hidden');
+ }
+
+ let formElements = [];
+ if (this.form) {
+ formElements = this.form.querySelectorAll('input, select, button');
+ formElements.forEach(el => el.disabled = true);
+ }
+
+ const tableContainer = document.querySelector(this.tableContainerSelector);
+ const tableBody = tableContainer ? tableContainer.querySelector('tbody') : null;
+ if (tableBody) {
+ tableBody.innerHTML = this.getSkeletonHtml();
+ }
+
+ try {
+ const [res] = await Promise.all([
+ fetch(url, { headers: { 'X-Requested-With': 'XMLHttpRequest' } }),
+ new Promise(resolve => setTimeout(resolve, 200)) // smooth transition
+ ]);
+
+ if (!res.ok) throw new Error('Network response was not ok');
+
+ const contentType = res.headers.get('content-type');
+ const updateDOM = async () => {
+ if (contentType && contentType.includes('application/json')) {
+ const data = await res.json();
+
+ const titleEl = document.getElementById('pageTitle');
+ if (titleEl && data.title !== undefined) {
+ titleEl.innerHTML = data.title;
+ }
+
+ const statsEl = document.getElementById('statsGrid');
+ if (statsEl && data.stats !== undefined) {
+ statsEl.innerHTML = data.stats;
+ }
+
+ if (tableContainer && data.table !== undefined) {
+ tableContainer.innerHTML = data.table;
+ }
+ } else {
+ const html = await res.text();
+ const parser = new DOMParser();
+ const doc = parser.parseFromString(html, 'text/html');
+
+ const currentTitle = document.getElementById('pageTitle');
+ const newTitle = doc.getElementById('pageTitle');
+ if (currentTitle && newTitle) currentTitle.innerHTML = newTitle.innerHTML;
+
+ const currentGrid = document.getElementById('statsGrid');
+ const newGrid = doc.getElementById('statsGrid');
+ if (currentGrid && newGrid) currentGrid.innerHTML = newGrid.innerHTML;
+
+ const newTable = doc.querySelector(this.tableContainerSelector);
+ if (tableContainer && newTable) tableContainer.innerHTML = newTable.innerHTML;
+ }
+ };
+
+ if (document.startViewTransition) {
+ document.startViewTransition(updateDOM);
+ } else {
+ await updateDOM();
+ }
+
+ if (updateHistory) {
+ window.history.pushState({}, '', url);
+ }
+
+ const newTableContainer = document.querySelector(this.tableContainerSelector);
+ if (newTableContainer) {
+ newTableContainer.scrollIntoView({
+ behavior: 'smooth',
+ block: 'start'
+ });
+ }
+
+ } catch (err) {
+ console.error('Error fetching data:', err);
+ window.location.reload();
+ } finally {
+ this.isLoading = false;
+ if (this.spinner) {
+ this.spinner.classList.add('hidden');
+ }
+ if (this.form) {
+ formElements.forEach(el => el.disabled = false);
+ }
+ this.toggleClearSearchBtn();
+ }
+ }
+}
diff --git a/resources/js/app.js b/resources/js/app.js
index e59d6a0..75f1282 100644
--- a/resources/js/app.js
+++ b/resources/js/app.js
@@ -1 +1,27 @@
import './bootstrap';
+import { AjaxTable } from './AjaxTable';
+
+window.AjaxTable = AjaxTable;
+
+document.addEventListener('DOMContentLoaded', () => {
+ if (document.getElementById('filterForm') && document.querySelector('.bg-white.shadow-sm')) {
+ new AjaxTable();
+ }
+});
+
+window.openDeleteModal = function(msnv, url) {
+ const messageEl = document.getElementById('deleteModal-message');
+
+ if (messageEl) {
+ messageEl.innerHTML = `Bạn có chắc chắn muốn đánh dấu nhân viên đã nghỉ việc?
Thao tác này sẽ ngăn user đăng nhập vào hệ thống, nhưng dữ liệu lịch sử vẫn được giữ lại.`;
+ }
+
+ const formEl = document.getElementById('deleteModal-form');
+
+ if (formEl) {
+ formEl.action = url;
+ }
+ if (typeof window.openModal === 'function') {
+ window.openModal('deleteModal');
+ }
+};
diff --git a/resources/views/admin/users/create.blade.php b/resources/views/admin/users/create.blade.php
index 0826b30..75723d8 100644
--- a/resources/views/admin/users/create.blade.php
+++ b/resources/views/admin/users/create.blade.php
@@ -2,69 +2,5 @@
@section('title', 'Thêm User Mới')
@section('content')
-
+
@endsection
diff --git a/resources/views/admin/users/edit.blade.php b/resources/views/admin/users/edit.blade.php
index 7cfb10a..ef592a5 100644
--- a/resources/views/admin/users/edit.blade.php
+++ b/resources/views/admin/users/edit.blade.php
@@ -2,120 +2,5 @@
@section('title', 'Chi tiết User')
@section('content')
-
-
-
-
-
-
-
-
-
Lịch sử nhận / gửi
-
-
-
-
-
-
-
- | Ngày |
- Hành động |
- Đối tượng |
- Số lượng |
-
-
-
- @forelse($administrations as $admin_record)
- @php
- $isReceiver = $admin_record->msnv == $user->msnv && $admin_record->received > 0;
- @endphp
-
- | {{ Carbon\Carbon::parse($admin_record->date)->format('d/m/Y') }} |
-
- @if($isReceiver)
- Nhận thẻ
- @else
- Gửi thẻ
- @endif
- |
-
- @if($isReceiver)
- Từ: {{ $admin_record->sender }}
- @else
- Tới: {{ $admin_record->receiver }}
- @endif
- |
-
-
- @empty
-
- | Không có giao dịch nào trong tháng này. |
-
- @endforelse
-
-
-
- @if($administrations->hasPages())
-
- {{ $administrations->links() }}
-
- @endif
-
-
-
+
@endsection
diff --git a/resources/views/admin/users/index.blade.php b/resources/views/admin/users/index.blade.php
index afbe795..ad391bd 100644
--- a/resources/views/admin/users/index.blade.php
+++ b/resources/views/admin/users/index.blade.php
@@ -2,257 +2,25 @@
@section('title', 'Quản lý Users')
@section('content')
-
-
- Danh sách User (Tháng {{ Carbon\Carbon::parse($selectedMonth)->format('m/Y') }})
+
+
+ @include('admin.users.partials.title')
-
-
-
-
-
+
-
-
-
-
- User nhận nhiều nhất
- {{ $topReceivedUser && $topReceivedUser->total_received > 0 ? $topReceivedUser->msnv : 'Chưa có' }}
-
-
- Tổng thẻ đã nhận
- {{ $topReceivedUser ? $topReceivedUser->total_received : 0 }} thẻ
-
-
-
-
-
-
-
- User gửi nhiều nhất
- {{ $topSentUser && $topSentUser->total_sent > 0 ? $topSentUser->msnv : 'Chưa có' }}
-
-
- Tổng thẻ đã gửi
- {{ $topSentUser ? $topSentUser->total_sent : 0 }} thẻ
-
-
-
+
+ @include('admin.users.partials.stats')
-
-
-
-
-
- | MSNV |
- Email |
- Đã Nhận |
- Đã Gửi |
- Số dư thẻ |
- Quyền gửi |
- Thao tác |
-
-
-
- @forelse($users as $u)
-
- | {{ $u->msnv }} |
- {{ $u->mail }} |
-
-
-
-
- @if($u->flag_send)
- Được gửi
- @else
- Không
- @endif
- |
-
- Quản lý
-
- |
-
- @empty
-
- | Chưa có user nào đang hoạt động. |
-
- @endforelse
-
-
-
- @if($users->hasPages())
-
- {{ $users->links() }}
-
- @endif
+
+
+
+ @include('admin.users.partials.table')
-
-
-
-
-
-
Xác nhận nghỉ việc
-
- Bạn có chắc chắn muốn đánh dấu nhân viên đã nghỉ việc?
- Thao tác này sẽ ngăn user đăng nhập vào hệ thống, nhưng dữ liệu lịch sử vẫn được giữ lại.
-
-
-
-
-
-
-
-
-
-
-@push('scripts')
-
-@endpush
+
@endsection
diff --git a/resources/views/admin/users/partials/stats.blade.php b/resources/views/admin/users/partials/stats.blade.php
new file mode 100644
index 0000000..9aaf676
--- /dev/null
+++ b/resources/views/admin/users/partials/stats.blade.php
@@ -0,0 +1,2 @@
+
+
diff --git a/resources/views/admin/users/partials/table.blade.php b/resources/views/admin/users/partials/table.blade.php
new file mode 100644
index 0000000..4090b27
--- /dev/null
+++ b/resources/views/admin/users/partials/table.blade.php
@@ -0,0 +1,55 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ @forelse($users as $u)
+
+ | {{ $u->msnv }} |
+ {{ $u->mail }} |
+ +{{ $u->total_received }} |
+ {{ $u->total_sent }} |
+ {{ $u->card }} |
+
+ @if($u->flag_send)
+ Được gửi
+ @else
+ Không
+ @endif
+ |
+
+ Quản lý
+
+ |
+
+ @empty
+
+ | Chưa có user nào đang hoạt động. |
+
+ @endforelse
+
+
+
+@if($users->hasPages())
+
+ {{ $users->links() }}
+
+@endif
diff --git a/resources/views/admin/users/partials/title.blade.php b/resources/views/admin/users/partials/title.blade.php
new file mode 100644
index 0000000..8391b80
--- /dev/null
+++ b/resources/views/admin/users/partials/title.blade.php
@@ -0,0 +1,4 @@
+Danh sách User
+
+ Loading...
+
diff --git a/resources/views/components/action-panel.blade.php b/resources/views/components/action-panel.blade.php
new file mode 100644
index 0000000..705bc0b
--- /dev/null
+++ b/resources/views/components/action-panel.blade.php
@@ -0,0 +1,17 @@
+@props(['mode', 'user' => null])
+
+
+{{-- duplicated block removed --}}
diff --git a/resources/views/components/confirm-modal.blade.php b/resources/views/components/confirm-modal.blade.php
new file mode 100644
index 0000000..70ee694
--- /dev/null
+++ b/resources/views/components/confirm-modal.blade.php
@@ -0,0 +1,26 @@
+@props(['id', 'title' => 'Xác nhận', 'message' => '', 'confirmText' => 'Xác nhận', 'cancelText' => 'Hủy bỏ', 'confirmBg' => 'bg-red-600 hover:bg-red-700'])
+
+
+
+
+
+
{{ $title }}
+
+ {{ $message }}
+
+
+
+
+
+
+
+
+
diff --git a/resources/views/components/icon.blade.php b/resources/views/components/icon.blade.php
new file mode 100644
index 0000000..c5e8675
--- /dev/null
+++ b/resources/views/components/icon.blade.php
@@ -0,0 +1,7 @@
+@props(['name', 'class' => ''])
+@php
+ $svgPath = public_path('assets/icons/' . $name . '.svg');
+@endphp
+
merge(['class' => $class]) }} role="img" aria-hidden="true">
+ {!! file_get_contents($svgPath) !!}
+
diff --git a/resources/views/components/month-filter.blade.php b/resources/views/components/month-filter.blade.php
new file mode 100644
index 0000000..4990342
--- /dev/null
+++ b/resources/views/components/month-filter.blade.php
@@ -0,0 +1,12 @@
+@props(['mode', 'user' => null, 'selectedMonth'])
+
+@php
+ $actionUrl = $mode === 'self'
+ ? route('user.dashboard')
+ : route('admin.users.edit', $user?->msnv);
+@endphp
+
+
diff --git a/resources/views/components/stat-card.blade.php b/resources/views/components/stat-card.blade.php
new file mode 100644
index 0000000..6d2d976
--- /dev/null
+++ b/resources/views/components/stat-card.blade.php
@@ -0,0 +1,14 @@
+@props(['title', 'user', 'value', 'bgClass' => 'bg-[#0f4c81]', 'valueLabel' => 'Tổng thẻ đã nhận'])
+
+
+
+
+ {{ $title }}
+ {{ $user && $value > 0 ? $user : 'Chưa có' }}
+
+
+ {{ $valueLabel }}
+ {{ $value }} thẻ
+
+
+
diff --git a/resources/views/components/table-skeleton.blade.php b/resources/views/components/table-skeleton.blade.php
new file mode 100644
index 0000000..793d8f8
--- /dev/null
+++ b/resources/views/components/table-skeleton.blade.php
@@ -0,0 +1,17 @@
+@props(['rows' => 10, 'cols' => 7])
+
+@for ($i = 0; $i < $rows; $i++)
+
+ @for ($j = 0; $j < $cols; $j++)
+ @if ($j === 1)
+ |
+ @elseif ($j === 5)
+ |
+ @elseif ($j === 6)
+ |
+ @else
+ |
+ @endif
+ @endfor
+
+@endfor
diff --git a/resources/views/components/transaction-history.blade.php b/resources/views/components/transaction-history.blade.php
new file mode 100644
index 0000000..f44d0af
--- /dev/null
+++ b/resources/views/components/transaction-history.blade.php
@@ -0,0 +1,78 @@
+@props(['mode', 'user', 'administrations', 'selectedMonth'])
+
+
+
+
+ @if($mode === 'self')
+ Lịch sử nhận / gửi thẻ (Tháng {{ Carbon\Carbon::parse($selectedMonth)->format('m/Y') }})
+ @else
+ Lịch sử nhận / gửi
+ @endif
+
+
+
+
+
+
+
+
+
+ | Ngày |
+
+ {{ $mode === 'self' ? 'Loại giao dịch' : 'Hành động' }}
+ |
+
+ {{ $mode === 'self' ? 'Đối tác' : 'Đối tượng' }}
+ |
+ Số lượng |
+
+
+
+ @forelse($administrations as $admin_record)
+ @php
+ $isReceiver = $admin_record->msnv == $user->msnv && $admin_record->received > 0;
+ @endphp
+
+ | {{ Carbon\Carbon::parse($admin_record->date)->format('d/m/Y') }} |
+
+ @if($isReceiver)
+
+ {{ $mode === 'self' ? 'Bạn đã nhận thẻ' : 'Nhận thẻ' }}
+
+ @else
+
+ {{ $mode === 'self' ? 'Bạn gửi tặng thẻ' : 'Gửi thẻ' }}
+
+ @endif
+ |
+
+ @if($isReceiver)
+ {{ $mode === 'self' ? 'Nhận từ: ' : 'Từ: ' }}{{ $admin_record->sender }}
+ @else
+ {{ $mode === 'self' ? 'Gửi cho: ' : 'Tới: ' }}{{ $admin_record->receiver }}
+ @endif
+ |
+
+
+ @empty
+
+ |
+ {{ $mode === 'self' ? 'Bạn chưa có giao dịch gửi/nhận thẻ nào trong tháng này.' : 'Không có giao dịch nào trong tháng này.' }}
+ |
+
+ @endforelse
+
+
+
+ @if($administrations->hasPages())
+
+ {{ $administrations->links() }}
+
+ @endif
+
diff --git a/resources/views/components/user-filter-form.blade.php b/resources/views/components/user-filter-form.blade.php
new file mode 100644
index 0000000..eeac7eb
--- /dev/null
+++ b/resources/views/components/user-filter-form.blade.php
@@ -0,0 +1,28 @@
+@props(['action', 'search' => '', 'flagSend' => '', 'selectedMonth' => ''])
+
+
diff --git a/resources/views/components/user-form.blade.php b/resources/views/components/user-form.blade.php
new file mode 100644
index 0000000..e70ef4e
--- /dev/null
+++ b/resources/views/components/user-form.blade.php
@@ -0,0 +1,141 @@
+@props(['mode', 'user' => null])
+
+@php
+ $isCreate = $mode === 'create';
+ $isEdit = $mode === 'edit';
+ $isDelete = $mode === 'delete';
+ $isSelf = $mode === 'self';
+
+ $actionUrl = match($mode) {
+ 'create' => route('admin.users.store'),
+ 'edit' => route('admin.users.update', $user?->msnv),
+ 'delete' => route('admin.users.destroy', $user?->msnv),
+ default => null,
+ };
+
+ $method = match($mode) {
+ 'edit' => 'PUT',
+ 'delete' => 'DELETE',
+ default => 'POST',
+ };
+@endphp
+
+
diff --git a/resources/views/components/user-page.blade.php b/resources/views/components/user-page.blade.php
new file mode 100644
index 0000000..a85250e
--- /dev/null
+++ b/resources/views/components/user-page.blade.php
@@ -0,0 +1,39 @@
+@props(['mode', 'user' => null, 'administrations' => null, 'selectedMonth' => null])
+
+@if($mode === 'self' && $user)
+
+
+
+ My Page
+
+
+
+ Thẻ hiện có:
{{ $user->card }}
+
+
+
+@endif
+
+
+
+
+
+ @if($mode === 'create')
+
+
+
+ @else
+
+ @endif
+
+
+
+ @if(in_array($mode, ['edit', 'delete', 'self']) && $user && $administrations)
+
+
+
+ @endif
+
+
diff --git a/resources/views/layouts/app.blade.php b/resources/views/layouts/app.blade.php
index ff8128a..23924d4 100644
--- a/resources/views/layouts/app.blade.php
+++ b/resources/views/layouts/app.blade.php
@@ -8,19 +8,24 @@
@vite(['resources/css/app.css', 'resources/js/app.js'])
@stack('styles')
-
+
-