feature: change password
This commit is contained in:
@@ -18,10 +18,13 @@ class AdminController extends Controller
|
||||
{
|
||||
$selectedMonth = $request->input('month', Carbon::now()->format('Y-m'));
|
||||
$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');
|
||||
|
||||
['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()) {
|
||||
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()
|
||||
|
||||
@@ -75,6 +75,10 @@ class UserController extends Controller
|
||||
{
|
||||
$request->validate([
|
||||
'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();
|
||||
|
||||
@@ -14,13 +14,18 @@ use Illuminate\Support\Facades\Hash;
|
||||
|
||||
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();
|
||||
$endOfMonth = Carbon::parse($selectedMonth)->endOfMonth();
|
||||
|
||||
$usersQuery = User::select('user.*')
|
||||
->where('status', User::STATUS_ACTIVE)
|
||||
->addSelect([
|
||||
'total_received' => Administration::selectRaw('COALESCE(SUM(received), 0)')
|
||||
->whereColumn('msnv', 'user.msnv')
|
||||
@@ -30,22 +35,35 @@ class AdminService implements AdminServiceInterface
|
||||
->whereBetween('date', [$startOfMonth, $endOfMonth])
|
||||
]);
|
||||
|
||||
$topReceivedUser = (clone $usersQuery)->orderByDesc('total_received')->first();
|
||||
$topSentUser = (clone $usersQuery)->orderByDesc('total_sent')->first();
|
||||
$topReceivedUser = (clone $usersQuery)->where('status', User::STATUS_ACTIVE)->orderByDesc('total_received')->first();
|
||||
$topSentUser = (clone $usersQuery)->where('status', User::STATUS_ACTIVE)->orderByDesc('total_sent')->first();
|
||||
|
||||
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) . '%']);
|
||||
});
|
||||
if (!is_null($status) && $status !== '') {
|
||||
$usersQuery->where('status', intval($status));
|
||||
}
|
||||
|
||||
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 !== '') {
|
||||
$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');
|
||||
}
|
||||
|
||||
@@ -6,7 +6,14 @@ use Illuminate\Pagination\LengthAwarePaginator;
|
||||
|
||||
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;
|
||||
|
||||
|
||||
@@ -106,9 +106,6 @@
|
||||
@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 {
|
||||
@apply flex-1 flex flex-col min-w-0;
|
||||
|
||||
+17
-10
@@ -6,7 +6,7 @@ export class AjaxTable {
|
||||
this.searchInputId = options.searchInputId || 'searchInput';
|
||||
this.btnClearSearchId = options.btnClearSearchId || 'btnClearSearch';
|
||||
this.btnClearFiltersId = options.btnClearFiltersId || 'btnClearFilters';
|
||||
this.flagSendFilterId = options.flagSendFilterId || 'flagSendFilter';
|
||||
this.statusFilterId = options.statusFilterId || 'statusFilter';
|
||||
this.monthFilterId = options.monthFilterId || 'monthFilter';
|
||||
this.skeletonColumns = options.skeletonColumns || 7;
|
||||
|
||||
@@ -22,7 +22,7 @@ export class AjaxTable {
|
||||
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.statusFilter = document.getElementById(this.statusFilterId);
|
||||
this.monthFilter = document.getElementById(this.monthFilterId);
|
||||
|
||||
if (this.form) {
|
||||
@@ -35,10 +35,6 @@ export class AjaxTable {
|
||||
if (this.searchInput) {
|
||||
this.searchInput.addEventListener('input', () => {
|
||||
this.toggleClearSearchBtn();
|
||||
clearTimeout(this.searchDebounceTimer);
|
||||
this.searchDebounceTimer = setTimeout(() => {
|
||||
this.submitForm();
|
||||
}, 500);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -52,16 +48,26 @@ export class AjaxTable {
|
||||
});
|
||||
}
|
||||
|
||||
if (this.flagSendFilter) {
|
||||
this.flagSendFilter.addEventListener('change', () => {
|
||||
this.submitForm();
|
||||
if (this.form) {
|
||||
this.form.querySelectorAll('select').forEach(select => {
|
||||
select.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.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) {
|
||||
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-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-8 bg-gray-200 rounded w-20 ml-auto"></div></td>
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
@section('title', 'Quản lý Users')
|
||||
|
||||
@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>
|
||||
<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">
|
||||
@@ -29,7 +29,15 @@
|
||||
@include('admin.users.partials.stats')
|
||||
</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">
|
||||
@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">
|
||||
<span class="text-sm font-semibold text-gray-500" id="paginationStats">
|
||||
@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
|
||||
Hiển thị 0 nhân viên
|
||||
@endif
|
||||
@@ -17,6 +17,7 @@
|
||||
<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">Trạng thái</th>
|
||||
<th class="table-header-cell text-right">Thao tác</th>
|
||||
</tr>
|
||||
</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>
|
||||
@endif
|
||||
</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">
|
||||
<a href="{{ route('admin.users.edit', $u->msnv) }}" class="table-action-link !text-[#3462f7] hover:!text-blue-800">Quản lý</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>
|
||||
</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>
|
||||
<td colspan="8" 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>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
@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">
|
||||
<img src="{{ asset('images/logo.png') }}" alt="GMO-Z.com RUNSYSTEM" class="h-6 w-auto object-contain">
|
||||
<!-- 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)]">
|
||||
<div class="relative flex items-center flex-1 min-w-[200px]">
|
||||
<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 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">
|
||||
<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" />
|
||||
@@ -13,15 +21,40 @@
|
||||
</button>
|
||||
</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]">
|
||||
<option value="" {{ is_null($flagSend) || $flagSend === '' ? 'selected' : '' }}>Quyền gửi: Tất cả</option>
|
||||
<!-- Status Filter -->
|
||||
<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="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-[#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="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>
|
||||
|
||||
@@ -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" />
|
||||
</svg>
|
||||
</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">
|
||||
<!-- 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>
|
||||
@@ -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" />
|
||||
</svg>
|
||||
</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">
|
||||
<!-- 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>
|
||||
|
||||
@@ -54,6 +54,15 @@
|
||||
</p>
|
||||
</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 class="px-8 py-5 border-t border-[#d9dfe7]/50 bg-gray-50/50 flex flex-col sm:flex-row justify-end gap-3">
|
||||
|
||||
@@ -202,13 +202,13 @@ class AdminUserListStatsTest extends TestCase
|
||||
$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
|
||||
User::create([
|
||||
'msnv' => 3001,
|
||||
'name' => 'DEV ENABLED',
|
||||
'mail' => 'enabled@example.com',
|
||||
'name' => 'DEV ACTIVE',
|
||||
'mail' => 'active@example.com',
|
||||
'pass' => md5('password'),
|
||||
'departments' => 1,
|
||||
'role' => User::ROLE_MEMBER,
|
||||
@@ -220,27 +220,75 @@ class AdminUserListStatsTest extends TestCase
|
||||
|
||||
User::create([
|
||||
'msnv' => 3002,
|
||||
'name' => 'DEV DISABLED',
|
||||
'mail' => 'disabled@example.com',
|
||||
'name' => 'DEV INACTIVE',
|
||||
'mail' => 'inactive@example.com',
|
||||
'pass' => md5('password'),
|
||||
'departments' => 1,
|
||||
'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,
|
||||
'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');
|
||||
// Filter by role = Admin ('1')
|
||||
$stats = $this->adminService->getUserListWithStats('2026-07', null, '1', '1');
|
||||
$users = collect($stats['users']->items());
|
||||
$this->assertTrue($users->contains('msnv', 3001));
|
||||
$this->assertFalse($users->contains('msnv', 3002));
|
||||
$this->assertTrue($users->contains('msnv', 4001));
|
||||
$this->assertFalse($users->contains('msnv', 4002));
|
||||
|
||||
// 3. Fetch statistics via AdminService with flag_send = '0'
|
||||
$stats2 = $this->adminService->getUserListWithStats('2026-07', null, '0');
|
||||
// Filter by department = HR ('2')
|
||||
$stats2 = $this->adminService->getUserListWithStats('2026-07', null, '1', null, '2');
|
||||
$users2 = collect($stats2['users']->items());
|
||||
$this->assertTrue($users2->contains('msnv', 3002));
|
||||
$this->assertFalse($users2->contains('msnv', 3001));
|
||||
$this->assertTrue($users2->contains('msnv', 4002));
|
||||
$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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import laravel from 'laravel-vite-plugin';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
|
||||
export default defineConfig({
|
||||
cacheDir: '/tmp/vite-cache',
|
||||
plugins: [
|
||||
laravel({
|
||||
input: ['resources/css/app.css', 'resources/js/app.js'],
|
||||
|
||||
Reference in New Issue
Block a user