diff --git a/BACKEND/Modules/Admin/app/Http/Controllers/CategoryController.php b/BACKEND/Modules/Admin/app/Http/Controllers/CategoryController.php index 58c767c..0150d30 100644 --- a/BACKEND/Modules/Admin/app/Http/Controllers/CategoryController.php +++ b/BACKEND/Modules/Admin/app/Http/Controllers/CategoryController.php @@ -14,7 +14,7 @@ class CategoryController extends Controller * @param Request $request The HTTP request object. * @return \Illuminate\Http\JsonResponse The JSON response containing the list of master data. */ - public function getListMaster(Request $request) + public static function getListMaster(Request $request) { $data = Category::where('c_type', '=', $request->type)->where('c_active', '=', 1)->select('id', 'c_code', 'c_name', 'c_value', 'c_type')->get(); return AbstractController::ResultSuccess($data); @@ -24,4 +24,9 @@ class CategoryController extends Controller $data = Category::where('c_type', '=', $type)->where('c_code', '=', $code)->where('c_active', '=', 1)->select('id', 'c_code', 'c_name', 'c_value', 'c_type')->first(); return $data; } + public static function getListMasterByType($type) + { + $data = Category::where('c_type', '=', $type)->where('c_active', '=', 1)->select('id', 'c_code', 'c_name', 'c_value', 'c_type')->get(); + return $data; + } } diff --git a/BACKEND/Modules/Admin/app/Http/Controllers/LeaveManagementController.php b/BACKEND/Modules/Admin/app/Http/Controllers/LeaveManagementController.php index 477b056..2aca157 100644 --- a/BACKEND/Modules/Admin/app/Http/Controllers/LeaveManagementController.php +++ b/BACKEND/Modules/Admin/app/Http/Controllers/LeaveManagementController.php @@ -35,7 +35,7 @@ class LeaveManagementController extends Controller }) ->leftJoin("categories as reason", function ($join) { $join->on('n_reason', '=', 'reason.c_code'); - $join->on('reason.c_type', DB::raw("CONCAT('REASON')")); + $join->on('reason.c_type', DB::raw("CONCAT('REASON_NOTES')")); }) ->select( DB::raw('notes.n_user_id as n_user_id'), @@ -44,13 +44,14 @@ class LeaveManagementController extends Controller DB::raw('notes.n_month as month'), DB::raw('categories.c_value as leave_days'), DB::raw('notes.n_day as day'), + DB::raw('notes.n_reason as reason_code'), 'reason.c_name as reason_name', 'categories.c_name as time_type_name', // DB::raw('SUM(categories.c_value) as leave_days') ) // ->where('notes.n_user_id', "1") ->where('notes.n_year', $year) - ->where('notes.n_reason', 'ONLEAVE') + ->whereIn('notes.n_reason', ['ONLEAVE', 'LEAVE_WITHOUT_PAY']) // ->groupBy("notes.n_user_id") ->orderBy('notes.n_month') ->orderBy('notes.n_day') @@ -59,7 +60,7 @@ class LeaveManagementController extends Controller return [ "day" => $item->day, "n_user_id" => $item->n_user_id, - // "time_type" => $item->time_type, + "reason_code" => $item->reason_code, "reason_name" => $item->reason_name, "time_type_name" => $item->time_type_name, "month" => $item->month, @@ -105,9 +106,10 @@ class LeaveManagementController extends Controller 'leaveDay' => [ 'id' => $item->id, 'ld_user_id' => $item->ld_user_id, - 'ld_day' => $item->ld_day, + 'ld_day_total' => $item->ld_day_total, 'ld_year' => $item->ld_year, - 'ld_date_additional' => $item->ld_date_additional, + 'ld_additional_day' => $item->ld_additional_day, + 'ld_special_leave_day' => $item->ld_special_leave_day, 'ld_note' => $item->ld_note, 'created_at' => $item->created_at, 'updated_at' => $item->updated_at, @@ -133,8 +135,9 @@ class LeaveManagementController extends Controller $validatedData = $request->all(); $leaveDays = LeaveDays::find($validatedData['id']); - $leaveDays->ld_day = $validatedData['totalLeave']; - $leaveDays->ld_date_additional = $validatedData['dayAdditional']; // Assuming you have this field to store additional days + $leaveDays->ld_day_total = $validatedData['totalLeave']; + $leaveDays->ld_additional_day = $validatedData['dayAdditional']; + $leaveDays->ld_special_leave_day = $validatedData['specialLeave']; $leaveDays->ld_note = $validatedData['note']; $leaveDays->save(); diff --git a/BACKEND/Modules/Admin/app/Http/Controllers/ProfileController.php b/BACKEND/Modules/Admin/app/Http/Controllers/ProfileController.php index d32762b..a69bdcb 100644 --- a/BACKEND/Modules/Admin/app/Http/Controllers/ProfileController.php +++ b/BACKEND/Modules/Admin/app/Http/Controllers/ProfileController.php @@ -10,9 +10,14 @@ use App\Traits\HasFilterRequest; use App\Traits\HasOrderByRequest; use App\Traits\HasSearchRequest; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Mail; use Illuminate\Support\Facades\Storage; +use Illuminate\Support\Facades\Log; +use Modules\Admin\app\Models\Admin; use Modules\Admin\app\Models\Sprint; use Modules\Admin\app\Models\UserCriteria; +use App\Models\Files; +use App\DataTransferObjects\FileData; class ProfileController extends Controller { @@ -28,6 +33,7 @@ class ProfileController extends Controller $this->jiraService = $jiraService; } + public function getProfilesData(Request $request) { $user = auth('admins')->user(); @@ -125,7 +131,7 @@ class ProfileController extends Controller $rootFolder = rtrim($rootFolder, '/') . '/'; // Get all files and directories in the specified root folder - $fileList = $this->getDirectoryTree(public_path($rootFolder), env('APP_ENV') === 'local' ? $rootFolder: 'image'.$rootFolder); + $fileList = $this->getDirectoryTree(public_path($rootFolder), env('APP_ENV') === 'local' ? $rootFolder : 'image' . $rootFolder); return response()->json(['data' => $fileList, 'status' => true]); } @@ -185,6 +191,10 @@ class ProfileController extends Controller if (!Storage::disk('public')->exists($othersDirectory)) { Storage::disk('public')->makeDirectory($othersDirectory); } + + $adminEmails = Admin::where('permission', 'like', '%admin%')->pluck('email')->toArray(); + $currentUser = auth('admins')->user(); + if ($request->hasFile('files')) { foreach ($request->file('files') as $file) { // Store the file and get its path @@ -197,6 +207,32 @@ class ProfileController extends Controller $path = $file->storeAs($baseDirectory, $originalFilename, 'public'); } $uploadedFiles[] = $path; + + // Tạo URL đầy đủ cho file + $fileUrl = (env('APP_ENV') === 'prod' || env('APP_ENV') === 'production') + ? env('APP_URL') . '/image/' . str_replace('/storage/', '', Storage::url($path)) + : env('APP_URL') . str_replace('/storage/', '', Storage::url($path)); + + // // Gửi email thông báo cho admin + // foreach ($adminEmails as $adminEmail) { + // $admin = Admin::where('email', $adminEmail)->first(); + // if ($admin) { + // $this->sendFileUploadNotification( + // $admin, + // "File {$originalFilename} đã được tải lên bởi {$currentUser->name}", + // $fileUrl, + // "[APAC Tech] {$currentUser->name} - Đã tải lên file mới" + // ); + // } + // } + + // // Gửi email xác nhận cho người tải lên + // $this->sendFileUploadNotification( + // $currentUser, + // "Bạn đã tải lên file {$originalFilename} thành công", + // $fileUrl, + // "[APAC Tech] {$currentUser->name} - Tải file thành công" + // ); } } @@ -237,4 +273,201 @@ class ProfileController extends Controller 'message' => 'File not found', ], 404); } + + public function sendFileUploadNotification($user, $description, $url, $subject, $note) + { + try { + // Gửi email bất đồng bộ không cần job + dispatch(function() use ($user, $description, $url, $subject, $note) { + Mail::send('emails.file_upload_notification', [ + 'user' => $user, + 'description' => $description, + 'url' => $url, + 'note' => $note + ], function ($message) use ($user, $subject) { + $message->to($user->email) + ->subject($subject); + }); + })->afterResponse(); + + return true; + } catch (\Exception $e) { + Log::error('Error dispatching file upload notification email: ' . $e->getMessage()); + return false; + } + } + + public function uploadFiles(Request $request) + { + try { + $request->validate([ + 'file' => 'required|file|mimes:jpg,jpeg,png,pdf,doc,docx,xls,xlsx,csv|max:5120', + 'name' => 'required|string|max:255', + 'description' => 'nullable|string', + 'user_name' => 'required|string|max:255' + ]); + + $file = $request->file('file'); + $user = auth('admins')->user(); + + // Tạo thư mục cho user nếu chưa tồn tại + $userFolder = 'files/' . $request->user_name; + if (!Storage::disk('public')->exists($userFolder)) { + Storage::disk('public')->makeDirectory($userFolder); + } + + $path = $file->store($userFolder, 'public'); + + $fileRecord = Files::create([ + 'name' => $request->name, + 'url' => $path, + 'type' => $this->getFileType($file->getClientOriginalName()), + 'description' => $request->description, + 'user_id' => Admin::where('name', $request->user_name)->first()->id + ]); + + $currentUser = Admin::where('name', $request->user_name)->first(); + // Gửi email thông báo cho người upload + $fileUrl = (env('APP_ENV') === 'prod' || env('APP_ENV') === 'production') + ? env('APP_URL') . '/image' . Storage::url($path) + : env('APP_URL') . Storage::url($path); + $this->sendFileUploadNotification( + $user, + 'Bạn đã tải lên file "' . $request->name . '" thành công', + $fileUrl, + "[APAC Tech] {$currentUser->name} - Đã tải lên file mới", + $request->description ?? 'No description' + ); + + // Gửi email thông báo cho tất cả admin khác + $otherAdmins = Admin::where('permission', 'like', '%admin%')->get(); + foreach ($otherAdmins as $admin) { + $this->sendFileUploadNotification( + $admin, + 'File "' . $request->name . '" đã được tải lên bởi ' . $user->name, + $fileUrl, + "[APAC Tech] {$currentUser->name} - Đã tải lên file mới", + $request->description ?? 'No description' + ); + } + + return response()->json([ + 'status' => true, + 'message' => 'File uploaded successfully', + 'data' => [ + 'id' => $fileRecord->id, + 'name' => $fileRecord->name, + 'url' => Storage::url($path), + 'type' => $fileRecord->type, + 'description' => $fileRecord->description + ] + ]); + + } catch (\Exception $e) { + return response()->json([ + 'status' => false, + 'message' => $e->getMessage() + ], 500); + } + } + + public function getFiles() + { + try { + // Lấy tất cả users + $users = Admin::all(); + + // Lấy files và map theo cấu trúc + $files = Files::with('user')->get() + ->map(function($file) { + return [ + 'id' => $file->id, + 'name' => $file->name, + 'url' => Storage::url($file->url), + 'type' => $file->type, + 'description' => $file->description, + 'created_at' => $file->created_at, + 'user_id' => $file->user_id, + 'user_name' => $file->user->name + ]; + }); + + // Tạo mảng kết quả với tất cả users, không có file thì mảng rỗng + $result = $users->pluck('name')->mapWithKeys(function($userName) use ($files) { + $userFiles = $files->where('user_name', $userName) + ->map(function($file) { + return (object)[ + 'id' => $file['id'], + 'name' => $file['name'], + 'url' => $file['url'], + 'type' => $file['type'], + 'description' => $file['description'], + 'created_at' => $file['created_at'], + 'user_id' => $file['user_id'] + ]; + })->values(); + + return [$userName => $userFiles]; + }); + + return response()->json([ + 'status' => true, + 'data' => $result + ]); + + } catch (\Exception $e) { + return response()->json([ + 'status' => false, + 'message' => $e->getMessage() + ], 500); + } + } + + public function deleteFile($id) + { + try { + $file = Files::findOrFail($id); + $user = auth('admins')->user(); + + if ($file->user_id !== $user->id) { + return response()->json([ + 'status' => false, + 'message' => 'Unauthorized' + ], 403); + } + + Storage::disk('public')->delete($file->url); + $file->delete(); + + return response()->json([ + 'status' => true, + 'message' => 'File deleted successfully' + ]); + + } catch (\Exception $e) { + return response()->json([ + 'status' => false, + 'message' => $e->getMessage() + ], 500); + } + } + + private function getFileType($filename) + { + $extension = strtolower(pathinfo($filename, PATHINFO_EXTENSION)); + + $typeMap = [ + 'pdf' => 'document', + 'doc' => 'document', + 'docx' => 'document', + 'jpg' => 'image', + 'jpeg' => 'image', + 'png' => 'image', + 'xls' => 'spreadsheet', + 'xlsx' => 'spreadsheet', + 'csv' => 'spreadsheet' + ]; + + return $typeMap[$extension] ?? 'other'; + } } diff --git a/BACKEND/Modules/Admin/app/Http/Controllers/TicketController.php b/BACKEND/Modules/Admin/app/Http/Controllers/TicketController.php index 1e3e27b..89cb023 100644 --- a/BACKEND/Modules/Admin/app/Http/Controllers/TicketController.php +++ b/BACKEND/Modules/Admin/app/Http/Controllers/TicketController.php @@ -20,6 +20,10 @@ use Modules\Admin\app\Models\Admin; use Modules\Admin\app\Models\Category; use Modules\Admin\app\Models\Ticket; use Modules\Admin\app\Models\Tracking; +use Illuminate\Support\Facades\Log; +use App\Models\LeaveDays; +use Illuminate\Http\JsonResponse; +use App\Models\Admin as UserModel; class TicketController extends Controller { @@ -101,7 +105,6 @@ class TicketController extends Controller ->paginate($request->get('per_page'))->toArray(), ['status' => true] ); - return response()->json($responseData); } @@ -204,29 +207,139 @@ class TicketController extends Controller $request->validate($rules); // return $request; - //Get data from request - $startDate = $request->input('start_date'); //Start day - $startPeriod = $request->input('start_period'); //The session begins - $endDate = $request->input('end_date'); //End date - $endPeriod = $request->input('end_period'); //Session ends + // Get data from request + $startDate = $request->input('start_date'); + $startPeriod = $request->input('start_period'); + $endDate = $request->input('end_date'); + $endPeriod = $request->input('end_period'); $type = $request->input('type'); $reason = $request->input('reason'); + $isAccept = $request->input('is_accept') ?? false; $user = auth('admins')->user(); // user create ticket - - // return $user; - - $dataMasterStartPeriod = CategoryController::getListMasterByCodeAndType("TIME_TYPE", $startPeriod); - $dataMasterEndPeriod = CategoryController::getListMasterByCodeAndType("TIME_TYPE", $endPeriod); - $dataMasterType = CategoryController::getListMasterByCodeAndType("REASON", $type); - - $formattedStartDate = Carbon::createFromFormat('Y-m-d', $startDate)->format('d/m/Y'); - $formattedEndDate = Carbon::createFromFormat('Y-m-d', $endDate)->format('d/m/Y'); - $user = auth('admins')->user(); + $start_date = Carbon::create($startDate)->setTimezone(env('TIME_ZONE')); + $end_date = Carbon::create($endDate)->setTimezone(env('TIME_ZONE')); + + // --- Chỉ kiểm tra ngày phép khi loại là ONLEAVE --- + if ($type === 'ONLEAVE' && !$isAccept) { + // Get mảng ngày nghỉ + $dataListPeriod = $this->getAllPeriodNew($start_date, $startPeriod, $end_date, $endPeriod); + if (empty($dataListPeriod)) { + return AbstractController::ResultError('Không thể tính toán khoảng thời gian nghỉ hợp lệ.'); + } + // Lấy thông tin tickets nghỉ phép đang ở trạng thái WAITING + $ticketsWaiting = Ticket::where('user_id', $user->id)->where('status', 'WAITING')->where('type', 'ONLEAVE') + ->get(); + $dataListPeriodWaiting = []; + if ($ticketsWaiting->count() > 0) { + foreach ($ticketsWaiting as $ticket) { + $dataListPeriodWaiting = array_merge($dataListPeriodWaiting, $this->getAllPeriodNew($ticket->start_date, $ticket->start_period, $ticket->end_date, $ticket->end_period)); + } + } + + // Lấy thông tin tickets nghỉ phép đang ở trạng thái CONFIRMED + $ticketsConfirmed = Ticket::where('user_id', $user->id)->where('status', 'CONFIRMED') + ->whereIn('type', ['ONLEAVE']) + ->where(DB::raw('DATE(start_date)'), '>=', $start_date->toDateString()) + ->where(DB::raw('DATE(end_date)'), '<=', $end_date->toDateString()) + ->get(); + + $dataListPeriodConfirmed = []; + if ($ticketsConfirmed->count() > 0) { + foreach ($ticketsConfirmed as $ticket) { + $dataListPeriodConfirmed = array_merge($dataListPeriodConfirmed, $this->getAllPeriodNew($ticket->start_date, $ticket->start_period, $ticket->end_date, $ticket->end_period)); + } + } + + // Chuyển đổi mảng đa chiều thành mảng chuỗi để có thể so sánh + $periodStrings = []; + $waitingPeriodStrings = []; + $confirmedPeriodStrings = []; + + foreach ($dataListPeriod as $period) { + if ($period['period'] == 'ALL') { + $periodStrings[] = $period['date'] . '_S'; + $periodStrings[] = $period['date'] . '_C'; + } else { + $periodStrings[] = $period['date'] . '_' . $period['period']; + } + } + + foreach ($dataListPeriodWaiting as $period) { + if ($period['period'] == 'ALL') { + $waitingPeriodStrings[] = $period['date'] . '_S'; + $waitingPeriodStrings[] = $period['date'] . '_C'; + } else { + $waitingPeriodStrings[] = $period['date'] . '_' . $period['period']; + } + } + + foreach ($dataListPeriodConfirmed as $period) { + if ($period['period'] == 'ALL') { + $confirmedPeriodStrings[] = $period['date'] . '_S'; + $confirmedPeriodStrings[] = $period['date'] . '_C'; + } else { + $confirmedPeriodStrings[] = $period['date'] . '_' . $period['period']; + } + } + + // Kiểm tra xem có sự trùng lặp giữa request mới và tickets đang chờ duyệt + if (count(array_intersect($periodStrings, $waitingPeriodStrings)) > 0) { + return AbstractController::ResultError('Đã có ticket đang chờ duyệt trong thời gian này, không thể tạo ticket mới!'); + } + + // Kiểm tra xem có sự trùng lặp giữa request mới và tickets đã được duyệt + if (count(array_intersect($periodStrings, $confirmedPeriodStrings)) > 0) { + return AbstractController::ResultError('Đã có ticket được duyệt trong thời gian này, không thể tạo ticket mới!'); + } + + // Tạo thông báo về tickets waiting nếu có + $waitingTicketsMessage = ''; + if (!empty($dataListPeriodWaiting)) { + // Kiểm tra số dư ngày phép cho tickets waiting + $waitingTicketsMessage = "Bạn đang có " . $ticketsWaiting->count() . " yêu cầu nghỉ phép chưa được duyệt"; + + // Nếu muốn thêm chi tiết từng ticket waiting + if ($ticketsWaiting->count() > 0) { + $waitingTicketsMessage .= ":\n"; + foreach ($ticketsWaiting as $ticket) { + $startDateFormat = Carbon::parse($ticket->start_date)->format('d/m/Y'); + $endDateFormat = Carbon::parse($ticket->end_date)->format('d/m/Y'); + $waitingTicketsMessage .= "- " . $ticket->startPeriodName . " (" . $startDateFormat . ") - " . + $ticket->endPeriodName . " (" . $endDateFormat . ")\n"; + } + } + } + + $balanceCheckResultWaiting = $this->checkLeaveBalance($user, $dataListPeriodWaiting); + if ($balanceCheckResultWaiting['months_info']) { + $monthsInfoWaiting = $balanceCheckResultWaiting['months_info']; + $waitingTicketsMessage .= $balanceCheckResultWaiting['message'] . "\n------------------------------------------------"; + $balanceCheckResult = $this->checkLeaveBalance($user, $dataListPeriod, $monthsInfoWaiting); + } else { + $balanceCheckResult = $this->checkLeaveBalance($user, $dataListPeriod); + } + + // Nếu không đủ ngày phép, trả về thông báo và không tạo ticket + if (!$balanceCheckResult['success']) { + $finalMessage = $waitingTicketsMessage; + if (!empty($finalMessage)) { + $finalMessage .= "\n\n"; + } + $finalMessage .= $balanceCheckResult['message']; + $balanceCheckResult['message'] = $finalMessage . "\n\nBạn có chấp nhận không?\n"; + $balanceCheckResult['waitingTicketMessage'] = $waitingTicketsMessage; + + return AbstractController::ResultError("Không thỏa mãn điều kiện ngày phép", $balanceCheckResult); + } + } + // --- Kết thúc kiểm tra --- + + // Nếu đủ ngày phép (hoặc loại ticket không phải ONLEAVE), tiếp tục tạo ticket $ticket = Ticket::create([ - 'start_date' => Carbon::create($startDate)->setTimezone(env('TIME_ZONE')), + 'start_date' => $start_date->toDateString(), 'start_period' => $startPeriod, - 'end_date' => Carbon::create($endDate)->setTimezone(env('TIME_ZONE')), + 'end_date' => $end_date->toDateString(), 'end_period' => $endPeriod, 'type' => $type, 'status' => 'WAITING', @@ -235,24 +348,290 @@ class TicketController extends Controller ]); // Send notification email to admin (list) + $dataMasterStartPeriod = CategoryController::getListMasterByCodeAndType("TIME_TYPE", $startPeriod); + $dataMasterEndPeriod = CategoryController::getListMasterByCodeAndType("TIME_TYPE", $endPeriod); + $dataMasterType = CategoryController::getListMasterByCodeAndType("REASON", $type); + + $formattedStartDate = Carbon::createFromFormat('Y-m-d', $startDate)->format('d/m/Y'); + $formattedEndDate = Carbon::createFromFormat('Y-m-d', $endDate)->format('d/m/Y'); + $admins = Admin::where('permission', 'like', '%admin%')->get(); foreach ($admins as $key => $value) { $data = array( "email_template" => "email.notification_tickets", "email" => $user->email, "name" => $user->name, - "date" => $dataMasterStartPeriod->c_name . " (" . $formattedStartDate . ") - " . $dataMasterEndPeriod->c_name . " (" . $formattedEndDate . ")", - "type" => $dataMasterType->c_name, + "date" => optional($dataMasterStartPeriod)->c_name . " (" . $formattedStartDate . ") - " . optional($dataMasterEndPeriod)->c_name . " (" . $formattedEndDate . ")", + "type" => optional($dataMasterType)->c_name, "note" => $reason, "link" => "/tickets-management", //link đến page admin "subject" => "[Ticket request] Ticket From " . $user->name ); + // Thêm kiểm tra null trước khi gửi mail + if ($dataMasterStartPeriod && $dataMasterEndPeriod && $dataMasterType) { Mail::to($value->email)->send(new TicketMail($data)); + } else { + Log::error("Missing category data for ticket ID: {$ticket->id}. Mail not sent."); + } } return response()->json(['data' => $ticket, 'status' => true]); } + /** + * Kiểm tra số dư ngày phép của người dùng. + * + * @param UserModel $user Người dùng tạo ticket + * @param array|null $dataListPeriod Danh sách các ngày xin nghỉ [['date' => 'Y-m-d', 'period' => 'ALL|S|C'], ...] + * @return array Kết quả kiểm tra ['success' => bool, 'message' => string|null, ...] + */ + private function checkLeaveBalance($user, ?array $dataListPeriod = null, ?array $monthsInfoWaiting = null): array + { + // Kiểm tra giới hạn nghỉ phép theo tháng + if (!empty($dataListPeriod)) { + return $this->checkMonthlyLeaveLimit($user, $dataListPeriod, $monthsInfoWaiting); + } + + // Đủ điều kiện + return [ + 'success' => true, + 'message' => null, + 'months_info' => [] + ]; + } + + private function getTotalAllocatedDays($user, int $year): float + { + $leaveDaysInfo = LeaveDays::where('ld_user_id', $user->id) + ->where('ld_year', $year) + ->first(); + + $totalAllocated = 0; + if ($leaveDaysInfo) { + $totalAllocated = $leaveDaysInfo->ld_day_total + $leaveDaysInfo->ld_additional_day + $leaveDaysInfo->ld_special_leave_day; + } else { + Log::warning("No LeaveDays record found for user ID: {$user->id}, year: {$year}. Assuming 0 allocated days."); + } + + return $totalAllocated; + } + + private function getUsedLeaveDays($user, int $year): float + { + return Notes::join('categories', function ($join) { + $join->on('notes.n_time_type', '=', 'categories.c_code') + ->where('categories.c_type', 'TIME_TYPE'); + }) + ->where('n_user_id', $user->id) + ->where('n_year', $year) + ->where('n_reason', 'ONLEAVE') + ->sum('categories.c_value'); + } + + //Tính tổng giới hạn ngày nghỉ có phép tối đa trong tháng + private function getMaxLeaveDaysPerMonth(): int + { + $limitLeaveMonth = Category::where('c_type', 'LIMIT_LEAVE_MONTH')->where('c_code', "LIMIT")->first(); + if ($limitLeaveMonth) { + $maxDaysPerMonth = (int)$limitLeaveMonth->c_value; + } else { + $maxDaysPerMonth = 3; // default nếu k có setting + } + return $maxDaysPerMonth; + } + + private function checkMonthlyLeaveLimit($user, array $dataListPeriod, ?array $monthsInfoWaiting = null): array + { + // Danh sách ngày nghỉ theo tháng + $requestMonths = $this->groupLeaveRequestsByMonth($dataListPeriod); + $monthsInfo = []; + $hasInsufficientDays = false; + $errorMessage = ''; + $remainingDaysInMonthIsUsed = 0; + + foreach ($requestMonths as $monthKey => $monthData) { + if ($monthsInfoWaiting) { + foreach ($monthsInfoWaiting as $monthInfo) { + if ($monthInfo['month'] == $monthData['month'] && $monthInfo['year'] == $monthData['year']) { + $remainingDaysInMonthIsUsed += $monthInfo['remaining_days_in_month_remaining']; + } + } + } + // Tính tổng số ngày nghỉ có phép trong tháng + $usedDaysInMonth = $this->getUsedLeaveDaysInMonth($user, $monthData['year'], $monthData['month'], 'ONLEAVE'); + + // Tính tổng số ngày nghỉ không phép trong tháng + $usedDaysInMonthWithoutPay = $this->getUsedLeaveDaysInMonth($user, $monthData['year'], $monthData['month'], 'LEAVE_WITHOUT_PAY'); + + // Tính tổng giới hạn ngày nghỉ có phép tối đa trong tháng + + $maxDaysPerMonth = $this->getMaxLeaveDaysPerMonth(); + + $days_will_use = 0; + $days_will_use_without_pay = 0; + + // Tính tổng số ngày nghỉ trong tháng + $totalDaysInMonth = $usedDaysInMonth + $usedDaysInMonthWithoutPay + $monthData['days_requested']; + + // Tính tổng phép có trong tháng + $totalLeaveDaysInMonth = $this->getTotalLeaveDaysInMonth($user, $monthData['year'], $monthData['month']); + + // Tính tổng số ngày nghỉ có phép đến tháng hiện tại + $totalLeaveDaysInMonthToMonth = $this->getTotalLeaveDaysInMonthToMonth($user, $monthData['year'], $monthData['month']); + + //Ngày phép còn lại trong tháng + $remainingDaysInMonth = $totalLeaveDaysInMonth - $totalLeaveDaysInMonthToMonth; + + $remainingDaysInMonthRemaining = $remainingDaysInMonth - $remainingDaysInMonthIsUsed; + // Xử lý các trường hợp thiếu ngày phép + if ($remainingDaysInMonthRemaining <= 0) { //hết phép + $hasInsufficientDays = true; + $month_data['status'] = 'no_days_left'; + $monthMessage = "* Hiện tại bạn đã hết phép nghỉ trong tháng {$monthData['month']}/{$monthData['year']}\n - Bạn sẽ nộp: " . $monthData['days_requested'] . " ngày không phép."; + $errorMessage .= $errorMessage ? "\n\n" . $monthMessage : $monthMessage; + + $days_will_use = 0; + $days_will_use_without_pay = $monthData['days_requested']; + } else if ($remainingDaysInMonthRemaining < $monthData['days_requested']) { // không đủ ngày phép + $hasInsufficientDays = true; + $month_data['status'] = 'insufficient_days'; + $daysNotEnough = $monthData['days_requested'] - $remainingDaysInMonthRemaining; + $monthMessage = "* Tháng {$monthData['month']}/{$monthData['year']}: \n - Số ngày phép còn lại: {$remainingDaysInMonthRemaining}, Số ngày yêu cầu: {$monthData['days_requested']}.\n - Bạn sẽ sử dụng {$remainingDaysInMonthRemaining} ngày phép và {$daysNotEnough} ngày không phép."; + $errorMessage .= $errorMessage ? "\n\n" . $monthMessage : $monthMessage; + $remainingDaysInMonthIsUsed = $remainingDaysInMonth; // lấy số ngày phép còn lại của tháng đó + + $days_will_use = $remainingDaysInMonthRemaining; + $days_will_use_without_pay = $daysNotEnough; + } else if ($remainingDaysInMonthRemaining >= $monthData['days_requested']) { // Đủ ngày phép ở tháng đó + + // 1. Check thêm rule 1 tháng chỉ được nghỉ tối đa $maxDaysPerMonth ngày có phép, ngày vượt sẽ là ngày không phép + if ($monthData['days_requested'] > $maxDaysPerMonth) { + $hasInsufficientDays = true; + $month_data['status'] = 'exceed_max_days'; + $daysWithoutPermission = $monthData['days_requested'] - $maxDaysPerMonth; + $monthMessage = "* Theo quy định ngày phép tôi đa mỗi tháng là {$maxDaysPerMonth} ngày. \nTháng {$monthData['month']}/{$monthData['year']}: \n - Số ngày phép còn lại: {$remainingDaysInMonthRemaining}, Số ngày yêu cầu: {$monthData['days_requested']}.\n - Bạn sẽ sử dụng {$maxDaysPerMonth} ngày phép và {$daysWithoutPermission} ngày không phép."; + $errorMessage .= $errorMessage ? "\n\n" . $monthMessage : $monthMessage; + + $days_will_use = $maxDaysPerMonth; + $days_will_use_without_pay = $daysWithoutPermission; + } else { + $days_will_use = $monthData['days_requested']; + $days_will_use_without_pay = 0; + } + $remainingDaysInMonthRemaining = $monthData['days_requested']; + } else { + $days_will_use = $monthData['days_requested']; + $days_will_use_without_pay = 0; + } + + $month_data = [ + 'year' => $monthData['year'], + 'month' => $monthData['month'], + 'total_leave_days_in_month' => $totalLeaveDaysInMonth, //tổng số ngày phép + 'total_leave_days_in_month_to_month' => $totalLeaveDaysInMonthToMonth, //tổng ngày nghỉ có phép đã nghỉ + 'remaining_days_in_month' => $remainingDaysInMonth, //số ngày phép còn lại + 'days_used' => $usedDaysInMonth, //tổng số ngày nghỉ có phép đã nghỉ ở tháng hiện tại + 'days_used_without_pay' => $usedDaysInMonthWithoutPay, //tổng số ngày nghỉ không phép đã nghỉ ở tháng hiện tại + 'days_requested' => $monthData['days_requested'], //số ngày yêu cầu nghỉ của tháng + 'remaining_days_in_month_remaining' => $remainingDaysInMonthRemaining, + 'days_will_use' => $days_will_use, //Số ngày phép sẽ sử dụng + 'days_will_use_without_pay' => $days_will_use_without_pay, //Số ngày không phép sẽ sử dụng + 'status' => 'ok', // mặc định là ok + ]; + + // Thêm thông tin tháng vào mảng kết quả + $monthsInfo[] = $month_data; + } + // Trả về kết quả tổng hợp + if ($hasInsufficientDays) { + return [ + 'success' => false, + 'message' => $errorMessage, + 'warning_type' => 'exceed_monthly_limit', + 'months_info' => $monthsInfo + ]; + } + + return [ + 'success' => true, + 'message' => "Đủ ngày phép cho yêu cầu.", + 'months_info' => $monthsInfo + ]; + } + + //Tính tổng số ngày nghỉ có phép đến tháng hiện tại + private function getTotalLeaveDaysInMonthToMonth($user, int $year, int $month): float + { + return Notes::join('categories', function ($join) { + $join->on('notes.n_time_type', '=', 'categories.c_code') + ->where('categories.c_type', 'TIME_TYPE'); + }) + ->where('n_user_id', $user->id) + ->where('n_year', $year) + ->where('n_month', "<=", $month) + ->where('n_reason', 'ONLEAVE') + ->sum('categories.c_value'); + } + + private function getTotalLeaveDaysInMonth($user, int $year, int $month): float + { + $leaveDaysInfo = LeaveDays::where('ld_user_id', $user->id) + ->where('ld_year', $year) + ->first(); + + $totalAllocated = 0; + if ($leaveDaysInfo) { + // if ($leaveDaysInfo->ld_day_total > $month) { + // $totalAllocated = $month; + // } else { + // $totalAllocated = $leaveDaysInfo->ld_day_total; + // } + $totalAllocated = $month; //(+ tạm để check) + // bên hàm duyệt ticket sẽ check lại để + 1 ngày trước job để đảm bảo đủ ngày phép + } else { + Log::warning("No LeaveDays record found for user ID: {$user->id}, year: {$year}. Assuming 0 allocated days."); + } + $totalAllocated = $totalAllocated + $leaveDaysInfo->ld_additional_day + $leaveDaysInfo->ld_special_leave_day; + return $totalAllocated; + } + + private function groupLeaveRequestsByMonth(array $dataListPeriod): array + { + $requestMonths = []; + foreach ($dataListPeriod as $periodData) { + $date = Carbon::parse($periodData['date']); + $monthKey = $date->format('Y-m'); // YYYY-MM + + if (!isset($requestMonths[$monthKey])) { + $requestMonths[$monthKey] = [ + 'year' => $date->year, + 'month' => $date->month, + 'days_requested' => 0, + 'days_used' => 0 + ]; + } + + // Tính số ngày yêu cầu trong tháng + $dayValue = ($periodData['period'] === 'ALL') ? 1.0 : 0.5; + $requestMonths[$monthKey]['days_requested'] += $dayValue; + } + + return $requestMonths; + } + + private function getUsedLeaveDaysInMonth($user, int $year, int $month, string $reason): float + { + return Notes::join('categories', function ($join) { + $join->on('notes.n_time_type', '=', 'categories.c_code') + ->where('categories.c_type', 'TIME_TYPE'); + }) + ->where('n_user_id', $user->id) + ->where('n_year', $year) + ->where('n_month', $month) + ->where('n_reason', $reason) + ->sum('categories.c_value'); + } + public function deleteTicket(Request $request) { $rules = [ @@ -299,18 +678,12 @@ class TicketController extends Controller if (!$ticket || $ticket->status !== "WAITING") { return response()->json(['message' => "Ticket not found", 'status' => false]); } - $results = $this->getAllPeriod($ticket->start_date, $ticket->start_period, $ticket->end_date, $ticket->end_period); - - // $admin->id != user_id of ticket ---> continue // Confirm - // Add records to the notes table like function Timekeeping.addNoteForUser() based on the $results array - + // Update updated_by and admin_note in tickets table + // Refuse // Update updated_by and admin_note in tickets table // Send notification email to users - - // Refuse - // Update updated_by and admin_note in tickets table $startDate = $ticket->start_date; //Start day $startPeriod = $ticket->start_period; //The session begins $endDate = $ticket->end_date; //End date @@ -321,13 +694,158 @@ class TicketController extends Controller $dataMasterEndPeriod = CategoryController::getListMasterByCodeAndType("TIME_TYPE", $endPeriod); $dataMasterType = CategoryController::getListMasterByCodeAndType("REASON", $type); + $dataMasterTypeNotes = CategoryController::getListMasterByType("REASON_NOTES"); + $onleave = null; + $leaveWithoutPay = null; + + if ($dataMasterTypeNotes) { + // get nghỉ phép, nghỉ không phép + $onleave = optional($dataMasterTypeNotes->where('c_code', 'ONLEAVE')->first())->c_code; + $leaveWithoutPay = optional($dataMasterTypeNotes->where('c_code', 'LEAVE_WITHOUT_PAY')->first())->c_code; + } + $formattedStartDate = Carbon::createFromFormat('Y-m-d', $startDate)->format('d/m/Y'); $formattedEndDate = Carbon::createFromFormat('Y-m-d', $endDate)->format('d/m/Y'); $user = Admin::find($ticket->user_id); - + + if ($onleave == null || $leaveWithoutPay == null) { + return response()->json(['message' => "Data reason notes not found", 'status' => false]); + } + if ($action == "confirm") { - foreach ($results as $result) { + if ($ticket->type == "ONLEAVE") { + $dataListPeriod = $this->getAllPeriodNew($ticket->start_date, $ticket->start_period, $ticket->end_date, $ticket->end_period); + $balanceCheckResult = $this->checkLeaveBalance($user, $dataListPeriod); + // dd($balanceCheckResult,$dataListPeriod); + if ($balanceCheckResult['success'] == false) { + if ($balanceCheckResult['months_info']) { + foreach ($balanceCheckResult['months_info'] as $monthInfo) { + // Lọc các ngày thuộc đúng tháng/năm này + $daysInMonth = array_filter($dataListPeriod, function ($item) use ($monthInfo) { + $date = \Carbon\Carbon::parse($item['date']); + return $date->year == $monthInfo['year'] && $date->month == $monthInfo['month']; + }); + + $daysWillUse = $monthInfo['days_will_use'] ?? 0; + $daysWillUseWithoutPay = $monthInfo['days_will_use_without_pay'] ?? 0; + // dd($daysWillUse,$daysWillUseWithoutPay,$daysInMonth); + foreach ($daysInMonth as $item) { + list($year, $month, $day) = explode('-', $item['date']); + $period = $item['period']; + $value = ($period === 'ALL') ? 1.0 : 0.5; + + if ($period === 'ALL' && $daysWillUse == 0.5) { + // Chỉ còn 0.5 phép, chia thành 2 bản ghi: 1 phép, 1 không phép + // Ưu tiên phép cho buổi sáng (S), không phép cho buổi chiều (C) + Notes::create([ + 'n_user_id' => $ticket->user_id, + 'n_day' => $day, + 'n_month' => $month, + 'n_year' => $year, + 'n_time_type' => 'S', + 'n_reason' => $onleave, + 'n_note' => $ticket->reason + ]); + Notes::create([ + 'n_user_id' => $ticket->user_id, + 'n_day' => $day, + 'n_month' => $month, + 'n_year' => $year, + 'n_time_type' => 'C', + 'n_reason' => $leaveWithoutPay, + 'n_note' => $ticket->reason + ]); + $daysWillUse = 0; + $daysWillUseWithoutPay -= 0.5; + } elseif ($daysWillUse > 0) { + // Dùng ngày phép trước + $use = min($daysWillUse, $value); + Notes::create([ + 'n_user_id' => $ticket->user_id, + 'n_day' => $day, + 'n_month' => $month, + 'n_year' => $year, + 'n_time_type' => $period, + 'n_reason' => $onleave, + 'n_note' => $ticket->reason + ]); + $daysWillUse -= $use; + } elseif ($daysWillUseWithoutPay > 0) { + // Hết phép, chuyển sang không phép + $use = min($daysWillUseWithoutPay, $value); + Notes::create([ + 'n_user_id' => $ticket->user_id, + 'n_day' => $day, + 'n_month' => $month, + 'n_year' => $year, + 'n_time_type' => $period, + 'n_reason' => $leaveWithoutPay, + 'n_note' => $ticket->reason + ]); + $daysWillUseWithoutPay -= $use; + } + // Nếu cả hai đều hết thì thôi, không tạo nữa + } + } + } + } else { + //Đủ phép + foreach ($dataListPeriod as $result) { + list($year, $month, $day) = explode('-', $result['date']); + Notes::create([ + 'n_user_id' => $ticket->user_id, + 'n_day' => $day, + 'n_month' => $month, + 'n_year' => $year, + 'n_time_type' => $result['period'], + 'n_reason' => $onleave, // có phép + 'n_note' => $ticket->reason + ]); + } + } + + $yearCheck = Carbon::parse($endDate)->year; + // Check giá trị ld_day_total của bảng leave_days thuộc user id đó với giá trị của list item note trong bảng notes của user id đó + $leaveDaysInfo = LeaveDays::where('ld_user_id', $ticket->user_id) + ->where('ld_year', $yearCheck) + ->first(); + if ($leaveDaysInfo) { + // Tính tổng số ngày nghỉ có phép đã sử dụng trong năm + $totalUsedLeaveDays = Notes::join('categories', function ($join) { + $join->on('notes.n_time_type', '=', 'categories.c_code') + ->where('categories.c_type', 'TIME_TYPE'); + }) + ->where('n_user_id', $ticket->user_id) + ->where('n_year', $yearCheck) + ->where('n_reason', 'ONLEAVE') + ->sum('categories.c_value'); + + // Tính tổng số ngày phép được cấp + $totalAllocatedDays = $leaveDaysInfo->ld_day_total + + $leaveDaysInfo->ld_additional_day + + $leaveDaysInfo->ld_special_leave_day; + + // Tính số ngày vượt quá và làm tròn lên + $excessDays = $totalUsedLeaveDays - $totalAllocatedDays; + $roundedExcessDays = ceil($excessDays); // Làm tròn lên số nguyên gần nhất + + // Kiểm tra nếu số ngày đã sử dụng vượt quá số ngày được cấp + if ($roundedExcessDays > 0) { + Log::warning("User ID: {$ticket->user_id} has used more leave days ({$totalUsedLeaveDays}) than allocated ({$totalAllocatedDays})"); + + // Cập nhật cột ld_day_total với số ngày đã làm tròn + if ($roundedExcessDays > 0) { + $leaveDaysInfo->ld_day_total += $roundedExcessDays; + $leaveDaysInfo->save(); + + Log::info("Updated leave days for User ID: {$ticket->user_id}. Added {$roundedExcessDays} days (rounded from {$excessDays})"); + } + } + } + } else if ($ticket->type == "WFH") { + $dataListPeriod = $this->getAllPeriod($ticket->start_date, $ticket->start_period, $ticket->end_date, $ticket->end_period); + foreach ($dataListPeriod as $result) { list($year, $month, $day) = explode('-', $result['date']); Notes::create([ 'n_user_id' => $ticket->user_id, @@ -339,7 +857,8 @@ class TicketController extends Controller 'n_note' => $ticket->reason ]); - if ($ticket->type == "WFH") { + + //WFH - start tracking $type = $result['period']; $date = Carbon::create($year, $month, $day)->setTimezone(env('TIME_ZONE')); @@ -369,6 +888,7 @@ class TicketController extends Controller 'created_at' => $end->setTimezone('UTC') ] ]); + //WFH - end tracking } } @@ -424,6 +944,112 @@ class TicketController extends Controller return response()->json(['message' => "failed", 'status' => false]); } + private function getAllPeriodNew($startDate, $startPeriod, $endDate, $endPeriod) + { + // Đảm bảo $startDate và $endDate là đối tượng Carbon + if (!($startDate instanceof Carbon)) { + $startDate = Carbon::parse($startDate); + } + + if (!($endDate instanceof Carbon)) { + $endDate = Carbon::parse($endDate); + } + + // Create an array to contain the results + $results = []; + + // Use CarbonPeriod to create a period from the start date to the end date + $period = CarbonPeriod::create($startDate, $endDate); + + $time_type = Category::where('c_type', 'TIME_TYPE')->get()->keyBy('c_code'); + $morning = $time_type->get('S'); + $afternoon = $time_type->get('C'); + $all_day = $time_type->get('ALL'); + + // Get all Saturday work schedules and sort them by date in descending order + $saturday_work_schedules = Category::where('c_type', 'SATURDAY_WORK_SCHEDULE') + ->get() + ->sortByDesc(function ($item) { + // Parse the date string from c_code to a Carbon instance for proper comparison + return Carbon::createFromFormat('d-m-Y', $item->c_code); + }); + // get day work special + $day_work_special = Category::where('c_type', 'DAY_WORK_SPECIAL') + ->get() + ->sortByDesc(function ($item) { + return Carbon::createFromFormat('d-m-Y', $item->c_code); + }); + + + // Get the most recent schedule date (first item after sorting) + $latest_schedule = $saturday_work_schedules->first(); + $latestScheduleDate = Carbon::createFromFormat('d-m-Y', $latest_schedule->c_code); + + if (!$morning || !$afternoon || !$all_day) { + // Handle error: TIME_TYPE categories not found + Log::error("TIME_TYPE categories (S, C, ALL) not found in database."); + return []; // Return empty or throw exception + } + + $special_dates = []; + foreach ($day_work_special as $item) { + $special_dates[] = Carbon::createFromFormat('d-m-Y', $item->c_code)->toDateString(); + } + foreach ($period as $date) { + // Check phải ngày thứ 7 đặc biệt thì tính như ngày bình thường + if (in_array($date->toDateString(), $special_dates)) { + } else { + // Check if the current day is a Saturday + if ($date->dayOfWeek === Carbon::SATURDAY) { + if ($latest_schedule) { + $weeksDifference = $latestScheduleDate->startOfDay()->diffInWeeks($date->copy()->startOfDay()); + $isSaturdayWorkDay = ($weeksDifference % 2 === 0); + + // echo $date->toDateString() . ' - ' . ($isSaturdayWorkDay ? 'Làm việc' : 'Nghỉ') . "
"; + } + + if ($isSaturdayWorkDay) { + $results[] = ['date' => $date->toDateString(), 'period' => "S"]; + } + + continue; + } + // Skip Sundays entirely + else if ($date->dayOfWeek === Carbon::SUNDAY) { + continue; + } + } + + if ($date->isSameDay($startDate)) { + //If the start date is morning, add afternoon + if ($startDate->isSameDay($endDate)) { // Nghỉ trong cùng 1 ngày + if ($startPeriod == $endPeriod) { // Cùng 1 buổi (S hoặc C) + $results[] = ['date' => $date->toDateString(), 'period' => $startPeriod]; + } else { // Khác buổi (S đến C) -> cả ngày + $results[] = ['date' => $date->toDateString(), 'period' => $all_day->c_code]; + } + } else { // Ngày bắt đầu khác ngày kết thúc + if ($startPeriod == $morning->c_code) { // Bắt đầu từ sáng -> tính cả ngày + $results[] = ['date' => $date->toDateString(), 'period' => $all_day->c_code]; + } else { // Bắt đầu từ chiều -> tính buổi chiều + $results[] = ['date' => $date->toDateString(), 'period' => $startPeriod]; // Là $afternoon->c_code + } + } + } elseif ($date->isSameDay($endDate)) { // Ngày kết thúc (khác ngày bắt đầu) + if ($endPeriod == $afternoon->c_code) { // Kết thúc vào buổi chiều -> tính cả ngày + $results[] = ['date' => $date->toDateString(), 'period' => $all_day->c_code]; + } else { // Kết thúc vào buổi sáng -> tính buổi sáng + $results[] = ['date' => $date->toDateString(), 'period' => $endPeriod]; // Là $morning->c_code + } + } else { // Những ngày ở giữa + $results[] = ['date' => $date->toDateString(), 'period' => $all_day->c_code]; + } + } + + // Returns results + return $results; + } + private function getAllPeriod($startDate, $startPeriod, $endDate, $endPeriod) { //Create an array to contain the results @@ -488,4 +1114,33 @@ class TicketController extends Controller //Returns results return $results; } + + /** + * Tính tổng số ngày nghỉ từ mảng các khoảng thời gian. + * 'ALL' = 1 ngày, 'S'/'C' = 0.5 ngày. + * + * @param array $dataListPeriod Mảng các khoảng thời gian nghỉ [['date' => 'Y-m-d', 'period' => 'ALL|S|C'], ...] + * @return float Tổng số ngày nghỉ + */ + private function calculateTotalLeaveDays(array $dataListPeriod): float + { + $totalDays = 0.0; + + foreach ($dataListPeriod as $periodData) { + if (isset($periodData['period'])) { + switch ($periodData['period']) { + case 'ALL': + $totalDays += 1.0; + break; + case 'S': // Buổi sáng + case 'C': // Buổi chiều + $totalDays += 0.5; + break; + // Có thể thêm default case để xử lý lỗi nếu cần + } + } + } + + return $totalDays; + } } diff --git a/BACKEND/Modules/Admin/app/Http/Controllers/TimekeepingController.php b/BACKEND/Modules/Admin/app/Http/Controllers/TimekeepingController.php index cddc744..b33b165 100644 --- a/BACKEND/Modules/Admin/app/Http/Controllers/TimekeepingController.php +++ b/BACKEND/Modules/Admin/app/Http/Controllers/TimekeepingController.php @@ -152,8 +152,6 @@ class TimekeepingController extends Controller return response()->json(['status' => true, 'message' => 'Add successfully']); } - - public function updateCacheMonth(Request $request) { $month = $request->month; @@ -180,6 +178,71 @@ class TimekeepingController extends Controller $note = Notes::find($id); if ($note) { + $n_month = $note->n_month; + $n_year = $note->n_year; + + if ($note->n_reason == "ONLEAVE") { + // Get note reason ONLEAVE by $n_month, $n_year not include $note->id & include $note->n_user_id + // $onleave = Notes::getNotesByMonthAndYearAndUserId($n_month, $n_year, $note->n_user_id, $note->id); + + // Get note reason LEAVE_WITHOUT_PAY by $n_month, $n_year & include $note->n_user_id + $leaveWithoutPay = Notes::getNotesByMonthAndYearAndUserIdAndReason($n_month, $n_year, $note->n_user_id, 'LEAVE_WITHOUT_PAY'); + + if (count($leaveWithoutPay) > 0) { + $deletedValue = ($note->n_time_type === 'ALL') ? 1.0 : 0.5; + $needUpdate = $deletedValue; + // dd($needUpdate, $leaveWithoutPay); + foreach ($leaveWithoutPay as $lwNote) { + if ($needUpdate <= 0) break; + + if ($lwNote->n_time_type === 'ALL') { + if ($needUpdate == 1.0) { + // Chuyển cả note ALL thành phép + $lwNote->update(['n_reason' => 'ONLEAVE']); + $needUpdate = 0; + break; + } else { // $needUpdate == 0.5 + // Tách ALL thành 2 note S và C, chuyển S thành phép, C giữ không phép + Notes::create([ + 'n_user_id' => $lwNote->n_user_id, + 'n_day' => $lwNote->n_day, + 'n_month' => $lwNote->n_month, + 'n_year' => $lwNote->n_year, + 'n_time_type' => 'S', + 'n_reason' => 'ONLEAVE', + 'n_note' => $lwNote->n_note + ]); + Notes::create([ + 'n_user_id' => $lwNote->n_user_id, + 'n_day' => $lwNote->n_day, + 'n_month' => $lwNote->n_month, + 'n_year' => $lwNote->n_year, + 'n_time_type' => 'C', + 'n_reason' => 'LEAVE_WITHOUT_PAY', + 'n_note' => $lwNote->n_note + ]); + $lwNote->delete(); + $needUpdate = 0; + break; + } + } else { + // Nếu $lwNote->n_time_type == 'S' hoặc 'C' => 0.5 + if ($needUpdate == 1.0) { + // Chuyển cả note ALL thành phép + $lwNote->update(['n_reason' => 'ONLEAVE']); + $needUpdate -= 0.5; + } else { // $needUpdate == 0.5 + // S hoặc C, chỉ cần chuyển đúng 0.5 ngày + $lwNote->update(['n_reason' => 'ONLEAVE']); + $needUpdate = 0; + break; + } + } + } + } else { + // Khi note phép và k tồn tại nghỉ không phép => phép + dồn cho tháng sau + } + } $note->delete(); $this->createOrUpdateRecordForCurrentMonth($month, $year); return response()->json(['message' => 'Delete success', 'status' => true]); @@ -206,10 +269,10 @@ class TimekeepingController extends Controller } // Lọc chỉ lấy user có permission bao gồm staff - $staffData = array_filter($responseData['data'], function($user) { + $staffData = array_filter($responseData['data'], function ($user) { return isset($user['user']['permission']) && strpos($user['user']['permission'], 'staff') !== false; }); - + $currentDate = date('d_His'); return Excel::download( new TimekeepingExport( diff --git a/BACKEND/Modules/Admin/routes/api.php b/BACKEND/Modules/Admin/routes/api.php index b561918..8f9ee2c 100755 --- a/BACKEND/Modules/Admin/routes/api.php +++ b/BACKEND/Modules/Admin/routes/api.php @@ -174,6 +174,9 @@ Route::middleware('api') Route::get('/all-files', [ProfileController::class, 'listFiles'])->middleware('check.permission:admin.hr.staff.accountant'); Route::post('/update-profile', [ProfileController::class, 'updateProfile'])->middleware('check.permission:admin.hr.staff.accountant'); Route::get('/delete-profile-file', [ProfileController::class, 'removeFile'])->middleware('check.permission:admin.hr.staff.accountant'); + Route::get('/files', [ProfileController::class, 'getFiles'])->middleware('check.permission:admin.hr.staff.accountant'); + Route::post('/upload-files', [ProfileController::class, 'uploadFiles'])->middleware('check.permission:admin.hr.staff.accountant'); + Route::delete('/files/{id}', [ProfileController::class, 'deleteFile'])->middleware('check.permission:admin.hr.staff.accountant'); }); Route::group([ diff --git a/BACKEND/app/Console/Commands/AddMonthlyLeaveDaysCommand.php b/BACKEND/app/Console/Commands/AddMonthlyLeaveDaysCommand.php new file mode 100644 index 0000000..f691d15 --- /dev/null +++ b/BACKEND/app/Console/Commands/AddMonthlyLeaveDaysCommand.php @@ -0,0 +1,24 @@ +argument('month'); + $year = $this->argument('year'); + AddMonthlyLeaveDays::dispatch($month, $year); + } +} \ No newline at end of file diff --git a/BACKEND/app/Console/Commands/InitializeLeaveDaysCommand.php b/BACKEND/app/Console/Commands/InitializeLeaveDaysCommand.php index ad0a9e9..bf74a72 100644 --- a/BACKEND/app/Console/Commands/InitializeLeaveDaysCommand.php +++ b/BACKEND/app/Console/Commands/InitializeLeaveDaysCommand.php @@ -8,7 +8,7 @@ use App\Jobs\InitializeLeaveDays; class InitializeLeaveDaysCommand extends Command { protected $signature = 'initialize:leavedays {year?}'; - protected $description = 'Initialize leave days for users'; + protected $description = 'Cấp phép năm cho tất cả người dùng'; public function __construct() { @@ -18,6 +18,7 @@ class InitializeLeaveDaysCommand extends Command public function handle() { $year = $this->argument('year'); - InitializeLeaveDays::dispatch($year); + // Không sử dụng nữa, theo rule mới + // InitializeLeaveDays::dispatch($year); } } diff --git a/BACKEND/app/Console/Kernel.php b/BACKEND/app/Console/Kernel.php index 6d56e12..d8fa86b 100755 --- a/BACKEND/app/Console/Kernel.php +++ b/BACKEND/app/Console/Kernel.php @@ -3,6 +3,7 @@ namespace App\Console; use App\Jobs\DeductLeaveDays; +use App\Jobs\AddMonthlyLeaveDays; use Illuminate\Console\Scheduling\Schedule; use Illuminate\Foundation\Console\Kernel as ConsoleKernel; @@ -24,7 +25,7 @@ class Kernel extends ConsoleKernel // ->dailyAt('18:00'); // Chạy command vào ngày 31/12 lúc 23:59:59 mỗi năm - $schedule->command('initialize:leavedays')->yearlyOn(12, 31, '23:59:59'); + // $schedule->command('initialize:leavedays')->yearlyOn(12, 31, '23:59:59'); $schedule->command('leave:deduct')->yearlyOn(3, 31, '23:59:59'); // Chạy buổi sáng lúc 12:00 @@ -32,6 +33,9 @@ class Kernel extends ConsoleKernel // Chạy buổi chiều lúc 17:30 $schedule->command('attendance:check C')->dailyAt('17:30'); + + // Chạy vào 00:01 ngày đầu tiên của mỗi tháng + $schedule->command('add:monthly-leavedays')->monthlyOn(1, '00:01'); } /** diff --git a/BACKEND/app/Exports/LeaveManagementExport.php b/BACKEND/app/Exports/LeaveManagementExport.php index 038ade9..402353b 100644 --- a/BACKEND/app/Exports/LeaveManagementExport.php +++ b/BACKEND/app/Exports/LeaveManagementExport.php @@ -41,7 +41,7 @@ class LeaveManagementExport implements FromArray, WithHeadings, WithStyles, With $stt = 0; foreach ($this->data as $index => $user) { $totalDayOff = 0; - $totalDayLeave = $user['leaveDay']['ld_day'] + $user['leaveDay']['ld_date_additional']; + $totalDayLeave = $user['leaveDay']['ld_day_total'] + $user['leaveDay']['ld_additional_day']; // Tính tổng ngày nghỉ theo tháng $monthlyLeaves = array_fill(1, 12, 0); diff --git a/BACKEND/app/Jobs/AddMonthlyLeaveDays.php b/BACKEND/app/Jobs/AddMonthlyLeaveDays.php new file mode 100644 index 0000000..4c58b9f --- /dev/null +++ b/BACKEND/app/Jobs/AddMonthlyLeaveDays.php @@ -0,0 +1,69 @@ +month = $month ?? Carbon::now()->month; + $this->year = $year ?? Carbon::now()->year; + } + + public function handle(): void + { + $users = User::get(); + + foreach ($users as $user) { + $leaveDay = LeaveDays::where('ld_user_id', $user->id) + ->where('ld_year', $this->year) + ->first(); + + if (!$leaveDay) { + // Nếu chưa có dữ liệu năm hiện tại, tạo mới + // Số ngày phép bằng với tháng hiện tại + $leaveDay = new LeaveDays([ + 'ld_user_id' => $user->id, + 'ld_day_total' => $this->month, // Số ngày phép bằng tháng hiện tại + 'ld_year' => $this->year, + 'ld_additional_day' => 0, + 'ld_note' => 'Khởi tạo ngày phép đến tháng ' . $this->month, + 'ld_special_leave_day' => 0, + ]); + $leaveDay->save(); + } else { + // Kiểm tra nếu số ngày phép hiện tại nhỏ hơn tháng hiện tại + if ($leaveDay->ld_day_total < $this->month) { + // Cập nhật số ngày phép bằng với tháng hiện tại + $oldDays = $leaveDay->ld_day_total; + $leaveDay->ld_day_total = $this->month; + + // Xử lý ghi chú + $newNote = "Cập nhật ngày phép đến tháng " . $this->month; + if (!empty($leaveDay->ld_note)) { + // Nếu đã có ghi chú, thêm ghi chú mới vào và xuống dòng + $leaveDay->ld_note = $leaveDay->ld_note . "\n" . $newNote; + } else { + // Nếu chưa có ghi chú, gán ghi chú mới + $leaveDay->ld_note = $newNote; + } + $leaveDay->save(); + } + } + } + } +} diff --git a/BACKEND/app/Jobs/DeductLeaveDays.php b/BACKEND/app/Jobs/DeductLeaveDays.php index 86dc3b0..2d4bd4a 100644 --- a/BACKEND/app/Jobs/DeductLeaveDays.php +++ b/BACKEND/app/Jobs/DeductLeaveDays.php @@ -36,7 +36,7 @@ class DeductLeaveDays implements ShouldQueue foreach ($users as $user) { $existingData = LeaveDays::where('ld_user_id', $user->id) ->where('ld_year', $this->year) - ->where('ld_date_additional', ">", 0) + ->where('ld_additional_day', ">", 0) ->first(); if (!$existingData) { continue; @@ -59,11 +59,11 @@ class DeductLeaveDays implements ShouldQueue if ($totalLeaveDaysByMonth) { //Nếu ngày phép thừa năm trước chưa sử dụng hết => cập nhật lại ngày đó (Ngày tồn đọng - ngày sử dụng) - if ($existingData->ld_date_additional > $totalLeaveDaysByMonth->leave_days) { + if ($existingData->ld_additional_day > $totalLeaveDaysByMonth->leave_days) { LeaveDays::where('ld_year', $this->year) ->where('ld_user_id', $user->id) ->update([ - 'ld_date_additional' => $totalLeaveDaysByMonth->leave_days, + 'ld_additional_day' => $totalLeaveDaysByMonth->leave_days, ]); } } else { @@ -71,7 +71,7 @@ class DeductLeaveDays implements ShouldQueue LeaveDays::where('ld_year', $this->year) ->where('ld_user_id', $user->id) ->update([ - 'ld_date_additional' => "0", + 'ld_additional_day' => "0", ]); } } diff --git a/BACKEND/app/Jobs/InitializeLeaveDays.php b/BACKEND/app/Jobs/InitializeLeaveDays.php index 3c545b5..1d7569f 100644 --- a/BACKEND/app/Jobs/InitializeLeaveDays.php +++ b/BACKEND/app/Jobs/InitializeLeaveDays.php @@ -34,7 +34,7 @@ class InitializeLeaveDays implements ShouldQueue public function handle(): void { $users = User::get(); - $ld_day = 12; + $ld_day_total = 12; foreach ($users as $user) { // Kiểm tra xem dữ liệu của user này đã tồn tại cho năm hiện tại chưa $existingData = LeaveDays::where('ld_user_id', $user->id) @@ -51,11 +51,11 @@ class InitializeLeaveDays implements ShouldQueue ->where('ld_year', $this->year - 1) ->first(); - $ld_date_additional = 0; + $ld_additional_day = 0; $ld_note = ''; if ($previousYearData) { - $ld_date_additional = $previousYearData->ld_day + $previousYearData->ld_date_additional; + $ld_additional_day = $previousYearData->ld_day_total + $previousYearData->ld_additional_day; $totalLeaveDaysByMonth = Notes::join('categories', function ($join) { $join->on('notes.n_time_type', '=', 'categories.c_code') ->where('categories.c_type', 'TIME_TYPE'); @@ -71,9 +71,9 @@ class InitializeLeaveDays implements ShouldQueue ->groupBy(DB::raw('notes.n_year')) ->first(); if ($totalLeaveDaysByMonth) { - $ld_date_additional = $ld_date_additional - $totalLeaveDaysByMonth->leave_days; - if ($ld_date_additional < 0) { - $ld_date_additional = 0; + $ld_additional_day = $ld_additional_day - $totalLeaveDaysByMonth->leave_days; + if ($ld_additional_day < 0) { + $ld_additional_day = 0; } } $ld_note = 'Cộng dồn ngày phép năm cũ'; @@ -82,9 +82,9 @@ class InitializeLeaveDays implements ShouldQueue // Tạo dữ liệu cho năm hiện tại LeaveDays::insert([ 'ld_user_id' => $user->id, - 'ld_day' => $ld_day, + 'ld_day_total' => $ld_day_total, 'ld_year' => $this->year, - 'ld_date_additional' => $ld_date_additional, + 'ld_additional_day' => $ld_additional_day, 'ld_note' => $ld_note, 'created_at' => now(), 'updated_at' => now(), diff --git a/BACKEND/app/Models/Files.php b/BACKEND/app/Models/Files.php new file mode 100644 index 0000000..64be814 --- /dev/null +++ b/BACKEND/app/Models/Files.php @@ -0,0 +1,25 @@ +belongsTo(User::class); + } +} diff --git a/BACKEND/app/Models/LeaveDays.php b/BACKEND/app/Models/LeaveDays.php index 67b2024..a974950 100644 --- a/BACKEND/app/Models/LeaveDays.php +++ b/BACKEND/app/Models/LeaveDays.php @@ -10,7 +10,7 @@ class LeaveDays extends Model use HasFactory; protected $fillable = [ - 'id', 'ld_user_id', 'ld_day', 'ld_year', 'ld_date_additional', 'ld_note' + 'id', 'ld_user_id', 'ld_day_total', 'ld_year', 'ld_additional_day', 'ld_note' ]; protected $table = 'leave_days'; diff --git a/BACKEND/app/Models/Notes.php b/BACKEND/app/Models/Notes.php index 57e4e49..659fcda 100644 --- a/BACKEND/app/Models/Notes.php +++ b/BACKEND/app/Models/Notes.php @@ -11,7 +11,13 @@ class Notes extends Model use HasFactory; protected $fillable = [ - 'n_user_id', 'n_day', 'n_month', 'n_year', 'n_time_type', 'n_reason', 'n_note', + 'n_user_id', + 'n_day', + 'n_month', + 'n_year', + 'n_time_type', + 'n_reason', + 'n_note', ]; /** @@ -25,7 +31,7 @@ class Notes extends Model { return self::leftJoin("categories as reason", function ($join) { $join->on('n_reason', '=', 'reason.c_code'); - $join->on('reason.c_type', DB::raw("CONCAT('REASON')")); + $join->on('reason.c_type', DB::raw("CONCAT('REASON_NOTES')")); }) ->leftJoin("categories as timeTypes", function ($join) { $join->on('n_time_type', '=', 'timeTypes.c_code'); @@ -47,4 +53,18 @@ class Notes extends Model ) ->get(); } + + public static function getNotesByMonthAndYearAndUserId($month, $year, $userId, $idNote) + { + return self::where('n_reason', 'ONLEAVE')->where('n_month', $month)->where('n_year', $year) + ->where('n_user_id', $userId) + ->where('id', '!=', $idNote)->get(); + } + + public static function getNotesByMonthAndYearAndUserIdAndReason($month, $year, $userId, $reason) + { + return self::where('n_reason', $reason)->where('n_month', $month)->where('n_year', $year) + ->where('n_user_id', $userId) + ->orderBy('n_day', 'asc')->orderBy('n_time_type', 'desc')->get(); + } } diff --git a/BACKEND/database/migrations/2025_03_13_070714_rename_ld_day_to_ld_day_total_in_leave_days_table.php b/BACKEND/database/migrations/2025_03_13_070714_rename_ld_day_to_ld_day_total_in_leave_days_table.php new file mode 100644 index 0000000..51b4754 --- /dev/null +++ b/BACKEND/database/migrations/2025_03_13_070714_rename_ld_day_to_ld_day_total_in_leave_days_table.php @@ -0,0 +1,22 @@ +renameColumn('ld_day_total', 'ld_day_total'); + }); + } + + public function down() + { + Schema::table('leave_days', function (Blueprint $table) { + $table->renameColumn('ld_day_total', 'ld_day_total'); + }); + } +} diff --git a/BACKEND/database/migrations/2025_03_13_075133_add_ld_special_leave_day_to_leave_days_table.php b/BACKEND/database/migrations/2025_03_13_075133_add_ld_special_leave_day_to_leave_days_table.php new file mode 100644 index 0000000..863d79f --- /dev/null +++ b/BACKEND/database/migrations/2025_03_13_075133_add_ld_special_leave_day_to_leave_days_table.php @@ -0,0 +1,22 @@ +float('ld_special_leave_day')->default(0); // Adding the new field + }); + } + + public function down() + { + Schema::table('leave_days', function (Blueprint $table) { + $table->dropColumn('ld_special_leave_day'); // Dropping the field if needed + }); + } +} diff --git a/BACKEND/database/migrations/2025_03_13_075235_rename_ld_date_additional_to_ld_additional_day_in_leave_days_table.php b/BACKEND/database/migrations/2025_03_13_075235_rename_ld_date_additional_to_ld_additional_day_in_leave_days_table.php new file mode 100644 index 0000000..b6be1ae --- /dev/null +++ b/BACKEND/database/migrations/2025_03_13_075235_rename_ld_date_additional_to_ld_additional_day_in_leave_days_table.php @@ -0,0 +1,22 @@ +renameColumn('ld_date_additional', 'ld_additional_day'); + }); + } + + public function down() + { + Schema::table('leave_days', function (Blueprint $table) { + $table->renameColumn('ld_date_additional', 'ld_additional_day'); + }); + } +} \ No newline at end of file diff --git a/BACKEND/database/migrations/2025_03_13_083500_update_name_in_categories_table.php b/BACKEND/database/migrations/2025_03_13_083500_update_name_in_categories_table.php new file mode 100644 index 0000000..9cd370f --- /dev/null +++ b/BACKEND/database/migrations/2025_03_13_083500_update_name_in_categories_table.php @@ -0,0 +1,41 @@ +insert([ + [ + 'c_code' => 'LEAVE_WITHOUT_PAY', + 'c_name' => 'Nghỉ không hưởng lương', + 'c_type' => 'REASON', + 'c_value' => "", + 'c_active' => 1, + 'created_at' => now(), + 'updated_at' => now(), + ], + ]); + + DB::table('categories') + ->where('c_name', 'Nghỉ phép') + ->update(['c_name' => 'Nghỉ phép năm']); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + DB::table('categories') + ->where('c_name', 'Nghỉ phép năm') + ->update(['c_name' => 'Nghỉ phép']); + } +}; diff --git a/BACKEND/database/migrations/2025_03_27_131933_create_files_table.php b/BACKEND/database/migrations/2025_03_27_131933_create_files_table.php new file mode 100644 index 0000000..0951ec1 --- /dev/null +++ b/BACKEND/database/migrations/2025_03_27_131933_create_files_table.php @@ -0,0 +1,32 @@ +id(); + $table->string('name'); + $table->string('url'); + $table->string('type'); + $table->text('description')->nullable(); + $table->foreignId('user_id')->constrained('users')->onDelete('cascade'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('files'); + } +}; diff --git a/BACKEND/database/migrations/2025_05_07_023335_update_leave_categories.php b/BACKEND/database/migrations/2025_05_07_023335_update_leave_categories.php new file mode 100644 index 0000000..ddb1b92 --- /dev/null +++ b/BACKEND/database/migrations/2025_05_07_023335_update_leave_categories.php @@ -0,0 +1,52 @@ +where('c_type', 'REASON') + ->where('c_code', 'LEAVE_WITHOUT_PAY') + ->delete(); + + // Cập nhật tên "Nghỉ phép năm" thành "Nghỉ phép" + DB::table('categories') + ->where('c_name', 'Nghỉ phép năm') + ->update(['c_name' => 'Nghỉ phép']); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + // Khôi phục item đã xóa + DB::table('categories')->insert([ + 'c_code' => 'LEAVE_WITHOUT_PAY', + 'c_name' => 'Không phép', + 'c_type' => 'REASON', + 'c_value' => "", + 'c_active' => 1, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + // Khôi phục tên cũ + DB::table('categories') + ->where('c_name', 'Nghỉ phép') + ->update(['c_name' => 'Nghỉ phép năm']); + } +} diff --git a/BACKEND/database/migrations/2025_05_07_024806_add_limit_leave_month_category.php b/BACKEND/database/migrations/2025_05_07_024806_add_limit_leave_month_category.php new file mode 100644 index 0000000..91cb49f --- /dev/null +++ b/BACKEND/database/migrations/2025_05_07_024806_add_limit_leave_month_category.php @@ -0,0 +1,40 @@ +insert([ + 'c_code' => 'LIMIT', + 'c_name' => 'Giới hạn số ngày nghỉ có phép/tháng', + 'c_type' => 'LIMIT_LEAVE_MONTH', + 'c_value' => '3', + 'c_active' => 1, + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + DB::table('categories') + ->where('c_code', 'LIMIT') + ->where('c_type', 'LIMIT_LEAVE_MONTH') + ->delete(); + } +} diff --git a/BACKEND/database/migrations/2025_05_07_024949_add_saturday_work_schedule_category.php b/BACKEND/database/migrations/2025_05_07_024949_add_saturday_work_schedule_category.php new file mode 100644 index 0000000..c0e51c8 --- /dev/null +++ b/BACKEND/database/migrations/2025_05_07_024949_add_saturday_work_schedule_category.php @@ -0,0 +1,40 @@ +insert([ + 'c_code' => '10-05-2025', + 'c_name' => 'Ngày bắt đầu làm việc thứ 7 trong năm', + 'c_type' => 'SATURDAY_WORK_SCHEDULE', + 'c_value' => '2025', + 'c_active' => 1, + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + DB::table('categories') + ->where('c_code', '10-05-2025') + ->where('c_type', 'SATURDAY_WORK_SCHEDULE') + ->delete(); + } +} diff --git a/BACKEND/database/migrations/2025_05_07_062606_add_day_work_special_category.php b/BACKEND/database/migrations/2025_05_07_062606_add_day_work_special_category.php new file mode 100644 index 0000000..c73131d --- /dev/null +++ b/BACKEND/database/migrations/2025_05_07_062606_add_day_work_special_category.php @@ -0,0 +1,40 @@ +insert([ + 'c_code' => '17-05-2025', + 'c_name' => 'Ngày làm việc đặc biệt', + 'c_type' => 'DAY_WORK_SPECIAL', + 'c_value' => '2025', + 'c_active' => 1, + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + DB::table('categories') + ->where('c_code', '17-05-2025') + ->where('c_type', 'DAY_WORK_SPECIAL') + ->delete(); + } +} diff --git a/BACKEND/database/migrations/2025_05_07_092903_add_leave_categories.php b/BACKEND/database/migrations/2025_05_07_092903_add_leave_categories.php new file mode 100644 index 0000000..30ffb7f --- /dev/null +++ b/BACKEND/database/migrations/2025_05_07_092903_add_leave_categories.php @@ -0,0 +1,60 @@ +insert([ + [ + 'c_code' => 'LEAVE_WITHOUT_PAY', + 'c_name' => 'Không phép', + 'c_type' => 'REASON_NOTES', + 'c_value' => "", + 'c_active' => 1, + 'created_at' => now(), + 'updated_at' => now(), + ], + [ + 'c_code' => 'WFH', + 'c_name' => 'Work From Home', + 'c_type' => 'REASON_NOTES', + 'c_value' => "", + 'c_active' => 1, + 'created_at' => now(), + 'updated_at' => now(), + ], + [ + 'c_code' => 'ONLEAVE', + 'c_name' => 'Nghỉ phép', + 'c_type' => 'REASON_NOTES', + 'c_value' => "", + 'c_active' => 1, + 'created_at' => now(), + 'updated_at' => now(), + ], + ]); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + DB::table('categories') + ->whereIn('c_code', ['LEAVE_WITHOUT_PAY', 'WFH', 'ONLEAVE']) + ->where('c_type', 'REASON_NOTES') + ->delete(); + } +} diff --git a/BACKEND/database/seeders/DatabaseSeeder.php b/BACKEND/database/seeders/DatabaseSeeder.php index a9f4519..c067f12 100755 --- a/BACKEND/database/seeders/DatabaseSeeder.php +++ b/BACKEND/database/seeders/DatabaseSeeder.php @@ -18,5 +18,10 @@ class DatabaseSeeder extends Seeder // 'name' => 'Test User', // 'email' => 'test@example.com', // ]); + + $this->call([ + UserSeeder::class, + FileSeeder::class, + ]); } } diff --git a/BACKEND/resources/views/emails/file_upload_notification.blade.php b/BACKEND/resources/views/emails/file_upload_notification.blade.php new file mode 100644 index 0000000..3bbad65 --- /dev/null +++ b/BACKEND/resources/views/emails/file_upload_notification.blade.php @@ -0,0 +1,72 @@ + + + + + Thông báo tải lên file mới + + + +
+
+

Thông báo tải lên file mới

+
+ +
+

Xin chào {{ $user->name }},

+ +

{{ $description }} ở hệ thống APAC Tech.

+

Note: {{ $note }}

+

Vui lòng kiểm tra ngay thông tin bằng cách nhấn nút bên dưới:

+ +
+ Kiểm tra ngay +
+ +

Trân trọng,
Đội ngũ APAC Tech

+
+ + +
+ + \ No newline at end of file diff --git a/FRONTEND/src/api/Admin.ts b/FRONTEND/src/api/Admin.ts index ef404fe..b1c613f 100755 --- a/FRONTEND/src/api/Admin.ts +++ b/FRONTEND/src/api/Admin.ts @@ -119,3 +119,8 @@ export const deleteDocument = API_URL + 'v1/admin/document/delete' // Download File export const downloadFile = API_URL + 'v1/admin/download-file' + +// Files APIs +export const getFiles = API_URL + 'v1/admin/profile/files' +export const uploadFiles = API_URL + 'v1/admin/profile/upload-files' +export const deleteFileById = API_URL + 'v1/admin/profile/files' diff --git a/FRONTEND/src/pages/AllProfiles/AllProfiles.tsx b/FRONTEND/src/pages/AllProfiles/AllProfiles.tsx index 1893703..9873dda 100644 --- a/FRONTEND/src/pages/AllProfiles/AllProfiles.tsx +++ b/FRONTEND/src/pages/AllProfiles/AllProfiles.tsx @@ -1,498 +1,330 @@ -import { - deleteFile, - getAllFilesInProfiles, - updateProfileFolder, -} from '@/api/Admin' -import { Xdelete } from '@/rtk/helpers/CRUD' +import { getFiles, uploadFiles } from '@/api/Admin' import { get } from '@/rtk/helpers/apiService' import { getAccessToken } from '@/rtk/localStorage' import { Box, Button, - FileInput, + Card, + Collapse, Group, Modal, - RenderTreeNodePayload, Stack, Text, TextInput, - Tooltip, - Tree, + Title, } from '@mantine/core' +import { notifications } from '@mantine/notifications' import { + IconChevronDown, + IconDownload, IconFileTypeDocx, IconFileTypePdf, IconFolder, - IconFolderOpen, - IconFolderX, IconListCheck, IconPhoto, + IconSearch, + IconTrash, } from '@tabler/icons-react' import axios from 'axios' import { useEffect, useState } from 'react' +import FileUploadForm from '../Profile/components/FileUploadForm' import classes from './AllProfiles.module.css' -interface FileIconProps { +interface FileData { + id: number name: string - isFolder: boolean - expanded: boolean + url: string + type: string + description?: string + created_at: string } -type TFileProfile = { - label: string - type: string - value: string - children?: TFileProfile[] +interface GroupedFiles { + [key: string]: FileData[] } const AllProfiles = () => { - const [treeData, setTreeData] = useState([]) - const [cv, setCv] = useState() - const [idCard, setIdCard] = useState() - const [transcript, setTranscript] = useState() - const [universityDiploma, setUniversityDiploma] = useState() - const [otherFiles, setOtherFiles] = useState([{ file: null, type: '' }]) - const [data, setData] = useState([]) - const [currentName, setCurrentName] = useState('') + const [groupedFiles, setGroupedFiles] = useState({}) + const [currentUser, setCurrentUser] = useState('') const [openedProfile, setOpenedProfile] = useState(false) - function FileIcon({ name, isFolder, expanded }: FileIconProps) { - if (name.endsWith('.pdf')) { - return - } + const [selectedFile, setSelectedFile] = useState(null) + const [isLoading, setIsLoading] = useState(false) + const [expandedFolders, setExpandedFolders] = useState<{ + [key: string]: boolean + }>({}) + const [searchTerms, setSearchTerms] = useState<{ [key: string]: string }>({}) - if (name.endsWith('.doc') || name.endsWith('.docx')) { - return - } - - if ( - name.endsWith('.jpg') || - name.endsWith('.png') || - name.endsWith('.jpeg') || - name.endsWith('.webp') - ) { - return - } - - if (isFolder) { - return expanded ? ( - - ) : ( - - ) - } - - return ( - - ) + const toggleFolder = (userName: string) => { + setExpandedFolders((prev) => ({ + ...prev, + [userName]: !prev[userName], + })) } - function Leaf({ - node, - expanded, - hasChildren, - elementProps, - }: RenderTreeNodePayload) { - return ( - - {!node.children ? ( - - - {node.label} - - ) : ( - <> - - {node.label} - - { - setCurrentName(node.label!.toString()) - setOpenedProfile(true) - }} - /> - - - )} - - ) + const getFileIcon = (type: string) => { + switch (type) { + case 'document': + return + case 'image': + return + default: + return + } } - const handleOtherFileChange = ( - index: number, - field: string, - value: File | string, + const handleSubmit = async ( + e: React.FormEvent, + fileName: string, + description: string, + currentUser: string ) => { - const updatedFiles: any = [...otherFiles] - updatedFiles[index][field] = value - setOtherFiles(updatedFiles) - } - - const addOtherFileInput = () => { - setOtherFiles([...otherFiles, { file: null, type: '' }]) - } - - const handleSubmit = async (e: any) => { e.preventDefault() + setIsLoading(true) const formData = new FormData() + if (selectedFile) { + formData.append('file', selectedFile) + formData.append('name', fileName) + formData.append('description', description) + formData.append('user_name', currentUser) - // Append each selected file to FormData - for (let i = 0; i < otherFiles.length; i++) { - if (otherFiles[i].file !== null && otherFiles[i].type !== '') { - formData.append( - 'files[]', - handleChangeFileName(otherFiles[i].file!, `__${otherFiles[i].type}`)!, - ) + try { + const token = await getAccessToken() + const response = await axios.post(uploadFiles, formData, { + headers: { + 'Content-Type': 'multipart/form-data', + Authorization: `Bearer ${token}`, + }, + }) + + if (response.status === 200) { + setSelectedFile(null) + await getAllFiles() + return true + } + return false + } catch (error) { + console.error('Error uploading file:', error) + throw error + } finally { + setIsLoading(false) } } + return false + } - if (cv) { - formData.append('files[]', cv) - } - - if (idCard) { - formData.append('files[]', idCard) - } - - if (transcript) { - formData.append('files[]', transcript) - } - - if (universityDiploma) { - formData.append('files[]', universityDiploma) - } - - formData.append('name', currentName) - - const token = await getAccessToken() + const getAllFiles = async () => { try { - const response = await axios.post(updateProfileFolder, formData, { + const res = await get(getFiles) + if (res.status === true) { + setGroupedFiles(res.data) + } + } catch (error) { + console.log(error) + } + } + + const removeFile = async (id: number) => { + try { + const token = await getAccessToken(); + const response = await axios.delete(`${import.meta.env.VITE_BACKEND_URL}api/v1/admin/profile/files/${id}`, { headers: { - 'Content-Type': 'multipart/form-data', Authorization: `Bearer ${token}`, }, - }) + }); if (response.status === 200) { - getAllFile() - getTree() - setOtherFiles([]) + notifications.show({ + title: 'Thành công', + message: 'Xóa file thành công', + color: 'green', + }); + await getAllFiles(); } } catch (error) { - console.error('Error uploading files', error) + console.log(error); + notifications.show({ + title: 'Lỗi', + message: 'Không thể xóa file', + color: 'red', + }); } } - const getAllFile = async () => { - try { - const res = await get(getAllFilesInProfiles, { - root_folder: '/storage/profiles/' + currentName, - }) - if (res.status === true) { - setData(res.data) - } - } catch (error) { - console.log(error) - } - } - - const removeFile = async (url: string) => { - try { - await Xdelete(deleteFile, { file_url: url }, getAllFile) - getTree() - } catch (error) { - console.log(error) - } - } - const getTree = async () => { - try { - const res = await get(getAllFilesInProfiles, { - root_folder: '/storage/profiles', - }) - if (res.status === true) { - setTreeData(res.data) - } - } catch (error) { - console.log(error) - } - } - - const handleChangeFileName = (e: File, newName: string) => { - const originalFile = e // Get the original file - const extend = originalFile.name.split('.')[1] - if (originalFile) { - const newFileName = `${newName}.${extend}` // Create new file name - const newFile = new File([originalFile], newFileName, { - type: originalFile.type, - }) // Create new file object - - return newFile // Save the new file object for further processing - } - } - - const checkFileExist = (nameField: string) => { - const file = data.find((f) => f.label.includes(nameField)) - return file - } - useEffect(() => { - getTree() + getAllFiles() }, []) - useEffect(() => { - getAllFile() - }, [currentName]) + const filterFiles = (files: FileData[], searchTerm: string) => { + return files.filter( + (file) => + file.name.toLowerCase().includes(searchTerm.toLowerCase()) || + (file.description && + file.description.toLowerCase().includes(searchTerm.toLowerCase())), + ) + } + return (

- Admin/ - Profiles + Admin/ + Files Management

- - } - /> + + + {Object.entries(groupedFiles).map(([userName, files]) => ( + + toggleFolder(userName)} + style={{ cursor: 'pointer' }} + > + + + {userName} + + + + + + + + + } + value={searchTerms[userName] || ''} + onChange={(e) => + setSearchTerms((prev) => ({ + ...prev, + [userName]: e.target.value, + })) + } + onClick={(e) => e.stopPropagation()} + /> + {filterFiles(files, searchTerms[userName] || '') + .sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()) + .map((file: FileData) => ( + + + + {getFileIcon(file.type)} + + + {file.name} + + {file.description && ( + + {file.description} + + )} + + Uploaded:{' '} + {new Date(file.created_at).toLocaleDateString()} + + + + + + + + + + ), + )} + + + + ))} + + { setOpenedProfile(false) + setCurrentUser('') + setSelectedFile(null) }} > -
- - - CV - - {`: ${checkFileExist('cv')?.label}`} - - - - { - 0 - setCv(handleChangeFileName(e!, 'cv')) - }} - accept=".pdf,.doc,.docx" - /> - - CCCD - - {`: ${checkFileExist('idCard')?.label}`} - - - - - { - setIdCard(handleChangeFileName(e!, 'idCard')) - }} - accept=".jpg,.jpeg,.png,.pdf" - /> - - Bảng điểm - - {`: ${checkFileExist('transcript')?.label}`} - - - - { - setTranscript(handleChangeFileName(e!, 'transcript')) - }} - accept=".pdf" - /> - - - Bằng đại học - - {`: ${ - checkFileExist('universityDiploma')?.label - }`} - - - - { - setUniversityDiploma( - handleChangeFileName(e!, 'universityDiploma'), - ) - }} - accept=".pdf,.jpg,.jpeg,.png" - /> - - Danh sách file khác: - - {data - .find((f) => f.label === 'others') - ?.children?.map((c, index) => { - return ( - - - {`${c?.label}`} - - - - ) - })} - - {otherFiles.map((fileInput, index) => ( - - - handleOtherFileChange(index, 'file', file!) - } - w={'30%'} - /> - - handleOtherFileChange( - index, - 'type', - e.currentTarget.value, - ) - } - /> - - ))} - - - - -
+ file && setSelectedFile(file)} + removeFile={removeFile} + isLoading={isLoading} + currentUser={currentUser} + />
diff --git a/FRONTEND/src/pages/LeaveManagement/LeaveManagement.tsx b/FRONTEND/src/pages/LeaveManagement/LeaveManagement.tsx index 1a8b020..640b95b 100644 --- a/FRONTEND/src/pages/LeaveManagement/LeaveManagement.tsx +++ b/FRONTEND/src/pages/LeaveManagement/LeaveManagement.tsx @@ -1,4 +1,9 @@ -import { getLeaveManagement, updateNoteLeave, exportLeaveManagement } from '@/api/Admin' +import { + getLeaveManagement, + updateNoteLeave, + exportLeaveManagement, + // getListMaster, +} from '@/api/Admin' import { update } from '@/rtk/helpers/CRUD' import { get, exportFile } from '@/rtk/helpers/apiService' import { @@ -40,8 +45,9 @@ interface LeaveDay { id: number ld_user_id: number ld_year: number - ld_day: number - ld_date_additional: number + ld_day_total: number + ld_additional_day: number + ld_special_leave_day: number ld_note: string created_at: string | null updated_at: string | null @@ -52,6 +58,7 @@ interface MonthlyLeaveDays { leave_days: number month: number n_user_id: number + reason_code: string reason_name: string time_type_name: string } @@ -62,6 +69,18 @@ interface UserData { monthlyLeaveDays: MonthlyLeaveDays[] } +// interface DataReason { +// id: number +// c_code: string +// c_name: string +// } + +// interface DataTimeType { +// id: number +// c_code: string +// c_name: string +// } + const LeaveManagement = () => { const [opened1, { open: open1, close: close1 }] = useDisclosure(false) const [disableBtn, setDisableBtn] = useState(false) @@ -75,6 +94,7 @@ const LeaveManagement = () => { note: string totalLeave: string dayAdditional: string + specialLeave: string }>({ id: 0, user: { @@ -84,12 +104,53 @@ const LeaveManagement = () => { note: '', totalLeave: '', dayAdditional: '', + specialLeave: '', }) const [data, setData] = useState([]) const [date, setDate] = useState({ year: new Date().getFullYear().toString(), }) + // const [dataTimeType, setDataTimeType] = useState([]) + // const [dataReason, setDataReason] = useState([]) + + const now = new Date() + const currentMonth = now.getMonth() + 1 // getMonth() trả về 0-11 + const currentYear = now.getFullYear() + + // const getListMasterByType = async (type: string) => { + // try { + // const params = { + // type: type, + // } + // const res = await get(getListMaster, params) + // if (res.status) { + // return res.data + // } + // } catch (error: any) { + // notifications.show({ + // title: 'Error', + // message: error.message ?? error, + // color: 'red', + // }) + // } + // return [] + // } + + // useEffect(() => { + // const fetchData = async () => { + // const resultTimeType = await getListMasterByType('TIME_TYPE') + // setDataTimeType( + // resultTimeType.filter((item: DataTimeType) => item.c_code !== 'ALL'), + // ) + + // const resultReason = await getListMasterByType('REASON') + // setDataReason(resultReason) + // } + + // fetchData() + // }, []) + const getLeaveList = async () => { try { const res = await get(getLeaveManagement, { @@ -122,6 +183,7 @@ const LeaveManagement = () => { }, totalLeave: string, dayAdditional: string, + specialLeave: string, note: string, ) => { try { @@ -132,6 +194,7 @@ const LeaveManagement = () => { users: users, totalLeave: totalLeave, dayAdditional: dayAdditional, + specialLeave: specialLeave, note: note, }, getLeaveList, @@ -237,8 +300,8 @@ const LeaveManagement = () => {
{isNewMonth &&

Month {lastmonth}

}

- - {itemDay.reason_name} ({itemDay.time_type_name}) {itemDay.day} - /{itemDay.month} + - {itemDay.reason_name} ({itemDay.time_type_name}) {itemDay.day}/ + {itemDay.month}

) @@ -253,11 +316,10 @@ const LeaveManagement = () => { await exportFile( exportLeaveManagement, { - year: parseInt(date.year) + year: parseInt(date.year), }, - fileName + fileName, ) - } catch (error) { console.error('Export error:', error) notifications.show({ @@ -271,9 +333,7 @@ const LeaveManagement = () => { return (
-

- Leave Management -

+

Leave Management

{ }) } }} - label={'Total Leave'} - placeholder="Input placeholder" + label={'Phép năm'} + placeholder="Nhập số ngày phép năm" /> { }) } }} - label={'Day additional leave'} - placeholder="Input placeholder" + label={'Phép năm cũ'} + placeholder="Nhập số ngày phép năm cũ" + /> + { + const value = e.target.value + if (value) { + const floatValue = parseFloat(value) + if ( + /^\d*\.?\d?$/.test(value) && + floatValue >= 0 && + floatValue <= 20 + ) { + setCustomAddNotes({ + ...customAddNotes, + specialLeave: value, + }) + } + } else { + setCustomAddNotes({ + ...customAddNotes, + specialLeave: '', + }) + } + }} + label={'Phép đặc biệt'} + placeholder="Nhập số ngày phép đặc biệt" /> - +