Merge pull request 'dev' (#130) from dev into master

Reviewed-on: #130
This commit is contained in:
joseph 2025-06-30 18:37:38 +10:00
commit 8f6977be72
46 changed files with 3416 additions and 481 deletions

View File

@ -14,7 +14,7 @@ class CategoryController extends Controller
* @param Request $request The HTTP request object.
* @return \Illuminate\Http\JsonResponse The JSON response containing the list of master data.
*/
public function getListMaster(Request $request)
public static function getListMaster(Request $request)
{
$data = Category::where('c_type', '=', $request->type)->where('c_active', '=', 1)->select('id', 'c_code', 'c_name', 'c_value', 'c_type')->get();
return AbstractController::ResultSuccess($data);
@ -24,4 +24,9 @@ class CategoryController extends Controller
$data = Category::where('c_type', '=', $type)->where('c_code', '=', $code)->where('c_active', '=', 1)->select('id', 'c_code', 'c_name', 'c_value', 'c_type')->first();
return $data;
}
public static function getListMasterByType($type)
{
$data = Category::where('c_type', '=', $type)->where('c_active', '=', 1)->select('id', 'c_code', 'c_name', 'c_value', 'c_type')->get();
return $data;
}
}

View File

@ -35,7 +35,7 @@ class LeaveManagementController extends Controller
})
->leftJoin("categories as reason", function ($join) {
$join->on('n_reason', '=', 'reason.c_code');
$join->on('reason.c_type', DB::raw("CONCAT('REASON')"));
$join->on('reason.c_type', DB::raw("CONCAT('REASON_NOTES')"));
})
->select(
DB::raw('notes.n_user_id as n_user_id'),
@ -44,13 +44,14 @@ class LeaveManagementController extends Controller
DB::raw('notes.n_month as month'),
DB::raw('categories.c_value as leave_days'),
DB::raw('notes.n_day as day'),
DB::raw('notes.n_reason as reason_code'),
'reason.c_name as reason_name',
'categories.c_name as time_type_name',
// DB::raw('SUM(categories.c_value) as leave_days')
)
// ->where('notes.n_user_id', "1")
->where('notes.n_year', $year)
->where('notes.n_reason', 'ONLEAVE')
->whereIn('notes.n_reason', ['ONLEAVE', 'LEAVE_WITHOUT_PAY', 'TEMPORARY_ONLEAVE'])
// ->groupBy("notes.n_user_id")
->orderBy('notes.n_month')
->orderBy('notes.n_day')
@ -59,7 +60,7 @@ class LeaveManagementController extends Controller
return [
"day" => $item->day,
"n_user_id" => $item->n_user_id,
// "time_type" => $item->time_type,
"reason_code" => $item->reason_code,
"reason_name" => $item->reason_name,
"time_type_name" => $item->time_type_name,
"month" => $item->month,
@ -105,9 +106,10 @@ class LeaveManagementController extends Controller
'leaveDay' => [
'id' => $item->id,
'ld_user_id' => $item->ld_user_id,
'ld_day' => $item->ld_day,
'ld_day_total' => $item->ld_day_total,
'ld_year' => $item->ld_year,
'ld_date_additional' => $item->ld_date_additional,
'ld_additional_day' => $item->ld_additional_day,
'ld_special_leave_day' => $item->ld_special_leave_day,
'ld_note' => $item->ld_note,
'created_at' => $item->created_at,
'updated_at' => $item->updated_at,
@ -133,8 +135,9 @@ class LeaveManagementController extends Controller
$validatedData = $request->all();
$leaveDays = LeaveDays::find($validatedData['id']);
$leaveDays->ld_day = $validatedData['totalLeave'];
$leaveDays->ld_date_additional = $validatedData['dayAdditional']; // Assuming you have this field to store additional days
$leaveDays->ld_day_total = $validatedData['totalLeave'];
$leaveDays->ld_additional_day = $validatedData['dayAdditional'];
$leaveDays->ld_special_leave_day = $validatedData['specialLeave'];
$leaveDays->ld_note = $validatedData['note'];
$leaveDays->save();
@ -152,7 +155,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;
});

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,54 @@ 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);
return response()->json(['message' => 'Delete success', 'status' => true]);
if (!$note) {
return response()->json(['message' => 'Note not found', 'status' => false]);
}
return response()->json(['message' => 'Delete fail', 'status' => false]);
$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]);
}
public function export(Request $request)
@ -206,10 +240,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
@ -163,6 +164,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');

