feature: change password

This commit is contained in:
antv
2026-07-08 13:03:48 +07:00
parent 63764d0718
commit 6056e33ef8
16 changed files with 371 additions and 56 deletions
+15 -2
View File
@@ -18,10 +18,13 @@ class AdminController extends Controller
{ {
$selectedMonth = $request->input('month', Carbon::now()->format('Y-m')); $selectedMonth = $request->input('month', Carbon::now()->format('Y-m'));
$search = $request->input('search'); $search = $request->input('search');
$status = $request->input('status', (string)User::STATUS_ACTIVE);
$role = $request->input('role');
$department = $request->input('department');
$flagSend = $request->input('flag_send'); $flagSend = $request->input('flag_send');
['users' => $users, 'topReceivedUser' => $topReceivedUser, 'topSentUser' => $topSentUser] ['users' => $users, 'topReceivedUser' => $topReceivedUser, 'topSentUser' => $topSentUser]
= $this->adminService->getUserListWithStats($selectedMonth, $search, $flagSend); = $this->adminService->getUserListWithStats($selectedMonth, $search, $status, $role, $department, $flagSend);
if ($request->ajax() || $request->wantsJson()) { if ($request->ajax() || $request->wantsJson()) {
return response()->json([ return response()->json([
@@ -31,7 +34,17 @@ class AdminController extends Controller
]); ]);
} }
return view('admin.users.index', compact('users', 'selectedMonth', 'topReceivedUser', 'topSentUser', 'search', 'flagSend')); return view('admin.users.index', compact(
'users',
'selectedMonth',
'topReceivedUser',
'topSentUser',
'search',
'status',
'role',
'department',
'flagSend'
));
} }
public function create() public function create()
@@ -75,6 +75,10 @@ class UserController extends Controller
{ {
$request->validate([ $request->validate([
'password' => 'required|min:6|confirmed', 'password' => 'required|min:6|confirmed',
], [
'password.required' => 'Mật khẩu mới không được để trống.',
'password.min' => 'Mật khẩu mới phải có ít nhất 6 ký tự.',
'password.confirmed' => 'Mật khẩu xác nhận không trùng khớp.',
]); ]);
$user = Auth::user(); $user = Auth::user();
+30 -12
View File
@@ -14,13 +14,18 @@ use Illuminate\Support\Facades\Hash;
class AdminService implements AdminServiceInterface class AdminService implements AdminServiceInterface
{ {
public function getUserListWithStats(string $selectedMonth, ?string $search = null, ?string $flagSend = null): array public function getUserListWithStats(
{ string $selectedMonth,
?string $search = null,
?string $status = '1',
?string $role = null,
?string $department = null,
?string $flagSend = null
): array {
$startOfMonth = Carbon::parse($selectedMonth)->startOfMonth(); $startOfMonth = Carbon::parse($selectedMonth)->startOfMonth();
$endOfMonth = Carbon::parse($selectedMonth)->endOfMonth(); $endOfMonth = Carbon::parse($selectedMonth)->endOfMonth();
$usersQuery = User::select('user.*') $usersQuery = User::select('user.*')
->where('status', User::STATUS_ACTIVE)
->addSelect([ ->addSelect([
'total_received' => Administration::selectRaw('COALESCE(SUM(received), 0)') 'total_received' => Administration::selectRaw('COALESCE(SUM(received), 0)')
->whereColumn('msnv', 'user.msnv') ->whereColumn('msnv', 'user.msnv')
@@ -30,22 +35,35 @@ class AdminService implements AdminServiceInterface
->whereBetween('date', [$startOfMonth, $endOfMonth]) ->whereBetween('date', [$startOfMonth, $endOfMonth])
]); ]);
$topReceivedUser = (clone $usersQuery)->orderByDesc('total_received')->first(); $topReceivedUser = (clone $usersQuery)->where('status', User::STATUS_ACTIVE)->orderByDesc('total_received')->first();
$topSentUser = (clone $usersQuery)->orderByDesc('total_sent')->first(); $topSentUser = (clone $usersQuery)->where('status', User::STATUS_ACTIVE)->orderByDesc('total_sent')->first();
if (!is_null($search) && trim($search) !== '') { if (!is_null($status) && $status !== '') {
$search = trim($search); $usersQuery->where('status', intval($status));
$usersQuery->where(function ($q) use ($search) { }
$q->whereRaw('LOWER(msnv) LIKE ?', ['%' . strtolower($search) . '%'])
->orWhereRaw('LOWER(mail) LIKE ?', ['%' . strtolower($search) . '%']); if (!is_null($role) && $role !== '') {
}); $usersQuery->where('role', intval($role));
}
if (!is_null($department) && $department !== '') {
$usersQuery->where('departments', intval($department));
} }
if (!is_null($flagSend) && $flagSend !== '') { if (!is_null($flagSend) && $flagSend !== '') {
$usersQuery->where('flag_send', intval($flagSend)); $usersQuery->where('flag_send', intval($flagSend));
} }
$users = $usersQuery->paginate(10)->withQueryString(); if (!is_null($search) && trim($search) !== '') {
$search = trim($search);
$usersQuery->where(function ($q) use ($search) {
$q->whereRaw('LOWER(msnv) LIKE ?', ['%' . strtolower($search) . '%'])
->orWhereRaw('LOWER(name) LIKE ?', ['%' . strtolower($search) . '%'])
->orWhereRaw('LOWER(mail) LIKE ?', ['%' . strtolower($search) . '%']);
});
}
$users = $usersQuery->paginate(20)->withQueryString();
return compact('users', 'topReceivedUser', 'topSentUser'); return compact('users', 'topReceivedUser', 'topSentUser');
} }
@@ -6,7 +6,14 @@ use Illuminate\Pagination\LengthAwarePaginator;
interface AdminServiceInterface interface AdminServiceInterface
{ {
public function getUserListWithStats(string $selectedMonth, ?string $search = null, ?string $flagSend = null): array; public function getUserListWithStats(
string $selectedMonth,
?string $search = null,
?string $status = '1',
?string $role = null,
?string $department = null,
?string $flagSend = null
): array;
public function getUserTransactions(string $msnv, string $selectedMonth): LengthAwarePaginator; public function getUserTransactions(string $msnv, string $selectedMonth): LengthAwarePaginator;
-3
View File
@@ -106,9 +106,6 @@
@apply min-h-screen flex bg-bg-main; @apply min-h-screen flex bg-bg-main;
} }
.sidebar {
@apply w-60 h-screen sticky top-0 bg-bg-sidebar border-r border-border-light flex flex-col justify-between shrink-0 shadow-sidebar transition-all duration-300 overflow-hidden;
}
.main-content { .main-content {
@apply flex-1 flex flex-col min-w-0; @apply flex-1 flex flex-col min-w-0;
+16 -9
View File
@@ -6,7 +6,7 @@ export class AjaxTable {
this.searchInputId = options.searchInputId || 'searchInput'; this.searchInputId = options.searchInputId || 'searchInput';
this.btnClearSearchId = options.btnClearSearchId || 'btnClearSearch'; this.btnClearSearchId = options.btnClearSearchId || 'btnClearSearch';
this.btnClearFiltersId = options.btnClearFiltersId || 'btnClearFilters'; this.btnClearFiltersId = options.btnClearFiltersId || 'btnClearFilters';
this.flagSendFilterId = options.flagSendFilterId || 'flagSendFilter'; this.statusFilterId = options.statusFilterId || 'statusFilter';
this.monthFilterId = options.monthFilterId || 'monthFilter'; this.monthFilterId = options.monthFilterId || 'monthFilter';
this.skeletonColumns = options.skeletonColumns || 7; this.skeletonColumns = options.skeletonColumns || 7;
@@ -22,7 +22,7 @@ export class AjaxTable {
this.searchInput = document.getElementById(this.searchInputId); this.searchInput = document.getElementById(this.searchInputId);
this.btnClearSearch = document.getElementById(this.btnClearSearchId); this.btnClearSearch = document.getElementById(this.btnClearSearchId);
this.btnClearFilters = document.getElementById(this.btnClearFiltersId); this.btnClearFilters = document.getElementById(this.btnClearFiltersId);
this.flagSendFilter = document.getElementById(this.flagSendFilterId); this.statusFilter = document.getElementById(this.statusFilterId);
this.monthFilter = document.getElementById(this.monthFilterId); this.monthFilter = document.getElementById(this.monthFilterId);
if (this.form) { if (this.form) {
@@ -35,10 +35,6 @@ export class AjaxTable {
if (this.searchInput) { if (this.searchInput) {
this.searchInput.addEventListener('input', () => { this.searchInput.addEventListener('input', () => {
this.toggleClearSearchBtn(); this.toggleClearSearchBtn();
clearTimeout(this.searchDebounceTimer);
this.searchDebounceTimer = setTimeout(() => {
this.submitForm();
}, 500);
}); });
} }
@@ -52,16 +48,26 @@ export class AjaxTable {
}); });
} }
if (this.flagSendFilter) { if (this.form) {
this.flagSendFilter.addEventListener('change', () => { this.form.querySelectorAll('select').forEach(select => {
select.addEventListener('change', () => {
this.submitForm(); this.submitForm();
}); });
});
} }
if (this.btnClearFilters) { if (this.btnClearFilters) {
this.btnClearFilters.addEventListener('click', () => { this.btnClearFilters.addEventListener('click', () => {
if (this.searchInput) this.searchInput.value = ''; if (this.searchInput) this.searchInput.value = '';
if (this.flagSendFilter) this.flagSendFilter.value = ''; if (this.statusFilter) this.statusFilter.value = '1';
const roleFilter = document.getElementById('roleFilter');
if (roleFilter) roleFilter.value = '';
const departmentFilter = document.getElementById('departmentFilter');
if (departmentFilter) departmentFilter.value = '';
const flagSendFilter = document.getElementById('flagSendFilter');
if (flagSendFilter) flagSendFilter.value = '';
if (this.monthFilter) { if (this.monthFilter) {
this.monthFilter.value = new Date().toISOString().slice(0, 7); this.monthFilter.value = new Date().toISOString().slice(0, 7);
} }
@@ -118,6 +124,7 @@ export class AjaxTable {
<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-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-6 bg-gray-200 rounded-full 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"><div class="h-8 bg-gray-200 rounded w-20 ml-auto"></div></td> <td class="px-6 py-4"><div class="h-8 bg-gray-200 rounded w-20 ml-auto"></div></td>
</tr>`; </tr>`;
} }
+10 -2
View File
@@ -2,7 +2,7 @@
@section('title', 'Quản lý Users') @section('title', 'Quản lý Users')
@section('content') @section('content')
<div class="mb-6 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 pl-1"> <div class="mb-6 flex flex-col sm:flex-row flex-wrap sm:items-center sm:justify-between gap-4 pl-1">
<div> <div>
<h1 class="text-[28px] font-extrabold text-[#1a2b49] flex items-center gap-2 mb-1.5 tracking-tight"> <h1 class="text-[28px] font-extrabold text-[#1a2b49] flex items-center gap-2 mb-1.5 tracking-tight">
<span id="pageTitle" class="inline-flex items-center gap-2"> <span id="pageTitle" class="inline-flex items-center gap-2">
@@ -29,7 +29,15 @@
@include('admin.users.partials.stats') @include('admin.users.partials.stats')
</div> </div>
<x-user-filter-form :action="route('admin.users.index')" :search="$search" :flag-send="$flagSend" :selected-month="$selectedMonth" /> <x-user-filter-form
:action="route('admin.users.index')"
:search="$search"
:selected-month="$selectedMonth"
:status="$status"
:role="$role ?? ''"
:department="$department ?? ''"
:flag-send="$flagSend ?? ''"
/>
<div id="userTableContainer" class="bg-white rounded-[24px] shadow-[0_10px_30px_rgba(15,23,42,0.05)] border border-white overflow-hidden mt-4"> <div id="userTableContainer" class="bg-white rounded-[24px] shadow-[0_10px_30px_rgba(15,23,42,0.05)] border border-white overflow-hidden mt-4">
@include('admin.users.partials.table') @include('admin.users.partials.table')
@@ -1,7 +1,7 @@
<div class="px-6 py-4 border-b border-gray-100 flex justify-between items-center bg-white"> <div class="px-6 py-4 border-b border-gray-100 flex justify-between items-center bg-white">
<span class="text-sm font-semibold text-gray-500" id="paginationStats"> <span class="text-sm font-semibold text-gray-500" id="paginationStats">
@if($users->total() > 0) @if($users->total() > 0)
Hiển thị {{ $users->firstItem() }}-{{ $users->lastItem() }} trên {{ $users->total() }} nhân viên Hiển thị {{ $users->firstItem() }}-{{ $users->lastItem() }} trên tổng {{ $users->total() }} nhân viên
@else @else
Hiển thị 0 nhân viên Hiển thị 0 nhân viên
@endif @endif
@@ -17,6 +17,7 @@
<th class="table-header-cell">Đã Gửi</th> <th class="table-header-cell">Đã Gửi</th>
<th class="table-header-cell">Số thẻ</th> <th class="table-header-cell">Số thẻ</th>
<th class="table-header-cell">Quyền gửi</th> <th class="table-header-cell">Quyền gửi</th>
<th class="table-header-cell">Trạng thái</th>
<th class="table-header-cell text-right">Thao tác</th> <th class="table-header-cell text-right">Thao tác</th>
</tr> </tr>
</thead> </thead>
@@ -40,14 +41,20 @@
<span class="px-2.5 py-1 inline-flex text-[10px] leading-5 font-bold rounded-full bg-gray-50 text-gray-500 border border-gray-100">Không</span> <span class="px-2.5 py-1 inline-flex text-[10px] leading-5 font-bold rounded-full bg-gray-50 text-gray-500 border border-gray-100">Không</span>
@endif @endif
</td> </td>
<td class="table-body-cell">
@if($u->status == \App\Models\User::STATUS_ACTIVE)
<span class="px-2.5 py-1 inline-flex text-[10px] leading-5 font-bold rounded-full bg-blue-50 text-blue-700 border border-blue-100">Đang làm việc</span>
@else
<span class="px-2.5 py-1 inline-flex text-[10px] leading-5 font-bold rounded-full bg-red-50 text-red-700 border border-red-100">Đã nghỉ việc</span>
@endif
</td>
<td class="table-body-cell text-right font-medium space-x-3"> <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-[#3462f7] hover:!text-blue-800">Quản </a> <a href="{{ route('admin.users.edit', $u->msnv) }}" class="table-action-link !text-[#3462f7] hover:!text-blue-800">Quản </a>
<button type="button" onclick="openDeleteModal('{{ $u->msnv }}', '{{ route('admin.users.destroy', $u->msnv) }}')" class="table-action-link !text-red-500 hover:!text-red-700">Nghỉ việc</button>
</td> </td>
</tr> </tr>
@empty @empty
<tr> <tr>
<td colspan="7" class="px-6 py-10 text-center text-text-light font-medium">Chưa user nào đang hoạt động.</td> <td colspan="8" class="px-6 py-10 text-center text-text-light font-medium">Chưa user nào đang hoạt động.</td>
</tr> </tr>
@endforelse @endforelse
</tbody> </tbody>
+1 -1
View File
@@ -1,6 +1,6 @@
@props(['active' => false]) @props(['active' => false])
<aside class="sidebar flex flex-col h-full bg-white border-r border-gray-100 shadow-sm transition-transform duration-300 w-64 fixed inset-y-0 left-0 z-40 lg:relative lg:translate-x-0 -translate-x-full" aria-label="Main navigation"> <aside class="sidebar flex flex-col justify-between h-screen w-60 bg-white border-r border-gray-100 shadow-sm transition-all duration-300 shrink-0 overflow-hidden fixed inset-y-0 left-0 z-40 -translate-x-full lg:sticky lg:top-0 lg:translate-x-0" aria-label="Main navigation">
<div class="p-6 flex items-center justify-between shrink-0"> <div class="p-6 flex items-center justify-between shrink-0">
<img src="{{ asset('images/logo.png') }}" alt="GMO-Z.com RUNSYSTEM" class="h-6 w-auto object-contain"> <img src="{{ asset('images/logo.png') }}" alt="GMO-Z.com RUNSYSTEM" class="h-6 w-auto object-contain">
<!-- Mobile hamburger button/close --> <!-- Mobile hamburger button/close -->
@@ -1,7 +1,15 @@
@props(['action', 'search' => '', 'flagSend' => '', 'selectedMonth' => '']) @props([
'action',
'search' => '',
'selectedMonth' => '',
'status' => '1',
'role' => '',
'department' => '',
'flagSend' => ''
])
<form id="filterForm" action="{{ $action }}" method="GET" class="flex flex-col md:flex-row items-stretch md:items-center gap-3.5 w-full mb-6 bg-white p-5 rounded-[24px] border border-white shadow-[0_10px_30px_rgba(15,23,42,0.05)]"> <form id="filterForm" action="{{ $action }}" method="GET" class="flex flex-col md:flex-row md:flex-wrap items-stretch md:items-center gap-3.5 w-full mb-6 bg-white p-5 rounded-[24px] border border-white shadow-[0_10px_30px_rgba(15,23,42,0.05)]">
<div class="relative flex items-center flex-1 min-w-[200px]"> <div class="relative flex items-center w-full lg:flex-1 lg: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-[#d9dfe7] rounded-xl bg-white"> <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-[#d9dfe7] rounded-xl bg-white">
<svg class="w-4 h-4 text-text-light absolute left-3.5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" style="pointer-events: none;"> <svg class="w-4 h-4 text-text-light absolute left-3.5" 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" /> <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" />
@@ -13,15 +21,40 @@
</button> </button>
</div> </div>
<select name="flag_send" id="flagSendFilter" class="form-input !py-1.5 !text-sm w-full md:w-auto border-[#d9dfe7] rounded-xl bg-white min-w-[180px]"> <!-- Status Filter -->
<option value="" {{ is_null($flagSend) || $flagSend === '' ? 'selected' : '' }}>Quyền gửi: Tất cả</option> <select name="status" id="statusFilter" class="form-input !py-1.5 !text-sm !w-full md:!w-[150px] border-[#d9dfe7] rounded-xl bg-white shrink-0">
<option value="" {{ $status === '' ? 'selected' : '' }}>Tất cả trạng thái</option>
<option value="1" {{ $status === '1' ? 'selected' : '' }}>Hoạt động</option>
<option value="0" {{ $status === '0' ? 'selected' : '' }}>Nghỉ việc</option>
</select>
<!-- Role Filter -->
<select name="role" id="roleFilter" class="form-input !py-1.5 !text-sm !w-full md:!w-[130px] border-[#d9dfe7] rounded-xl bg-white shrink-0">
<option value="" {{ $role === '' ? 'selected' : '' }}>Tất cả vai trò</option>
<option value="1" {{ $role === '1' ? 'selected' : '' }}>Admin</option>
<option value="0" {{ $role === '0' ? 'selected' : '' }}>Member</option>
</select>
<!-- Department Filter -->
<select name="department" id="departmentFilter" class="form-input !py-1.5 !text-sm !w-full md:!w-[170px] border-[#d9dfe7] rounded-xl bg-white shrink-0">
<option value="" {{ $department === '' ? 'selected' : '' }}>Tất cả phòng ban</option>
<option value="1" {{ $department === '1' ? 'selected' : '' }}>Phòng Phát Triển (Dev)</option>
<option value="2" {{ $department === '2' ? 'selected' : '' }}>Phòng Nhân Sự (HR)</option>
<option value="3" {{ $department === '3' ? 'selected' : '' }}>Phòng Kinh Doanh (Sale)</option>
<option value="4" {{ $department === '4' ? 'selected' : '' }}>Ban Giám Đốc (Board)</option>
<option value="5" {{ $department === '5' ? 'selected' : '' }}>Phòng Marketing</option>
</select>
<!-- Flag Send Filter -->
<select name="flag_send" id="flagSendFilter" class="form-input !py-1.5 !text-sm !w-full md:!w-[150px] border-[#d9dfe7] rounded-xl bg-white shrink-0">
<option value="" {{ $flagSend === '' ? 'selected' : '' }}>Tất cả quyền gửi</option>
<option value="1" {{ $flagSend === '1' ? 'selected' : '' }}>Được gửi</option> <option value="1" {{ $flagSend === '1' ? 'selected' : '' }}>Được gửi</option>
<option value="0" {{ $flagSend === '0' ? 'selected' : '' }}>Không được gửi</option> <option value="0" {{ $flagSend === '0' ? 'selected' : '' }}>Không được gửi</option>
</select> </select>
<input type="month" name="month" id="monthFilter" value="{{ $selectedMonth }}" class="form-input !py-1.5 !text-sm w-full md:w-auto border-[#d9dfe7] rounded-xl bg-white"> <input type="month" name="month" id="monthFilter" value="{{ $selectedMonth }}" class="form-input !py-1.5 !text-sm !w-full md:!w-[140px] border-[#d9dfe7] rounded-xl bg-white shrink-0">
<div class="flex items-center gap-2 w-full md:w-auto shrink-0"> <div class="flex items-center gap-2 w-full md:w-auto md:ml-auto shrink-0">
<button type="submit" id="btnFilter" class="btn-primary !py-2.5 !px-5 text-sm rounded-xl font-bold shadow-md shadow-blue-500/10 !bg-[#3462f7] hover:!bg-blue-700 flex items-center justify-center min-w-[80px] flex-1 md:flex-none">Tìm</button> <button type="submit" id="btnFilter" class="btn-primary !py-2.5 !px-5 text-sm rounded-xl font-bold shadow-md shadow-blue-500/10 !bg-[#3462f7] hover:!bg-blue-700 flex items-center justify-center min-w-[80px] flex-1 md:flex-none">Tìm</button>
<button type="button" id="btnClearFilters" class="btn-secondary !py-2.5 !px-5 text-sm rounded-xl font-bold border border-[#d9dfe7] hover:bg-gray-100 flex items-center justify-center min-w-[100px] flex-1 md:flex-none">Xóa bộ lọc</button> <button type="button" id="btnClearFilters" class="btn-secondary !py-2.5 !px-5 text-sm rounded-xl font-bold border border-[#d9dfe7] hover:bg-gray-100 flex items-center justify-center min-w-[100px] flex-1 md:flex-none">Xóa bộ lọc</button>
</div> </div>
@@ -33,7 +33,7 @@
<path stroke-linecap="round" stroke-linejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 10-9 0V10.5m-2.812 10.5h14.625c.621 0 1.125-.504 1.125-1.125V11.25c0-.621-.504-1.125-1.125-1.125H3.75c-.621 0-1.125.504-1.125 1.125v7.875c0 .621.504 1.125 1.125 1.125z" /> <path stroke-linecap="round" stroke-linejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 10-9 0V10.5m-2.812 10.5h14.625c.621 0 1.125-.504 1.125-1.125V11.25c0-.621-.504-1.125-1.125-1.125H3.75c-.621 0-1.125.504-1.125 1.125v7.875c0 .621.504 1.125 1.125 1.125z" />
</svg> </svg>
</span> </span>
<input type="password" name="password" id="password" class="w-full h-[46px] pl-[42px] pr-12 bg-white border border-gray-200 rounded-lg text-sm text-gray-800 focus:border-[#2563eb] focus:ring-1 focus:ring-[#2563eb] outline-none transition-all shadow-sm placeholder:text-gray-400" required placeholder="Nhập mật khẩu mới"> <input type="password" name="password" id="password" class="w-full h-[46px] pl-[42px] pr-12 bg-white border @error('password') border-red-500 focus:border-red-500 focus:ring-red-500/20 @else border-gray-200 focus:border-[#2563eb] focus:ring-[#2563eb]/20 @enderror rounded-lg text-sm text-gray-800 outline-none transition-all shadow-sm placeholder:text-gray-400" required placeholder="Nhập mật khẩu mới">
<button type="button" onclick="togglePasswordVisibility('password', this)" class="absolute right-3.5 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 focus:outline-none cursor-pointer" aria-label="Show password"> <button type="button" onclick="togglePasswordVisibility('password', this)" class="absolute right-3.5 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 focus:outline-none cursor-pointer" aria-label="Show password">
<!-- Eye Closed (Default) --> <!-- Eye Closed (Default) -->
<svg class="w-5 h-5 eye-icon-closed" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M3.98 8.223A10.477 10.477 0 001.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.45 10.45 0 0112 4.5c4.756 0 8.773 3.162 10.065 7.498a10.523 10.523 0 01-4.293 5.774M6.228 6.228L3 3m3.228 3.228l3.65 3.65m7.894 7.894L21 21m-3.228-3.228l-3.65-3.65m0 0a3 3 0 10-4.243-4.243m4.242 4.242L9.88 9.88" /></svg> <svg class="w-5 h-5 eye-icon-closed" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M3.98 8.223A10.477 10.477 0 001.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.45 10.45 0 0112 4.5c4.756 0 8.773 3.162 10.065 7.498a10.523 10.523 0 01-4.293 5.774M6.228 6.228L3 3m3.228 3.228l3.65 3.65m7.894 7.894L21 21m-3.228-3.228l-3.65-3.65m0 0a3 3 0 10-4.243-4.243m4.242 4.242L9.88 9.88" /></svg>
@@ -52,7 +52,7 @@
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /> <path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg> </svg>
</span> </span>
<input type="password" name="password_confirmation" id="password_confirmation" class="w-full h-[46px] pl-[42px] pr-12 bg-white border border-gray-200 rounded-lg text-sm text-gray-800 focus:border-[#2563eb] focus:ring-1 focus:ring-[#2563eb] outline-none transition-all shadow-sm placeholder:text-gray-400" required placeholder="Nhập lại mật khẩu mới"> <input type="password" name="password_confirmation" id="password_confirmation" class="w-full h-[46px] pl-[42px] pr-12 bg-white border @error('password') border-red-500 focus:border-red-500 focus:ring-red-500/20 @else border-gray-200 focus:border-[#2563eb] focus:ring-[#2563eb]/20 @enderror rounded-lg text-sm text-gray-800 outline-none transition-all shadow-sm placeholder:text-gray-400" required placeholder="Nhập lại mật khẩu mới">
<button type="button" onclick="togglePasswordVisibility('password_confirmation', this)" class="absolute right-3.5 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 focus:outline-none cursor-pointer" aria-label="Show password"> <button type="button" onclick="togglePasswordVisibility('password_confirmation', this)" class="absolute right-3.5 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 focus:outline-none cursor-pointer" aria-label="Show password">
<!-- Eye Closed (Default) --> <!-- Eye Closed (Default) -->
<svg class="w-5 h-5 eye-icon-closed" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M3.98 8.223A10.477 10.477 0 001.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.45 10.45 0 0112 4.5c4.756 0 8.773 3.162 10.065 7.498a10.523 10.523 0 01-4.293 5.774M6.228 6.228L3 3m3.228 3.228l3.65 3.65m7.894 7.894L21 21m-3.228-3.228l-3.65-3.65m0 0a3 3 0 10-4.243-4.243m4.242 4.242L9.88 9.88" /></svg> <svg class="w-5 h-5 eye-icon-closed" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M3.98 8.223A10.477 10.477 0 001.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.45 10.45 0 0112 4.5c4.756 0 8.773 3.162 10.065 7.498a10.523 10.523 0 01-4.293 5.774M6.228 6.228L3 3m3.228 3.228l3.65 3.65m7.894 7.894L21 21m-3.228-3.228l-3.65-3.65m0 0a3 3 0 10-4.243-4.243m4.242 4.242L9.88 9.88" /></svg>
+9
View File
@@ -54,6 +54,15 @@
</p> </p>
</div> </div>
</div> </div>
<div class="flex flex-col gap-2 pt-2">
<div class="flex items-center gap-3 bg-gray-50/50 p-4 rounded-xl border border-[#d9dfe7]/60 hover:bg-gray-50 transition-colors">
<input type="checkbox" name="confirm_written" id="confirm_written" value="1" class="w-5 h-5 text-[#3462f7] border-[#d9dfe7] rounded-md focus:ring-[#3462f7]/20 transition-all cursor-pointer" required>
<label for="confirm_written" class="text-[13px] font-medium text-gray-700 cursor-pointer select-none">
Xác nhận đã viết card <span class="text-red-500">*</span>
</label>
</div>
</div>
</div> </div>
<div class="px-8 py-5 border-t border-[#d9dfe7]/50 bg-gray-50/50 flex flex-col sm:flex-row justify-end gap-3"> <div class="px-8 py-5 border-t border-[#d9dfe7]/50 bg-gray-50/50 flex flex-col sm:flex-row justify-end gap-3">
+61 -13
View File
@@ -202,13 +202,13 @@ class AdminUserListStatsTest extends TestCase
$this->assertEquals(2002, $users2->items()[0]->msnv); $this->assertEquals(2002, $users2->items()[0]->msnv);
} }
public function test_user_list_stats_filters_by_flag_send(): void public function test_user_list_stats_filters_by_status(): void
{ {
// 1. Create mock users // 1. Create mock users
User::create([ User::create([
'msnv' => 3001, 'msnv' => 3001,
'name' => 'DEV ENABLED', 'name' => 'DEV ACTIVE',
'mail' => 'enabled@example.com', 'mail' => 'active@example.com',
'pass' => md5('password'), 'pass' => md5('password'),
'departments' => 1, 'departments' => 1,
'role' => User::ROLE_MEMBER, 'role' => User::ROLE_MEMBER,
@@ -220,27 +220,75 @@ class AdminUserListStatsTest extends TestCase
User::create([ User::create([
'msnv' => 3002, 'msnv' => 3002,
'name' => 'DEV DISABLED', 'name' => 'DEV INACTIVE',
'mail' => 'disabled@example.com', 'mail' => 'inactive@example.com',
'pass' => md5('password'), 'pass' => md5('password'),
'departments' => 1, 'departments' => 1,
'role' => User::ROLE_MEMBER, 'role' => User::ROLE_MEMBER,
'status' => User::STATUS_INACTIVE,
'card' => 10,
'flag_send' => User::FLAG_SEND_DISABLED,
'first_login' => User::FIRST_LOGIN_FALSE,
]);
// 2. Fetch statistics via AdminService with status = '1'
$stats = $this->adminService->getUserListWithStats('2026-07', null, '1');
$users = collect($stats['users']->items());
$this->assertTrue($users->contains('msnv', 3001));
$this->assertFalse($users->contains('msnv', 3002));
// 3. Fetch statistics via AdminService with status = '0'
$stats2 = $this->adminService->getUserListWithStats('2026-07', null, '0');
$users2 = collect($stats2['users']->items());
$this->assertTrue($users2->contains('msnv', 3002));
$this->assertFalse($users2->contains('msnv', 3001));
}
public function test_user_list_stats_filters_by_role_department_and_flag_send(): void
{
// 1. Create mock users
User::create([
'msnv' => 4001,
'name' => 'ADMIN DEV ENABLED',
'mail' => 'admin_dev@example.com',
'pass' => md5('password'),
'departments' => 1,
'role' => User::ROLE_ADMIN,
'status' => User::STATUS_ACTIVE,
'card' => 10,
'flag_send' => User::FLAG_SEND_ENABLED,
'first_login' => User::FIRST_LOGIN_FALSE,
]);
User::create([
'msnv' => 4002,
'name' => 'MEMBER HR DISABLED',
'mail' => 'member_hr@example.com',
'pass' => md5('password'),
'departments' => 2,
'role' => User::ROLE_MEMBER,
'status' => User::STATUS_ACTIVE, 'status' => User::STATUS_ACTIVE,
'card' => 10, 'card' => 10,
'flag_send' => User::FLAG_SEND_DISABLED, 'flag_send' => User::FLAG_SEND_DISABLED,
'first_login' => User::FIRST_LOGIN_FALSE, 'first_login' => User::FIRST_LOGIN_FALSE,
]); ]);
// 2. Fetch statistics via AdminService with flag_send = '1' // Filter by role = Admin ('1')
$stats = $this->adminService->getUserListWithStats('2026-07', null, '1'); $stats = $this->adminService->getUserListWithStats('2026-07', null, '1', '1');
$users = collect($stats['users']->items()); $users = collect($stats['users']->items());
$this->assertTrue($users->contains('msnv', 3001)); $this->assertTrue($users->contains('msnv', 4001));
$this->assertFalse($users->contains('msnv', 3002)); $this->assertFalse($users->contains('msnv', 4002));
// 3. Fetch statistics via AdminService with flag_send = '0' // Filter by department = HR ('2')
$stats2 = $this->adminService->getUserListWithStats('2026-07', null, '0'); $stats2 = $this->adminService->getUserListWithStats('2026-07', null, '1', null, '2');
$users2 = collect($stats2['users']->items()); $users2 = collect($stats2['users']->items());
$this->assertTrue($users2->contains('msnv', 3002)); $this->assertTrue($users2->contains('msnv', 4002));
$this->assertFalse($users2->contains('msnv', 3001)); $this->assertFalse($users2->contains('msnv', 4001));
// Filter by flag_send = Enabled ('1')
$stats3 = $this->adminService->getUserListWithStats('2026-07', null, '1', null, null, '1');
$users3 = collect($stats3['users']->items());
$this->assertTrue($users3->contains('msnv', 4001));
$this->assertFalse($users3->contains('msnv', 4002));
} }
} }
+71
View File
@@ -0,0 +1,71 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class PasswordValidationTest extends TestCase
{
use RefreshDatabase;
private User $user;
protected function setUp(): void
{
parent::setUp();
$this->user = User::create([
'msnv' => 5001,
'name' => 'Test User',
'mail' => 'test@example.com',
'pass' => md5('password123'),
'departments' => 1,
'role' => User::ROLE_MEMBER,
'status' => User::STATUS_ACTIVE,
'card' => 10,
'flag_send' => User::FLAG_SEND_ENABLED,
'first_login' => User::FIRST_LOGIN_TRUE,
]);
}
public function test_password_change_requires_fields(): void
{
$res = $this->actingAs($this->user)
->post(route('user.update_password'), [
'password' => '',
'password_confirmation' => '',
]);
$res->assertSessionHasErrors([
'password' => 'Mật khẩu mới không được để trống.',
]);
}
public function test_password_change_requires_minimum_length(): void
{
$res = $this->actingAs($this->user)
->post(route('user.update_password'), [
'password' => '123',
'password_confirmation' => '123',
]);
$res->assertSessionHasErrors([
'password' => 'Mật khẩu mới phải có ít nhất 6 ký tự.',
]);
}
public function test_password_change_requires_confirmation_match(): void
{
$res = $this->actingAs($this->user)
->post(route('user.update_password'), [
'password' => 'newpassword123',
'password_confirmation' => 'differentpassword',
]);
$res->assertSessionHasErrors([
'password' => 'Mật khẩu xác nhận không trùng khớp.',
]);
}
}
+92
View File
@@ -0,0 +1,92 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use App\Models\Administration;
use App\Services\User\Contracts\UserServiceInterface;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class SendThankCardTest extends TestCase
{
use RefreshDatabase;
private UserServiceInterface $userService;
private User $sender;
private User $receiver;
protected function setUp(): void
{
parent::setUp();
$this->userService = $this->app->make(UserServiceInterface::class);
// Create sender and receiver
$this->sender = User::create([
'msnv' => 5001,
'name' => 'Sender User',
'mail' => 'sender@example.com',
'pass' => md5('password'),
'departments' => 1,
'role' => User::ROLE_MEMBER,
'status' => User::STATUS_ACTIVE,
'card' => 10,
'flag_send' => User::FLAG_SEND_ENABLED,
'first_login' => User::FIRST_LOGIN_FALSE,
]);
$this->receiver = User::create([
'msnv' => 5002,
'name' => 'Receiver User',
'mail' => 'receiver@example.com',
'pass' => md5('password'),
'departments' => 1,
'role' => User::ROLE_MEMBER,
'status' => User::STATUS_ACTIVE,
'card' => 0,
'flag_send' => User::FLAG_SEND_ENABLED,
'first_login' => User::FIRST_LOGIN_FALSE,
]);
}
public function test_send_thankcards_reduces_card_balance_and_creates_records(): void
{
$this->userService->sendThankcards($this->sender, '5002', 3);
$this->sender->refresh();
$this->assertEquals(7, $this->sender->card);
// Check administration records
$this->assertDatabaseHas('administration', [
'msnv' => 5002,
'received' => 3,
'sender' => 5001,
'sent' => 0,
'receiver' => null,
]);
$this->assertDatabaseHas('administration', [
'msnv' => 5001,
'received' => 0,
'sender' => null,
'sent' => 3,
'receiver' => 5002,
]);
}
public function test_controller_validates_and_stores_thankcard(): void
{
$this->actingAs($this->sender);
$response = $this->postJson(route('user.store_send'), [
'receiver' => '5002',
'amount' => 2,
]);
$response->assertStatus(200);
$response->assertJson(['success' => true]);
$this->sender->refresh();
$this->assertEquals(8, $this->sender->card);
}
}
+1
View File
@@ -3,6 +3,7 @@ import laravel from 'laravel-vite-plugin';
import tailwindcss from '@tailwindcss/vite'; import tailwindcss from '@tailwindcss/vite';
export default defineConfig({ export default defineConfig({
cacheDir: '/tmp/vite-cache',
plugins: [ plugins: [
laravel({ laravel({
input: ['resources/css/app.css', 'resources/js/app.js'], input: ['resources/css/app.css', 'resources/js/app.js'],