Compare commits

..

No commits in common. "master" and "truong-leave-day" have entirely different histories.

205 changed files with 5823 additions and 33615 deletions

BIN
.DS_Store vendored

Binary file not shown.

View File

@ -3,7 +3,6 @@
namespace Modules\Admin\app\Http\Controllers;
use App\Http\Controllers\Controller;
use Carbon\Carbon;
use Illuminate\Http\Request;
use Modules\Admin\app\Models\Category;
@ -30,45 +29,4 @@ 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'
]);
}
}

View File

@ -1,359 +0,0 @@
<?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);
}
}

View File

@ -1,517 +0,0 @@
<?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());
}
}
}

View File

@ -7,18 +7,13 @@ 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);
@ -43,7 +38,6 @@ 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'),
@ -57,14 +51,13 @@ class LeaveManagementController extends Controller
)
// ->where('notes.n_user_id', "1")
->where('notes.n_year', $year)
->whereIn('notes.n_reason', ['ONLEAVE', 'LEAVE_WITHOUT_PAY', 'TEMPORARY_ONLEAVE'])
->whereIn('notes.n_reason', ['ONLEAVE', 'LEAVE_WITHOUT_PAY'])
// ->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,
@ -83,7 +76,6 @@ 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',
@ -105,7 +97,6 @@ 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,
@ -154,38 +145,6 @@ 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);

File diff suppressed because it is too large Load Diff

View File

@ -11,14 +11,12 @@ use App\Traits\HasOrderByRequest;
use App\Traits\HasSearchRequest;
use Carbon\Carbon;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Mail;
use App\Mail\TicketMail;
use Illuminate\Support\Facades\DB;
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
{
@ -78,14 +76,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(env('TIME_ZONE'))
'created_at' => $start->setTimezone('UTC')
],
[
'name' => $user->name,
'user_id' => $user->id,
'status' => 'check out',
'time_string' => $end->format('Y-m-d H:i:s'),
'created_at' => $end->setTimezone(env('TIME_ZONE'))
'created_at' => $end->setTimezone('UTC')
]
]);
}
@ -172,66 +170,87 @@ 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) {
return response()->json(['message' => 'Note not found', 'status' => false]);
if ($note) {
$n_month = $note->n_month;
$n_year = $note->n_year;
if ($note->n_reason == "ONLEAVE") {
// Get note reason ONLEAVE by $n_month, $n_year not include $note->id & include $note->n_user_id
// $onleave = Notes::getNotesByMonthAndYearAndUserId($n_month, $n_year, $note->n_user_id, $note->id);
// Get note reason LEAVE_WITHOUT_PAY by $n_month, $n_year & include $note->n_user_id
$leaveWithoutPay = Notes::getNotesByMonthAndYearAndUserIdAndReason($n_month, $n_year, $note->n_user_id, 'LEAVE_WITHOUT_PAY');
if (count($leaveWithoutPay) > 0) {
$deletedValue = ($note->n_time_type === 'ALL') ? 1.0 : 0.5;
$needUpdate = $deletedValue;
// dd($needUpdate, $leaveWithoutPay);
foreach ($leaveWithoutPay as $lwNote) {
if ($needUpdate <= 0) break;
if ($lwNote->n_time_type === 'ALL') {
if ($needUpdate == 1.0) {
// Chuyển cả note ALL thành phép
$lwNote->update(['n_reason' => 'ONLEAVE']);
$needUpdate = 0;
break;
} else { // $needUpdate == 0.5
// Tách ALL thành 2 note S và C, chuyển S thành phép, C giữ không phép
Notes::create([
'n_user_id' => $lwNote->n_user_id,
'n_day' => $lwNote->n_day,
'n_month' => $lwNote->n_month,
'n_year' => $lwNote->n_year,
'n_time_type' => 'S',
'n_reason' => 'ONLEAVE',
'n_note' => $lwNote->n_note
]);
Notes::create([
'n_user_id' => $lwNote->n_user_id,
'n_day' => $lwNote->n_day,
'n_month' => $lwNote->n_month,
'n_year' => $lwNote->n_year,
'n_time_type' => 'C',
'n_reason' => 'LEAVE_WITHOUT_PAY',
'n_note' => $lwNote->n_note
]);
$lwNote->delete();
$needUpdate = 0;
break;
}
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]);
} 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
}
$admin = auth('admins')->user();
// Handle send mail
$dataMasterStartPeriod = CategoryController::getListMasterByCodeAndType("TIME_TYPE", $ticket->start_period);
$dataMasterEndPeriod = CategoryController::getListMasterByCodeAndType("TIME_TYPE", $ticket->end_period);
$dataMasterType = CategoryController::getListMasterByCodeAndType("REASON", $ticket->type);
$formattedStartDate = Carbon::createFromFormat('Y-m-d', $ticket->start_date)->format('d/m/Y');
$formattedEndDate = Carbon::createFromFormat('Y-m-d', $ticket->end_date)->format('d/m/Y');
$user = Admin::find($ticket->user_id);
$data = array(
"email_template" => "email.notification_tickets_user",
"user_name" => $user->name,
"email" => $user->email,
"name" => $admin->name, //name admin duyệt
"date" => $dataMasterStartPeriod->c_name . " (" . $formattedStartDate . ") - " . $dataMasterEndPeriod->c_name . " (" . $formattedEndDate . ")",
"type" => $dataMasterType->c_name,
"note" => $ticket->reason,
"admin_note" => $ticket->admin_note,
"link" => "/tickets", //link đến page admin
"status" => "refused",
"subject" => "[Ticket response] Ticket From " . $admin->name
);
Mail::to($user->email)->send(new TicketMail($data));
// Update
$ticket->updated_by = $admin->name;
$ticket->status = "REFUSED";
$ticket->save();
Notes::where('ticket_id', $ticket->id)->delete();
// Clear Timekeeping cache
$this->createOrUpdateRecordForCurrentMonth(Carbon::parse($ticket->start_date)->month, Carbon::parse($ticket->start_date)->year);
$this->createOrUpdateRecordForCurrentMonth(Carbon::parse($ticket->end_date)->month, Carbon::parse($ticket->end_date)->year);
return response()->json(['message' => 'Delete success', 'status' => true]);
}
$note->delete();
$this->createOrUpdateRecordForCurrentMonth($month, $year);
return response()->json(['message' => 'Delete success', 'status' => true]);
}
return response()->json(['message' => 'Delete fail', 'status' => false]);
}
public function export(Request $request)
{
// Validate request

View File

@ -8,12 +8,4 @@ use Illuminate\Database\Eloquent\Model;
class Category extends Model
{
use HasFactory;
protected $fillable = [
'c_type',
'c_name',
'c_code',
'c_value',
'c_active',
];
}

View File

@ -1,52 +0,0 @@
<?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');
}
}