View File

@ -3,13 +3,15 @@
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;
class UserController extends Controller
@ -34,9 +36,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 +74,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 +141,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

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

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

@ -48,6 +48,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

View File

@ -1,14 +1,18 @@
import { getLeaveManagement, updateNoteLeave, exportLeaveManagement } from '@/api/Admin'
import { update } from '@/rtk/helpers/CRUD'
import { get, exportFile } from '@/rtk/helpers/apiService'
import { useEffect, useState } from 'react'
import moment from 'moment'
import {
Avatar,
Badge,
Box,
Button,
Drawer,
Flex,
Group,
HoverCard,
Menu,
Select,
Stack,
Table,
Text,
Textarea,
@ -17,13 +21,17 @@ import {
} from '@mantine/core'
import { useDisclosure } from '@mantine/hooks'
import { notifications } from '@mantine/notifications'
import moment from 'moment'
import { useEffect, useState } from 'react'
import { IconEdit, IconFileExcel } from '@tabler/icons-react'
import { IconEdit, IconFileExcel, IconHelpCircle } from '@tabler/icons-react'
import classes from './LeaveManagement.module.css'
import {
getLeaveManagement,
updateNoteLeave,
exportLeaveManagement,
} from '@/api/Admin'
import { update } from '@/rtk/helpers/CRUD'
import { get, exportFile } from '@/rtk/helpers/apiService'
interface User {
id: number
name: string
@ -40,8 +48,9 @@ interface LeaveDay {
id: number
ld_user_id: number
ld_year: number
ld_day: number
ld_date_additional: number
ld_day_total: number
ld_additional_day: number
ld_special_leave_day: number
ld_note: string
created_at: string | null
updated_at: string | null
@ -52,6 +61,7 @@ interface MonthlyLeaveDays {
leave_days: number
month: number
n_user_id: number
reason_code: string
reason_name: string
time_type_name: string
}
@ -62,6 +72,18 @@ interface UserData {
monthlyLeaveDays: MonthlyLeaveDays[]
}
// interface DataReason {
// id: number
// c_code: string
// c_name: string
// }
// interface DataTimeType {
// id: number
// c_code: string
// c_name: string
// }
const LeaveManagement = () => {
const [opened1, { open: open1, close: close1 }] = useDisclosure(false)
const [disableBtn, setDisableBtn] = useState(false)
@ -75,6 +97,7 @@ const LeaveManagement = () => {
note: string
totalLeave: string
dayAdditional: string
specialLeave: string
}>({
id: 0,
user: {
@ -84,12 +107,53 @@ const LeaveManagement = () => {
note: '',
totalLeave: '',
dayAdditional: '',
specialLeave: '',
})
const [data, setData] = useState<UserData[]>([])
const [date, setDate] = useState({
year: new Date().getFullYear().toString(),
})
// const [dataTimeType, setDataTimeType] = useState<DataTimeType[]>([])
// const [dataReason, setDataReason] = useState<DataReason[]>([])
const now = new Date()
const currentMonth = now.getMonth() + 1 // getMonth() trả về 0-11
const currentYear = now.getFullYear()
// const getListMasterByType = async (type: string) => {
// try {
// const params = {
// type: type,
// }
// const res = await get(getListMaster, params)
// if (res.status) {
// return res.data
// }
// } catch (error: any) {
// notifications.show({
// title: 'Error',
// message: error.message ?? error,
// color: 'red',
// })
// }
// return []
// }
// useEffect(() => {
// const fetchData = async () => {
// const resultTimeType = await getListMasterByType('TIME_TYPE')
// setDataTimeType(
// resultTimeType.filter((item: DataTimeType) => item.c_code !== 'ALL'),
// )
// const resultReason = await getListMasterByType('REASON')
// setDataReason(resultReason)
// }
// fetchData()
// }, [])
const getLeaveList = async () => {
try {
const res = await get(getLeaveManagement, {
@ -122,6 +186,7 @@ const LeaveManagement = () => {
},
totalLeave: string,
dayAdditional: string,
specialLeave: string,
note: string,
) => {
try {
@ -132,6 +197,7 @@ const LeaveManagement = () => {
users: users,
totalLeave: totalLeave,
dayAdditional: dayAdditional,
specialLeave: specialLeave,
note: note,
},
getLeaveList,
@ -202,8 +268,6 @@ const LeaveManagement = () => {
})
}
// console.log(customAddNotes, 'customAddNotes')
const getDetailLeaveDay = (monthlyLeaveDays: MonthlyLeaveDays[]) => {
type MonthlyLeaveDaysAcc = {
[key: string]: { n_user_id: number; month: number; leave_days: number }
@ -227,37 +291,97 @@ const LeaveManagement = () => {
}
const showAllOff = (monthlyLeaveDays: MonthlyLeaveDays[]) => {
let lastmonth = 0
return monthlyLeaveDays.map((itemDay, indexDay) => {
const isNewMonth = lastmonth !== itemDay.month
if (isNewMonth) {
lastmonth = itemDay.month
return monthInYear.map((d, i) => {
let totalOnLeaveMonth = 0
let totalLeaveWithoutPayMonth = 0
let totalTempMonth = 0
monthlyLeaveDays
.filter((item) => item.month === d.value)
.map((item) => {
if (item.reason_code === 'ONLEAVE') {
totalOnLeaveMonth += Number(item.leave_days)
} else if (item.reason_code === 'LEAVE_WITHOUT_PAY') {
totalLeaveWithoutPayMonth += Number(item.leave_days)
} else {
totalTempMonth += Number(item.leave_days)
}
})
if (totalOnLeaveMonth === 0 && totalLeaveWithoutPayMonth === 0) {
return ''
}
return (
<div key={indexDay}>
{isNewMonth && <p>Month {lastmonth}</p>}
<p style={{ paddingLeft: '20px' }}>
- {itemDay.reason_name} ({itemDay.time_type_name}) {itemDay.day}
/{itemDay.month}
</p>
</div>
<Box key={i} px="xs" my="xs">
<Group gap="xs">
{totalOnLeaveMonth > 0 && (
<Badge color="teal" variant="light">
{totalOnLeaveMonth} phép
</Badge>
)}
{totalLeaveWithoutPayMonth > 0 && (
<Badge color="red" variant="light">
{totalLeaveWithoutPayMonth} không phép
</Badge>
)}
<Text size="xs" color="dimmed">
({d.value}/2025)
</Text>
</Group>
</Box>
)
})
}
const showAllTotal = (
ld_day_total: number,
ld_additional_day: number,
ld_special_leave_day: number,
): JSX.Element => {
const showItem = (label: string, value: number, color: string = 'gray') => {
if (value === 0) return null
return (
<Group justify="space-between" gap="xs">
<Text size="sm" c="dimmed">
{label}
</Text>
<Text size="sm" fw={500} c={color}>
{value}
</Text>
</Group>
)
}
return (
<Box p="sm">
<Stack gap={4}>
{showItem(
'Tổng phép hiện có:',
ld_day_total + ld_additional_day + ld_special_leave_day,
'white',
)}
{showItem('+ Phép được cấp năm nay:', ld_day_total, 'teal')}
{showItem('+ Phép tồn năm trước:', ld_additional_day, 'violet')}
{showItem('+ Phép đặc biệt:', ld_special_leave_day, 'orange')}
</Stack>
</Box>
)
}
const handleExport = async () => {
try {
const timestamp = moment().format('DDMMYYYY_HHmmss')
const fileName = `LeaveManagement_${date.year}_${timestamp}.xlsx`
await exportFile(
exportLeaveManagement,
{
year: parseInt(date.year)
year: parseInt(date.year),
},
fileName
fileName,
)
} catch (error) {
console.error('Export error:', error)
notifications.show({
@ -271,10 +395,9 @@ const LeaveManagement = () => {
return (
<div>
<div className={classes.title}>
<h3>
Leave Management
</h3>
<h3>Leave Management</h3>
</div>
{/* Update Leave Day */}
<Drawer
opened={opened1}
onClose={close1}
@ -305,8 +428,8 @@ const LeaveManagement = () => {
})
}
}}
label={'Total Leave'}
placeholder="Input placeholder"
label={'Phép năm'}
placeholder="Nhập số ngày phép năm"
/>
<TextInput
mb={'md'}
@ -332,10 +455,36 @@ const LeaveManagement = () => {
})
}
}}
label={'Day additional leave'}
placeholder="Input placeholder"
label={'Phép năm cũ'}
placeholder="Nhập số ngày phép năm cũ"
/>
<TextInput
mb={'md'}
value={customAddNotes.specialLeave}
onChange={(e) => {
const value = e.target.value
if (value) {
const floatValue = parseFloat(value)
if (
/^\d*\.?\d?$/.test(value) &&
floatValue >= 0 &&
floatValue <= 20
) {
setCustomAddNotes({
...customAddNotes,
specialLeave: value,
})
}
} else {
setCustomAddNotes({
...customAddNotes,
specialLeave: '',
})
}
}}
label={'Phép đặc biệt'}
placeholder="Nhập số ngày phép đặc biệt"
/>
<Textarea
mb={'md'}
label="Note"
@ -343,6 +492,7 @@ const LeaveManagement = () => {
onChange={(e) => {
setCustomAddNotes({ ...customAddNotes, note: e.target.value })
}}
rows={10}
/>
<Button
@ -368,6 +518,7 @@ const LeaveManagement = () => {
customAddNotes.user,
customAddNotes.totalLeave,
customAddNotes.dayAdditional,
customAddNotes.specialLeave,
customAddNotes.note,
)
}
@ -377,77 +528,76 @@ const LeaveManagement = () => {
Save
</Button>
</Drawer>
<Box display={'flex'}>
<Box style={{ display: 'flex', flexFlow: 'column' }} w={'30%'}>
<Box w="100%" display={'flex'}>
<Select
w="50%"
value={date.year}
size="xs"
ml={'sm'}
label="Year"
data={Array.from({ length: 10 }, (_, index) => {
return {
value: (
parseInt(moment(Date.now()).format('YYYY')) -
3 +
index
).toString(),
label: (
parseInt(moment(Date.now()).format('YYYY')) -
3 +
index
).toString(),
disabled:
parseInt(moment(Date.now()).format('YYYY')) - 3 + index >
parseInt(moment(Date.now()).format('YYYY')),
}
})}
onChange={(e) => {
setDate({ ...date, year: e! })
}}
></Select>
</Box>
</Box>
<Box
w="70%"
pl={200}
style={{
display: 'flex',
justifyContent: 'end',
{/* Filter Year, Export Btn */}
<Flex justify="space-between" align="flex-end">
<Select
value={date.year}
size="xs"
label="Year"
data={Array.from({ length: 10 }, (_, index) => {
return {
value: (
parseInt(moment(Date.now()).format('YYYY')) -
3 +
index
).toString(),
label: (
parseInt(moment(Date.now()).format('YYYY')) -
3 +
index
).toString(),
disabled:
parseInt(moment(Date.now()).format('YYYY')) - 3 + index >
parseInt(moment(Date.now()).format('YYYY')),
}
})}
onChange={(e) => {
setDate({ ...date, year: e! })
}}
w={200}
/>
<Button
size="xs"
onClick={handleExport}
leftSection={<IconFileExcel size={16} />}
>
<Box display={'flex'} style={{ alignItems: 'end' }}>
<Button
size="xs"
ml={'sm'}
onClick={handleExport}
leftSection={<IconFileExcel size={16} />}
>
Export Excel
</Button>
</Box>
</Box>
</Box>
<Box>
Export Excel
</Button>
</Flex>
{/* Leave Day Table */}
<Box style={{ overflowX: 'auto' }}>
<Table
striped
highlightOnHover
withTableBorder
withColumnBorders
mt={'md'}
miw={1580}
>
<Table.Thead>
<Table.Tr bg={'#228be66b'}>
<Table.Th ></Table.Th>
<Table.Th ta={'center'} style={{ width: '40px' }}></Table.Th>
<Table.Th>User</Table.Th>
{monthInYear.map((d) => {
const isCurrentMonth =
Number(date.year) === currentYear && d.value === currentMonth
return (
<Menu width={200} shadow="md" key={d.value}>
<Menu.Target>
<Table.Th
ta={'center'}
style={{ cursor: 'pointer', width: '60px' }}
style={{
cursor: 'pointer',
width: '40px',
backgroundColor: isCurrentMonth
? '#F2E891'
: undefined,
color: isCurrentMonth ? '#000' : undefined,
fontWeight: isCurrentMonth ? 'bold' : undefined,
}}
>
<span>{d.name}</span>
</Table.Th>
@ -458,7 +608,7 @@ const LeaveManagement = () => {
<Table.Th ta={'center'} style={{ width: '80px' }}>
Total
</Table.Th>
<Table.Th ta={'center'} style={{ width: '80px' }}>
<Table.Th ta={'center'} style={{ width: '130px' }}>
Off
</Table.Th>
<Table.Th ta={'center'} style={{ width: '80px' }}>
@ -472,23 +622,41 @@ const LeaveManagement = () => {
{data.map((user, index) => {
let totalDayOff = 0
let totalDayLeave =
user.leaveDay.ld_day + user.leaveDay.ld_date_additional
user.leaveDay.ld_day_total +
user.leaveDay.ld_additional_day +
user.leaveDay.ld_special_leave_day
let ld_day_total = user.leaveDay.ld_day_total
let ld_additional_day = user.leaveDay.ld_additional_day
let ld_special_leave_day = user.leaveDay.ld_special_leave_day
let ld_note = user.leaveDay.ld_note
let totalOnLeave = 0
let totalLeaveWithoutPay = 0
return (
<Table.Tr key={user.user.id} className={classes.tableTr}>
<Table.Td ta={'center'}>{index + 1}</Table.Td>
<Table.Td>
<Tooltip multiline label={user.user.name}>
<div style={{display:'flex', alignItems:'center'}}><Avatar size={'md'} mr={'md'} src={import.meta.env.VITE_BACKEND_URL.includes('local')
? import.meta.env.VITE_BACKEND_URL +
'storage/' +
user.user.avatar
: import.meta.env.VITE_BACKEND_URL +
'image/storage/' +
user.user.avatar}/>{user.user.name}</div>
<div style={{ display: 'flex', alignItems: 'center' }}>
<Avatar
size={'md'}
mr={'md'}
src={
import.meta.env.VITE_BACKEND_URL.includes('local')
? import.meta.env.VITE_BACKEND_URL +
'storage/' +
user.user.avatar
: import.meta.env.VITE_BACKEND_URL +
'image/storage/' +
user.user.avatar
}
/>
{user.user.name}
</div>
</Tooltip>
</Table.Td>
{/* On leave per month */}
{monthInYear.map((d, i) => {
let leaveDataByMonth = getDetailLeaveDay(
user.monthlyLeaveDays,
@ -497,62 +665,220 @@ const LeaveManagement = () => {
const monthData = leaveDataByMonth[d.value]
let total = monthData ? monthData.leave_days : 0
totalDayOff = totalDayOff + total
let onleaveDaysInMonth: MonthlyLeaveDays[] = []
let nopayDaysInMonth: MonthlyLeaveDays[] = []
let tempDaysInMonth: MonthlyLeaveDays[] = []
let totalOnLeaveMonth = 0
let totalLeaveWithoutPayMonth = 0
let totalTempMonth = 0
let usedAdditionalDay = 0
user.monthlyLeaveDays
.filter((item) => item.month === d.value)
.map((item) => {
if (item.reason_code === 'ONLEAVE') {
totalOnLeaveMonth += Number(item.leave_days)
onleaveDaysInMonth.push(item)
} else if (item.reason_code === 'LEAVE_WITHOUT_PAY') {
totalLeaveWithoutPayMonth += Number(item.leave_days)
nopayDaysInMonth.push(item)
} else {
totalTempMonth += Number(item.leave_days)
tempDaysInMonth.push(item)
}
})
// Xử lý hiện thị phép tồn sử dụng
let tmpTotalOnleave = totalOnLeave
totalOnLeave += totalOnLeaveMonth
if (d.value < 4) {
if (totalOnLeave < ld_additional_day) {
usedAdditionalDay = totalOnLeaveMonth
totalOnLeaveMonth = 0
} else {
usedAdditionalDay = ld_additional_day - tmpTotalOnleave
if (usedAdditionalDay >= 0) {
totalOnLeaveMonth -= usedAdditionalDay
}
}
}
totalLeaveWithoutPay += totalLeaveWithoutPayMonth
return (
<Table.Td
bg={total > 0 ? '#ffb5b5' : ''}
opacity={d.value > currentMonth ? 0.4 : 1}
key={i}
ta={'center'}
>
<Tooltip
multiline
label={user.monthlyLeaveDays
.filter((item) => item.month === d.value)
.map((itemDay, indexDay) => {
return (
<p key={indexDay}>
- {itemDay.reason_name} (
{itemDay.time_type_name}) {itemDay.day}/
{itemDay.month}
</p>
)
})}
label={
<Box p={4}>
{usedAdditionalDay > 0 && (
<Text fw={500} c="violet" size="sm" mb={4}>
Phép tồn: {usedAdditionalDay}
</Text>
)}
{totalOnLeaveMonth > 0 && (
<Box>
<Text fw={500} c="teal" size="sm" mb={4}>
phép: {totalOnLeaveMonth}
</Text>
</Box>
)}
{totalOnLeaveMonth > 0 ||
usedAdditionalDay > 0 ? (
<Stack gap={2} pl="md">
{onleaveDaysInMonth?.map(
(itemDay: any, indexDay: number) => (
<Text size="xs" key={indexDay}>
{itemDay.time_type_name} (
{itemDay.day}/{itemDay.month})
</Text>
),
)}
</Stack>
) : (
''
)}
{totalLeaveWithoutPayMonth > 0 && (
<Box mt={6}>
<Text fw={500} c="red" size="sm" mb={4}>
Không phép: {totalLeaveWithoutPayMonth}
</Text>
<Stack gap={2} pl="md">
{nopayDaysInMonth?.map(
(itemDay: any, indexDay: number) => (
<Text size="xs" key={indexDay}>
{itemDay.time_type_name} (
{itemDay.day}/{itemDay.month})
</Text>
),
)}
</Stack>
</Box>
)}
{totalTempMonth > 0 && (
<Box mt={6}>
<Group align="center">
<Text fw={500} c="blue" size="sm" mb={3}>
Nghỉ dự kiến: {totalTempMonth}
</Text>
<Tooltip
multiline
opened
position="right"
label={
<Text size="xs">
Ngày nghỉ đã đưc duyệt, sẽ tính phép
khi đến tháng nghỉ.
</Text>
}
offset={{ mainAxis: 15 }}
>
<Text fw={500} c="blue">
<IconHelpCircle
width={15}
height={15}
/>
</Text>
</Tooltip>
</Group>
<Stack gap={2} pl="md">
{tempDaysInMonth?.map(
(itemDay: any, indexDay: number) => (
<Text size="xs" key={indexDay}>
{itemDay.time_type_name} (
{itemDay.day}/{itemDay.month})
</Text>
),
)}
</Stack>
</Box>
)}
</Box>
}
>
<p>{total === 0 ? '' : total}</p>
<Text size="sm">{total === 0 ? '' : total}</Text>
</Tooltip>
</Table.Td>
)
})}
<Table.Td
ta={'center'}
bg={totalDayLeave > 0 ? '#92e6f2' : ''}
>
{totalDayLeave}
{/* Total */}
<Table.Td ta={'center'} bg="#92e6f2">
<Tooltip
multiline
label={showAllTotal(
ld_day_total,
ld_additional_day,
ld_special_leave_day,
)}
>
<Text size="sm">{totalDayLeave}</Text>
</Tooltip>
</Table.Td>
<Table.Td ta={'center'} bg={totalDayOff > 0 ? '#ffb5b5' : ''}>
{/* Off */}
<Table.Td>
{totalDayOff > 0 ? (
<Tooltip
multiline
label={showAllOff(user.monthlyLeaveDays)}
>
<p> {totalDayOff}</p>
<Box>
<Flex justify="space-between" mb="xs" align="center">
<Text size="sm"> phép: </Text>
<Text
size="sm"
bg="#c3ffc3"
fw="bold"
p={5}
style={{ borderRadius: 5 }}
>
{totalOnLeave}
</Text>
</Flex>
<Flex justify="space-between" align="center">
<Text size="sm">Không phép: </Text>
<Text
size="sm"
bg="#ffb5b5"
fw="bold"
p={5}
style={{ borderRadius: 5 }}
>
{totalLeaveWithoutPay}
</Text>
</Flex>
</Box>
</Tooltip>
) : (
<></>
''
)}
</Table.Td>
{/* Remaining */}
<Table.Td
ta={'center'}
bg={
totalDayLeave - totalDayOff == 0
? ''
: totalDayLeave - totalDayOff > 0
? '#c3ffc3'
: '#ffb5b5'
}
bg={totalDayLeave - totalOnLeave > 0 ? '#b5cafb' : ''}
>
{totalDayLeave - totalDayOff}
<Text size="sm">{totalDayLeave - totalOnLeave}</Text>
</Table.Td>
{/* Note */}
<Table.Td>
<Box
style={{
@ -571,13 +897,18 @@ const LeaveManagement = () => {
</Text>
</HoverCard.Target>
<HoverCard.Dropdown>
<Textarea size="sm" autosize>
{ld_note}
</Textarea>
<Textarea
size="sm"
autosize
value={ld_note}
readOnly
/>
</HoverCard.Dropdown>
</HoverCard>
</Box>
</Table.Td>
{/* Action */}
<Table.Td ta={'center'}>
<IconEdit
color="green"
@ -585,13 +916,17 @@ const LeaveManagement = () => {
style={{ cursor: 'pointer' }}
onClick={() => {
let totalLeave =
user.leaveDay.ld_day == 0
user.leaveDay.ld_day_total == 0
? ''
: String(user.leaveDay.ld_day)
: String(user.leaveDay.ld_day_total)
let dayAdditional =
user.leaveDay.ld_date_additional == 0
user.leaveDay.ld_additional_day == 0
? ''
: String(user.leaveDay.ld_date_additional)
: String(user.leaveDay.ld_additional_day)
let specialLeave =
user.leaveDay.ld_special_leave_day == 0
? ''
: String(user.leaveDay.ld_special_leave_day)
open1()
setCustomAddNotes({
...customAddNotes,
@ -599,6 +934,7 @@ const LeaveManagement = () => {
note: ld_note,
totalLeave: totalLeave,
dayAdditional: dayAdditional,
specialLeave: specialLeave,
user: {
id: user.user.id,
name: user.user.name,

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

@ -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') {
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 = {