Compare commits
117 Commits
truong-lea
...
master
| Author | SHA1 | Date |
|---|---|---|
|
|
f0bed712e6 | |
|
|
f1de8cb627 | |
|
|
b50dc96ce2 | |
|
|
744c206743 | |
|
|
a23f2155dc | |
|
|
25918fcb62 | |
|
|
607882343a | |
|
|
8cd93c578e | |
|
|
755297e5b0 | |
|
|
a3ec46db0a | |
|
|
ab7ccbe194 | |
|
|
4eb3dda0e1 | |
|
|
1cd6b3a108 | |
|
|
935e88bb72 | |
|
|
2675a60ffc | |
|
|
95e4e833b5 | |
|
|
8ad0ba9c25 | |
|
|
574e92bd23 | |
|
|
925823c525 | |
|
|
a594ba5ba2 | |
|
|
683f85efd4 | |
|
|
6a1e19ec7f | |
|
|
8e54ca0ceb | |
|
|
0f2d5db8a3 | |
|
|
980676fe16 | |
|
|
9d536955e4 | |
|
|
a5fa0cbcda | |
|
|
0a4332729d | |
|
|
2f1e26d3f6 | |
|
|
3d1b43e200 | |
|
|
8d86fc2182 | |
|
|
54b8b9737b | |
|
|
42c5791da8 | |
|
|
8d5199342d | |
|
|
beccf5796c | |
|
|
baa3216c69 | |
|
|
de351f0877 | |
|
|
2c94e91668 | |
|
|
b22c9b9cc5 | |
|
|
f494367683 | |
|
|
61124cd9bb | |
|
|
78066f624e | |
|
|
d7b7a61511 | |
|
|
8f30081e55 | |
|
|
a0bfb19a34 | |
|
|
7e369785d5 | |
|
|
54046ac676 | |
|
|
9f921750fc | |
|
|
bd0d4fa13b | |
|
|
f5362e54ef | |
|
|
17dc0c3374 | |
|
|
29dc787c65 | |
|
|
6ccf5e9e8a | |
|
|
5736166da3 | |
|
|
7be6a12edc | |
|
|
70436f737f | |
|
|
25162be83d | |
|
|
06027e8f23 | |
|
|
8c7a24d31c | |
|
|
1f08e2b503 | |
|
|
9d77dfc5d6 | |
|
|
e7aa80f14b | |
|
|
24bd605b7c | |
|
|
2a5f7b0b25 | |
|
|
9183540976 | |
|
|
cdce726ff1 | |
|
|
790cab255d | |
|
|
647fc455d5 | |
|
|
b5f5fa1748 | |
|
|
5a564e3703 | |
|
|
0af2357bc2 | |
|
|
a623659b52 | |
|
|
b35d4d7ca5 | |
|
|
522034ab76 | |
|
|
487f4227d8 | |
|
|
56d2889297 | |
|
|
7c36add579 | |
|
|
6c61819f2e | |
|
|
f649b2a6ac | |
|
|
d335803cf8 | |
|
|
da0bb9ad0d | |
|
|
d3d9acb1ce | |
|
|
a4751e9d38 | |
|
|
b52537cfe4 | |
|
|
efaf906f0a | |
|
|
3e865e5575 | |
|
|
4f6f3dd803 | |
|
|
ee281986b8 | |
|
|
6e1e452bf1 | |
|
|
6528b7bc59 | |
|
|
fd1be27414 | |
|
|
d064b242e6 | |
|
|
8f6977be72 | |
|
|
af67644416 | |
|
|
705e8f9216 | |
|
|
db4feedde3 | |
|
|
27b59ae939 | |
|
|
034b9eee2c | |
|
|
543b1af58c | |
|
|
d09fa6d432 | |
|
|
40f80579dc | |
|
|
b1e7aaa1ea | |
|
|
a883662f0b | |
|
|
a11f67f252 | |
|
|
fb6c58f1c0 | |
|
|
c2c9322e7d | |
|
|
3263bce784 | |
|
|
500d6ec34d | |
|
|
1b35f8481b | |
|
|
172af363de | |
|
|
1328320713 | |
|
|
7217a83e33 | |
|
|
8474bd97a1 | |
|
|
f8c2af2ec8 | |
|
|
fa47d32b07 | |
|
|
160596a7a3 | |
|
|
5cd7071459 |
|
|
@ -3,6 +3,7 @@
|
|||
namespace Modules\Admin\app\Http\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Http\Request;
|
||||
use Modules\Admin\app\Models\Category;
|
||||
|
||||
|
|
@ -29,4 +30,45 @@ class CategoryController extends Controller
|
|||
$data = Category::where('c_type', '=', $type)->where('c_active', '=', 1)->select('id', 'c_code', 'c_name', 'c_value', 'c_type')->get();
|
||||
return $data;
|
||||
}
|
||||
|
||||
public function workDays()
|
||||
{
|
||||
$saturday_work_schedules = Category::where('c_type', 'SATURDAY_WORK_SCHEDULE')
|
||||
->get();
|
||||
|
||||
return response()->json([
|
||||
'data' => $saturday_work_schedules,
|
||||
'status' => true
|
||||
]);
|
||||
}
|
||||
|
||||
public function updateWorkDays(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'c_code' => 'required|date_format:d-m-Y',
|
||||
]);
|
||||
|
||||
$schedule = Category::where('c_type', 'SATURDAY_WORK_SCHEDULE')->first();
|
||||
|
||||
if (!$schedule) {
|
||||
$schedule = Category::create([
|
||||
'c_type' => 'SATURDAY_WORK_SCHEDULE',
|
||||
'c_name' => "Ngày bắt đầu làm việc thứ 7 trong năm",
|
||||
'c_code' => $request->c_code,
|
||||
'c_value' => Carbon::now()->year,
|
||||
'c_active' => true,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
} else {
|
||||
$schedule->update([
|
||||
'c_code' => $request->c_code,
|
||||
]);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'status' => true,
|
||||
'message' => 'Saturday work schedule updated successfully'
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,359 @@
|
|||
<?php
|
||||
|
||||
namespace Modules\Admin\app\Http\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Traits\HasFilterRequest;
|
||||
use App\Traits\HasOrderByRequest;
|
||||
use App\Traits\HasSearchRequest;
|
||||
use Illuminate\Http\Request;
|
||||
use Modules\Admin\app\Models\ElectricityBill;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Barryvdh\DomPDF\Facade\Pdf;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class ElectricityBillController extends Controller
|
||||
{
|
||||
use HasOrderByRequest;
|
||||
use HasFilterRequest;
|
||||
use HasSearchRequest;
|
||||
|
||||
/**
|
||||
* Get all electricity bills with pagination
|
||||
*/
|
||||
public function index(Request $request)
|
||||
{
|
||||
try {
|
||||
$bills = new ElectricityBill;
|
||||
|
||||
// Order by
|
||||
$this->orderByRequest($bills, $request);
|
||||
|
||||
// Filter
|
||||
$this->filterRequest(
|
||||
builder: $bills,
|
||||
request: $request,
|
||||
filterKeys: [
|
||||
'billing_date' => self::F_TEXT,
|
||||
]
|
||||
);
|
||||
|
||||
// Search
|
||||
$this->searchRequest(
|
||||
builder: $bills,
|
||||
value: $request->get('search'),
|
||||
fields: ['billing_date', 'notes']
|
||||
);
|
||||
|
||||
$responseData = $bills
|
||||
->leftJoin('users as creator', 'electricity_bills.created_by', '=', 'creator.id')
|
||||
->leftJoin('users as updater', 'electricity_bills.updated_by', '=', 'updater.id')
|
||||
->orderBy('electricity_bills.billing_date', 'desc')
|
||||
->select(
|
||||
'electricity_bills.*',
|
||||
'creator.name as creator_name',
|
||||
'updater.name as updater_name'
|
||||
)
|
||||
->paginate($request->get('per_page', 15));
|
||||
|
||||
return AbstractController::ResultSuccess($responseData);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Error fetching electricity bills: ' . $e->getMessage());
|
||||
return AbstractController::ResultError($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create new electricity bill
|
||||
*/
|
||||
public function create(Request $request)
|
||||
{
|
||||
try {
|
||||
$validated = $request->validate([
|
||||
'billing_date' => 'required|string',
|
||||
'previous_reading' => 'required|numeric|min:0',
|
||||
'current_reading' => 'required|numeric|min:0',
|
||||
'unit_price' => 'required|numeric|min:0',
|
||||
'notes' => 'nullable|string',
|
||||
]);
|
||||
|
||||
// Check if billing_date already exists
|
||||
$existingBill = ElectricityBill::where('billing_date', $validated['billing_date'])->first();
|
||||
if ($existingBill) {
|
||||
return AbstractController::ResultError('Bill for this month already exists', 422);
|
||||
}
|
||||
|
||||
// Calculate total amount
|
||||
$consumption = $validated['current_reading'] - $validated['previous_reading'];
|
||||
$totalAmount = $consumption * $validated['unit_price'];
|
||||
|
||||
$bill = ElectricityBill::create([
|
||||
'billing_date' => $validated['billing_date'],
|
||||
'previous_reading' => $validated['previous_reading'],
|
||||
'current_reading' => $validated['current_reading'],
|
||||
'unit_price' => $validated['unit_price'],
|
||||
'total_amount' => $totalAmount,
|
||||
'notes' => $validated['notes'] ?? null,
|
||||
'created_by' => auth('admins')->user()->id ?? null,
|
||||
]);
|
||||
|
||||
return AbstractController::ResultSuccess($bill, 'Electricity bill created successfully');
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Error creating electricity bill: ' . $e->getMessage());
|
||||
return AbstractController::ResultError($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update electricity bill
|
||||
*/
|
||||
public function update(Request $request, $id)
|
||||
{
|
||||
try {
|
||||
$validated = $request->validate([
|
||||
'billing_date' => 'sometimes|string',
|
||||
'previous_reading' => 'sometimes|numeric|min:0',
|
||||
'current_reading' => 'sometimes|numeric|min:0',
|
||||
'unit_price' => 'sometimes|numeric|min:0',
|
||||
'notes' => 'nullable|string',
|
||||
]);
|
||||
|
||||
$bill = ElectricityBill::findOrFail($id);
|
||||
|
||||
// Check if billing_date already exists (excluding current record)
|
||||
if (isset($validated['billing_date'])) {
|
||||
$existingBill = ElectricityBill::where('billing_date', $validated['billing_date'])
|
||||
->where('id', '!=', $id)
|
||||
->first();
|
||||
if ($existingBill) {
|
||||
return AbstractController::ResultError('Bill for this month already exists', 422);
|
||||
}
|
||||
}
|
||||
|
||||
// Recalculate total if any reading or price changed
|
||||
$previousReading = $validated['previous_reading'] ?? $bill->previous_reading;
|
||||
$currentReading = $validated['current_reading'] ?? $bill->current_reading;
|
||||
$unitPrice = $validated['unit_price'] ?? $bill->unit_price;
|
||||
$consumption = $currentReading - $previousReading;
|
||||
$totalAmount = $consumption * $unitPrice;
|
||||
|
||||
$bill->update(array_merge($validated, [
|
||||
'total_amount' => $totalAmount,
|
||||
'updated_by' => auth('admins')->user()->id ?? null,
|
||||
]));
|
||||
|
||||
return AbstractController::ResultSuccess($bill, 'Electricity bill updated successfully');
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Error updating electricity bill: ' . $e->getMessage());
|
||||
return AbstractController::ResultError($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete electricity bill
|
||||
*/
|
||||
public function delete(Request $request, $id)
|
||||
{
|
||||
try {
|
||||
$bill = ElectricityBill::findOrFail($id);
|
||||
$bill->delete();
|
||||
|
||||
return AbstractController::ResultSuccess(null, 'Electricity bill deleted successfully');
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Error deleting electricity bill: ' . $e->getMessage());
|
||||
return AbstractController::ResultError($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Export electricity bill to PDF
|
||||
*/
|
||||
public function exportPdf(Request $request, $id)
|
||||
{
|
||||
try {
|
||||
$bill = ElectricityBill::findOrFail($id);
|
||||
|
||||
// Get month name from billing_date
|
||||
$consumption = $bill->current_reading - $bill->previous_reading;
|
||||
$totalText = $this->numberToVietnamese($bill->total_amount);
|
||||
$date = Carbon::parse($bill->billing_date);
|
||||
|
||||
$dateNow = 'Ngày ' . $date->day .
|
||||
' tháng ' . $date->month .
|
||||
' năm ' . $date->year;
|
||||
|
||||
// Generate PDF
|
||||
$pdf = Pdf::loadView('admin::admin.electricity_bills.pdf', [
|
||||
'bill' => $bill,
|
||||
'consumption' => $consumption,
|
||||
'dateNow' => $dateNow,
|
||||
'totalText' => $totalText
|
||||
]);
|
||||
|
||||
$fileName = 'electricity_bill_' . $bill->billing_date . '.pdf';
|
||||
$filePath = 'electricity_bills/' . $fileName;
|
||||
|
||||
// đảm bảo folder tồn tại
|
||||
if (!Storage::disk('public')->exists('electricity_bills')) {
|
||||
Storage::disk('public')->makeDirectory('electricity_bills');
|
||||
}
|
||||
|
||||
// 👇 render 1 lần
|
||||
$pdfContent = $pdf->output();
|
||||
|
||||
// 👇 lưu file
|
||||
Storage::disk('public')->put($filePath, $pdfContent);
|
||||
|
||||
// update DB
|
||||
$bill->update(['file_path' => $filePath]);
|
||||
|
||||
// 👇 trả về đúng file đã tạo
|
||||
return response($pdfContent)
|
||||
->header('Content-Type', 'application/pdf')
|
||||
->header('Content-Disposition', 'attachment; filename="' . $fileName . '"');
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Error exporting electricity bill to PDF: ' . $e->getMessage());
|
||||
return AbstractController::ResultError($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get electricity bill by ID
|
||||
*/
|
||||
public function show($id)
|
||||
{
|
||||
try {
|
||||
$bill = ElectricityBill::with(['creator', 'updater'])->findOrFail($id);
|
||||
return AbstractController::ResultSuccess($bill);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Error fetching electricity bill: ' . $e->getMessage());
|
||||
return AbstractController::ResultError($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload an attachment (PDF or image) for an electricity bill
|
||||
*/
|
||||
public function uploadAttachment(Request $request, $id)
|
||||
{
|
||||
try {
|
||||
$request->validate([
|
||||
'file' => 'required|file|mimes:pdf,jpg,jpeg,png,gif,webp|max:10240',
|
||||
]);
|
||||
|
||||
$bill = ElectricityBill::findOrFail($id);
|
||||
|
||||
$file = $request->file('file');
|
||||
$extension = $file->getClientOriginalExtension();
|
||||
$filename = 'bill_' . $bill->id . '_' . time() . '.' . $extension;
|
||||
$directory = 'electricity_bills/attachments';
|
||||
|
||||
if (!Storage::disk('public')->exists($directory)) {
|
||||
Storage::disk('public')->makeDirectory($directory);
|
||||
}
|
||||
|
||||
// Delete old attachment if exists
|
||||
if ($bill->attachment_path && Storage::disk('public')->exists($bill->attachment_path)) {
|
||||
Storage::disk('public')->delete($bill->attachment_path);
|
||||
}
|
||||
|
||||
$path = $file->storeAs($directory, $filename, 'public');
|
||||
|
||||
$bill->update([
|
||||
'attachment_path' => $path,
|
||||
'updated_by' => auth('admins')->user()->id ?? null,
|
||||
]);
|
||||
|
||||
return AbstractController::ResultSuccess($bill, 'Attachment uploaded successfully');
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Error uploading electricity bill attachment: ' . $e->getMessage());
|
||||
return AbstractController::ResultError($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the attachment of an electricity bill
|
||||
*/
|
||||
public function deleteAttachment($id)
|
||||
{
|
||||
try {
|
||||
$bill = ElectricityBill::findOrFail($id);
|
||||
|
||||
if ($bill->attachment_path && Storage::disk('public')->exists($bill->attachment_path)) {
|
||||
Storage::disk('public')->delete($bill->attachment_path);
|
||||
}
|
||||
|
||||
$bill->update([
|
||||
'attachment_path' => null,
|
||||
'updated_by' => auth('admins')->user()->id ?? null,
|
||||
]);
|
||||
|
||||
return AbstractController::ResultSuccess($bill, 'Attachment deleted successfully');
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Error deleting electricity bill attachment: ' . $e->getMessage());
|
||||
return AbstractController::ResultError($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
function numberToVietnamese($number)
|
||||
{
|
||||
$units = ["", "một", "hai", "ba", "bốn", "năm", "sáu", "bảy", "tám", "chín"];
|
||||
$levels = ["", "nghìn", "triệu", "tỷ"];
|
||||
|
||||
if ($number == 0) return "không đồng";
|
||||
|
||||
$number = (int)$number;
|
||||
$result = "";
|
||||
$level = 0;
|
||||
|
||||
while ($number > 0) {
|
||||
$threeDigits = $number % 1000;
|
||||
if ($threeDigits != 0) {
|
||||
$result = $this->readThreeDigits($threeDigits, $units) . " " . $levels[$level] . " " . $result;
|
||||
}
|
||||
$number = floor($number / 1000);
|
||||
$level++;
|
||||
}
|
||||
|
||||
return ucfirst(trim(preg_replace('/\s+/', ' ', $result))) . " đồng";
|
||||
}
|
||||
|
||||
function readThreeDigits($number, $units)
|
||||
{
|
||||
$hundreds = floor($number / 100);
|
||||
$tens = floor(($number % 100) / 10);
|
||||
$ones = $number % 10;
|
||||
|
||||
$result = "";
|
||||
|
||||
if ($hundreds > 0) {
|
||||
$result .= $units[$hundreds] . " trăm";
|
||||
if ($tens == 0 && $ones > 0) {
|
||||
$result .= " lẻ";
|
||||
}
|
||||
}
|
||||
|
||||
if ($tens > 1) {
|
||||
$result .= " " . $units[$tens] . " mươi";
|
||||
if ($ones == 1) {
|
||||
$result .= " mốt";
|
||||
} elseif ($ones == 5) {
|
||||
$result .= " lăm";
|
||||
} elseif ($ones > 0) {
|
||||
$result .= " " . $units[$ones];
|
||||
}
|
||||
} elseif ($tens == 1) {
|
||||
$result .= " mười";
|
||||
if ($ones == 5) {
|
||||
$result .= " lăm";
|
||||
} elseif ($ones > 0) {
|
||||
$result .= " " . $units[$ones];
|
||||
}
|
||||
} elseif ($ones > 0) {
|
||||
$result .= " " . $units[$ones];
|
||||
}
|
||||
|
||||
return trim($result);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,517 @@
|
|||
<?php
|
||||
|
||||
namespace Modules\Admin\app\Http\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Traits\HasFilterRequest;
|
||||
use App\Traits\HasOrderByRequest;
|
||||
use App\Traits\HasSearchRequest;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Modules\Admin\app\Models\Admin;
|
||||
use Modules\Admin\app\Models\Equipment;
|
||||
use Modules\Admin\app\Models\EquipmentAssignment;
|
||||
|
||||
class EquipmentController extends Controller
|
||||
{
|
||||
use HasOrderByRequest;
|
||||
use HasFilterRequest;
|
||||
use HasSearchRequest;
|
||||
|
||||
/**
|
||||
* Get all equipments with pagination + filter + search.
|
||||
*/
|
||||
public function index(Request $request)
|
||||
{
|
||||
try {
|
||||
$equipments = new Equipment;
|
||||
|
||||
$this->orderByRequest($equipments, $request);
|
||||
|
||||
$this->filterRequest(
|
||||
builder: $equipments,
|
||||
request: $request,
|
||||
filterKeys: [
|
||||
'code' => self::F_TEXT,
|
||||
'name' => self::F_TEXT,
|
||||
'note' => self::F_TEXT,
|
||||
]
|
||||
);
|
||||
|
||||
$this->searchRequest(
|
||||
builder: $equipments,
|
||||
value: $request->get('search'),
|
||||
fields: ['equipments.code', 'equipments.name', 'equipments.note']
|
||||
);
|
||||
|
||||
// Optional filter: assigned (in use) vs warehouse
|
||||
$assignedFilter = $request->get('assigned');
|
||||
if ($assignedFilter === 'in_use') {
|
||||
$equipments->whereNotNull('equipments.current_user_id');
|
||||
} elseif ($assignedFilter === 'warehouse') {
|
||||
$equipments->whereNull('equipments.current_user_id');
|
||||
}
|
||||
|
||||
$responseData = $equipments
|
||||
->leftJoin('users as holder', 'equipments.current_user_id', '=', 'holder.id')
|
||||
->orderBy('equipments.updated_at', 'desc')
|
||||
->select(
|
||||
'equipments.*',
|
||||
'holder.name as current_user_name'
|
||||
)
|
||||
->paginate($request->get('per_page', 15));
|
||||
|
||||
return AbstractController::ResultSuccess($responseData);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Error fetching equipments: ' . $e->getMessage());
|
||||
return AbstractController::ResultError($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single equipment by code (for barcode scan).
|
||||
*/
|
||||
public function showByCode(Request $request)
|
||||
{
|
||||
try {
|
||||
$code = trim((string) $request->get('code'));
|
||||
if ($code === '') {
|
||||
return AbstractController::ResultError('Code is required', 422);
|
||||
}
|
||||
|
||||
$equipment = Equipment::leftJoin('users as holder', 'equipments.current_user_id', '=', 'holder.id')
|
||||
->where('equipments.code', $code)
|
||||
->select('equipments.*', 'holder.name as current_user_name')
|
||||
->first();
|
||||
|
||||
if (!$equipment) {
|
||||
return AbstractController::ResultSuccess(null, 'Equipment not found');
|
||||
}
|
||||
|
||||
$history = EquipmentAssignment::leftJoin('users', 'equipment_assignments.user_id', '=', 'users.id')
|
||||
->where('equipment_assignments.equipment_id', $equipment->id)
|
||||
->orderBy('equipment_assignments.assigned_at', 'desc')
|
||||
->select(
|
||||
'equipment_assignments.*',
|
||||
'users.name as user_name'
|
||||
)
|
||||
->get();
|
||||
|
||||
$equipment->history = $history;
|
||||
|
||||
return AbstractController::ResultSuccess($equipment);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Error fetching equipment by code: ' . $e->getMessage());
|
||||
return AbstractController::ResultError($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get assignment history for an equipment.
|
||||
*/
|
||||
public function history($id)
|
||||
{
|
||||
try {
|
||||
$history = EquipmentAssignment::leftJoin('users', 'equipment_assignments.user_id', '=', 'users.id')
|
||||
->where('equipment_assignments.equipment_id', $id)
|
||||
->orderBy('equipment_assignments.assigned_at', 'desc')
|
||||
->select(
|
||||
'equipment_assignments.*',
|
||||
'users.name as user_name'
|
||||
)
|
||||
->get();
|
||||
|
||||
return AbstractController::ResultSuccess($history);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Error fetching equipment history: ' . $e->getMessage());
|
||||
return AbstractController::ResultError($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all users for assignment selectbox.
|
||||
*/
|
||||
public function userOptions()
|
||||
{
|
||||
try {
|
||||
$users = Admin::orderBy('name', 'asc')
|
||||
->select('id', 'name', 'email')
|
||||
->get();
|
||||
return AbstractController::ResultSuccess($users);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Error fetching user options: ' . $e->getMessage());
|
||||
return AbstractController::ResultError($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create new equipment. Accepts multipart with optional image file.
|
||||
*/
|
||||
public function create(Request $request)
|
||||
{
|
||||
try {
|
||||
$validated = $request->validate([
|
||||
'code' => 'required|string|max:255|unique:equipments,code',
|
||||
'name' => 'required|string|max:255',
|
||||
'note' => 'nullable|string',
|
||||
'current_user_id' => 'nullable|integer|exists:users,id',
|
||||
'image' => 'nullable|file|mimes:jpg,jpeg,png,gif,webp|max:10240',
|
||||
]);
|
||||
|
||||
$authId = auth('admins')->user()->id ?? null;
|
||||
|
||||
$imagePath = null;
|
||||
if ($request->hasFile('image')) {
|
||||
$directory = 'equipments/images';
|
||||
if (!Storage::disk('public')->exists($directory)) {
|
||||
Storage::disk('public')->makeDirectory($directory);
|
||||
}
|
||||
$imagePath = $request->file('image')->store($directory, 'public');
|
||||
}
|
||||
|
||||
$equipment = DB::transaction(function () use ($validated, $authId, $imagePath) {
|
||||
$now = Carbon::now();
|
||||
$currentUserId = $validated['current_user_id'] ?? null;
|
||||
|
||||
$equipment = Equipment::create([
|
||||
'code' => $validated['code'],
|
||||
'name' => $validated['name'],
|
||||
'note' => $validated['note'] ?? null,
|
||||
'image_path' => $imagePath,
|
||||
'current_user_id' => $currentUserId,
|
||||
'assigned_at' => $now,
|
||||
'created_by' => $authId,
|
||||
]);
|
||||
|
||||
// Always create an initial assignment record so the history
|
||||
// shows the very first state (warehouse or user).
|
||||
EquipmentAssignment::create([
|
||||
'equipment_id' => $equipment->id,
|
||||
'user_id' => $currentUserId, // null = at warehouse
|
||||
'assigned_at' => $now,
|
||||
'note' => $currentUserId ? 'Initial assignment' : 'Initial — at warehouse',
|
||||
'created_by' => $authId,
|
||||
]);
|
||||
|
||||
return $equipment;
|
||||
});
|
||||
|
||||
return AbstractController::ResultSuccess($equipment, 'Equipment created successfully');
|
||||
} catch (\Illuminate\Validation\ValidationException $e) {
|
||||
return AbstractController::ResultError($e->getMessage(), 422);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Error creating equipment: ' . $e->getMessage());
|
||||
return AbstractController::ResultError($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload / replace the equipment image (optional, post-create or edit).
|
||||
*/
|
||||
public function uploadImage(Request $request, $id)
|
||||
{
|
||||
try {
|
||||
$request->validate([
|
||||
'image' => 'required|file|mimes:jpg,jpeg,png,gif,webp|max:10240',
|
||||
]);
|
||||
|
||||
$equipment = Equipment::findOrFail($id);
|
||||
|
||||
// Delete old image if present
|
||||
if ($equipment->image_path && Storage::disk('public')->exists($equipment->image_path)) {
|
||||
Storage::disk('public')->delete($equipment->image_path);
|
||||
}
|
||||
|
||||
$directory = 'equipments/images';
|
||||
if (!Storage::disk('public')->exists($directory)) {
|
||||
Storage::disk('public')->makeDirectory($directory);
|
||||
}
|
||||
$path = $request->file('image')->store($directory, 'public');
|
||||
|
||||
$equipment->update([
|
||||
'image_path' => $path,
|
||||
'updated_by' => auth('admins')->user()->id ?? null,
|
||||
]);
|
||||
|
||||
return AbstractController::ResultSuccess($equipment, 'Image uploaded');
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Error uploading equipment image: ' . $e->getMessage());
|
||||
return AbstractController::ResultError($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the equipment image.
|
||||
*/
|
||||
public function deleteImage($id)
|
||||
{
|
||||
try {
|
||||
$equipment = Equipment::findOrFail($id);
|
||||
if ($equipment->image_path && Storage::disk('public')->exists($equipment->image_path)) {
|
||||
Storage::disk('public')->delete($equipment->image_path);
|
||||
}
|
||||
$equipment->update([
|
||||
'image_path' => null,
|
||||
'updated_by' => auth('admins')->user()->id ?? null,
|
||||
]);
|
||||
return AbstractController::ResultSuccess($equipment, 'Image deleted');
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Error deleting equipment image: ' . $e->getMessage());
|
||||
return AbstractController::ResultError($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update equipment basic info (code, name, note).
|
||||
*/
|
||||
public function update(Request $request, $id)
|
||||
{
|
||||
try {
|
||||
$equipment = Equipment::findOrFail($id);
|
||||
|
||||
$validated = $request->validate([
|
||||
'code' => 'sometimes|string|max:255|unique:equipments,code,' . $id,
|
||||
'name' => 'sometimes|string|max:255',
|
||||
'note' => 'nullable|string',
|
||||
]);
|
||||
|
||||
$equipment->update(array_merge($validated, [
|
||||
'updated_by' => auth('admins')->user()->id ?? null,
|
||||
]));
|
||||
|
||||
return AbstractController::ResultSuccess($equipment, 'Equipment updated successfully');
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Error updating equipment: ' . $e->getMessage());
|
||||
return AbstractController::ResultError($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Assign equipment to a user, or return to warehouse (user_id = null).
|
||||
* Every state change is recorded: closes the previous open record AND opens
|
||||
* a new one for the new holder (user or warehouse).
|
||||
*/
|
||||
public function assign(Request $request, $id)
|
||||
{
|
||||
try {
|
||||
$validated = $request->validate([
|
||||
'user_id' => 'nullable|integer|exists:users,id',
|
||||
'note' => 'nullable|string',
|
||||
]);
|
||||
|
||||
$authId = auth('admins')->user()->id ?? null;
|
||||
|
||||
$equipment = DB::transaction(function () use ($id, $validated, $authId) {
|
||||
$equipment = Equipment::lockForUpdate()->findOrFail($id);
|
||||
$now = Carbon::now();
|
||||
$newUserId = $validated['user_id'] ?? null;
|
||||
|
||||
// No-op guard: if assigning to the same holder, skip to avoid duplicate history rows
|
||||
if ($equipment->current_user_id == $newUserId) {
|
||||
return $equipment;
|
||||
}
|
||||
|
||||
// Close any currently open assignment (records who/Kho was holding it until now)
|
||||
EquipmentAssignment::where('equipment_id', $equipment->id)
|
||||
->whereNull('returned_at')
|
||||
->update([
|
||||
'returned_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
// Always open a new assignment row — for a user OR for the warehouse.
|
||||
// user_id = null means "currently at warehouse".
|
||||
EquipmentAssignment::create([
|
||||
'equipment_id' => $equipment->id,
|
||||
'user_id' => $newUserId,
|
||||
'assigned_at' => $now,
|
||||
'note' => $validated['note'] ?? ($newUserId === null ? 'Returned to warehouse' : null),
|
||||
'created_by' => $authId,
|
||||
]);
|
||||
|
||||
$equipment->update([
|
||||
'current_user_id' => $newUserId,
|
||||
'assigned_at' => $now,
|
||||
'updated_by' => $authId,
|
||||
]);
|
||||
|
||||
return $equipment->fresh();
|
||||
});
|
||||
|
||||
return AbstractController::ResultSuccess($equipment, 'Equipment assignment updated');
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Error assigning equipment: ' . $e->getMessage());
|
||||
return AbstractController::ResultError($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Statistics: list users grouped with the equipments they are currently holding.
|
||||
*/
|
||||
public function userStatistics(Request $request)
|
||||
{
|
||||
try {
|
||||
$search = trim((string) $request->get('search', ''));
|
||||
|
||||
$usersQuery = DB::table('equipments')
|
||||
->join('users', 'equipments.current_user_id', '=', 'users.id')
|
||||
->whereNotNull('equipments.current_user_id');
|
||||
|
||||
if ($search !== '') {
|
||||
$usersQuery->where(function ($q) use ($search) {
|
||||
$q->where('users.name', 'like', "%{$search}%")
|
||||
->orWhere('users.email', 'like', "%{$search}%")
|
||||
->orWhere('equipments.code', 'like', "%{$search}%")
|
||||
->orWhere('equipments.name', 'like', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
$userTotals = (clone $usersQuery)
|
||||
->select(
|
||||
'users.id as user_id',
|
||||
'users.name as user_name',
|
||||
'users.email as user_email',
|
||||
DB::raw('COUNT(equipments.id) as equipment_count')
|
||||
)
|
||||
->groupBy('users.id', 'users.name', 'users.email')
|
||||
->orderByDesc('equipment_count')
|
||||
->orderBy('users.name')
|
||||
->get();
|
||||
|
||||
$userIds = $userTotals->pluck('user_id')->all();
|
||||
|
||||
$devices = Equipment::leftJoin('users as holder', 'equipments.current_user_id', '=', 'holder.id')
|
||||
->whereIn('equipments.current_user_id', $userIds)
|
||||
->select(
|
||||
'equipments.id',
|
||||
'equipments.code',
|
||||
'equipments.name',
|
||||
'equipments.note',
|
||||
'equipments.image_path',
|
||||
'equipments.current_user_id',
|
||||
'equipments.assigned_at'
|
||||
)
|
||||
->orderBy('equipments.assigned_at', 'desc')
|
||||
->get()
|
||||
->groupBy('current_user_id');
|
||||
|
||||
$result = $userTotals->map(function ($u) use ($devices) {
|
||||
return [
|
||||
'user_id' => $u->user_id,
|
||||
'user_name' => $u->user_name,
|
||||
'user_email' => $u->user_email,
|
||||
'equipment_count' => (int) $u->equipment_count,
|
||||
'equipments' => ($devices->get($u->user_id) ?? collect())->values(),
|
||||
];
|
||||
});
|
||||
|
||||
// Also include warehouse summary
|
||||
$warehouseCount = Equipment::whereNull('current_user_id')->count();
|
||||
|
||||
return AbstractController::ResultSuccess([
|
||||
'users' => $result,
|
||||
'warehouse_count' => $warehouseCount,
|
||||
'total_equipment' => Equipment::count(),
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Error fetching equipment user statistics: ' . $e->getMessage());
|
||||
return AbstractController::ResultError($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete equipment (and its history via cascade).
|
||||
*/
|
||||
public function delete($id)
|
||||
{
|
||||
try {
|
||||
$equipment = Equipment::findOrFail($id);
|
||||
$equipment->delete();
|
||||
return AbstractController::ResultSuccess(null, 'Equipment deleted successfully');
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Error deleting equipment: ' . $e->getMessage());
|
||||
return AbstractController::ResultError($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Export the (filtered) equipment list as CSV.
|
||||
*/
|
||||
public function export(Request $request)
|
||||
{
|
||||
try {
|
||||
$equipments = new Equipment;
|
||||
|
||||
$this->filterRequest(
|
||||
builder: $equipments,
|
||||
request: $request,
|
||||
filterKeys: [
|
||||
'code' => self::F_TEXT,
|
||||
'name' => self::F_TEXT,
|
||||
'note' => self::F_TEXT,
|
||||
]
|
||||
);
|
||||
|
||||
$this->searchRequest(
|
||||
builder: $equipments,
|
||||
value: $request->get('search'),
|
||||
fields: ['equipments.code', 'equipments.name', 'equipments.note']
|
||||
);
|
||||
|
||||
$assignedFilter = $request->get('assigned');
|
||||
if ($assignedFilter === 'in_use') {
|
||||
$equipments->whereNotNull('equipments.current_user_id');
|
||||
} elseif ($assignedFilter === 'warehouse') {
|
||||
$equipments->whereNull('equipments.current_user_id');
|
||||
}
|
||||
|
||||
$rows = $equipments
|
||||
->leftJoin('users as holder', 'equipments.current_user_id', '=', 'holder.id')
|
||||
->orderBy('equipments.updated_at', 'desc')
|
||||
->select(
|
||||
'equipments.id',
|
||||
'equipments.code',
|
||||
'equipments.name',
|
||||
'equipments.note',
|
||||
'equipments.assigned_at',
|
||||
'equipments.created_at',
|
||||
'equipments.updated_at',
|
||||
'holder.name as current_user_name'
|
||||
)
|
||||
->get();
|
||||
|
||||
$filename = 'equipments_' . date('Y-m-d_His') . '.csv';
|
||||
|
||||
$callback = function () use ($rows) {
|
||||
$out = fopen('php://output', 'w');
|
||||
// BOM for Excel UTF-8
|
||||
fwrite($out, "\xEF\xBB\xBF");
|
||||
fputcsv($out, ['ID', 'Code', 'Name', 'Note', 'Current Holder', 'Assigned At', 'Created At', 'Updated At']);
|
||||
foreach ($rows as $r) {
|
||||
fputcsv($out, [
|
||||
$r->id,
|
||||
$r->code,
|
||||
$r->name,
|
||||
$r->note,
|
||||
$r->current_user_name ?: 'Kho',
|
||||
$r->assigned_at,
|
||||
$r->created_at,
|
||||
$r->updated_at,
|
||||
]);
|
||||
}
|
||||
fclose($out);
|
||||
};
|
||||
|
||||
return response()->stream($callback, 200, [
|
||||
'Content-Type' => 'text/csv; charset=UTF-8',
|
||||
'Content-Disposition' => 'attachment; filename="' . $filename . '"',
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Error exporting equipments: ' . $e->getMessage());
|
||||
return AbstractController::ResultError($e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -7,13 +7,18 @@ use App\Http\Controllers\Controller;
|
|||
use App\Jobs\InitializeLeaveDays;
|
||||
use App\Models\LeaveDays;
|
||||
use App\Models\Notes;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Maatwebsite\Excel\Facades\Excel;
|
||||
use Modules\Admin\app\Models\Ticket;
|
||||
use App\Traits\AnalyzeData;
|
||||
|
||||
class LeaveManagementController extends Controller
|
||||
{
|
||||
use AnalyzeData;
|
||||
|
||||
public function get(Request $request)
|
||||
{
|
||||
$yearNow = $request->query('year', now()->year);
|
||||
|
|
@ -38,6 +43,7 @@ class LeaveManagementController extends Controller
|
|||
$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'),
|
||||
|
|
@ -51,13 +57,14 @@ class LeaveManagementController extends Controller
|
|||
)
|
||||
// ->where('notes.n_user_id', "1")
|
||||
->where('notes.n_year', $year)
|
||||
->whereIn('notes.n_reason', ['ONLEAVE', 'LEAVE_WITHOUT_PAY'])
|
||||
->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,
|
||||
"reason_code" => $item->reason_code,
|
||||
|
|
@ -76,6 +83,7 @@ class LeaveManagementController extends Controller
|
|||
'users.name as user_name',
|
||||
'users.email',
|
||||
'users.avatar',
|
||||
'users.is_separated',
|
||||
'users.created_at as user_created_at',
|
||||
'users.permission',
|
||||
'users.updated_at as user_updated_at',
|
||||
|
|
@ -97,6 +105,7 @@ class LeaveManagementController extends Controller
|
|||
'name' => $item->user_name,
|
||||
'email' => $item->email,
|
||||
'avatar' => $item->avatar,
|
||||
'is_separated' => $item->is_separated,
|
||||
'created_at' => $item->user_created_at,
|
||||
'permission' => $item->permission,
|
||||
'updated_at' => $item->user_updated_at,
|
||||
|
|
@ -145,6 +154,38 @@ 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();
|
||||
|
||||
// Clear Timekeeping cache
|
||||
$ticket = Ticket::find($note->ticket_id);
|
||||
if ($ticket) {
|
||||
$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);
|
||||
} else {
|
||||
$this->createOrUpdateRecordForCurrentMonth(Carbon::parse($note->created_at)->month, Carbon::parse($note->created_at)->year);
|
||||
}
|
||||
|
||||
return response()->json(data: ['message' => 'Update success', 'status' => true]);
|
||||
}
|
||||
|
||||
public function export(Request $request)
|
||||
{
|
||||
$year = $request->query('year', now()->year);
|
||||
|
|
@ -155,7 +196,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
|
|
@ -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
|
||||
{
|
||||
|
|
@ -76,14 +78,14 @@ class TimekeepingController extends Controller
|
|||
'user_id' => $user->id,
|
||||
'status' => 'check in',
|
||||
'time_string' => $start->format('Y-m-d H:i:s'),
|
||||
'created_at' => $start->setTimezone('UTC')
|
||||
'created_at' => $start->setTimezone(env('TIME_ZONE'))
|
||||
],
|
||||
[
|
||||
'name' => $user->name,
|
||||
'user_id' => $user->id,
|
||||
'status' => 'check out',
|
||||
'time_string' => $end->format('Y-m-d H:i:s'),
|
||||
'created_at' => $end->setTimezone('UTC')
|
||||
'created_at' => $end->setTimezone(env('TIME_ZONE'))
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
|
@ -170,85 +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) {
|
||||
$n_month = $note->n_month;
|
||||
$n_year = $note->n_year;
|
||||
if (!$note) {
|
||||
return response()->json(['message' => 'Note not found', 'status' => false]);
|
||||
}
|
||||
|
||||
if ($note->n_reason == "ONLEAVE") {
|
||||
// Get note reason ONLEAVE by $n_month, $n_year not include $note->id & include $note->n_user_id
|
||||
// $onleave = Notes::getNotesByMonthAndYearAndUserId($n_month, $n_year, $note->n_user_id, $note->id);
|
||||
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]);
|
||||
}
|
||||
|
||||
// Get note reason LEAVE_WITHOUT_PAY by $n_month, $n_year & include $note->n_user_id
|
||||
$leaveWithoutPay = Notes::getNotesByMonthAndYearAndUserIdAndReason($n_month, $n_year, $note->n_user_id, 'LEAVE_WITHOUT_PAY');
|
||||
$admin = auth('admins')->user();
|
||||
|
||||
if (count($leaveWithoutPay) > 0) {
|
||||
$deletedValue = ($note->n_time_type === 'ALL') ? 1.0 : 0.5;
|
||||
$needUpdate = $deletedValue;
|
||||
// dd($needUpdate, $leaveWithoutPay);
|
||||
foreach ($leaveWithoutPay as $lwNote) {
|
||||
if ($needUpdate <= 0) break;
|
||||
// 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);
|
||||
|
||||
if ($lwNote->n_time_type === 'ALL') {
|
||||
if ($needUpdate == 1.0) {
|
||||
// Chuyển cả note ALL thành phép
|
||||
$lwNote->update(['n_reason' => 'ONLEAVE']);
|
||||
$needUpdate = 0;
|
||||
break;
|
||||
} else { // $needUpdate == 0.5
|
||||
// Tách ALL thành 2 note S và C, chuyển S thành phép, C giữ không phép
|
||||
Notes::create([
|
||||
'n_user_id' => $lwNote->n_user_id,
|
||||
'n_day' => $lwNote->n_day,
|
||||
'n_month' => $lwNote->n_month,
|
||||
'n_year' => $lwNote->n_year,
|
||||
'n_time_type' => 'S',
|
||||
'n_reason' => 'ONLEAVE',
|
||||
'n_note' => $lwNote->n_note
|
||||
]);
|
||||
Notes::create([
|
||||
'n_user_id' => $lwNote->n_user_id,
|
||||
'n_day' => $lwNote->n_day,
|
||||
'n_month' => $lwNote->n_month,
|
||||
'n_year' => $lwNote->n_year,
|
||||
'n_time_type' => 'C',
|
||||
'n_reason' => 'LEAVE_WITHOUT_PAY',
|
||||
'n_note' => $lwNote->n_note
|
||||
]);
|
||||
$lwNote->delete();
|
||||
$needUpdate = 0;
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
// Nếu $lwNote->n_time_type == 'S' hoặc 'C' => 0.5
|
||||
if ($needUpdate == 1.0) {
|
||||
// Chuyển cả note ALL thành phép
|
||||
$lwNote->update(['n_reason' => 'ONLEAVE']);
|
||||
$needUpdate -= 0.5;
|
||||
} else { // $needUpdate == 0.5
|
||||
// S hoặc C, chỉ cần chuyển đúng 0.5 ngày
|
||||
$lwNote->update(['n_reason' => 'ONLEAVE']);
|
||||
$needUpdate = 0;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Khi note phép và k tồn tại nghỉ không phép => phép + dồn cho tháng sau
|
||||
}
|
||||
}
|
||||
$note->delete();
|
||||
$this->createOrUpdateRecordForCurrentMonth($month, $year);
|
||||
return response()->json(['message' => 'Delete success', 'status' => true]);
|
||||
}
|
||||
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -8,4 +8,12 @@ use Illuminate\Database\Eloquent\Model;
|
|||
class Category extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'c_type',
|
||||
'c_name',
|
||||
'c_code',
|
||||
'c_value',
|
||||
'c_active',
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,52 @@
|
|||
<?php
|
||||
|
||||
namespace Modules\Admin\app\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use App\Traits\HasCacheModel;
|
||||
|
||||
class ElectricityBill extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
use HasCacheModel;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->table = 'electricity_bills';
|
||||
$this->guarded = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate total amount based on reading difference and unit price
|
||||
*/
|
||||
public function calculateTotal(): float
|
||||
{
|
||||
$consumption = $this->current_reading - $this->previous_reading;
|
||||
return round($consumption * $this->unit_price, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get consumption in kWh
|
||||
*/
|
||||
public function getConsumption(): float
|
||||
{
|
||||
return $this->current_reading - $this->previous_reading;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user who created this record
|
||||
*/
|
||||
public function creator()
|
||||
{
|
||||
return $this->belongsTo(\App\Models\User::class, 'created_by');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user who updated this record
|
||||
*/
|
||||
public function updater()
|
||||
{
|
||||
return $this->belongsTo(\App\Models\User::class, 'updated_by');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
<?php
|
||||
|
||||
namespace Modules\Admin\app\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Equipment extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->table = 'equipments';
|
||||
$this->guarded = [];
|
||||
}
|
||||
|
||||
public function currentUser()
|
||||
{
|
||||
return $this->belongsTo(\App\Models\User::class, 'current_user_id');
|
||||
}
|
||||
|
||||
public function assignments()
|
||||
{
|
||||
return $this->hasMany(EquipmentAssignment::class, 'equipment_id')->orderBy('assigned_at', 'desc');
|
||||
}
|
||||
|
||||
public function creator()
|
||||
{
|
||||
return $this->belongsTo(\App\Models\User::class, 'created_by');
|
||||
}
|
||||
|
||||
public function updater()
|
||||
{
|
||||
return $this->belongsTo(\App\Models\User::class, 'updated_by');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
<?php
|
||||
|
||||
namespace Modules\Admin\app\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class EquipmentAssignment extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->table = 'equipment_assignments';
|
||||
$this->guarded = [];
|
||||
}
|
||||
|
||||
public function equipment()
|
||||
{
|
||||
return $this->belongsTo(Equipment::class, 'equipment_id');
|
||||
}
|
||||
|
||||
public function user()
|
||||
{
|
||||
return $this->belongsTo(\App\Models\User::class, 'user_id');
|
||||
}
|
||||
|
||||
public function creator()
|
||||
{
|
||||
return $this->belongsTo(\App\Models\User::class, 'created_by');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,98 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="vi">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Bảng kê thanh toán tiền điện</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: DejaVu Sans, sans-serif;
|
||||
font-size: 14px;
|
||||
}
|
||||
.text-center {
|
||||
text-align: center;
|
||||
}
|
||||
.mt-10 {
|
||||
margin-top: 10px;
|
||||
}
|
||||
.mt-20 {
|
||||
margin-top: 20px;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-top: 15px;
|
||||
}
|
||||
table, th, td {
|
||||
border: 1px solid #000;
|
||||
}
|
||||
th, td {
|
||||
padding: 8px;
|
||||
text-align: center;
|
||||
}
|
||||
.no-border {
|
||||
border: none;
|
||||
}
|
||||
.signature {
|
||||
width: 100%;
|
||||
margin-top: 50px;
|
||||
}
|
||||
.signature td {
|
||||
border: none;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<h3 class="text-center">BẢNG KÊ THANH TOÁN TIỀN ĐIỆN</h3>
|
||||
<p class="text-center">({{ $dateNow ?? '' }})</p>
|
||||
|
||||
<div class="mt-20">
|
||||
<p>- Tên doanh nghiệp: Công ty TNHH Kỹ Thuật Công Nghệ APAC</p>
|
||||
<p>- Mã số thuế: 0110038408</p>
|
||||
<p>- Địa chỉ: Số 219/26/3 đường Lĩnh Nam, Phường Vĩnh Hưng, thành phố Hà Nội, Việt Nam</p>
|
||||
<p>- Tên chủ sở hữu cho thuê địa điểm sản xuất kinh doanh: Lâm Văn Mười</p>
|
||||
<p>- Địa chỉ thuê: 50B31 tại Khu dân cư 91B giai đoạn 3, phường Tân An, thành phố Cần Thơ</p>
|
||||
</div>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Số điện đầu kỳ</th>
|
||||
<th>Số điện cuối kỳ</th>
|
||||
<th>Số điện tiêu thụ</th>
|
||||
<th>Đơn giá</th>
|
||||
<th>Thành tiền</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>{{ number_format($bill->previous_reading) ?? 0 }}</td>
|
||||
<td>{{ number_format($bill->current_reading) ?? 0 }}</td>
|
||||
<td>{{ $consumption ?? 0 }}</td>
|
||||
<td>{{ number_format($bill->unit_price) ?? '0' }}</td>
|
||||
<td>{{ number_format($bill->total_amount) ?? '0' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<p class="mt-20">
|
||||
- Tổng tiền thanh toán: <strong>{{ number_format($bill->total_amount) ?? '0' }} VND</strong>
|
||||
({{ $totalText ?? '' }})
|
||||
</p>
|
||||
|
||||
<table class="signature no-border">
|
||||
<tr>
|
||||
<td>
|
||||
Người lập bảng kê<br>
|
||||
(Ký, ghi rõ họ tên)
|
||||
</td>
|
||||
<td>
|
||||
Đại diện doanh nghiệp<br>
|
||||
(Ký, ghi rõ họ tên)
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -23,6 +23,8 @@ use Modules\Admin\app\Http\Controllers\ProjectReviewController;
|
|||
use Modules\Admin\app\Http\Controllers\ProfileController;
|
||||
use Modules\Admin\app\Http\Controllers\TechnicalController;
|
||||
use Modules\Admin\app\Http\Controllers\TestCaseForSprintController;
|
||||
use Modules\Admin\app\Http\Controllers\ElectricityBillController;
|
||||
use Modules\Admin\app\Http\Controllers\EquipmentController;
|
||||
use Modules\Admin\app\Http\Middleware\AdminMiddleware;
|
||||
|
||||
/*
|
||||
|
|
@ -44,6 +46,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
|
||||
|
|
@ -148,6 +151,8 @@ Route::middleware('api')
|
|||
'prefix' => 'category',
|
||||
], function () {
|
||||
Route::get('/get-list-master', [CategoryController::class, 'getListMaster']);
|
||||
Route::get('/work-days', [CategoryController::class, 'workDays'])->middleware('check.permission:admin.hr');
|
||||
Route::put('/update-work-days', [CategoryController::class, 'updateWorkDays'])->middleware('check.permission:admin.hr');
|
||||
});
|
||||
|
||||
Route::group([
|
||||
|
|
@ -156,6 +161,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,11 +169,44 @@ 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');
|
||||
});
|
||||
|
||||
// Electricity Bills
|
||||
Route::group([
|
||||
'prefix' => 'electricity-bill',
|
||||
], function () {
|
||||
Route::get('/', [ElectricityBillController::class, 'index'])->middleware('check.permission:admin.hr.staff.accountant');
|
||||
Route::get('/{id}', [ElectricityBillController::class, 'show'])->middleware('check.permission:admin.hr.staff.accountant');
|
||||
Route::post('/create', [ElectricityBillController::class, 'create'])->middleware('check.permission:admin.hr.staff.accountant');
|
||||
Route::put('/{id}', [ElectricityBillController::class, 'update'])->middleware('check.permission:admin.hr.staff.accountant');
|
||||
Route::get('/delete/{id}', [ElectricityBillController::class, 'delete'])->middleware('check.permission:admin.hr.staff.accountant');
|
||||
Route::get('/export-pdf/{id}', [ElectricityBillController::class, 'exportPdf'])->middleware('check.permission:admin.hr.staff.accountant');
|
||||
Route::post('/upload-attachment/{id}', [ElectricityBillController::class, 'uploadAttachment'])->middleware('check.permission:admin.hr.staff.accountant');
|
||||
Route::delete('/delete-attachment/{id}', [ElectricityBillController::class, 'deleteAttachment'])->middleware('check.permission:admin.hr.staff.accountant');
|
||||
});
|
||||
|
||||
// Equipments
|
||||
Route::group([
|
||||
'prefix' => 'equipment',
|
||||
], function () {
|
||||
Route::get('/', [EquipmentController::class, 'index'])->middleware('check.permission:admin.hr.staff.accountant');
|
||||
Route::get('/users', [EquipmentController::class, 'userOptions'])->middleware('check.permission:admin.hr.staff.accountant');
|
||||
Route::get('/user-statistics', [EquipmentController::class, 'userStatistics'])->middleware('check.permission:admin.hr.staff.accountant');
|
||||
Route::get('/export', [EquipmentController::class, 'export'])->middleware('check.permission:admin.hr.staff.accountant');
|
||||
Route::get('/by-code', [EquipmentController::class, 'showByCode'])->middleware('check.permission:admin.hr.staff.accountant');
|
||||
Route::get('/history/{id}', [EquipmentController::class, 'history'])->middleware('check.permission:admin.hr.staff.accountant');
|
||||
Route::post('/create', [EquipmentController::class, 'create'])->middleware('check.permission:admin.hr.staff.accountant');
|
||||
Route::put('/{id}', [EquipmentController::class, 'update'])->middleware('check.permission:admin.hr.staff.accountant');
|
||||
Route::post('/{id}/assign', [EquipmentController::class, 'assign'])->middleware('check.permission:admin.hr.staff.accountant');
|
||||
Route::post('/{id}/upload-image', [EquipmentController::class, 'uploadImage'])->middleware('check.permission:admin.hr.staff.accountant');
|
||||
Route::delete('/{id}/image', [EquipmentController::class, 'deleteImage'])->middleware('check.permission:admin.hr.staff.accountant');
|
||||
Route::get('/delete/{id}', [EquipmentController::class, 'delete'])->middleware('check.permission:admin.hr.staff.accountant');
|
||||
});
|
||||
|
||||
Route::group([
|
||||
'prefix' => 'profile',
|
||||
], function () {
|
||||
|
|
|
|||
|
|
@ -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,41 @@ class UserController extends Controller
|
|||
]);
|
||||
|
||||
if ($request->has('id')) {
|
||||
$payload = $request->only(['name', 'email', 'permission']);
|
||||
$payload = $request->only(['name', 'email', 'permission', 'is_permanent', 'is_separated']);
|
||||
$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.']);
|
||||
}
|
||||
|
||||
if (!$request->is_separated && $user->is_separated) {
|
||||
return response()->json(['status' => false, 'message' => 'You cannot change status of separated employee.']);
|
||||
}
|
||||
|
||||
// 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 +81,23 @@ class UserController extends Controller
|
|||
'name' => $request->name,
|
||||
'email' => $request->email,
|
||||
'password' => bcrypt('Work@1234'),
|
||||
'permission' => $request->permission
|
||||
'permission' => $request->permission,
|
||||
'is_permanent' => false,
|
||||
'is_separated' => 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 +149,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)
|
||||
|
|
|
|||
|
|
@ -25,7 +25,10 @@ class User extends Authenticatable implements JWTSubject
|
|||
'name',
|
||||
'email',
|
||||
'password',
|
||||
'permission'
|
||||
'permission',
|
||||
'is_permanent',
|
||||
'is_separated',
|
||||
'permanent_date'
|
||||
];
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -17,8 +17,6 @@ class InitializeLeaveDaysCommand extends Command
|
|||
|
||||
public function handle()
|
||||
{
|
||||
$year = $this->argument('year');
|
||||
// Không sử dụng nữa, theo rule mới
|
||||
// InitializeLeaveDays::dispatch($year);
|
||||
InitializeLeaveDays::dispatch();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -24,9 +24,11 @@ class Kernel extends ConsoleKernel
|
|||
// $schedule->command('daily:api-call')
|
||||
// ->dailyAt('18:00');
|
||||
|
||||
// Chạy command vào ngày 31/12 lúc 23:59:59 mỗi năm
|
||||
// $schedule->command('initialize:leavedays')->yearlyOn(12, 31, '23:59:59');
|
||||
$schedule->command('leave:deduct')->yearlyOn(3, 31, '23:59:59');
|
||||
// Chạy command vào ngày 01/01 lúc 00:00 mỗi năm
|
||||
$schedule->command('initialize:leavedays')->yearlyOn(1, 1, '00:00');
|
||||
|
||||
// Chạy command vào ngày 01/04 lúc 00:00 mỗi năm
|
||||
$schedule->command('leave:deduct')->yearlyOn(4, 1, '00:00');
|
||||
|
||||
// Chạy buổi sáng lúc 12:00
|
||||
$schedule->command('attendance:check S')->dailyAt('12:00');
|
||||
|
|
@ -34,8 +36,9 @@ class Kernel extends ConsoleKernel
|
|||
// Chạy buổi chiều lúc 17:30
|
||||
$schedule->command('attendance:check C')->dailyAt('17:30');
|
||||
|
||||
// Chạy vào 00:01 ngày đầu tiên của mỗi tháng
|
||||
// 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');
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
namespace App\Jobs;
|
||||
|
||||
use App\Models\LeaveDays;
|
||||
use App\Models\Notes;
|
||||
use App\Models\User;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Bus\Queueable;
|
||||
|
|
@ -10,6 +11,8 @@ use Illuminate\Contracts\Queue\ShouldQueue;
|
|||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Modules\Admin\app\Models\Category;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class AddMonthlyLeaveDays implements ShouldQueue
|
||||
{
|
||||
|
|
@ -18,6 +21,8 @@ class AddMonthlyLeaveDays implements ShouldQueue
|
|||
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;
|
||||
|
|
@ -29,38 +34,86 @@ class AddMonthlyLeaveDays implements ShouldQueue
|
|||
$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;
|
||||
}
|
||||
|
||||
// Nếu là nhân viên nghỉ việc, ko cộng phép
|
||||
if ($user->is_separated) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$leaveDay = LeaveDays::where('ld_user_id', $user->id)
|
||||
->where('ld_year', $this->year)
|
||||
->first();
|
||||
|
||||
if (!$leaveDay) {
|
||||
if (!$leaveDay && $this->month > 1) {
|
||||
// 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
|
||||
$previousYearData = LeaveDays::where('ld_user_id', $user->id)
|
||||
->where('ld_year', $this->year - 1)
|
||||
->first();
|
||||
$ld_additional_day = 0;
|
||||
$ld_note = '';
|
||||
|
||||
if ($previousYearData) {
|
||||
$ld_additional_day = $previousYearData->ld_day_total + $previousYearData->ld_additional_day + $previousYearData->ld_special_leave_day;
|
||||
$totalLeaveDaysByMonth = 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 - 1)
|
||||
->where('notes.n_user_id', $user->id)
|
||||
->where('notes.n_reason', 'ONLEAVE')
|
||||
->groupBy(DB::raw('notes.n_year'))
|
||||
->first();
|
||||
if ($totalLeaveDaysByMonth) {
|
||||
$ld_additional_day = $ld_additional_day - $totalLeaveDaysByMonth->leave_days;
|
||||
if ($ld_additional_day < 0) {
|
||||
$ld_additional_day = 0;
|
||||
}
|
||||
}
|
||||
|
||||
if ($ld_additional_day > 0) {
|
||||
$ld_note = "Cộng " . $ld_additional_day . " ngày phép tồn năm trước. \n";
|
||||
}
|
||||
}
|
||||
|
||||
$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_additional_day' => $ld_additional_day,
|
||||
'ld_note' => $ld_note,
|
||||
'ld_special_leave_day' => 0,
|
||||
]);
|
||||
$leaveDay->save();
|
||||
} else {
|
||||
// Kiểm tra nếu số ngày phép hiện tại nhỏ hơn tháng hiện tại
|
||||
if ($leaveDay->ld_day_total < $this->month) {
|
||||
// Cập nhật số ngày phép bằng với tháng hiện tại
|
||||
$oldDays = $leaveDay->ld_day_total;
|
||||
$leaveDay->ld_day_total = $this->month;
|
||||
// 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;
|
||||
|
||||
// Xử lý ghi chú
|
||||
$newNote = "Cập nhật ngày phép đến tháng " . $this->month;
|
||||
if (!empty($leaveDay->ld_note)) {
|
||||
// Nếu đã có ghi chú, thêm ghi chú mới vào và xuống dòng
|
||||
$leaveDay->ld_note = $leaveDay->ld_note . "\n" . $newNote;
|
||||
} else {
|
||||
// Nếu chưa có ghi chú, gán ghi chú mới
|
||||
$leaveDay->ld_note = $newNote;
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,10 +39,13 @@ class CheckUserAttendanceJob implements ShouldQueue
|
|||
// Lấy tất cả người dùng
|
||||
$users = User::where('permission', 'not like', '%admin%')
|
||||
->where('permission', 'not like', '%accountant%')->get();
|
||||
|
||||
foreach ($users as $key => $user) {
|
||||
// if ($user->id != 4) {
|
||||
// continue;
|
||||
// }
|
||||
// Check nhân viên nghỉ việc
|
||||
if ($user->is_separated) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Kiểm tra dựa trên period (Sáng 'S' hoặc Chiều 'C')
|
||||
if ($this->period === 'S') {
|
||||
$this->checkMorning($user);
|
||||
|
|
@ -128,14 +131,19 @@ class CheckUserAttendanceJob implements ShouldQueue
|
|||
$reason = 'KHONG PHEP';
|
||||
|
||||
//Check ngày hợp lệ nếu có check và check out của user khác
|
||||
$dateNow = Tracking::whereBetween('time_string', [$startTime, $endTime])
|
||||
->get();
|
||||
$hasCheckIndateNow = $dateNow->where('status', 'check in')->isNotEmpty();
|
||||
$hasCheckOutdateNow = $dateNow->where('status', 'check out')->isNotEmpty();
|
||||
$dateNow = Tracking::whereBetween('time_string', [$startTime, $endTime])->exists();
|
||||
|
||||
// Nếu không có check-in hoặc check-out, và chưa có ticket, tạo ticket mới
|
||||
if (($hasCheckIndateNow && $hasCheckOutdateNow) && !$hasCheckIn && !$hasCheckOut && !$existingTicket) {
|
||||
Ticket::create([
|
||||
// Nếu không có check in/out, không gửi ticket và có check in/out của người khác => tạo ticket không phép
|
||||
if ($hasCheckIn || $hasCheckOut) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($existingTicket) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($dateNow) {
|
||||
$ticket = Ticket::create([
|
||||
'user_id' => $userId,
|
||||
'start_date' => $today->format('Y-m-d'),
|
||||
'start_period' => $periodCode,
|
||||
|
|
@ -159,8 +167,10 @@ class CheckUserAttendanceJob implements ShouldQueue
|
|||
$admins = Admin::where('permission', 'like', '%admin%')->get();
|
||||
foreach ($admins as $key => $value) {
|
||||
$data = array(
|
||||
"ticket_id" => $ticket->id,
|
||||
"email_template" => "email.notification_tickets",
|
||||
"email" => $user->email,
|
||||
"admin_email" => $value->email,
|
||||
"name" => $user->name,
|
||||
"date" => $dataMasterStartPeriod->c_name . " (" . $formattedStartDate . ") - " . $dataMasterEndPeriod->c_name . " (" . $formattedEndDate . ")",
|
||||
"type" => $dataMasterType->c_name,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -42,38 +41,30 @@ class DeductLeaveDays implements ShouldQueue
|
|||
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();
|
||||
->where('n_user_id', $user->id)
|
||||
->where('n_year', $this->year)
|
||||
->where('n_month', "<=", 3)
|
||||
->where('n_reason', 'ONLEAVE')
|
||||
->sum('categories.c_value');
|
||||
|
||||
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_additional_day > $totalLeaveDaysByMonth->leave_days) {
|
||||
LeaveDays::where('ld_year', $this->year)
|
||||
->where('ld_user_id', $user->id)
|
||||
->update([
|
||||
'ld_additional_day' => $totalLeaveDaysByMonth->leave_days,
|
||||
]);
|
||||
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 . $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_additional_day' => "0",
|
||||
]);
|
||||
$ld_note = "Trừ " . $existingData->ld_additional_day . " ngày phép tồn năm trước. \n";
|
||||
$existingData->ld_note = $existingData->ld_note . $ld_note;
|
||||
$existingData->ld_additional_day = 0;
|
||||
}
|
||||
|
||||
$existingData->save();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,15 +34,11 @@ class InitializeLeaveDays implements ShouldQueue
|
|||
public function handle(): void
|
||||
{
|
||||
$users = User::get();
|
||||
$ld_day_total = 12;
|
||||
foreach ($users as $user) {
|
||||
// Kiểm tra xem dữ liệu của user này đã tồn tại cho năm hiện tại chưa
|
||||
$existingData = LeaveDays::where('ld_user_id', $user->id)
|
||||
->where('ld_year', $this->year)
|
||||
->first();
|
||||
$ld_day_total = Carbon::now()->month; // Khởi tạo phép hiện có bằng tháng hiện tại
|
||||
|
||||
if ($existingData) {
|
||||
// Nếu dữ liệu đã tồn tại, bỏ qua user này
|
||||
foreach ($users as $user) {
|
||||
// Check nhân viên nghỉ việc
|
||||
if ($user->is_separated) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
@ -55,7 +51,7 @@ class InitializeLeaveDays implements ShouldQueue
|
|||
$ld_note = '';
|
||||
|
||||
if ($previousYearData) {
|
||||
$ld_additional_day = $previousYearData->ld_day_total + $previousYearData->ld_additional_day;
|
||||
$ld_additional_day = $previousYearData->ld_day_total + $previousYearData->ld_additional_day + $previousYearData->ld_special_leave_day;
|
||||
$totalLeaveDaysByMonth = Notes::join('categories', function ($join) {
|
||||
$join->on('notes.n_time_type', '=', 'categories.c_code')
|
||||
->where('categories.c_type', 'TIME_TYPE');
|
||||
|
|
@ -76,13 +72,29 @@ class InitializeLeaveDays implements ShouldQueue
|
|||
$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";
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
->where('ld_year', $this->year)
|
||||
->first();
|
||||
|
||||
if ($existingData) {
|
||||
// Nếu dữ liệu đã tồn tại, update lại phép tồn
|
||||
$existingData->ld_note = $ld_note;
|
||||
$existingData->ld_additional_day = $ld_additional_day;
|
||||
$existingData->save();
|
||||
continue;
|
||||
}
|
||||
|
||||
// Tạo dữ liệu cho năm hiện tại
|
||||
LeaveDays::insert([
|
||||
'ld_user_id' => $user->id,
|
||||
'ld_day_total' => $ld_day_total,
|
||||
'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_additional_day' => $ld_additional_day,
|
||||
'ld_note' => $ld_note,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -18,6 +18,7 @@ class Notes extends Model
|
|||
'n_time_type',
|
||||
'n_reason',
|
||||
'n_note',
|
||||
'ticket_id'
|
||||
];
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -2,11 +2,16 @@
|
|||
"name": "laravel/laravel",
|
||||
"type": "project",
|
||||
"description": "The skeleton application for the Laravel framework.",
|
||||
"keywords": ["laravel", "framework"],
|
||||
"keywords": [
|
||||
"laravel",
|
||||
"framework"
|
||||
],
|
||||
"license": "MIT",
|
||||
"require": {
|
||||
"php": "^8.2",
|
||||
"barryvdh/laravel-debugbar": "^3.9",
|
||||
"barryvdh/laravel-dompdf": "^2.0",
|
||||
"doctrine/dbal": "^3.10",
|
||||
"drnxloc/laravel-simple-html-dom": "^1.9",
|
||||
"guzzlehttp/guzzle": "^7.8",
|
||||
"laravel/framework": "^10.10",
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -59,6 +59,8 @@ return [
|
|||
|
||||
'asset_url' => env('ASSET_URL'),
|
||||
|
||||
'client_url' => env('ADMIN_URL', 'http://localhost'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Application Timezone
|
||||
|
|
|
|||
|
|
@ -9,14 +9,14 @@ class RenameLdDayToLdDayTotalInLeaveDaysTable extends Migration
|
|||
public function up()
|
||||
{
|
||||
Schema::table('leave_days', function (Blueprint $table) {
|
||||
$table->renameColumn('ld_day_total', 'ld_day_total');
|
||||
$table->renameColumn('ld_day', 'ld_day_total');
|
||||
});
|
||||
}
|
||||
|
||||
public function down()
|
||||
{
|
||||
Schema::table('leave_days', function (Blueprint $table) {
|
||||
$table->renameColumn('ld_day_total', 'ld_day_total');
|
||||
$table->renameColumn('ld_day_total', 'ld_day');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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();
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
<?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_separated')->default(false);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->dropColumn('is_separated');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
<?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('electricity_bills', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->date('billing_date')->comment('Ngày lập hóa đơn');
|
||||
$table->decimal('previous_reading', 12, 2)->comment('Số điện kỳ trước');
|
||||
$table->decimal('current_reading', 12, 2)->comment('Số điện kỳ này');
|
||||
$table->decimal('unit_price', 12, 2)->comment('Đơn giá điện');
|
||||
$table->decimal('total_amount', 12, 2)->comment('Tổng tiền điện');
|
||||
$table->string('notes')->nullable()->comment('Ghi chú');
|
||||
$table->string('file_path')->nullable()->comment('Đường dẫn file PDF');
|
||||
$table->unsignedBigInteger('created_by')->nullable();
|
||||
$table->unsignedBigInteger('updated_by')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->foreign('created_by')->references('id')->on('users')->onDelete('set null');
|
||||
$table->foreign('updated_by')->references('id')->on('users')->onDelete('set null');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('electricity_bills');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
<?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('electricity_bills', function (Blueprint $table) {
|
||||
$table->string('attachment_path')->nullable()->after('file_path')
|
||||
->comment('User-uploaded attachment (PDF or image)');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('electricity_bills', function (Blueprint $table) {
|
||||
$table->dropColumn('attachment_path');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
<?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::create('equipments', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('code')->unique()->comment('Mã thiết bị (dùng để scan barcode)');
|
||||
$table->string('name')->comment('Tên thiết bị');
|
||||
$table->text('note')->nullable()->comment('Ghi chú');
|
||||
$table->unsignedBigInteger('current_user_id')->nullable()->comment('Người đang sử dụng, null = đang ở kho');
|
||||
$table->timestamp('assigned_at')->nullable()->comment('Thời điểm giao thiết bị hiện tại');
|
||||
$table->unsignedBigInteger('created_by')->nullable();
|
||||
$table->unsignedBigInteger('updated_by')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->foreign('current_user_id')->references('id')->on('users')->onDelete('set null');
|
||||
$table->foreign('created_by')->references('id')->on('users')->onDelete('set null');
|
||||
$table->foreign('updated_by')->references('id')->on('users')->onDelete('set null');
|
||||
});
|
||||
|
||||
Schema::create('equipment_assignments', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->unsignedBigInteger('equipment_id');
|
||||
$table->unsignedBigInteger('user_id')->nullable()->comment('null = trả về kho');
|
||||
$table->timestamp('assigned_at')->comment('Bắt đầu sử dụng');
|
||||
$table->timestamp('returned_at')->nullable()->comment('Trả thiết bị');
|
||||
$table->text('note')->nullable();
|
||||
$table->unsignedBigInteger('created_by')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->foreign('equipment_id')->references('id')->on('equipments')->onDelete('cascade');
|
||||
$table->foreign('user_id')->references('id')->on('users')->onDelete('set null');
|
||||
$table->foreign('created_by')->references('id')->on('users')->onDelete('set null');
|
||||
|
||||
$table->index(['equipment_id', 'returned_at']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('equipment_assignments');
|
||||
Schema::dropIfExists('equipments');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
<?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('equipments', function (Blueprint $table) {
|
||||
$table->string('image_path')->nullable()->after('note')
|
||||
->comment('Ảnh thiết bị (tùy chọn)');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('equipments', function (Blueprint $table) {
|
||||
$table->dropColumn('image_path');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -1,4 +1,3 @@
|
|||
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
|
|
@ -37,9 +36,7 @@
|
|||
<title>{{ $data['subject'] }}</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
<body style="
|
||||
<body style="
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
background-color: #edf2f7;
|
||||
">
|
||||
|
|
@ -83,7 +80,7 @@
|
|||
<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:
|
||||
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>
|
||||
|
|
@ -98,15 +95,34 @@
|
|||
</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'] }}'
|
||||
<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: 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));
|
||||
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;
|
||||
|
|
@ -116,11 +132,43 @@
|
|||
margin: 0;
|
||||
padding: 10px 12px;
|
||||
">
|
||||
Check now</a>
|
||||
</p>
|
||||
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>
|
||||
|
|
@ -149,7 +197,6 @@
|
|||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
|
@ -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
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -23,6 +23,8 @@
|
|||
"@tabler/icons-react": "^3.19.0",
|
||||
"@uiw/react-codemirror": "^4.23.5",
|
||||
"@vanilla-extract/css": "^1.16.0",
|
||||
"@zxing/browser": "^0.2.0",
|
||||
"@zxing/library": "^0.23.0",
|
||||
"axios": "^1.6.1",
|
||||
"clsx": "^2.1.1",
|
||||
"dayjs": "^1.11.10",
|
||||
|
|
|
|||
Binary file not shown.
|
After Width: | Height: | Size: 124 KiB |
|
|
@ -35,11 +35,15 @@ export const updateWorkingDays =
|
|||
|
||||
//Category
|
||||
export const getListMaster = API_URL + 'v1/admin/category/get-list-master'
|
||||
export const getWorkDay = API_URL + 'v1/admin/category/work-days'
|
||||
export const updateWorkDay = API_URL + 'v1/admin/category/update-work-days'
|
||||
|
||||
//LeaveManagement
|
||||
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 +52,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
|
||||
|
|
@ -120,6 +125,44 @@ export const deleteDocument = API_URL + 'v1/admin/document/delete'
|
|||
// Download File
|
||||
export const downloadFile = API_URL + 'v1/admin/download-file'
|
||||
|
||||
// Electricity Bills
|
||||
export const getElectricityBills = API_URL + 'v1/admin/electricity-bill'
|
||||
export const getElectricityBillById = (id: number) =>
|
||||
API_URL + `v1/admin/electricity-bill/${id}`
|
||||
export const createElectricityBill =
|
||||
API_URL + 'v1/admin/electricity-bill/create'
|
||||
export const updateElectricityBill = (id: number) =>
|
||||
API_URL + `v1/admin/electricity-bill/${id}`
|
||||
export const deleteElectricityBill = (id: number) =>
|
||||
API_URL + `v1/admin/electricity-bill/delete/${id}`
|
||||
export const exportElectricityBillPdf = (id: number) =>
|
||||
API_URL + `v1/admin/electricity-bill/export-pdf/${id}`
|
||||
export const uploadElectricityBillAttachment = (id: number) =>
|
||||
API_URL + `v1/admin/electricity-bill/upload-attachment/${id}`
|
||||
export const deleteElectricityBillAttachment = (id: number) =>
|
||||
API_URL + `v1/admin/electricity-bill/delete-attachment/${id}`
|
||||
|
||||
// Equipments
|
||||
export const getEquipments = API_URL + 'v1/admin/equipment'
|
||||
export const getEquipmentUserOptions = API_URL + 'v1/admin/equipment/users'
|
||||
export const getEquipmentUserStatistics =
|
||||
API_URL + 'v1/admin/equipment/user-statistics'
|
||||
export const exportEquipments = API_URL + 'v1/admin/equipment/export'
|
||||
export const getEquipmentByCode = API_URL + 'v1/admin/equipment/by-code'
|
||||
export const getEquipmentHistory = (id: number) =>
|
||||
API_URL + `v1/admin/equipment/history/${id}`
|
||||
export const createEquipment = API_URL + 'v1/admin/equipment/create'
|
||||
export const updateEquipment = (id: number) =>
|
||||
API_URL + `v1/admin/equipment/${id}`
|
||||
export const assignEquipment = (id: number) =>
|
||||
API_URL + `v1/admin/equipment/${id}/assign`
|
||||
export const uploadEquipmentImage = (id: number) =>
|
||||
API_URL + `v1/admin/equipment/${id}/upload-image`
|
||||
export const deleteEquipmentImage = (id: number) =>
|
||||
API_URL + `v1/admin/equipment/${id}/image`
|
||||
export const deleteEquipment = (id: number) =>
|
||||
API_URL + `v1/admin/equipment/delete/${id}`
|
||||
|
||||
// Files APIs
|
||||
export const getFiles = API_URL + 'v1/admin/profile/files'
|
||||
export const uploadFiles = API_URL + 'v1/admin/profile/upload-files'
|
||||
|
|
|
|||
|
|
@ -250,7 +250,14 @@ export const DataTableAll = ({
|
|||
if (query !== '') {
|
||||
setTData(
|
||||
data.filter((obj) =>
|
||||
Object.values(obj)?.find((c: any) => c.toString().normalize('NFC').toLowerCase().includes(query.normalize('NFC').toLowerCase())))
|
||||
Object.values(obj)?.find((c: any) =>
|
||||
c
|
||||
.toString()
|
||||
.normalize('NFC')
|
||||
.toLowerCase()
|
||||
.includes(query.normalize('NFC').toLowerCase()),
|
||||
),
|
||||
),
|
||||
)
|
||||
} else {
|
||||
if (pagination) {
|
||||
|
|
@ -456,7 +463,7 @@ export const DataTablePagination = ({
|
|||
})
|
||||
const [selectedRows, setSelectedRows] = useState<any[]>([])
|
||||
const navigate = useNavigate()
|
||||
const urlParams = new URLSearchParams(location.search)
|
||||
let urlParams = new URLSearchParams(location.search)
|
||||
|
||||
// Render headers
|
||||
const headers = columns.map((col) => (
|
||||
|
|
@ -596,7 +603,7 @@ export const DataTablePagination = ({
|
|||
|
||||
// Remove specific parameters
|
||||
params.delete(name)
|
||||
|
||||
urlParams.delete(name)
|
||||
// Update the URL without reloading the page
|
||||
window.history.replaceState({}, document.title, url.toString())
|
||||
}
|
||||
|
|
@ -660,9 +667,8 @@ export const DataTablePagination = ({
|
|||
date_used_to: date_used,
|
||||
})
|
||||
}
|
||||
|
||||
// Add all attributes in 'params' to URL params
|
||||
Object.entries(params).forEach((param) => urlParams.set(...param))
|
||||
urlParams = new URLSearchParams(Object.entries(params))
|
||||
Object.entries(dataFilter).forEach(([key, value]) => {
|
||||
const typeFilter = filterInfo.find((o) => o.key === key).type
|
||||
const hasType = {
|
||||
|
|
@ -679,17 +685,16 @@ export const DataTablePagination = ({
|
|||
if (hasType.Date) {
|
||||
value = value ? Date.parse(String(value)) / 1000 : '' // to unix timestamp
|
||||
}
|
||||
|
||||
console.log(String(value))
|
||||
String(value).length
|
||||
? urlParams.set(key, String(value))
|
||||
: urlParams.delete(key)
|
||||
})
|
||||
|
||||
// Request to get data API
|
||||
const res = await get(url, Object.fromEntries(urlParams.entries()))
|
||||
if (res.status) {
|
||||
setBaseData(res)
|
||||
setTData(res.data)
|
||||
setBaseData(res.data?.links ? res?.data : res)
|
||||
setTData(res.data?.data ? res.data?.data : res.data)
|
||||
setSkeletion(false)
|
||||
navigate({
|
||||
pathname: location.pathname,
|
||||
|
|
@ -765,7 +770,7 @@ export const DataTablePagination = ({
|
|||
|
||||
if (order_by_) {
|
||||
const sortParam = {
|
||||
name: order_by_.split('=')[0].split('_')[2],
|
||||
name: order_by_.split('=')[0].split('_').slice(2).join('_'),
|
||||
status: order_by_.split('=')[1],
|
||||
}
|
||||
if (JSON.stringify(sortParam) !== JSON.stringify(statusSort)) {
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ import {
|
|||
IconReport,
|
||||
IconScan,
|
||||
IconSettings,
|
||||
IconShredder,
|
||||
IconSun,
|
||||
IconTicket,
|
||||
IconUsersGroup,
|
||||
|
|
@ -146,8 +147,8 @@ const data = [
|
|||
link: '/organization-settings',
|
||||
label: 'Organization Settings',
|
||||
icon: IconSettings,
|
||||
group: 'admin',
|
||||
permissions: 'admin',
|
||||
group: 'other',
|
||||
permissions: 'admin,hr',
|
||||
},
|
||||
{
|
||||
link: '/profiles',
|
||||
|
|
@ -156,6 +157,13 @@ const data = [
|
|||
group: 'admin',
|
||||
permissions: 'admin,accountant',
|
||||
},
|
||||
{
|
||||
link: '/office-support',
|
||||
label: 'Office Support',
|
||||
icon: IconShredder,
|
||||
group: 'other',
|
||||
permissions: 'admin,hr,accountant',
|
||||
},
|
||||
// { link: '/jira', label: 'Jira', icon: IconSubtask },
|
||||
// { link: '/custom-theme', label: 'Custom Theme', icon: IconBrush },
|
||||
// { link: '/general-setting', label: 'General Setting', icon: IconSettings },
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,48 @@
|
|||
.title {
|
||||
background-color: light-dark(var(white), var(--mantine-color-dark-7));
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 var(--mantine-spacing-sm) var(--mantine-spacing-lg)
|
||||
var(--mantine-spacing-sm);
|
||||
border-bottom: solid rgba(201, 201, 201, 0.377) 1px;
|
||||
}
|
||||
|
||||
.optionIcon {
|
||||
display: flex;
|
||||
justify-content: space-evenly;
|
||||
}
|
||||
|
||||
.deleteIcon {
|
||||
color: red;
|
||||
cursor: pointer;
|
||||
padding: 2px;
|
||||
border-radius: 25%;
|
||||
}
|
||||
|
||||
|
||||
.editIcon {
|
||||
color: rgb(9, 132, 132);
|
||||
cursor: pointer;
|
||||
padding: 2px;
|
||||
border-radius: 25%;
|
||||
}
|
||||
|
||||
.editIcon:hover {
|
||||
background-color: rgba(203, 203, 203, 0.809);
|
||||
}
|
||||
|
||||
.deleteIcon:hover {
|
||||
background-color: rgba(203, 203, 203, 0.809);
|
||||
}
|
||||
|
||||
.dialog {
|
||||
background-color: light-dark(white, #2d353c);
|
||||
text-align: center;
|
||||
border: solid 1px rgb(255, 145, 0);
|
||||
}
|
||||
|
||||
.dialogText {
|
||||
color: light-dark(#2d353c, white);
|
||||
}
|
||||
|
|
@ -0,0 +1,876 @@
|
|||
import {
|
||||
createElectricityBill,
|
||||
deleteElectricityBill,
|
||||
deleteElectricityBillAttachment,
|
||||
exportElectricityBillPdf,
|
||||
getElectricityBills,
|
||||
updateElectricityBill,
|
||||
uploadElectricityBillAttachment,
|
||||
} from '@/api/Admin'
|
||||
import { DataTablePagination } from '@/components/DataTable/DataTable'
|
||||
import { Xdelete } from '@/rtk/helpers/CRUD'
|
||||
import { get, post, put } from '@/rtk/helpers/apiService'
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Dialog,
|
||||
FileButton,
|
||||
Group,
|
||||
Modal,
|
||||
NumberInput,
|
||||
Text,
|
||||
Tabs,
|
||||
Flex,
|
||||
ActionIcon,
|
||||
} from '@mantine/core'
|
||||
import { useForm } from '@mantine/form'
|
||||
import { notifications } from '@mantine/notifications'
|
||||
import axios from 'axios'
|
||||
import {
|
||||
IconDevices,
|
||||
IconDownload,
|
||||
IconEdit,
|
||||
IconEye,
|
||||
IconFileInvoice,
|
||||
IconTrash,
|
||||
IconUpload,
|
||||
IconX,
|
||||
} from '@tabler/icons-react'
|
||||
import moment from 'moment'
|
||||
import { useEffect, useState } from 'react'
|
||||
import classes from './OfficeSupport.module.css'
|
||||
import { _NOTIFICATION_MESS } from '@/rtk/helpers/notificationMess'
|
||||
import { getHeaderInfo } from '@/rtk/helpers/tokenCreator'
|
||||
import { DateInput } from '@mantine/dates'
|
||||
import EquipmentManagement from './EquipmentManagement'
|
||||
|
||||
interface ElectricityBill {
|
||||
id: number
|
||||
billing_date: string
|
||||
previous_reading: number
|
||||
current_reading: number
|
||||
unit_price: number
|
||||
total_amount: number
|
||||
notes: string | null
|
||||
file_path: string | null
|
||||
attachment_path: string | null
|
||||
created_by: number | null
|
||||
updated_by: number | null
|
||||
created_at: string | null
|
||||
updated_at: string | null
|
||||
creator_name?: string
|
||||
updater_name?: string
|
||||
}
|
||||
|
||||
interface ElectricityBillsResponse {
|
||||
data: ElectricityBill[]
|
||||
current_page: number
|
||||
last_page: number
|
||||
per_page: number
|
||||
total: number
|
||||
}
|
||||
|
||||
const OfficeSupport = () => {
|
||||
const [activeTab, setActiveTab] = useState<string | null>('calculate')
|
||||
const [listBills, setListBills] = useState<ElectricityBillsResponse>({
|
||||
data: [],
|
||||
current_page: 1,
|
||||
last_page: 1,
|
||||
per_page: 15,
|
||||
total: 0,
|
||||
})
|
||||
const [action, setAction] = useState('')
|
||||
const [item, setItem] = useState<ElectricityBill | null>(null)
|
||||
const [activeBtn, setActiveBtn] = useState(false)
|
||||
const [disableBtn, setDisableBtn] = useState(false)
|
||||
|
||||
const [confirmModal, setConfirmModal] = useState(false)
|
||||
const [confirmMessage, setConfirmMessage] = useState('')
|
||||
const [confirmValues, setConfirmValues] = useState<any>(null)
|
||||
const [confirmLoading, setConfirmLoading] = useState(false)
|
||||
const [attachmentToDelete, setAttachmentToDelete] =
|
||||
useState<ElectricityBill | null>(null)
|
||||
const filterInfo: any[] = []
|
||||
|
||||
const getAllBills = async (page: number = 1) => {
|
||||
try {
|
||||
const params = { page }
|
||||
const res = await get(getElectricityBills, params)
|
||||
if (res?.data) {
|
||||
setListBills(res?.data)
|
||||
}
|
||||
} catch (error: any) {
|
||||
notifications.show({
|
||||
title: 'Error',
|
||||
message: error.message ?? error,
|
||||
color: 'red',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
getAllBills()
|
||||
}, [])
|
||||
|
||||
const columns = [
|
||||
{
|
||||
name: 'billing_date',
|
||||
size: '15%',
|
||||
header: 'Date',
|
||||
render: (row: ElectricityBill) => {
|
||||
const date = new Date(row.billing_date)
|
||||
return <Text fz={'sm'}>{moment(date).format('DD MMMM YYYY')}</Text>
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'previous_reading',
|
||||
size: '15%',
|
||||
header: 'Previous Reading',
|
||||
render: (row: ElectricityBill) => (
|
||||
<Text fz={'sm'}>
|
||||
{Number(row.previous_reading)?.toLocaleString()} kWh
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'current_reading',
|
||||
size: '15%',
|
||||
header: 'Current Reading',
|
||||
render: (row: ElectricityBill) => (
|
||||
<Text fz={'sm'}>
|
||||
{Number(row.current_reading)?.toLocaleString()} kWh
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
name: '#',
|
||||
size: '10%',
|
||||
header: 'Consumption',
|
||||
render: (row: ElectricityBill) => {
|
||||
const consumption =
|
||||
Number(row.current_reading) - Number(row.previous_reading)
|
||||
return (
|
||||
<Text fz={'sm'} fw={600}>
|
||||
{consumption.toLocaleString()} kWh
|
||||
</Text>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'unit_price',
|
||||
size: '10%',
|
||||
header: 'Unit Price',
|
||||
render: (row: ElectricityBill) => (
|
||||
<Text fz={'sm'}>
|
||||
{Number(row.unit_price)?.toLocaleString(undefined, {
|
||||
minimumFractionDigits: 0,
|
||||
})}{' '}
|
||||
VNĐ
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'total_amount',
|
||||
size: '15%',
|
||||
header: 'Total Amount',
|
||||
render: (row: ElectricityBill) => (
|
||||
<Text fz={'sm'} fw={700} c="green">
|
||||
{Number(row.total_amount)?.toLocaleString(undefined, {
|
||||
minimumFractionDigits: 0,
|
||||
})}{' '}
|
||||
VNĐ
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'actions',
|
||||
size: '15%',
|
||||
header: 'Actions',
|
||||
render: (row: ElectricityBill) => {
|
||||
return (
|
||||
<Group gap="xs">
|
||||
<ActionIcon
|
||||
disabled={disableBtn}
|
||||
onClick={() => handleExportPdf(row.id, row.billing_date)}
|
||||
variant="outline"
|
||||
w={20}
|
||||
h={20}
|
||||
color={'blue'}
|
||||
title="Export PDF"
|
||||
>
|
||||
<IconDownload className={classes.deleteIcon} color="blue" />
|
||||
</ActionIcon>
|
||||
<FileButton
|
||||
onChange={(file) => handleUploadAttachment(row.id, file)}
|
||||
accept="application/pdf,image/jpeg,image/png"
|
||||
>
|
||||
{(props) => (
|
||||
<ActionIcon
|
||||
{...props}
|
||||
disabled={disableBtn}
|
||||
variant="outline"
|
||||
w={20}
|
||||
h={20}
|
||||
color={'purple'}
|
||||
title={
|
||||
row.attachment_path
|
||||
? 'Replace attachment'
|
||||
: 'Upload attachment'
|
||||
}
|
||||
>
|
||||
<IconUpload className={classes.deleteIcon} color="purple" />
|
||||
</ActionIcon>
|
||||
)}
|
||||
</FileButton>
|
||||
{row.attachment_path && (
|
||||
<>
|
||||
<ActionIcon
|
||||
disabled={disableBtn}
|
||||
onClick={() =>
|
||||
window.open(
|
||||
buildAttachmentUrl(row.attachment_path as string),
|
||||
'_blank',
|
||||
)
|
||||
}
|
||||
variant="outline"
|
||||
w={20}
|
||||
h={20}
|
||||
color={'teal'}
|
||||
title="View attachment"
|
||||
>
|
||||
<IconEye className={classes.deleteIcon} color="teal" />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
disabled={disableBtn}
|
||||
onClick={() => setAttachmentToDelete(row)}
|
||||
variant="outline"
|
||||
w={20}
|
||||
h={20}
|
||||
color={'orange'}
|
||||
title="Remove attachment"
|
||||
>
|
||||
<IconX className={classes.deleteIcon} color="orange" />
|
||||
</ActionIcon>
|
||||
</>
|
||||
)}
|
||||
<ActionIcon
|
||||
disabled={disableBtn}
|
||||
onClick={() => {
|
||||
setItem(row)
|
||||
setAction('edit')
|
||||
form.setFieldValue(
|
||||
'billing_date',
|
||||
row?.billing_date || moment().format('YYYY-MM-DD'),
|
||||
)
|
||||
form.setFieldValue(
|
||||
'current_reading',
|
||||
Number(row?.current_reading) || 0,
|
||||
)
|
||||
form.setFieldValue(
|
||||
'previous_reading',
|
||||
Number(row?.previous_reading) || 0,
|
||||
)
|
||||
form.setFieldValue('unit_price', row?.unit_price || 4000)
|
||||
form.setFieldValue('notes', row?.notes || '')
|
||||
}}
|
||||
variant="outline"
|
||||
w={20}
|
||||
h={20}
|
||||
color={'green'}
|
||||
title="Edit"
|
||||
>
|
||||
<IconEdit className={classes.deleteIcon} color="green" />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
disabled={disableBtn}
|
||||
onClick={() => {
|
||||
setAction('delete')
|
||||
setItem(row)
|
||||
}}
|
||||
variant="outline"
|
||||
w={20}
|
||||
h={20}
|
||||
color={'red'}
|
||||
title="Delete"
|
||||
>
|
||||
<IconTrash className={classes.deleteIcon} color="red" />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
)
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const handleCreate = async (values: any) => {
|
||||
try {
|
||||
setDisableBtn(true)
|
||||
|
||||
const params = {
|
||||
billing_date: values.billing_date,
|
||||
previous_reading: values.previous_reading,
|
||||
current_reading: values.current_reading,
|
||||
unit_price: values.unit_price,
|
||||
notes: values.notes || null,
|
||||
}
|
||||
|
||||
let res
|
||||
if (action === 'add') {
|
||||
res = await post(createElectricityBill, params)
|
||||
} else if (action === 'edit' && item) {
|
||||
res = await put(updateElectricityBill(item.id), params)
|
||||
}
|
||||
|
||||
if (res?.status) {
|
||||
notifications.show({
|
||||
title: 'Success',
|
||||
message:
|
||||
action === 'add'
|
||||
? _NOTIFICATION_MESS.create_success
|
||||
: 'Updated successfully',
|
||||
color: 'green',
|
||||
})
|
||||
setAction('')
|
||||
form.reset()
|
||||
|
||||
// Auto export PDF after creating
|
||||
if (action === 'add' && res.data?.id) {
|
||||
handleExportPdf(res.data.id, res.data.billing_date)
|
||||
}
|
||||
|
||||
getAllBills()
|
||||
} else if (!res?.status && res?.errors) {
|
||||
if (!res?.data?.status && res?.data?.message) {
|
||||
setConfirmMessage(res.data?.message)
|
||||
setConfirmValues(values)
|
||||
setConfirmModal(true)
|
||||
} else {
|
||||
notifications.show({
|
||||
title: 'Error',
|
||||
message: res.message ?? _NOTIFICATION_MESS.create_error,
|
||||
color: 'red',
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error.response?.message) {
|
||||
const errorMess = error.response.message
|
||||
notifications.show({
|
||||
title: 'Error',
|
||||
message: errorMess,
|
||||
color: 'red',
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
setDisableBtn(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
try {
|
||||
await Xdelete(deleteElectricityBill(id), {}, () => getAllBills())
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleExportPdf = async (id: number, date: string) => {
|
||||
try {
|
||||
setDisableBtn(true)
|
||||
const header = await getHeaderInfo()
|
||||
const res = await fetch(exportElectricityBillPdf(id), { ...header })
|
||||
|
||||
if (!res.ok) throw new Error('Export failed')
|
||||
|
||||
const blob = await res.blob()
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
const newDate = moment(new Date(date)).format('DD-M-YYYY')
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `Bảng thanh toán tiền điện APAC - ${newDate}.pdf`
|
||||
a.click()
|
||||
|
||||
// notifications.show({
|
||||
// title: 'Success',
|
||||
// message: 'PDF exported successfully',
|
||||
// color: 'green',
|
||||
// })
|
||||
setDisableBtn(false)
|
||||
} catch (error: any) {
|
||||
setDisableBtn(false)
|
||||
notifications.show({
|
||||
title: 'Error',
|
||||
message: error.message,
|
||||
color: 'red',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleUploadAttachment = async (id: number, file: File | null) => {
|
||||
if (!file) return
|
||||
|
||||
const allowedTypes = [
|
||||
'application/pdf',
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/gif',
|
||||
'image/webp',
|
||||
]
|
||||
if (!allowedTypes.includes(file.type)) {
|
||||
notifications.show({
|
||||
title: 'Error',
|
||||
message: 'Only PDF or image files are allowed',
|
||||
color: 'red',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
notifications.show({
|
||||
title: 'Error',
|
||||
message: 'File size must be 10MB or less',
|
||||
color: 'red',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setDisableBtn(true)
|
||||
const header = await getHeaderInfo()
|
||||
header.headers['Content-Type'] = 'multipart/form-data'
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
|
||||
const res = await axios.post(
|
||||
uploadElectricityBillAttachment(id),
|
||||
formData,
|
||||
header,
|
||||
)
|
||||
|
||||
if (res?.data?.status) {
|
||||
notifications.show({
|
||||
title: 'Success',
|
||||
message: 'Attachment uploaded successfully',
|
||||
color: 'green',
|
||||
})
|
||||
getAllBills(listBills.current_page)
|
||||
} else {
|
||||
notifications.show({
|
||||
title: 'Error',
|
||||
message: res?.data?.message ?? 'Upload failed',
|
||||
color: 'red',
|
||||
})
|
||||
}
|
||||
} catch (error: any) {
|
||||
notifications.show({
|
||||
title: 'Error',
|
||||
message: error?.response?.data?.message ?? error.message,
|
||||
color: 'red',
|
||||
})
|
||||
} finally {
|
||||
setDisableBtn(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteAttachment = async (id: number) => {
|
||||
try {
|
||||
setDisableBtn(true)
|
||||
const header = await getHeaderInfo()
|
||||
const res = await axios.delete(
|
||||
deleteElectricityBillAttachment(id),
|
||||
header,
|
||||
)
|
||||
|
||||
if (res?.data?.status) {
|
||||
notifications.show({
|
||||
title: 'Success',
|
||||
message: 'Attachment deleted successfully',
|
||||
color: 'green',
|
||||
})
|
||||
getAllBills(listBills.current_page)
|
||||
} else {
|
||||
notifications.show({
|
||||
title: 'Error',
|
||||
message: res?.data?.message ?? 'Delete failed',
|
||||
color: 'red',
|
||||
})
|
||||
}
|
||||
} catch (error: any) {
|
||||
notifications.show({
|
||||
title: 'Error',
|
||||
message: error?.response?.data?.message ?? error.message,
|
||||
color: 'red',
|
||||
})
|
||||
} finally {
|
||||
setDisableBtn(false)
|
||||
setAttachmentToDelete(null)
|
||||
}
|
||||
}
|
||||
|
||||
const buildAttachmentUrl = (path: string) => {
|
||||
const base = import.meta.env.VITE_BACKEND_URL
|
||||
return base.includes('local')
|
||||
? base + 'storage/' + path
|
||||
: base + 'image/storage/' + path
|
||||
}
|
||||
|
||||
const getLastReading = () => {
|
||||
if (!listBills?.data?.length) return 0
|
||||
|
||||
const sorted = [...listBills.data].sort(
|
||||
(a, b) =>
|
||||
new Date(b.billing_date).getTime() - new Date(a.billing_date).getTime(),
|
||||
)
|
||||
return sorted[0] ? Number(sorted[0]?.current_reading) : 0
|
||||
}
|
||||
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
id: 0,
|
||||
billing_date: moment().format('YYYY-MM-DD'),
|
||||
previous_reading: 0,
|
||||
current_reading: 0,
|
||||
unit_price: 4000,
|
||||
notes: '',
|
||||
},
|
||||
validate: {
|
||||
billing_date: (value) => (!value ? 'Date is required' : null),
|
||||
previous_reading: (value) =>
|
||||
value < 0 ? 'Previous reading must be positive' : null,
|
||||
current_reading: (value) =>
|
||||
value < 0 ? 'Current reading must be positive' : null,
|
||||
unit_price: (value) =>
|
||||
value <= 0 ? 'Unit price must be greater than 0' : null,
|
||||
},
|
||||
})
|
||||
|
||||
// Calculate preview
|
||||
const calculatePreview = () => {
|
||||
const consumption =
|
||||
form.values.current_reading - form.values.previous_reading
|
||||
const total = consumption * form.values.unit_price
|
||||
return { consumption, total }
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className={classes.title}>
|
||||
<h3>Office Support</h3>
|
||||
</div>
|
||||
|
||||
<Box p={20}>
|
||||
<Tabs value={activeTab} onChange={setActiveTab}>
|
||||
<Tabs.List>
|
||||
<Tabs.Tab
|
||||
value="calculate"
|
||||
leftSection={<IconFileInvoice size={16} />}
|
||||
>
|
||||
Electricity Bill
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab
|
||||
value="equipment"
|
||||
leftSection={<IconDevices size={16} />}
|
||||
>
|
||||
Equipment Management
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="calculate" pt="md">
|
||||
{/* Calculate Tab Content */}
|
||||
<Box>
|
||||
<Button
|
||||
m={5}
|
||||
onClick={() => {
|
||||
setAction('add')
|
||||
form.reset()
|
||||
form.setFieldValue('previous_reading', getLastReading())
|
||||
form.setFieldValue('current_reading', getLastReading())
|
||||
}}
|
||||
>
|
||||
+ Add New Bill
|
||||
</Button>
|
||||
</Box>
|
||||
{/* History Tab Content */}
|
||||
{listBills.data.length > 0 ? (
|
||||
<DataTablePagination
|
||||
filterInfo={filterInfo}
|
||||
data={listBills}
|
||||
columns={columns}
|
||||
searchInput
|
||||
size=""
|
||||
/>
|
||||
) : (
|
||||
<Text c="dimmed" ta="center" py="xl">
|
||||
No electricity bills found.
|
||||
</Text>
|
||||
)}
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="equipment" pt="md">
|
||||
<EquipmentManagement />
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
</Box>
|
||||
|
||||
{/* Add/Edit Modal */}
|
||||
<Modal
|
||||
opened={action === 'add' || action === 'edit'}
|
||||
onClose={() => {
|
||||
setAction('')
|
||||
setItem(null)
|
||||
form.reset()
|
||||
}}
|
||||
title={
|
||||
<Text pl={'sm'} fw={700} fz={'lg'}>
|
||||
{action === 'add' && 'Add Electricity Bill'}
|
||||
{action === 'edit' && 'Edit Electricity Bill'}
|
||||
</Text>
|
||||
}
|
||||
size="lg"
|
||||
>
|
||||
<form
|
||||
onSubmit={form.onSubmit(async (values) => {
|
||||
setDisableBtn(true)
|
||||
await handleCreate(values)
|
||||
setDisableBtn(false)
|
||||
})}
|
||||
>
|
||||
<Box pl={'md'} pr={'md'}>
|
||||
<DateInput
|
||||
required
|
||||
mb="md"
|
||||
label="Billing Date"
|
||||
placeholder="Pick date"
|
||||
valueFormat="DD-MM-YYYY"
|
||||
value={
|
||||
form.values.billing_date
|
||||
? new Date(form.values.billing_date)
|
||||
: null
|
||||
}
|
||||
error={form.errors.billing_date}
|
||||
onChange={(date) =>
|
||||
form.setFieldValue(
|
||||
'billing_date',
|
||||
date ? moment(date).format('YYYY-MM-DD') : '',
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<Flex gap={'md'}>
|
||||
<Box style={{ flex: 1 }}>
|
||||
<NumberInput
|
||||
required
|
||||
mb={'md'}
|
||||
label={'Previous Reading (kWh)'}
|
||||
value={form.values.previous_reading}
|
||||
error={form.errors.previous_reading}
|
||||
onChange={(e) =>
|
||||
form.setFieldValue('previous_reading', Number(e))
|
||||
}
|
||||
min={0}
|
||||
thousandSeparator=","
|
||||
/>
|
||||
</Box>
|
||||
<Box style={{ flex: 1 }}>
|
||||
<NumberInput
|
||||
required
|
||||
mb={'md'}
|
||||
label={'Current Reading (kWh)'}
|
||||
value={form.values.current_reading}
|
||||
error={form.errors.current_reading}
|
||||
onChange={(e) =>
|
||||
form.setFieldValue('current_reading', Number(e))
|
||||
}
|
||||
min={0}
|
||||
thousandSeparator=","
|
||||
/>
|
||||
</Box>
|
||||
</Flex>
|
||||
|
||||
<NumberInput
|
||||
required
|
||||
mb={'md'}
|
||||
label={'Unit Price (VNĐ/kWh)'}
|
||||
value={form.values.unit_price}
|
||||
error={form.errors.unit_price}
|
||||
onChange={(e) => form.setFieldValue('unit_price', Number(e))}
|
||||
min={0}
|
||||
thousandSeparator=","
|
||||
/>
|
||||
|
||||
{/* Preview */}
|
||||
<Box
|
||||
p="md"
|
||||
style={{
|
||||
backgroundColor: '#f8f9fa',
|
||||
borderRadius: '8px',
|
||||
marginBottom: '16px',
|
||||
}}
|
||||
>
|
||||
<Text fw={600} mb="sm">
|
||||
Preview:
|
||||
</Text>
|
||||
<Text>
|
||||
Consumption:{' '}
|
||||
<Text span fw={600}>
|
||||
{calculatePreview().consumption.toLocaleString()} kWh
|
||||
</Text>
|
||||
</Text>
|
||||
<Text>
|
||||
Total Amount:{' '}
|
||||
<Text span fw={700} c="green" size="lg">
|
||||
{calculatePreview().total.toLocaleString()} VNĐ
|
||||
</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box ta={'center'}>
|
||||
<Button
|
||||
mt={'lg'}
|
||||
bg={'green'}
|
||||
type="submit"
|
||||
disabled={disableBtn}
|
||||
>
|
||||
{action === 'add' ? 'Create & Export PDF' : 'Update'}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<Dialog
|
||||
className={classes.dialog}
|
||||
opened={action === 'delete'}
|
||||
withCloseButton
|
||||
onClose={() => setAction('')}
|
||||
size="lg"
|
||||
radius="md"
|
||||
position={{ top: 30, right: 10 }}
|
||||
>
|
||||
<Text className={classes.dialogText} size="sm" mb="xs" fw={500}>
|
||||
Do you want to delete this record?
|
||||
<Group justify="center" m={10}>
|
||||
<Button
|
||||
disabled={activeBtn}
|
||||
fw={700}
|
||||
size="xs"
|
||||
variant="light"
|
||||
onClick={async () => {
|
||||
setActiveBtn(true)
|
||||
if (item) {
|
||||
await handleDelete(item.id)
|
||||
}
|
||||
setActiveBtn(false)
|
||||
setAction('')
|
||||
setItem(null)
|
||||
}}
|
||||
>
|
||||
Yes
|
||||
</Button>
|
||||
<Button
|
||||
fw={700}
|
||||
size="xs"
|
||||
variant="light"
|
||||
onClick={() => {
|
||||
setAction('')
|
||||
setItem(null)
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</Group>
|
||||
</Text>
|
||||
</Dialog>
|
||||
|
||||
{/* Remove Attachment Confirmation */}
|
||||
<Modal
|
||||
opened={!!attachmentToDelete}
|
||||
onClose={() => setAttachmentToDelete(null)}
|
||||
title={
|
||||
<Text fw={700} fz="lg">
|
||||
Remove Attachment
|
||||
</Text>
|
||||
}
|
||||
centered
|
||||
>
|
||||
<Box p="md">
|
||||
<Text mb={20}>
|
||||
Are you sure you want to remove the attached file for this bill?
|
||||
</Text>
|
||||
<Group justify="center">
|
||||
<Button
|
||||
color="red"
|
||||
disabled={disableBtn}
|
||||
onClick={() => {
|
||||
if (attachmentToDelete) {
|
||||
handleDeleteAttachment(attachmentToDelete.id)
|
||||
}
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
disabled={disableBtn}
|
||||
onClick={() => setAttachmentToDelete(null)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</Group>
|
||||
</Box>
|
||||
</Modal>
|
||||
|
||||
{/* 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)
|
||||
await handleCreate(confirmValues)
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
export default OfficeSupport
|
||||
|
|
@ -1,28 +1,47 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Code,
|
||||
Dialog,
|
||||
Flex,
|
||||
Grid,
|
||||
Group,
|
||||
Loader,
|
||||
LoadingOverlay,
|
||||
Modal,
|
||||
Paper,
|
||||
Select,
|
||||
Tabs,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
} from '@mantine/core'
|
||||
import classes from './OrganizationSettings.module.css'
|
||||
import DataTableAll from '@/components/DataTable/DataTable'
|
||||
import { get, post } from '@/rtk/helpers/apiService'
|
||||
import { get, post, put } from '@/rtk/helpers/apiService'
|
||||
import { notifications } from '@mantine/notifications'
|
||||
import { createTechnical, deleteTechnical, listTechnical } from '@/api/Admin'
|
||||
import {
|
||||
createTechnical,
|
||||
deleteTechnical,
|
||||
getWorkDay,
|
||||
listTechnical,
|
||||
updateWorkDay,
|
||||
} from '@/api/Admin'
|
||||
import { useForm } from '@mantine/form'
|
||||
import { Xdelete } from '@/rtk/helpers/CRUD'
|
||||
import moment from 'moment'
|
||||
import { DatePickerInput } from '@mantine/dates'
|
||||
import { IconInfoCircle } from '@tabler/icons-react'
|
||||
import dayjs from 'dayjs'
|
||||
import customParseFormat from 'dayjs/plugin/customParseFormat'
|
||||
import { checkPermissionAdmin } from '@/rtk/localStorage'
|
||||
|
||||
dayjs.extend(customParseFormat)
|
||||
|
||||
function OrganizationSettings() {
|
||||
const [activeTab, setActiveTab] = useState<string | null>('technical')
|
||||
const [activeTab, setActiveTab] = useState<string | null>('work-day')
|
||||
|
||||
return (
|
||||
<div>
|
||||
|
|
@ -36,22 +55,22 @@ function OrganizationSettings() {
|
|||
<Box w="100%" display={'flex'} mt={15} ml={10}>
|
||||
<Tabs w="100%" value={activeTab} onChange={setActiveTab}>
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="work-day">Work Day Setting</Tabs.Tab>
|
||||
|
||||
{checkPermissionAdmin('admin') && (
|
||||
<Tabs.Tab value="technical">Technical Setting</Tabs.Tab>
|
||||
<Tabs.Tab value="second">Setting 2</Tabs.Tab>
|
||||
<Tabs.Tab value="third">Setting 3</Tabs.Tab>
|
||||
)}
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="work-day" pt="xs">
|
||||
<WorkDaySettingTab />
|
||||
</Tabs.Panel>
|
||||
|
||||
{checkPermissionAdmin('admin') && (
|
||||
<Tabs.Panel value="technical" pt="xs">
|
||||
<TechnicalSettingTab />
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="second" pt="xs">
|
||||
Setting 2
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="third" pt="xs">
|
||||
Setting 3
|
||||
</Tabs.Panel>
|
||||
)}
|
||||
</Tabs>
|
||||
</Box>
|
||||
</div>
|
||||
|
|
@ -330,4 +349,126 @@ const TechnicalSettingTab = () => {
|
|||
)
|
||||
}
|
||||
|
||||
const WorkDaySettingTab = () => {
|
||||
const [workDay, setWorkDay] = useState<any>(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [selectedDate, setSelectedDate] = useState<Date | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
getWorkDays()
|
||||
}, [])
|
||||
|
||||
const getWorkDays = async () => {
|
||||
try {
|
||||
setIsLoading(true)
|
||||
const res = await get(getWorkDay, {})
|
||||
if (res.status && res.data?.length > 0) {
|
||||
const item = res.data[0]
|
||||
setWorkDay(item)
|
||||
const parsed = dayjs(item.c_code, 'DD-MM-YYYY').toDate()
|
||||
setSelectedDate(parsed)
|
||||
}
|
||||
} catch (error: any) {
|
||||
notifications.show({
|
||||
title: 'Error',
|
||||
message: error.message ?? error,
|
||||
color: 'red',
|
||||
})
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!selectedDate) return
|
||||
try {
|
||||
setIsSaving(true)
|
||||
const payload = {
|
||||
c_code: dayjs(selectedDate).format('DD-MM-YYYY'),
|
||||
}
|
||||
const res = await put(`${updateWorkDay}`, payload)
|
||||
if (res.status) {
|
||||
notifications.show({
|
||||
title: 'Success',
|
||||
message: res.message,
|
||||
color: 'green',
|
||||
})
|
||||
}
|
||||
} catch (error: any) {
|
||||
notifications.show({
|
||||
title: 'Error',
|
||||
message: error.message ?? error,
|
||||
color: 'red',
|
||||
})
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleReset = () => {
|
||||
if (!workDay) return
|
||||
const parsed = dayjs(workDay.c_code, 'DD-MM-YYYY').toDate()
|
||||
setSelectedDate(parsed)
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<LoadingOverlay visible={isLoading} />
|
||||
|
||||
<Title order={4} mb="xs">
|
||||
Set up Saturday as a working day.
|
||||
</Title>
|
||||
|
||||
<Paper withBorder p="lg" radius="md">
|
||||
<Grid>
|
||||
<Grid.Col span={12}>
|
||||
<DatePickerInput
|
||||
label="Saturday Work Start Date"
|
||||
description="Weeks starting from this date will include Saturday as a working day."
|
||||
placeholder="Select Date"
|
||||
value={selectedDate}
|
||||
onChange={setSelectedDate}
|
||||
valueFormat="DD/MM/YYYY"
|
||||
clearable={false}
|
||||
excludeDate={(date) => date.getDay() === 0}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
|
||||
{selectedDate && (
|
||||
<Alert
|
||||
mt="md"
|
||||
variant="light"
|
||||
color="blue"
|
||||
icon={<IconInfoCircle size={16} />}
|
||||
>
|
||||
Starting from{' '}
|
||||
<Text span fw={500}>
|
||||
{dayjs(selectedDate).format('DD/MM/YYYY')}
|
||||
</Text>
|
||||
, Saturdays will be treated as working days in applicable weeks.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Group justify="flex-end" mt="lg" gap="sm">
|
||||
<Button variant="default" onClick={handleReset} disabled={isSaving}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSave} loading={isSaving}>
|
||||
Save change
|
||||
</Button>
|
||||
</Group>
|
||||
</Paper>
|
||||
|
||||
<Paper withBorder p="sm" radius="md" mt="sm" bg="gray.0">
|
||||
<Text size="xs" c="dimmed">
|
||||
Setting type:{' '}
|
||||
<Code>{workDay?.c_type ?? 'SATURDAY_WORK_SCHEDULE'}</Code>
|
||||
</Text>
|
||||
</Paper>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export default OrganizationSettings
|
||||
|
|
|
|||
|
|
@ -79,6 +79,7 @@ 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[]>([])
|
||||
|
|
@ -109,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'),
|
||||
|
|
@ -116,6 +119,8 @@ const Tickets = () => {
|
|||
|
||||
const resultReason = await getListMasterByType('REASON')
|
||||
setDataReason(resultReason)
|
||||
|
||||
setIsFetch(false)
|
||||
}
|
||||
|
||||
fetchData()
|
||||
|
|
@ -369,6 +374,7 @@ const Tickets = () => {
|
|||
setAction('add')
|
||||
form.reset()
|
||||
}}
|
||||
disabled={isFetchData}
|
||||
>
|
||||
+ Add
|
||||
</Button>
|
||||
|
|
@ -554,7 +560,8 @@ const Tickets = () => {
|
|||
if (confirmValues) {
|
||||
try {
|
||||
setConfirmLoading(true)
|
||||
action === 'add' && (await handleCreate(confirmValues, true))
|
||||
action === 'add' &&
|
||||
(await handleCreate(confirmValues, true))
|
||||
setConfirmLoading(false)
|
||||
setConfirmModal(false)
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -46,3 +46,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 #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;
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import {
|
|||
TextInput,
|
||||
Tooltip,
|
||||
Modal,
|
||||
Badge,
|
||||
} from '@mantine/core'
|
||||
import { useDisclosure } from '@mantine/hooks'
|
||||
import { notifications } from '@mantine/notifications'
|
||||
|
|
@ -48,6 +49,7 @@ interface User {
|
|||
permission: string
|
||||
remember_token: string | null
|
||||
avatar: string
|
||||
is_separated: boolean
|
||||
created_at: string | null
|
||||
updated_at: string | null
|
||||
}
|
||||
|
|
@ -148,6 +150,7 @@ const Timekeeping = () => {
|
|||
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) => {
|
||||
|
|
@ -467,7 +470,9 @@ const Timekeeping = () => {
|
|||
|
||||
const handleConfirmDelete = async () => {
|
||||
if (noteToDelete) {
|
||||
setIsDisableDeleteBtn(true)
|
||||
await handleDelete(noteToDelete.id)
|
||||
setIsDisableDeleteBtn(false)
|
||||
setIsDeleteConfirmOpen(false)
|
||||
setNoteToDelete(null)
|
||||
}
|
||||
|
|
@ -493,7 +498,11 @@ const Timekeeping = () => {
|
|||
>
|
||||
<Text className={classes.deleteModalTitle}>Confirm Delete</Text>
|
||||
<Text className={classes.deleteModalContent}>
|
||||
Are you sure you want to delete this note?
|
||||
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
|
||||
|
|
@ -502,10 +511,15 @@ const Timekeeping = () => {
|
|||
setIsDeleteConfirmOpen(false)
|
||||
setNoteToDelete(null)
|
||||
}}
|
||||
disabled={isDisableDeleteBtn}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button className={classes.deleteButton} onClick={handleConfirmDelete}>
|
||||
<Button
|
||||
className={classes.deleteButton}
|
||||
onClick={handleConfirmDelete}
|
||||
disabled={isDisableDeleteBtn}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</Box>
|
||||
|
|
@ -935,14 +949,8 @@ const Timekeeping = () => {
|
|||
2
|
||||
return (
|
||||
<Table.Tr key={user.user.id} className={classes.tableTr}>
|
||||
<Table.Td>
|
||||
<Tooltip
|
||||
// position={'auto'}
|
||||
multiline
|
||||
// opened
|
||||
// offset={{ mainAxis: 5, crossAxis: 0 }}
|
||||
label={showTooltipAllNote(user)}
|
||||
>
|
||||
<Table.Td pos="relative">
|
||||
<Tooltip multiline label={showTooltipAllNote(user)}>
|
||||
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<Avatar
|
||||
size={'md'}
|
||||
|
|
@ -960,6 +968,27 @@ const Timekeeping = () => {
|
|||
{user.user.name}
|
||||
</div>
|
||||
</Tooltip>
|
||||
|
||||
{user.user.is_separated ? (
|
||||
<Tooltip label="Nhân viên không còn làm việc, đang xử lý hồ sơ">
|
||||
<Badge
|
||||
color="red"
|
||||
pos="absolute"
|
||||
top={0}
|
||||
right={0}
|
||||
radius={0}
|
||||
size="xs"
|
||||
style={{
|
||||
fontSize: 10,
|
||||
textTransform: 'capitalize',
|
||||
}}
|
||||
>
|
||||
Off
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
) : (
|
||||
''
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td ta={'center'}>{totalDays}</Table.Td>
|
||||
<Table.Td ta={'center'}>{workingDays - totalDays}</Table.Td>
|
||||
|
|
|
|||
|
|
@ -45,3 +45,70 @@
|
|||
.dialogText {
|
||||
color: light-dark(#2d353c, white);
|
||||
}
|
||||
|
||||
/* Thêm styles cho Modal xác nhận xóa */
|
||||
.confirmModal {
|
||||
background-color: light-dark(white, #2d353c);
|
||||
text-align: center;
|
||||
border: solid 1px rgb(9, 132, 132);
|
||||
}
|
||||
|
||||
.confirmModalTitle {
|
||||
color: rgb(9, 132, 132);
|
||||
font-weight: 600;
|
||||
font-size: 1.2rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.confirmModalContent {
|
||||
color: light-dark(#2d353c, white);
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.confirmModalFooter {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.confirmButton {
|
||||
background-color: rgb(9, 132, 132);
|
||||
}
|
||||
|
||||
.confirmButton:hover {
|
||||
background-color: rgb(9, 132, 132);
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
|
@ -13,6 +13,7 @@ import {
|
|||
Group,
|
||||
Modal,
|
||||
MultiSelect,
|
||||
Select,
|
||||
Text,
|
||||
TextInput,
|
||||
} from '@mantine/core'
|
||||
|
|
@ -20,32 +21,50 @@ import { useForm } from '@mantine/form'
|
|||
import { IconEdit, IconX } from '@tabler/icons-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import classes from './UsersManagement.module.css'
|
||||
|
||||
type TUserForm = {
|
||||
id: number
|
||||
email: string
|
||||
name: string
|
||||
permission: string
|
||||
status: string
|
||||
}
|
||||
|
||||
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,
|
||||
is_separated: false,
|
||||
})
|
||||
const [disableBtn, setDisableBtn] = useState(false)
|
||||
const [info, setInfo] = useState('')
|
||||
const [isPermanentConfirmOpen, setIsPermanentConfirmOpen] =
|
||||
useState<boolean>(false)
|
||||
const [isSeparatedConfirmOpen, setIsSeparatedConfirmOpen] =
|
||||
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 +76,25 @@ const UsersManagement = () => {
|
|||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'is_permanent',
|
||||
size: '20%',
|
||||
header: 'Employment Type',
|
||||
render: (row: TUser) => {
|
||||
if (row.is_separated) {
|
||||
return <Badge color="red">Separated</Badge>
|
||||
}
|
||||
|
||||
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,7 +103,12 @@ const UsersManagement = () => {
|
|||
className={classes.editIcon}
|
||||
onClick={() => {
|
||||
setAction('edit')
|
||||
form.setValues(row)
|
||||
setItem(row)
|
||||
form.reset()
|
||||
form.setValues({
|
||||
...row,
|
||||
status: row.is_permanent ? 'permanent' : '',
|
||||
})
|
||||
}}
|
||||
width={20}
|
||||
height={20}
|
||||
|
|
@ -94,6 +134,7 @@ const UsersManagement = () => {
|
|||
name: '',
|
||||
email: '',
|
||||
permission: '',
|
||||
status: '',
|
||||
},
|
||||
})
|
||||
|
||||
|
|
@ -108,7 +149,7 @@ const UsersManagement = () => {
|
|||
}
|
||||
}
|
||||
|
||||
const handleCreate = async (values: TUser) => {
|
||||
const handleCreate = async (values: TUserForm) => {
|
||||
try {
|
||||
const { id, ...data } = values
|
||||
const res = await post(createOrUpdateUser, data)
|
||||
|
|
@ -123,11 +164,25 @@ const UsersManagement = () => {
|
|||
}
|
||||
}
|
||||
|
||||
const handleUpdate = async (values: TUser) => {
|
||||
const handleUpdate = async (values: TUserForm) => {
|
||||
try {
|
||||
const res = await update(createOrUpdateUser, values, getAll)
|
||||
const res = await update(
|
||||
createOrUpdateUser,
|
||||
{
|
||||
...values,
|
||||
is_permanent: item.is_permanent
|
||||
? true
|
||||
: values.status === 'permanent',
|
||||
is_separated: item.is_separated
|
||||
? true
|
||||
: values.status === 'separated',
|
||||
},
|
||||
getAll,
|
||||
)
|
||||
if (res === true) {
|
||||
setAction('')
|
||||
setIsPermanentConfirmOpen(false)
|
||||
setIsSeparatedConfirmOpen(false)
|
||||
form.reset()
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
@ -172,6 +227,8 @@ const UsersManagement = () => {
|
|||
opened={action === 'add' || action === 'edit'}
|
||||
onClose={() => {
|
||||
setAction('')
|
||||
setIsPermanentConfirmOpen(false)
|
||||
setIsSeparatedConfirmOpen(false)
|
||||
form.reset()
|
||||
}}
|
||||
title={
|
||||
|
|
@ -183,9 +240,17 @@ const UsersManagement = () => {
|
|||
<form
|
||||
onSubmit={form.onSubmit(async (values) => {
|
||||
setDisableBtn(true)
|
||||
action === 'edit'
|
||||
? await handleUpdate(values)
|
||||
: await handleCreate(values)
|
||||
if (action === 'edit') {
|
||||
if (values.status === 'permanent' && !item.is_permanent) {
|
||||
setIsPermanentConfirmOpen(true)
|
||||
} else if (values.status === 'separated' && !item.is_separated) {
|
||||
setIsSeparatedConfirmOpen(true)
|
||||
} else {
|
||||
await handleUpdate(values)
|
||||
}
|
||||
} else {
|
||||
await handleCreate(values)
|
||||
}
|
||||
setDisableBtn(false)
|
||||
})}
|
||||
>
|
||||
|
|
@ -196,6 +261,7 @@ const UsersManagement = () => {
|
|||
value={form.values.name}
|
||||
error={form.errors.name}
|
||||
onChange={(e) => form.setFieldValue('name', e.target.value)}
|
||||
required
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
|
|
@ -204,6 +270,7 @@ const UsersManagement = () => {
|
|||
value={form.values.email}
|
||||
error={form.errors.email}
|
||||
onChange={(e) => form.setFieldValue('email', e.target.value)}
|
||||
required
|
||||
/>
|
||||
|
||||
<MultiSelect
|
||||
|
|
@ -224,7 +291,29 @@ const UsersManagement = () => {
|
|||
e!.filter((p) => p.trim() !== '').join(','),
|
||||
)
|
||||
}
|
||||
mb={'md'}
|
||||
/>
|
||||
|
||||
{action === 'edit' && !item.is_separated ? (
|
||||
<Select
|
||||
label="Employment type"
|
||||
data={[
|
||||
{ value: 'permanent', label: 'Permanent' },
|
||||
{ value: 'separated', label: 'Separated' },
|
||||
]}
|
||||
value={form.values.status}
|
||||
onChange={(value) => {
|
||||
if (value) {
|
||||
form.setFieldValue('status', value)
|
||||
}
|
||||
}}
|
||||
w="50%"
|
||||
required
|
||||
/>
|
||||
) : (
|
||||
''
|
||||
)}
|
||||
|
||||
<Box ta={'center'}>
|
||||
{action === 'add' ? (
|
||||
<Button
|
||||
|
|
@ -273,6 +362,93 @@ const UsersManagement = () => {
|
|||
{info}
|
||||
</Code>
|
||||
</Modal>
|
||||
|
||||
{/* Confirm change to permanent employee */}
|
||||
<Modal
|
||||
opened={isPermanentConfirmOpen}
|
||||
onClose={() => setIsPermanentConfirmOpen(false)}
|
||||
centered
|
||||
size="sm"
|
||||
classNames={{
|
||||
content: classes.confirmModal,
|
||||
}}
|
||||
>
|
||||
<Text className={classes.confirmModalTitle}>Confirm Update</Text>
|
||||
<Text className={classes.confirmModalContent}>
|
||||
This action will change the employment type from{' '}
|
||||
<strong>Probation</strong> to <strong>Permanent</strong>.
|
||||
</Text>
|
||||
<Text className={classes.confirmModalContent}>
|
||||
Are you sure you want to proceed?
|
||||
</Text>
|
||||
|
||||
<Box className={classes.confirmModalFooter}>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setIsPermanentConfirmOpen(false)
|
||||
}}
|
||||
disabled={disableBtn}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
className={classes.confirmButton}
|
||||
onClick={async () => {
|
||||
setDisableBtn(true)
|
||||
await handleUpdate(form.values)
|
||||
setDisableBtn(false)
|
||||
}}
|
||||
disabled={disableBtn}
|
||||
>
|
||||
Confirm
|
||||
</Button>
|
||||
</Box>
|
||||
</Modal>
|
||||
|
||||
{/* Confirm change to separated employee */}
|
||||
<Modal
|
||||
opened={isSeparatedConfirmOpen}
|
||||
onClose={() => setIsSeparatedConfirmOpen(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 to{' '}
|
||||
<strong>Separated</strong>.
|
||||
</Text>
|
||||
<Text className={classes.deleteModalContent}>
|
||||
Are you sure you want to proceed?
|
||||
</Text>
|
||||
|
||||
<Box className={classes.deleteModalFooter}>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setIsSeparatedConfirmOpen(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'}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import PageLogin from '@/pages/Auth/Login/Login'
|
|||
import Document from '@/pages/Document/Document'
|
||||
import LeaveManagement from '@/pages/LeaveManagement/LeaveManagement'
|
||||
import PageNotFound from '@/pages/NotFound/NotFound'
|
||||
import OfficeSupport from '@/pages/OfficeSupport/OfficeSupport'
|
||||
import OrganizationSettings from '@/pages/OrganizationSettings/OrganizationSettings'
|
||||
import Profile from '@/pages/Profile/Profile'
|
||||
import SprintReview from '@/pages/SprintReview/SprintReview'
|
||||
|
|
@ -239,7 +240,7 @@ const mainRoutes = [
|
|||
{
|
||||
path: '/organization-settings',
|
||||
element: (
|
||||
<ProtectedRoute mode="route" permission="admin">
|
||||
<ProtectedRoute mode="route" permission="admin,hr">
|
||||
<BasePage
|
||||
main={
|
||||
<>
|
||||
|
|
@ -264,6 +265,20 @@ const mainRoutes = [
|
|||
</ProtectedRoute>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: '/office-support',
|
||||
element: (
|
||||
<ProtectedRoute mode="route" permission="admin,hr,accountant">
|
||||
<BasePage
|
||||
main={
|
||||
<>
|
||||
<OfficeSupport />
|
||||
</>
|
||||
}
|
||||
></BasePage>
|
||||
</ProtectedRoute>
|
||||
),
|
||||
},
|
||||
// {
|
||||
// path: '/packages',
|
||||
// element: (
|
||||
|
|
|
|||
|
|
@ -36,7 +36,11 @@ export const create = async (
|
|||
if (res.status === false) {
|
||||
notifications.show({
|
||||
title: 'Error',
|
||||
message: <div style={{ whiteSpace: 'pre-line' }}>{res.message ?? _NOTIFICATION_MESS.create_error}</div>,
|
||||
message: (
|
||||
<div style={{ whiteSpace: 'pre-line' }}>
|
||||
{res.message ?? _NOTIFICATION_MESS.create_error}
|
||||
</div>
|
||||
),
|
||||
color: 'red',
|
||||
})
|
||||
}
|
||||
|
|
@ -116,7 +120,7 @@ export const Xdelete = async (url: string, data: any, fnc?: () => void) => {
|
|||
try {
|
||||
const res = await get(url, data)
|
||||
|
||||
if (res.status) {
|
||||
if (res.status || res.success) {
|
||||
notifications.show({
|
||||
title: 'Success',
|
||||
message: _NOTIFICATION_MESS.delete_success,
|
||||
|
|
@ -124,7 +128,7 @@ export const Xdelete = async (url: string, data: any, fnc?: () => void) => {
|
|||
})
|
||||
fnc && fnc()
|
||||
}
|
||||
if (res.status === false) {
|
||||
if (res.status === false && !res.success) {
|
||||
notifications.show({
|
||||
title: 'Error',
|
||||
message: res.message ?? _NOTIFICATION_MESS.delete_error,
|
||||
|
|
|
|||
|
|
@ -76,6 +76,8 @@ export type TUser = {
|
|||
email: string
|
||||
name: string
|
||||
permission: string
|
||||
is_permanent: boolean
|
||||
is_separated: boolean
|
||||
}
|
||||
|
||||
export type DataReason = {
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -0,0 +1,6 @@
|
|||
path
|
||||
__pycache__
|
||||
images
|
||||
uploads
|
||||
venv
|
||||
log.log
|
||||
|
|
@ -0,0 +1 @@
|
|||
{}
|
||||
|
|
@ -0,0 +1,134 @@
|
|||
# TrackingToolWeb — CLAUDE.md
|
||||
|
||||
## Tổng quan dự án
|
||||
|
||||
Hệ thống điểm danh khuôn mặt (Face Check-in) tích hợp với Management System tại `ms.prology.net`. Camera nhận diện khuôn mặt → FastAPI backend so khớp → ghi log → đồng bộ sang hệ thống quản lý.
|
||||
|
||||
---
|
||||
|
||||
## Kiến trúc
|
||||
|
||||
```
|
||||
Frontend (React/Vite) → Backend (FastAPI/Python) → MySQL
|
||||
↓
|
||||
External MS API (ms.prology.net)
|
||||
```
|
||||
|
||||
**Backend**: `main.py` (FastAPI) + `api.py` (external calls) + `sync.py` (data sync)
|
||||
**Frontend**: `client/src/` — React 19, TypeScript, TailwindCSS, Zustand
|
||||
**Database**: MySQL — database `face_checkin`
|
||||
**Deployment**: Backend phục vụ luôn frontend build (`static/`) qua route `/`
|
||||
|
||||
---
|
||||
|
||||
## Commands
|
||||
|
||||
### Backend
|
||||
```bash
|
||||
# Development
|
||||
uvicorn main:app --reload
|
||||
|
||||
# Production
|
||||
nohup uvicorn main:app --host 172.16.6.38 --port 8080 > log.log 2>&1 &
|
||||
```
|
||||
|
||||
### Frontend
|
||||
```bash
|
||||
cd client
|
||||
npm run dev # dev server (Vite HMR)
|
||||
npm run build # build to client/dist/
|
||||
npm run lint # ESLint
|
||||
```
|
||||
|
||||
### Deploy frontend
|
||||
Sau khi build, copy `client/dist/` vào `static/`. Đảm bảo asset paths trong `index.html` dùng prefix `/camera/static/assets/`.
|
||||
|
||||
---
|
||||
|
||||
## Cấu hình
|
||||
|
||||
### Backend (hardcoded — cần đưa vào .env)
|
||||
| Biến | Giá trị hiện tại | File |
|
||||
|------|-----------------|------|
|
||||
| DB URL | `mysql+pymysql://root:123@localhost/face_checkin` | `database.py` |
|
||||
| MS API base | `https://ms.prology.net/api/v1` | `api.py` |
|
||||
| JWT token | hardcoded string | `api.py` |
|
||||
| Face threshold | `0.42` | `main.py:217` |
|
||||
| Ratio threshold | `0.85` | `main.py:286` |
|
||||
| Recent check window | 0.5 phút | `main.py` |
|
||||
|
||||
### Frontend (.env trong `client/`)
|
||||
```
|
||||
VITE_API_BASE_URL=/camera # production (proxy qua nginx)
|
||||
VITE_API_BASE_MS=https://ms.prology.net
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API Endpoints
|
||||
|
||||
| Method | Path | Mô tả |
|
||||
|--------|------|-------|
|
||||
| GET | `/` | Phục vụ `static/index.html` |
|
||||
| POST | `/register` | Đăng ký khuôn mặt (name, email, file ảnh) |
|
||||
| POST | `/register-simple` | Đăng ký/cập nhật user không cần ảnh |
|
||||
| POST | `/checkin` | Nhận diện & điểm danh (file ảnh, camera_id) |
|
||||
| GET | `/logs` | 20 log điểm danh gần nhất |
|
||||
| GET | `/users` | Danh sách users + 5 checkpoint gần nhất |
|
||||
|
||||
---
|
||||
|
||||
## Database Schema
|
||||
|
||||
```sql
|
||||
students (id, name, email UNIQUE, avatar)
|
||||
student_encodings (id, student_id FK, encoding BLOB[1024 bytes = 128 float64], created_at)
|
||||
checkin_logs (id, student_id FK, time, camera_id, status[check in/check out])
|
||||
```
|
||||
|
||||
**Encoding format**: `np.float64` array 128 chiều → `.tobytes()` → BLOB 1024 bytes
|
||||
**Giải mã**: `np.frombuffer(blob, dtype=np.float64)` — validate `enc.size == 128`
|
||||
|
||||
---
|
||||
|
||||
## Logic nhận diện khuôn mặt (`/checkin`)
|
||||
|
||||
1. Nhận ảnh JPEG → lưu tạm `uploads/checkin.jpg`
|
||||
2. `face_recognition.face_encodings()` → encoding 128-dim
|
||||
3. Load **tất cả** encodings từ DB → so khớp `face_recognition.face_distance()`
|
||||
4. Chọn student có `min_dist` nhỏ nhất
|
||||
5. Kiểm tra: `best_distance ≤ 0.42` **AND** `ratio = best/second_best ≤ 0.85`
|
||||
6. Kiểm tra recent check (tránh điểm danh 2 lần trong 30 giây)
|
||||
7. Ghi `checkin_logs` → `BackgroundTask`: gửi ảnh + tạo history trên MS API
|
||||
|
||||
**Bottleneck chính**: Bước 3 — load toàn bộ encodings, giải mã numpy, so khớp tuần tự trong request.
|
||||
|
||||
---
|
||||
|
||||
## External API (ms.prology.net)
|
||||
|
||||
- `POST /api/v1/admin/tracking/scan-create` — tạo history check-in
|
||||
- `POST /api/v1/admin/tracking/send-image` — upload ảnh check-in
|
||||
- `GET /api/v1/admin/timekeeping` — lấy dữ liệu chấm công (dùng trong `sync.py`)
|
||||
|
||||
Token JWT được hardcode trong `api.py` — cần chuyển sang env variable.
|
||||
|
||||
---
|
||||
|
||||
## Frontend State Management
|
||||
|
||||
**Zustand stores:**
|
||||
- `use-app-store.ts` — `isAutoChecking`, `isCountDown`, `refreshLog`, video/canvas refs
|
||||
- `use-user-store.ts` — `currentUser` (user được chọn cho checkpoint)
|
||||
|
||||
**Auto check-in**: interval 3000ms, gọi `/checkin` liên tục khi `isAutoChecking = true`
|
||||
|
||||
---
|
||||
|
||||
## Các lưu ý quan trọng
|
||||
|
||||
- `UPLOAD_DIR = ./uploads/` — lưu ảnh tạm check-in, bị ghi đè mỗi lần (`checkin.jpg`)
|
||||
- `images/{YYYY_MM_DD}/` — lưu ảnh vĩnh viễn theo ngày (tạo trong `sync.py`)
|
||||
- DB session trong `/checkin` dùng `Depends(get_db)`, các endpoint khác tạo `SessionLocal()` trực tiếp — cần thống nhất
|
||||
- Tối đa 10 encodings/user (giới hạn trong `sync.py`)
|
||||
- CORS `allow_origins=["*"]` — chấp nhận vì deploy nội bộ
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
Run client: cd client && npm run dev or npm run build && npm run preview
|
||||
|
||||
==> Build client xong => coppy file asset và index vào folder static của server => thêm prefix static vào link của assets trong file index VD: /camera/static/assets
|
||||
|
||||
Run server uvicorn main:app --reload
|
||||
|
||||
nohup uvicorn main:app --host 172.16.6.38 --port 8080 > log.log 2>&1 &
|
||||
|
||||
ps aux | grep uvicorn
|
||||
|
||||
truncate -s 0 log.log
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
|
||||
import os
|
||||
import datetime
|
||||
import requests
|
||||
from fastapi import UploadFile
|
||||
|
||||
URL_API = "http://172.16.6.38:8000/api/v1"
|
||||
|
||||
|
||||
def send_image(id, image_bytes, student_name: str, status: str):
|
||||
id = str(id)
|
||||
|
||||
today = datetime.datetime.now().strftime("%Y_%m_%d")
|
||||
folder_path = f"./images/{today}"
|
||||
os.makedirs(folder_path, exist_ok=True)
|
||||
|
||||
safe_student = "".join(c for c in student_name if c.isalnum() or c in ("-", "_"))
|
||||
safe_status = "".join(c for c in status if c.isalnum() or c in ("-", "_"))
|
||||
timestamp = datetime.datetime.now().strftime("%Y_%m_%d_%H_%M_%S")
|
||||
|
||||
file_name = f"{safe_student}_{safe_status}_at_{timestamp}.png"
|
||||
file_path = os.path.join(folder_path, file_name)
|
||||
|
||||
# Lưu xuống
|
||||
with open(file_path, "wb") as f:
|
||||
f.write(image_bytes)
|
||||
|
||||
# Gửi API
|
||||
try:
|
||||
with open(file_path, "rb") as image_file:
|
||||
response = requests.post(
|
||||
URL_API + "/admin/tracking/send-image",
|
||||
data={"id": id, "file_name": file_name},
|
||||
files={"image": image_file}
|
||||
)
|
||||
response.raise_for_status()
|
||||
except Exception as e:
|
||||
print("Send image failed:", e)
|
||||
|
||||
|
||||
def create_history(data):
|
||||
# Gửi yêu cầu POST với dữ liệu đã chỉ định
|
||||
response = requests.post(URL_API+"/admin/tracking/scan-create", data=data)
|
||||
res = response.json()
|
||||
|
||||
print(res)
|
||||
|
||||
return res
|
||||
|
||||
|
||||
def users(params):
|
||||
# Gửi yêu cầu POST với dữ liệu đã chỉ định
|
||||
response = requests.get(URL_API+"/admin/timekeeping", params=params, headers={"authorization": "Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJodHRwczovL21zLnByb2xvZ3kubmV0L2FwaS92MS9hZG1pbi9sb2dpbiIsImlhdCI6MTc1Njg2MDQ1OSwiZXhwIjoxNzg4Mzk2NDU5LCJuYmYiOjE3NTY4NjA0NTksImp0aSI6IkRrb0NLbHBKV1pkNnZCN0QiLCJzdWIiOiIxNSIsInBydiI6ImQyZmYyOTMzOWE4YTNlODJjMzU4MmE1YThlNzM5ZGYxNzg5YmIxMmYifQ.DoHqHeAGGxpvzlNQ9dAZjZf2Yl573XCgNBT8ZiSx5N4"})
|
||||
res = response.json()
|
||||
|
||||
return res
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
import cv2
|
||||
import requests
|
||||
# source path/to/venv/bin/activate
|
||||
API_URL = "http://localhost:8000/checkin" # Đổi lại nếu backend chạy ở địa chỉ khác
|
||||
CAMERA_ID = "cam_pc_01"
|
||||
|
||||
def capture_and_checkin():
|
||||
cap = cv2.VideoCapture(0) # Dùng camera mặc định (webcam)
|
||||
|
||||
if not cap.isOpened():
|
||||
print("Không mở được camera.")
|
||||
return
|
||||
|
||||
print("Đang mở camera. Nhấn phím 'c' để check-in, 'q' để thoát.")
|
||||
while True:
|
||||
ret, frame = cap.read()
|
||||
if not ret:
|
||||
print("Không đọc được frame.")
|
||||
break
|
||||
|
||||
cv2.imshow("Camera", frame)
|
||||
|
||||
key = cv2.waitKey(1)
|
||||
if key == ord("q"):
|
||||
break
|
||||
elif key == ord("c"):
|
||||
# Ghi tạm ảnh ra file
|
||||
filename = "frame.jpg"
|
||||
cv2.imwrite(filename, frame)
|
||||
|
||||
# Gửi ảnh lên server
|
||||
with open(filename, "rb") as f:
|
||||
response = requests.post(
|
||||
API_URL,
|
||||
files={"file": ("frame.jpg", f, "image/jpeg")},
|
||||
data={"camera_id": CAMERA_ID}
|
||||
)
|
||||
|
||||
print("📡 Server:", response.json())
|
||||
|
||||
cap.release()
|
||||
cv2.destroyAllWindows()
|
||||
|
||||
if __name__ == "__main__":
|
||||
capture_and_checkin()
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
VITE_API_BASE_URL = "/camera"
|
||||
# VITE_API_BASE_URL = "http://127.0.0.1:8000"
|
||||
VITE_API_BASE_MS = "https://ms.prology.net"
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
*.env
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
# React + TypeScript + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||
|
||||
## React Compiler
|
||||
|
||||
The React Compiler is enabled on this template. See [this documentation](https://react.dev/learn/react-compiler) for more information.
|
||||
|
||||
Note: This will impact Vite dev & build performances.
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||
|
||||
```js
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
|
||||
// Remove tseslint.configs.recommended and replace with this
|
||||
tseslint.configs.recommendedTypeChecked,
|
||||
// Alternatively, use this for stricter rules
|
||||
tseslint.configs.strictTypeChecked,
|
||||
// Optionally, add this for stylistic rules
|
||||
tseslint.configs.stylisticTypeChecked,
|
||||
|
||||
// Other configs...
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
|
||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||
|
||||
```js
|
||||
// eslint.config.js
|
||||
import reactX from 'eslint-plugin-react-x'
|
||||
import reactDom from 'eslint-plugin-react-dom'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
// Enable lint rules for React
|
||||
reactX.configs['recommended-typescript'],
|
||||
// Enable lint rules for React DOM
|
||||
reactDom.configs.recommended,
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/index.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"registries": {}
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
import js from "@eslint/js";
|
||||
import globals from "globals";
|
||||
import reactHooks from "eslint-plugin-react-hooks";
|
||||
import reactRefresh from "eslint-plugin-react-refresh";
|
||||
import tseslint from "typescript-eslint";
|
||||
import { defineConfig, globalIgnores } from "eslint/config";
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(["dist"]),
|
||||
{
|
||||
files: ["**/*.{ts,tsx}"],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
rules: {
|
||||
"eslint-disable @typescript-eslint/no-explicit-any": "off",
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>client</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,55 @@
|
|||
{
|
||||
"name": "client",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@radix-ui/react-avatar": "^1.1.11",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@tailwindcss/vite": "^4.1.17",
|
||||
"axios": "^1.13.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"face-api.js": "^0.22.2",
|
||||
"framer-motion": "^12.23.25",
|
||||
"lucide-react": "^0.556.0",
|
||||
"moment": "^2.30.1",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-hook-form": "^7.68.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwindcss": "^4.1.17",
|
||||
"zod": "^4.1.13",
|
||||
"zustand": "^5.0.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@types/node": "^24.10.2",
|
||||
"@types/react": "^19.2.5",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"babel-plugin-react-compiler": "^1.0.0",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"globals": "^16.5.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.46.4",
|
||||
"vite": "^7.2.4"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
|
@ -0,0 +1 @@
|
|||
[{"weights":[{"name":"conv0/filters","shape":[3,3,3,16],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.009007044399485869,"min":-1.2069439495311063}},{"name":"conv0/bias","shape":[16],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.005263455241334205,"min":-0.9211046672334858}},{"name":"conv1/depthwise_filter","shape":[3,3,16,1],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.004001977630690033,"min":-0.5042491814669441}},{"name":"conv1/pointwise_filter","shape":[1,1,16,32],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.013836609615999109,"min":-1.411334180831909}},{"name":"conv1/bias","shape":[32],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.0015159862590771096,"min":-0.30926119685173037}},{"name":"conv2/depthwise_filter","shape":[3,3,32,1],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.002666276225856706,"min":-0.317286870876948}},{"name":"conv2/pointwise_filter","shape":[1,1,32,64],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.015265831292844286,"min":-1.6792414422128714}},{"name":"conv2/bias","shape":[64],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.0020280554598453,"min":-0.37113414915168985}},{"name":"conv3/depthwise_filter","shape":[3,3,64,1],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.006100742489683862,"min":-0.8907084034938438}},{"name":"conv3/pointwise_filter","shape":[1,1,64,128],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.016276211832083907,"min":-2.0508026908425725}},{"name":"conv3/bias","shape":[128],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.003394414279975143,"min":-0.7637432129944072}},{"name":"conv4/depthwise_filter","shape":[3,3,128,1],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.006716050119961009,"min":-0.8059260143953211}},{"name":"conv4/pointwise_filter","shape":[1,1,128,256],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.021875603993733724,"min":-2.8875797271728514}},{"name":"conv4/bias","shape":[256],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.0041141652009066415,"min":-0.8187188749804216}},{"name":"conv5/depthwise_filter","shape":[3,3,256,1],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.008423839597141042,"min":-0.9013508368940915}},{"name":"conv5/pointwise_filter","shape":[1,1,256,512],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.030007277283014035,"min":-3.8709387695088107}},{"name":"conv5/bias","shape":[512],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.008402082966823203,"min":-1.4871686851277068}},{"name":"conv8/filters","shape":[1,1,512,25],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.028336129469030042,"min":-4.675461362389957}},{"name":"conv8/bias","shape":[25],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.002268134028303857,"min":-0.41053225912299807}}],"paths":["tiny_face_detector_model-shard1"]}]
|
||||
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
|
|
@ -0,0 +1,7 @@
|
|||
import ax from "axios";
|
||||
|
||||
const axios = ax.create({
|
||||
baseURL: import.meta.env.VITE_API_BASE_URL || "/",
|
||||
});
|
||||
|
||||
export default axios;
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import axios from "./axios";
|
||||
|
||||
class CheckingApi {
|
||||
async logs() {
|
||||
return await axios({
|
||||
method: "GET",
|
||||
url: "logs",
|
||||
});
|
||||
}
|
||||
async users() {
|
||||
return await axios({
|
||||
method: "GET",
|
||||
url: "users",
|
||||
});
|
||||
}
|
||||
|
||||
async register({ file, user }: { user: IUser; file: any }) {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file, "frame.jpg");
|
||||
|
||||
for (const [key, value] of Object.entries(user)) {
|
||||
formData.append(key, value);
|
||||
}
|
||||
|
||||
return await axios({
|
||||
headers: {
|
||||
"Content-Type": "multipart/form-data",
|
||||
},
|
||||
method: "POST",
|
||||
url: "/register",
|
||||
data: formData,
|
||||
});
|
||||
}
|
||||
|
||||
async registerSimple({ user }: { user: IUser }) {
|
||||
const formData = new FormData();
|
||||
|
||||
for (const [key, value] of Object.entries(user)) {
|
||||
formData.append(key, value);
|
||||
}
|
||||
|
||||
return await axios({
|
||||
headers: {
|
||||
"Content-Type": "multipart/form-data",
|
||||
},
|
||||
method: "POST",
|
||||
url: "/register-simple",
|
||||
data: formData,
|
||||
});
|
||||
}
|
||||
|
||||
async checkin({ file }: { file: any }) {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file, "frame.jpg");
|
||||
|
||||
return await axios({
|
||||
headers: {
|
||||
"Content-Type": "multipart/form-data",
|
||||
},
|
||||
method: "POST",
|
||||
url: "/checkin",
|
||||
data: formData,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const checkingApi = new CheckingApi();
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
import axios from "axios";
|
||||
|
||||
class MsApi {
|
||||
async timekeepings() {
|
||||
return await axios({
|
||||
headers: {
|
||||
Authorization:
|
||||
"Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJodHRwczovL21zLnByb2xvZ3kubmV0L2FwaS92MS9hZG1pbi9sb2dpbiIsImlhdCI6MTc1Njg2MDQ1OSwiZXhwIjoxNzg4Mzk2NDU5LCJuYmYiOjE3NTY4NjA0NTksImp0aSI6IkRrb0NLbHBKV1pkNnZCN0QiLCJzdWIiOiIxNSIsInBydiI6ImQyZmYyOTMzOWE4YTNlODJjMzU4MmE1YThlNzM5ZGYxNzg5YmIxMmYifQ.DoHqHeAGGxpvzlNQ9dAZjZf2Yl573XCgNBT8ZiSx5N4",
|
||||
},
|
||||
baseURL: import.meta.env.VITE_API_BASE_MS + "/api/v1/admin",
|
||||
method: "GET",
|
||||
url: "timekeeping",
|
||||
params: {
|
||||
month: new Date().getMonth(),
|
||||
year: new Date().getFullYear(),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const msApi = new MsApi();
|
||||
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
|
|
@ -0,0 +1,125 @@
|
|||
/* eslint-disable react-refresh/only-export-components */
|
||||
"use client";
|
||||
|
||||
import type React from "react";
|
||||
import { createContext, useContext, useState, useCallback } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { AlertTriangle, Info, CheckCircle } from "lucide-react";
|
||||
|
||||
interface ConfirmOptions {
|
||||
title?: string;
|
||||
message: string;
|
||||
confirmText?: string;
|
||||
cancelText?: string;
|
||||
variant?: "default" | "destructive" | "warning" | "success";
|
||||
}
|
||||
|
||||
interface ConfirmContextType {
|
||||
confirm: (options: ConfirmOptions) => Promise<boolean>;
|
||||
}
|
||||
|
||||
const ConfirmContext = createContext<ConfirmContextType | undefined>(undefined);
|
||||
|
||||
export function ConfirmModalProvider({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [options, setOptions] = useState<ConfirmOptions>({
|
||||
message: "",
|
||||
variant: "default",
|
||||
});
|
||||
const [resolveCallback, setResolveCallback] = useState<
|
||||
((value: boolean) => void) | null
|
||||
>(null);
|
||||
|
||||
const confirm = useCallback((options: ConfirmOptions): Promise<boolean> => {
|
||||
setOptions({
|
||||
title: options.title || "Xác nhận",
|
||||
confirmText: options.confirmText || "Xác nhận",
|
||||
cancelText: options.cancelText || "Hủy",
|
||||
variant: options.variant || "default",
|
||||
...options,
|
||||
});
|
||||
setIsOpen(true);
|
||||
|
||||
return new Promise<boolean>((resolve) => {
|
||||
setResolveCallback(() => resolve);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleConfirm = () => {
|
||||
setIsOpen(false);
|
||||
resolveCallback?.(true);
|
||||
setResolveCallback(null);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setIsOpen(false);
|
||||
resolveCallback?.(false);
|
||||
setResolveCallback(null);
|
||||
};
|
||||
|
||||
const getIcon = () => {
|
||||
switch (options.variant) {
|
||||
case "destructive":
|
||||
case "warning":
|
||||
return <AlertTriangle className="h-6 w-6 text-destructive" />;
|
||||
case "success":
|
||||
return <CheckCircle className="h-6 w-6 text-green-600" />;
|
||||
default:
|
||||
return <Info className="h-6 w-6 text-blue-600" />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ConfirmContext.Provider value={{ confirm }}>
|
||||
{children}
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
{getIcon()}
|
||||
<DialogTitle className="text-lg">{options.title}</DialogTitle>
|
||||
</div>
|
||||
<DialogDescription className="pt-2 text-base">
|
||||
{options.message}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="gap-2 sm:gap-0 flex ">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" onClick={handleCancel}>
|
||||
{options.cancelText}
|
||||
</Button>
|
||||
<Button
|
||||
variant={
|
||||
options.variant === "destructive" ? "destructive" : "default"
|
||||
}
|
||||
onClick={handleConfirm}
|
||||
>
|
||||
{options.confirmText}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</ConfirmContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useConfirm() {
|
||||
const context = useContext(ConfirmContext);
|
||||
if (!context) {
|
||||
throw new Error("useConfirm must be used within ConfirmModalProvider");
|
||||
}
|
||||
return context.confirm;
|
||||
}
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
import * as React from "react"
|
||||
import * as AvatarPrimitive from "@radix-ui/react-avatar"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Avatar({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
|
||||
return (
|
||||
<AvatarPrimitive.Root
|
||||
data-slot="avatar"
|
||||
className={cn(
|
||||
"relative flex size-8 shrink-0 overflow-hidden rounded-full",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarImage({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
|
||||
return (
|
||||
<AvatarPrimitive.Image
|
||||
data-slot="avatar-image"
|
||||
className={cn("aspect-square size-full", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarFallback({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
|
||||
return (
|
||||
<AvatarPrimitive.Fallback
|
||||
data-slot="avatar-fallback"
|
||||
className={cn(
|
||||
"bg-muted flex size-full items-center justify-center rounded-full",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Avatar, AvatarImage, AvatarFallback }
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"span"> &
|
||||
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : "span"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="badge"
|
||||
className={cn(badgeVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost:
|
||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
icon: "size-9",
|
||||
"icon-sm": "size-8",
|
||||
"icon-lg": "size-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Button, buttonVariants }
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
className={cn(
|
||||
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn("leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
}
|
||||
|
|
@ -0,0 +1,141 @@
|
|||
import * as React from "react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { XIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Dialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||
}
|
||||
|
||||
function DialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||
}
|
||||
|
||||
function DialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||
}
|
||||
|
||||
function DialogClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DialogPortal data-slot="dialog-portal">
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close
|
||||
data-slot="dialog-close"
|
||||
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||
>
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn("text-lg leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
}
|
||||
|
|
@ -0,0 +1,255 @@
|
|||
import * as React from "react"
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function DropdownMenu({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Trigger
|
||||
data-slot="dropdown-menu-trigger"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuContent({
|
||||
className,
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
data-slot="dropdown-menu-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
variant?: "default" | "destructive"
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Item
|
||||
data-slot="dropdown-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioGroup
|
||||
data-slot="dropdown-menu-radio-group"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CircleIcon className="size-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Label
|
||||
data-slot="dropdown-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
data-slot="dropdown-menu-separator"
|
||||
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
className={cn(
|
||||
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSub({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto size-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSubContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
}
|
||||
|
|
@ -0,0 +1,165 @@
|
|||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import {
|
||||
Controller,
|
||||
FormProvider,
|
||||
useFormContext,
|
||||
useFormState,
|
||||
type ControllerProps,
|
||||
type FieldPath,
|
||||
type FieldValues,
|
||||
} from "react-hook-form"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Label } from "@/components/ui/label"
|
||||
|
||||
const Form = FormProvider
|
||||
|
||||
type FormFieldContextValue<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
> = {
|
||||
name: TName
|
||||
}
|
||||
|
||||
const FormFieldContext = React.createContext<FormFieldContextValue>(
|
||||
{} as FormFieldContextValue
|
||||
)
|
||||
|
||||
const FormField = <
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
>({
|
||||
...props
|
||||
}: ControllerProps<TFieldValues, TName>) => {
|
||||
return (
|
||||
<FormFieldContext.Provider value={{ name: props.name }}>
|
||||
<Controller {...props} />
|
||||
</FormFieldContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
const useFormField = () => {
|
||||
const fieldContext = React.useContext(FormFieldContext)
|
||||
const itemContext = React.useContext(FormItemContext)
|
||||
const { getFieldState } = useFormContext()
|
||||
const formState = useFormState({ name: fieldContext.name })
|
||||
const fieldState = getFieldState(fieldContext.name, formState)
|
||||
|
||||
if (!fieldContext) {
|
||||
throw new Error("useFormField should be used within <FormField>")
|
||||
}
|
||||
|
||||
const { id } = itemContext
|
||||
|
||||
return {
|
||||
id,
|
||||
name: fieldContext.name,
|
||||
formItemId: `${id}-form-item`,
|
||||
formDescriptionId: `${id}-form-item-description`,
|
||||
formMessageId: `${id}-form-item-message`,
|
||||
...fieldState,
|
||||
}
|
||||
}
|
||||
|
||||
type FormItemContextValue = {
|
||||
id: string
|
||||
}
|
||||
|
||||
const FormItemContext = React.createContext<FormItemContextValue>(
|
||||
{} as FormItemContextValue
|
||||
)
|
||||
|
||||
function FormItem({ className, ...props }: React.ComponentProps<"div">) {
|
||||
const id = React.useId()
|
||||
|
||||
return (
|
||||
<FormItemContext.Provider value={{ id }}>
|
||||
<div
|
||||
data-slot="form-item"
|
||||
className={cn("grid gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
</FormItemContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
function FormLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
const { error, formItemId } = useFormField()
|
||||
|
||||
return (
|
||||
<Label
|
||||
data-slot="form-label"
|
||||
data-error={!!error}
|
||||
className={cn("data-[error=true]:text-destructive", className)}
|
||||
htmlFor={formItemId}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
|
||||
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
|
||||
|
||||
return (
|
||||
<Slot
|
||||
data-slot="form-control"
|
||||
id={formItemId}
|
||||
aria-describedby={
|
||||
!error
|
||||
? `${formDescriptionId}`
|
||||
: `${formDescriptionId} ${formMessageId}`
|
||||
}
|
||||
aria-invalid={!!error}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||
const { formDescriptionId } = useFormField()
|
||||
|
||||
return (
|
||||
<p
|
||||
data-slot="form-description"
|
||||
id={formDescriptionId}
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
|
||||
const { error, formMessageId } = useFormField()
|
||||
const body = error ? String(error?.message ?? "") : props.children
|
||||
|
||||
if (!body) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<p
|
||||
data-slot="form-message"
|
||||
id={formMessageId}
|
||||
className={cn("text-destructive text-sm", className)}
|
||||
{...props}
|
||||
>
|
||||
{body}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
useFormField,
|
||||
Form,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormMessage,
|
||||
FormField,
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Input }
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Label({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
return (
|
||||
<LabelPrimitive.Root
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Label }
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
import * as React from "react"
|
||||
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function ScrollArea({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.Root
|
||||
data-slot="scroll-area"
|
||||
className={cn("relative", className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport
|
||||
data-slot="scroll-area-viewport"
|
||||
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
|
||||
>
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
function ScrollBar({
|
||||
className,
|
||||
orientation = "vertical",
|
||||
...props
|
||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
data-slot="scroll-area-scrollbar"
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"flex touch-none p-px transition-colors select-none",
|
||||
orientation === "vertical" &&
|
||||
"h-full w-2.5 border-l border-l-transparent",
|
||||
orientation === "horizontal" &&
|
||||
"h-2.5 flex-col border-t border-t-transparent",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb
|
||||
data-slot="scroll-area-thumb"
|
||||
className="bg-border relative flex-1 rounded-full"
|
||||
/>
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
)
|
||||
}
|
||||
|
||||
export { ScrollArea, ScrollBar }
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
import {
|
||||
CircleCheckIcon,
|
||||
InfoIcon,
|
||||
Loader2Icon,
|
||||
OctagonXIcon,
|
||||
TriangleAlertIcon,
|
||||
} from "lucide-react"
|
||||
import { useTheme } from "next-themes"
|
||||
import { Toaster as Sonner, type ToasterProps } from "sonner"
|
||||
|
||||
const Toaster = ({ ...props }: ToasterProps) => {
|
||||
const { theme = "system" } = useTheme()
|
||||
|
||||
return (
|
||||
<Sonner
|
||||
theme={theme as ToasterProps["theme"]}
|
||||
className="toaster group"
|
||||
icons={{
|
||||
success: <CircleCheckIcon className="size-4" />,
|
||||
info: <InfoIcon className="size-4" />,
|
||||
warning: <TriangleAlertIcon className="size-4" />,
|
||||
error: <OctagonXIcon className="size-4" />,
|
||||
loading: <Loader2Icon className="size-4 animate-spin" />,
|
||||
}}
|
||||
style={
|
||||
{
|
||||
"--normal-bg": "var(--popover)",
|
||||
"--normal-text": "var(--popover-foreground)",
|
||||
"--normal-border": "var(--border)",
|
||||
"--border-radius": "var(--radius)",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Toaster }
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
import * as React from "react";
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Tabs({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
|
||||
return (
|
||||
<TabsPrimitive.Root
|
||||
data-slot="tabs"
|
||||
className={cn("flex flex-col gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TabsList({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.List>) {
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
data-slot="tabs-list"
|
||||
className={cn(
|
||||
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TabsTrigger({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(
|
||||
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TabsContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
|
||||
return (
|
||||
<TabsPrimitive.Content
|
||||
data-slot="tabs-content"
|
||||
className={cn("flex-1 outline-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent };
|
||||
|
|
@ -0,0 +1,120 @@
|
|||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
}
|
||||
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
interface ILog {
|
||||
name: string;
|
||||
time: string;
|
||||
camera_id: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
interface IUser {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
avatar?: string | null;
|
||||
checkpoints?: any[];
|
||||
}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
export function speak({
|
||||
type,
|
||||
str,
|
||||
}: {
|
||||
type?: "check out" | "check in";
|
||||
str?: string;
|
||||
}) {
|
||||
// Ưu tiên str, nếu không có thì dùng type
|
||||
const text =
|
||||
str ||
|
||||
(type === "check in"
|
||||
? "Check-in successful"
|
||||
: type === "check out"
|
||||
? "Check-out successful"
|
||||
: "");
|
||||
|
||||
if (!text) return; // không có gì để đọc
|
||||
|
||||
const utterance = new SpeechSynthesisUtterance(text);
|
||||
|
||||
utterance.lang = "en-US";
|
||||
utterance.rate = 1;
|
||||
utterance.pitch = 1;
|
||||
utterance.volume = 1;
|
||||
|
||||
speechSynthesis.speak(utterance);
|
||||
}
|
||||
|
|
@ -0,0 +1,388 @@
|
|||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { useEffect, useRef, useState, type RefObject } from "react";
|
||||
|
||||
export type FaceZoomTransform = {
|
||||
scale: number;
|
||||
translateX: number; // percent of element width
|
||||
translateY: number; // percent of element height
|
||||
};
|
||||
|
||||
export type FaceBox = {
|
||||
/** All values are ratios (0..1) of the source video frame. */
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
export type FaceZoomResult = {
|
||||
transform: FaceZoomTransform;
|
||||
box: FaceBox | null;
|
||||
/** Detector confidence (0..1). */
|
||||
confidence: number;
|
||||
/** 0..1 fill progress of the stable-face timer. Updates at 60fps. */
|
||||
stabilityProgress: number;
|
||||
};
|
||||
|
||||
const IDLE_TRANSFORM: FaceZoomTransform = {
|
||||
scale: 1,
|
||||
translateX: 0,
|
||||
translateY: 0,
|
||||
};
|
||||
|
||||
type Options = {
|
||||
enabled?: boolean;
|
||||
intervalMs?: number;
|
||||
targetFaceRatio?: number;
|
||||
minScale?: number;
|
||||
maxScale?: number;
|
||||
/** Per-frame lerp factor at 60fps (0..1). Higher = snappier. */
|
||||
followSpeed?: number;
|
||||
/** EMA weight on each new detection (0..1). Lower = smoother target. */
|
||||
detectionSmoothing?: number;
|
||||
detectorInputSize?: number;
|
||||
detectorScoreThreshold?: number;
|
||||
|
||||
// --- Stable-face auto trigger ---
|
||||
/** Enable the stable-face callback. */
|
||||
stabilityEnabled?: boolean;
|
||||
/** Continuous duration (ms) the same face must stay before firing. */
|
||||
stableMs?: number;
|
||||
/** Max center movement (frame ratio) to still count as same face. */
|
||||
stabilityPositionThreshold?: number;
|
||||
/** Max relative face-size change to still count as same face. */
|
||||
stabilitySizeThreshold?: number;
|
||||
/** Fires once when stability duration is met; re-arms on absence/new person. */
|
||||
onStableFace?: () => void;
|
||||
};
|
||||
|
||||
type FilteredSample = {
|
||||
fx: number; // face center x ratio
|
||||
fy: number; // face center y ratio
|
||||
fh: number; // face height ratio
|
||||
};
|
||||
|
||||
export function useFaceZoom(
|
||||
videoRef: RefObject<HTMLVideoElement | null>,
|
||||
{
|
||||
enabled = true,
|
||||
intervalMs = 120,
|
||||
targetFaceRatio = 0.6,
|
||||
minScale = 1.35,
|
||||
maxScale = 4,
|
||||
followSpeed = 0.11,
|
||||
detectionSmoothing = 0.35,
|
||||
detectorInputSize = 416,
|
||||
detectorScoreThreshold = 0.3,
|
||||
stabilityEnabled = false,
|
||||
stableMs = 2000,
|
||||
stabilityPositionThreshold = 0.12,
|
||||
stabilitySizeThreshold = 0.4,
|
||||
onStableFace,
|
||||
}: Options = {}
|
||||
): FaceZoomResult {
|
||||
const [transform, setTransform] = useState<FaceZoomTransform>(IDLE_TRANSFORM);
|
||||
const [box, setBox] = useState<FaceBox | null>(null);
|
||||
const [confidence, setConfidence] = useState(0);
|
||||
const [stabilityProgress, setStabilityProgress] = useState(0);
|
||||
const stabilityProgressRef = useRef(0);
|
||||
const currentRef = useRef<FaceZoomTransform>(IDLE_TRANSFORM);
|
||||
const targetRef = useRef<FaceZoomTransform>(IDLE_TRANSFORM);
|
||||
const filteredRef = useRef<FilteredSample | null>(null);
|
||||
const filteredBoxRef = useRef<FaceBox | null>(null);
|
||||
const filteredConfRef = useRef(0);
|
||||
const missCountRef = useRef(0);
|
||||
|
||||
// Stability state — kept in refs so they survive ticks without re-rendering.
|
||||
const stableSinceRef = useRef<number | null>(null);
|
||||
const stableFiredRef = useRef(false);
|
||||
const stablePrevRef = useRef<FilteredSample | null>(null);
|
||||
// Latest stability config (so updating it doesn't tear down the detection loop).
|
||||
const stabilityCfgRef = useRef({
|
||||
enabled: stabilityEnabled,
|
||||
stableMs,
|
||||
positionThreshold: stabilityPositionThreshold,
|
||||
sizeThreshold: stabilitySizeThreshold,
|
||||
onStableFace,
|
||||
});
|
||||
useEffect(() => {
|
||||
stabilityCfgRef.current = {
|
||||
enabled: stabilityEnabled,
|
||||
stableMs,
|
||||
positionThreshold: stabilityPositionThreshold,
|
||||
sizeThreshold: stabilitySizeThreshold,
|
||||
onStableFace,
|
||||
};
|
||||
// When disabling, also clear pending state so it re-arms cleanly next time.
|
||||
if (!stabilityEnabled) {
|
||||
stableSinceRef.current = null;
|
||||
stableFiredRef.current = false;
|
||||
stablePrevRef.current = null;
|
||||
}
|
||||
}, [
|
||||
stabilityEnabled,
|
||||
stableMs,
|
||||
stabilityPositionThreshold,
|
||||
stabilitySizeThreshold,
|
||||
onStableFace,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
currentRef.current = IDLE_TRANSFORM;
|
||||
targetRef.current = IDLE_TRANSFORM;
|
||||
filteredRef.current = null;
|
||||
filteredBoxRef.current = null;
|
||||
filteredConfRef.current = 0;
|
||||
setTransform(IDLE_TRANSFORM);
|
||||
setBox(null);
|
||||
setConfidence(0);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
let intervalId: number | null = null;
|
||||
let rafId: number | null = null;
|
||||
let lastTs = 0;
|
||||
let faceapi: any = null;
|
||||
let detectorOptions: any = null;
|
||||
let running = false;
|
||||
|
||||
const animate = (ts: number) => {
|
||||
if (cancelled) return;
|
||||
const dt = lastTs ? (ts - lastTs) / 1000 : 1 / 60;
|
||||
lastTs = ts;
|
||||
// Frame-rate-aware lerp: stays consistent at 60/120Hz.
|
||||
const k = 1 - Math.pow(1 - followSpeed, dt * 60);
|
||||
|
||||
const cur = currentRef.current;
|
||||
const tgt = targetRef.current;
|
||||
const next: FaceZoomTransform = {
|
||||
scale: cur.scale + (tgt.scale - cur.scale) * k,
|
||||
translateX: cur.translateX + (tgt.translateX - cur.translateX) * k,
|
||||
translateY: cur.translateY + (tgt.translateY - cur.translateY) * k,
|
||||
};
|
||||
|
||||
currentRef.current = next;
|
||||
|
||||
const dScale = Math.abs(next.scale - tgt.scale);
|
||||
const dTx = Math.abs(next.translateX - tgt.translateX);
|
||||
const dTy = Math.abs(next.translateY - tgt.translateY);
|
||||
// Skip re-render when essentially settled — avoids React churn.
|
||||
if (dScale > 0.0005 || dTx > 0.02 || dTy > 0.02) {
|
||||
setTransform(next);
|
||||
} else if (
|
||||
next.scale !== tgt.scale ||
|
||||
next.translateX !== tgt.translateX ||
|
||||
next.translateY !== tgt.translateY
|
||||
) {
|
||||
currentRef.current = tgt;
|
||||
setTransform(tgt);
|
||||
}
|
||||
|
||||
// Smooth 60fps fill of the stability progress (independent of the
|
||||
// ~110ms detector tick → no stair-step in the progress bar).
|
||||
const cfg = stabilityCfgRef.current;
|
||||
let nextProgress = 0;
|
||||
if (cfg.enabled) {
|
||||
if (stableFiredRef.current) {
|
||||
nextProgress = 1;
|
||||
} else if (stableSinceRef.current !== null && cfg.stableMs > 0) {
|
||||
nextProgress = Math.min(
|
||||
1,
|
||||
(ts - stableSinceRef.current) / cfg.stableMs
|
||||
);
|
||||
}
|
||||
}
|
||||
if (Math.abs(nextProgress - stabilityProgressRef.current) > 0.003) {
|
||||
stabilityProgressRef.current = nextProgress;
|
||||
setStabilityProgress(nextProgress);
|
||||
} else if (
|
||||
(nextProgress === 0 || nextProgress === 1) &&
|
||||
stabilityProgressRef.current !== nextProgress
|
||||
) {
|
||||
// Snap to exact endpoints so the bar fully clears / fills.
|
||||
stabilityProgressRef.current = nextProgress;
|
||||
setStabilityProgress(nextProgress);
|
||||
}
|
||||
|
||||
rafId = requestAnimationFrame(animate);
|
||||
};
|
||||
|
||||
const tick = async () => {
|
||||
if (running) return;
|
||||
const video = videoRef.current;
|
||||
if (!video || video.readyState < 2 || !video.videoWidth) return;
|
||||
running = true;
|
||||
try {
|
||||
const detection = await faceapi.detectSingleFace(video, detectorOptions);
|
||||
if (cancelled) return;
|
||||
|
||||
if (!detection) {
|
||||
missCountRef.current += 1;
|
||||
// Stability resets quickly so the timer truly restarts on absence.
|
||||
if (missCountRef.current >= 2) {
|
||||
stableSinceRef.current = null;
|
||||
stableFiredRef.current = false;
|
||||
stablePrevRef.current = null;
|
||||
}
|
||||
// Hold zoom position longer to avoid jarring zoom-out on brief misses.
|
||||
if (missCountRef.current >= 25) {
|
||||
targetRef.current = IDLE_TRANSFORM;
|
||||
filteredRef.current = null;
|
||||
filteredBoxRef.current = null;
|
||||
filteredConfRef.current = 0;
|
||||
setBox(null);
|
||||
setConfidence(0);
|
||||
}
|
||||
return;
|
||||
}
|
||||
missCountRef.current = 0;
|
||||
|
||||
const { x, y, width, height } = detection.box;
|
||||
const score = detection.score ?? detection.classScore ?? 0;
|
||||
const vw = video.videoWidth;
|
||||
const vh = video.videoHeight;
|
||||
if (!vw || !vh) return;
|
||||
|
||||
const sample: FilteredSample = {
|
||||
fx: (x + width / 2) / vw,
|
||||
fy: (y + height / 2) / vh,
|
||||
fh: height / vh,
|
||||
};
|
||||
const sampleBox: FaceBox = {
|
||||
x: x / vw,
|
||||
y: y / vh,
|
||||
width: width / vw,
|
||||
height: height / vh,
|
||||
};
|
||||
|
||||
const a = detectionSmoothing;
|
||||
const prev = filteredRef.current;
|
||||
const filt: FilteredSample = prev
|
||||
? {
|
||||
fx: prev.fx + (sample.fx - prev.fx) * a,
|
||||
fy: prev.fy + (sample.fy - prev.fy) * a,
|
||||
fh: prev.fh + (sample.fh - prev.fh) * a,
|
||||
}
|
||||
: sample;
|
||||
filteredRef.current = filt;
|
||||
|
||||
const prevBox = filteredBoxRef.current;
|
||||
const filtBox: FaceBox = prevBox
|
||||
? {
|
||||
x: prevBox.x + (sampleBox.x - prevBox.x) * a,
|
||||
y: prevBox.y + (sampleBox.y - prevBox.y) * a,
|
||||
width: prevBox.width + (sampleBox.width - prevBox.width) * a,
|
||||
height: prevBox.height + (sampleBox.height - prevBox.height) * a,
|
||||
}
|
||||
: sampleBox;
|
||||
filteredBoxRef.current = filtBox;
|
||||
setBox(filtBox);
|
||||
|
||||
const filtConf =
|
||||
filteredConfRef.current + (score - filteredConfRef.current) * a;
|
||||
filteredConfRef.current = filtConf;
|
||||
setConfidence(filtConf);
|
||||
|
||||
// --- Stable-face tracking (uses RAW sample so a new person trips it
|
||||
// immediately, before EMA can drift toward the new position). ---
|
||||
const cfg = stabilityCfgRef.current;
|
||||
if (cfg.enabled) {
|
||||
const prevS = stablePrevRef.current;
|
||||
const now = performance.now();
|
||||
let sameFace = false;
|
||||
if (prevS) {
|
||||
const dx = Math.abs(sample.fx - prevS.fx);
|
||||
const dy = Math.abs(sample.fy - prevS.fy);
|
||||
const dh =
|
||||
Math.abs(sample.fh - prevS.fh) / Math.max(prevS.fh, 0.01);
|
||||
sameFace =
|
||||
dx < cfg.positionThreshold &&
|
||||
dy < cfg.positionThreshold &&
|
||||
dh < cfg.sizeThreshold;
|
||||
}
|
||||
|
||||
if (!sameFace) {
|
||||
// First detection after absence OR a different person → restart timer.
|
||||
stableSinceRef.current = now;
|
||||
stableFiredRef.current = false;
|
||||
} else if (
|
||||
!stableFiredRef.current &&
|
||||
stableSinceRef.current !== null &&
|
||||
now - stableSinceRef.current >= cfg.stableMs
|
||||
) {
|
||||
stableFiredRef.current = true;
|
||||
try {
|
||||
cfg.onStableFace?.();
|
||||
} catch (e) {
|
||||
console.error("onStableFace handler threw", e);
|
||||
}
|
||||
}
|
||||
stablePrevRef.current = sample;
|
||||
}
|
||||
|
||||
let scale = targetFaceRatio / Math.max(filt.fh, 0.05);
|
||||
scale = Math.max(minScale, Math.min(scale, maxScale));
|
||||
|
||||
const halfViewX = 0.5 / scale;
|
||||
const halfViewY = 0.5 / scale;
|
||||
const fx = Math.min(1 - halfViewX, Math.max(halfViewX, filt.fx));
|
||||
const fy = Math.min(1 - halfViewY, Math.max(halfViewY, filt.fy));
|
||||
|
||||
targetRef.current = {
|
||||
scale,
|
||||
translateX: (0.5 - fx * scale) * 100,
|
||||
translateY: (0.5 - fy * scale) * 100,
|
||||
};
|
||||
} catch (err) {
|
||||
console.debug("face detect error", err);
|
||||
} finally {
|
||||
running = false;
|
||||
}
|
||||
};
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
faceapi = await import("face-api.js");
|
||||
const modelUrl = `${import.meta.env.BASE_URL}models`.replace(
|
||||
/\/\/+/g,
|
||||
"/"
|
||||
);
|
||||
if (!faceapi.nets.tinyFaceDetector.isLoaded) {
|
||||
await faceapi.nets.tinyFaceDetector.loadFromUri(modelUrl);
|
||||
}
|
||||
if (cancelled) return;
|
||||
detectorOptions = new faceapi.TinyFaceDetectorOptions({
|
||||
inputSize: detectorInputSize,
|
||||
scoreThreshold: detectorScoreThreshold,
|
||||
});
|
||||
intervalId = window.setInterval(tick, intervalMs);
|
||||
rafId = requestAnimationFrame(animate);
|
||||
} catch (err) {
|
||||
console.error("[useFaceZoom] failed to load face-api models", err);
|
||||
}
|
||||
};
|
||||
|
||||
load();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (intervalId !== null) clearInterval(intervalId);
|
||||
if (rafId !== null) cancelAnimationFrame(rafId);
|
||||
};
|
||||
}, [
|
||||
videoRef,
|
||||
enabled,
|
||||
intervalMs,
|
||||
targetFaceRatio,
|
||||
minScale,
|
||||
maxScale,
|
||||
followSpeed,
|
||||
detectionSmoothing,
|
||||
detectorInputSize,
|
||||
detectorScoreThreshold,
|
||||
]);
|
||||
|
||||
return { transform, box, confidence, stabilityProgress };
|
||||
}
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import moment from "moment";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import useAppStore from "@/stores/use-app-store";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
export const formatTime = (timeString: string) => {
|
||||
return moment(timeString).format("DD/MM/YYYY HH:mm:ss");
|
||||
};
|
||||
|
||||
export function capture(videoRef: any, canvasRef: any) {
|
||||
if (!videoRef.current || !canvasRef.current) return;
|
||||
|
||||
const canvas = canvasRef.current;
|
||||
const video = videoRef.current;
|
||||
const context = canvas.getContext("2d");
|
||||
|
||||
const vw: number = video.videoWidth;
|
||||
const vh: number = video.videoHeight;
|
||||
|
||||
// Crop to the same region the user sees on screen (zoomed/focused view).
|
||||
// The video element has transform: translate(tx%, ty%) scale(s) with
|
||||
// transform-origin (0,0). The visible source region in video coords is:
|
||||
// srcX = (-tx/100)/s * vw
|
||||
// srcY = (-ty/100)/s * vh
|
||||
// srcW = vw / s
|
||||
// srcH = vh / s
|
||||
let srcX = 0;
|
||||
let srcY = 0;
|
||||
let srcW = vw;
|
||||
let srcH = vh;
|
||||
|
||||
const t = useAppStore.getState().faceZoomTransform;
|
||||
if (t && t.scale > 1.0001) {
|
||||
const s = t.scale;
|
||||
srcW = vw / s;
|
||||
srcH = vh / s;
|
||||
srcX = (-t.translateX / 100 / s) * vw;
|
||||
srcY = (-t.translateY / 100 / s) * vh;
|
||||
// Clamp inside the source frame (the on-screen clamp already prevents
|
||||
// empty edges, but rounding can drift a sub-pixel out of range).
|
||||
srcX = Math.max(0, Math.min(vw - srcW, srcX));
|
||||
srcY = Math.max(0, Math.min(vh - srcH, srcY));
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
canvas.width = Math.round(srcW);
|
||||
canvas.height = Math.round(srcH);
|
||||
|
||||
context.drawImage(
|
||||
video,
|
||||
srcX,
|
||||
srcY,
|
||||
srcW,
|
||||
srcH,
|
||||
0,
|
||||
0,
|
||||
canvas.width,
|
||||
canvas.height
|
||||
);
|
||||
|
||||
canvas.toBlob(
|
||||
(blob: unknown) => {
|
||||
if (!blob) {
|
||||
reject("Không thể tạo blob từ canvas");
|
||||
return;
|
||||
}
|
||||
resolve(blob);
|
||||
},
|
||||
"image/jpeg",
|
||||
0.95 // chất lượng cao
|
||||
);
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue