Compare commits

...

64 Commits

Author SHA1 Message Date
joseph efaf906f0a Merge pull request 'dev' (#134) from dev into master
Reviewed-on: #134
2025-07-02 12:54:43 +10:00
joseph 3e865e5575 Merge pull request 'update permission display for leave management' (#133) from vi.ticket-email into dev
Reviewed-on: #133
2025-07-02 11:23:51 +10:00
dbdbd9 ee281986b8 update permission display for leave management 2025-07-01 16:36:21 +07:00
joseph 6e1e452bf1 Merge pull request 'Deploy to prod' (#132) from dev into master
Reviewed-on: #132
2025-07-01 17:54:53 +10:00
joseph 6528b7bc59 Merge pull request 'add update note status' (#131) from vi.ticket-email into dev
Reviewed-on: #131
2025-07-01 17:26:34 +10:00
dbdbd9 fd1be27414 Merge branch 'dev' of https://gitea.nswteam.net/joseph/ManagementSystem into vi.ticket-email 2025-07-01 13:41:05 +07:00
dbdbd9 d064b242e6 add update note status 2025-07-01 13:40:48 +07:00
joseph 8f6977be72 Merge pull request 'dev' (#130) from dev into master
Reviewed-on: #130
2025-06-30 18:37:38 +10:00
joseph af67644416 Merge pull request 'adjust UI, add send mail when delete note/update refuse ticket' (#129) from vi.ticket-email into dev
Reviewed-on: #129
2025-06-30 18:14:26 +10:00
dbdbd9 705e8f9216 adjust UI, add send mail when delete note/update refuse ticket 2025-06-30 14:58:06 +07:00
joseph db4feedde3 Merge pull request 'update handle calculate onleave ticket in next months' (#128) from vi.ticket-email into dev
Reviewed-on: #128
2025-06-30 16:11:00 +10:00
dbdbd9 27b59ae939 update msg 2025-06-30 11:04:44 +07:00
dbdbd9 034b9eee2c update handle calculate onleave ticket in next months 2025-06-30 10:57:02 +07:00
joseph 543b1af58c Merge pull request 'update UI leave management, add func update old data' (#127) from vi.ticket-email into dev
Reviewed-on: #127
2025-06-27 12:51:16 +10:00
dbdbd9 d09fa6d432 comment log 2025-06-27 09:48:15 +07:00
dbdbd9 40f80579dc update UI leave management, add func update old data 2025-06-27 09:45:21 +07:00
joseph b1e7aaa1ea Merge pull request 'fix calculate onleave day' (#126) from vi.ticket-email into dev
Reviewed-on: #126
2025-06-25 13:37:41 +10:00
hoangvi.ng a883662f0b fix calculate onleave day 2025-06-25 10:34:02 +07:00
joseph a11f67f252 Merge pull request 'refactor create, handle ticket' (#125) from vi.ticket-email into dev
Reviewed-on: #125
2025-06-24 18:45:44 +10:00
dbdbd9 fb6c58f1c0 handle onleave new user, adjust background jobs 2025-06-24 15:39:11 +07:00
dbdbd9 c2c9322e7d update delete note, add update ticket 2025-06-21 11:11:02 +07:00
dbdbd9 3263bce784 fix refactor function check leave day, update handle ticket send mail 2025-06-20 14:29:47 +07:00
dbdbd9 500d6ec34d fix warnning on_leave_note_pay message 2025-06-18 15:23:55 +07:00
dbdbd9 1b35f8481b refactor create, handle ticket 2025-06-17 15:44:44 +07:00
vincent.vo 172af363de Merge pull request 'truong-leave-day' (#124) from truong-leave-day into dev
Reviewed-on: #124
2025-06-16 16:34:55 +10:00
Truong Vo cd9c01cdee fix 2025-06-16 13:34:41 +07:00
Truong Vo 2c8bb92775 Bổ sung check ngày phép tối đa 1 tháng mặc dù còn nhiều phép 2025-06-16 13:31:15 +07:00
Truong Vo 5b65415d76 Bổ sung check ticket đang được duyệt k thể tạo 2025-06-16 09:41:54 +07:00
Truong Vo 655d9cd4c6 Hiệu chỉnh ngày phép 2025-06-16 09:29:45 +07:00
Truong Vo e05e92b96b fix lỗi có phép được duyệt trong thời gian được chọn khi tạo ticket 2025-06-16 08:15:14 +07:00
joseph 1328320713 Merge pull request 'add confirm/refuse in ticket mail' (#123) from vi.ticket-email into dev
Reviewed-on: #123
2025-06-12 12:54:55 +10:00
dbdbd9 7217a83e33 add confirm/refuse in ticket mail 2025-06-11 16:44:11 +07:00
dbdbd9 8474bd97a1 Merge branch 'dev' of https://gitea.nswteam.net/joseph/ManagementSystem into vi.document 2025-06-11 14:52:24 +07:00
vincent.vo f8c2af2ec8 Merge pull request 'Hiệu chỉnh hàm tính toán ngày phép' (#122) from truong-leave-day into dev
Reviewed-on: #122
2025-06-11 17:49:54 +10:00
Truong Vo 5f8b03f558 Hiệu chỉnh hàm tính toán ngày phép 2025-06-11 14:49:16 +07:00
dbdbd9 fa47d32b07 Merge branch 'dev' of https://gitea.nswteam.net/joseph/ManagementSystem into vi.document 2025-06-10 10:05:47 +07:00
joseph 160596a7a3 Merge pull request 'truong-leave-day' (#116) from truong-leave-day into dev
Reviewed-on: #116
2025-06-10 12:51:31 +10:00
Truong Vo 177296a324 fix build 2025-05-07 16:49:47 +07:00
Truong Vo d5936087ff fix build 2025-05-07 16:49:15 +07:00
Truong Vo 951c3be613 fix build 2025-05-07 16:48:14 +07:00
Truong Vo b5a7740d9f Bổ sung form xác nhận trước khi xóa note 2025-05-07 15:13:37 +07:00
Truong Vo 2011797dd6 Bổ sung xóa ngày phép 2025-05-07 13:28:14 +07:00
Truong Vo 7dc31bf75b Bổ sung xóa ngày phép 2025-05-07 11:33:52 +07:00
Truong Vo ca766fc293 Bổ sung check trùng ngày phép đã tạo 2025-04-26 10:43:18 +07:00
Truong Vo 32b197969c Bổ sung check trùng ngày phép đã tạo 2025-04-26 09:49:52 +07:00
Truong Vo 462d896e57 Hiệu chỉnh add ticket 2025-04-25 17:03:24 +07:00
Truong Vo 44fa6b55f7 Hiệu chỉnh add ticket 2025-04-25 10:29:35 +07:00
Truong Vo ddcb78ef98 Hiệu chỉnh chức năng ngày phép 2025-04-24 16:37:53 +07:00
Truong Vo d61fd879b7 Hiệu chỉnh chức năng ngày phép 2025-04-24 15:23:25 +07:00
Truong Vo d3c1d9bf60 Hiệu chỉnh chức năng ngày phép 2025-04-24 15:19:12 +07:00
Truong Vo 65c4dbcf88 Hiệu chỉnh chức năng ngày phép 2025-04-24 09:55:03 +07:00
Truong Vo af5721682b [Ngày Phép] Bổ sung chức năng cập nhật ngày nghỉ cho ngày nghỉ đặt biệt (3 ngày nghỉ đám cưới) 2025-04-15 14:07:20 +07:00
Truong Vo 308f5a3a2e [Ngày Phép] Thực hiện Job cập nhật ngày phép bảng mỗi tháng + 1 ngày phép cho mỗi user, thời gian 00:00 ngày 1 hàng tháng 2025-04-15 10:34:54 +07:00
Truong Vo 05cb3f722a Merge branch 'dev' into truong-leave-day 2025-04-04 10:39:11 +07:00
Truong Vo 1f9ab744ae Merge branch 'master' into truong-leave-day 2025-04-04 10:39:00 +07:00
Joseph f807a635bb update logic upload file 2025-03-27 17:18:53 +07:00
Joseph d2c0e0b95d update logic upload file 2025-03-27 17:08:49 +07:00
Joseph 4da48df8d7 update logic upload file 2025-03-27 17:05:32 +07:00
dbdbd9 5cd7071459 Merge branch 'master' of https://gitea.nswteam.net/joseph/ManagementSystem into vi.document 2025-03-27 07:50:25 +07:00
Joseph 6e2a8c2578 update type file profile upload 2025-03-26 09:29:14 +07:00
joseph db5c9b09a8 Merge pull request 'master' (#121) from master into dev
Reviewed-on: #121
2025-03-14 19:36:10 +11:00
Truong Vo 650cfe1b13 [Ngày Phép] Cập nhật loại phép nộp: WFH, Nghỉ phép năm, Nghỉ không lương 2025-03-13 15:41:43 +07:00
Truong Vo 8ce0d957b1 [Ngày Phép] Cập nhật lại tên cột cho bảng ngày nghỉ phép năm 2025-03-13 14:54:37 +07:00
Truong Vo 86f2bb12fc [Ngày Phép] Cập nhật lại tên cột cho bảng ngày nghỉ phép năm 2025-03-13 14:50:57 +07:00
56 changed files with 5060 additions and 1193 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,31 +35,34 @@ 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.id as id'),
DB::raw('notes.n_user_id as n_user_id'),
DB::raw('notes.n_time_type as time_type'),
DB::raw('notes.n_year as year'),
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', 'TEMPORARY_ONLEAVE'])
// ->groupBy("notes.n_user_id")
->orderBy('notes.n_month')
->orderBy('notes.n_day')
->get()
->map(function ($item) {
return [
"id" => $item->id,
"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 +108,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 +137,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();
@ -142,6 +147,28 @@ class LeaveManagementController extends Controller
return response()->json(['status' => true, 'message' => 'Updated successfully']);
}
public function updateNoteStatus(Request $request)
{
$rules = [
'id' => 'required',
'n_reason' => 'required|in:ONLEAVE,LEAVE_WITHOUT_PAY'
];
// Validate the request
$request->validate($rules);
$id = $request->input('id');
$reason = $request->input('n_reason');
$note = Notes::find($id);
if (!$note) {
return response()->json(['message' => 'Note not found', 'status' => false]);
}
$note->n_reason = $reason;
$note->save();
return response()->json(data: ['message' => 'Update success', 'status' => true]);
}
public function export(Request $request)
{
$year = $request->query('year', now()->year);
@ -152,7 +179,7 @@ class LeaveManagementController extends Controller
}
// Lọc chỉ lấy user có permission bao gồm staff
$staffData = $leaveDays->filter(function($user) {
$staffData = $leaveDays->filter(function ($user) {
return isset($user['user']['permission']) && strpos($user['user']['permission'], 'staff') !== false;
});

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]);
}
@ -169,7 +175,7 @@ class ProfileController extends Controller
$name = $request->input('name') ?? auth('admins')->user()->name;
// Validate the incoming files
$request->validate([
'files.*' => 'required|file|mimes:jpg,png,jpeg,pdf,doc,docx|max:5120', // Adjust file types and size limit as needed
'files.*' => 'required|file|mimes:jpg,png,jpeg,pdf,doc,docx,xlsx,xls,csv|max:5120', // Adjust file types and size limit as needed
]);
$uploadedFiles = [];
@ -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';
}
}

File diff suppressed because it is too large Load Diff

View File

@ -11,12 +11,14 @@ use App\Traits\HasOrderByRequest;
use App\Traits\HasSearchRequest;
use Carbon\Carbon;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;
use App\Mail\TicketMail;
use Modules\Admin\app\Models\Admin;
use Modules\Admin\app\Models\MonthlyTimekeeping;
use Modules\Admin\app\Models\Tracking;
use Maatwebsite\Excel\Facades\Excel;
use App\Exports\TimekeepingExport;
use Modules\Admin\app\Models\Ticket;
class TimekeepingController extends Controller
{
@ -152,8 +154,6 @@ class TimekeepingController extends Controller
return response()->json(['status' => true, 'message' => 'Add successfully']);
}
public function updateCacheMonth(Request $request)
{
$month = $request->month;
@ -172,20 +172,64 @@ class TimekeepingController extends Controller
// Validate the request
$request->validate($rules);
$id = $request->input('id');
$month = $request->month;
$year = $request->year;
$note = Notes::find($id);
if ($note) {
$note->delete();
$this->createOrUpdateRecordForCurrentMonth($month, $year);
if (!$note) {
return response()->json(['message' => 'Note not found', 'status' => false]);
}
if ($note->ticket_id != null) {
$ticket = Ticket::find($note->ticket_id);
if (!$ticket) {
return response()->json(['message' => 'Ticket not found, can not delete note', 'status' => false]);
}
$admin = auth('admins')->user();
// Handle send mail
$dataMasterStartPeriod = CategoryController::getListMasterByCodeAndType("TIME_TYPE", $ticket->start_period);
$dataMasterEndPeriod = CategoryController::getListMasterByCodeAndType("TIME_TYPE", $ticket->end_period);
$dataMasterType = CategoryController::getListMasterByCodeAndType("REASON", $ticket->type);
$formattedStartDate = Carbon::createFromFormat('Y-m-d', $ticket->start_date)->format('d/m/Y');
$formattedEndDate = Carbon::createFromFormat('Y-m-d', $ticket->end_date)->format('d/m/Y');
$user = Admin::find($ticket->user_id);
$data = array(
"email_template" => "email.notification_tickets_user",
"user_name" => $user->name,
"email" => $user->email,
"name" => $admin->name, //name admin duyệt
"date" => $dataMasterStartPeriod->c_name . " (" . $formattedStartDate . ") - " . $dataMasterEndPeriod->c_name . " (" . $formattedEndDate . ")",
"type" => $dataMasterType->c_name,
"note" => $ticket->reason,
"admin_note" => $ticket->admin_note,
"link" => "/tickets", //link đến page admin
"status" => "refused",
"subject" => "[Ticket response] Ticket From " . $admin->name
);
Mail::to($user->email)->send(new TicketMail($data));
// Update
$ticket->updated_by = $admin->name;
$ticket->status = "REFUSED";
$ticket->save();
Notes::where('ticket_id', $ticket->id)->delete();
// Clear Timekeeping cache
$this->createOrUpdateRecordForCurrentMonth(Carbon::parse($ticket->start_date)->month, Carbon::parse($ticket->start_date)->year);
$this->createOrUpdateRecordForCurrentMonth(Carbon::parse($ticket->end_date)->month, Carbon::parse($ticket->end_date)->year);
return response()->json(['message' => 'Delete success', 'status' => true]);
}
return response()->json(['message' => 'Delete fail', 'status' => false]);
$note->delete();
$this->createOrUpdateRecordForCurrentMonth($month, $year);
return response()->json(['message' => 'Delete success', 'status' => true]);
}
public function export(Request $request)
@ -206,10 +250,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

@ -44,6 +44,7 @@ Route::middleware('api')
Route::post('login', [AdminController::class, 'login']);
Route::post('reset-password', [AdminController::class, 'resetPassword']);
Route::get('forgot-password', [AdminController::class, 'forgotPassword']);
Route::get('/email-handle-ticket', [TicketController::class, 'handleTicketEmail'])->name('email.ticket.handle');
});
// NOTE after login
@ -156,6 +157,7 @@ Route::middleware('api')
Route::get('/', [LeaveManagementController::class, 'get'])->middleware('check.permission:admin.hr.staff.accountant');
Route::get('/export', [LeaveManagementController::class, 'export'])->middleware('check.permission:admin.hr.staff.accountant');
Route::post('/saveNoteLeave', [LeaveManagementController::class, 'saveNoteLeave'])->middleware('check.permission:admin.hr');
Route::post('/updateNoteStatus', [LeaveManagementController::class, 'updateNoteStatus'])->middleware('check.permission:admin.hr');
});
Route::group([
@ -163,6 +165,7 @@ Route::middleware('api')
], function () {
Route::get('/all', [TicketController::class, 'getAll'])->middleware('check.permission:admin.hr');
Route::get('/getByUserId', [TicketController::class, 'getByUserId'])->middleware('check.permission:admin.hr.staff');
Route::post('/update', [TicketController::class, 'updateTicket'])->middleware('check.permission:admin.hr');
Route::post('/create', [TicketController::class, 'createTicket'])->middleware('check.permission:admin.hr.staff');
Route::get('/delete', [TicketController::class, 'deleteTicket'])->middleware('check.permission:admin.hr.staff');
Route::post('/handle-ticket', [TicketController::class, 'handleTicket'])->middleware('check.permission:admin');
@ -174,6 +177,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

@ -3,18 +3,23 @@
namespace Modules\Auth\app\Http\Controllers;
use App\Http\Controllers\Controller;
use App\Models\LeaveDays;
use App\Traits\IsAPI;
use Carbon\Carbon;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
use Modules\Auth\app\Models\User;
use Illuminate\Support\Str;
use Modules\Admin\app\Models\Category;
use SimpleSoftwareIO\QrCode\Facades\QrCode;
use App\Traits\AnalyzeData;
class UserController extends Controller
{
use IsAPI;
use AnalyzeData;
public function __construct()
{
$this->middleware('jwt.auth');
@ -34,9 +39,37 @@ class UserController extends Controller
]);
if ($request->has('id')) {
$payload = $request->only(['name', 'email', 'permission']);
$payload = $request->only(['name', 'email', 'permission', 'is_permanent']);
$user = User::find($request->id);
// Không cho chuyển từ chính thức thành lại thử việc
if (!$request->is_permanent && $user->is_permanent) {
return response()->json(['status' => false, 'message' => 'You cannot change an employee from permanent to probationary.']);
}
// Thêm ngày phép khi thành nhân viên chính thức
if ($request->is_permanent && !$user->is_permanent) {
$userLeaveDay = LeaveDays::where('ld_user_id', $user->id)
->where('ld_year', Carbon::now()->year)
->first();
if ($userLeaveDay) {
$permanentCategory = Category::where('c_type', 'PERMANENT_ONLEAVE')->where('c_code', "PERMANENT")->first();
$permanentDefault = (int) $permanentCategory->c_value; // Ngày phép khi thành nv chính thức
$userLeaveDay->ld_day_total = $permanentDefault;
$newNote = "Cộng ngày phép cho nhân viên chính thức"; // Thêm ghi chú
if (!empty($userLeaveDay->ld_note)) {
$userLeaveDay->ld_note = $userLeaveDay->ld_note . "\n" . $newNote;
} else {
$userLeaveDay->ld_note = $newNote;
}
$userLeaveDay->save();
}
$payload['permanent_date'] = Carbon::now()->toDateString();
}
$user->update($payload);
return response()->json(['data' => $user, 'status' => true, 'message' => 'Update successful']);
} else {
@ -44,9 +77,22 @@ class UserController extends Controller
'name' => $request->name,
'email' => $request->email,
'password' => bcrypt('Work@1234'),
'permission' => $request->permission
'permission' => $request->permission,
'is_permanent' => false
]);
// Khởi tạo LeaveDays cho nhân viên mới
LeaveDays::insert([
'ld_user_id' => $user->id,
'ld_day_total' => 0,
'ld_year' => Carbon::now()->year,
'ld_additional_day' => 0,
'ld_note' => '',
'created_at' => now(),
'updated_at' => now(),
]);
$this->createOrUpdateRecordForCurrentMonth(Carbon::now()->month, Carbon::now()->year);
$user_res = [
'name' => $user->name,
'email' => $user->email,
@ -98,8 +144,6 @@ class UserController extends Controller
return response()->json(['data' => ['user' => $user_res, 'gitea' => "dev", 'zulip' => "dev"], 'status' => true, 'message' => 'Create successful']);
}
}
return response()->json(['status' => false, 'message' => 'Process fail']);
}
public function delete(Request $request)

View File

@ -25,7 +25,9 @@ class User extends Authenticatable implements JWTSubject
'name',
'email',
'password',
'permission'
'permission',
'is_permanent',
'permanent_date'
];
/**

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

@ -0,0 +1,24 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use App\Jobs\UpdateTemporaryLeaveDays;
class UpdateTemporaryLeaveDaysCommand extends Command
{
protected $signature = 'update:temporary-leavedays {month?} {year?}';
protected $description = 'Tính lại ngày phép cho các note tạm.';
public function __construct()
{
parent::__construct();
}
public function handle()
{
$month = $this->argument('month');
$year = $this->argument('year');
UpdateTemporaryLeaveDays::dispatch($month, $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;
@ -32,6 +33,10 @@ 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 ngày đầu tiên của mỗi tháng
$schedule->command('add:monthly-leavedays')->monthlyOn(1, '00:01');
$schedule->command('update:temporary-leavedays')->monthlyOn(1, '00:05');
}
/**

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,82 @@
<?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;
use Modules\Admin\app\Models\Category;
class AddMonthlyLeaveDays implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $month;
protected $year;
private const ONLEAVE_PER_MONTH = 1; // Ngày phép cộng mỗi tháng
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) {
// Nếu là nhân viên chưa chính thức, ko cộng phép
if (!$user->is_permanent) {
continue;
}
$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 {
// Check có phải là nhân viên chính thức trong năm nay (Nhân viên mới)
if ($user->permanent_date && $user->permanent_date !== '0000-00-00') {
$permenantYear = Carbon::parse($user->permanent_date)->year;
if ($permenantYear === $this->year) {
$permanentCategory = Category::where('c_type', 'PERMANENT_ONLEAVE')->where('c_code', "PERMANENT")->first();
$permanentDefault = (int) $permanentCategory->c_value; // Ngày phép khi thành nv chính thức
$permanentMonth = Carbon::parse($user->permanent_date)->month;
if ($this->month > $leaveDay->ld_day_total - ($permanentDefault - $permanentMonth)) {
$leaveDay->ld_day_total += self::ONLEAVE_PER_MONTH;
$leaveDay->save();
}
}
}
// Kiểm tra nếu số ngày phép hiện tại nhỏ hơn tháng hiện tại (Nhân viên cũ)
if ($leaveDay->ld_day_total < $this->month) {
// Cộng mỗi tháng 1 ngày phép cho nhân viên
$leaveDay->ld_day_total += self::ONLEAVE_PER_MONTH;
$leaveDay->save();
}
}
}
}
}

View File

@ -10,7 +10,6 @@ use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\DB;
use Carbon\Carbon;
class DeductLeaveDays implements ShouldQueue
@ -36,44 +35,34 @@ 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;
}
$totalLeaveDaysByMonth = Notes::join('categories', function ($join) {
// Lấy tổng ngày nghỉ phép 3 tháng đầu trong năm
$usedOnleaveDaysTotal = Notes::join('categories', function ($join) {
$join->on('notes.n_time_type', '=', 'categories.c_code')
->where('categories.c_type', 'TIME_TYPE');
})
->select(
DB::raw('notes.n_user_id as n_user_id'),
DB::raw('notes.n_year as year'),
DB::raw('SUM(categories.c_value) as leave_days')
)
->where('notes.n_year', $this->year)
->where('notes.n_user_id', $user->id)
->where('notes.n_reason', 'ONLEAVE')
->groupBy(DB::raw('notes.n_year'))
->first();
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) {
LeaveDays::where('ld_year', $this->year)
->where('ld_user_id', $user->id)
->update([
'ld_date_additional' => $totalLeaveDaysByMonth->leave_days,
]);
->where('n_user_id', $user->id)
->where('n_year', $this->year)
->where('n_month', "<=", 3)
->where('n_reason', 'ONLEAVE')
->sum('categories.c_value');
if ($usedOnleaveDaysTotal) {
if ($existingData->ld_additional_day > $usedOnleaveDaysTotal) {
$ld_note = "Trừ " . $existingData->ld_additional_day - $usedOnleaveDaysTotal . " ngày phép tồn năm trước. \n";
$existingData->ld_note = $existingData->ld_note . "\n" . $ld_note;
$existingData->ld_additional_day = $usedOnleaveDaysTotal;
}
} else {
//Nếu không sử dụng ngày nghỉ còn lại ở năm rồi thì xóa => theo luật ld
LeaveDays::where('ld_year', $this->year)
->where('ld_user_id', $user->id)
->update([
'ld_date_additional' => "0",
]);
$existingData->ld_additional_day = 0;
}
$existingData->save();
}
}
}

View File

@ -34,7 +34,8 @@ class InitializeLeaveDays implements ShouldQueue
public function handle(): void
{
$users = User::get();
$ld_day = 12;
$ld_day_total = Carbon::now()->month; // Khởi tạo phép hiện có bằng tháng hiện tại
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 +52,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,20 +72,23 @@ 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ũ';
if ($ld_additional_day > 0) {
$ld_note = "Cộng " . $ld_additional_day . " ngày phép tồn năm trước. \n";
}
}
// 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' => $user->is_permanent ? $ld_day_total : 0, // Nếu là nhân viên mới, ko cấp phép
'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,220 @@
<?php
namespace App\Jobs;
use App\Models\LeaveDays;
use App\Models\Notes;
use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Carbon\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Modules\Admin\app\Models\Category;
class UpdateTemporaryLeaveDays 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;
}
/**
* Execute the job.
*/
public function handle()
{
$users = User::get();
foreach ($users as $user) {
$leaveDay = LeaveDays::where('ld_user_id', $user->id)
->where('ld_year', $this->year)
->first();
$notes = Notes::where('n_reason', 'TEMPORARY_ONLEAVE')
->where('n_user_id', $user->id)
->where('n_year', $this->year)
->where('n_month', $this->month)
->whereExists(function ($query) use ($user) {
$query->select(DB::raw(1))
->from('tickets')
->where('tickets.user_id', $user->id)
->where('tickets.status', 'CONFIRMED')
->where('tickets.type', 'ONLEAVE');
})
->get();
$maxDaysPerMonth = $this->getMaxLeaveDaysPerMonth();
// Tổng ngày nghỉ sẽ dùng trong tháng
$willUsedDaysInMonth = 0;
foreach ($notes as $note) {
$willUsedDaysInMonth += $note->n_time_type == 'ALL' ? 1.0 : 0.5;
}
// Tổng phép đang có
$onleaveDaysTotal = $leaveDay->ld_day_total + $leaveDay->ld_additional_day + $leaveDay->ld_special_leave_day;
// Phép đã sử dụng tới tháng hiện tại
$usedOnleaveDaysTotal = 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', $this->year)
->where('n_month', "<=", $this->month)
->where('n_reason', 'ONLEAVE')
->sum('categories.c_value');
// Phép còn lại
$remainingOnleaveDays = $onleaveDaysTotal - $usedOnleaveDaysTotal;
// Log::debug("User {$user->name}\n");
// Log::debug(
// "📊 Thống kê ngày phép:\n" .
// " - Tháng: {$this->month}\n" .
// " - Tổng ngày nghỉ sẽ dùng trong tháng: $willUsedDaysInMonth\n" .
// " - Tổng ngày phép: $onleaveDaysTotal\n" .
// " - Tổng ngày phép đã nghỉ: $usedOnleaveDaysTotal\n" .
// " - Tổng ngày phép còn lại: $remainingOnleaveDays\n"
// );
$onleave_days_will_use = 0; // Ngày phép sẽ dùng
$nopay_days_will_use = 0; // Ngày ko phép sẽ dùng
// Ngày phép còn lại <= 0 (Hết phép)
if ($remainingOnleaveDays <= 0) {
$onleave_days_will_use = 0;
$nopay_days_will_use = $willUsedDaysInMonth;
Log::debug("--- Hết phép trong tháng ---");
}
// Ngày phép còn lại < ngày yêu cầu (Không đủ phép)
else if ($remainingOnleaveDays < $willUsedDaysInMonth) {
// Vượt limit
if ($willUsedDaysInMonth > $maxDaysPerMonth) {
// Phép còn lại > limit
if ($remainingOnleaveDays > $maxDaysPerMonth) {
$onleave_days_will_use = $maxDaysPerMonth;
$nopay_days_will_use = $willUsedDaysInMonth - $maxDaysPerMonth;
}
// Phép còn lại < limit
else {
$onleave_days_will_use = $remainingOnleaveDays;
$nopay_days_will_use = $willUsedDaysInMonth - $remainingOnleaveDays;
}
Log::debug("--- Không đủ phép trong tháng, vượt quá limit ---",);
}
// Không vượt limit
else {
$onleave_days_will_use = $remainingOnleaveDays;
$nopay_days_will_use = $willUsedDaysInMonth - $remainingOnleaveDays;
Log::debug("--- Không đủ phép trong tháng, ko vượt limit ---");
}
}
// Ngày phép còn lại >= ngày yêu cầu (Đủ phép)
else {
// Vượt limit
if ($willUsedDaysInMonth > $maxDaysPerMonth) {
$onleave_days_will_use = $maxDaysPerMonth;
$nopay_days_will_use = $willUsedDaysInMonth - $maxDaysPerMonth;
Log::debug("--- Đủ phép, vượt limit ---");
}
// Không vượt limit
else {
$onleave_days_will_use = $willUsedDaysInMonth;
$nopay_days_will_use = 0;
Log::debug("--- Đủ phép ---");
}
}
Log::debug("", [
"Phep" => $onleave_days_will_use,
"Khong Phep" => $nopay_days_will_use
]);
// Có nghỉ không phép
if ($nopay_days_will_use > 0) {
foreach ($notes as $note) {
$value = ($note->n_time_type === 'ALL') ? 1.0 : 0.5;
if ($note->n_time_type === 'ALL' && $onleave_days_will_use == 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' => $user->id,
'n_day' => $note->n_day,
'n_month' => $note->n_month,
'n_year' => $note->n_year,
'n_time_type' => 'S',
'n_reason' => 'ONLEAVE',
'n_note' => $note->n_note,
'ticket_id' => $note->ticket_id
]);
Notes::create([
'n_user_id' => $user->id,
'n_day' => $note->n_day,
'n_month' => $note->n_month,
'n_year' => $note->n_year,
'n_time_type' => 'C',
'n_reason' => 'LEAVE_WITHOUT_PAY',
'n_note' => $note->n_note,
'ticket_id' => $note->ticket_id
]);
$note->delete();
$onleave_days_will_use = 0;
$nopay_days_will_use -= 0.5;
} elseif ($onleave_days_will_use > 0) {
// Dùng ngày phép trước
$use = min($onleave_days_will_use, $value);
$note->update([
'n_reason' => "ONLEAVE"
]);
$onleave_days_will_use -= $use;
} elseif ($nopay_days_will_use > 0) {
// Hết phép, chuyển sang không phép
$use = min($nopay_days_will_use, $value);
$note->update([
'n_reason' => "LEAVE_WITHOUT_PAY"
]);
$nopay_days_will_use -= $use;
}
}
}
// Đủ phép
else {
foreach ($notes as $note) {
$note->update([
'n_reason' => "ONLEAVE"
]);
}
}
}
}
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;
}
}

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,14 @@ 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',
'ticket_id'
];
/**
@ -25,7 +32,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 +54,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

@ -59,6 +59,8 @@ return [
'asset_url' => env('ASSET_URL'),
'client_url' => env('ADMIN_URL', 'http://localhost'),
/*
|--------------------------------------------------------------------------
| Application Timezone

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', 'ld_day_total');
});
}
public function down()
{
Schema::table('leave_days', function (Blueprint $table) {
$table->renameColumn('ld_day_total', 'ld_day');
});
}
}

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

@ -0,0 +1,26 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('notes', function (Blueprint $table) {
$table->foreignId('ticket_id')
->nullable()
->constrained('tickets')
->onDelete('cascade');
});
}
public function down(): void
{
Schema::table('notes', function (Blueprint $table) {
$table->dropForeign(['ticket_id']);
$table->dropColumn('ticket_id');
});
}
};

View File

@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->boolean('is_permanent')->default(true);
$table->date('permanent_date');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('is_permanent');
$table->dropColumn('permanent_date');
});
}
};

View File

@ -0,0 +1,45 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
DB::table('categories')->insert([
[
'c_code' => 'PERMANENT',
'c_name' => 'Phép cộng nhân viên chính thức',
'c_type' => 'PERMANENT_ONLEAVE',
'c_value' => 1,
'c_active' => 1,
'created_at' => now(),
'updated_at' => now(),
],
[
'c_code' => 'TEMPORARY_ONLEAVE',
'c_name' => 'Nghỉ dự kiến',
'c_type' => 'REASON_NOTES',
'c_value' => "",
'c_active' => 1,
'created_at' => now(),
'updated_at' => now(),
],
]);
}
/**
* Reverse the migrations.
*/
public function down(): void
{
DB::table('categories')->where('c_code', 'PERMANENT')->where('c_type', 'PERMANENT_ONLEAVE')->delete();
DB::table('categories')->where('c_code', 'TEMPORARY_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

@ -1,4 +1,3 @@
<!DOCTYPE html>
<html lang="en">
@ -37,119 +36,167 @@
<title>{{ $data['subject'] }}</title>
</head>
<body>
<body style="
<body style="
font-family: Arial, Helvetica, sans-serif;
background-color: #edf2f7;
">
<table style="margin: 0 auto">
<tr>
<td>
<table
style="
<table style="margin: 0 auto">
<tr>
<td>
<table
style="
margin: 0 auto;
width: 768px;
">
<tr>
<td align="center" valign="top" style="padding: 36px 24px;">
<a href="{{ config('app.url') }}" target="_blank" style="display: inline-block;">
<img src="https://apactech.io/wp-content/uploads/2022/12/APAC-TECH_side-e1670975093601-190x78.png"
alt="Logo" border="0" width="100"
style="display: block; width: 100px; max-width: 100px; min-width: 48px;">
</a>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td>
<table
style="
<tr>
<td align="center" valign="top" style="padding: 36px 24px;">
<a href="{{ config('app.url') }}" target="_blank" style="display: inline-block;">
<img src="https://apactech.io/wp-content/uploads/2022/12/APAC-TECH_side-e1670975093601-190x78.png"
alt="Logo" border="0" width="100"
style="display: block; width: 100px; max-width: 100px; min-width: 48px;">
</a>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td>
<table
style="
margin: 0 auto;
background-color: #ffffff;
width: 768px;
padding: 24px;
">
<tr>
<td>
<h3 style="color: #222222; margin: 5px 0 0 0; font-weight: bold">
Dear Admin,
</h3>
</td>
</tr>
<tr>
<td>
<p style=" white-space:pre-line; margin: 0; margin-bottom: 5px">
Employee <span style="color: #222222;font-weight: bold;">{{ $data['name'] }}</span> has sent a request ticket, the specific content is as follows:
</p>
</td>
</tr>
<tr>
<td>
<div style="padding-left: 10px;color: #696969; margin-bottom: 15px">
<p style="padding: 3px;">Name: <span style="color: #222222;font-weight: bold;">{{ $data['name'] }}</span></p>
<p style="padding: 3px;">Date: <span style="color: #222222;font-weight: bold;">{{ $data['date'] }}</span></p>
<p style="padding: 3px;">Type: <span style="color: #222222;font-weight: bold;">{{ $data['type'] }}</span></p>
<p style="padding: 3px;">Note: <span style="color: #222222;font-weight: bold;">{{ $data['note'] }}</span></p>
</div>
</td>
</tr>
<tr>
<td>
<p style="margin:0 0 16px;padding:5px;margin: 5px;text-align: center;">
<a href='{{ config('app.url') . $data['link'] }}'
style="
color: #fff;
border-radius: 10px;
background-color: rgba(68,115,196);
background-image: linear-gradient(to top left,rgba(0,0,0,.2),rgba(0,0,0,.2) 30%,rgba(0,0,0,0));
text-decoration: none;
display: inline-block;
font-weight: 600;
font-size: 16px;
line-height: 150%;
text-align: center;
margin: 0;
padding: 10px 12px;
">
Check now</a>
</p>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td>
<table
style="
<tr>
<td>
<h3 style="color: #222222; margin: 5px 0 0 0; font-weight: bold">
Dear Admin,
</h3>
</td>
</tr>
<tr>
<td>
<p style=" white-space:pre-line; margin: 0; margin-bottom: 5px">
Employee <span style="color: #222222;font-weight: bold;">{{ $data['name'] }}</span> has sent a <a href="{{ config('app.client_url') . $data['link'] }}"> request ticket</a>, the specific content is as follows:
</p>
</td>
</tr>
<tr>
<td>
<div style="padding-left: 10px;color: #696969; margin-bottom: 15px">
<p style="padding: 3px;">Name: <span style="color: #222222;font-weight: bold;">{{ $data['name'] }}</span></p>
<p style="padding: 3px;">Date: <span style="color: #222222;font-weight: bold;">{{ $data['date'] }}</span></p>
<p style="padding: 3px;">Type: <span style="color: #222222;font-weight: bold;">{{ $data['type'] }}</span></p>
<p style="padding: 3px;">Note: <span style="color: #222222;font-weight: bold;">{{ $data['note'] }}</span></p>
</div>
</td>
</tr>
<tr>
<td>
<p style="text-align: center">
You can quick
<span style="font-weight: bold">Confirm</span> or
<span style="font-weight: bold">Refuse</span> here:
</p>
<div
style="
display: flex;
justify-content: center;
gap: 10px;
margin-top: 10px;
">
<a
href="{{ route('email.ticket.handle', ['ticket_id' => $data['ticket_id'], 'action' => 'confirm', 'admin_email' => $data['admin_email']]) }}"
style="
color: #fff;
border-radius: 10px;
background-color: #12b886;
background-image: linear-gradient(
to top left,
rgba(0, 0, 0, 0.2),
rgba(0, 0, 0, 0.2) 30%,
rgba(0, 0, 0, 0)
);
text-decoration: none;
display: inline-block;
font-weight: 600;
font-size: 16px;
line-height: 150%;
text-align: center;
margin: 0;
padding: 10px 12px;
">
Confirm</a>
<a
href="{{ route('email.ticket.handle', ['ticket_id' => $data['ticket_id'], 'action' => 'refuse', 'admin_email' => $data['admin_email']]) }}"
style="
color: #fff;
border-radius: 10px;
background-color: #f03e3e;
background-image: linear-gradient(
to top left,
rgba(0, 0, 0, 0.2),
rgba(0, 0, 0, 0.2) 30%,
rgba(0, 0, 0, 0)
);
text-decoration: none;
display: inline-block;
font-weight: 600;
font-size: 16px;
line-height: 150%;
text-align: center;
margin: 0;
padding: 10px 12px;
">
Refuse</a>
</div>
</td>
</tr>
<tr>
<td style="color: #222222;">
<div style="margin-top: 3rem">
<p><span style="font-weight: bold">Note</span>: If you are redirected to a <span style="font-weight: bold">404 page</span>, it means:</p>
<p>1. The ticket has already been approved by another admin.</p>
<p>2. The ticket has been deleted.</p>
</div>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td>
<table
style="
margin: 0 auto;
width: 768px;
">
<tr>
<td>
<h5
style="
<tr>
<td>
<h5
style="
color: #222222;
text-align: center;
padding: 10px 36px;
margin: 0;
">
<p>© 2024 APAC Tech.</p>
</h5>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
<p>© 2024 APAC Tech.</p>
</h5>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
</html>

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

@ -0,0 +1,13 @@
<?php
use Carbon\Carbon;
require_once __DIR__ . "/../vendor/autoload.php";
$app = include_once __DIR__ . '/../bootstrap/app.php';
$kernel = $app->make(Illuminate\Contracts\Console\Kernel::class);
$kernel->bootstrap();
// Cập nhật lại data cho tới tháng hiện tại
$currentMonth = Carbon::now()->month;
$tmpClass = $app->make('Modules\Admin\app\Http\Controllers\TicketController');
$tmpClass->updateOldData($currentMonth, 2025); // Params: month, year

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

View File

@ -40,6 +40,8 @@ export const getListMaster = API_URL + 'v1/admin/category/get-list-master'
export const getLeaveManagement = API_URL + 'v1/admin/leave-management'
export const updateNoteLeave =
API_URL + 'v1/admin/leave-management/saveNoteLeave'
export const updateNoteStatus =
API_URL + 'v1/admin/leave-management/updateNoteStatus'
export const exportLeaveManagement =
API_URL + 'v1/admin/leave-management/export'
@ -48,6 +50,7 @@ export const getTickets = API_URL + 'v1/admin/ticket/all'
export const getTicketsOfUser = API_URL + 'v1/admin/ticket/getByUserId'
export const deleteTicket = API_URL + 'v1/admin/ticket/delete'
export const addTicket = API_URL + 'v1/admin/ticket/create'
export const updateTicket = API_URL + 'v1/admin/ticket/update'
export const handleTicket = API_URL + 'v1/admin/ticket/handle-ticket'
//Users
@ -119,3 +122,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

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

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,42 @@
export const PageNotFound = () => {
return <>{'Not found!'}</>
import {
Box,
Button,
Container,
Image,
SimpleGrid,
Text,
Title,
} from '@mantine/core'
import image404 from '../../../public/404Image.jpg'
import { useNavigate } from 'react-router-dom'
const PageNotFound = () => {
const navigate = useNavigate()
return (
<Container mt="lg">
<SimpleGrid spacing={{ base: 40, sm: 80 }} cols={{ base: 1, sm: 2 }}>
<Box py="3rem">
<Title> Something is not right...</Title>
<Text c="dimmed" size="lg">
Page you are trying to open does not exist. You may have mistyped
the address, or the page has been moved to another URL. If you think
this is an error contact support.
</Text>
<Button
variant="outline"
size="md"
mt="xl"
onClick={() => navigate('/')}
>
Get back to home page
</Button>
</Box>
<Image src={image404} />
</SimpleGrid>
</Container>
)
}
export default PageNotFound

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
@ -78,10 +79,16 @@ const Tickets = () => {
const [item, setItem] = useState({ id: 0 })
const [activeBtn, setActiveBtn] = useState(false)
const [disableBtn, setDisableBtn] = useState(false)
const [isFetchData, setIsFetch] = useState(false)
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 = {
@ -103,6 +110,8 @@ const Tickets = () => {
useEffect(() => {
const fetchData = async () => {
setIsFetch(true)
const resultTimeType = await getListMasterByType('TIME_TYPE')
setDataTimeType(
resultTimeType.filter((item: DataTimeType) => item.c_code !== 'ALL'),
@ -110,6 +119,8 @@ const Tickets = () => {
const resultReason = await getListMasterByType('REASON')
setDataReason(resultReason)
setIsFetch(false)
}
fetchData()
@ -271,27 +282,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,15 +367,14 @@ const Tickets = () => {
return (
<div>
<div className={classes.title}>
<h3>
Tickets
</h3>
<h3>Tickets</h3>
<Button
m={5}
onClick={() => {
setAction('add')
form.reset()
}}
disabled={isFetchData}
>
+ Add
</Button>
@ -490,6 +534,57 @@ 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

@ -45,4 +45,38 @@
.dialogText {
color: light-dark(#2d353c, white);
}
}
/* 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

@ -1,6 +1,11 @@
import { getListMaster, getTickets, handleTicket } from '@/api/Admin'
import {
getListMaster,
getTickets,
handleTicket,
updateTicket,
} from '@/api/Admin'
import { DataTablePagination } from '@/components/DataTable/DataTable'
import { create } from '@/rtk/helpers/CRUD'
import { create, update } from '@/rtk/helpers/CRUD'
import { get } from '@/rtk/helpers/apiService'
import {
Badge,
@ -9,12 +14,13 @@ import {
HoverCard,
Modal,
Select,
Switch,
Text,
Textarea,
} from '@mantine/core'
import { useForm } from '@mantine/form'
import { notifications } from '@mantine/notifications'
import { IconCheckbox, IconSquareXFilled } from '@tabler/icons-react'
import { IconCheckbox, IconEdit, IconSquareXFilled } from '@tabler/icons-react'
import moment from 'moment'
import { useEffect, useState } from 'react'
import classes from './TicketsManagement.module.css'
@ -24,6 +30,7 @@ type TTickets = {
ticket_id: number
admin_note: string
action: string
status: string
}
type TListTickets = {
@ -78,6 +85,7 @@ const TicketsManagement = () => {
end_period: '',
reason: '',
type: '',
status: '',
})
const [disableBtn, setDisableBtn] = useState(false)
const [filter, setFilter] = useState({
@ -86,6 +94,7 @@ const TicketsManagement = () => {
})
const [dataTimeType, setDataTimeType] = useState<DataTimeType[]>([])
const [dataReason, setDataReason] = useState<DataReason[]>([])
const [isRefuseConfirmOpen, setIsRefuseConfirmOpen] = useState<boolean>(false)
const getListMasterByType = async (type: string) => {
try {
@ -156,7 +165,7 @@ const TicketsManagement = () => {
},
{
name: 'reason',
size: '15%',
size: '10%',
header: 'Notes',
render: (row: any) => {
return (
@ -198,7 +207,7 @@ const TicketsManagement = () => {
},
{
name: 'admin_note',
size: '15%',
size: '10%',
header: 'Admin Notes',
render: (row: any) => {
return (
@ -226,6 +235,18 @@ const TicketsManagement = () => {
size: '10%',
header: 'Updated By',
},
{
name: 'updated_at',
size: '10%',
header: 'Updated At',
render: (row: any) => {
if (row?.updated_at) {
return (
<Box>{moment(row?.updated_at).format('HH:mm:ss DD/MM/YYYY')}</Box>
)
}
},
},
{
name: '#',
size: '5%',
@ -254,7 +275,23 @@ const TicketsManagement = () => {
height={20}
/>
</Box>
) : null
) : (
<Box className={classes.optionIcon}>
<IconEdit
className={classes.editIcon}
onClick={() => {
setAction('update')
setItem(row)
form.reset()
console.log(row)
form.setFieldValue('status', row.status)
form.setFieldValue('admin_note', row.admin_note)
}}
width={20}
height={20}
/>
</Box>
)
},
},
]
@ -347,6 +384,27 @@ const TicketsManagement = () => {
}
}
const handleUpdate = async (values: TTickets) => {
try {
const res = await update(
updateTicket + `?ticket_id=${item?.id}`,
{
ticket_id: item.id,
admin_note: values.admin_note,
status: values.status,
},
getAllTickets,
)
if (res === true) {
setAction('')
setIsRefuseConfirmOpen(false)
form.reset()
}
} catch (error) {
console.log(error)
}
}
useEffect(() => {
getAllTickets()
}, [filter])
@ -356,6 +414,7 @@ const TicketsManagement = () => {
ticket_id: 0,
action: '',
admin_note: '',
status: '',
},
})
@ -416,21 +475,38 @@ const TicketsManagement = () => {
</Box>
<Modal
opened={action === 'confirm' || action === 'refuse'}
opened={
action === 'confirm' || action === 'refuse' || action === 'update'
}
onClose={() => {
setAction('')
setIsRefuseConfirmOpen(false)
form.reset()
}}
title={
<Text pl={'sm'} fw={700} fz={'lg'}>
{action === 'confirm' ? 'Confirm Ticket' : 'Refuse Ticket'}
{action === 'confirm'
? 'Confirm Ticket'
: action === 'refuse'
? 'Refuse Ticket'
: 'Update Ticket'}
</Text>
}
>
<form
onSubmit={form.onSubmit(async (values) => {
setDisableBtn(true)
await handleSave(values)
if (action === 'update') {
if (values.status === 'REFUSED' && item.status !== 'REFUSED') {
setIsRefuseConfirmOpen(true)
} else {
await handleUpdate(values)
}
} else {
await handleSave(values)
}
setDisableBtn(false)
})}
>
@ -509,14 +585,39 @@ const TicketsManagement = () => {
<Textarea
label="Admin Notes"
// required
value={form.values.admin_note}
onChange={(e) => form.setFieldValue('admin_note', e.target.value)}
/>
{action === 'update' && item.status !== 'REFUSED' ? (
<Switch
mt="md"
color="red"
label={
<Text size="sm">
Change status to{' '}
<Text span color="red" fw="bold">
Refuse
</Text>
</Text>
}
style={{ width: 'fit-content' }}
checked={form.values.status === 'REFUSED'}
onChange={(event) =>
form.setFieldValue(
'status',
event.currentTarget.checked ? 'REFUSED' : 'CONFIRMED',
)
}
/>
) : (
''
)}
<Box ta={'center'}>
<Button
mt={'lg'}
bg={'green'}
bg={action === 'update' ? 'blue' : 'green'}
type="submit"
disabled={disableBtn}
>
@ -526,6 +627,50 @@ const TicketsManagement = () => {
</Box>
</form>
</Modal>
<Modal
opened={isRefuseConfirmOpen}
onClose={() => {
setIsRefuseConfirmOpen(false)
}}
centered
size="sm"
classNames={{
content: classes.deleteModal,
}}
>
<Text className={classes.deleteModalTitle}>Confirm Update</Text>
<Text className={classes.deleteModalContent}>
Changing ticket status to <strong>Refused</strong> will also delete
all related notes.
</Text>
<Text className={classes.deleteModalContent}>
Are you sure you want to proceed?
</Text>
<Box className={classes.deleteModalFooter}>
<Button
variant="outline"
onClick={() => {
setIsRefuseConfirmOpen(false)
}}
disabled={disableBtn}
>
Cancel
</Button>
<Button
className={classes.deleteButton}
onClick={async () => {
setDisableBtn(true)
await handleUpdate(form.values)
setDisableBtn(false)
}}
disabled={disableBtn}
>
Confirm
</Button>
</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,10 @@ const Timekeeping = () => {
const [exportModalOpened, setExportModalOpened] = useState(false)
const [exportOption, setExportOption] = useState('default')
const [isDeleteConfirmOpen, setIsDeleteConfirmOpen] = useState(false)
const [isDisableDeleteBtn, setIsDisableDeleteBtn] = useState(false)
const [noteToDelete, setNoteToDelete] = useState<any>(null)
const getListMasterByType = async (type: string) => {
try {
const params = {
@ -445,16 +449,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 +466,64 @@ const Timekeeping = () => {
}
}
const handleConfirmDelete = async () => {
if (noteToDelete) {
setIsDisableDeleteBtn(true)
await handleDelete(noteToDelete.id)
setIsDisableDeleteBtn(false)
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}>
This action will change the ticket status to <strong>Refused</strong>{' '}
and delete all related notes.
</Text>
<Text className={classes.deleteModalContent}>
Are you sure you want to proceed?
</Text>
<Box className={classes.deleteModalFooter}>
<Button
variant="outline"
onClick={() => {
setIsDeleteConfirmOpen(false)
setNoteToDelete(null)
}}
disabled={isDisableDeleteBtn}
>
Cancel
</Button>
<Button
className={classes.deleteButton}
onClick={handleConfirmDelete}
disabled={isDisableDeleteBtn}
>
Delete
</Button>
</Box>
</Modal>
)
return (
<div>
<div className={classes.title}>
@ -631,11 +693,7 @@ const Timekeeping = () => {
>
<IconTrash
className={classes.deleteIcon}
onClick={async () => {
await handleDelete(item.id)
// handleUpdateCacheMonth()
// close2()
}}
onClick={() => openDeleteConfirm(item)}
width={20}
height={20}
/>
@ -655,7 +713,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 +768,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 +1132,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

@ -45,3 +45,37 @@
.dialogText {
color: light-dark(#2d353c, white);
}
/* Thêm styles cho Modal xác nhận xóa */
.deleteModal {
background-color: light-dark(white, #2d353c);
text-align: center;
border: solid 1px rgb(9, 132, 132);
}
.deleteModalTitle {
color: rgb(9, 132, 132);
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: rgb(9, 132, 132);
}
.deleteButton:hover {
background-color: rgb(9, 132, 132);
}

View File

@ -13,6 +13,7 @@ import {
Group,
Modal,
MultiSelect,
Switch,
Text,
TextInput,
} from '@mantine/core'
@ -24,28 +25,31 @@ const UsersManagement = () => {
const [users, setUsers] = useState<TUser[]>([])
const [action, setAction] = useState('')
const [activeBtn, setActiveBtn] = useState(false)
const [item, setItem] = useState({ id: 0 })
const [item, setItem] = useState({ id: 0, is_permanent: false })
const [disableBtn, setDisableBtn] = useState(false)
const [info, setInfo] = useState('')
const [isPermanentConfirmOpen, setIsPermanentConfirmOpen] =
useState<boolean>(false)
const columns = [
{
name: 'id',
size: '3%',
size: '5%',
header: 'ID',
},
{
name: 'name',
size: '17%',
size: '20%',
header: 'Name',
},
{
name: 'email',
size: '26%',
size: '25%',
header: 'Email',
},
{
name: 'permission',
size: '10%',
size: '20%',
header: 'Permission',
render: (row: TUser) => {
if (row.permission.includes(',')) {
@ -57,9 +61,21 @@ const UsersManagement = () => {
}
},
},
{
name: 'is_permanent',
size: '20%',
header: 'Employment Type',
render: (row: TUser) => {
return row.is_permanent ? (
<Badge color="teal">Permanent</Badge>
) : (
<Badge color="violet">Probation</Badge>
)
},
},
{
name: '#',
size: '5%',
size: '10%',
header: 'Action',
render: (row: TUser) => {
return (
@ -68,6 +84,8 @@ const UsersManagement = () => {
className={classes.editIcon}
onClick={() => {
setAction('edit')
setItem(row)
form.reset()
form.setValues(row)
}}
width={20}
@ -94,6 +112,7 @@ const UsersManagement = () => {
name: '',
email: '',
permission: '',
is_permanent: false,
},
})
@ -128,6 +147,7 @@ const UsersManagement = () => {
const res = await update(createOrUpdateUser, values, getAll)
if (res === true) {
setAction('')
setIsPermanentConfirmOpen(false)
form.reset()
}
} catch (error) {
@ -172,6 +192,7 @@ const UsersManagement = () => {
opened={action === 'add' || action === 'edit'}
onClose={() => {
setAction('')
setIsPermanentConfirmOpen(false)
form.reset()
}}
title={
@ -183,9 +204,15 @@ const UsersManagement = () => {
<form
onSubmit={form.onSubmit(async (values) => {
setDisableBtn(true)
action === 'edit'
? await handleUpdate(values)
: await handleCreate(values)
if (action === 'edit') {
if (values.is_permanent && !item.is_permanent) {
setIsPermanentConfirmOpen(true)
} else {
await handleUpdate(values)
}
} else {
await handleCreate(values)
}
setDisableBtn(false)
})}
>
@ -196,6 +223,7 @@ const UsersManagement = () => {
value={form.values.name}
error={form.errors.name}
onChange={(e) => form.setFieldValue('name', e.target.value)}
required
/>
<TextInput
@ -204,6 +232,7 @@ const UsersManagement = () => {
value={form.values.email}
error={form.errors.email}
onChange={(e) => form.setFieldValue('email', e.target.value)}
required
/>
<MultiSelect
@ -224,7 +253,25 @@ const UsersManagement = () => {
e!.filter((p) => p.trim() !== '').join(','),
)
}
mb={'md'}
/>
{action === 'edit' && !item.is_permanent ? (
<Switch
label="Permanent employee"
style={{ width: 'fit-content' }}
checked={form.values.is_permanent}
onChange={(event) =>
form.setFieldValue(
'is_permanent',
event.currentTarget.checked,
)
}
/>
) : (
''
)}
<Box ta={'center'}>
{action === 'add' ? (
<Button
@ -273,6 +320,50 @@ const UsersManagement = () => {
{info}
</Code>
</Modal>
{/* Confirm change to permanent employee */}
<Modal
opened={isPermanentConfirmOpen}
onClose={() => setIsPermanentConfirmOpen(false)}
centered
size="sm"
classNames={{
content: classes.deleteModal,
}}
>
<Text className={classes.deleteModalTitle}>Confirm Update</Text>
<Text className={classes.deleteModalContent}>
This action will change the employment type from{' '}
<strong>Probation</strong> to <strong>Permanent</strong>.
</Text>
<Text className={classes.deleteModalContent}>
Are you sure you want to proceed?
</Text>
<Box className={classes.deleteModalFooter}>
<Button
variant="outline"
onClick={() => {
setIsPermanentConfirmOpen(false)
}}
disabled={disableBtn}
>
Cancel
</Button>
<Button
className={classes.deleteButton}
onClick={async () => {
setDisableBtn(true)
await handleUpdate(form.values)
setDisableBtn(false)
}}
disabled={disableBtn}
>
Confirm
</Button>
</Box>
</Modal>
<Dialog
className={classes.dialog}
opened={action === 'delete'}

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',
})
}

View File

@ -76,6 +76,7 @@ export type TUser = {
email: string
name: string
permission: string
is_permanent: boolean
}
export type DataReason = {