reuse form
This commit is contained in:
@@ -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
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
$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])
|
||||
->sum('received');
|
||||
]);
|
||||
|
||||
$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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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],
|
||||
// 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'],
|
||||
|
||||
// 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
|
||||
['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'],
|
||||
|
||||
// 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)],
|
||||
// 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']
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
+14
-14
@@ -18,19 +18,19 @@
|
||||
</include>
|
||||
</source>
|
||||
<php>
|
||||
<env name="APP_ENV" value="testing"/>
|
||||
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
|
||||
<env name="BCRYPT_ROUNDS" value="4"/>
|
||||
<env name="BROADCAST_CONNECTION" value="null"/>
|
||||
<env name="CACHE_STORE" value="array"/>
|
||||
<env name="DB_CONNECTION" value="sqlite"/>
|
||||
<env name="DB_DATABASE" value=":memory:"/>
|
||||
<env name="DB_URL" value=""/>
|
||||
<env name="MAIL_MAILER" value="array"/>
|
||||
<env name="QUEUE_CONNECTION" value="sync"/>
|
||||
<env name="SESSION_DRIVER" value="array"/>
|
||||
<env name="PULSE_ENABLED" value="false"/>
|
||||
<env name="TELESCOPE_ENABLED" value="false"/>
|
||||
<env name="NIGHTWATCH_ENABLED" value="false"/>
|
||||
<env name="APP_ENV" value="testing" force="true"/>
|
||||
<env name="APP_MAINTENANCE_DRIVER" value="file" force="true"/>
|
||||
<env name="BCRYPT_ROUNDS" value="4" force="true"/>
|
||||
<env name="BROADCAST_CONNECTION" value="null" force="true"/>
|
||||
<env name="CACHE_STORE" value="array" force="true"/>
|
||||
<env name="DB_CONNECTION" value="sqlite" force="true"/>
|
||||
<env name="DB_DATABASE" value=":memory:" force="true"/>
|
||||
<env name="DB_URL" value="" force="true"/>
|
||||
<env name="MAIL_MAILER" value="array" force="true"/>
|
||||
<env name="QUEUE_CONNECTION" value="sync" force="true"/>
|
||||
<env name="SESSION_DRIVER" value="array" force="true"/>
|
||||
<env name="PULSE_ENABLED" value="false" force="true"/>
|
||||
<env name="TELESCOPE_ENABLED" value="false" force="true"/>
|
||||
<env name="NIGHTWATCH_ENABLED" value="false" force="true"/>
|
||||
</php>
|
||||
</phpunit>
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg>
|
||||
|
After Width: | Height: | Size: 201 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>
|
||||
|
After Width: | Height: | Size: 200 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
|
||||
|
After Width: | Height: | Size: 277 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
|
||||
|
After Width: | Height: | Size: 277 B |
+13
-1
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 += `
|
||||
<tr class="animate-pulse h-[69px]">
|
||||
<td class="px-6 py-4"><div class="h-4 bg-gray-200 rounded w-16"></div></td>
|
||||
<td class="px-6 py-4"><div class="h-4 bg-gray-200 rounded w-48"></div></td>
|
||||
<td class="px-6 py-4"><div class="h-4 bg-gray-200 rounded w-12"></div></td>
|
||||
<td class="px-6 py-4"><div class="h-4 bg-gray-200 rounded w-12"></div></td>
|
||||
<td class="px-6 py-4"><div class="h-4 bg-gray-200 rounded w-16"></div></td>
|
||||
<td class="px-6 py-4"><div class="h-6 bg-gray-200 rounded-full w-20"></div></td>
|
||||
<td class="px-6 py-4"><div class="h-8 bg-gray-200 rounded w-20 ml-auto"></div></td>
|
||||
</tr>`;
|
||||
}
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 <span class="font-extrabold text-red-600">${msnv}</span> đã nghỉ việc?<br><br>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');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2,69 +2,5 @@
|
||||
@section('title', 'Thêm User Mới')
|
||||
|
||||
@section('content')
|
||||
<div class="form-container">
|
||||
<div class="form-header">
|
||||
<h3 class="form-header-title">Thêm User Mới</h3>
|
||||
</div>
|
||||
|
||||
<form action="{{ route('admin.users.store') }}" method="POST">
|
||||
@csrf
|
||||
|
||||
<div class="form-body">
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="msnv">Mã nhân viên (MSNV) <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 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>
|
||||
</span>
|
||||
<input type="text" name="msnv" id="msnv" class="form-input form-input-with-icon" required value="{{ old('msnv') }}" placeholder="VD: RUN-0123">
|
||||
</div>
|
||||
@error('msnv')<span class="error-text">{{ $message }}</span>@enderror
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="mail">Email <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="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>
|
||||
</span>
|
||||
<input type="email" name="mail" id="mail" class="form-input form-input-with-icon" required value="{{ old('mail') }}" placeholder="VD: nguyenva@runsystem.net">
|
||||
</div>
|
||||
@error('mail')<span class="error-text">{{ $message }}</span>@enderror
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<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="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>
|
||||
</span>
|
||||
<input type="text" name="password" id="password" class="form-input form-input-with-icon" required placeholder="Nhập mật khẩu cho user...">
|
||||
</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>
|
||||
@error('password')<span class="error-text">{{ $message }}</span>@enderror
|
||||
</div>
|
||||
|
||||
<div class="form-group mb-0">
|
||||
<label class="form-label" for="role">Quyền hạn <span class="text-red-500">*</span></label>
|
||||
<select name="role" id="role" class="form-input" required>
|
||||
<option value="{{ \App\Models\User::ROLE_MEMBER }}" {{ old('role') == \App\Models\User::ROLE_MEMBER ? 'selected' : '' }}>Nhân viên (Member)</option>
|
||||
<option value="{{ \App\Models\User::ROLE_ADMIN }}" {{ old('role') == \App\Models\User::ROLE_ADMIN ? 'selected' : '' }}>Quản trị viên (Admin)</option>
|
||||
</select>
|
||||
@error('role')<span class="error-text">{{ $message }}</span>@enderror
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-footer">
|
||||
<a href="{{ route('admin.users.index') }}" class="btn-secondary">Hủy bỏ</a>
|
||||
<button type="submit" class="btn-primary">Tạo Nhân Viên</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<x-user-page mode="create" />
|
||||
@endsection
|
||||
|
||||
@@ -2,120 +2,5 @@
|
||||
@section('title', 'Chi tiết User')
|
||||
|
||||
@section('content')
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6 items-start">
|
||||
<!-- Cột trái: Form thao tác -->
|
||||
<div class="col-span-1">
|
||||
<div class="form-container">
|
||||
<div class="form-header">
|
||||
<h3 class="form-header-title">Quản Lý Thẻ: {{ $user->msnv }}</h3>
|
||||
</div>
|
||||
<div class="form-body">
|
||||
<div class="mb-6 pb-6 border-b border-border-light space-y-3">
|
||||
<div class="flex justify-between items-center text-sm">
|
||||
<span class="text-text-medium font-bold">Email:</span>
|
||||
<span class="font-semibold text-text-dark">{{ $user->mail }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center text-sm">
|
||||
<span class="text-text-medium font-bold">Role:</span>
|
||||
<span class="font-semibold text-text-dark">{{ $user->role == \App\Models\User::ROLE_ADMIN ? 'Admin' : 'Member' }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center text-sm">
|
||||
<span class="text-text-medium font-bold">Số dư thẻ:</span>
|
||||
<span class="font-extrabold text-orange-500 text-xl">{{ $user->card }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form action="{{ route('admin.users.update', $user->msnv) }}" method="POST">
|
||||
@csrf
|
||||
@method('PUT')
|
||||
|
||||
<div class="form-body pt-0">
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="num_card">Nạp thêm thẻ</label>
|
||||
<input type="number" name="num_card" id="num_card" min="1" class="form-input" placeholder="Nhập số lượng thẻ muốn nạp...">
|
||||
@error('num_card')<span class="error-text">{{ $message }}</span>@enderror
|
||||
</div>
|
||||
|
||||
<div class="form-group mb-0">
|
||||
<label class="flex items-center gap-3 cursor-pointer p-4 border border-[#d9dfe7] rounded-lg hover:bg-gray-50 hover:border-gray-400 transition-colors shadow-sm">
|
||||
<input type="checkbox" name="flag_send" id="flag_send" value="1" class="form-input !w-5 !h-5 !min-h-0" {{ $user->flag_send ? 'checked' : '' }}>
|
||||
<span class="text-[14px] font-[600] text-text-dark">Bật quyền cho phép User gửi thẻ</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-footer">
|
||||
<a href="{{ route('admin.users.index') }}" class="btn-secondary">Quay lại</a>
|
||||
<button type="submit" class="btn-primary">Lưu Thay Đổi</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cột phải: Lịch sử -->
|
||||
<div class="col-span-1 lg:col-span-2">
|
||||
<div class="bg-white rounded-xl shadow-sm border border-border-light overflow-hidden flex flex-col h-full">
|
||||
<div class="px-6 py-4 sm:py-5 border-b border-border-light bg-gray-50/50 flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||||
<h3 class="text-lg font-bold text-text-dark">Lịch sử nhận / gửi</h3>
|
||||
<form action="{{ route('admin.users.edit', $user->msnv) }}" method="GET" class="flex-shrink-0">
|
||||
<input type="month" name="month" value="{{ $selectedMonth }}" onchange="this.form.submit()" class="form-input !min-h-[38px] !py-1.5 !text-sm w-auto border-border-medium rounded">
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="p-0 overflow-y-auto max-h-[600px]">
|
||||
<table class="min-w-full divide-y divide-border-light text-sm">
|
||||
<thead class="bg-gray-50/80 sticky top-0">
|
||||
<tr>
|
||||
<th class="px-6 py-4 text-left text-xs font-bold text-text-light uppercase tracking-wider">Ngày</th>
|
||||
<th class="px-6 py-4 text-left text-xs font-bold text-text-light uppercase tracking-wider">Hành động</th>
|
||||
<th class="px-6 py-4 text-left text-xs font-bold text-text-light uppercase tracking-wider">Đối tượng</th>
|
||||
<th class="px-6 py-4 text-left text-xs font-bold text-text-light uppercase tracking-wider">Số lượng</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-border-light">
|
||||
@forelse($administrations as $admin_record)
|
||||
@php
|
||||
$isReceiver = $admin_record->msnv == $user->msnv && $admin_record->received > 0;
|
||||
@endphp
|
||||
<tr class="hover:bg-slate-50 transition-colors">
|
||||
<td class="px-6 py-3 whitespace-nowrap text-text-medium font-medium">{{ Carbon\Carbon::parse($admin_record->date)->format('d/m/Y') }}</td>
|
||||
<td class="px-6 py-3 whitespace-nowrap">
|
||||
@if($isReceiver)
|
||||
<span class="px-2 py-1 inline-flex text-xs leading-5 font-bold rounded-full bg-green-100 text-green-800">Nhận thẻ</span>
|
||||
@else
|
||||
<span class="px-2 py-1 inline-flex text-xs leading-5 font-bold rounded-full bg-blue-100 text-blue-800">Gửi thẻ</span>
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-6 py-3 whitespace-nowrap font-bold text-text-dark">
|
||||
@if($isReceiver)
|
||||
Từ: <span class="text-primary">{{ $admin_record->sender }}</span>
|
||||
@else
|
||||
Tới: <span class="text-primary">{{ $admin_record->receiver }}</span>
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-6 py-3 whitespace-nowrap font-extrabold text-lg">
|
||||
@if($isReceiver)
|
||||
<span class="text-green-600">+{{ $admin_record->received }}</span>
|
||||
@else
|
||||
<span class="text-blue-600">-{{ $admin_record->sent }}</span>
|
||||
@endif
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="4" class="px-6 py-12 text-center text-text-light font-medium text-base">Không có giao dịch nào trong tháng này.</td>
|
||||
</tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@if($administrations->hasPages())
|
||||
<div class="px-6 py-4 border-t border-border-light bg-gray-50/30">
|
||||
{{ $administrations->links() }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<x-user-page mode="edit" :user="$user" :administrations="$administrations" :selectedMonth="$selectedMonth" />
|
||||
@endsection
|
||||
|
||||
@@ -2,257 +2,25 @@
|
||||
@section('title', 'Quản lý Users')
|
||||
|
||||
@section('content')
|
||||
<div class="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<h2 class="text-xl font-bold text-text-dark flex items-center gap-2">
|
||||
Danh sách User (Tháng {{ Carbon\Carbon::parse($selectedMonth)->format('m/Y') }})
|
||||
<div class="flex flex-col md:flex-row md:items-center justify-between gap-4 border-b border-border-light pb-4 mb-4">
|
||||
<h2 id="pageTitle" class="text-xl font-bold text-text-dark flex items-center gap-2">
|
||||
@include('admin.users.partials.title')
|
||||
</h2>
|
||||
<div class="flex flex-wrap items-center gap-4">
|
||||
<form id="filterForm" action="{{ route('admin.users.index') }}" method="GET" class="flex items-center gap-2">
|
||||
<input type="month" name="month" value="{{ $selectedMonth }}" class="form-input !py-1.5 !text-sm w-auto border-border-medium rounded">
|
||||
<button type="submit" id="btnFilter" class="btn-primary !py-1.5 !px-4 text-sm rounded shadow-sm hover:translate-y-[-1px] flex items-center justify-center min-w-[70px]">Lọc</button>
|
||||
</form>
|
||||
|
||||
<form action="{{ route('admin.reset_cards') }}" method="POST" onsubmit="return confirm('Bạn có chắc chắn muốn reset toàn bộ số card hiện tại của các user về 0? Hành động này thường chỉ thực hiện vào cuối tháng.');">
|
||||
<form action="{{ route('admin.reset_cards') }}" method="POST" class="w-full sm:w-auto" onsubmit="return confirm('Bạn có chắc chắn muốn reset toàn bộ số card hiện tại của các user về 0? Hành động này thường chỉ thực hiện vào cuối tháng.');">
|
||||
@csrf
|
||||
<button type="submit" class="bg-red-500 hover:bg-red-600 text-white font-bold py-1.5 px-4 rounded text-sm shadow-md transition-colors">Reset Card Tháng</button>
|
||||
<button type="submit" class="border border-red-200 hover:bg-red-50 text-red-600 font-semibold py-1.5 px-4 rounded text-sm transition-colors shadow-sm w-full sm:w-auto">Reset Card Tháng</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="card-stat-active !bg-[#0f4c81]">
|
||||
<div class="space-y-2">
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-white/90 text-xs font-bold uppercase tracking-wider">User nhận nhiều nhất</span>
|
||||
<span class="text-2xl font-black">{{ $topReceivedUser && $topReceivedUser->total_received > 0 ? $topReceivedUser->msnv : 'Chưa có' }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center pt-2 border-t border-white/20">
|
||||
<span class="text-white/80 text-[10px] font-bold">Tổng thẻ đã nhận</span>
|
||||
<span class="text-lg font-bold text-yellow-300">{{ $topReceivedUser ? $topReceivedUser->total_received : 0 }} thẻ</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-stat-active !bg-[#ef222e]">
|
||||
<div class="space-y-2">
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-white/90 text-xs font-bold uppercase tracking-wider">User gửi nhiều nhất</span>
|
||||
<span class="text-2xl font-black">{{ $topSentUser && $topSentUser->total_sent > 0 ? $topSentUser->msnv : 'Chưa có' }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center pt-2 border-t border-white/20">
|
||||
<span class="text-white/80 text-[10px] font-bold">Tổng thẻ đã gửi</span>
|
||||
<span class="text-lg font-bold text-yellow-300">{{ $topSentUser ? $topSentUser->total_sent : 0 }} thẻ</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="statsGrid" class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
|
||||
@include('admin.users.partials.stats')
|
||||
</div>
|
||||
|
||||
<div class="bg-white shadow-sm rounded-xl overflow-hidden border border-border-light mt-2">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-border-light text-sm">
|
||||
<thead class="bg-gray-50/80">
|
||||
<tr>
|
||||
<th class="px-6 py-4 text-left text-xs font-bold text-text-light uppercase tracking-wider">MSNV</th>
|
||||
<th class="px-6 py-4 text-left text-xs font-bold text-text-light uppercase tracking-wider">Email</th>
|
||||
<th class="px-6 py-4 text-left text-xs font-bold text-text-light uppercase tracking-wider">Đã Nhận</th>
|
||||
<th class="px-6 py-4 text-left text-xs font-bold text-text-light uppercase tracking-wider">Đã Gửi</th>
|
||||
<th class="px-6 py-4 text-left text-xs font-bold text-text-light uppercase tracking-wider">Số dư thẻ</th>
|
||||
<th class="px-6 py-4 text-left text-xs font-bold text-text-light uppercase tracking-wider">Quyền gửi</th>
|
||||
<th class="px-6 py-4 text-right text-xs font-bold text-text-light uppercase tracking-wider">Thao tác</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-border-light">
|
||||
@forelse($users as $u)
|
||||
<tr class="hover:bg-slate-50 transition-colors">
|
||||
<td class="px-6 py-3 whitespace-nowrap font-bold text-text-dark">{{ $u->msnv }}</td>
|
||||
<td class="px-6 py-3 whitespace-nowrap text-text-medium">{{ $u->mail }}</td>
|
||||
<td class="px-6 py-3 whitespace-nowrap font-extrabold text-green-600">+{{ $u->total_received }}</td>
|
||||
<td class="px-6 py-3 whitespace-nowrap font-extrabold text-blue-600">{{ $u->total_sent }}</td>
|
||||
<td class="px-6 py-3 whitespace-nowrap font-extrabold text-orange-500">{{ $u->card }}</td>
|
||||
<td class="px-6 py-3 whitespace-nowrap">
|
||||
@if($u->flag_send)
|
||||
<span class="px-2 py-1 inline-flex text-[10px] leading-5 font-semibold rounded-full bg-green-100 text-green-800">Được gửi</span>
|
||||
@else
|
||||
<span class="px-2 py-1 inline-flex text-[10px] leading-5 font-semibold rounded-full bg-gray-100 text-gray-800">Không</span>
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-6 py-3 whitespace-nowrap text-right text-sm font-medium space-x-3">
|
||||
<a href="{{ route('admin.users.edit', $u->msnv) }}" class="text-primary hover:text-[#0f4c81] font-semibold underline underline-offset-2">Quản lý</a>
|
||||
<button type="button" onclick="openDeleteModal('{{ $u->msnv }}', '{{ route('admin.users.destroy', $u->msnv) }}')" class="text-red-500 hover:text-red-700 font-semibold underline underline-offset-2">Nghỉ việc</button>
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="7" class="px-6 py-10 text-center text-text-light font-medium">Chưa có user nào đang hoạt động.</td>
|
||||
</tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@if($users->hasPages())
|
||||
<div class="px-6 py-4 border-t border-border-light bg-gray-50/30">
|
||||
{{ $users->links() }}
|
||||
</div>
|
||||
@endif
|
||||
<x-user-filter-form :action="route('admin.users.index')" :search="$search" :flag-send="$flagSend" :selected-month="$selectedMonth" />
|
||||
|
||||
<div id="userTableContainer" class="bg-white shadow-sm rounded-xl overflow-hidden border border-border-light mt-2">
|
||||
@include('admin.users.partials.table')
|
||||
</div>
|
||||
|
||||
<!-- Modal Xác nhận Nghỉ việc -->
|
||||
<x-modal id="deleteModal" title="Xác nhận nghỉ việc" maxWidth="md" hideHeader="true">
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="flex-shrink-0 flex items-center justify-center w-12 h-12 bg-red-100 rounded-full">
|
||||
<svg class="w-6 h-6 text-red-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1 mt-1">
|
||||
<h3 class="text-lg font-bold text-gray-900" id="modal-title">Xác nhận nghỉ việc</h3>
|
||||
<p class="text-sm text-gray-600 mt-2 leading-relaxed">
|
||||
Bạn có chắc chắn muốn đánh dấu nhân viên <span id="deleteMsnv" class="font-extrabold text-red-600"></span> đã nghỉ việc?<br><br>
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<x-slot name="footer">
|
||||
<form id="deleteForm" method="POST" class="w-full sm:w-auto">
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
<button type="submit" class="w-full btn-primary !bg-red-600 hover:!bg-red-700 focus:!ring-red-500 !px-6 shadow-sm">Xác nhận Nghỉ việc</button>
|
||||
</form>
|
||||
<button type="button" onclick="closeModal('deleteModal')" class="w-full sm:w-auto btn-secondary !px-6 shadow-sm">Hủy bỏ</button>
|
||||
</x-slot>
|
||||
</x-modal>
|
||||
|
||||
@push('scripts')
|
||||
<script>
|
||||
function openDeleteModal(msnv, url) {
|
||||
document.getElementById('deleteMsnv').innerText = msnv;
|
||||
document.getElementById('deleteForm').action = url;
|
||||
openModal('deleteModal');
|
||||
}
|
||||
|
||||
document.getElementById('filterForm').addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
const btn = document.getElementById('btnFilter');
|
||||
|
||||
btn.classList.add('opacity-50', 'cursor-not-allowed');
|
||||
btn.disabled = true;
|
||||
|
||||
const tableBody = document.querySelector('.bg-white.shadow-sm tbody');
|
||||
if(tableBody) {
|
||||
let skeletonHtml = '';
|
||||
for(let i=0; i<5; i++) {
|
||||
skeletonHtml += `
|
||||
<tr class="animate-pulse">
|
||||
<td class="px-6 py-4"><div class="h-4 bg-gray-200 rounded w-16"></div></td>
|
||||
<td class="px-6 py-4"><div class="h-4 bg-gray-200 rounded w-48"></div></td>
|
||||
<td class="px-6 py-4"><div class="h-4 bg-gray-200 rounded w-12"></div></td>
|
||||
<td class="px-6 py-4"><div class="h-4 bg-gray-200 rounded w-12"></div></td>
|
||||
<td class="px-6 py-4"><div class="h-4 bg-gray-200 rounded w-16"></div></td>
|
||||
<td class="px-6 py-4"><div class="h-4 bg-gray-200 rounded w-20"></div></td>
|
||||
<td class="px-6 py-4"><div class="h-6 bg-gray-200 rounded-full w-20"></div></td>
|
||||
<td class="px-6 py-4 flex justify-end gap-3"><div class="h-8 bg-gray-200 rounded w-20"></div></td>
|
||||
</tr>`;
|
||||
}
|
||||
tableBody.innerHTML = skeletonHtml;
|
||||
}
|
||||
|
||||
try {
|
||||
const url = this.action + '?' + new URLSearchParams(new FormData(this)).toString();
|
||||
|
||||
const [res] = await Promise.all([
|
||||
fetch(url, { headers: { 'X-Requested-With': 'XMLHttpRequest' } }),
|
||||
new Promise(resolve => setTimeout(resolve, 200))
|
||||
]);
|
||||
|
||||
if(!res.ok) throw new Error('Network response was not ok');
|
||||
const html = await res.text();
|
||||
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(html, 'text/html');
|
||||
|
||||
const updateDOM = () => {
|
||||
const currentTitle = document.querySelector('h2.text-xl');
|
||||
const newTitle = doc.querySelector('h2.text-xl');
|
||||
if(currentTitle && newTitle) currentTitle.innerHTML = newTitle.innerHTML;
|
||||
|
||||
const currentGrid = document.querySelector('.grid.grid-cols-1');
|
||||
const newGrid = doc.querySelector('.grid.grid-cols-1');
|
||||
if(currentGrid && newGrid) currentGrid.innerHTML = newGrid.innerHTML;
|
||||
|
||||
const currentTable = document.querySelector('.bg-white.shadow-sm');
|
||||
const newTable = doc.querySelector('.bg-white.shadow-sm');
|
||||
if(currentTable && newTable) currentTable.innerHTML = newTable.innerHTML;
|
||||
};
|
||||
|
||||
if (document.startViewTransition) {
|
||||
document.startViewTransition(updateDOM);
|
||||
} else {
|
||||
updateDOM();
|
||||
}
|
||||
|
||||
window.history.pushState({}, '', url);
|
||||
|
||||
} catch(err) {
|
||||
console.error('Error fetching data:', err);
|
||||
window.location.reload();
|
||||
} finally {
|
||||
btn.classList.remove('opacity-50', 'cursor-not-allowed');
|
||||
btn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
// Intercept pagination clicks for AJAX loading
|
||||
document.addEventListener('click', async function(e) {
|
||||
const link = e.target.closest('.bg-white.shadow-sm nav a');
|
||||
if (!link) return;
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
const tableBody = document.querySelector('.bg-white.shadow-sm tbody');
|
||||
if(tableBody) {
|
||||
let skeletonHtml = '';
|
||||
for(let i=0; i<5; i++) {
|
||||
skeletonHtml += `
|
||||
<tr class="animate-pulse">
|
||||
<td class="px-6 py-4"><div class="h-4 bg-gray-200 rounded w-16"></div></td>
|
||||
<td class="px-6 py-4"><div class="h-4 bg-gray-200 rounded w-48"></div></td>
|
||||
<td class="px-6 py-4"><div class="h-4 bg-gray-200 rounded w-12"></div></td>
|
||||
<td class="px-6 py-4"><div class="h-4 bg-gray-200 rounded w-12"></div></td>
|
||||
<td class="px-6 py-4"><div class="h-4 bg-gray-200 rounded w-16"></div></td>
|
||||
<td class="px-6 py-4 flex justify-end gap-3"><div class="h-8 bg-gray-200 rounded w-20"></div></td>
|
||||
</tr>`;
|
||||
}
|
||||
tableBody.innerHTML = skeletonHtml;
|
||||
}
|
||||
|
||||
try {
|
||||
const url = link.href;
|
||||
const res = await fetch(url, { headers: { 'X-Requested-With': 'XMLHttpRequest' } });
|
||||
if(!res.ok) throw new Error('Network response was not ok');
|
||||
const html = await res.text();
|
||||
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(html, 'text/html');
|
||||
|
||||
const updateDOM = () => {
|
||||
const currentTable = document.querySelector('.bg-white.shadow-sm');
|
||||
const newTable = doc.querySelector('.bg-white.shadow-sm');
|
||||
if(currentTable && newTable) currentTable.innerHTML = newTable.innerHTML;
|
||||
};
|
||||
|
||||
if (document.startViewTransition) {
|
||||
document.startViewTransition(updateDOM);
|
||||
} else {
|
||||
updateDOM();
|
||||
}
|
||||
|
||||
window.history.pushState({}, '', url);
|
||||
} catch(err) {
|
||||
console.error('Error fetching pagination data:', err);
|
||||
window.location.reload();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@endpush
|
||||
<x-confirm-modal id="deleteModal" title="Xác nhận nghỉ việc" confirmText="Xác nhận Nghỉ việc" />
|
||||
@endsection
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
<x-stat-card title="User nhận nhiều nhất" :user="$topReceivedUser?->msnv" :value="$topReceivedUser?->total_received ?? 0" bgClass="!bg-[#0f4c81]" valueLabel="Tổng thẻ đã nhận" />
|
||||
<x-stat-card title="User gửi nhiều nhất" :user="$topSentUser?->msnv" :value="$topSentUser?->total_sent ?? 0" bgClass="!bg-[#ef222e]" valueLabel="Tổng thẻ đã gửi" />
|
||||
@@ -0,0 +1,55 @@
|
||||
<div class="px-6 py-4 border-b border-border-light flex justify-between items-center bg-gray-50/50">
|
||||
<span class="text-sm font-medium text-text-medium" id="paginationStats">
|
||||
@if($users->total() > 0)
|
||||
Showing {{ $users->firstItem() }}-{{ $users->lastItem() }} of {{ $users->total() }} users
|
||||
@else
|
||||
Showing 0 users
|
||||
@endif
|
||||
</span>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-border-light text-sm">
|
||||
<thead class="bg-gray-50/80">
|
||||
<tr>
|
||||
<th class="table-header-cell">MSNV</th>
|
||||
<th class="table-header-cell">Email</th>
|
||||
<th class="table-header-cell">Đã Nhận</th>
|
||||
<th class="table-header-cell">Đã Gửi</th>
|
||||
<th class="table-header-cell">Số dư thẻ</th>
|
||||
<th class="table-header-cell">Quyền gửi</th>
|
||||
<th class="table-header-cell text-right">Thao tác</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-border-light">
|
||||
@forelse($users as $u)
|
||||
<tr class="hover:bg-slate-50 transition-colors">
|
||||
<td class="table-body-cell font-bold text-text-dark">{{ $u->msnv }}</td>
|
||||
<td class="table-body-cell text-text-medium">{{ $u->mail }}</td>
|
||||
<td class="table-body-cell font-extrabold text-green-600">+{{ $u->total_received }}</td>
|
||||
<td class="table-body-cell font-extrabold text-blue-600">{{ $u->total_sent }}</td>
|
||||
<td class="table-body-cell font-extrabold text-orange-500">{{ $u->card }}</td>
|
||||
<td class="table-body-cell">
|
||||
@if($u->flag_send)
|
||||
<span class="px-2 py-1 inline-flex text-[10px] leading-5 font-semibold rounded-full bg-green-100 text-green-800">Được gửi</span>
|
||||
@else
|
||||
<span class="px-2 py-1 inline-flex text-[10px] leading-5 font-semibold rounded-full bg-gray-100 text-gray-800">Không</span>
|
||||
@endif
|
||||
</td>
|
||||
<td class="table-body-cell text-right font-medium space-x-3">
|
||||
<a href="{{ route('admin.users.edit', $u->msnv) }}" class="table-action-link text-primary">Quản lý</a>
|
||||
<button type="button" onclick="openDeleteModal('{{ $u->msnv }}', '{{ route('admin.users.destroy', $u->msnv) }}')" class="table-action-link text-red-500">Nghỉ việc</button>
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="7" class="px-6 py-10 text-center text-text-light font-medium">Chưa có user nào đang hoạt động.</td>
|
||||
</tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@if($users->hasPages())
|
||||
<div class="px-6 py-4 border-t border-border-light bg-gray-50/30">
|
||||
{{ $users->links() }}
|
||||
</div>
|
||||
@endif
|
||||
@@ -0,0 +1,4 @@
|
||||
Danh sách User
|
||||
<div id="loadingSpinner" class="hidden animate-spin rounded-full h-5 w-5 border-2 border-indigo-600 border-t-transparent" role="status">
|
||||
<span class="sr-only">Loading...</span>
|
||||
</div>
|
||||
@@ -0,0 +1,17 @@
|
||||
@props(['mode', 'user' => null])
|
||||
|
||||
<div class="form-footer flex space-x-2">
|
||||
@if($mode === 'create')
|
||||
<a href="{{ route('admin.users.index') }}" class="btn-secondary min-w-[120px] h-[45px]">Hủy bỏ</a>
|
||||
<button type="submit" class="btn-primary min-w-[120px] h-[45px]">Tạo Nhân Viên</button>
|
||||
@elseif($mode === 'edit')
|
||||
<a href="{{ route('admin.users.index') }}" class="btn-secondary min-w-[120px] h-[45px]">Quay lại</a>
|
||||
<button type="submit" class="btn-primary min-w-[120px] h-[45px]">Lưu Thay Đổi</button>
|
||||
@elseif($mode === 'delete')
|
||||
<a href="{{ route('admin.users.index') }}" class="btn-secondary min-w-[120px] h-[45px]">Quay lại</a>
|
||||
<button type="submit" class="btn-primary bg-red-600 hover:bg-red-700 min-w-[120px] h-[45px]">Nghỉ việc</button>
|
||||
@elseif($mode === 'self')
|
||||
<a href="{{ route('user.dashboard') }}" class="btn-secondary min-w-[120px] h-[45px]">Quay lại</a>
|
||||
@endif
|
||||
</div>
|
||||
{{-- duplicated block removed --}}
|
||||
@@ -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'])
|
||||
|
||||
<x-modal :id="$id" :title="$title" maxWidth="md" hideHeader="true">
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="flex-shrink-0 flex items-center justify-center w-12 h-12 bg-red-100 rounded-full">
|
||||
<svg class="w-6 h-6 text-red-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1 mt-1">
|
||||
<h3 class="text-lg font-bold text-gray-900">{{ $title }}</h3>
|
||||
<p class="text-sm text-gray-600 mt-2 leading-relaxed" id="{{ $id }}-message">
|
||||
{{ $message }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<x-slot name="footer">
|
||||
<form id="{{ $id }}-form" method="POST" class="w-full sm:w-auto">
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
<button type="submit" id="{{ $id }}-confirm-btn" class="w-full btn-primary !px-6 shadow-sm {{ $confirmBg }}">{{ $confirmText }}</button>
|
||||
</form>
|
||||
<button type="button" onclick="closeModal('{{ $id }}')" class="w-full sm:w-auto btn-secondary !px-6 shadow-sm">{{ $cancelText }}</button>
|
||||
</x-slot>
|
||||
</x-modal>
|
||||
@@ -0,0 +1,7 @@
|
||||
@props(['name', 'class' => ''])
|
||||
@php
|
||||
$svgPath = public_path('assets/icons/' . $name . '.svg');
|
||||
@endphp
|
||||
<span {{ $attributes->merge(['class' => $class]) }} role="img" aria-hidden="true">
|
||||
{!! file_get_contents($svgPath) !!}
|
||||
</span>
|
||||
@@ -0,0 +1,12 @@
|
||||
@props(['mode', 'user' => null, 'selectedMonth'])
|
||||
|
||||
@php
|
||||
$actionUrl = $mode === 'self'
|
||||
? route('user.dashboard')
|
||||
: route('admin.users.edit', $user?->msnv);
|
||||
@endphp
|
||||
|
||||
<form action="{{ $actionUrl }}" method="GET" class="flex items-center gap-2 flex-shrink-0">
|
||||
<input type="month" name="month" value="{{ $selectedMonth }}" onchange="this.form.submit()" class="form-input !min-h-[45px] !py-0 !text-sm w-auto border-border-medium rounded bg-white">
|
||||
<button type="submit" class="btn-primary !h-[45px] !px-4 text-sm rounded shadow-sm hover:translate-y-[-1px] sm:hidden">Lọc</button>
|
||||
</form>
|
||||
@@ -0,0 +1,14 @@
|
||||
@props(['title', 'user', 'value', 'bgClass' => 'bg-[#0f4c81]', 'valueLabel' => 'Tổng thẻ đã nhận'])
|
||||
|
||||
<div class="card-stat-active {{ $bgClass }}">
|
||||
<div class="space-y-2">
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-white/90 text-xs font-bold uppercase tracking-wider">{{ $title }}</span>
|
||||
<span class="text-2xl font-black">{{ $user && $value > 0 ? $user : 'Chưa có' }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center pt-2 border-t border-white/20">
|
||||
<span class="text-white/80 text-[10px] font-bold">{{ $valueLabel }}</span>
|
||||
<span class="text-lg font-bold text-yellow-300">{{ $value }} thẻ</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,17 @@
|
||||
@props(['rows' => 10, 'cols' => 7])
|
||||
|
||||
@for ($i = 0; $i < $rows; $i++)
|
||||
<tr class="animate-pulse h-[69px]">
|
||||
@for ($j = 0; $j < $cols; $j++)
|
||||
@if ($j === 1)
|
||||
<td class="px-6 py-4"><div class="h-4 bg-gray-200 rounded w-48"></div></td>
|
||||
@elseif ($j === 5)
|
||||
<td class="px-6 py-4"><div class="h-6 bg-gray-200 rounded-full w-20"></div></td>
|
||||
@elseif ($j === 6)
|
||||
<td class="px-6 py-4"><div class="h-8 bg-gray-200 rounded w-20 ml-auto"></div></td>
|
||||
@else
|
||||
<td class="px-6 py-4"><div class="h-4 bg-gray-200 rounded w-16"></div></td>
|
||||
@endif
|
||||
@endfor
|
||||
</tr>
|
||||
@endfor
|
||||
@@ -0,0 +1,78 @@
|
||||
@props(['mode', 'user', 'administrations', 'selectedMonth'])
|
||||
|
||||
<div class="bg-white rounded-xl shadow-sm border border-border-light overflow-hidden flex flex-col h-full">
|
||||
<div class="px-6 py-4 sm:py-5 border-b border-border-light bg-gray-50/50 flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||||
<h3 class="text-lg font-bold text-text-dark">
|
||||
@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
|
||||
</h3>
|
||||
|
||||
<x-month-filter :mode="$mode" :user="$user" :selectedMonth="$selectedMonth" />
|
||||
</div>
|
||||
|
||||
<div class="p-0 overflow-y-auto max-h-[600px]">
|
||||
<table class="min-w-full divide-y divide-border-light text-sm">
|
||||
<thead class="bg-gray-50/80 sticky top-0">
|
||||
<tr>
|
||||
<th class="px-6 py-4 text-left text-xs font-bold text-text-light uppercase tracking-wider">Ngày</th>
|
||||
<th class="px-6 py-4 text-left text-xs font-bold text-text-light uppercase tracking-wider">
|
||||
{{ $mode === 'self' ? 'Loại giao dịch' : 'Hành động' }}
|
||||
</th>
|
||||
<th class="px-6 py-4 text-left text-xs font-bold text-text-light uppercase tracking-wider">
|
||||
{{ $mode === 'self' ? 'Đối tác' : 'Đối tượng' }}
|
||||
</th>
|
||||
<th class="px-6 py-4 text-left text-xs font-bold text-text-light uppercase tracking-wider">Số lượng</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-border-light">
|
||||
@forelse($administrations as $admin_record)
|
||||
@php
|
||||
$isReceiver = $admin_record->msnv == $user->msnv && $admin_record->received > 0;
|
||||
@endphp
|
||||
<tr class="hover:bg-slate-50 transition-colors">
|
||||
<td class="px-6 py-3 whitespace-nowrap text-text-medium font-medium">{{ Carbon\Carbon::parse($admin_record->date)->format('d/m/Y') }}</td>
|
||||
<td class="px-6 py-3 whitespace-nowrap">
|
||||
@if($isReceiver)
|
||||
<span class="px-2 py-1 inline-flex text-xs leading-5 font-bold rounded-full bg-green-100 text-green-800">
|
||||
{{ $mode === 'self' ? 'Bạn đã nhận thẻ' : 'Nhận thẻ' }}
|
||||
</span>
|
||||
@else
|
||||
<span class="px-2 py-1 inline-flex text-xs leading-5 font-bold rounded-full bg-blue-100 text-blue-800">
|
||||
{{ $mode === 'self' ? 'Bạn gửi tặng thẻ' : 'Gửi thẻ' }}
|
||||
</span>
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-6 py-3 whitespace-nowrap font-bold text-text-dark">
|
||||
@if($isReceiver)
|
||||
{{ $mode === 'self' ? 'Nhận từ: ' : 'Từ: ' }}<span class="text-primary">{{ $admin_record->sender }}</span>
|
||||
@else
|
||||
{{ $mode === 'self' ? 'Gửi cho: ' : 'Tới: ' }}<span class="text-primary">{{ $admin_record->receiver }}</span>
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-6 py-3 whitespace-nowrap font-extrabold text-lg">
|
||||
@if($isReceiver)
|
||||
<span class="text-green-600">+{{ $admin_record->received }}</span>
|
||||
@else
|
||||
<span class="text-blue-600">-{{ $admin_record->sent }}</span>
|
||||
@endif
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="4" class="px-6 py-12 text-center text-text-light font-medium text-base">
|
||||
{{ $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.' }}
|
||||
</td>
|
||||
</tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@if($administrations->hasPages())
|
||||
<div class="px-6 py-4 border-t border-border-light bg-gray-50/30">
|
||||
{{ $administrations->links() }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@@ -0,0 +1,28 @@
|
||||
@props(['action', 'search' => '', 'flagSend' => '', 'selectedMonth' => ''])
|
||||
|
||||
<form id="filterForm" action="{{ $action }}" method="GET" class="flex flex-col md:flex-row items-stretch md:items-center gap-3 w-full mb-6 bg-gray-50/50 p-4 rounded-xl border border-border-light">
|
||||
<div class="relative flex items-center flex-1 min-w-[200px]">
|
||||
<input type="text" id="searchInput" name="search" value="{{ $search }}" placeholder="Tìm MSNV hoặc Email..." class="form-input !py-1.5 !text-sm !pl-10 !pr-10 w-full border-border-medium rounded bg-white">
|
||||
<svg class="w-4 h-4 text-text-light absolute left-3" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" style="pointer-events: none;">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
<button type="button" id="btnClearSearch" class="{{ empty($search) ? 'hidden' : '' }} absolute right-3 text-text-light hover:text-text-dark focus:outline-none">
|
||||
<svg class="w-4 h-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<select name="flag_send" id="flagSendFilter" class="form-input !py-1.5 !text-sm w-full md:w-auto border-border-medium rounded bg-white min-w-[150px]">
|
||||
<option value="" {{ is_null($flagSend) || $flagSend === '' ? 'selected' : '' }}>Quyền gửi: Tất cả</option>
|
||||
<option value="1" {{ $flagSend === '1' ? 'selected' : '' }}>Được gửi</option>
|
||||
<option value="0" {{ $flagSend === '0' ? 'selected' : '' }}>Không được gửi</option>
|
||||
</select>
|
||||
|
||||
<input type="month" name="month" id="monthFilter" value="{{ $selectedMonth }}" class="form-input !py-1.5 !text-sm w-full md:w-auto border-border-medium rounded bg-white">
|
||||
|
||||
<div class="flex items-center gap-2 w-full md:w-auto">
|
||||
<button type="submit" id="btnFilter" class="btn-primary !py-1.5 !px-4 text-sm rounded shadow-sm hover:translate-y-[-1px] flex items-center justify-center min-w-[70px] flex-1 md:flex-none">Tìm</button>
|
||||
<button type="button" id="btnClearFilters" class="btn-secondary !py-1.5 !px-4 text-sm rounded border border-border-medium hover:bg-gray-100 flex items-center justify-center min-w-[100px] flex-1 md:flex-none">Xóa bộ lọc</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -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
|
||||
|
||||
<div class="form-container flex flex-col h-full justify-between">
|
||||
<div class="form-header">
|
||||
<h3 class="form-header-title">
|
||||
{{ 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),
|
||||
} }}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
@if(!$isCreate && $user)
|
||||
<div class="form-body pb-0">
|
||||
<div class="mb-6 pb-6 border-b border-border-light space-y-3">
|
||||
<div class="flex justify-between items-center text-sm">
|
||||
<span class="text-text-medium font-bold">Email:</span>
|
||||
<span class="font-semibold text-text-dark">{{ $user->mail }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center text-sm">
|
||||
<span class="text-text-medium font-bold">Role:</span>
|
||||
<span class="font-semibold text-text-dark">{{ $user->role == \App\Models\User::ROLE_ADMIN ? 'Admin' : 'Member' }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center text-sm">
|
||||
<span class="text-text-medium font-bold">Số dư thẻ:</span>
|
||||
<span class="font-extrabold text-orange-500 text-xl">{{ $user->card }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if($actionUrl)
|
||||
<form action="{{ $actionUrl }}" method="POST" class="flex flex-col flex-1 justify-between">
|
||||
@csrf
|
||||
@if($method !== 'POST')
|
||||
@method($method)
|
||||
@endif
|
||||
@endif
|
||||
|
||||
<div class="form-body {{ !$isCreate ? 'pt-0' : '' }}">
|
||||
@if($isCreate)
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="msnv">Mã nhân viên (MSNV) <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 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>
|
||||
</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">
|
||||
</div>
|
||||
@error('msnv')<span class="error-text">{{ $message }}</span>@enderror
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="mail">Email <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="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>
|
||||
</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">
|
||||
</div>
|
||||
@error('mail')<span class="error-text">{{ $message }}</span>@enderror
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<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="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>
|
||||
</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...">
|
||||
</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>
|
||||
@error('password')<span class="error-text">{{ $message }}</span>@enderror
|
||||
</div>
|
||||
|
||||
<div class="form-group mb-0">
|
||||
<label class="form-label" for="role">Quyền hạn <span class="text-red-500">*</span></label>
|
||||
<select name="role" id="role" class="form-input bg-white" required>
|
||||
<option value="{{ \App\Models\User::ROLE_MEMBER }}" {{ old('role') == \App\Models\User::ROLE_MEMBER ? 'selected' : '' }}>Nhân viên (Member)</option>
|
||||
<option value="{{ \App\Models\User::ROLE_ADMIN }}" {{ old('role') == \App\Models\User::ROLE_ADMIN ? 'selected' : '' }}>Quản trị viên (Admin)</option>
|
||||
</select>
|
||||
@error('role')<span class="error-text">{{ $message }}</span>@enderror
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if($isEdit && $user)
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="num_card">Nạp thêm thẻ</label>
|
||||
<input type="number" name="num_card" id="num_card" min="1" class="form-input bg-white" placeholder="Nhập số lượng thẻ muốn nạp...">
|
||||
@error('num_card')<span class="error-text">{{ $message }}</span>@enderror
|
||||
</div>
|
||||
|
||||
<div class="form-group mb-0">
|
||||
<label class="flex items-center gap-3 cursor-pointer p-4 border border-[#d9dfe7] rounded-lg hover:bg-gray-50 hover:border-gray-400 transition-colors shadow-sm bg-white">
|
||||
<input type="checkbox" name="flag_send" id="flag_send" value="1" class="form-input !w-5 !h-5 !min-h-0" {{ $user->flag_send ? 'checked' : '' }}>
|
||||
<span class="text-[14px] font-[600] text-text-dark">Bật quyền cho phép User gửi thẻ</span>
|
||||
</label>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if($isDelete && $user)
|
||||
<p class="text-sm text-red-600 leading-relaxed font-semibold">
|
||||
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.
|
||||
</p>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<x-action-panel :mode="$mode" :user="$user" />
|
||||
|
||||
@if($actionUrl)
|
||||
</form>
|
||||
@endif
|
||||
</div>
|
||||
@@ -0,0 +1,39 @@
|
||||
@props(['mode', 'user' => null, 'administrations' => null, 'selectedMonth' => null])
|
||||
|
||||
@if($mode === 'self' && $user)
|
||||
<div class="flex flex-col md:flex-row md:items-center justify-between gap-4 mb-6">
|
||||
<div class="flex items-center gap-4">
|
||||
<h2 class="text-xl font-bold text-text-dark flex items-center gap-2">
|
||||
My Page
|
||||
</h2>
|
||||
<div class="px-4 py-1.5 bg-orange-100 border border-orange-200 rounded-full text-orange-800 font-bold text-sm flex items-center gap-2 shadow-sm">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-5 h-5 text-orange-500">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 8.25h19.5M2.25 9h19.5m-16.5 5.25h6m-6 2.25h3m-3.75 3h15a2.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.5z" />
|
||||
</svg>
|
||||
Thẻ hiện có: <span class="text-lg">{{ $user->card }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="grid grid-cols-1 {{ in_array($mode, ['edit', 'delete']) ? 'lg:grid-cols-3' : '' }} gap-6 items-start">
|
||||
|
||||
<!-- LEFT PANEL: User Profile & Actions / Form -->
|
||||
<div class="{{ in_array($mode, ['edit', 'delete']) ? 'col-span-1' : 'col-span-full' }}">
|
||||
@if($mode === 'create')
|
||||
<div class="max-w-2xl mx-auto w-full">
|
||||
<x-user-form :mode="$mode" :user="$user" />
|
||||
</div>
|
||||
@else
|
||||
<x-user-form :mode="$mode" :user="$user" />
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<!-- RIGHT PANEL: Transaction History -->
|
||||
@if(in_array($mode, ['edit', 'delete', 'self']) && $user && $administrations)
|
||||
<div class="col-span-1 lg:col-span-2">
|
||||
<x-transaction-history :mode="$mode" :user="$user" :administrations="$administrations" :selectedMonth="$selectedMonth" />
|
||||
</div>
|
||||
@endif
|
||||
|
||||
</div>
|
||||
@@ -8,19 +8,24 @@
|
||||
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
||||
@stack('styles')
|
||||
</head>
|
||||
<body class="bg-bg-main text-text-dark font-sans antialiased">
|
||||
<body class="bg-bg-main text-text-dark font-sans antialiased" data-sidebar="expanded">
|
||||
<div class="app-layout">
|
||||
<aside class="sidebar">
|
||||
<!-- Mobile overlay (click outside to close) -->
|
||||
<div id="sidebar-overlay" class="fixed inset-0 bg-black/30 hidden" aria-hidden="true"></div>
|
||||
<aside class="sidebar" aria-label="Main navigation">
|
||||
<div class="flex flex-col h-full justify-between">
|
||||
<div class="flex-1 flex flex-col min-h-0 overflow-hidden">
|
||||
<div class="h-16 flex items-center justify-between px-6 border-b border-border-light">
|
||||
<div class="flex items-center gap-2">
|
||||
<img src="{{ asset('images/logo.png') }}" alt="GMO-Z.com RUNSYSTEM" class="h-8 w-auto object-contain">
|
||||
</div>
|
||||
<button class="text-text-light hover:text-primary">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-5 h-5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
|
||||
</svg>
|
||||
<!-- Collapse / Expand button (desktop) -->
|
||||
<button id="sidebar-toggle" class="hidden lg:inline-flex p-2 focus:outline-none focus:ring-2 focus:ring-white" aria-label="Collapse sidebar" aria-controls="sidebar" aria-expanded="true">
|
||||
<x-icon name="icn-chevron-left" class="w-5 h-5" />
|
||||
</button>
|
||||
<!-- Mobile hamburger button -->
|
||||
<button id="sidebar-hamburger" class="lg:hidden p-2 focus:outline-none focus:ring-2 focus:ring-white" aria-label="Open navigation" aria-controls="sidebar" aria-expanded="false">
|
||||
<x-icon name="icn-menu" class="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
+174
-80
@@ -14,135 +14,229 @@
|
||||
.animate-fade-in {
|
||||
animation: fadeIn 0.3s ease-in-out forwards;
|
||||
}
|
||||
|
||||
.floating-group {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
.floating-input {
|
||||
width: 100%;
|
||||
height: 56px;
|
||||
padding-left: 2.75rem;
|
||||
padding-right: 1rem;
|
||||
font-size: 0.875rem;
|
||||
color: #1e293b;
|
||||
background-color: transparent;
|
||||
border: 1px solid #c4c4c4;
|
||||
border-radius: 4px;
|
||||
outline: none;
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
.floating-input:hover {
|
||||
border-color: #475569;
|
||||
}
|
||||
.floating-input:focus {
|
||||
border: 2px solid #0f4c81;
|
||||
padding-left: calc(2.75rem - 1px);
|
||||
}
|
||||
.floating-label {
|
||||
position: absolute;
|
||||
left: 2.75rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: #94a3b8;
|
||||
font-size: 0.875rem;
|
||||
pointer-events: none;
|
||||
transition: all 0.2s ease-in-out;
|
||||
background-color: transparent;
|
||||
padding: 0;
|
||||
}
|
||||
.floating-input:focus ~ .floating-label,
|
||||
.floating-input:not(:placeholder-shown) ~ .floating-label {
|
||||
top: 0;
|
||||
transform: translateY(-50%);
|
||||
font-size: 0.75rem;
|
||||
color: #0f4c81;
|
||||
background-color: #ffffff;
|
||||
padding: 0 6px;
|
||||
left: 2.5rem;
|
||||
}
|
||||
.floating-input:not(:focus):not(:placeholder-shown) ~ .floating-label {
|
||||
color: #64748b;
|
||||
}
|
||||
.floating-icon {
|
||||
position: absolute;
|
||||
left: 1rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: #94a3b8;
|
||||
pointer-events: none;
|
||||
transition: color 0.2s ease-in-out;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.floating-input:focus ~ .floating-icon {
|
||||
color: #0f4c81;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-[#f0f4f9] min-h-screen flex items-center justify-center font-sans antialiased overflow-x-hidden">
|
||||
<div class="relative w-full max-w-6xl mx-auto px-4 flex flex-col lg:flex-row items-center justify-between gap-12 py-10">
|
||||
<body class="min-h-screen flex items-center justify-center font-sans antialiased overflow-x-hidden relative bg-gradient-to-br from-[#ffffff] via-[#f8fafc] to-[#eef2f6]">
|
||||
|
||||
<div class="flex-1 flex flex-col items-center">
|
||||
<div class="relative w-full max-w-[680px] min-h-[490px] lg:aspect-[16/10.5] lg:min-h-0 bg-[#1e293b] rounded-t-[24px] p-3 shadow-2xl border border-slate-700">
|
||||
<div class="w-full h-full bg-[#f0f4f9] rounded-t-[12px] rounded-b-none overflow-hidden flex flex-col justify-between p-6 relative">
|
||||
<div class="absolute inset-0 pointer-events-none opacity-20">
|
||||
<svg class="w-full h-full" viewBox="0 0 100 100" preserveAspectRatio="none">
|
||||
<path d="M0,40 C30,45 70,30 100,40 L100,100 L0,100 Z" fill="#0f4c81"></path>
|
||||
<div class="absolute inset-0 pointer-events-none overflow-hidden z-0 select-none">
|
||||
<svg class="absolute bottom-0 left-0 w-[45%] h-[40%] min-w-[320px] max-w-[600px] text-[#0f4c81]/[0.04]" viewBox="0 0 100 100" preserveAspectRatio="none" fill="currentColor">
|
||||
<path d="M 0,100 C 30,85 55,60 70,30 C 85,10 95,5 100,0 L 0,0 Z" transform="rotate(180, 50, 50)" />
|
||||
<path d="M 0,100 C 40,75 60,50 75,25 C 90,5 95,0 100,0 L 0,0 Z" transform="rotate(180, 50, 50)" opacity="0.5" />
|
||||
</svg>
|
||||
<svg class="absolute top-10 right-10 w-[180px] h-[180px] text-[#0f4c81]/[0.03]" viewBox="0 0 100 100" fill="currentColor">
|
||||
<circle cx="10" cy="10" r="1.5" /> <circle cx="30" cy="10" r="1.5" /> <circle cx="50" cy="10" r="1.5" /> <circle cx="70" cy="10" r="1.5" /> <circle cx="90" cy="10" r="1.5" />
|
||||
<circle cx="10" cy="30" r="1.5" /> <circle cx="30" cy="30" r="1.5" /> <circle cx="50" cy="30" r="1.5" /> <circle cx="70" cy="30" r="1.5" /> <circle cx="90" cy="30" r="1.5" />
|
||||
<circle cx="10" cy="50" r="1.5" /> <circle cx="30" cy="50" r="1.5" /> <circle cx="50" cy="50" r="1.5" /> <circle cx="70" cy="50" r="1.5" /> <circle cx="90" cy="50" r="1.5" />
|
||||
<circle cx="10" cy="70" r="1.5" /> <circle cx="30" cy="70" r="1.5" /> <circle cx="50" cy="70" r="1.5" /> <circle cx="70" cy="70" r="1.5" /> <circle cx="90" cy="70" r="1.5" />
|
||||
<circle cx="10" cy="90" r="1.5" /> <circle cx="30" cy="90" r="1.5" /> <circle cx="50" cy="90" r="1.5" /> <circle cx="70" cy="90" r="1.5" /> <circle cx="90" cy="90" r="1.5" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-start items-center z-10">
|
||||
<img src="{{ asset('images/logo.png') }}" alt="GMO-Z.com RUNSYSTEM" class="h-10 w-auto object-contain">
|
||||
<div class="relative w-full max-w-6xl mx-auto px-6 flex flex-col lg:flex-row items-center justify-between gap-16 py-12 z-10">
|
||||
|
||||
<div class="flex-1 flex flex-col items-center w-full">
|
||||
<div class="relative w-full max-w-[620px] bg-[#263238] rounded-t-[16px] p-2 sm:p-4 shadow-2xl border border-slate-700/60 transition-all duration-300">
|
||||
<div class="w-full bg-white rounded-[6px] overflow-hidden flex flex-col justify-between p-6 aspect-[16/10.5] min-h-[440px] relative shadow-inner">
|
||||
|
||||
<div class="flex justify-start items-center">
|
||||
<img src="{{ asset('images/logo.png') }}" alt="GMO-Z.com RUNSYSTEM" class="h-8 w-auto object-contain">
|
||||
</div>
|
||||
|
||||
<div class="w-full max-w-[380px] mx-auto z-10 space-y-4 my-auto">
|
||||
<h3 class="text-2xl font-bold text-center text-[#1e293b]">{{ __('auth.login') }}</h3>
|
||||
<div class="w-full max-w-[360px] mx-auto space-y-4 my-auto">
|
||||
<h3 class="text-xl font-bold text-center text-[#0f4c81] tracking-wide">{{ __('auth.login') }}</h3>
|
||||
|
||||
<form action="{{ route('login') }}" method="POST" class="space-y-3.5">
|
||||
<form action="{{ route('login') }}" method="POST" class="space-y-4">
|
||||
@csrf
|
||||
|
||||
<!-- Reserved Error Area to Prevent Layout Shift (CLS) -->
|
||||
<div class="min-h-[52px] flex items-center justify-center transition-all duration-300" aria-live="polite">
|
||||
@if($errors->any())
|
||||
<div class="w-full bg-red-50 text-red-600 text-xs sm:text-sm font-semibold p-2.5 rounded-lg border border-red-200 shadow-sm flex items-start gap-2 animate-fade-in transition-opacity duration-300 ease-in-out opacity-100">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 shrink-0 mt-0.5 text-red-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<div class="w-full bg-red-50 text-red-600 text-xs font-semibold p-2.5 rounded border border-red-200 shadow-sm flex items-start gap-2 animate-fade-in transition-opacity duration-300 ease-in-out opacity-100">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 shrink-0 mt-0.5 text-red-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
<span class="leading-normal">{{ $errors->first() }}</span>
|
||||
</div>
|
||||
@else
|
||||
<div class="w-full opacity-0 pointer-events-none select-none" aria-hidden="true">
|
||||
<div class="p-2.5 text-xs sm:text-sm border border-transparent flex items-start gap-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24"></svg>
|
||||
<div class="p-2.5 text-xs border border-transparent flex items-start gap-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24"></svg>
|
||||
<span class="leading-normal"> </span>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label !mb-1.5 uppercase tracking-wider text-xs">{{ __('auth.email') }} <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="2" stroke="currentColor" class="w-4.5 h-4.5 text-text-light">
|
||||
<div class="floating-group">
|
||||
<input type="email" name="mail" id="mail" placeholder=" " required value="{{ old('mail') }}" class="floating-input" />
|
||||
<label for="mail" class="floating-label">
|
||||
{{ __('auth.email') }} <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<span class="floating-icon">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-5 h-5">
|
||||
<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>
|
||||
</span>
|
||||
<input type="email" name="mail" placeholder="{{ __('auth.email_placeholder') }}" class="form-input form-input-with-icon !bg-white" required value="{{ old('mail') }}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label !mb-1.5 uppercase tracking-wider text-xs">{{ __('auth.password') }} <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="2" stroke="currentColor" class="w-4.5 h-4.5 text-text-light">
|
||||
<div class="floating-group">
|
||||
<input type="password" name="password" id="password" placeholder=" " required class="floating-input" />
|
||||
<label for="password" class="floating-label">
|
||||
{{ __('auth.password') }} <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<span class="floating-icon">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-5 h-5">
|
||||
<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>
|
||||
</span>
|
||||
<input type="password" name="password" placeholder="{{ __('auth.password_placeholder') }}" class="form-input form-input-with-icon !bg-white" required>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn-primary w-full shadow-md text-sm uppercase tracking-wide">
|
||||
<button type="submit" class="w-full h-[48px] bg-[#0f4c81] hover:bg-[#0a3a63] text-white font-semibold rounded shadow-md hover:shadow-lg transition-all duration-200 tracking-wide uppercase text-sm cursor-pointer focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[#0f4c81]">
|
||||
{{ __('auth.login') }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="text-center z-10 mt-auto">
|
||||
<span class="text-xs text-[#475569]/80 font-semibold">© {{ __('auth.developed_by') }}</span>
|
||||
<div class="text-center mt-auto">
|
||||
<span class="text-[11px] text-slate-400 font-medium">© {{ __('auth.developed_by') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="relative w-full max-w-[760px] h-3 bg-[#e2e8f0] rounded-b-2xl border-t border-white/20 shadow-lg">
|
||||
<div class="absolute left-1/2 -translate-x-1/2 top-0 w-28 h-1.5 bg-[#cbd5e1] rounded-b-md"></div>
|
||||
<div class="relative w-full max-w-[690px] h-2 sm:h-3 bg-[#b0bec5] rounded-b-[16px] border-t border-white/20 shadow-xl transition-all duration-300">
|
||||
<div class="absolute left-1/2 -translate-x-1/2 top-0 w-20 sm:w-24 h-1 sm:h-1.5 bg-[#90a4ae] rounded-b-[4px]"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 flex justify-center items-center relative min-h-[360px] lg:min-h-[480px]">
|
||||
<div class="relative z-10 w-full max-w-[420px]">
|
||||
<div class="flex-1 hidden lg:flex justify-center items-center relative min-h-[480px]">
|
||||
<div class="relative z-10 w-full max-w-[440px]">
|
||||
<svg viewBox="0 0 500 500" class="w-full h-auto">
|
||||
<circle cx="250" cy="400" r="80" fill="#e2e8f0" />
|
||||
<rect x="235" y="320" width="30" height="90" fill="#78350f" />
|
||||
<polygon points="210,410 290,410 270,440 230,440" fill="#451a03" />
|
||||
<g transform="translate(0, -20)">
|
||||
<path d="M 280,310 C 280,210 320,180 340,160 C 350,150 370,160 380,180 C 390,200 400,240 370,300 C 340,360 300,320 280,310 Z" fill="#334155" />
|
||||
<path d="M 310,210 C 320,200 335,200 345,210 C 350,215 350,225 340,235 C 330,245 315,245 305,235 C 300,230 300,215 310,210 Z" fill="#fbcfe8" />
|
||||
<circle cx="335" cy="205" r="16" fill="#fbcfe8" />
|
||||
<path d="M 320,195 C 322,185 330,180 340,183 C 345,185 348,190 345,198 Z" fill="#1e293b" />
|
||||
<ellipse cx="338" cy="203" rx="1" ry="2" fill="#1e293b" />
|
||||
<path d="M 334,212 Q 338,216 342,212" stroke="#1e293b" stroke-width="1.5" fill="none" />
|
||||
<path d="M 345,200 Q 365,190 350,175 Q 335,160 320,178 Z" fill="#1e293b" />
|
||||
<path d="M 340,160 L 350,170 L 335,175 Z" fill="#fbbf24" />
|
||||
</g>
|
||||
<g transform="translate(0, -20)">
|
||||
<path d="M 320,220 C 340,230 370,250 380,280 L 330,340 L 290,280 Z" fill="#ef222e" />
|
||||
<rect x="330" y="270" width="22" height="18" fill="#ffffff" rx="2" />
|
||||
<text x="332" y="282" font-size="8" font-family="sans-serif" font-weight="bold" fill="#ef222e">GMO-Z</text>
|
||||
</g>
|
||||
<g transform="translate(0, -20)">
|
||||
<path d="M 285,280 L 245,360 C 235,380 250,400 270,400 L 325,400 L 340,335 Z" fill="#334155" />
|
||||
<path d="M 315,335 L 340,400 C 345,410 360,400 355,390 L 330,335 Z" fill="#334155" />
|
||||
<path d="M 245,360 L 220,375 C 210,380 220,400 235,395 L 255,385 Z" fill="#fde047" />
|
||||
<path d="M 340,400 L 315,420 C 305,425 315,440 330,435 L 355,415 Z" fill="#fde047" />
|
||||
</g>
|
||||
<g transform="translate(-20, -10)">
|
||||
<rect x="250" y="295" width="65" height="45" rx="4" fill="#cbd5e1" transform="rotate(-10, 250, 295)" />
|
||||
<rect x="255" y="300" width="55" height="35" rx="2" fill="#94a3b8" transform="rotate(-10, 250, 295)" />
|
||||
<circle cx="282" cy="318" r="4" fill="#ffffff" />
|
||||
<rect x="278" y="338" width="40" height="4" rx="2" fill="#e2e8f0" transform="rotate(-10, 278, 338)" />
|
||||
</g>
|
||||
<g transform="translate(380, 110)">
|
||||
<circle cx="50" cy="50" r="30" fill="#ffffff" stroke="#e2e8f0" stroke-width="2" />
|
||||
<path d="M 47,40 L 53,40 L 53,46 L 47,46 Z" fill="#fbbf24" />
|
||||
<path d="M 43,46 L 57,46 L 57,60 L 43,60 Z" fill="#fbbf24" rx="2" />
|
||||
<circle cx="50" cy="53" r="2" fill="#1e293b" />
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
<circle cx="250" cy="250" r="180" fill="none" stroke="#e2e8f0" stroke-width="2" stroke-dasharray="6,6" opacity="0.6" />
|
||||
|
||||
<div class="absolute left-4 top-1/2 -translate-y-1/2 w-10 h-10 rounded-full bg-[#fbbf24] flex items-center justify-center shadow-md">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-6 h-6 text-white">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z" />
|
||||
<rect x="60" y="80" width="120" height="70" rx="4" fill="none" stroke="#cbd5e1" stroke-width="2" />
|
||||
<line x1="60" y1="120" x2="180" y2="120" stroke="#cbd5e1" stroke-width="2" />
|
||||
<rect x="75" y="95" width="12" height="25" fill="#94a3b8" />
|
||||
<rect x="90" y="90" width="14" height="30" fill="#cbd5e1" />
|
||||
<rect x="110" y="100" width="14" height="20" fill="#94a3b8" transform="rotate(15, 110, 100)" />
|
||||
|
||||
<rect x="340" y="60" width="100" height="130" rx="6" fill="none" stroke="#cbd5e1" stroke-width="2" />
|
||||
<line x1="390" y1="60" x2="390" y2="190" stroke="#cbd5e1" stroke-width="1.5" />
|
||||
<line x1="340" y1="120" x2="440" y2="120" stroke="#cbd5e1" stroke-width="1.5" />
|
||||
|
||||
<ellipse cx="250" cy="455" rx="55" ry="9" fill="#cbd5e1" opacity="0.4" />
|
||||
|
||||
<line x1="230" y1="360" x2="215" y2="450" stroke="#d29a0f" stroke-width="4.5" stroke-linecap="round" />
|
||||
<line x1="270" y1="360" x2="285" y2="450" stroke="#d29a0f" stroke-width="4.5" stroke-linecap="round" />
|
||||
<line x1="250" y1="360" x2="250" y2="450" stroke="#9a6a00" stroke-width="3.5" stroke-linecap="round" />
|
||||
<ellipse cx="250" cy="415" rx="20" ry="4" fill="none" stroke="#d29a0f" stroke-width="2" />
|
||||
<ellipse cx="250" cy="435" rx="24" ry="5" fill="none" stroke="#d29a0f" stroke-width="2" />
|
||||
<rect x="220" y="348" width="60" height="12" rx="5" fill="#fbbf24" />
|
||||
|
||||
<polygon points="150,450 170,450 166,415 154,415" fill="#94a3b8" />
|
||||
<path d="M 160,415 Q 145,390 135,385" fill="none" stroke="#cbd5e1" stroke-width="2.5" stroke-linecap="round" />
|
||||
<path d="M 160,415 Q 165,380 175,370" fill="none" stroke="#cbd5e1" stroke-width="2.5" stroke-linecap="round" />
|
||||
<circle cx="135" cy="385" r="7" fill="#cbd5e1" />
|
||||
<circle cx="175" cy="370" r="9" fill="#94a3b8" />
|
||||
|
||||
<path d="M 230,358 Q 220,400 225,435" fill="none" stroke="#334155" stroke-width="12" stroke-linecap="round" />
|
||||
<path d="M 252,358 Q 262,400 258,435" fill="none" stroke="#334155" stroke-width="12" stroke-linecap="round" />
|
||||
<path d="M 225,435 L 218,443 L 230,443 Z" fill="#fbbf24" stroke="#fbbf24" stroke-width="3" stroke-linejoin="round" />
|
||||
<path d="M 258,435 L 265,443 L 253,443 Z" fill="#fbbf24" stroke="#fbbf24" stroke-width="3" stroke-linejoin="round" />
|
||||
|
||||
<path d="M 222,275 C 222,275 258,275 258,275 L 252,325 C 252,325 228,325 228,325 Z" fill="#ffffff" />
|
||||
<rect x="242" y="285" width="8" height="10" rx="1.5" fill="#ef222e" />
|
||||
|
||||
<rect x="235" y="260" width="10" height="16" fill="#fed7aa" />
|
||||
|
||||
<circle cx="240" cy="242" r="16" fill="#fed7aa" />
|
||||
<circle cx="236" cy="240" r="18" fill="#1e293b" />
|
||||
<path d="M 220,245 C 205,245 195,255 198,270 C 208,265 218,255 222,250 Z" fill="#1e293b" />
|
||||
<path d="M 226,232 Q 242,225 252,238 C 252,238 246,228 234,228 Z" fill="#1e293b" />
|
||||
<path d="M 228,230 Q 240,223 248,232" fill="none" stroke="#fbbf24" stroke-width="3.5" stroke-linecap="round" />
|
||||
|
||||
<polygon points="270,305 305,305 295,332 260,332" fill="#e2e8f0" />
|
||||
<polygon points="273,308 302,308 293,329 264,329" fill="#0f4c81" opacity="0.1" />
|
||||
<polygon points="252,332 295,332 290,338 247,338" fill="#cbd5e1" />
|
||||
|
||||
<path d="M 222,275 Q 240,312 265,315" fill="none" stroke="#fed7aa" stroke-width="7" stroke-linecap="round" />
|
||||
<path d="M 242,275 Q 255,295 272,300" fill="none" stroke="#fed7aa" stroke-width="7" stroke-linecap="round" />
|
||||
<circle cx="265" cy="315" r="4.5" fill="#fed7aa" />
|
||||
<circle cx="272" cy="300" r="4.5" fill="#fed7aa" />
|
||||
|
||||
<g transform="translate(45, -20)">
|
||||
<path d="M 266,160 C 266,120 305,100 335,120 C 355,130 365,150 365,170 C 365,190 345,210 325,205 L 312,222 L 309,204 C 282,198 266,180 266,160 Z" fill="#ffffff" filter="drop-shadow(0 4px 6px rgba(0,0,0,0.06))" />
|
||||
|
||||
<g transform="translate(302, 138)">
|
||||
<path d="M 8,10 L 8,6 C 8,2.5 18,2.5 18,6 L 18,10" stroke="#0f4c81" stroke-width="2.5" stroke-linecap="round" fill="none" />
|
||||
<rect x="3" y="10" width="20" height="15" rx="3.5" fill="#fbbf24" />
|
||||
<circle cx="13" cy="16.5" r="1.5" fill="#0f4c81" />
|
||||
<line x1="13" y1="18" x2="13" y2="21" stroke="#0f4c81" stroke-width="1.8" />
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,81 +2,5 @@
|
||||
@section('title', 'My Page')
|
||||
|
||||
@section('content')
|
||||
<div class="flex flex-col md:flex-row md:items-center justify-between gap-4 mb-6">
|
||||
<div class="flex items-center gap-4">
|
||||
<h2 class="text-xl font-bold text-text-dark flex items-center gap-2">
|
||||
My Page
|
||||
</h2>
|
||||
<div class="px-4 py-1.5 bg-orange-100 border border-orange-200 rounded-full text-orange-800 font-bold text-sm flex items-center gap-2 shadow-sm">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-5 h-5 text-orange-500">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 8.25h19.5M2.25 9h19.5m-16.5 5.25h6m-6 2.25h3m-3.75 3h15a2.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.5z" />
|
||||
</svg>
|
||||
Thẻ hiện có: <span class="text-lg">{{ $user->card }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form action="{{ route('user.dashboard') }}" method="GET" class="flex items-center gap-2">
|
||||
<input type="month" name="month" value="{{ $selectedMonth }}" class="form-input !py-1.5 !text-sm w-auto border-border-medium rounded">
|
||||
<button type="submit" class="btn-primary !py-1.5 !px-4 text-sm rounded shadow-sm hover:translate-y-[-1px]">Lọc</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="bg-white shadow-sm rounded-xl overflow-hidden border border-border-light">
|
||||
<div class="px-6 py-4 border-b border-border-light bg-gray-50/50">
|
||||
<h3 class="text-lg font-bold text-text-dark">Lịch sử nhận / gửi thẻ (Tháng {{ Carbon\Carbon::parse($selectedMonth)->format('m/Y') }})</h3>
|
||||
</div>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-border-light text-sm">
|
||||
<thead class="bg-gray-50/80">
|
||||
<tr>
|
||||
<th class="px-6 py-4 text-left text-xs font-bold text-text-light uppercase tracking-wider">Ngày</th>
|
||||
<th class="px-6 py-4 text-left text-xs font-bold text-text-light uppercase tracking-wider">Loại giao dịch</th>
|
||||
<th class="px-6 py-4 text-left text-xs font-bold text-text-light uppercase tracking-wider">Đối tác</th>
|
||||
<th class="px-6 py-4 text-left text-xs font-bold text-text-light uppercase tracking-wider">Số lượng</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-border-light">
|
||||
@forelse($administrations as $admin_record)
|
||||
@php
|
||||
$isReceiver = $admin_record->msnv == $user->msnv && $admin_record->received > 0;
|
||||
@endphp
|
||||
<tr class="hover:bg-slate-50 transition-colors">
|
||||
<td class="px-6 py-3 whitespace-nowrap text-text-medium font-medium">{{ Carbon\Carbon::parse($admin_record->date)->format('d/m/Y') }}</td>
|
||||
<td class="px-6 py-3 whitespace-nowrap">
|
||||
@if($isReceiver)
|
||||
<span class="px-3 py-1 inline-flex text-xs leading-5 font-bold rounded-full bg-green-100 text-green-800">Bạn đã nhận thẻ</span>
|
||||
@else
|
||||
<span class="px-3 py-1 inline-flex text-xs leading-5 font-bold rounded-full bg-blue-100 text-blue-800">Bạn gửi tặng thẻ</span>
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-6 py-3 whitespace-nowrap font-bold text-text-dark">
|
||||
@if($isReceiver)
|
||||
Nhận từ: <span class="text-primary">{{ $admin_record->sender }}</span>
|
||||
@else
|
||||
Gửi cho: <span class="text-primary">{{ $admin_record->receiver }}</span>
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-6 py-3 whitespace-nowrap font-extrabold text-lg">
|
||||
@if($isReceiver)
|
||||
<span class="text-green-600">+{{ $admin_record->received }}</span>
|
||||
@else
|
||||
<span class="text-blue-600">-{{ $admin_record->sent }}</span>
|
||||
@endif
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="4" class="px-6 py-12 text-center text-text-light font-medium text-base">Bạn chưa có giao dịch gửi/nhận thẻ nào trong tháng này.</td>
|
||||
</tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@if($administrations->hasPages())
|
||||
<div class="px-6 py-4 border-t border-border-light bg-gray-50/30">
|
||||
{{ $administrations->links() }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
<x-user-page mode="self" :user="$user" :administrations="$administrations" :selectedMonth="$selectedMonth" />
|
||||
@endsection
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Administration;
|
||||
use App\Models\User;
|
||||
use App\Services\Admin\Contracts\AdminServiceInterface;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class AdminUserListStatsTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private AdminServiceInterface $adminService;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->adminService = $this->app->make(AdminServiceInterface::class);
|
||||
}
|
||||
|
||||
public function test_user_list_stats_displays_correct_sent_and_received_cards(): void
|
||||
{
|
||||
// 1. Create mock users
|
||||
$userA = User::create([
|
||||
'msnv' => 'USER_A',
|
||||
'mail' => 'usera@example.com',
|
||||
'pass' => bcrypt('password'),
|
||||
'role' => User::ROLE_MEMBER,
|
||||
'status' => User::STATUS_ACTIVE,
|
||||
'card' => 10,
|
||||
'flag_send' => User::FLAG_SEND_ENABLED,
|
||||
'first_login' => User::FIRST_LOGIN_FALSE,
|
||||
]);
|
||||
|
||||
$userB = User::create([
|
||||
'msnv' => 'USER_B',
|
||||
'mail' => 'userb@example.com',
|
||||
'pass' => bcrypt('password'),
|
||||
'role' => User::ROLE_MEMBER,
|
||||
'status' => User::STATUS_ACTIVE,
|
||||
'card' => 10,
|
||||
'flag_send' => User::FLAG_SEND_ENABLED,
|
||||
'first_login' => User::FIRST_LOGIN_FALSE,
|
||||
]);
|
||||
|
||||
$selectedMonth = '2026-07';
|
||||
$testDate = '2026-07-15';
|
||||
|
||||
// 2. Create mock transaction logs in administration table
|
||||
// A sends 3 cards to B
|
||||
Administration::create([
|
||||
'msnv' => 'USER_A',
|
||||
'received' => 0,
|
||||
'sender' => null,
|
||||
'sent' => 3,
|
||||
'receiver' => 'USER_B',
|
||||
'date' => $testDate,
|
||||
]);
|
||||
|
||||
Administration::create([
|
||||
'msnv' => 'USER_B',
|
||||
'received' => 3,
|
||||
'sender' => 'USER_A',
|
||||
'sent' => 0,
|
||||
'receiver' => null,
|
||||
'date' => $testDate,
|
||||
]);
|
||||
|
||||
// 3. Fetch statistics via AdminService
|
||||
$stats = $this->adminService->getUserListWithStats($selectedMonth);
|
||||
|
||||
$users = $stats['users'];
|
||||
$topReceived = $stats['topReceivedUser'];
|
||||
$topSent = $stats['topSentUser'];
|
||||
|
||||
// Find users in the paginated collection
|
||||
$userAResult = collect($users->items())->firstWhere('msnv', 'USER_A');
|
||||
$userBResult = collect($users->items())->firstWhere('msnv', 'USER_B');
|
||||
|
||||
$this->assertNotNull($userAResult);
|
||||
$this->assertNotNull($userBResult);
|
||||
|
||||
// Assert card counts
|
||||
$this->assertEquals(3, $userAResult->total_sent);
|
||||
$this->assertEquals(0, $userAResult->total_received);
|
||||
|
||||
$this->assertEquals(0, $userBResult->total_sent);
|
||||
$this->assertEquals(3, $userBResult->total_received);
|
||||
|
||||
// Assert top users
|
||||
$this->assertEquals('USER_B', $topReceived->msnv);
|
||||
$this->assertEquals(3, $topReceived->total_received);
|
||||
|
||||
$this->assertEquals('USER_A', $topSent->msnv);
|
||||
$this->assertEquals(3, $topSent->total_sent);
|
||||
}
|
||||
|
||||
public function test_user_transactions_history_displays_correct_records(): void
|
||||
{
|
||||
$userA = User::create([
|
||||
'msnv' => 'USER_A',
|
||||
'mail' => 'usera@example.com',
|
||||
'pass' => bcrypt('password'),
|
||||
'role' => User::ROLE_MEMBER,
|
||||
'status' => User::STATUS_ACTIVE,
|
||||
'card' => 10,
|
||||
'flag_send' => User::FLAG_SEND_ENABLED,
|
||||
'first_login' => User::FIRST_LOGIN_FALSE,
|
||||
]);
|
||||
|
||||
$selectedMonth = '2026-07';
|
||||
$testDate = '2026-07-15';
|
||||
|
||||
// 1. Create a send record
|
||||
Administration::create([
|
||||
'msnv' => 'USER_A',
|
||||
'received' => 0,
|
||||
'sender' => null,
|
||||
'sent' => 3,
|
||||
'receiver' => 'USER_B',
|
||||
'date' => $testDate,
|
||||
]);
|
||||
|
||||
// 2. Create a receive record
|
||||
Administration::create([
|
||||
'msnv' => 'USER_A',
|
||||
'received' => 5,
|
||||
'sender' => 'USER_C',
|
||||
'sent' => 0,
|
||||
'receiver' => null,
|
||||
'date' => $testDate,
|
||||
]);
|
||||
|
||||
$transactions = $this->adminService->getUserTransactions('USER_A', $selectedMonth);
|
||||
|
||||
$this->assertCount(2, $transactions->items());
|
||||
|
||||
// Assert specific transaction fields
|
||||
$sendTx = collect($transactions->items())->firstWhere('sent', 3);
|
||||
$receiveTx = collect($transactions->items())->firstWhere('received', 5);
|
||||
|
||||
$this->assertNotNull($sendTx);
|
||||
$this->assertNotNull($receiveTx);
|
||||
|
||||
$this->assertEquals('USER_B', $sendTx->receiver);
|
||||
$this->assertNull($sendTx->sender);
|
||||
|
||||
$this->assertEquals('USER_C', $receiveTx->sender);
|
||||
$this->assertNull($receiveTx->receiver);
|
||||
}
|
||||
|
||||
public function test_user_list_stats_filters_by_search_query(): void
|
||||
{
|
||||
// 1. Create mock users
|
||||
User::create([
|
||||
'msnv' => 'DEV_ALPHA',
|
||||
'mail' => 'alpha@example.com',
|
||||
'pass' => bcrypt('password'),
|
||||
'role' => User::ROLE_MEMBER,
|
||||
'status' => User::STATUS_ACTIVE,
|
||||
'card' => 10,
|
||||
'flag_send' => User::FLAG_SEND_ENABLED,
|
||||
'first_login' => User::FIRST_LOGIN_FALSE,
|
||||
]);
|
||||
|
||||
User::create([
|
||||
'msnv' => 'DEV_BETA',
|
||||
'mail' => 'beta@example.com',
|
||||
'pass' => bcrypt('password'),
|
||||
'role' => User::ROLE_MEMBER,
|
||||
'status' => User::STATUS_ACTIVE,
|
||||
'card' => 10,
|
||||
'flag_send' => User::FLAG_SEND_ENABLED,
|
||||
'first_login' => User::FIRST_LOGIN_FALSE,
|
||||
]);
|
||||
|
||||
// 2. Fetch statistics via AdminService with search for 'ALPHA'
|
||||
$stats = $this->adminService->getUserListWithStats('2026-07', 'ALPHA');
|
||||
$users = $stats['users'];
|
||||
|
||||
$this->assertCount(1, $users->items());
|
||||
$this->assertEquals('DEV_ALPHA', $users->items()[0]->msnv);
|
||||
|
||||
// 3. Fetch statistics via AdminService with search for 'beta@example.com'
|
||||
$stats2 = $this->adminService->getUserListWithStats('2026-07', 'beta@example.com');
|
||||
$users2 = $stats2['users'];
|
||||
|
||||
$this->assertCount(1, $users2->items());
|
||||
$this->assertEquals('DEV_BETA', $users2->items()[0]->msnv);
|
||||
}
|
||||
|
||||
public function test_user_list_stats_filters_by_flag_send(): void
|
||||
{
|
||||
// 1. Create mock users
|
||||
User::create([
|
||||
'msnv' => 'DEV_ENABLED',
|
||||
'mail' => 'enabled@example.com',
|
||||
'pass' => bcrypt('password'),
|
||||
'role' => User::ROLE_MEMBER,
|
||||
'status' => User::STATUS_ACTIVE,
|
||||
'card' => 10,
|
||||
'flag_send' => User::FLAG_SEND_ENABLED,
|
||||
'first_login' => User::FIRST_LOGIN_FALSE,
|
||||
]);
|
||||
|
||||
User::create([
|
||||
'msnv' => 'DEV_DISABLED',
|
||||
'mail' => 'disabled@example.com',
|
||||
'pass' => bcrypt('password'),
|
||||
'role' => User::ROLE_MEMBER,
|
||||
'status' => User::STATUS_ACTIVE,
|
||||
'card' => 10,
|
||||
'flag_send' => User::FLAG_SEND_DISABLED,
|
||||
'first_login' => User::FIRST_LOGIN_FALSE,
|
||||
]);
|
||||
|
||||
// 2. Fetch statistics via AdminService with flag_send = '1'
|
||||
$stats = $this->adminService->getUserListWithStats('2026-07', null, '1');
|
||||
$users = collect($stats['users']->items());
|
||||
$this->assertTrue($users->contains('msnv', 'DEV_ENABLED'));
|
||||
$this->assertFalse($users->contains('msnv', 'DEV_DISABLED'));
|
||||
|
||||
// 3. Fetch statistics via AdminService with flag_send = '0'
|
||||
$stats2 = $this->adminService->getUserListWithStats('2026-07', null, '0');
|
||||
$users2 = collect($stats2['users']->items());
|
||||
$this->assertTrue($users2->contains('msnv', 'DEV_DISABLED'));
|
||||
$this->assertFalse($users2->contains('msnv', 'DEV_ENABLED'));
|
||||
}
|
||||
}
|
||||
+9
-1
@@ -6,5 +6,13 @@ use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
|
||||
|
||||
abstract class TestCase extends BaseTestCase
|
||||
{
|
||||
//
|
||||
protected function setUp(): void
|
||||
{
|
||||
$_ENV['DB_CONNECTION'] = 'sqlite';
|
||||
$_ENV['DB_DATABASE'] = ':memory:';
|
||||
$_SERVER['DB_CONNECTION'] = 'sqlite';
|
||||
$_SERVER['DB_DATABASE'] = ':memory:';
|
||||
|
||||
parent::setUp();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user