select box

This commit is contained in:
antv
2026-07-13 16:28:44 +07:00
parent f0a25b840c
commit 47a8892623
24 changed files with 889 additions and 302 deletions
@@ -146,6 +146,31 @@ class AdminController extends Controller
} }
} }
/**
* Delete card allocation history record.
*/
public function destroyAddCard(Request $request, int $id)
{
try {
$this->adminService->destroyAddCard($id);
if ($request->expectsJson()) {
return response()->json([
'success' => true,
'message' => 'Xóa lịch sử cấp phát thẻ thành công.',
]);
}
return redirect()->back()->with('success', 'Xóa lịch sử cấp phát thẻ thành công.');
} catch (\Exception $e) {
if ($request->expectsJson()) {
return response()->json([
'success' => false,
'message' => $e->getMessage(),
], 422);
}
return redirect()->back()->withErrors(['error' => $e->getMessage()]);
}
}
/** /**
* Reset all cards for active users. * Reset all cards for active users.
*/ */
+2 -2
View File
@@ -116,8 +116,8 @@ class UserController extends Controller
$this->userService->updateProfile( $this->userService->updateProfile(
$user, $user,
$request->file('avatar'), $request->validated(),
$request->filled('new_password') ? $request->input('new_password') : null $request->file('avatar')
); );
return redirect()->route('user.edit')->with('success', 'Cập nhật hồ sơ thành công'); return redirect()->route('user.edit')->with('success', 'Cập nhật hồ sơ thành công');
@@ -29,6 +29,8 @@ class UpdateUserRequest extends FormRequest
*/ */
public function rules(): array public function rules(): array
{ {
$msnv = $this->route('msnv');
return [ return [
'card' => 'nullable|integer|min:0', 'card' => 'nullable|integer|min:0',
'num_card' => 'nullable|integer|min:1', 'num_card' => 'nullable|integer|min:1',
@@ -36,6 +38,12 @@ class UpdateUserRequest extends FormRequest
'status' => 'nullable|in:' . config('constants.STATUS_INACTIVE') . ',' . config('constants.STATUS_ACTIVE'), 'status' => 'nullable|in:' . config('constants.STATUS_INACTIVE') . ',' . config('constants.STATUS_ACTIVE'),
'flag_send' => 'nullable|in:0,1', 'flag_send' => 'nullable|in:0,1',
'first_login' => 'nullable|in:' . config('constants.FIRST_LOGIN_FALSE') . ',' . config('constants.FIRST_LOGIN_TRUE'), 'first_login' => 'nullable|in:' . config('constants.FIRST_LOGIN_FALSE') . ',' . config('constants.FIRST_LOGIN_TRUE'),
// Editable basic info
'name' => 'sometimes|required|string|max:255',
'mail' => 'sometimes|required|email|unique:user,mail,' . $msnv . ',msnv',
'departments' => 'sometimes|required|string|in:' . implode(',', config('constants.DEPARTMENTS')),
'avatar' => 'nullable|image|max:2048',
]; ];
} }
} }
@@ -20,8 +20,35 @@ class UpdateProfileRequest extends FormRequest
public function rules(): array public function rules(): array
{ {
return [ return [
'avatar' => 'nullable|image|max:2048', // 2MB max 'name' => 'required|string|max:255',
'new_password' => 'nullable|min:6|confirmed', 'avatar' => 'nullable|image|max:2048', // 2MB max
'current_password' => [
'nullable',
'required_with:new_password',
function ($attribute, $value, $fail) {
if ($value && md5($value) !== auth()->user()->pass) {
$fail('Mật khẩu hiện tại không chính xác.');
}
},
],
'new_password' => 'nullable|min:6|confirmed|different:current_password',
];
}
/**
* Get the error messages for the defined validation rules.
*/
public function messages(): array
{
return [
'name.required' => 'Họ và tên không được để trống.',
'name.string' => 'Họ và tên phải là chuỗi ký tự.',
'name.max' => 'Họ và tên không được vượt quá 255 ký tự.',
'avatar.image' => 'Ảnh đại diện phải là tệp ảnh.',
'avatar.max' => 'Ảnh đại diện không được vượt quá 2MB.',
'new_password.min' => 'Mật khẩu mới phải có ít nhất 6 ký tự.',
'new_password.confirmed' => 'Mật khẩu xác nhận không trùng khớp.',
'new_password.different' => 'Mật khẩu mới phải khác mật khẩu hiện tại.',
]; ];
} }
} }
+85 -11
View File
@@ -16,6 +16,9 @@ use App\DTOs\UserFilterDto;
class AdminService implements AdminServiceInterface class AdminService implements AdminServiceInterface
{ {
public function __construct(
private readonly \App\Services\User\Contracts\UserServiceInterface $userService
) {}
public function getUserListWithStats( public function getUserListWithStats(
string|UserFilterDto $monthOrFilters, string|UserFilterDto $monthOrFilters,
?string $search = null, ?string $search = null,
@@ -136,6 +139,19 @@ class AdminService implements AdminServiceInterface
: User::where('msnv', $userOrMsnv)->firstOrFail(); : User::where('msnv', $userOrMsnv)->firstOrFail();
DB::transaction(function () use ($data, $user) { DB::transaction(function () use ($data, $user) {
// Update profile fields (name & avatar) via UserService
$this->userService->updateProfile($user, [
'name' => $data['name'] ?? null,
], request()->file('avatar'));
if (isset($data['mail'])) {
$user->mail = $data['mail'];
}
if (isset($data['departments'])) {
$user->departments = $data['departments'];
}
$newCard = $user->card; $newCard = $user->card;
if (isset($data['num_card'])) { if (isset($data['num_card'])) {
$newCard = $user->card + intval($data['num_card']); $newCard = $user->card + intval($data['num_card']);
@@ -147,12 +163,21 @@ class AdminService implements AdminServiceInterface
if ($newCard > $oldCard) { if ($newCard > $oldCard) {
$diff = $newCard - $oldCard; $diff = $newCard - $oldCard;
AddCard::create([ $todayRecord = AddCard::where('buyer', $user->msnv)
'buyer' => $user->msnv, ->where('seller', Auth::user()->msnv)
'num_card' => $diff, ->whereDate('date', Carbon::today())
'seller' => Auth::user()->msnv, ->first();
'date' => Carbon::today(), if ($todayRecord) {
]); $todayRecord->num_card += $diff;
$todayRecord->save();
} else {
AddCard::create([
'buyer' => $user->msnv,
'num_card' => $diff,
'seller' => Auth::user()->msnv,
'date' => Carbon::today(),
]);
}
} }
$user->card = $newCard; $user->card = $newCard;
@@ -173,14 +198,16 @@ class AdminService implements AdminServiceInterface
public function resetAllCards(): void public function resetAllCards(): void
{ {
User::where('status', config('constants.STATUS_ACTIVE'))->update(['card' => 0]); User::where('status', config('constants.STATUS_ACTIVE'))->update([
'card' => 0,
'flag_send' => config('constants.FLAG_SEND_DISABLED'),
]);
} }
public function updateAddCard(int $id, array $data): void public function updateAddCard(int $id, array $data): void
{ {
$addCard = AddCard::findOrFail($id); $addCard = AddCard::findOrFail($id);
$currentMonth = Carbon::now()->format('Y-m'); $currentMonth = Carbon::now()->format('Y-m');
$recordMonth = Carbon::parse($addCard->date)->format('Y-m'); $recordMonth = Carbon::parse($addCard->date)->format('Y-m');
@@ -195,15 +222,25 @@ class AdminService implements AdminServiceInterface
$user = User::where('msnv', $addCard->buyer)->firstOrFail(); $user = User::where('msnv', $addCard->buyer)->firstOrFail();
DB::transaction(function () use ($addCard, $user, $data) { $startOfMonth = Carbon::now()->startOfMonth();
$endOfMonth = Carbon::now()->endOfMonth();
$totalSentThisMonth = Administration::where('msnv', $user->msnv)
->whereBetween('date', [$startOfMonth, $endOfMonth])
->sum('sent');
DB::transaction(function () use ($addCard, $user, $data, $totalSentThisMonth) {
$oldNumCard = intval($addCard->num_card); $oldNumCard = intval($addCard->num_card);
$newNumCard = intval($data['num_card']); $newNumCard = intval($data['num_card']);
$diff = $newNumCard - $oldNumCard; $diff = $newNumCard - $oldNumCard;
$newCardBalance = $user->card + $diff;
if ($user->card + $diff < 0) { if ($newCardBalance < 0) {
throw new \Exception('Số lượng card cập nhật không hợp lệ vì tổng số card của user không được nhỏ hơn 0.'); throw new \Exception('Số lượng card cập nhật không hợp lệ vì tổng số card của user không được nhỏ hơn 0.');
} }
if ($newCardBalance < $totalSentThisMonth) {
throw new \Exception('Số lượng card cập nhật không hợp lệ vì tổng số card của user không được nhỏ hơn số card đã gửi trong tháng (' . $totalSentThisMonth . ' card).');
}
$user->card += $diff; $user->card += $diff;
$user->save(); $user->save();
@@ -214,6 +251,43 @@ class AdminService implements AdminServiceInterface
}); });
} }
public function destroyAddCard(int $id): void
{
$addCard = AddCard::findOrFail($id);
$currentMonth = Carbon::now()->format('Y-m');
$recordMonth = Carbon::parse($addCard->date)->format('Y-m');
if ($recordMonth !== $currentMonth) {
throw new \Exception('Không cho phép xóa dữ liệu card của các tháng trước.');
}
$user = User::where('msnv', $addCard->buyer)->firstOrFail();
$startOfMonth = Carbon::now()->startOfMonth();
$endOfMonth = Carbon::now()->endOfMonth();
$totalSentThisMonth = Administration::where('msnv', $user->msnv)
->whereBetween('date', [$startOfMonth, $endOfMonth])
->sum('sent');
DB::transaction(function () use ($addCard, $user, $totalSentThisMonth) {
$newCardBalance = $user->card - intval($addCard->num_card);
if ($newCardBalance < 0) {
throw new \Exception('Không thể xóa lịch sử cấp phát thẻ này vì tổng số card của user không được nhỏ hơn 0.');
}
if ($newCardBalance < $totalSentThisMonth) {
throw new \Exception('Không thể xóa lịch sử cấp phát thẻ này vì tổng số card của user không được nhỏ hơn số card đã gửi trong tháng (' . $totalSentThisMonth . ' card).');
}
$user->card = $newCardBalance;
$user->save();
$addCard->delete();
});
}
public function getUserByMsnv(string $msnv): User public function getUserByMsnv(string $msnv): User
{ {
return User::where('msnv', $msnv)->firstOrFail(); return User::where('msnv', $msnv)->firstOrFail();
@@ -27,6 +27,8 @@ interface AdminServiceInterface
public function updateAddCard(int $id, array $data): void; public function updateAddCard(int $id, array $data): void;
public function destroyAddCard(int $id): void;
public function getUserByMsnv(string $msnv): \App\Models\User; public function getUserByMsnv(string $msnv): \App\Models\User;
public function getAddCardsHistory(string $buyerMsnv, ?string $selectedMonth = null): \Illuminate\Database\Eloquent\Collection; public function getAddCardsHistory(string $buyerMsnv, ?string $selectedMonth = null): \Illuminate\Database\Eloquent\Collection;
@@ -19,5 +19,5 @@ interface UserServiceInterface
public function getRankingList(string $type, string $selectedMonth, User $currentUser): array; public function getRankingList(string $type, string $selectedMonth, User $currentUser): array;
public function updateProfile(User $user, ?\Illuminate\Http\UploadedFile $avatarFile, ?string $newPassword): void; public function updateProfile(User $user, array $data, ?\Illuminate\Http\UploadedFile $avatarFile = null): void;
} }
+7 -3
View File
@@ -214,15 +214,19 @@ class UserService implements UserServiceInterface
]; ];
} }
public function updateProfile(User $user, ?\Illuminate\Http\UploadedFile $avatarFile, ?string $newPassword): void public function updateProfile(User $user, array $data, ?\Illuminate\Http\UploadedFile $avatarFile = null): void
{ {
if (isset($data['name'])) {
$user->name = $data['name'];
}
if ($avatarFile) { if ($avatarFile) {
$path = $avatarFile->store('avatars', 'public'); $path = $avatarFile->store('avatars', 'public');
$user->avatar = 'storage/' . $path; $user->avatar = 'storage/' . $path;
} }
if ($newPassword) { if (!empty($data['new_password'])) {
$user->pass = md5($newPassword); $user->pass = md5($data['new_password']);
$user->first_login = config('constants.FIRST_LOGIN_FALSE'); $user->first_login = config('constants.FIRST_LOGIN_FALSE');
} }
+28
View File
@@ -0,0 +1,28 @@
# Hướng dẫn thiết lập Cron Job cho Laravel trên Ubuntu Server
Để hệ thống tự động chạy các tác vụ định kỳ (bao gồm tác vụ tự động reset thẻ vào cuối tháng qua lệnh `cards:reset`), bạn cần cấu hình Cron Job trên hệ điều hành Ubuntu Server trỏ đến bộ lập lịch (Task Scheduler) của Laravel.
## Các bước thiết lập
1. **Mở cấu hình Crontab:**
Truy cập vào terminal của máy chủ Ubuntu dưới quyền của user chạy web (thường là `www-data` hoặc user deploy của bạn) và chạy lệnh:
```bash
crontab -e
```
2. **Thêm cấu hình chạy Scheduler:**
Thêm dòng dưới đây vào cuối file cấu hình crontab để gọi Laravel scheduler kiểm tra tác vụ mỗi phút:
```bash
* * * * * cd /đường-dẫn-đến-thư-mục-dự-án && php artisan schedule:run >> /dev/null 2>&1
```
*Lưu ý:* Thay thế `/đường-dẫn-đến-thư-mục-dự-án` bằng đường dẫn tuyệt đối đến thư mục chứa dự án trên server (Ví dụ: `/var/www/thankcard-system`).
3. **Lưu và Thoát:**
- Nếu bạn dùng trình soạn thảo `nano`: Nhấn `Ctrl + O` -> nhấn `Enter` để lưu, sau đó bấm `Ctrl + X` để thoát.
- Hệ thống sẽ hiển thị thông báo `crontab: installing new crontab`.
4. **Kiểm tra trạng thái:**
Chạy lệnh sau để hiển thị danh sách các cron job đang hoạt động trên user đó:
```bash
crontab -l
```
+104
View File
@@ -51,6 +51,110 @@
@import 'tailwindcss'; @import 'tailwindcss';
@import 'tom-select/dist/css/tom-select.css'; @import 'tom-select/dist/css/tom-select.css';
/* --- TOM SELECT CUSTOM DESIGN SYSTEM --- */
.ts-wrapper.form-input {
padding: 0 !important;
border: none !important;
background: transparent !important;
height: 48px !important;
box-shadow: none !important;
}
.ts-wrapper.form-input .ts-control {
border: 1px solid #d9dfe7 !important;
border-radius: 0.75rem !important; /* 12px / rounded-xl */
height: 48px !important;
padding: 0 1rem !important;
display: flex !important;
align-items: center !important;
font-size: 0.875rem !important;
color: #1e293b !important;
background-color: #ffffff !important;
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05) !important;
transition: border-color 0.2s, box-shadow 0.2s !important;
position: relative !important;
}
.ts-wrapper.form-input:hover .ts-control {
border-color: #9ca3af !important;
}
.ts-wrapper.form-input.focus .ts-control {
border-color: #3462f7 !important;
box-shadow: 0 0 0 4px rgba(52, 98, 247, 0.2) !important;
outline: none !important;
}
.ts-wrapper.form-input.focus:hover .ts-control {
border-color: #3462f7 !important;
}
.ts-wrapper.form-input-with-icon .ts-control {
padding-left: 48px !important;
}
/* Selected value item styling */
.ts-wrapper.form-input .ts-control .item {
white-space: nowrap !important;
overflow: hidden !important;
text-overflow: ellipsis !important;
max-width: calc(100% - 24px) !important;
color: #1e293b !important;
font-size: 0.875rem !important;
padding: 0 !important;
margin: 0 !important;
line-height: normal !important;
}
/* Search input field styling */
.ts-wrapper.form-input .ts-control input {
font-size: 0.875rem !important;
padding: 0 !important;
margin: 0 !important;
height: auto !important;
line-height: normal !important;
background: transparent !important;
border: none !important;
box-shadow: none !important;
}
/* Dropdown popup list styling */
.ts-wrapper.form-input .ts-dropdown {
border: 1px solid #d9dfe7 !important;
border-radius: 0.75rem !important;
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1) !important;
margin-top: 4px !important;
z-index: 100 !important;
background-color: #ffffff !important;
overflow: hidden !important;
}
.ts-wrapper.form-input .ts-dropdown .option {
padding: 0.65rem 1rem !important;
font-size: 0.875rem !important;
color: #475569 !important;
white-space: normal !important;
word-break: break-word !important;
}
.ts-wrapper.form-input .ts-dropdown .option.active {
background-color: #e8f0fe !important;
color: #3462f7 !important;
}
/* Select dropdown arrow icon */
.ts-wrapper.form-input.single .ts-control::after {
right: 1.25rem !important;
border-color: #6b7280 transparent transparent transparent !important;
border-width: 5px 5px 0 5px !important;
top: 50% !important;
margin-top: -2.5px !important;
}
.ts-wrapper.form-input.single.dropdown-active .ts-control::after {
border-color: transparent transparent #6b7280 transparent !important;
border-width: 0 5px 5px 5px !important;
margin-top: -2.5px !important;
}
/* Specific height override for the search filter form */
#filterForm .ts-wrapper.form-input {
height: 38px !important;
}
#filterForm .ts-wrapper.form-input .ts-control {
height: 38px !important;
min-height: 38px !important;
padding: 0.375rem 1rem !important;
}
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php'; @source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
@source '../../storage/framework/views/*.php'; @source '../../storage/framework/views/*.php';
@source '../**/*.blade.php'; @source '../**/*.blade.php';
@@ -15,5 +15,6 @@
<button type="submit" class="inline-flex items-center justify-center bg-red-600 hover:bg-red-700 text-white font-bold py-2.5 px-6 rounded-xl text-sm transition-colors shadow-md shadow-red-500/10 min-w-[120px] h-[45px]">Nghỉ việc</button> <button type="submit" class="inline-flex items-center justify-center bg-red-600 hover:bg-red-700 text-white font-bold py-2.5 px-6 rounded-xl text-sm transition-colors shadow-md shadow-red-500/10 min-w-[120px] h-[45px]">Nghỉ việc</button>
@elseif($mode === 'self') @elseif($mode === 'self')
<a href="{{ route('user.dashboard') }}" class="inline-flex items-center justify-center bg-white border border-[#d9dfe7] hover:bg-gray-50 text-[#1a2b49] font-bold py-2.5 px-6 rounded-xl text-sm transition-colors shadow-sm min-w-[120px] h-[45px]">Quay lại</a> <a href="{{ route('user.dashboard') }}" class="inline-flex items-center justify-center bg-white border border-[#d9dfe7] hover:bg-gray-50 text-[#1a2b49] font-bold py-2.5 px-6 rounded-xl text-sm transition-colors shadow-sm min-w-[120px] h-[45px]">Quay lại</a>
<button type="submit" class="inline-flex items-center justify-center bg-[#3462f7] hover:bg-blue-700 text-white font-bold py-2.5 px-6 rounded-xl text-sm transition-colors shadow-md shadow-blue-500/10 min-w-[120px] h-[45px]">Lưu Thay Đổi</button>
@endif @endif
</div> </div>
@@ -0,0 +1,55 @@
@props(['mode', 'user' => null])
@if($mode === 'create' || $mode === 'edit')
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- Quyền hạn -->
<div class="relative">
<label class="block text-[14px] font-bold text-[#1a2b49] mb-2">Quyền hạn <span class="text-red-500">*</span></label>
<div class="grid grid-cols-2 gap-2 mt-2 bg-gray-100/80 p-1 rounded-xl">
<label class="flex items-center justify-center gap-2 cursor-pointer py-2 rounded-lg transition-all text-center has-[:checked]:bg-white has-[:checked]:text-[#3462f7] has-[:checked]:shadow-sm text-gray-500 font-bold text-sm hover:bg-white/50">
<input type="radio" name="role" value="{{ config('constants.ROLE_MEMBER') }}" class="sr-only" {{ old('role', $user?->role) == config('constants.ROLE_MEMBER') || (is_null($user) && old('role') != config('constants.ROLE_ADMIN')) ? 'checked' : '' }}>
<span>Member</span>
</label>
<label class="flex items-center justify-center gap-2 cursor-pointer py-2 rounded-lg transition-all text-center has-[:checked]:bg-white has-[:checked]:text-[#3462f7] has-[:checked]:shadow-sm text-gray-500 font-bold text-sm hover:bg-white/50">
<input type="radio" name="role" value="{{ config('constants.ROLE_ADMIN') }}" class="sr-only" {{ old('role', $user?->role) == config('constants.ROLE_ADMIN') ? 'checked' : '' }}>
<span>Admin</span>
</label>
</div>
@error('role')<span class="text-red-500 text-[13px] font-semibold mt-1.5 block">{{ $message }}</span>@enderror
</div>
@if($mode === 'edit')
<!-- Trạng thái -->
<div class="relative">
<label class="block text-[14px] font-bold text-[#1a2b49] mb-2">Trạng thái <span class="text-red-500">*</span></label>
<div class="grid grid-cols-2 gap-2 mt-2 bg-gray-100/80 p-1 rounded-xl">
<label class="flex items-center justify-center gap-2 cursor-pointer py-2 rounded-lg transition-all text-center has-[:checked]:bg-white has-[:checked]:text-[#3462f7] has-[:checked]:shadow-sm text-gray-500 font-bold text-sm hover:bg-white/50">
<input type="radio" name="status" value="{{ config('constants.STATUS_ACTIVE') }}" class="sr-only" {{ old('status', $user?->status) == config('constants.STATUS_ACTIVE') ? 'checked' : '' }}>
<span>Đang làm việc</span>
</label>
<label class="flex items-center justify-center gap-2 cursor-pointer py-2 rounded-lg transition-all text-center has-[:checked]:bg-white has-[:checked]:text-[#3462f7] has-[:checked]:shadow-sm text-gray-500 font-bold text-sm hover:bg-white/50">
<input type="radio" name="status" value="{{ config('constants.STATUS_INACTIVE') }}" class="sr-only" {{ old('status', $user?->status) == config('constants.STATUS_INACTIVE') ? 'checked' : '' }}>
<span>Đã nghỉ việc</span>
</label>
</div>
@error('status')<span class="text-red-500 text-[13px] font-semibold mt-1.5 block">{{ $message }}</span>@enderror
</div>
<!-- Quyền gửi thẻ -->
<div class="relative col-span-1">
<label class="block text-[14px] font-bold text-[#1a2b49] mb-2">Quyền gửi thẻ <span class="text-red-500">*</span></label>
<div class="grid grid-cols-2 gap-2 mt-2 bg-gray-100/80 p-1 rounded-xl">
<label class="flex items-center justify-center gap-2 cursor-pointer py-2 rounded-lg transition-all text-center has-[:checked]:bg-white has-[:checked]:text-[#3462f7] has-[:checked]:shadow-sm text-gray-500 font-bold text-sm hover:bg-white/50">
<input type="radio" name="flag_send" value="{{ config('constants.FLAG_SEND_ENABLED') }}" class="sr-only" {{ old('flag_send', $user?->flag_send) == config('constants.FLAG_SEND_ENABLED') ? 'checked' : '' }}>
<span>Được phép gửi</span>
</label>
<label class="flex items-center justify-center gap-2 cursor-pointer py-2 rounded-lg transition-all text-center has-[:checked]:bg-white has-[:checked]:text-[#3462f7] has-[:checked]:shadow-sm text-gray-500 font-bold text-sm hover:bg-white/50">
<input type="radio" name="flag_send" value="{{ config('constants.FLAG_SEND_DISABLED') }}" class="sr-only" {{ old('flag_send', $user?->flag_send) == config('constants.FLAG_SEND_DISABLED') ? 'checked' : '' }}>
<span>Không cho phép</span>
</label>
</div>
@error('flag_send')<span class="text-red-500 text-[13px] font-semibold mt-1.5 block">{{ $message }}</span>@enderror
</div>
@endif
</div>
@endif
@@ -0,0 +1,29 @@
@props(['mode', 'user' => null])
<div class="flex items-center gap-4 mb-6">
<div class="w-16 h-16 rounded-full overflow-hidden border border-gray-200 shrink-0">
<img src="{{ ($user && $user->avatar) ? asset($user->avatar) : asset('images/avatar.png') }}" alt="Avatar" class="w-full h-full object-cover" id="avatar-preview" onerror="this.onerror=null; this.src='{{ asset('images/avatar.png') }}';">
</div>
<div class="flex-1">
<label class="block text-[13px] font-bold text-gray-700 mb-1" for="avatar">Thay đổi ảnh đại diện</label>
<input type="file" name="avatar" id="avatar" accept="image/*" class="block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-3 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-[#3462f7] file:text-white hover:file:bg-[#254edb]" onchange="previewAvatar(event)">
@error('avatar')<span class="text-red-500 text-[13px] font-semibold mt-1.5 block">{{ $message }}</span>@enderror
</div>
</div>
@once
@push('scripts')
<script>
function previewAvatar(event) {
const input = event.target;
if (input.files && input.files[0]) {
const reader = new FileReader();
reader.onload = function(e) {
document.getElementById('avatar-preview').src = e.target.result;
};
reader.readAsDataURL(input.files[0]);
}
}
</script>
@endpush
@endonce
@@ -0,0 +1,95 @@
@props(['mode', 'user' => null])
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
<!-- MSNV -->
<div class="relative">
<label class="block text-[14px] font-bold text-[#1a2b49] mb-2" for="msnv"> nhân viên (MSNV) <span class="text-red-500">*</span></label>
<div class="relative flex items-center w-full">
<span class="absolute left-4 text-gray-400 pointer-events-none w-5 h-5 flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.8" 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="number" name="msnv" id="msnv"
class="form-input form-input-with-icon !rounded-xl !focus:border-[#3462f7] !focus:ring-[#3462f7]/20 {{ $mode !== 'create' ? '!bg-gray-50 !text-gray-500 cursor-not-allowed' : '' }}"
style="padding-left: 48px;"
{{ $mode !== 'create' ? 'readonly' : 'required' }}
value="{{ old('msnv', $user?->msnv) }}"
placeholder="VD: 1001">
</div>
@error('msnv')<span class="text-red-500 text-[13px] font-semibold mt-1.5 block">{{ $message }}</span>@enderror
</div>
<!-- Name -->
<div class="relative">
<label class="block text-[14px] font-bold text-[#1a2b49] mb-2" for="name">Họ tên <span class="text-red-500">*</span></label>
<div class="relative flex items-center w-full">
<span class="absolute left-4 text-gray-400 pointer-events-none w-5 h-5 flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.8" stroke="currentColor" class="w-5 h-5">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.501 20.118a7.5 7.5 0 0 1 14.998 0A17.933 17.933 0 0 1 12 21.75c-2.676 0-5.216-.584-7.499-1.632Z" />
</svg>
</span>
<input type="text" name="name" id="name"
class="form-input form-input-with-icon !rounded-xl !focus:border-[#3462f7] !focus:ring-[#3462f7]/20"
style="padding-left: 48px;"
required
value="{{ old('name', $user?->name) }}"
placeholder="VD: Nguyễn Văn A">
</div>
@error('name')<span class="text-red-500 text-[13px] font-semibold mt-1.5 block">{{ $message }}</span>@enderror
</div>
<!-- Email -->
<div class="relative">
<label class="block text-[14px] font-bold text-[#1a2b49] mb-2" for="mail">Email <span class="text-red-500">*</span></label>
<div class="relative flex items-center w-full">
<span class="absolute left-4 text-gray-400 pointer-events-none w-5 h-5 flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.8" 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 !rounded-xl !focus:border-[#3462f7] !focus:ring-[#3462f7]/20 {{ $mode === 'self' ? '!bg-gray-50 !text-gray-500 cursor-not-allowed' : '' }}"
style="padding-left: 48px;"
{{ $mode === 'self' ? 'readonly' : 'required' }}
value="{{ old('mail', $user?->mail) }}"
placeholder="VD: nguyenvana@runsystem.net">
</div>
@error('mail')<span class="text-red-500 text-[13px] font-semibold mt-1.5 block">{{ $message }}</span>@enderror
</div>
<!-- Department -->
<div class="relative">
<label class="block text-[14px] font-bold text-[#1a2b49] mb-2" for="departments">Phòng ban <span class="text-red-500">*</span></label>
@if($mode === 'self')
<div class="relative flex items-center w-full">
<span class="absolute left-4 text-gray-400 pointer-events-none w-5 h-5 flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.8" stroke="currentColor" class="w-5 h-5">
<path stroke-linecap="round" stroke-linejoin="round" d="M20.25 14.15v4.25c0 .621-.504 1.125-1.125 1.125H4.875c-.621 0-1.125-.504-1.125-1.125v-4.25m16.5 0a2.18 2.18 0 0 0 .75-1.661V8.706c0-1.081-.768-2.015-1.837-2.175a48.114 48.114 0 0 0-3.413-.387m4.5 8.006c-.194.165-.453.254-.718.254H4.875a1.03 1.03 0 0 1-.718-.254m16.5 0c-.417-.38-.953-.59-1.517-.59H4.875A2.25 2.25 0 0 0 2.25 14.15v-4.25c0-1.081.768-2.015 1.837-2.175a48.067 48.067 0 0 1 16.076 0c1.069.16 1.837 1.094 1.837 2.175v4.25Z" />
</svg>
</span>
<input type="text"
class="form-input form-input-with-icon !rounded-xl !bg-gray-50 !text-gray-500 cursor-not-allowed w-full"
style="padding-left: 48px;"
readonly
title="{{ $user?->departments }}"
value="{{ $user?->departments }}">
</div>
@else
<div class="relative flex items-center w-full">
<span class="absolute left-4 text-gray-400 pointer-events-none w-5 h-5 flex items-center justify-center z-10">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.8" stroke="currentColor" class="w-5 h-5">
<path stroke-linecap="round" stroke-linejoin="round" d="M20.25 14.15v4.25c0 .621-.504 1.125-1.125 1.125H4.875c-.621 0-1.125-.504-1.125-1.125v-4.25m16.5 0a2.18 2.18 0 0 0 .75-1.661V8.706c0-1.081-.768-2.015-1.837-2.175a48.114 48.114 0 0 0-3.413-.387m4.5 8.006c-.194.165-.453.254-.718.254H4.875a1.03 1.03 0 0 1-.718-.254m16.5 0c-.417-.38-.953-.59-1.517-.59H4.875A2.25 2.25 0 0 0 2.25 14.15v-4.25c0-1.081.768-2.015 1.837-2.175a48.067 48.067 0 0 1 16.076 0c1.069.16 1.837 1.094 1.837 2.175v4.25Z" />
</svg>
</span>
<select name="departments" id="departments" class="form-input form-input-with-icon !rounded-xl !focus:border-[#3462f7] !focus:ring-[#3462f7]/20 w-full" style="padding-left: 48px;" required>
<option value="">-- Chọn phòng ban --</option>
@foreach(config('constants.DEPARTMENTS') as $dept)
<option value="{{ $dept }}" {{ old('departments', $user?->departments) == $dept ? 'selected' : '' }}>{{ $dept }}</option>
@endforeach
</select>
</div>
@endif
@error('departments')<span class="text-red-500 text-[13px] font-semibold mt-1.5 block">{{ $message }}</span>@enderror
</div>
</div>
@@ -68,8 +68,6 @@
width: 220px !important; width: 220px !important;
} }
} }
.ts-control { border-radius: 0.75rem; border-color: #d9dfe7; padding: 0.45rem 1rem; }
.ts-wrapper.form-input { padding: 0; border: none; }
</style> </style>
@endpush @endpush
@@ -84,6 +82,9 @@
}, },
placeholder: "🔍 Tìm phòng ban...", placeholder: "🔍 Tìm phòng ban...",
render: { render: {
item: function(data, escape) {
return '<div class="item" title="' + escape(data.text) + '">' + escape(data.text) + '</div>';
},
no_results: function(data, escape) { no_results: function(data, escape) {
return '<div class="no-results p-3 text-gray-500">Không tìm thấy phòng ban nào</div>'; return '<div class="no-results p-3 text-gray-500">Không tìm thấy phòng ban nào</div>';
} }
+46 -183
View File
@@ -22,18 +22,19 @@
@endphp @endphp
<div class="bg-white rounded-[24px] shadow-[0_10px_30px_rgba(15,23,42,0.05)] border border-white overflow-hidden flex flex-col justify-between"> <div class="bg-white rounded-[24px] shadow-[0_10px_30px_rgba(15,23,42,0.05)] border border-white overflow-hidden flex flex-col justify-between">
<div class="bg-gradient-to-r from-blue-50/70 via-indigo-50/50 to-purple-50/30 px-6 py-6 sm:px-8 border-b border-gray-100"> @if($mode !== 'edit')
<h3 class="text-[17px] font-extrabold text-[#1a2b49] leading-tight flex items-center gap-2"> <div class="bg-gradient-to-r from-blue-50/70 via-indigo-50/50 to-purple-50/30 px-6 py-6 sm:px-8 border-b border-gray-100">
<span>{{ match($mode) { <h3 class="text-[17px] font-extrabold text-[#1a2b49] leading-tight flex items-center gap-2">
'create' => '👥 Thêm User Mới', <span>{{ match($mode) {
'edit' => '⚙️ Quản Lý Thẻ: ' . ($user?->msnv), 'create' => '👥 Thêm User Mới',
'delete' => '⚠️ Xác nhận nghỉ việc: ' . ($user?->msnv), 'delete' => '⚠️ Xác nhận nghỉ việc: ' . ($user?->msnv),
'self' => '👤 Thông tin cá nhân: ' . ($user?->msnv), 'self' => '👤 Thông tin cá nhân: ' . ($user?->msnv),
} }}</span> } }}</span>
</h3> </h3>
</div> </div>
@endif
@if(!$isCreate && $user && !$isEdit) @if(!$isCreate && $user && !$isEdit && !$isSelf)
<div class="p-6 sm:p-8 pb-0"> <div class="p-6 sm:p-8 pb-0">
<div class="mb-2 p-5 rounded-2xl bg-gradient-to-br from-slate-50 to-gray-50 border border-gray-100 flex items-center gap-4 shadow-sm"> <div class="mb-2 p-5 rounded-2xl bg-gradient-to-br from-slate-50 to-gray-50 border border-gray-100 flex items-center gap-4 shadow-sm">
<div class="w-12 h-12 rounded-full bg-blue-50 text-[#3462f7] flex items-center justify-center font-extrabold text-lg shadow-inner flex-shrink-0"> <div class="w-12 h-12 rounded-full bg-blue-50 text-[#3462f7] flex items-center justify-center font-extrabold text-lg shadow-inner flex-shrink-0">
@@ -55,168 +56,32 @@
@endif @endif
@if($actionUrl) @if($actionUrl)
<form action="{{ $actionUrl }}" method="POST" class="flex flex-col flex-1 justify-between"> <form action="{{ $actionUrl }}" method="POST" enctype="multipart/form-data" class="flex flex-col flex-1 justify-between">
@csrf @csrf
@if($method !== 'POST') @if($method !== 'POST')
@method($method) @method($method)
@endif @endif
@endif @endif
<div class="p-6 sm:p-8 {{ !$isCreate && !$isEdit ? 'pt-0' : '' }} space-y-5"> <div class="p-6 sm:p-8 {{ !$isCreate && !$isEdit && !$isSelf ? 'pt-0' : '' }} space-y-5">
@if($isCreate)
<div class="relative">
<label class="block text-[14px] font-bold text-[#1a2b49] mb-2" for="msnv"> nhân viên (MSNV) <span class="text-red-500">*</span></label>
<div class="relative flex items-center w-full">
<span class="absolute left-4 text-gray-400 pointer-events-none w-5 h-5 flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.8" 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="number" name="msnv" id="msnv" class="form-input form-input-with-icon !rounded-xl !focus:border-[#3462f7] !focus:ring-[#3462f7]/20" style="padding-left: 48px;" required value="{{ old('msnv') }}" placeholder="VD: 1001">
</div>
@error('msnv')<span class="text-red-500 text-[13px] font-semibold mt-1.5 block">{{ $message }}</span>@enderror
</div>
<div class="relative">
<label class="block text-[14px] font-bold text-[#1a2b49] mb-2" for="name">Họ tên <span class="text-red-500">*</span></label>
<div class="relative flex items-center w-full">
<span class="absolute left-4 text-gray-400 pointer-events-none w-5 h-5 flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.8" stroke="currentColor" class="w-5 h-5">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.501 20.118a7.5 7.5 0 0 1 14.998 0A17.933 17.933 0 0 1 12 21.75c-2.676 0-5.216-.584-7.499-1.632Z" />
</svg>
</span>
<input type="text" name="name" id="name" class="form-input form-input-with-icon !rounded-xl !focus:border-[#3462f7] !focus:ring-[#3462f7]/20" style="padding-left: 48px;" required value="{{ old('name') }}" placeholder="VD: Nguyễn Văn A">
</div>
@error('name')<span class="text-red-500 text-[13px] font-semibold mt-1.5 block">{{ $message }}</span>@enderror
</div>
<div class="relative">
<label class="block text-[14px] font-bold text-[#1a2b49] mb-2" for="mail">Email <span class="text-red-500">*</span></label>
<div class="relative flex items-center w-full">
<span class="absolute left-4 text-gray-400 pointer-events-none w-5 h-5 flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.8" 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 !rounded-xl !focus:border-[#3462f7] !focus:ring-[#3462f7]/20" style="padding-left: 48px;" required value="{{ old('mail') }}" placeholder="VD: nguyenvana@runsystem.net">
</div>
@error('mail')<span class="text-red-500 text-[13px] font-semibold mt-1.5 block">{{ $message }}</span>@enderror
</div>
<div class="relative">
<label class="block text-[14px] font-bold text-[#1a2b49] mb-2" for="departments">Phòng ban <span class="text-red-500">*</span></label>
<select name="departments" id="departments" class="form-input !rounded-xl !focus:border-[#3462f7] !focus:ring-[#3462f7]/20" required>
<option value="">-- Chọn phòng ban --</option>
@foreach(config('constants.DEPARTMENTS') as $dept)
<option value="{{ $dept }}" {{ old('departments') == $dept ? 'selected' : '' }}>{{ $dept }}</option>
@endforeach
</select>
@error('departments')<span class="text-red-500 text-[13px] font-semibold mt-1.5 block">{{ $message }}</span>@enderror
</div>
<div class="relative">
<label class="block text-[14px] font-bold text-[#1a2b49] mb-2" for="password">Mật khẩu khởi tạo <span class="text-red-500">*</span></label>
<div class="relative flex items-center w-full">
<span class="absolute left-4 text-gray-400 pointer-events-none w-5 h-5 flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.8" 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 !rounded-xl !bg-gray-50 !text-gray-500 cursor-not-allowed" style="padding-left: 48px;" readonly required value="1234@Dcba">
</div>
<span class="text-[13px] text-gray-400 mt-2 block">Mật khẩu mặc định cho tất cả user mới tạo.</span>
@error('password')<span class="text-red-500 text-[13px] font-semibold mt-1.5 block">{{ $message }}</span>@enderror
</div>
<div class="relative">
<label class="block text-[14px] font-bold text-[#1a2b49] mb-2" for="role">Quyền hạn <span class="text-red-500">*</span></label>
<select name="role" id="role" class="form-input !rounded-xl !focus:border-[#3462f7] !focus:ring-[#3462f7]/20" required>
<option value="{{ config('constants.ROLE_MEMBER') }}" {{ old('role') == config('constants.ROLE_MEMBER') ? 'selected' : '' }}>Member</option>
<option value="{{ config('constants.ROLE_ADMIN') }}" {{ old('role') == config('constants.ROLE_ADMIN') ? 'selected' : '' }}>Admin</option>
</select>
@error('role')<span class="text-red-500 text-[13px] font-semibold mt-1.5 block">{{ $message }}</span>@enderror
</div>
@endif
@if($isEdit && $user)
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="relative">
<label class="block text-[14px] font-bold text-[#1a2b49] mb-2" for="mail">Email (Chỉ xem)</label>
<div class="relative flex items-center w-full">
<span class="absolute left-4 text-gray-400 pointer-events-none w-5 h-5 flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.8" 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 !rounded-xl !bg-gray-50 !text-gray-500 cursor-not-allowed w-full" style="padding-left: 48px;" readonly value="{{ $user->mail }}">
</div>
</div>
<div class="relative">
<label class="block text-[14px] font-bold text-[#1a2b49] mb-2" for="password">Mật khẩu (Chỉ xem)</label>
<div class="relative flex items-center w-full">
<span class="absolute left-4 text-gray-400 pointer-events-none w-5 h-5 flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.8" 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" id="password" class="form-input form-input-with-icon !rounded-xl !bg-gray-50 !text-gray-500 cursor-not-allowed w-full" style="padding-left: 48px;" readonly value="********">
</div>
</div>
<div class="relative">
<label class="block text-[14px] font-bold text-[#1a2b49] mb-2">Quyền hạn</label>
<div class="grid grid-cols-2 gap-2 mt-2 bg-gray-100/80 p-1 rounded-xl">
<label class="flex items-center justify-center gap-2 cursor-pointer py-2 rounded-lg transition-all text-center has-[:checked]:bg-white has-[:checked]:text-[#3462f7] has-[:checked]:shadow-sm text-gray-500 font-bold text-sm hover:bg-white/50">
<input type="radio" name="role" value="{{ config('constants.ROLE_MEMBER') }}" class="sr-only" {{ old('role', $user->role) == config('constants.ROLE_MEMBER') ? 'checked' : '' }}>
<span>Member</span>
</label>
<label class="flex items-center justify-center gap-2 cursor-pointer py-2 rounded-lg transition-all text-center has-[:checked]:bg-white has-[:checked]:text-[#3462f7] has-[:checked]:shadow-sm text-gray-500 font-bold text-sm hover:bg-white/50">
<input type="radio" name="role" value="{{ config('constants.ROLE_ADMIN') }}" class="sr-only" {{ old('role', $user->role) == config('constants.ROLE_ADMIN') ? 'checked' : '' }}>
<span>Admin</span>
</label>
</div>
@error('role')<span class="text-red-500 text-[13px] font-semibold mt-1.5 block">{{ $message }}</span>@enderror
</div>
<div class="relative">
<label class="block text-[14px] font-bold text-[#1a2b49] mb-2">Trạng thái</label>
<div class="grid grid-cols-2 gap-2 mt-2 bg-gray-100/80 p-1 rounded-xl">
<label class="flex items-center justify-center gap-2 cursor-pointer py-2 rounded-lg transition-all text-center has-[:checked]:bg-white has-[:checked]:text-[#3462f7] has-[:checked]:shadow-sm text-gray-500 font-bold text-sm hover:bg-white/50">
<input type="radio" name="status" value="{{ config('constants.STATUS_ACTIVE') }}" class="sr-only" {{ old('status', $user->status) == config('constants.STATUS_ACTIVE') ? 'checked' : '' }}>
<span>Đang làm việc</span>
</label>
<label class="flex items-center justify-center gap-2 cursor-pointer py-2 rounded-lg transition-all text-center has-[:checked]:bg-white has-[:checked]:text-[#3462f7] has-[:checked]:shadow-sm text-gray-500 font-bold text-sm hover:bg-white/50">
<input type="radio" name="status" value="{{ config('constants.STATUS_INACTIVE') }}" class="sr-only" {{ old('status', $user->status) == config('constants.STATUS_INACTIVE') ? 'checked' : '' }}>
<span>Đã nghỉ việc</span>
</label>
</div>
@error('status')<span class="text-red-500 text-[13px] font-semibold mt-1.5 block">{{ $message }}</span>@enderror
</div>
<div class="relative">
<label class="block text-[14px] font-bold text-[#1a2b49] mb-2">Quyền gửi thẻ</label>
<div class="grid grid-cols-2 gap-2 mt-2 bg-gray-100/80 p-1 rounded-xl">
<label class="flex items-center justify-center gap-2 cursor-pointer py-2 rounded-lg transition-all text-center has-[:checked]:bg-white has-[:checked]:text-[#3462f7] has-[:checked]:shadow-sm text-gray-500 font-bold text-sm hover:bg-white/50">
<input type="radio" name="flag_send" value="{{ config('constants.FLAG_SEND_ENABLED') }}" class="sr-only" {{ old('flag_send', $user->flag_send) == config('constants.FLAG_SEND_ENABLED') ? 'checked' : '' }}>
<span>Được phép gửi</span>
</label>
<label class="flex items-center justify-center gap-2 cursor-pointer py-2 rounded-lg transition-all text-center has-[:checked]:bg-white has-[:checked]:text-[#3462f7] has-[:checked]:shadow-sm text-gray-500 font-bold text-sm hover:bg-white/50">
<input type="radio" name="flag_send" value="{{ config('constants.FLAG_SEND_DISABLED') }}" class="sr-only" {{ old('flag_send', $user->flag_send) == config('constants.FLAG_SEND_DISABLED') ? 'checked' : '' }}>
<span>Không cho phép</span>
</label>
</div>
@error('flag_send')<span class="text-red-500 text-[13px] font-semibold mt-1.5 block">{{ $message }}</span>@enderror
</div>
<div class="hidden md:block"></div>
</div>
@endif
@if($isDelete && $user) @if($isDelete && $user)
<p class="text-sm text-red-600 leading-relaxed font-semibold"> <p class="text-sm text-red-600 leading-relaxed font-semibold">
Bạn 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. Bạn 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> </p>
@else
<!-- 1. User Avatar Section -->
@if($isSelf || $isEdit)
<x-user-avatar-section :mode="$mode" :user="$user" />
@endif
<!-- 2. User Basic Information Section -->
<x-user-basic-info :mode="$mode" :user="$user" />
<!-- 3. User Security Section -->
<x-user-security-section :mode="$mode" :user="$user" />
<!-- 4. User Administration Fields Section -->
<x-user-admin-section :mode="$mode" :user="$user" />
@endif @endif
</div> </div>
@@ -227,30 +92,28 @@
@endif @endif
</div> </div>
@if($isCreate) @if($isCreate || $isEdit)
@push('styles')
<style>
.ts-control { border-radius: 0.75rem; border-color: #d9dfe7; padding: 0.65rem 1rem; }
.ts-wrapper.form-input { padding: 0; border: none; }
</style>
@endpush
@push('scripts') @push('scripts')
<script> <script>
document.addEventListener('DOMContentLoaded', function () { document.addEventListener('DOMContentLoaded', function () {
new TomSelect("#departments", { if (document.getElementById('departments') && typeof TomSelect === 'function') {
create: false, new TomSelect("#departments", {
sortField: { create: false,
field: "text", sortField: {
direction: "asc" field: "text",
}, direction: "asc"
placeholder: "🔍 Tìm phòng ban...", },
render: { placeholder: "Tìm phòng ban...",
no_results: function(data, escape) { render: {
return '<div class="no-results p-3 text-gray-500">Không tìm thấy phòng ban nào</div>'; item: function(data, escape) {
return '<div class="item" title="' + escape(data.text) + '">' + escape(data.text) + '</div>';
},
no_results: function(data, escape) {
return '<div class="no-results p-3 text-gray-500">Không tìm thấy phòng ban nào</div>';
}
} }
} });
}); }
}); });
</script> </script>
@endpush @endpush
+55 -22
View File
@@ -44,11 +44,11 @@
</div> </div>
</div> </div>
<div class="flex items-center gap-4 bg-slate-50 border border-slate-100 rounded-2xl p-4 relative z-10 min-w-[200px] shadow-sm"> <div class="flex items-center gap-4 bg-slate-50 border border-slate-100 rounded-2xl p-4 relative z-10 min-w-[220px] shadow-sm">
<div class="w-11 h-11 bg-white rounded-xl shadow-sm flex items-center justify-center text-xl"> <div class="w-11 h-11 bg-white rounded-xl shadow-sm flex items-center justify-center text-xl">
🎫 🎫
</div> </div>
<div> <div class="flex-1">
<span class="text-[10px] font-bold text-gray-500 uppercase tracking-wider block">Số thẻ hiện </span> <span class="text-[10px] font-bold text-gray-500 uppercase tracking-wider block">Số thẻ hiện </span>
<span id="user-card-balance" class="text-xl font-black text-[#1a2b49]">{{ $user->card }}</span> <span id="user-card-balance" class="text-xl font-black text-[#1a2b49]">{{ $user->card }}</span>
</div> </div>
@@ -82,10 +82,10 @@
<table class="min-w-full divide-y divide-gray-100 text-sm"> <table class="min-w-full divide-y divide-gray-100 text-sm">
<thead class="bg-gray-50/50 sticky top-0 z-10"> <thead class="bg-gray-50/50 sticky top-0 z-10">
<tr> <tr>
<th class="px-6 py-4 text-center text-xs font-bold text-text-light uppercase tracking-wider w-24">Hành động</th>
<th class="px-6 py-4 text-left text-xs font-bold text-text-light uppercase tracking-wider">Ngày cấp</th> <th class="px-6 py-4 text-left text-xs font-bold text-text-light uppercase tracking-wider">Ngày cấp</th>
<th class="px-6 py-4 text-left text-xs font-bold text-text-light uppercase tracking-wider">Người cấp (Admin)</th> <th class="px-6 py-4 text-left text-xs font-bold text-text-light uppercase tracking-wider">Người cấp (Admin)</th>
<th class="px-6 py-4 text-left text-xs font-bold text-text-light uppercase tracking-wider">Số lượng thẻ</th> <th class="px-6 py-4 text-left text-xs font-bold text-text-light uppercase tracking-wider">Số lượng thẻ</th>
<th class="px-6 py-4 text-center text-xs font-bold text-text-light uppercase tracking-wider w-24">Hành động</th>
</tr> </tr>
</thead> </thead>
<tbody class="bg-white divide-y divide-gray-100"> <tbody class="bg-white divide-y divide-gray-100">
@@ -94,6 +94,16 @@
$isCurrentMonth = Carbon\Carbon::parse($addCard->date)->format('Y-m') === Carbon\Carbon::now()->format('Y-m'); $isCurrentMonth = Carbon\Carbon::parse($addCard->date)->format('Y-m') === Carbon\Carbon::now()->format('Y-m');
@endphp @endphp
<tr class="hover:bg-slate-50/60 transition-colors"> <tr class="hover:bg-slate-50/60 transition-colors">
<td class="px-6 py-3.5 whitespace-nowrap text-center">
@if($isCurrentMonth)
<div class="flex items-center justify-center gap-3">
<button type="button" onclick="openEditAddCardModal({{ json_encode($addCard) }})" class="text-blue-600 hover:text-blue-900 font-bold transition-colors">Sửa</button>
<button type="button" onclick="confirmDeleteAddCard({{ $addCard->id }})" class="text-red-600 hover:text-red-900 font-bold transition-colors">Xóa</button>
</div>
@else
<span class="text-gray-400 font-semibold text-xs cursor-not-allowed" title="Không thể chỉnh sửa giao dịch tháng trước">Tháng trước</span>
@endif
</td>
<td class="px-6 py-3.5 whitespace-nowrap text-text-medium font-medium">{{ Carbon\Carbon::parse($addCard->date)->format('d/m/Y') }}</td> <td class="px-6 py-3.5 whitespace-nowrap text-text-medium font-medium">{{ Carbon\Carbon::parse($addCard->date)->format('d/m/Y') }}</td>
<td class="px-6 py-3.5 whitespace-nowrap font-bold text-[#1a2b49]"> <td class="px-6 py-3.5 whitespace-nowrap font-bold text-[#1a2b49]">
@if($addCard->sellerUser) @if($addCard->sellerUser)
@@ -106,13 +116,6 @@
@endif @endif
</td> </td>
<td class="px-6 py-3.5 whitespace-nowrap font-extrabold text-base text-green-600">+{{ $addCard->num_card }}</td> <td class="px-6 py-3.5 whitespace-nowrap font-extrabold text-base text-green-600">+{{ $addCard->num_card }}</td>
<td class="px-6 py-3.5 whitespace-nowrap text-center">
@if($isCurrentMonth)
<button type="button" onclick="openEditAddCardModal({{ json_encode($addCard) }})" class="text-blue-600 hover:text-blue-900 font-bold transition-colors">Sửa</button>
@else
<span class="text-gray-400 font-semibold text-xs cursor-not-allowed" title="Không thể chỉnh sửa giao dịch tháng trước">Tháng trước</span>
@endif
</td>
</tr> </tr>
@empty @empty
<tr> <tr>
@@ -124,9 +127,12 @@
</tbody> </tbody>
</table> </table>
</div> </div>
@php
$isCurrentMonthFilter = $selectedMonth === Carbon\Carbon::now()->format('Y-m');
@endphp
<div class="px-6 py-4 bg-slate-50 border-t border-gray-100 flex justify-end"> <div class="px-6 py-4 bg-slate-50 border-t border-gray-100 flex justify-end">
<button type="button" onclick="openAddCardModal()" class="inline-flex items-center gap-2 bg-[#3462f7] hover:bg-blue-700 text-white font-bold text-sm px-5 py-2.5 rounded-xl shadow-md shadow-blue-500/10 transition-all"> <button type="button" onclick="openAddCardModal()" {{ !$isCurrentMonthFilter ? 'disabled' : '' }} class="inline-flex items-center justify-center bg-[#3462f7] hover:bg-blue-700 disabled:bg-gray-300 disabled:text-gray-500 disabled:cursor-not-allowed disabled:shadow-none text-white font-bold text-sm px-5 py-2.5 rounded-xl shadow-md shadow-blue-500/10 transition-all">
Thêm thẻ Thêm thẻ
</button> </button>
</div> </div>
</div> </div>
@@ -171,10 +177,10 @@
<table class="min-w-full divide-y divide-gray-100 text-sm"> <table class="min-w-full divide-y divide-gray-100 text-sm">
<thead class="bg-gray-50/50 sticky top-0"> <thead class="bg-gray-50/50 sticky top-0">
<tr> <tr>
<th class="px-6 py-4 text-center text-xs font-bold text-text-light uppercase tracking-wider w-24">Hành động</th>
<th class="px-6 py-4 text-left text-xs font-bold text-text-light uppercase tracking-wider">Ngày cấp</th> <th class="px-6 py-4 text-left text-xs font-bold text-text-light uppercase tracking-wider">Ngày cấp</th>
<th class="px-6 py-4 text-left text-xs font-bold text-text-light uppercase tracking-wider">Người cấp (Admin)</th> <th class="px-6 py-4 text-left text-xs font-bold text-text-light uppercase tracking-wider">Người cấp (Admin)</th>
<th class="px-6 py-4 text-left text-xs font-bold text-text-light uppercase tracking-wider">Số lượng thẻ</th> <th class="px-6 py-4 text-left text-xs font-bold text-text-light uppercase tracking-wider">Số lượng thẻ</th>
<th class="px-6 py-4 text-center text-xs font-bold text-text-light uppercase tracking-wider w-24">Hành động</th>
</tr> </tr>
</thead> </thead>
<tbody class="bg-white divide-y divide-gray-100"> <tbody class="bg-white divide-y divide-gray-100">
@@ -183,6 +189,16 @@
$isCurrentMonth = Carbon\Carbon::parse($addCard->date)->format('Y-m') === Carbon\Carbon::now()->format('Y-m'); $isCurrentMonth = Carbon\Carbon::parse($addCard->date)->format('Y-m') === Carbon\Carbon::now()->format('Y-m');
@endphp @endphp
<tr class="hover:bg-slate-50/60 transition-colors"> <tr class="hover:bg-slate-50/60 transition-colors">
<td class="px-6 py-3.5 whitespace-nowrap text-center">
@if($isCurrentMonth)
<div class="flex items-center justify-center gap-3">
<button type="button" onclick="openEditAddCardModal({{ json_encode($addCard) }})" class="text-blue-600 hover:text-blue-900 font-bold transition-colors">Sửa</button>
<button type="button" onclick="confirmDeleteAddCard({{ $addCard->id }})" class="text-red-600 hover:text-red-900 font-bold transition-colors">Xóa</button>
</div>
@else
<span class="text-gray-400 font-semibold text-xs cursor-not-allowed" title="Không thể chỉnh sửa giao dịch tháng trước">Tháng trước</span>
@endif
</td>
<td class="px-6 py-3.5 whitespace-nowrap text-text-medium font-medium">{{ Carbon\Carbon::parse($addCard->date)->format('d/m/Y') }}</td> <td class="px-6 py-3.5 whitespace-nowrap text-text-medium font-medium">{{ Carbon\Carbon::parse($addCard->date)->format('d/m/Y') }}</td>
<td class="px-6 py-3.5 whitespace-nowrap font-bold text-[#1a2b49]"> <td class="px-6 py-3.5 whitespace-nowrap font-bold text-[#1a2b49]">
@if($addCard->sellerUser) @if($addCard->sellerUser)
@@ -195,13 +211,6 @@
@endif @endif
</td> </td>
<td class="px-6 py-3.5 whitespace-nowrap font-extrabold text-base text-green-600">+{{ $addCard->num_card }}</td> <td class="px-6 py-3.5 whitespace-nowrap font-extrabold text-base text-green-600">+{{ $addCard->num_card }}</td>
<td class="px-6 py-3.5 whitespace-nowrap text-center">
@if($isCurrentMonth)
<button type="button" onclick="openEditAddCardModal({{ json_encode($addCard) }})" class="text-blue-600 hover:text-blue-900 font-bold transition-colors">Sửa</button>
@else
<span class="text-gray-400 font-semibold text-xs cursor-not-allowed" title="Không thể chỉnh sửa giao dịch tháng trước">Tháng trước</span>
@endif
</td>
</tr> </tr>
@empty @empty
<tr> <tr>
@@ -213,9 +222,12 @@
</tbody> </tbody>
</table> </table>
</div> </div>
@php
$isCurrentMonthFilter = $selectedMonth === Carbon\Carbon::now()->format('Y-m');
@endphp
<div class="px-6 py-4 bg-slate-50 border-t border-gray-100 flex justify-end"> <div class="px-6 py-4 bg-slate-50 border-t border-gray-100 flex justify-end">
<button type="button" onclick="openAddCardModal()" class="inline-flex items-center gap-2 bg-[#3462f7] hover:bg-blue-700 text-white font-bold text-sm px-5 py-2.5 rounded-xl shadow-md shadow-blue-500/10 transition-all"> <button type="button" onclick="openAddCardModal()" {{ !$isCurrentMonthFilter ? 'disabled' : '' }} class="inline-flex items-center justify-center bg-[#3462f7] hover:bg-blue-700 disabled:bg-gray-300 disabled:text-gray-500 disabled:cursor-not-allowed disabled:shadow-none text-white font-bold text-sm px-5 py-2.5 rounded-xl shadow-md shadow-blue-500/10 transition-all">
Thêm thẻ Thêm thẻ
</button> </button>
</div> </div>
</div> </div>
@@ -275,6 +287,8 @@
</form> </form>
</x-modal> </x-modal>
<x-confirm-modal id="deleteAddCardModal" title="Xác nhận xóa giao dịch" confirmText="Xác nhận xóa" />
<script> <script>
function openEditAddCardModal(record) { function openEditAddCardModal(record) {
const form = document.getElementById('editAddCardForm'); const form = document.getElementById('editAddCardForm');
@@ -340,6 +354,20 @@
} }
} }
function confirmDeleteAddCard(id) {
const messageEl = document.getElementById('deleteAddCardModal-message');
if (messageEl) {
messageEl.innerHTML = 'Bạn có chắc chắn muốn xóa giao dịch cấp phát thẻ này không? Thao tác này sẽ trừ số thẻ tương ứng của user.';
}
const formEl = document.getElementById('deleteAddCardModal-form');
if (formEl) {
formEl.action = `/admin/add-cards/${id}`;
}
openModal('deleteAddCardModal');
}
async function reloadListsSection() { async function reloadListsSection() {
try { try {
const response = await fetch(window.location.href, { const response = await fetch(window.location.href, {
@@ -379,6 +407,11 @@
if (editForm) { if (editForm) {
editForm.addEventListener('submit', (e) => submitModalForm(e, 'editAddCardForm', 'editAddCardModal')); editForm.addEventListener('submit', (e) => submitModalForm(e, 'editAddCardForm', 'editAddCardModal'));
} }
const deleteForm = document.getElementById('deleteAddCardModal-form');
if (deleteForm) {
deleteForm.addEventListener('submit', (e) => submitModalForm(e, 'deleteAddCardModal-form', 'deleteAddCardModal'));
}
} }
if (document.readyState !== 'loading') { if (document.readyState !== 'loading') {
@@ -0,0 +1,103 @@
@props(['mode', 'user' => null])
@if($mode === 'create')
<div class="relative">
<label class="block text-[14px] font-bold text-[#1a2b49] mb-2" for="password">Mật khẩu khởi tạo <span class="text-red-500">*</span></label>
<div class="relative flex items-center w-full">
<span class="absolute left-4 text-gray-400 pointer-events-none w-5 h-5 flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.8" 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 !rounded-xl !bg-gray-50 !text-gray-500 cursor-not-allowed" style="padding-left: 48px;" readonly required value="1234@Dcba">
</div>
<span class="text-[13px] text-gray-400 mt-2 block">Mật khẩu mặc định cho tất cả user mới tạo.</span>
</div>
@elseif($mode === 'edit')
<div class="relative">
<label class="block text-[14px] font-bold text-[#1a2b49] mb-2" for="password">Mật khẩu (Chỉ xem)</label>
<div class="relative flex items-center w-full">
<span class="absolute left-4 text-gray-400 pointer-events-none w-5 h-5 flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.8" 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" id="password" class="form-input form-input-with-icon !rounded-xl !bg-gray-50 !text-gray-500 cursor-not-allowed w-full" style="padding-left: 48px;" readonly value="********">
</div>
</div>
@elseif($mode === 'self')
<div class="border-t border-gray-100 pt-5 mt-5">
<h4 class="text-sm font-bold text-[#1a2b49] mb-4 flex items-center gap-2">
🔑 Bảo mật & Đổi mật khẩu
</h4>
<div class="space-y-4">
<!-- Current Password -->
<div class="flex flex-col">
<label class="text-[13px] font-bold text-gray-700 mb-1.5" for="current_password">Mật khẩu hiện tại</label>
<div class="relative">
<span class="absolute left-3.5 top-1/2 -translate-y-1/2 text-gray-400">
<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="current_password" id="current_password"
class="w-full h-[46px] pl-[42px] pr-12 bg-white border @error('current_password') border-red-500 focus:border-red-500 focus:ring-red-500/20 @else border-gray-200 focus:border-[#3462f7] focus:ring-[#3462f7]/20 @enderror rounded-xl text-sm text-gray-800 outline-none transition-all shadow-sm placeholder:text-gray-400"
placeholder="Nhập mật khẩu hiện tại (chỉ cần khi đổi mật khẩu)">
<button type="button" onclick="togglePasswordVisibility('current_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">
<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 12c1.292 4.338 5.31 7.5 10.066 7.5.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-open hidden" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z"/><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
</button>
</div>
@error('current_password')
<span class="text-red-500 text-[12px] font-semibold mt-1.5 block">{{ $message }}</span>
@enderror
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- New Password -->
<div class="flex flex-col">
<label class="text-[13px] font-bold text-gray-700 mb-1.5" for="new_password">Mật khẩu mới</label>
<div class="relative">
<span class="absolute left-3.5 top-1/2 -translate-y-1/2 text-gray-400">
<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="new_password" id="new_password"
class="w-full h-[46px] pl-[42px] pr-12 bg-white border @error('new_password') border-red-500 focus:border-red-500 focus:ring-red-500/20 @else border-gray-200 focus:border-[#3462f7] focus:ring-[#3462f7]/20 @enderror rounded-xl text-sm text-gray-800 outline-none transition-all shadow-sm placeholder:text-gray-400"
placeholder="Nhập mật khẩu mới">
<button type="button" onclick="togglePasswordVisibility('new_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">
<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 12c1.292 4.338 5.31 7.5 10.066 7.5.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-open hidden" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z"/><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
</button>
</div>
@error('new_password')
<span class="text-red-500 text-[12px] font-semibold mt-1.5 block">{{ $message }}</span>
@enderror
</div>
<!-- Confirm Password -->
<div class="flex flex-col">
<label class="text-[13px] font-bold text-gray-700 mb-1.5" for="new_password_confirmation">Xác nhận mật khẩu mới</label>
<div class="relative">
<span class="absolute left-3.5 top-1/2 -translate-y-1/2 text-gray-400">
<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="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="new_password_confirmation" id="new_password_confirmation"
class="w-full h-[46px] pl-[42px] pr-12 bg-white border @error('new_password_confirmation') border-red-500 focus:border-red-500 focus:ring-red-500/20 @else border-gray-200 focus:border-[#3462f7] focus:ring-[#3462f7]/20 @enderror rounded-xl text-sm text-gray-800 outline-none transition-all shadow-sm placeholder:text-gray-400"
placeholder="Nhập lại mật khẩu mới">
<button type="button" onclick="togglePasswordVisibility('new_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">
<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 12c1.292 4.338 5.31 7.5 10.066 7.5.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-open hidden" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z"/><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
</button>
</div>
@error('new_password_confirmation')
<span class="text-red-500 text-[12px] font-semibold mt-1.5 block">{{ $message }}</span>
@enderror
</div>
</div>
</div>
</div>
@endif
+6 -68
View File
@@ -3,76 +3,14 @@
@section('title', 'Cài đặt tài khoản') @section('title', 'Cài đặt tài khoản')
@section('content') @section('content')
<div class="max-w-[480px] mx-auto bg-white rounded-[24px] shadow-[0_10px_30px_rgba(15,23,42,0.05)] p-8 mt-6"> <div class="max-w-2xl mx-auto w-full mt-6">
<h3 class="text-[20px] font-extrabold text-[#1a2b49] mb-4 text-center">Cài đặt tài khoản</h3>
@if(session('success')) @if(session('success'))
<div class="mb-4 p-3 bg-green-100 text-green-800 rounded-xl">{{ session('success') }}</div> <div class="mb-4 p-4 bg-green-50 border border-green-200 text-green-800 rounded-2xl font-semibold flex items-center gap-2 shadow-sm">
<span></span>
<span>{{ session('success') }}</span>
</div>
@endif @endif
<form action="{{ route('user.update_profile') }}" method="POST" enctype="multipart/form-data"> <x-user-form mode="self" :user="Auth::user()" />
@csrf
<!-- Avatar preview and upload -->
<div class="flex items-center gap-4 mb-6">
<div class="w-16 h-16 rounded-full overflow-hidden border border-gray-200">
<img src="{{ Auth::user()->avatar ?? asset('images/avatar.png') }}" alt="Avatar" class="w-full h-full object-cover" id="avatar-preview" onerror="this.onerror=null; this.src='{{ asset('images/avatar.png') }}';">
</div>
<div class="flex-1">
<label class="block text-[13px] font-bold text-gray-700 mb-1" for="avatar">Thay đổi ảnh đại diện</label>
<input type="file" name="avatar" id="avatar" accept="image/*" class="block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-3 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-[#3462f7] file:text-white hover:file:bg-[#254edb]" onchange="previewAvatar(event)">
</div>
</div>
<!-- New password -->
<div class="space-y-6 mb-4">
<div class="flex flex-col">
<label class="text-[13px] font-bold text-gray-700 mb-1.5" for="new_password">Mật khẩu mới <span class="text-red-500">*</span></label>
<div class="relative">
<span class="absolute left-3.5 top-1/2 -translate-y-1/2 text-gray-400"><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="new_password" id="new_password" class="w-full h-[46px] pl-[42px] pr-12 bg-white border @error('new_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" placeholder="Nhập mật khẩu mới">
<button type="button" onclick="togglePasswordVisibility('new_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">
<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 12c1.292 4.338 5.31 7.5 10.066 7.5.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-open hidden" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z"/><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
</button>
</div>
@error('new_password')
<span class="text-red-500 text-[12px] font-semibold mt-1.5 block">{{ $message }}</span>
@enderror
</div>
<div class="flex flex-col">
<label class="text-[13px] font-bold text-gray-700 mb-1.5" for="new_password_confirmation">Xác nhận mật khẩu mới <span class="text-red-500">*</span></label>
<div class="relative">
<span class="absolute left-3.5 top-1/2 -translate-y-1/2 text-gray-400"><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=\"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="new_password_confirmation" id="new_password_confirmation" class="w-full h-[46px] pl-[42px] pr-12 bg-white border @error('new_password_confirmation') 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" placeholder="Nhập lại mật khẩu mới">
<button type="button" onclick="togglePasswordVisibility('new_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">
<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 12c1.292 4.338 5.31 7.5 10.066 7.5.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-open hidden" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z"/><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
</button>
</div>
@error('new_password_confirmation')
<span class="text-red-500 text-[12px] font-semibold mt-1.5 block">{{ $message }}</span>
@enderror
</div>
</div>
<div class="flex items-center gap-3 pt-4">
<a href="{{ route('user.dashboard') }}" class="flex-1 text-center py-2 bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-xl font-medium">Hủy bỏ</a>
<button type="submit" class="flex-1 py-2 bg-[#3462f7] hover:bg-[#254edb] text-white rounded-xl font-bold">Lưu thay đổi</button>
</div>
</form>
</div> </div>
<script>
function previewAvatar(event) {
const input = event.target;
if (input.files && input.files[0]) {
const reader = new FileReader();
reader.onload = function(e) {
document.getElementById('avatar-preview').src = e.target.result;
};
reader.readAsDataURL(input.files[0]);
}
}
</script>
@endsection @endsection
-7
View File
@@ -1,13 +1,6 @@
@extends('layouts.app') @extends('layouts.app')
@section('title', 'Gửi Thank Card') @section('title', 'Gửi Thank Card')
@push('styles')
<style>
.ts-control { border-radius: 0.75rem; border-color: #d9dfe7; padding: 0.65rem 1rem; }
.ts-wrapper.form-input { padding: 0; border: none; }
</style>
@endpush
@section('content') @section('content')
<div class="max-w-[640px] mx-auto w-full pb-10"> <div class="max-w-[640px] mx-auto w-full pb-10">
<div class="bg-white rounded-[24px] shadow-[0_10px_30px_rgba(15,23,42,0.05)] border border-white overflow-hidden"> <div class="bg-white rounded-[24px] shadow-[0_10px_30px_rgba(15,23,42,0.05)] border border-white overflow-hidden">
+1
View File
@@ -14,6 +14,7 @@ Route::middleware(['auth', 'force_change_password', 'admin'])->prefix('admin')->
Route::delete('/users/{msnv}', [AdminController::class, 'destroy'])->name('users.destroy'); Route::delete('/users/{msnv}', [AdminController::class, 'destroy'])->name('users.destroy');
Route::put('/add-cards/{id}', [AdminController::class, 'updateAddCard'])->name('add_cards.update'); Route::put('/add-cards/{id}', [AdminController::class, 'updateAddCard'])->name('add_cards.update');
Route::delete('/add-cards/{id}', [AdminController::class, 'destroyAddCard'])->name('add_cards.destroy');
Route::post('/reset-cards', [AdminController::class, 'resetCards'])->name('reset_cards'); Route::post('/reset-cards', [AdminController::class, 'resetCards'])->name('reset_cards');
}); });
+3 -1
View File
@@ -8,4 +8,6 @@ Artisan::command('inspire', function () {
$this->comment(Inspiring::quote()); $this->comment(Inspiring::quote());
})->purpose('Display an inspiring quote'); })->purpose('Display an inspiring quote');
Schedule::command('cards:reset')->monthlyOn(1, '00:00'); Schedule::command('cards:reset')->dailyAt('23:59')->when(function () {
return \Carbon\Carbon::now()->isLastOfMonth();
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

+201
View File
@@ -770,4 +770,205 @@ class AdminUserListStatsTest extends TestCase
$member->refresh(); $member->refresh();
$this->assertEquals(16, $member->card); $this->assertEquals(16, $member->card);
} }
public function test_delete_add_card_successfully_updates_balance(): void
{
$admin = User::create([
'msnv' => 9001,
'name' => 'Admin User',
'mail' => 'admin@example.com',
'pass' => md5('password'),
'departments' => 1,
'role' => config('constants.ROLE_ADMIN'),
'status' => config('constants.STATUS_ACTIVE'),
'card' => 10,
'flag_send' => config('constants.FLAG_SEND_ENABLED'),
'first_login' => config('constants.FIRST_LOGIN_FALSE'),
]);
$member = User::create([
'msnv' => 9002,
'name' => 'Member User',
'mail' => 'member@example.com',
'pass' => md5('password'),
'departments' => 1,
'role' => config('constants.ROLE_MEMBER'),
'status' => config('constants.STATUS_ACTIVE'),
'card' => 15,
'flag_send' => config('constants.FLAG_SEND_ENABLED'),
'first_login' => config('constants.FIRST_LOGIN_FALSE'),
]);
$addCard = \App\Models\AddCard::create([
'buyer' => 9002,
'num_card' => 5,
'seller' => 9001,
'date' => Carbon::now()->format('Y-m-d'),
]);
// Action: delete the allocation
$response = $this->actingAs($admin)->delete(route('admin.add_cards.destroy', $addCard->id));
$response->assertRedirect();
$this->assertDatabaseMissing('add_card', ['id' => $addCard->id]);
$member->refresh();
$this->assertEquals(10, $member->card);
}
public function test_update_add_card_fails_if_balance_drops_below_sent_cards(): void
{
$admin = User::create([
'msnv' => 9001,
'name' => 'Admin User',
'mail' => 'admin@example.com',
'pass' => md5('password'),
'departments' => 1,
'role' => config('constants.ROLE_ADMIN'),
'status' => config('constants.STATUS_ACTIVE'),
'card' => 10,
'flag_send' => config('constants.FLAG_SEND_ENABLED'),
'first_login' => config('constants.FIRST_LOGIN_FALSE'),
]);
$member = User::create([
'msnv' => 9002,
'name' => 'Member User',
'mail' => 'member@example.com',
'pass' => md5('password'),
'departments' => 1,
'role' => config('constants.ROLE_MEMBER'),
'status' => config('constants.STATUS_ACTIVE'),
'card' => 10,
'flag_send' => config('constants.FLAG_SEND_ENABLED'),
'first_login' => config('constants.FIRST_LOGIN_FALSE'),
]);
$addCard = \App\Models\AddCard::create([
'buyer' => 9002,
'num_card' => 10,
'seller' => 9001,
'date' => Carbon::now()->format('Y-m-d'),
]);
// Member sent 6 cards this month
Administration::create([
'msnv' => 9002,
'received' => 0,
'sent' => 6,
'date' => Carbon::now()->format('Y-m-d'),
]);
// Try to update num_card from 10 to 4 (decrease of 6, new user balance would be 10 - 6 = 4).
// Since member sent 6 cards, 4 is less than 6, so this should fail!
$response = $this->actingAs($admin)->put(route('admin.add_cards.update', $addCard->id), [
'num_card' => 4,
'seller' => 9001,
'date' => Carbon::now()->format('Y-m-d'),
]);
$response->assertRedirect();
$response->assertSessionHasErrors('error');
$this->assertStringContainsString('Số lượng card cập nhật không hợp lệ vì tổng số card của user không được nhỏ hơn số card đã gửi trong tháng', session('errors')->first('error'));
}
public function test_delete_add_card_fails_if_balance_drops_below_sent_cards(): void
{
$admin = User::create([
'msnv' => 9001,
'name' => 'Admin User',
'mail' => 'admin@example.com',
'pass' => md5('password'),
'departments' => 1,
'role' => config('constants.ROLE_ADMIN'),
'status' => config('constants.STATUS_ACTIVE'),
'card' => 10,
'flag_send' => config('constants.FLAG_SEND_ENABLED'),
'first_login' => config('constants.FIRST_LOGIN_FALSE'),
]);
$member = User::create([
'msnv' => 9002,
'name' => 'Member User',
'mail' => 'member@example.com',
'pass' => md5('password'),
'departments' => 1,
'role' => config('constants.ROLE_MEMBER'),
'status' => config('constants.STATUS_ACTIVE'),
'card' => 10,
'flag_send' => config('constants.FLAG_SEND_ENABLED'),
'first_login' => config('constants.FIRST_LOGIN_FALSE'),
]);
$addCard = \App\Models\AddCard::create([
'buyer' => 9002,
'num_card' => 10,
'seller' => 9001,
'date' => Carbon::now()->format('Y-m-d'),
]);
// Member sent 6 cards this month
Administration::create([
'msnv' => 9002,
'received' => 0,
'sent' => 6,
'date' => Carbon::now()->format('Y-m-d'),
]);
// Try to delete the 10 cards allocation (user balance would be 10 - 10 = 0).
// Since member sent 6 cards, 0 is less than 6, so this should fail!
$response = $this->actingAs($admin)->delete(route('admin.add_cards.destroy', $addCard->id));
$response->assertRedirect();
$response->assertSessionHasErrors('error');
$this->assertStringContainsString('Không thể xóa lịch sử cấp phát thẻ này vì tổng số card của user không được nhỏ hơn số card đã gửi trong tháng', session('errors')->first('error'));
}
public function test_card_additions_on_same_day_are_accumulated(): void
{
$admin = User::create([
'msnv' => 9001,
'name' => 'Admin User',
'mail' => 'admin@example.com',
'pass' => md5('password'),
'departments' => 1,
'role' => config('constants.ROLE_ADMIN'),
'status' => config('constants.STATUS_ACTIVE'),
'card' => 0,
'flag_send' => config('constants.FLAG_SEND_DISABLED'),
'first_login' => config('constants.FIRST_LOGIN_FALSE'),
]);
$member = User::create([
'msnv' => 9002,
'name' => 'Member User',
'mail' => 'member@example.com',
'pass' => md5('password'),
'departments' => 1,
'role' => config('constants.ROLE_MEMBER'),
'status' => config('constants.STATUS_ACTIVE'),
'card' => 5,
'flag_send' => config('constants.FLAG_SEND_ENABLED'),
'first_login' => config('constants.FIRST_LOGIN_FALSE'),
]);
// First allocation: add 10 cards
$this->actingAs($admin)->put(route('admin.users.update', 9002), [
'card' => 15,
]);
// Second allocation: add 5 cards
$this->actingAs($admin)->put(route('admin.users.update', 9002), [
'card' => 20,
]);
$member->refresh();
$this->assertEquals(20, $member->card);
// There should be only 1 AddCard record with 15 cards (10 + 5)
$addCards = \App\Models\AddCard::where('buyer', 9002)->where('seller', 9001)->get();
$this->assertCount(1, $addCards);
$this->assertEquals(15, $addCards->first()->num_card);
}
} }