This commit is contained in:
dbdbd9 2025-06-10 10:05:47 +07:00
commit fa47d32b07
38 changed files with 2850 additions and 824 deletions

View File

@ -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;
}
}

View File

@ -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();

View File

@ -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';
}
}

View File

@ -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ố 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ỉ') . "<br>";
}
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;
}
}

View File

@ -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(

View File

@ -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([

View File

@ -0,0 +1,24 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use App\Jobs\AddMonthlyLeaveDays;
class AddMonthlyLeaveDaysCommand extends Command
{
protected $signature = 'add:monthly-leavedays {month?} {year?}';
protected $description = 'Cộng 1 ngày phép hàng tháng cho tất cả người dùng';
public function __construct()
{
parent::__construct();
}
public function handle()
{
$month = $this->argument('month');
$year = $this->argument('year');
AddMonthlyLeaveDays::dispatch($month, $year);
}
}

View File

@ -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);
}
}

View File

@ -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');
}
/**

View File

@ -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);

View File

@ -0,0 +1,69 @@
<?php
namespace App\Jobs;
use App\Models\LeaveDays;
use App\Models\User;
use Carbon\Carbon;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class AddMonthlyLeaveDays implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $month;
protected $year;
public function __construct($month = null, $year = null)
{
$this->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();
}
}
}
}
}

View File

@ -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",
]);
}
}

View File

@ -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(),

View File

@ -0,0 +1,25 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Files extends Model
{
use HasFactory;
protected $fillable = [
'name',
'url',
'type',
'description',
'user_id'
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

View File

@ -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';

View File

@ -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();
}
}

View File

@ -0,0 +1,22 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class RenameLdDayToLdDayTotalInLeaveDaysTable extends Migration
{
public function up()
{
Schema::table('leave_days', function (Blueprint $table) {
$table->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');
});
}
}

View File

@ -0,0 +1,22 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class AddLdSpecialLeaveDayToLeaveDaysTable extends Migration
{
public function up()
{
Schema::table('leave_days', function (Blueprint $table) {
$table->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
});
}
}

View File

@ -0,0 +1,22 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class RenameLdDateAdditionalToLdAdditionalDayInLeaveDaysTable extends Migration
{
public function up()
{
Schema::table('leave_days', function (Blueprint $table) {
$table->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');
});
}
}

View File

@ -0,0 +1,41 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
DB::table('categories')->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']);
}
};

View File

@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('files', function (Blueprint $table) {
$table->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');
}
};

View File

@ -0,0 +1,52 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\DB;
class UpdateLeaveCategories extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
// Xóa item với type REASON và code LEAVE_WITHOUT_PAY
DB::table('categories')
->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']);
}
}

View File

@ -0,0 +1,40 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\DB;
class AddLimitLeaveMonthCategory extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
DB::table('categories')->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();
}
}

View File

@ -0,0 +1,40 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\DB;
class AddSaturdayWorkScheduleCategory extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
DB::table('categories')->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();
}
}

View File

@ -0,0 +1,40 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\DB;
class AddDayWorkSpecialCategory extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
DB::table('categories')->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();
}
}

View File

@ -0,0 +1,60 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\DB;
class AddLeaveCategories extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
DB::table('categories')->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();
}
}

View File

@ -18,5 +18,10 @@ class DatabaseSeeder extends Seeder
// 'name' => 'Test User',
// 'email' => 'test@example.com',
// ]);
$this->call([
UserSeeder::class,
FileSeeder::class,
]);
}
}

View File

@ -0,0 +1,72 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Thông báo tải lên file mới</title>
<style>
body {
font-family: Arial, sans-serif;
line-height: 1.6;
color: #333;
margin: 0;
padding: 0;
}
.container {
max-width: 600px;
margin: 0 auto;
padding: 20px;
}
.header {
background-color: #1a73e8;
color: white;
padding: 20px;
text-align: center;
}
.content {
padding: 20px;
background-color: #f9f9f9;
}
.button {
display: inline-block;
padding: 12px 24px;
background-color: #1a73e8;
color: white;
text-decoration: none;
border-radius: 4px;
margin: 20px 0;
}
.footer {
text-align: center;
padding: 20px;
background-color: #f5f5f5;
font-size: 12px;
color: #666;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>Thông báo tải lên file mới</h1>
</div>
<div class="content">
<p>Xin chào {{ $user->name }},</p>
<p>{{ $description }} hệ thống APAC Tech.</p>
<p>Note: {{ $note }}</p>
<p>Vui lòng kiểm tra ngay thông tin bằng cách nhấn nút bên dưới:</p>
<div style="text-align: center;">
<a href="{{ $url }}" class="button">Kiểm tra ngay</a>
</div>
<p>Trân trọng,<br>Đội ngũ APAC Tech</p>
</div>
<div class="footer">
<p>Email này được gửi tự động từ hệ thống APAC Tech. Vui lòng không trả lời email này.</p>
</div>
</div>
</body>
</html>

View File

@ -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'

View File

@ -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<File>()
const [idCard, setIdCard] = useState<File>()
const [transcript, setTranscript] = useState<File>()
const [universityDiploma, setUniversityDiploma] = useState<File>()
const [otherFiles, setOtherFiles] = useState([{ file: null, type: '' }])
const [data, setData] = useState<TFileProfile[]>([])
const [currentName, setCurrentName] = useState<string>('')
const [groupedFiles, setGroupedFiles] = useState<GroupedFiles>({})
const [currentUser, setCurrentUser] = useState<string>('')
const [openedProfile, setOpenedProfile] = useState(false)
function FileIcon({ name, isFolder, expanded }: FileIconProps) {
if (name.endsWith('.pdf')) {
return <IconFileTypePdf size={14} />
}
const [selectedFile, setSelectedFile] = useState<File | null>(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 <IconFileTypeDocx size={14} />
}
if (
name.endsWith('.jpg') ||
name.endsWith('.png') ||
name.endsWith('.jpeg') ||
name.endsWith('.webp')
) {
return <IconPhoto size={14} />
}
if (isFolder) {
return expanded ? (
<IconFolderOpen
color="var(--mantine-color-yellow-9)"
size={14}
stroke={2.5}
/>
) : (
<IconFolder
color="var(--mantine-color-yellow-9)"
size={14}
stroke={2.5}
/>
)
}
return (
<IconFolderX color="var(--mantine-color-red-9)" size={14} stroke={2.5} />
)
const toggleFolder = (userName: string) => {
setExpandedFolders((prev) => ({
...prev,
[userName]: !prev[userName],
}))
}
function Leaf({
node,
expanded,
hasChildren,
elementProps,
}: RenderTreeNodePayload) {
return (
<Group gap={5} {...elementProps}>
{!node.children ? (
<a href={node.value} target="_blank">
<FileIcon
name={node.value}
isFolder={hasChildren}
expanded={expanded}
/>
<span>{node.label}</span>
</a>
) : (
<>
<FileIcon
name={node.value}
isFolder={hasChildren}
expanded={expanded}
/>
<span>{node.label}</span>
<Tooltip label="Upload">
<IconListCheck
color="green"
width={15}
height={15}
style={{
display: node.label !== 'others' ? 'block' : 'none',
cursor: 'pointer',
zIndex: 100000,
}}
onClick={() => {
setCurrentName(node.label!.toString())
setOpenedProfile(true)
}}
/>
</Tooltip>
</>
)}
</Group>
)
const getFileIcon = (type: string) => {
switch (type) {
case 'document':
return <IconFileTypeDocx size={16} />
case 'image':
return <IconPhoto size={16} />
default:
return <IconFileTypePdf size={16} />
}
}
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 (
<div>
<div className={classes.title}>
<h3>
<Text>Admin/</Text>
Profiles
<Text size="sm">Admin/</Text>
Files Management
</h3>
</div>
<Box ml={'lg'}>
<Tree
classNames={classes}
selectOnClick
clearSelectionOnOutsideClick
data={treeData}
renderNode={(payload) => <Leaf {...payload} />}
/>
<Box ml={'md'}>
<Stack gap="xs">
{Object.entries(groupedFiles).map(([userName, files]) => (
<Card key={userName} shadow="xs" radius="sm" withBorder p="xs">
<Group
justify="space-between"
mb="xs"
gap="xs"
onClick={() => toggleFolder(userName)}
style={{ cursor: 'pointer' }}
>
<Group gap="xs">
<IconFolder size={18} color="var(--mantine-color-yellow-9)" />
<Title order={5}>{userName}</Title>
</Group>
<Group gap="xs">
<Button
size="xs"
variant="subtle"
color="gray"
onClick={() => toggleFolder(userName)}
leftSection={
<IconChevronDown
size={14}
style={{
transform: expandedFolders[userName]
? 'rotate(180deg)'
: 'none',
transition: 'transform 0.2s',
}}
/>
}
>
{expandedFolders[userName] ? 'Collapse' : 'Expand'}
</Button>
<Button
size="xs"
variant="light"
color="blue"
onClick={() => {
setCurrentUser(userName)
setOpenedProfile(true)
}}
>
<Group gap={2}>
<IconListCheck size={14} />
<Text size="xs">Upload Files</Text>
</Group>
</Button>
</Group>
</Group>
<Collapse in={expandedFolders[userName]}>
<Stack gap="xs">
<TextInput
placeholder="Search files by name or description..."
size="xs"
leftSection={<IconSearch size={14} />}
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) => (
<Card
key={file.id}
shadow="xs"
padding="xs"
radius="sm"
withBorder
>
<Group justify="space-between" gap="xs">
<Group gap="xs">
{getFileIcon(file.type)}
<Box>
<Text size="xs" fw={500}>
{file.name}
</Text>
{file.description && (
<Text size="xs" c="dimmed">
{file.description}
</Text>
)}
<Text size="xs" c="dimmed">
Uploaded:{' '}
{new Date(file.created_at).toLocaleDateString()}
</Text>
</Box>
</Group>
<Group gap="xs">
<Button
size="xs"
variant="light"
color="blue"
component='a'
href={`${import.meta.env.VITE_BACKEND_URL}${
import.meta.env.VITE_BACKEND_URL?.includes(
'localhost',
)
? ''
: 'image/'
}${file.url.slice(1)}`}
target="_blank"
>
<Group gap={2}>
<IconDownload size={12} />
<Text size="xs">Download</Text>
</Group>
</Button>
<Button
size="xs"
variant="light"
color="red"
onClick={() => removeFile(file.id)}
>
<Group gap={2}>
<IconTrash size={12} />
<Text size="xs">Delete</Text>
</Group>
</Button>
</Group>
</Group>
</Card>
),
)}
</Stack>
</Collapse>
</Card>
))}
</Stack>
<Modal
size={'lg'}
size="lg"
opened={openedProfile}
onClose={() => {
setOpenedProfile(false)
setCurrentUser('')
setSelectedFile(null)
}}
>
<Box>
<form onSubmit={handleSubmit}>
<Stack>
<Box
style={{ display: checkFileExist('cv') ? 'flex' : 'none' }}
>
<Text>CV</Text>
<a href={checkFileExist('cv')?.value} target="_blank">
<Text>{`: ${checkFileExist('cv')?.label}`}</Text>
</a>
<Button
variant="outline"
size="xs"
color="red"
ml={'sm'}
onClick={() => {
removeFile(checkFileExist('cv')?.value!)
}}
>
Delete
</Button>
</Box>
<FileInput
label={'CV'}
placeholder="Tải lên CV"
style={{ display: checkFileExist('cv') ? 'none' : 'block' }}
onChange={(e) => {
0
setCv(handleChangeFileName(e!, 'cv'))
}}
accept=".pdf,.doc,.docx"
/>
<Box
style={{
display: checkFileExist('idCard') ? 'flex' : 'none',
}}
>
<Text>CCCD</Text>
<a href={checkFileExist('idCard')?.value} target="_blank">
<Text>{`: ${checkFileExist('idCard')?.label}`}</Text>
</a>
<Button
variant="outline"
size="xs"
color="red"
ml={'sm'}
onClick={() => {
removeFile(checkFileExist('idCard')?.value!)
}}
>
Delete
</Button>
</Box>
<FileInput
label="CCCD"
style={{
display: checkFileExist('idCard') ? 'none' : 'block',
}}
placeholder="Tải lên CCCD"
onChange={(e) => {
setIdCard(handleChangeFileName(e!, 'idCard'))
}}
accept=".jpg,.jpeg,.png,.pdf"
/>
<Box
style={{
display: checkFileExist('transcript') ? 'flex' : 'none',
}}
>
<Text>Bảng điểm</Text>
<a href={checkFileExist('transcript')?.value} target="_blank">
<Text>{`: ${checkFileExist('transcript')?.label}`}</Text>
</a>
<Button
variant="outline"
size="xs"
color="red"
ml={'sm'}
onClick={() => {
removeFile(checkFileExist('transcript')?.value!)
}}
>
Delete
</Button>
</Box>
<FileInput
label="Bảng điểm"
style={{
display: checkFileExist('transcript') ? 'none' : 'block',
}}
placeholder="Tải lên bảng điểm"
onChange={(e) => {
setTranscript(handleChangeFileName(e!, 'transcript'))
}}
accept=".pdf"
/>
<Box
style={{
display: checkFileExist('universityDiploma')
? 'flex'
: 'none',
}}
>
<Text>Bằng đi học</Text>
<a
href={checkFileExist('universityDiploma')?.value}
target="_blank"
>
<Text>{`: ${
checkFileExist('universityDiploma')?.label
}`}</Text>
</a>
<Button
variant="outline"
size="xs"
color="red"
ml={'sm'}
onClick={() => {
removeFile(checkFileExist('universityDiploma')?.value!)
}}
>
Delete
</Button>
</Box>
<FileInput
label="Bằng đại học"
style={{
display: checkFileExist('universityDiploma')
? 'none'
: 'block',
}}
placeholder="Tải lên bằng đại học"
onChange={(e) => {
setUniversityDiploma(
handleChangeFileName(e!, 'universityDiploma'),
)
}}
accept=".pdf,.jpg,.jpeg,.png"
/>
<Text>Danh sách file khác:</Text>
<Box>
{data
.find((f) => f.label === 'others')
?.children?.map((c, index) => {
return (
<Box
key={index}
style={{
display: 'flex',
marginBottom: '5px',
}}
>
<a href={c?.value} target="_blank">
<Text>{`${c?.label}`}</Text>
</a>
<Button
variant="outline"
size="xs"
color="red"
ml={'sm'}
onClick={() => {
removeFile(c?.value!)
}}
>
Delete
</Button>
</Box>
)
})}
</Box>
{otherFiles.map((fileInput, index) => (
<Group key={index}>
<FileInput
placeholder="Chọn file"
onChange={(file) =>
handleOtherFileChange(index, 'file', file!)
}
w={'30%'}
/>
<TextInput
placeholder="Nhập tên file (không cần phần mở rộng)"
value={fileInput.type}
w={'65%'}
onChange={(e) =>
handleOtherFileChange(
index,
'type',
e.currentTarget.value,
)
}
/>
</Group>
))}
<Button type="button" onClick={addOtherFileInput}>
Thêm file khác
</Button>
<Button type="submit" color="blue">
Save
</Button>
</Stack>
</form>
<FileUploadForm
data={groupedFiles[currentUser] || []}
handleSubmit={handleSubmit}
handleFileChange={(file) => file && setSelectedFile(file)}
removeFile={removeFile}
isLoading={isLoading}
currentUser={currentUser}
/>
</Box>
</Modal>
</Box>

View File

@ -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<UserData[]>([])
const [date, setDate] = useState({
year: new Date().getFullYear().toString(),
})
// const [dataTimeType, setDataTimeType] = useState<DataTimeType[]>([])
// const [dataReason, setDataReason] = useState<DataReason[]>([])
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 = () => {
<div key={indexDay}>
{isNewMonth && <p>Month {lastmonth}</p>}
<p style={{ paddingLeft: '20px' }}>
- {itemDay.reason_name} ({itemDay.time_type_name}) {itemDay.day}
/{itemDay.month}
- {itemDay.reason_name} ({itemDay.time_type_name}) {itemDay.day}/
{itemDay.month}
</p>
</div>
)
@ -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 (
<div>
<div className={classes.title}>
<h3>
Leave Management
</h3>
<h3>Leave Management</h3>
</div>
<Drawer
opened={opened1}
@ -305,8 +365,8 @@ const LeaveManagement = () => {
})
}
}}
label={'Total Leave'}
placeholder="Input placeholder"
label={'Phép năm'}
placeholder="Nhập số ngày phép năm"
/>
<TextInput
mb={'md'}
@ -332,10 +392,36 @@ const LeaveManagement = () => {
})
}
}}
label={'Day additional leave'}
placeholder="Input placeholder"
label={'Phép năm cũ'}
placeholder="Nhập số ngày phép năm cũ"
/>
<TextInput
mb={'md'}
value={customAddNotes.specialLeave}
onChange={(e) => {
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"
/>
<Textarea
mb={'md'}
label="Note"
@ -368,6 +454,7 @@ const LeaveManagement = () => {
customAddNotes.user,
customAddNotes.totalLeave,
customAddNotes.dayAdditional,
customAddNotes.specialLeave,
customAddNotes.note,
)
}
@ -439,15 +526,23 @@ const LeaveManagement = () => {
>
<Table.Thead>
<Table.Tr bg={'#228be66b'}>
<Table.Th ></Table.Th>
<Table.Th ta={'center'} style={{ width: '40px' }}></Table.Th>
<Table.Th>User</Table.Th>
{monthInYear.map((d) => {
const isCurrentMonth =
Number(date.year) === currentYear && d.value === currentMonth
return (
<Menu width={200} shadow="md" key={d.value}>
<Menu.Target>
<Table.Th
ta={'center'}
style={{ cursor: 'pointer', width: '60px' }}
style={{
cursor: 'pointer',
width: '40px',
backgroundColor: isCurrentMonth ? '#ffe066' : undefined,
color: isCurrentMonth ? '#000' : undefined,
fontWeight: isCurrentMonth ? 'bold' : undefined,
}}
>
<span>{d.name}</span>
</Table.Th>
@ -455,10 +550,10 @@ const LeaveManagement = () => {
</Menu>
)
})}
<Table.Th ta={'center'} style={{ width: '80px' }}>
<Table.Th ta={'center'} style={{ width: '150px' }}>
Total
</Table.Th>
<Table.Th ta={'center'} style={{ width: '80px' }}>
<Table.Th ta={'center'} style={{ width: '130px' }}>
Off
</Table.Th>
<Table.Th ta={'center'} style={{ width: '80px' }}>
@ -472,24 +567,43 @@ const LeaveManagement = () => {
{data.map((user, index) => {
let totalDayOff = 0
let totalDayLeave =
user.leaveDay.ld_day + user.leaveDay.ld_date_additional
user.leaveDay.ld_day_total +
user.leaveDay.ld_additional_day +
user.leaveDay.ld_special_leave_day
let ld_day_total = user.leaveDay.ld_day_total
let ld_additional_day = user.leaveDay.ld_additional_day
let ld_special_leave_day = user.leaveDay.ld_special_leave_day
let ld_note = user.leaveDay.ld_note
let totalOnLeave = 0
let totalLeaveWithoutPay = 0
return (
<Table.Tr key={user.user.id} className={classes.tableTr}>
<Table.Td ta={'center'}>{index + 1}</Table.Td>
<Table.Td>
<Tooltip multiline label={user.user.name}>
<div style={{display:'flex', alignItems:'center'}}><Avatar size={'md'} mr={'md'} src={import.meta.env.VITE_BACKEND_URL.includes('local')
<div style={{ display: 'flex', alignItems: 'center' }}>
<Avatar
size={'md'}
mr={'md'}
src={
import.meta.env.VITE_BACKEND_URL.includes('local')
? import.meta.env.VITE_BACKEND_URL +
'storage/' +
user.user.avatar
: import.meta.env.VITE_BACKEND_URL +
'image/storage/' +
user.user.avatar}/>{user.user.name}</div>
user.user.avatar
}
/>
{user.user.name}
</div>
</Tooltip>
</Table.Td>
{monthInYear.map((d, i) => {
// const isCurrentMonth =
// Number(date.year) === currentYear && d.value === currentMonth
let leaveDataByMonth = getDetailLeaveDay(
user.monthlyLeaveDays,
)
@ -497,6 +611,18 @@ const LeaveManagement = () => {
const monthData = leaveDataByMonth[d.value]
let total = monthData ? monthData.leave_days : 0
totalDayOff = totalDayOff + total
user.monthlyLeaveDays
.filter((item) => item.month === d.value)
.map((item) => {
if (item.reason_code === 'ONLEAVE') {
totalOnLeave = totalOnLeave + Number(item.leave_days)
} else {
totalLeaveWithoutPay =
totalLeaveWithoutPay + Number(item.leave_days)
}
})
return (
<Table.Td
bg={total > 0 ? '#ffb5b5' : ''}
@ -523,35 +649,128 @@ const LeaveManagement = () => {
)
})}
{/* Total */}
<Table.Td
ta={'center'}
bg={totalDayLeave > 0 ? '#92e6f2' : ''}
// bg={totalDayLeave > 0 ? '#92e6f2' : ''}
>
{totalDayLeave}
<p
style={{
// backgroundColor: '#c3ffc3',
display: ld_day_total > 0 ? 'block' : 'none',
}}
>
{'Phép năm:'}{' '}
<span
style={{
backgroundColor: '#c3ffc3',
padding: '5px',
borderRadius: '5px',
fontWeight: 'bold',
color: 'black',
}}
>
{ld_day_total}
</span>
</p>
<p
style={{
// backgroundColor: '#92e6f2',
display: ld_additional_day > 0 ? 'block' : 'none',
}}
>
{'Phép năm cũ:'}{' '}
<span
style={{
backgroundColor: '#92e6f2',
padding: '5px',
borderRadius: '5px',
fontWeight: 'bold',
color: 'black',
}}
>
{ld_additional_day}
</span>
</p>
<p
style={{
display: ld_special_leave_day > 0 ? 'block' : 'none',
}}
>
{'Phép đặc biệt:'}{' '}
<span
style={{
backgroundColor: '#b5cafb',
padding: '5px',
borderRadius: '5px',
fontWeight: 'bold',
color: 'black',
}}
>
{ld_special_leave_day}
</span>
</p>
</Table.Td>
<Table.Td ta={'center'} bg={totalDayOff > 0 ? '#ffb5b5' : ''}>
{/* Off */}
<Table.Td ta={'center'}>
{totalDayOff > 0 ? (
<Tooltip
multiline
label={showAllOff(user.monthlyLeaveDays)}
>
<p> {totalDayOff}</p>
<div>
<p
// style={{ backgroundColor: '#c3ffc3' }}
>
{'Nghỉ phép:'}{' '}
<span
style={{
fontWeight: 'bold',
color: 'black',
backgroundColor: '#c3ffc3',
padding: '5px',
borderRadius: '5px',
}}
>
{totalOnLeave}
</span>
</p>
<p
// style={{ backgroundColor: '#ffb5b5' }}
>
{'Không phép:'}{' '}
<span
style={{
fontWeight: 'bold',
color: 'black',
backgroundColor: '#ffb5b5',
padding: '5px',
borderRadius: '5px',
}}
>
{totalLeaveWithoutPay}
</span>
</p>
</div>
</Tooltip>
) : (
<></>
)}
</Table.Td>
{/* Remaining */}
<Table.Td
ta={'center'}
bg={
totalDayLeave - totalDayOff == 0
totalDayLeave - totalOnLeave == 0
? ''
: totalDayLeave - totalDayOff > 0
: totalDayLeave - totalOnLeave > 0
? '#c3ffc3'
: '#ffb5b5'
}
>
{totalDayLeave - totalDayOff}
{totalDayLeave - totalOnLeave}
</Table.Td>
<Table.Td>
<Box
@ -571,9 +790,12 @@ const LeaveManagement = () => {
</Text>
</HoverCard.Target>
<HoverCard.Dropdown>
<Textarea size="sm" autosize>
{ld_note}
</Textarea>
<Textarea
size="sm"
autosize
value={ld_note}
readOnly
/>
</HoverCard.Dropdown>
</HoverCard>
</Box>
@ -585,13 +807,17 @@ const LeaveManagement = () => {
style={{ cursor: 'pointer' }}
onClick={() => {
let totalLeave =
user.leaveDay.ld_day == 0
user.leaveDay.ld_day_total == 0
? ''
: String(user.leaveDay.ld_day)
: String(user.leaveDay.ld_day_total)
let dayAdditional =
user.leaveDay.ld_date_additional == 0
user.leaveDay.ld_additional_day == 0
? ''
: String(user.leaveDay.ld_date_additional)
: String(user.leaveDay.ld_additional_day)
let specialLeave =
user.leaveDay.ld_special_leave_day == 0
? ''
: String(user.leaveDay.ld_special_leave_day)
open1()
setCustomAddNotes({
...customAddNotes,
@ -599,6 +825,7 @@ const LeaveManagement = () => {
note: ld_note,
totalLeave: totalLeave,
dayAdditional: dayAdditional,
specialLeave: specialLeave,
user: {
id: user.user.id,
name: user.user.name,

View File

@ -1,18 +1,16 @@
import {
deleteFile,
getAllFilesInProfiles,
getFiles,
getProfilesData,
listUserTechnical,
updateProfileFolder,
updateProfilesData,
updateUserTechnical,
uploadFiles
} from '@/api/Admin'
import { changePassword } from '@/api/Auth'
import DataTableAll from '@/components/DataTable/DataTable'
import PasswordRequirementInput from '@/components/PasswordRequirementInput/PasswordRequirementInput'
import ProjectInvolvement from '@/components/ProjectInvolvement/ProjectInvolvement'
import { logout } from '@/rtk/dispatches/auth'
import { Xdelete } from '@/rtk/helpers/CRUD'
import { get, post, postImage } from '@/rtk/helpers/apiService'
import { requirementsPassword } from '@/rtk/helpers/variables'
import { useAppDispatch, useAppSelector } from '@/rtk/hooks'
@ -22,16 +20,13 @@ import {
Avatar,
Box,
Button,
FileInput,
Flex,
Group,
Loader,
Modal,
PasswordInput,
Stack,
Text,
TextInput,
Title,
Title
} from '@mantine/core'
import { notifications } from '@mantine/notifications'
import {
@ -46,15 +41,26 @@ import moment from 'moment'
import { useCallback, useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import classes from './Profile.module.css'
import FileUploadForm from './components/FileUploadForm'
const isCompactMenu = false
type TFileProfile = {
label: string
type: string
value: string
children?: TFileProfile[]
// type TFileProfile = {
// label: string
// type: string
// value: string
// children?: TFileProfile[]
// }
interface FileData {
id: number;
name: string;
url: string;
type: string;
description?: string;
created_at: string;
}
const Profile = () => {
const user = useAppSelector((state) => state.authentication.user)
const userData = getUser()
@ -77,6 +83,11 @@ const Profile = () => {
const navigate = useNavigate()
const dispatch = useAppDispatch()
const [selectedFile, setSelectedFile] = useState<File | null>(null)
const [isLoading, setIsLoading] = useState(false)
const [data, setData] = useState<FileData[]>([])
const [openedProfile, setOpenedProfile] = useState(false)
const updateAvatar = async (file: File) => {
try {
const res = await postImage(updateProfilesData, file, 'post')
@ -137,102 +148,113 @@ const Profile = () => {
return []
}
const [cv, setCv] = useState<File>()
const [idCard, setIdCard] = useState<File>()
const [transcript, setTranscript] = useState<File>()
const [universityDiploma, setUniversityDiploma] = useState<File>()
const [otherFiles, setOtherFiles] = useState([{ file: null, type: '' }])
const [data, setData] = useState<TFileProfile[]>([])
const [openedProfile, setOpenedProfile] = useState(false)
const handleOtherFileChange = (
index: number,
field: string,
value: File | string,
// const [cv, setCv] = useState<File>()
// const [idCard, setIdCard] = useState<File>()
// const [transcript, setTranscript] = useState<File>()
// const [universityDiploma, setUniversityDiploma] = useState<File>()
// const [otherFiles, setOtherFiles] = useState([{ file: null, type: '' }])
// const handleOtherFileChange = (
// index: number,
// field: string,
// value: File | string,
// ) => {
// const updatedFiles: any = [...otherFiles]
// updatedFiles[index][field] = value
// setOtherFiles(updatedFiles)
// }
// const addOtherFileInput = () => {
// setOtherFiles([...otherFiles, { file: null, type: '' }])
// }
const handleSubmit = async (
e: React.FormEvent,
fileName: string,
description: 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()
// 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}`)!,
)
if (selectedFile) {
formData.append('file', selectedFile)
formData.append('name', fileName)
formData.append('description', description)
formData.append('user_name', user.user.name)
formData.append('user_id', user.user.id.toString())
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) {
await getAllFile()
setSelectedFile(null)
}
} catch (error) {
console.error('Error uploading file:', error)
} finally {
setIsLoading(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)
}
const token = await getAccessToken()
try {
const response = await axios.post(updateProfileFolder, formData, {
headers: {
'Content-Type': 'multipart/form-data',
Authorization: `Bearer ${token}`,
},
})
if(response.status === 200){
getAllFile()
setOtherFiles([])
}
} catch (error) {
console.error('Error uploading files', error)
}
}
const getAllFile = async () => {
try {
const res = await get(getAllFilesInProfiles, {
root_folder: '/storage/profiles/' + JSON.parse(getUser())?.user?.name,
})
const res = await get(getFiles)
if (res.status === true) {
setData(res.data)
const userFiles = res.data[user.user.name] || [];
setData(userFiles);
}
} catch (error) {
console.log(error)
notifications.show({
title: 'Lỗi',
message: 'Không thể tải danh sách file',
color: 'red',
})
}
}
const removeFile = async (url: string) => {
const removeFile = async (id: number) => {
try {
await Xdelete(deleteFile, {file_url: url}, getAllFile)
const token = await getAccessToken();
const response = await axios.delete(`${import.meta.env.VITE_BACKEND_URL}api/v1/admin/profile/files/${id}`, {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (response.status === 200) {
notifications.show({
title: 'Thành công',
message: 'Xóa file thành công',
color: 'green',
});
await getAllFile();
}
} catch (error) {
console.log(error)
console.log(error);
notifications.show({
title: 'Lỗi',
message: 'Không thể xóa file',
color: 'red',
});
}
}
};
useEffect(() => {
const fetchData = async () => {
const result = await getListProfilesData()
setDataProfile(result ?? [])
await getAllFile()
}
fetchData()
getAllFile()
}, [])
const handleChangePassword = async () => {
@ -306,23 +328,19 @@ const Profile = () => {
dispatch(logout(navigate))
}, [dispatch, navigate])
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
// const handleChangeFileName = (e: File, newName: string): File => {
// const originalFile = e;
// const extend = originalFile.name.split('.')[1];
// const newFileName = `${newName}.${extend}`;
// return new File([originalFile], newFileName, {
// type: originalFile.type,
// });
// };
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
}
// const checkFileExist = (nameField: string) => {
// const file = data.find((f) => f.name.includes(nameField));
// return file;
// };
return (
<div>
@ -578,162 +596,14 @@ const Profile = () => {
}}
>
<Box>
<form onSubmit={handleSubmit}>
<Stack>
<Box
style={{ display: checkFileExist('cv') ? 'flex' : 'none' }}
>
<Text>CV</Text>
<a href={checkFileExist('cv')?.value} target="_blank">
<Text>{`: ${checkFileExist('cv')?.label}`}</Text>
</a>
<Button variant="outline" size="xs" color="red" ml={'sm'} onClick={()=>{removeFile(checkFileExist('cv')?.value!)}}>
Delete
</Button>
</Box>
<FileInput
label={'CV'}
placeholder="Tải lên CV"
style={{ display: checkFileExist('cv') ? 'none' : 'block' }}
onChange={(e) => {
0
setCv(handleChangeFileName(e!, 'cv'))
}}
accept=".pdf,.doc,.docx"
/>
<Box
style={{
display: checkFileExist('idCard') ? 'flex' : 'none',
}}
>
<Text>CCCD</Text>
<a href={checkFileExist('idCard')?.value} target="_blank">
<Text>{`: ${checkFileExist('idCard')?.label}`}</Text>
</a>
<Button variant="outline" size="xs" color="red" ml={'sm'} onClick={()=>{removeFile(checkFileExist('idCard')?.value!)}}>
Delete
</Button>
</Box>
<FileInput
label="CCCD"
style={{ display: checkFileExist('idCard') ? 'none' : 'block' }}
placeholder="Tải lên CCCD"
onChange={(e) => {
setIdCard(handleChangeFileName(e!, 'idCard'))
}}
accept=".jpg,.jpeg,.png,.pdf"
/>
<Box
style={{
display: checkFileExist('transcript') ? 'flex' : 'none',
}}
>
<Text>Bảng điểm</Text>
<a href={checkFileExist('transcript')?.value} target="_blank">
<Text>{`: ${checkFileExist('transcript')?.label}`}</Text>
</a>
<Button variant="outline" size="xs" color="red" ml={'sm'} onClick={()=>{removeFile(checkFileExist('transcript')?.value!)}}>
Delete
</Button>
</Box>
<FileInput
label="Bảng điểm"
style={{ display: checkFileExist('transcript') ? 'none' : 'block' }}
placeholder="Tải lên bảng điểm"
onChange={(e) => {
setTranscript(handleChangeFileName(e!, 'transcript'))
}}
accept=".pdf"
/>
<Box
style={{
display: checkFileExist('universityDiploma')
? 'flex'
: 'none',
}}
>
<Text>Bằng đi học</Text>
<a
href={checkFileExist('universityDiploma')?.value}
target="_blank"
>
<Text>{`: ${
checkFileExist('universityDiploma')?.label
}`}</Text>
</a>
<Button variant="outline" size="xs" color="red" ml={'sm'} onClick={()=>{removeFile(checkFileExist('universityDiploma')?.value!)}}>
Delete
</Button>
</Box>
<FileInput
label="Bằng đại học"
style={{ display: checkFileExist('universityDiploma') ? 'none' : 'block' }}
placeholder="Tải lên bằng đại học"
onChange={(e) => {
setUniversityDiploma(
handleChangeFileName(e!, 'universityDiploma'),
)
}}
accept=".pdf,.jpg,.jpeg,.png"
/>
<Text>Danh sách file khác:</Text>
<Box>
{data.find((f)=>f.label === 'others')?.children?.map((c, index)=>{
return <Box key={index}
style={{
display: 'flex',
marginBottom:'5px'
}}
>
<a
href={c?.value}
target="_blank"
>
<Text>{`${
c?.label
}`}</Text>
</a>
<Button variant="outline" size="xs" color="red" ml={'sm'} onClick={()=>{removeFile(c?.value!)}}>
Delete
</Button>
</Box>
})}
</Box>
{otherFiles.map((fileInput, index) => (
<Group key={index}>
<FileInput
placeholder="Chọn file"
onChange={(file) =>
handleOtherFileChange(index, 'file', file!)
}
w={'30%'}
/>
<TextInput
placeholder="Nhập tên file (không cần phần mở rộng)"
value={fileInput.type}
w={'65%'}
onChange={(e) =>
handleOtherFileChange(
index,
'type',
e.currentTarget.value,
)
}
/>
</Group>
))}
<Button type="button" onClick={addOtherFileInput}>
Thêm file khác
</Button>
<Button type="submit" color="blue">
Save
</Button>
</Stack>
</form>
<FileUploadForm
data={data}
handleSubmit={handleSubmit}
handleFileChange={(file) => file && setSelectedFile(file)}
removeFile={removeFile}
isLoading={isLoading}
currentUser={user.user.name}
/>
</Box>
</Modal>
</Box>

View File

@ -0,0 +1,121 @@
.fileContainer {
padding: 12px;
margin: 8px 0;
border: 1px solid #e9ecef;
border-radius: 6px;
transition: all 0.2s ease;
display: flex;
flex-direction: column;
gap: 4px;
}
.fileContainer:hover {
background-color: #f8f9fa;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
}
.fileHeader {
display: flex;
justify-content: space-between;
align-items: center;
}
.fileName {
display: flex;
align-items: center;
gap: 8px;
font-weight: 500;
}
.fileDescription {
font-size: 13px;
color: #666;
margin: 2px 0;
}
.fileActions {
display: flex;
gap: 8px;
margin-top: 4px;
}
.fileLink {
color: #228be6;
text-decoration: none;
font-size: 13px;
transition: color 0.2s ease;
padding: 4px 8px;
border-radius: 4px;
background-color: #e7f5ff;
}
.fileLink:hover {
color: #1c7ed6;
background-color: #d0ebff;
}
.deleteButton {
padding: 4px 8px;
font-size: 13px;
}
.fileInputGroup {
padding: 16px;
margin: 16px 0;
border: 2px dashed #e9ecef;
border-radius: 6px;
background-color: #f8f9fa;
}
.fileInput,
.fileNameInput,
.descriptionInput {
margin-bottom: 12px;
}
.saveButton {
margin-top: 16px;
width: 100%;
max-width: 180px;
}
.saveButton:disabled {
background-color: #e9ecef;
cursor: not-allowed;
}
.loadingOverlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(255, 255, 255, 0.8);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.loadingSpinner {
width: 32px;
height: 32px;
border: 3px solid #f3f3f3;
border-top: 3px solid #3498db;
border-radius: 50%;
animation: spin 1s linear infinite;
}
.sectionTitle {
font-size: 16px;
font-weight: 600;
margin-bottom: 12px;
color: #343a40;
padding-bottom: 8px;
border-bottom: 1px solid #e9ecef;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}

View File

@ -0,0 +1,251 @@
import {
Box,
Button,
Card,
FileInput,
Group,
Stack,
Text,
TextInput,
Textarea,
} from '@mantine/core'
import { notifications } from '@mantine/notifications'
import {
IconDownload,
IconFileTypeDocx,
IconFileTypePdf,
IconFileTypeXls,
IconPhoto,
IconSearch,
IconTrash,
} from '@tabler/icons-react'
import { useState } from 'react'
import classes from './FileUploadForm.module.css'
// type TFileProfile = {
// label: string
// type: string
// value: string
// children?: TFileProfile[]
// }
interface FileData {
id: number
name: string
url: string
type: string
description?: string
created_at: string
}
type FileUploadFormProps = {
data: FileData[];
handleSubmit: (e: React.FormEvent, fileName: string, description: string, currentUser: string) => Promise<boolean | void>;
handleFileChange: (file: File | null) => void;
removeFile: (id: number) => Promise<void>;
isLoading: boolean;
currentUser: string;
};
const FileUploadForm = ({
data,
handleSubmit,
handleFileChange,
removeFile,
isLoading,
currentUser,
}: FileUploadFormProps) => {
const [selectedFile, setSelectedFile] = useState<File | null>(null)
const [fileName, setFileName] = useState('')
const [description, setDescription] = useState('')
const [isUploading, setIsUploading] = useState(false)
const [searchTerm, setSearchTerm] = useState('')
const handleFileSelect = (file: File | null) => {
setSelectedFile(file)
handleFileChange(file)
if (file) {
// Set default name as file name without extension
setFileName(file.name.split('.')[0])
}
}
const handleFormSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setIsUploading(true)
try {
await handleSubmit(e, fileName, description, currentUser)
notifications.show({
title: 'Thành công',
message: 'Tải file lên thành công',
color: 'green',
})
setFileName('')
setDescription('')
setSelectedFile(null)
} catch (error) {
console.error('Error submitting form:', error)
notifications.show({
title: 'Lỗi',
message: 'Không thể tải file lên',
color: 'red',
})
} finally {
setIsUploading(false)
}
}
const getFileIcon = (type: string) => {
switch (type) {
case 'document':
return <IconFileTypeDocx size={16} />
case 'image':
return <IconPhoto size={16} />
case 'spreadsheet':
return <IconFileTypeXls size={16} />
default:
return <IconFileTypePdf size={16} />
}
}
const filteredFiles = data.filter(
(file) =>
file.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
(file.description &&
file.description.toLowerCase().includes(searchTerm.toLowerCase())),
)
return (
<>
{isLoading && (
<div className={classes.loadingOverlay}>
<div className={classes.loadingSpinner} />
</div>
)}
<form onSubmit={handleFormSubmit}>
<Box>
<Text className={classes.sectionTitle}>Tài liệu</Text>
<Box className={classes.fileInputGroup}>
<FileInput
label="Chọn file"
placeholder="Chọn file để tải lên"
accept="image/png,image/jpeg,image/jpg,application/pdf,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document,application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,application/vnd.oasis.opendocument.spreadsheet"
onChange={handleFileSelect}
value={selectedFile}
className={classes.fileInput}
/>
<TextInput
label="Tên file"
placeholder="Nhập tên file"
value={fileName}
onChange={(e) => setFileName(e.target.value)}
className={classes.fileNameInput}
required
/>
<Textarea
label="Mô tả"
placeholder="Nhập mô tả cho file"
value={description}
onChange={(e) => setDescription(e.target.value)}
className={classes.descriptionInput}
minRows={3}
/>
<Button
type="submit"
color="blue"
className={classes.saveButton}
disabled={isLoading || isUploading || !selectedFile || !fileName}
loading={isLoading || isUploading}
>
{isLoading || isUploading ? 'Đang xử lý...' : 'Lưu thay đổi'}
</Button>
</Box>
<Box className={classes.fileListContainer}>
<TextInput
placeholder="Tìm kiếm theo tên hoặc mô tả..."
leftSection={<IconSearch size={14} />}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className={classes.searchInput}
/>
<Stack className={classes.fileList} gap="xs">
{filteredFiles.map((file) => (
<Card
key={file.id}
shadow="xs"
padding="xs"
radius="sm"
withBorder
>
<Group justify="space-between" gap="xs" wrap="nowrap">
<Group gap="xs" className={classes.cardContent}>
{getFileIcon(file.type)}
<Box style={{ minWidth: 0 }}>
<Text size="xs" fw={500} className={classes.cardTitle}>
{file.name}
</Text>
{file.description && (
<Text
size="xs"
c="dimmed"
className={classes.cardDescription}
>
{file.description}
</Text>
)}
<Text size="xs" c="dimmed">
Uploaded:{' '}
{new Date(file.created_at).toLocaleDateString()}
</Text>
</Box>
</Group>
<Group gap="xs" wrap="nowrap">
<Button
size="xs"
variant="light"
color="blue"
component="a"
href={`${import.meta.env.VITE_BACKEND_URL}${
import.meta.env.VITE_BACKEND_URL?.includes(
'localhost',
)
? ''
: 'image/'
}${file.url.slice(1)}`}
target="_blank"
>
<Group gap={2}>
<IconDownload size={12} />
</Group>
</Button>
<Button
size="xs"
variant="light"
color="red"
onClick={() => removeFile(file.id)}
>
<Group gap={2}>
<IconTrash size={12} />
</Group>
</Button>
</Group>
</Group>
</Card>
))}
</Stack>
</Box>
</Box>
</form>
</>
)
}
export default FileUploadForm

View File

@ -5,8 +5,8 @@ import {
getTicketsOfUser,
} from '@/api/Admin'
import { DataTablePagination } from '@/components/DataTable/DataTable'
import { Xdelete, create } from '@/rtk/helpers/CRUD'
import { get } from '@/rtk/helpers/apiService'
import { Xdelete } from '@/rtk/helpers/CRUD'
import { get, post } from '@/rtk/helpers/apiService'
import {
Badge,
Box,
@ -26,6 +26,7 @@ import { IconTrash } from '@tabler/icons-react'
import moment from 'moment'
import { useEffect, useState } from 'react'
import classes from './Tickets.module.css'
import { _NOTIFICATION_MESS } from '@/rtk/helpers/notificationMess'
type TTickets = {
id: number
@ -82,6 +83,11 @@ const Tickets = () => {
const [dataTimeType, setDataTimeType] = useState<DataTimeType[]>([])
const [dataReason, setDataReason] = useState<DataReason[]>([])
const [confirmModal, setConfirmModal] = useState(false)
const [confirmMessage, setConfirmMessage] = useState('')
const [confirmValues, setConfirmValues] = useState<TTickets | null>(null)
const [confirmLoading, setConfirmLoading] = useState(false)
const getListMasterByType = async (type: string) => {
try {
const params = {
@ -271,27 +277,61 @@ const Tickets = () => {
}
}
const handleCreate = async (values: TTickets) => {
const handleCreate = async (values: TTickets, confirm: boolean = false) => {
try {
const res = await create(
addTicket,
{
// time_string: moment(values.time_string).format('YYYY-MM-DD HH:mm:ss'),
start_date: moment(values.start_date).format('YYYY-MM-DD'),
start_period: values.start_period,
end_date: moment(values.end_date).format('YYYY-MM-DD'),
end_period: values.end_period,
type: values.type,
reason: values.reason,
},
getAllTickets,
)
if (res === true) {
let params = {
// time_string: moment(values.time_string).format('YYYY-MM-DD HH:mm:ss'),
start_date: moment(values.start_date).format('YYYY-MM-DD'),
start_period: values.start_period,
end_date: moment(values.end_date).format('YYYY-MM-DD'),
end_period: values.end_period,
type: values.type,
reason: values.reason,
is_accept: confirm,
}
let res = await post(addTicket, params)
if (res.status) {
notifications.show({
title: 'Success',
message: _NOTIFICATION_MESS.create_success,
color: 'green',
})
setAction('')
form.reset()
getAllTickets()
}
if (!res.status && res.errors) {
if (!res.data?.success && res.data?.message) {
//popup notification confirm or cancel
setConfirmMessage(res.data?.message)
setConfirmValues(values)
setConfirmModal(true)
} else {
notifications.show({
title: 'Error',
message: (
<div style={{ whiteSpace: 'pre-line' }}>
{res.message ?? _NOTIFICATION_MESS.create_error}
</div>
),
color: 'red',
})
}
}
console.log(res, 'res')
// return res.status
} catch (error: any) {
if (error.response.status === 422) {
const errorMess = error.response.data.message
notifications.show({
title: 'Error',
message: errorMess,
color: 'red',
})
}
} catch (error) {
console.log(error)
}
}
@ -322,9 +362,7 @@ const Tickets = () => {
return (
<div>
<div className={classes.title}>
<h3>
Tickets
</h3>
<h3>Tickets</h3>
<Button
m={5}
onClick={() => {
@ -490,6 +528,56 @@ const Tickets = () => {
</Group>
</Text>
</Dialog>
{/* Confirm Modal */}
<Modal
opened={confirmModal}
onClose={() => !confirmLoading && setConfirmModal(false)}
title={
<Text fw={700} fz="lg">
Warning
</Text>
}
centered
closeOnClickOutside={!confirmLoading}
closeOnEscape={!confirmLoading}
>
<Box p="md">
<Text style={{ whiteSpace: 'pre-line' }} mb={20}>
{confirmMessage}
</Text>
<Group justify="center">
<Button
color="green"
loading={confirmLoading}
onClick={async () => {
if (confirmValues) {
try {
setConfirmLoading(true)
action === 'add' && (await handleCreate(confirmValues, true))
setConfirmLoading(false)
setConfirmModal(false)
} catch (error) {
setConfirmLoading(false)
console.error(error)
}
}
}}
>
Confirm
</Button>
<Button
color="red"
disabled={confirmLoading}
onClick={() => {
setConfirmModal(false)
}}
>
Cancel
</Button>
</Group>
</Box>
</Modal>
</div>
)
}

View File

@ -60,3 +60,37 @@
padding-top: 5px;
padding-bottom: 5px;
}
/* Thêm styles cho Modal xác nhận xóa */
.deleteModal {
background-color: light-dark(white, #2d353c);
text-align: center;
border: solid 1px #ff4646;
}
.deleteModalTitle {
color: #ff4646;
font-weight: 600;
font-size: 1.2rem;
margin-bottom: 1rem;
}
.deleteModalContent {
color: light-dark(#2d353c, white);
margin-bottom: 1.5rem;
}
.deleteModalFooter {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 1rem;
}
.deleteButton {
background-color: #ff4646;
}
.deleteButton:hover {
background-color: #ff6b6b;
}

View File

@ -147,6 +147,9 @@ const Timekeeping = () => {
const [exportModalOpened, setExportModalOpened] = useState(false)
const [exportOption, setExportOption] = useState('default')
const [isDeleteConfirmOpen, setIsDeleteConfirmOpen] = useState(false)
const [noteToDelete, setNoteToDelete] = useState<any>(null)
const getListMasterByType = async (type: string) => {
try {
const params = {
@ -445,16 +448,16 @@ const Timekeeping = () => {
try {
const timestamp = moment().format('DDMMYYYY_HHmmss')
const fileName = `Timekeeping_${date.month}_${date.year}_${timestamp}.xlsx`
await exportFile(
exportTimekeeping,
{
month: date.month,
year: date.year,
working_days: workingDays,
option: option
option: option,
},
fileName
fileName,
)
setExportModalOpened(false)
} catch (error) {
@ -462,6 +465,53 @@ const Timekeeping = () => {
}
}
const handleConfirmDelete = async () => {
if (noteToDelete) {
await handleDelete(noteToDelete.id)
setIsDeleteConfirmOpen(false)
setNoteToDelete(null)
}
}
const openDeleteConfirm = (note: any) => {
setNoteToDelete(note)
setIsDeleteConfirmOpen(true)
}
const DeleteConfirmModal = () => (
<Modal
opened={isDeleteConfirmOpen}
onClose={() => {
setIsDeleteConfirmOpen(false)
setNoteToDelete(null)
}}
centered
size="sm"
classNames={{
content: classes.deleteModal,
}}
>
<Text className={classes.deleteModalTitle}>Confirm Delete</Text>
<Text className={classes.deleteModalContent}>
Are you sure you want to delete this note?
</Text>
<Box className={classes.deleteModalFooter}>
<Button
variant="outline"
onClick={() => {
setIsDeleteConfirmOpen(false)
setNoteToDelete(null)
}}
>
Cancel
</Button>
<Button className={classes.deleteButton} onClick={handleConfirmDelete}>
Delete
</Button>
</Box>
</Modal>
)
return (
<div>
<div className={classes.title}>
@ -631,11 +681,7 @@ const Timekeeping = () => {
>
<IconTrash
className={classes.deleteIcon}
onClick={async () => {
await handleDelete(item.id)
// handleUpdateCacheMonth()
// close2()
}}
onClick={() => openDeleteConfirm(item)}
width={20}
height={20}
/>
@ -655,7 +701,7 @@ const Timekeeping = () => {
data={Array.from({ length: 12 }, (_, index) => {
return {
value: (1 + index).toString(),
label: (1 + index).toString()
label: (1 + index).toString(),
}
})}
onChange={(e) => {
@ -710,9 +756,9 @@ const Timekeeping = () => {
Save
</Button>
</Tooltip>
<Button
onClick={() => setExportModalOpened(true)}
size="xs"
<Button
onClick={() => setExportModalOpened(true)}
size="xs"
ml="md"
bg={'green'}
leftSection={<IconFileExcel size={16} />}
@ -1074,20 +1120,19 @@ const Timekeeping = () => {
placeholder="Choose an option"
value={exportOption}
onChange={(value) => setExportOption(value || 'default')}
data={[
{ value: 'default', label: 'Default' }
]}
data={[{ value: 'default', label: 'Default' }]}
mb="md"
/>
<Box style={{ display: 'flex', justifyContent: 'flex-end', gap: '10px' }}>
<Box
style={{ display: 'flex', justifyContent: 'flex-end', gap: '10px' }}
>
<Button variant="outline" onClick={() => setExportModalOpened(false)}>
Close
</Button>
<Button onClick={() => handleExport(exportOption)}>
Export
</Button>
<Button onClick={() => handleExport(exportOption)}>Export</Button>
</Box>
</Modal>
<DeleteConfirmModal />
</div>
)
}

View File

@ -36,7 +36,7 @@ export const create = async (
if (res.status === false) {
notifications.show({
title: 'Error',
message: res.message ?? _NOTIFICATION_MESS.create_error,
message: <div style={{ whiteSpace: 'pre-line' }}>{res.message ?? _NOTIFICATION_MESS.create_error}</div>,
color: 'red',
})
}