all feature

This commit is contained in:
antv
2026-06-29 11:55:33 +07:00
parent 08d13336cd
commit 8a77324f89
59 changed files with 5587 additions and 145 deletions
@@ -0,0 +1,70 @@
@extends('layouts.app')
@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"> 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>
@endsection
+116
View File
@@ -0,0 +1,116 @@
@extends('layouts.app')
@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 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ố 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-[16px] 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 giao dịch nào trong tháng này.</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</div>
</div>
</div>
@endsection
+258
View File
@@ -0,0 +1,258 @@
@extends('layouts.app')
@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') }})
</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.');">
@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>
</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>
<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ố 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 </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 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
</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 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
@endsection