View File

@ -1,37 +0,0 @@
<?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');
}
}

View File

@ -1,32 +0,0 @@
<?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');
}
}

View File

@ -1,98 +0,0 @@
<!DOCTYPE html>
<html lang="vi">
<head>
<meta charset="UTF-8">
<title>Bảng 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 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>- số thuế: 0110038408</p>
<p>- Địa chỉ: Số 219/26/3 đường Lĩnh Nam, Phường Vĩnh Hưng, thành phố 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 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 <br>
(, ghi họ tên)
</td>
<td>
Đại diện doanh nghiệp<br>
(, ghi họ tên)
</td>
</tr>
</table>
</body>
</html>

View File

@ -23,8 +23,6 @@ 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;
/*
@ -46,7 +44,6 @@ 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
@ -151,8 +148,6 @@ 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([
@ -161,7 +156,6 @@ 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([
@ -169,44 +163,11 @@ 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 () {

View File

@ -3,23 +3,18 @@
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');
@ -39,41 +34,9 @@ class UserController extends Controller
]);
if ($request->has('id')) {
$payload = $request->only(['name', 'email', 'permission', 'is_permanent', 'is_separated']);
$payload = $request->only(['name', 'email', 'permission']);
$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 {
@ -81,23 +44,9 @@ class UserController extends Controller
'name' => $request->name,
'email' => $request->email,
'password' => bcrypt('Work@1234'),
'permission' => $request->permission,
'is_permanent' => false,
'is_separated' => false
'permission' => $request->permission
]);
// 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,
@ -149,6 +98,8 @@ class UserController extends Controller
return response()->json(['data' => ['user' => $user_res, 'gitea' => "dev", 'zulip' => "dev"], 'status' => true, 'message' => 'Create successful']);
}
}
return response()->json(['status' => false, 'message' => 'Process fail']);
}
public function delete(Request $request)

View File

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

View File

@ -17,6 +17,8 @@ class InitializeLeaveDaysCommand extends Command
public function handle()
{
InitializeLeaveDays::dispatch();
$year = $this->argument('year');
// Không sử dụng nữa, theo rule mới
// InitializeLeaveDays::dispatch($year);
}
}

View File

@ -1,24 +0,0 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use App\Jobs\UpdateTemporaryLeaveDays;
class UpdateTemporaryLeaveDaysCommand extends Command
{
protected $signature = 'update:temporary-leavedays {month?} {year?}';
protected $description = 'Tính lại ngày phép cho các note tạm.';
public function __construct()
{
parent::__construct();
}
public function handle()
{
$month = $this->argument('month');
$year = $this->argument('year');
UpdateTemporaryLeaveDays::dispatch($month, $year);
}
}

View File

@ -24,11 +24,9 @@ class Kernel extends ConsoleKernel
// $schedule->command('daily:api-call')
// ->dailyAt('18:00');
// 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 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 buổi sáng lúc 12:00
$schedule->command('attendance:check S')->dailyAt('12:00');
@ -36,9 +34,8 @@ class Kernel extends ConsoleKernel
// Chạy buổi chiều lúc 17:30
$schedule->command('attendance:check C')->dailyAt('17:30');
// Chạy vào ngày đầu tiên của mỗi tháng
// Chạy vào 00:01 ngày đầu tiên của mỗi tháng
$schedule->command('add:monthly-leavedays')->monthlyOn(1, '00:01');
$schedule->command('update:temporary-leavedays')->monthlyOn(1, '00:05');
}
/**

View File

@ -3,7 +3,6 @@
namespace App\Jobs;
use App\Models\LeaveDays;
use App\Models\Notes;
use App\Models\User;
use Carbon\Carbon;
use Illuminate\Bus\Queueable;
@ -11,8 +10,6 @@ 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
{
@ -21,8 +18,6 @@ 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;
@ -34,86 +29,38 @@ 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 && $this->month > 1) {
if (!$leaveDay) {
// Nếu chưa có dữ liệu năm hiện tại, tạo mớ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";
}
}
// Số ngày phép bằng với tháng hiện tại
$leaveDay = new LeaveDays([
'ld_user_id' => $user->id,
'ld_day_total' => $this->month, // Số ngày phép bằng tháng hiện tại
'ld_year' => $this->year,
'ld_additional_day' => $ld_additional_day,
'ld_note' => $ld_note,
'ld_additional_day' => 0,
'ld_note' => 'Khởi tạo ngày phép đến tháng ' . $this->month,
'ld_special_leave_day' => 0,
]);
$leaveDay->save();
} else {
// Check có phải là nhân viên chính thức trong năm nay (Nhân viên mới)
if ($user->permanent_date && $user->permanent_date !== '0000-00-00') {
$permenantYear = Carbon::parse($user->permanent_date)->year;
if ($permenantYear === $this->year) {
$permanentCategory = Category::where('c_type', 'PERMANENT_ONLEAVE')->where('c_code', "PERMANENT")->first();
$permanentDefault = (int) $permanentCategory->c_value; // Ngày phép khi thành nv chính thức
$permanentMonth = Carbon::parse($user->permanent_date)->month;
if ($this->month > $leaveDay->ld_day_total - ($permanentDefault - $permanentMonth)) {
$leaveDay->ld_day_total += self::ONLEAVE_PER_MONTH;
$leaveDay->save();
}
}
}
// Kiểm tra nếu số ngày phép hiện tại nhỏ hơn tháng hiện tại (Nhân viên cũ)
// 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ộng mỗi tháng 1 ngày phép cho nhân viên
$leaveDay->ld_day_total += self::ONLEAVE_PER_MONTH;
// Cập nhật số ngày phép bằng với tháng hiện tại
$oldDays = $leaveDay->ld_day_total;
$leaveDay->ld_day_total = $this->month;
// Xử lý ghi chú
$newNote = "Cập nhật ngày phép đến tháng " . $this->month;
if (!empty($leaveDay->ld_note)) {
// Nếu đã có ghi chú, thêm ghi chú mới vào và xuống dòng
$leaveDay->ld_note = $leaveDay->ld_note . "\n" . $newNote;
} else {
// Nếu chưa có ghi chú, gán ghi chú mới
$leaveDay->ld_note = $newNote;
}
$leaveDay->save();
}
}

View File

@ -39,13 +39,10 @@ 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) {
// Check nhân viên nghỉ việc
if ($user->is_separated) {
continue;
}
// if ($user->id != 4) {
// continue;
// }
// Kiểm tra dựa trên period (Sáng 'S' hoặc Chiều 'C')
if ($this->period === 'S') {
$this->checkMorning($user);
@ -131,19 +128,14 @@ 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])->exists();
$dateNow = Tracking::whereBetween('time_string', [$startTime, $endTime])
->get();
$hasCheckIndateNow = $dateNow->where('status', 'check in')->isNotEmpty();
$hasCheckOutdateNow = $dateNow->where('status', 'check out')->isNotEmpty();
// 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([
// 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([
'user_id' => $userId,
'start_date' => $today->format('Y-m-d'),
'start_period' => $periodCode,
@ -167,10 +159,8 @@ 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,

View File

@ -10,6 +10,7 @@ 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
@ -41,30 +42,38 @@ class DeductLeaveDays implements ShouldQueue
continue;
}
// Lấy tổng ngày nghỉ phép 3 tháng đầu trong năm
$usedOnleaveDaysTotal = Notes::join('categories', function ($join) {
$totalLeaveDaysByMonth = 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', "<=", 3)
->where('n_reason', 'ONLEAVE')
->sum('categories.c_value');
->select(
DB::raw('notes.n_user_id as n_user_id'),
DB::raw('notes.n_year as year'),
DB::raw('SUM(categories.c_value) as leave_days')
)
->where('notes.n_year', $this->year)
->where('notes.n_user_id', $user->id)
->where('notes.n_reason', 'ONLEAVE')
->groupBy(DB::raw('notes.n_year'))
->first();
if ($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;
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,
]);
}
} else {
$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();
//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",
]);
}
}
}
}

View File

@ -34,11 +34,15 @@ class InitializeLeaveDays implements ShouldQueue
public function handle(): void
{
$users = User::get();
$ld_day_total = Carbon::now()->month; // Khởi tạo phép hiện có bằng tháng hiện tại
$ld_day_total = 12;
foreach ($users as $user) {
// Check nhân viên nghỉ việc
if ($user->is_separated) {
// 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, bỏ qua user này
continue;
}
@ -51,7 +55,7 @@ class InitializeLeaveDays implements ShouldQueue
$ld_note = '';
if ($previousYearData) {
$ld_additional_day = $previousYearData->ld_day_total + $previousYearData->ld_additional_day + $previousYearData->ld_special_leave_day;
$ld_additional_day = $previousYearData->ld_day_total + $previousYearData->ld_additional_day;
$totalLeaveDaysByMonth = Notes::join('categories', function ($join) {
$join->on('notes.n_time_type', '=', 'categories.c_code')
->where('categories.c_type', 'TIME_TYPE');
@ -72,29 +76,13 @@ class InitializeLeaveDays implements ShouldQueue
$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";
}
}
// 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;
$ld_note = 'Cộng dồn ngày phép năm cũ';
}
// Tạo dữ liệu cho năm hiện tại
LeaveDays::insert([
'ld_user_id' => $user->id,
'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_day_total' => $ld_day_total,
'ld_year' => $this->year,
'ld_additional_day' => $ld_additional_day,
'ld_note' => $ld_note,

View File

@ -1,220 +0,0 @@
<?php
namespace App\Jobs;
use App\Models\LeaveDays;
use App\Models\Notes;
use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Carbon\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Modules\Admin\app\Models\Category;
class UpdateTemporaryLeaveDays implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $month;
protected $year;
public function __construct($month = null, $year = null)
{
$this->month = $month ?? Carbon::now()->month;
$this->year = $year ?? Carbon::now()->year;
}
/**
* Execute the job.
*/
public function handle()
{
$users = User::get();
foreach ($users as $user) {
$leaveDay = LeaveDays::where('ld_user_id', $user->id)
->where('ld_year', $this->year)
->first();
$notes = Notes::where('n_reason', 'TEMPORARY_ONLEAVE')
->where('n_user_id', $user->id)
->where('n_year', $this->year)
->where('n_month', $this->month)
->whereExists(function ($query) use ($user) {
$query->select(DB::raw(1))
->from('tickets')
->where('tickets.user_id', $user->id)
->where('tickets.status', 'CONFIRMED')
->where('tickets.type', 'ONLEAVE');
})
->get();
$maxDaysPerMonth = $this->getMaxLeaveDaysPerMonth();
// Tổng ngày nghỉ sẽ dùng trong tháng
$willUsedDaysInMonth = 0;
foreach ($notes as $note) {
$willUsedDaysInMonth += $note->n_time_type == 'ALL' ? 1.0 : 0.5;
}
// Tổng phép đang có
$onleaveDaysTotal = $leaveDay->ld_day_total + $leaveDay->ld_additional_day + $leaveDay->ld_special_leave_day;
// Phép đã sử dụng tới tháng hiện tại
$usedOnleaveDaysTotal = Notes::join('categories', function ($join) {
$join->on('notes.n_time_type', '=', 'categories.c_code')
->where('categories.c_type', 'TIME_TYPE');
})
->where('n_user_id', $user->id)
->where('n_year', $this->year)
->where('n_month', "<=", $this->month)
->where('n_reason', 'ONLEAVE')
->sum('categories.c_value');
// Phép còn lại
$remainingOnleaveDays = $onleaveDaysTotal - $usedOnleaveDaysTotal;
// Log::debug("User {$user->name}\n");
// Log::debug(
// "📊 Thống kê ngày phép:\n" .
// " - Tháng: {$this->month}\n" .
// " - Tổng ngày nghỉ sẽ dùng trong tháng: $willUsedDaysInMonth\n" .
// " - Tổng ngày phép: $onleaveDaysTotal\n" .
// " - Tổng ngày phép đã nghỉ: $usedOnleaveDaysTotal\n" .
// " - Tổng ngày phép còn lại: $remainingOnleaveDays\n"
// );
$onleave_days_will_use = 0; // Ngày phép sẽ dùng
$nopay_days_will_use = 0; // Ngày ko phép sẽ dùng
// Ngày phép còn lại <= 0 (Hết phép)
if ($remainingOnleaveDays <= 0) {
$onleave_days_will_use = 0;
$nopay_days_will_use = $willUsedDaysInMonth;
Log::debug("--- Hết phép trong tháng ---");
}
// Ngày phép còn lại < ngày yêu cầu (Không đủ phép)
else if ($remainingOnleaveDays < $willUsedDaysInMonth) {
// Vượt limit
if ($willUsedDaysInMonth > $maxDaysPerMonth) {
// Phép còn lại > limit
if ($remainingOnleaveDays > $maxDaysPerMonth) {
$onleave_days_will_use = $maxDaysPerMonth;
$nopay_days_will_use = $willUsedDaysInMonth - $maxDaysPerMonth;
}
// Phép còn lại < limit
else {
$onleave_days_will_use = $remainingOnleaveDays;
$nopay_days_will_use = $willUsedDaysInMonth - $remainingOnleaveDays;
}
Log::debug("--- Không đủ phép trong tháng, vượt quá limit ---",);
}
// Không vượt limit
else {
$onleave_days_will_use = $remainingOnleaveDays;
$nopay_days_will_use = $willUsedDaysInMonth - $remainingOnleaveDays;
Log::debug("--- Không đủ phép trong tháng, ko vượt limit ---");
}
}
// Ngày phép còn lại >= ngày yêu cầu (Đủ phép)
else {
// Vượt limit
if ($willUsedDaysInMonth > $maxDaysPerMonth) {
$onleave_days_will_use = $maxDaysPerMonth;
$nopay_days_will_use = $willUsedDaysInMonth - $maxDaysPerMonth;
Log::debug("--- Đủ phép, vượt limit ---");
}
// Không vượt limit
else {
$onleave_days_will_use = $willUsedDaysInMonth;
$nopay_days_will_use = 0;
Log::debug("--- Đủ phép ---");
}
}
Log::debug("", [
"Phep" => $onleave_days_will_use,
"Khong Phep" => $nopay_days_will_use
]);
// Có nghỉ không phép
if ($nopay_days_will_use > 0) {
foreach ($notes as $note) {
$value = ($note->n_time_type === 'ALL') ? 1.0 : 0.5;
if ($note->n_time_type === 'ALL' && $onleave_days_will_use == 0.5) {
// Chỉ còn 0.5 phép, chia thành 2 bản ghi: 1 phép, 1 không phép
// Ưu tiên phép cho buổi sáng (S), không phép cho buổi chiều (C)
Notes::create([
'n_user_id' => $user->id,
'n_day' => $note->n_day,
'n_month' => $note->n_month,
'n_year' => $note->n_year,
'n_time_type' => 'S',
'n_reason' => 'ONLEAVE',
'n_note' => $note->n_note,
'ticket_id' => $note->ticket_id
]);
Notes::create([
'n_user_id' => $user->id,
'n_day' => $note->n_day,
'n_month' => $note->n_month,
'n_year' => $note->n_year,
'n_time_type' => 'C',
'n_reason' => 'LEAVE_WITHOUT_PAY',
'n_note' => $note->n_note,
'ticket_id' => $note->ticket_id
]);
$note->delete();
$onleave_days_will_use = 0;
$nopay_days_will_use -= 0.5;
} elseif ($onleave_days_will_use > 0) {
// Dùng ngày phép trước
$use = min($onleave_days_will_use, $value);
$note->update([
'n_reason' => "ONLEAVE"
]);
$onleave_days_will_use -= $use;
} elseif ($nopay_days_will_use > 0) {
// Hết phép, chuyển sang không phép
$use = min($nopay_days_will_use, $value);
$note->update([
'n_reason' => "LEAVE_WITHOUT_PAY"
]);
$nopay_days_will_use -= $use;
}
}
}
// Đủ phép
else {
foreach ($notes as $note) {
$note->update([
'n_reason' => "ONLEAVE"
]);
}
}
}
}
private function getMaxLeaveDaysPerMonth(): int
{
$limitLeaveMonth = Category::where('c_type', 'LIMIT_LEAVE_MONTH')->where('c_code', "LIMIT")->first();
if ($limitLeaveMonth) {
$maxDaysPerMonth = (int)$limitLeaveMonth->c_value;
} else {
$maxDaysPerMonth = 3; // default nếu k có setting
}
return $maxDaysPerMonth;
}
}

