From f1858f8f0fedf6bd329a5c532af756bb1011ce56 Mon Sep 17 00:00:00 2001 From: antv Date: Wed, 1 Jul 2026 16:57:34 +0700 Subject: [PATCH] reuse form --- .env.testing | 10 + app/Http/Controllers/AdminController.php | 14 +- app/Services/Admin/AdminService.php | 53 ++-- .../Admin/Contracts/AdminServiceInterface.php | 2 +- database/seeders/MockDataSeeder.php | 88 +++--- phpunit.xml | 28 +- resources/assets/icons/icn-chevron-left.svg | 1 + resources/assets/icons/icn-chevron-right.svg | 1 + resources/assets/icons/icn-close.svg | 1 + resources/assets/icons/icn-menu.svg | 1 + resources/css/app.css | 14 +- resources/js/AjaxTable.js | 223 ++++++++++++++ resources/js/app.js | 26 ++ resources/views/admin/users/create.blade.php | 66 +---- resources/views/admin/users/edit.blade.php | 117 +------- resources/views/admin/users/index.blade.php | 260 +---------------- .../admin/users/partials/stats.blade.php | 2 + .../admin/users/partials/table.blade.php | 55 ++++ .../admin/users/partials/title.blade.php | 4 + .../views/components/action-panel.blade.php | 17 ++ .../views/components/confirm-modal.blade.php | 26 ++ resources/views/components/icon.blade.php | 7 + .../views/components/month-filter.blade.php | 12 + .../views/components/stat-card.blade.php | 14 + .../views/components/table-skeleton.blade.php | 17 ++ .../components/transaction-history.blade.php | 78 +++++ .../components/user-filter-form.blade.php | 28 ++ .../views/components/user-form.blade.php | 141 +++++++++ .../views/components/user-page.blade.php | 39 +++ resources/views/layouts/app.blade.php | 17 +- resources/views/login.blade.php | 272 ++++++++++++------ resources/views/user/dashboard.blade.php | 78 +---- tests/Feature/AdminUserListStatsTest.php | 232 +++++++++++++++ tests/TestCase.php | 10 +- 34 files changed, 1273 insertions(+), 681 deletions(-) create mode 100644 .env.testing create mode 100644 resources/assets/icons/icn-chevron-left.svg create mode 100644 resources/assets/icons/icn-chevron-right.svg create mode 100644 resources/assets/icons/icn-close.svg create mode 100644 resources/assets/icons/icn-menu.svg create mode 100644 resources/js/AjaxTable.js create mode 100644 resources/views/admin/users/partials/stats.blade.php create mode 100644 resources/views/admin/users/partials/table.blade.php create mode 100644 resources/views/admin/users/partials/title.blade.php create mode 100644 resources/views/components/action-panel.blade.php create mode 100644 resources/views/components/confirm-modal.blade.php create mode 100644 resources/views/components/icon.blade.php create mode 100644 resources/views/components/month-filter.blade.php create mode 100644 resources/views/components/stat-card.blade.php create mode 100644 resources/views/components/table-skeleton.blade.php create mode 100644 resources/views/components/transaction-history.blade.php create mode 100644 resources/views/components/user-filter-form.blade.php create mode 100644 resources/views/components/user-form.blade.php create mode 100644 resources/views/components/user-page.blade.php create mode 100644 tests/Feature/AdminUserListStatsTest.php 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 ${msnv} đã 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') -
-
-

Thêm User Mới

-
- -
- @csrf - -
-
- -
- - - - - - -
- @error('msnv'){{ $message }}@enderror -
- -
- -
- - - - - - -
- @error('mail'){{ $message }}@enderror -
- -
- -
- - - - - - -
- Lưu ý: User sẽ bị buộc phải đổi mật khẩu này ở lần đăng nhập đầu tiên. - @error('password'){{ $message }}@enderror -
- -
- - - @error('role'){{ $message }}@enderror -
-
- - -
-
+ @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') -
- -
-
-
-

Quản Lý Thẻ: {{ $user->msnv }}

-
-
-
-
- Email: - {{ $user->mail }} -
-
- Role: - {{ $user->role == \App\Models\User::ROLE_ADMIN ? 'Admin' : 'Member' }} -
-
- Số dư thẻ: - {{ $user->card }} -
-
-
- -
- @csrf - @method('PUT') - -
-
- - - @error('num_card'){{ $message }}@enderror -
- -
- -
-
- - -
-
-
- - -
-
-
-

Lịch sử nhận / gửi

-
- -
-
- -
- - - - - - - - - - - @forelse($administrations as $admin_record) - @php - $isReceiver = $admin_record->msnv == $user->msnv && $admin_record->received > 0; - @endphp - - - - - - - @empty - - - - @endforelse - -
NgàyHành độngĐối tượngSố lượng
{{ 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 - - @if($isReceiver) - +{{ $admin_record->received }} - @else - -{{ $admin_record->sent }} - @endif -
Không có giao dịch nào trong tháng này.
-
- @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')

-
-
- - -
- -
- @csrf - -
-
+
+ @csrf + +
-
-
-
-
- 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')
-
-
- - - - - - - - - - - - - - @forelse($users as $u) - - - - - - - - - - @empty - - - - @endforelse - -
MSNVEmailĐã NhậnĐã GửiSố dư thẻQuyền gửiThao tác
{{ $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ý - -
Chưa có user nào đang hoạt động.
-
- @if($users->hasPages()) -
- {{ $users->links() }} -
- @endif + + +
+ @include('admin.users.partials.table')
- - -
-
- - - -
-
- -

- 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. -

-
-
- - -
- @csrf - @method('DELETE') - -
- -
-
- -@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 @@ +
+ + @if($users->total() > 0) + Showing {{ $users->firstItem() }}-{{ $users->lastItem() }} of {{ $users->total() }} users + @else + Showing 0 users + @endif + +
+
+ + + + + + + + + + + + + + @forelse($users as $u) + + + + + + + + + + @empty + + + + @endforelse + +
MSNVEmailĐã NhậnĐã GửiSố dư thẻQuyền gửiThao tác
{{ $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ý + +
Chưa có user nào đang hoạt động.
+
+@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 + 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 }} +

+
+
+ + +
+ @csrf + @method('DELETE') + +
+ +
+
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 +

+ + +
+ +
+ + + + + + + + + + + @forelse($administrations as $admin_record) + @php + $isReceiver = $admin_record->msnv == $user->msnv && $admin_record->received > 0; + @endphp + + + + + + + @empty + + + + @endforelse + +
Ngày + {{ $mode === 'self' ? 'Loại giao dịch' : 'Hành động' }} + + {{ $mode === 'self' ? 'Đối tác' : 'Đối tượng' }} + Số lượng
{{ 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 + + @if($isReceiver) + +{{ $admin_record->received }} + @else + -{{ $admin_record->sent }} + @endif +
+ {{ $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.' }} +
+
+ @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 + +
+
+

+ {{ match($mode) { + 'create' => 'Thêm User Mới', + 'edit' => 'Quản Lý Thẻ: ' . ($user?->msnv), + 'delete' => 'Xác nhận nghỉ việc: ' . ($user?->msnv), + 'self' => 'Thông tin cá nhân: ' . ($user?->msnv), + } }} +

+
+ + @if(!$isCreate && $user) +
+
+
+ Email: + {{ $user->mail }} +
+
+ Role: + {{ $user->role == \App\Models\User::ROLE_ADMIN ? 'Admin' : 'Member' }} +
+
+ Số dư thẻ: + {{ $user->card }} +
+
+
+ @endif + + @if($actionUrl) +
+ @csrf + @if($method !== 'POST') + @method($method) + @endif + @endif + +
+ @if($isCreate) +
+ +
+ + + + + + +
+ @error('msnv'){{ $message }}@enderror +
+ +
+ +
+ + + + + + +
+ @error('mail'){{ $message }}@enderror +
+ +
+ +
+ + + + + + +
+ Lưu ý: User sẽ bị buộc phải đổi mật khẩu này ở lần đăng nhập đầu tiên. + @error('password'){{ $message }}@enderror +
+ +
+ + + @error('role'){{ $message }}@enderror +
+ @endif + + @if($isEdit && $user) +
+ + + @error('num_card'){{ $message }}@enderror +
+ +
+ +
+ @endif + + @if($isDelete && $user) +

+ Bạn có chắc chắn muốn cho nhân viên {{ $user->msnv }} nghỉ việc không? Tài khoản này sẽ không thể đăng nhập hoặc thực hiện các giao dịch thẻ sau khi xác nhận. +

+ @endif +
+ + + + @if($actionUrl) + + @endif +
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') - +
-