View File

@ -18,7 +18,6 @@ class Notes extends Model
'n_time_type',
'n_reason',
'n_note',
'ticket_id'
];
/**

View File

@ -2,16 +2,11 @@
"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",

3079
BACKEND/composer.lock generated

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -9,14 +9,14 @@ class RenameLdDayToLdDayTotalInLeaveDaysTable extends Migration
public function up()
{
Schema::table('leave_days', function (Blueprint $table) {
$table->renameColumn('ld_day', 'ld_day_total');
$table->renameColumn('ld_day_total', 'ld_day_total');
});
}
public function down()
{
Schema::table('leave_days', function (Blueprint $table) {
$table->renameColumn('ld_day_total', 'ld_day');
$table->renameColumn('ld_day_total', 'ld_day_total');
});
}
}

View File

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

View File

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

View File

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

View File

@ -1,29 +0,0 @@
<?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');
});
}
};

View File

@ -1,39 +0,0 @@
<?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');
}
};

View File

@ -1,23 +0,0 @@
<?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');
});
}
};

View File

@ -1,50 +0,0 @@
<?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');
}
};

View File

@ -1,23 +0,0 @@
<?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');
});
}
};

View File

@ -1,3 +1,4 @@
<!DOCTYPE html>
<html lang="en">
@ -36,6 +37,8 @@
<title>{{ $data['subject'] }}</title>
</head>
<body>
<body style="
font-family: Arial, Helvetica, sans-serif;
background-color: #edf2f7;
@ -80,7 +83,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 <a href="{{ config('app.client_url') . $data['link'] }}"> request ticket</a>, the specific content is as follows:
Employee <span style="color: #222222;font-weight: bold;">{{ $data['name'] }}</span> has sent a request ticket, the specific content is as follows:
</p>
</td>
</tr>
@ -95,80 +98,29 @@
</div>
</td>
</tr>
<tr>
<td>
<p style="text-align: center">
You can quick
<span style="font-weight: bold">Confirm</span> or
<span style="font-weight: bold">Refuse</span> here:
<p style="margin:0 0 16px;padding:5px;margin: 5px;text-align: center;">
<a href='{{ config('app.url') . $data['link'] }}'
style="
color: #fff;
border-radius: 10px;
background-color: rgba(68,115,196);
background-image: linear-gradient(to top left,rgba(0,0,0,.2),rgba(0,0,0,.2) 30%,rgba(0,0,0,0));
text-decoration: none;
display: inline-block;
font-weight: 600;
font-size: 16px;
line-height: 150%;
text-align: center;
margin: 0;
padding: 10px 12px;
">
Check now</a>
</p>
<div
style="
display: flex;
justify-content: center;
gap: 10px;
margin-top: 10px;
">
<a
href="{{ route('email.ticket.handle', ['ticket_id' => $data['ticket_id'], 'action' => 'confirm', 'admin_email' => $data['admin_email']]) }}"
style="
color: #fff;
border-radius: 10px;
background-color: #12b886;
background-image: linear-gradient(
to top left,
rgba(0, 0, 0, 0.2),
rgba(0, 0, 0, 0.2) 30%,
rgba(0, 0, 0, 0)
);
text-decoration: none;
display: inline-block;
font-weight: 600;
font-size: 16px;
line-height: 150%;
text-align: center;
margin: 0;
padding: 10px 12px;
">
Confirm</a>
<a
href="{{ route('email.ticket.handle', ['ticket_id' => $data['ticket_id'], 'action' => 'refuse', 'admin_email' => $data['admin_email']]) }}"
style="
color: #fff;
border-radius: 10px;
background-color: #f03e3e;
background-image: linear-gradient(
to top left,
rgba(0, 0, 0, 0.2),
rgba(0, 0, 0, 0.2) 30%,
rgba(0, 0, 0, 0)
);
text-decoration: none;
display: inline-block;
font-weight: 600;
font-size: 16px;
line-height: 150%;
text-align: center;
margin: 0;
padding: 10px 12px;
">
Refuse</a>
</div>
</td>
</tr>
<tr>
<td style="color: #222222;">
<div style="margin-top: 3rem">
<p><span style="font-weight: bold">Note</span>: If you are redirected to a <span style="font-weight: bold">404 page</span>, it means:</p>
<p>1. The ticket has already been approved by another admin.</p>
<p>2. The ticket has been deleted.</p>
</div>
</td>
</tr>
</table>
</td>
</tr>
@ -198,5 +150,6 @@
</tr>
</table>
</body>
</body>
</html>

View File

@ -1,13 +0,0 @@
<?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

View File

@ -23,8 +23,6 @@
"@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.

Before

Width:  |  Height:  |  Size: 124 KiB

View File

@ -35,15 +35,11 @@ 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'
@ -52,7 +48,6 @@ 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
@ -125,44 +120,6 @@ 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'

View File

@ -250,14 +250,7 @@ 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) {
@ -463,7 +456,7 @@ export const DataTablePagination = ({
})
const [selectedRows, setSelectedRows] = useState<any[]>([])
const navigate = useNavigate()
let urlParams = new URLSearchParams(location.search)
const urlParams = new URLSearchParams(location.search)
// Render headers
const headers = columns.map((col) => (
@ -603,7 +596,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())
}
@ -667,8 +660,9 @@ export const DataTablePagination = ({
date_used_to: date_used,
})
}
// Add all attributes in 'params' to URL params
urlParams = new URLSearchParams(Object.entries(params))
Object.entries(params).forEach((param) => urlParams.set(...param))
Object.entries(dataFilter).forEach(([key, value]) => {
const typeFilter = filterInfo.find((o) => o.key === key).type
const hasType = {
@ -685,16 +679,17 @@ 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.data?.links ? res?.data : res)
setTData(res.data?.data ? res.data?.data : res.data)
setBaseData(res)
setTData(res.data)
setSkeletion(false)
navigate({
pathname: location.pathname,
@ -770,7 +765,7 @@ export const DataTablePagination = ({
if (order_by_) {
const sortParam = {
name: order_by_.split('=')[0].split('_').slice(2).join('_'),
name: order_by_.split('=')[0].split('_')[2],
status: order_by_.split('=')[1],
}
if (JSON.stringify(sortParam) !== JSON.stringify(statusSort)) {

View File

@ -39,7 +39,6 @@ import {
IconReport,
IconScan,
IconSettings,
IconShredder,
IconSun,
IconTicket,
IconUsersGroup,
@ -147,8 +146,8 @@ const data = [
link: '/organization-settings',
label: 'Organization Settings',
icon: IconSettings,
group: 'other',
permissions: 'admin,hr',
group: 'admin',
permissions: 'admin',
},
{
link: '/profiles',
@ -157,13 +156,6 @@ 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 },

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -1,42 +1,5 @@
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 const PageNotFound = () => {
return <>{'Not found!'}</>
}
export default PageNotFound

File diff suppressed because it is too large Load Diff

View File

@ -1,48 +0,0 @@
.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);
}

View File

@ -1,876 +0,0 @@
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

View File

@ -1,47 +1,28 @@
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, put } from '@/rtk/helpers/apiService'
import { get, post } from '@/rtk/helpers/apiService'
import { notifications } from '@mantine/notifications'
import {
createTechnical,
deleteTechnical,
getWorkDay,
listTechnical,
updateWorkDay,
} from '@/api/Admin'
import { createTechnical, deleteTechnical, listTechnical } 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>('work-day')
const [activeTab, setActiveTab] = useState<string | null>('technical')
return (
<div>
@ -55,22 +36,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>
@ -349,126 +330,4 @@ 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

View File

@ -79,7 +79,6 @@ 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[]>([])
@ -110,8 +109,6 @@ const Tickets = () => {
useEffect(() => {
const fetchData = async () => {
setIsFetch(true)
const resultTimeType = await getListMasterByType('TIME_TYPE')
setDataTimeType(
resultTimeType.filter((item: DataTimeType) => item.c_code !== 'ALL'),
@ -119,8 +116,6 @@ const Tickets = () => {
const resultReason = await getListMasterByType('REASON')
setDataReason(resultReason)
setIsFetch(false)
}
fetchData()
@ -374,7 +369,6 @@ const Tickets = () => {
setAction('add')
form.reset()
}}
disabled={isFetchData}
>
+ Add
</Button>
@ -560,8 +554,7 @@ 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) {

View File

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

View File

@ -1,11 +1,6 @@
import {
getListMaster,
getTickets,
handleTicket,
updateTicket,
} from '@/api/Admin'
import { getListMaster, getTickets, handleTicket } from '@/api/Admin'
import { DataTablePagination } from '@/components/DataTable/DataTable'
import { create, update } from '@/rtk/helpers/CRUD'
import { create } from '@/rtk/helpers/CRUD'
import { get } from '@/rtk/helpers/apiService'
import {
Badge,
@ -14,13 +9,12 @@ import {
HoverCard,
Modal,
Select,
Switch,
Text,
Textarea,
} from '@mantine/core'
import { useForm } from '@mantine/form'
import { notifications } from '@mantine/notifications'
import { IconCheckbox, IconEdit, IconSquareXFilled } from '@tabler/icons-react'
import { IconCheckbox, IconSquareXFilled } from '@tabler/icons-react'
import moment from 'moment'
import { useEffect, useState } from 'react'
import classes from './TicketsManagement.module.css'
@ -30,7 +24,6 @@ type TTickets = {
ticket_id: number
admin_note: string
action: string
status: string
}
type TListTickets = {
@ -85,7 +78,6 @@ const TicketsManagement = () => {
end_period: '',
reason: '',
type: '',
status: '',
})
const [disableBtn, setDisableBtn] = useState(false)
const [filter, setFilter] = useState({
@ -94,7 +86,6 @@ const TicketsManagement = () => {
})
const [dataTimeType, setDataTimeType] = useState<DataTimeType[]>([])
const [dataReason, setDataReason] = useState<DataReason[]>([])
const [isRefuseConfirmOpen, setIsRefuseConfirmOpen] = useState<boolean>(false)
const getListMasterByType = async (type: string) => {
try {
@ -165,7 +156,7 @@ const TicketsManagement = () => {
},
{
name: 'reason',
size: '10%',
size: '15%',
header: 'Notes',
render: (row: any) => {
return (
@ -207,7 +198,7 @@ const TicketsManagement = () => {
},
{
name: 'admin_note',
size: '10%',
size: '15%',
header: 'Admin Notes',
render: (row: any) => {
return (
@ -235,18 +226,6 @@ 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%',
@ -275,23 +254,7 @@ const TicketsManagement = () => {
height={20}
/>
</Box>
) : (
<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>
)
) : null
},
},
]
@ -384,27 +347,6 @@ 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])
@ -414,7 +356,6 @@ const TicketsManagement = () => {
ticket_id: 0,
action: '',
admin_note: '',
status: '',
},
})
@ -475,38 +416,21 @@ const TicketsManagement = () => {
</Box>
<Modal
opened={
action === 'confirm' || action === 'refuse' || action === 'update'
}
opened={action === 'confirm' || action === 'refuse'}
onClose={() => {
setAction('')
setIsRefuseConfirmOpen(false)
form.reset()
}}
title={
<Text pl={'sm'} fw={700} fz={'lg'}>
{action === 'confirm'
? 'Confirm Ticket'
: action === 'refuse'
? 'Refuse Ticket'
: 'Update Ticket'}
{action === 'confirm' ? 'Confirm Ticket' : 'Refuse 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)
})}
>
@ -585,39 +509,14 @@ 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={action === 'update' ? 'blue' : 'green'}
bg={'green'}
type="submit"
disabled={disableBtn}
>
@ -627,50 +526,6 @@ const TicketsManagement = () => {
</Box>
</form>
</Modal>
<Modal
opened={isRefuseConfirmOpen}
onClose={() => {
setIsRefuseConfirmOpen(false)
}}
centered
size="sm"
classNames={{
content: classes.deleteModal,
}}
>
<Text className={classes.deleteModalTitle}>Confirm Update</Text>
<Text className={classes.deleteModalContent}>
Changing ticket status to <strong>Refused</strong> will also delete
all related notes.
</Text>
<Text className={classes.deleteModalContent}>
Are you sure you want to proceed?
</Text>
<Box className={classes.deleteModalFooter}>
<Button
variant="outline"
onClick={() => {
setIsRefuseConfirmOpen(false)
}}
disabled={disableBtn}
>
Cancel
</Button>
<Button
className={classes.deleteButton}
onClick={async () => {
setDisableBtn(true)
await handleUpdate(form.values)
setDisableBtn(false)
}}
disabled={disableBtn}
>
Confirm
</Button>
</Box>
</Modal>
</div>
)
}

View File

@ -24,7 +24,6 @@ import {
TextInput,
Tooltip,
Modal,
Badge,
} from '@mantine/core'
import { useDisclosure } from '@mantine/hooks'
import { notifications } from '@mantine/notifications'
@ -49,7 +48,6 @@ interface User {
permission: string
remember_token: string | null
avatar: string
is_separated: boolean
created_at: string | null
updated_at: string | null
}
@ -150,7 +148,6 @@ 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) => {
@ -470,9 +467,7 @@ const Timekeeping = () => {
const handleConfirmDelete = async () => {
if (noteToDelete) {
setIsDisableDeleteBtn(true)
await handleDelete(noteToDelete.id)
setIsDisableDeleteBtn(false)
setIsDeleteConfirmOpen(false)
setNoteToDelete(null)
}
@ -498,11 +493,7 @@ const Timekeeping = () => {
>
<Text className={classes.deleteModalTitle}>Confirm Delete</Text>
<Text className={classes.deleteModalContent}>
This action will change the ticket status to <strong>Refused</strong>{' '}
and delete all related notes.
</Text>
<Text className={classes.deleteModalContent}>
Are you sure you want to proceed?
Are you sure you want to delete this note?
</Text>
<Box className={classes.deleteModalFooter}>
<Button
@ -511,15 +502,10 @@ const Timekeeping = () => {
setIsDeleteConfirmOpen(false)
setNoteToDelete(null)
}}
disabled={isDisableDeleteBtn}
>
Cancel
</Button>
<Button
className={classes.deleteButton}
onClick={handleConfirmDelete}
disabled={isDisableDeleteBtn}
>
<Button className={classes.deleteButton} onClick={handleConfirmDelete}>
Delete
</Button>
</Box>
@ -949,8 +935,14 @@ const Timekeeping = () => {
2
return (
<Table.Tr key={user.user.id} className={classes.tableTr}>
<Table.Td pos="relative">
<Tooltip multiline label={showTooltipAllNote(user)}>
<Table.Td>
<Tooltip
// position={'auto'}
multiline
// opened
// offset={{ mainAxis: 5, crossAxis: 0 }}
label={showTooltipAllNote(user)}
>
<div style={{ display: 'flex', alignItems: 'center' }}>
<Avatar
size={'md'}
@ -968,27 +960,6 @@ 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>

View File

@ -45,70 +45,3 @@
.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;
}

View File

@ -13,7 +13,6 @@ import {
Group,
Modal,
MultiSelect,
Select,
Text,
TextInput,
} from '@mantine/core'
@ -21,50 +20,32 @@ 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,
is_permanent: false,
is_separated: false,
})
const [item, setItem] = useState({ id: 0 })
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: '5%',
size: '3%',
header: 'ID',
},
{
name: 'name',
size: '20%',
size: '17%',
header: 'Name',
},
{
name: 'email',
size: '25%',
size: '26%',
header: 'Email',
},
{
name: 'permission',
size: '20%',
size: '10%',
header: 'Permission',
render: (row: TUser) => {
if (row.permission.includes(',')) {
@ -76,25 +57,9 @@ 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: '10%',
size: '5%',
header: 'Action',
render: (row: TUser) => {
return (
@ -103,12 +68,7 @@ const UsersManagement = () => {
className={classes.editIcon}
onClick={() => {
setAction('edit')
setItem(row)
form.reset()
form.setValues({
...row,
status: row.is_permanent ? 'permanent' : '',
})
form.setValues(row)
}}
width={20}
height={20}
@ -134,7 +94,6 @@ const UsersManagement = () => {
name: '',
email: '',
permission: '',
status: '',
},
})
@ -149,7 +108,7 @@ const UsersManagement = () => {
}
}
const handleCreate = async (values: TUserForm) => {
const handleCreate = async (values: TUser) => {
try {
const { id, ...data } = values
const res = await post(createOrUpdateUser, data)
@ -164,25 +123,11 @@ const UsersManagement = () => {
}
}
const handleUpdate = async (values: TUserForm) => {
const handleUpdate = async (values: TUser) => {
try {
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,
)
const res = await update(createOrUpdateUser, values, getAll)
if (res === true) {
setAction('')
setIsPermanentConfirmOpen(false)
setIsSeparatedConfirmOpen(false)
form.reset()
}
} catch (error) {
@ -227,8 +172,6 @@ const UsersManagement = () => {
opened={action === 'add' || action === 'edit'}
onClose={() => {
setAction('')
setIsPermanentConfirmOpen(false)
setIsSeparatedConfirmOpen(false)
form.reset()
}}
title={
@ -240,17 +183,9 @@ const UsersManagement = () => {
<form
onSubmit={form.onSubmit(async (values) => {
setDisableBtn(true)
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)
}
action === 'edit'
? await handleUpdate(values)
: await handleCreate(values)
setDisableBtn(false)
})}
>
@ -261,7 +196,6 @@ const UsersManagement = () => {
value={form.values.name}
error={form.errors.name}
onChange={(e) => form.setFieldValue('name', e.target.value)}
required
/>
<TextInput
@ -270,7 +204,6 @@ const UsersManagement = () => {
value={form.values.email}
error={form.errors.email}
onChange={(e) => form.setFieldValue('email', e.target.value)}
required
/>
<MultiSelect
@ -291,29 +224,7 @@ 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
@ -362,93 +273,6 @@ 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'}

View File

@ -8,7 +8,6 @@ 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'
@ -240,7 +239,7 @@ const mainRoutes = [
{
path: '/organization-settings',
element: (
<ProtectedRoute mode="route" permission="admin,hr">
<ProtectedRoute mode="route" permission="admin">
<BasePage
main={
<>
@ -265,20 +264,6 @@ const mainRoutes = [
</ProtectedRoute>
),
},
{
path: '/office-support',
element: (
<ProtectedRoute mode="route" permission="admin,hr,accountant">
<BasePage
main={
<>
<OfficeSupport />
</>
}
></BasePage>
</ProtectedRoute>
),
},
// {
// path: '/packages',
// element: (

View File

@ -36,11 +36,7 @@ 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',
})
}
@ -120,7 +116,7 @@ export const Xdelete = async (url: string, data: any, fnc?: () => void) => {
try {
const res = await get(url, data)
if (res.status || res.success) {
if (res.status) {
notifications.show({
title: 'Success',
message: _NOTIFICATION_MESS.delete_success,
@ -128,7 +124,7 @@ export const Xdelete = async (url: string, data: any, fnc?: () => void) => {
})
fnc && fnc()
}
if (res.status === false && !res.success) {
if (res.status === false) {
notifications.show({
title: 'Error',
message: res.message ?? _NOTIFICATION_MESS.delete_error,

View File

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

Binary file not shown.

View File

@ -1,6 +0,0 @@
path
__pycache__
images
uploads
venv
log.log

View File

@ -1 +0,0 @@
{}

View File

@ -1,134 +0,0 @@
# 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ộ

View File

@ -1,11 +0,0 @@
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

View File

@ -1,56 +0,0 @@
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

View File

@ -1,45 +0,0 @@
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()

View File

@ -1,3 +0,0 @@
VITE_API_BASE_URL = "/camera"
# VITE_API_BASE_URL = "http://127.0.0.1:8000"
VITE_API_BASE_MS = "https://ms.prology.net"

View File

@ -1,25 +0,0 @@
# 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

View File

@ -1,75 +0,0 @@
# 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...
},
},
])
```

View File

@ -1,22 +0,0 @@
{
"$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": {}
}

View File

@ -1,26 +0,0 @@
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",
},
},
]);

View File

@ -1,13 +0,0 @@
<!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

View File

@ -1,55 +0,0 @@
{
"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"
}
}

View File

@ -1 +0,0 @@
[{"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"]}]

View File

@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -1,7 +0,0 @@
import ax from "axios";
const axios = ax.create({
baseURL: import.meta.env.VITE_API_BASE_URL || "/",
});
export default axios;

View File

@ -1,68 +0,0 @@
/* 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();

View File

@ -1,23 +0,0 @@
/* 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();

View File

@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 4.0 KiB

View File

@ -1,125 +0,0 @@
/* 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;
}

View File

@ -1,51 +0,0 @@
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 }

View File

@ -1,46 +0,0 @@
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 }

View File

@ -1,60 +0,0 @@
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 }

View File

@ -1,92 +0,0 @@
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,
}

View File

@ -1,141 +0,0 @@
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,
}

View File

@ -1,255 +0,0 @@
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,
}

View File

@ -1,165 +0,0 @@
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,
}

View File

@ -1,21 +0,0 @@
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 }

View File

@ -1,24 +0,0 @@
"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 }

View File

@ -1,56 +0,0 @@
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 }

View File

@ -1,38 +0,0 @@
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 }

View File

@ -1,64 +0,0 @@
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 };

View File

@ -1,120 +0,0 @@
@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;
}
}

View File

@ -1,15 +0,0 @@
/* 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[];
}

View File

@ -1,27 +0,0 @@
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);
}

View File

@ -1,388 +0,0 @@
/* 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 };
}

View File

@ -1,82 +0,0 @@
/* 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