Compare commits
1 Commits
master
...
zelda.crea
| Author | SHA1 | Date |
|---|---|---|
|
|
5702cda371 |
|
|
@ -3,7 +3,6 @@
|
||||||
namespace Modules\Admin\app\Http\Controllers;
|
namespace Modules\Admin\app\Http\Controllers;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use Carbon\Carbon;
|
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Modules\Admin\app\Models\Category;
|
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();
|
$data = Category::where('c_type', '=', $type)->where('c_active', '=', 1)->select('id', 'c_code', 'c_name', 'c_value', 'c_type')->get();
|
||||||
return $data;
|
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'
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -726,40 +726,6 @@ class TicketController extends Controller
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Khởi tạo dữ liệu ngày phép cho user nếu chưa có
|
|
||||||
*
|
|
||||||
* @param UserModel $user
|
|
||||||
* @param int $year
|
|
||||||
* @return LeaveDays|null
|
|
||||||
*/
|
|
||||||
private function initializeLeaveDaysForYear($user, int $year): ?LeaveDays
|
|
||||||
{
|
|
||||||
// Nếu là nhân viên chưa chính thức, ko cộng phép
|
|
||||||
if (!$user->is_permanent) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Nếu là nhân viên nghỉ việc, ko cộng phép
|
|
||||||
if ($user->is_separated) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Nếu chưa có dữ liệu năm hiện tại, tạo mới
|
|
||||||
// Số ngày phép bằng với tháng hiện tại
|
|
||||||
$leaveDaysInfo = new LeaveDays([
|
|
||||||
'ld_user_id' => $user->id,
|
|
||||||
'ld_day_total' => 1,
|
|
||||||
'ld_year' => $year,
|
|
||||||
'ld_additional_day' => 0,
|
|
||||||
'ld_note' => 'Khởi tạo ngày phép tháng 1 khi tạo ticket cho năm sau',
|
|
||||||
'ld_special_leave_day' => 0,
|
|
||||||
]);
|
|
||||||
$leaveDaysInfo->save();
|
|
||||||
|
|
||||||
return $leaveDaysInfo;
|
|
||||||
}
|
|
||||||
|
|
||||||
//Tính tổng số ngày nghỉ có phép đến tháng hiện tại
|
//Tính tổng số ngày nghỉ có phép đến tháng hiện tại
|
||||||
private function getTotalLeaveDaysInMonthToMonth($user, int $year, int $month): float
|
private function getTotalLeaveDaysInMonthToMonth($user, int $year, int $month): float
|
||||||
{
|
{
|
||||||
|
|
@ -779,13 +745,7 @@ class TicketController extends Controller
|
||||||
$leaveDaysInfo = LeaveDays::where('ld_user_id', $user->id)
|
$leaveDaysInfo = LeaveDays::where('ld_user_id', $user->id)
|
||||||
->where('ld_year', $year)
|
->where('ld_year', $year)
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
if (!$leaveDaysInfo) {
|
|
||||||
$leaveDaysInfo = $this->initializeLeaveDaysForYear($user, $year);
|
|
||||||
if (!$leaveDaysInfo) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
$totalAllocated = 0;
|
$totalAllocated = 0;
|
||||||
// Xử lý gửi ticket sau tháng hiện tại
|
// Xử lý gửi ticket sau tháng hiện tại
|
||||||
if ($leaveDaysInfo && $user->is_permanent) {
|
if ($leaveDaysInfo && $user->is_permanent) {
|
||||||
|
|
|
||||||
|
|
@ -8,12 +8,4 @@ use Illuminate\Database\Eloquent\Model;
|
||||||
class Category extends Model
|
class Category extends Model
|
||||||
{
|
{
|
||||||
use HasFactory;
|
use HasFactory;
|
||||||
|
|
||||||
protected $fillable = [
|
|
||||||
'c_type',
|
|
||||||
'c_name',
|
|
||||||
'c_code',
|
|
||||||
'c_value',
|
|
||||||
'c_active',
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,98 +0,0 @@
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="vi">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<title>Bảng kê thanh toán tiền điện</title>
|
|
||||||
<style>
|
|
||||||
body {
|
|
||||||
font-family: DejaVu Sans, sans-serif;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
.text-center {
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
.mt-10 {
|
|
||||||
margin-top: 10px;
|
|
||||||
}
|
|
||||||
.mt-20 {
|
|
||||||
margin-top: 20px;
|
|
||||||
}
|
|
||||||
table {
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
|
||||||
margin-top: 15px;
|
|
||||||
}
|
|
||||||
table, th, td {
|
|
||||||
border: 1px solid #000;
|
|
||||||
}
|
|
||||||
th, td {
|
|
||||||
padding: 8px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
.no-border {
|
|
||||||
border: none;
|
|
||||||
}
|
|
||||||
.signature {
|
|
||||||
width: 100%;
|
|
||||||
margin-top: 50px;
|
|
||||||
}
|
|
||||||
.signature td {
|
|
||||||
border: none;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
|
|
||||||
<h3 class="text-center">BẢNG KÊ THANH TOÁN TIỀN ĐIỆN</h3>
|
|
||||||
<p class="text-center">({{ $dateNow ?? '' }})</p>
|
|
||||||
|
|
||||||
<div class="mt-20">
|
|
||||||
<p>- Tên doanh nghiệp: Công ty TNHH Kỹ Thuật Công Nghệ APAC</p>
|
|
||||||
<p>- Mã số thuế: 0110038408</p>
|
|
||||||
<p>- Địa chỉ: Số 219/26/3 đường Lĩnh Nam, Phường Vĩnh Hưng, thành phố Hà Nội, Việt Nam</p>
|
|
||||||
<p>- Tên chủ sở hữu cho thuê địa điểm sản xuất kinh doanh: Lâm Văn Mười</p>
|
|
||||||
<p>- Địa chỉ thuê: 50B31 tại Khu dân cư 91B giai đoạn 3, phường Tân An, thành phố Cần Thơ</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Số điện đầu kỳ</th>
|
|
||||||
<th>Số điện cuối kỳ</th>
|
|
||||||
<th>Số điện tiêu thụ</th>
|
|
||||||
<th>Đơn giá</th>
|
|
||||||
<th>Thành tiền</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<td>{{ number_format($bill->previous_reading) ?? 0 }}</td>
|
|
||||||
<td>{{ number_format($bill->current_reading) ?? 0 }}</td>
|
|
||||||
<td>{{ $consumption ?? 0 }}</td>
|
|
||||||
<td>{{ number_format($bill->unit_price) ?? '0' }}</td>
|
|
||||||
<td>{{ number_format($bill->total_amount) ?? '0' }}</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<p class="mt-20">
|
|
||||||
- Tổng tiền thanh toán: <strong>{{ number_format($bill->total_amount) ?? '0' }} VND</strong>
|
|
||||||
({{ $totalText ?? '' }})
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<table class="signature no-border">
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
Người lập bảng kê<br>
|
|
||||||
(Ký, ghi rõ họ tên)
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
Đại diện doanh nghiệp<br>
|
|
||||||
(Ký, ghi rõ họ tên)
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|
@ -23,8 +23,6 @@ use Modules\Admin\app\Http\Controllers\ProjectReviewController;
|
||||||
use Modules\Admin\app\Http\Controllers\ProfileController;
|
use Modules\Admin\app\Http\Controllers\ProfileController;
|
||||||
use Modules\Admin\app\Http\Controllers\TechnicalController;
|
use Modules\Admin\app\Http\Controllers\TechnicalController;
|
||||||
use Modules\Admin\app\Http\Controllers\TestCaseForSprintController;
|
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;
|
use Modules\Admin\app\Http\Middleware\AdminMiddleware;
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|
@ -151,8 +149,6 @@ Route::middleware('api')
|
||||||
'prefix' => 'category',
|
'prefix' => 'category',
|
||||||
], function () {
|
], function () {
|
||||||
Route::get('/get-list-master', [CategoryController::class, 'getListMaster']);
|
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([
|
Route::group([
|
||||||
|
|
@ -175,38 +171,6 @@ Route::middleware('api')
|
||||||
Route::post('/handle-ticket', [TicketController::class, 'handleTicket'])->middleware('check.permission:admin');
|
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([
|
Route::group([
|
||||||
'prefix' => 'profile',
|
'prefix' => 'profile',
|
||||||
], function () {
|
], function () {
|
||||||
|
|
|
||||||
|
|
@ -24,11 +24,9 @@ class Kernel extends ConsoleKernel
|
||||||
// $schedule->command('daily:api-call')
|
// $schedule->command('daily:api-call')
|
||||||
// ->dailyAt('18:00');
|
// ->dailyAt('18:00');
|
||||||
|
|
||||||
// Chạy command vào ngày 01/01 lúc 00:00 mỗi năm
|
// Chạy command vào ngày 31/12 lúc 23:59:59 mỗi năm
|
||||||
$schedule->command('initialize:leavedays')->yearlyOn(1, 1, '00:00');
|
$schedule->command('initialize:leavedays')->yearlyOn(12, 31, '23:59:59');
|
||||||
|
$schedule->command('leave:deduct')->yearlyOn(3, 31, '23:59:59');
|
||||||
// Chạy command vào ngày 01/04 lúc 00:00 mỗi năm
|
|
||||||
$schedule->command('leave:deduct')->yearlyOn(4, 1, '00:00');
|
|
||||||
|
|
||||||
// Chạy buổi sáng lúc 12:00
|
// Chạy buổi sáng lúc 12:00
|
||||||
$schedule->command('attendance:check S')->dailyAt('12:00');
|
$schedule->command('attendance:check S')->dailyAt('12:00');
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@
|
||||||
namespace App\Jobs;
|
namespace App\Jobs;
|
||||||
|
|
||||||
use App\Models\LeaveDays;
|
use App\Models\LeaveDays;
|
||||||
use App\Models\Notes;
|
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Carbon\Carbon;
|
use Carbon\Carbon;
|
||||||
use Illuminate\Bus\Queueable;
|
use Illuminate\Bus\Queueable;
|
||||||
|
|
@ -12,7 +11,6 @@ use Illuminate\Foundation\Bus\Dispatchable;
|
||||||
use Illuminate\Queue\InteractsWithQueue;
|
use Illuminate\Queue\InteractsWithQueue;
|
||||||
use Illuminate\Queue\SerializesModels;
|
use Illuminate\Queue\SerializesModels;
|
||||||
use Modules\Admin\app\Models\Category;
|
use Modules\Admin\app\Models\Category;
|
||||||
use Illuminate\Support\Facades\DB;
|
|
||||||
|
|
||||||
class AddMonthlyLeaveDays implements ShouldQueue
|
class AddMonthlyLeaveDays implements ShouldQueue
|
||||||
{
|
{
|
||||||
|
|
@ -48,48 +46,15 @@ class AddMonthlyLeaveDays implements ShouldQueue
|
||||||
->where('ld_year', $this->year)
|
->where('ld_year', $this->year)
|
||||||
->first();
|
->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
|
// 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)
|
// Số ngày phép bằng với tháng hiện tại
|
||||||
->where('ld_year', $this->year - 1)
|
|
||||||
->first();
|
|
||||||
$ld_additional_day = 0;
|
|
||||||
$ld_note = '';
|
|
||||||
|
|
||||||
if ($previousYearData) {
|
|
||||||
$ld_additional_day = $previousYearData->ld_day_total + $previousYearData->ld_additional_day + $previousYearData->ld_special_leave_day;
|
|
||||||
$totalLeaveDaysByMonth = Notes::join('categories', function ($join) {
|
|
||||||
$join->on('notes.n_time_type', '=', 'categories.c_code')
|
|
||||||
->where('categories.c_type', 'TIME_TYPE');
|
|
||||||
})
|
|
||||||
->select(
|
|
||||||
DB::raw('notes.n_user_id as n_user_id'),
|
|
||||||
DB::raw('notes.n_year as year'),
|
|
||||||
DB::raw('SUM(categories.c_value) as leave_days')
|
|
||||||
)
|
|
||||||
->where('notes.n_year', $this->year - 1)
|
|
||||||
->where('notes.n_user_id', $user->id)
|
|
||||||
->where('notes.n_reason', 'ONLEAVE')
|
|
||||||
->groupBy(DB::raw('notes.n_year'))
|
|
||||||
->first();
|
|
||||||
if ($totalLeaveDaysByMonth) {
|
|
||||||
$ld_additional_day = $ld_additional_day - $totalLeaveDaysByMonth->leave_days;
|
|
||||||
if ($ld_additional_day < 0) {
|
|
||||||
$ld_additional_day = 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($ld_additional_day > 0) {
|
|
||||||
$ld_note = "Cộng " . $ld_additional_day . " ngày phép tồn năm trước. \n";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$leaveDay = new LeaveDays([
|
$leaveDay = new LeaveDays([
|
||||||
'ld_user_id' => $user->id,
|
'ld_user_id' => $user->id,
|
||||||
'ld_day_total' => $this->month, // Số ngày phép bằng tháng hiện tại
|
'ld_day_total' => $this->month, // Số ngày phép bằng tháng hiện tại
|
||||||
'ld_year' => $this->year,
|
'ld_year' => $this->year,
|
||||||
'ld_additional_day' => $ld_additional_day,
|
'ld_additional_day' => 0,
|
||||||
'ld_note' => $ld_note,
|
'ld_note' => 'Khởi tạo ngày phép đến tháng ' . $this->month,
|
||||||
'ld_special_leave_day' => 0,
|
'ld_special_leave_day' => 0,
|
||||||
]);
|
]);
|
||||||
$leaveDay->save();
|
$leaveDay->save();
|
||||||
|
|
|
||||||
|
|
@ -55,12 +55,10 @@ class DeductLeaveDays implements ShouldQueue
|
||||||
if ($usedOnleaveDaysTotal) {
|
if ($usedOnleaveDaysTotal) {
|
||||||
if ($existingData->ld_additional_day > $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";
|
$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_note = $existingData->ld_note . "\n" . $ld_note;
|
||||||
$existingData->ld_additional_day = $usedOnleaveDaysTotal;
|
$existingData->ld_additional_day = $usedOnleaveDaysTotal;
|
||||||
}
|
}
|
||||||
} else {
|
} 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->ld_additional_day = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,16 @@ class InitializeLeaveDays implements ShouldQueue
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
|
||||||
// Kiểm tra dữ liệu của user này trong năm trước
|
// Kiểm tra dữ liệu của user này trong năm trước
|
||||||
$previousYearData = LeaveDays::where('ld_user_id', $user->id)
|
$previousYearData = LeaveDays::where('ld_user_id', $user->id)
|
||||||
->where('ld_year', $this->year - 1)
|
->where('ld_year', $this->year - 1)
|
||||||
|
|
@ -51,7 +61,7 @@ class InitializeLeaveDays implements ShouldQueue
|
||||||
$ld_note = '';
|
$ld_note = '';
|
||||||
|
|
||||||
if ($previousYearData) {
|
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) {
|
$totalLeaveDaysByMonth = Notes::join('categories', function ($join) {
|
||||||
$join->on('notes.n_time_type', '=', 'categories.c_code')
|
$join->on('notes.n_time_type', '=', 'categories.c_code')
|
||||||
->where('categories.c_type', 'TIME_TYPE');
|
->where('categories.c_type', 'TIME_TYPE');
|
||||||
|
|
@ -78,19 +88,6 @@ class InitializeLeaveDays implements ShouldQueue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Kiểm tra xem dữ liệu của user này đã tồn tại cho năm hiện tại chưa
|
|
||||||
$existingData = LeaveDays::where('ld_user_id', $user->id)
|
|
||||||
->where('ld_year', $this->year)
|
|
||||||
->first();
|
|
||||||
|
|
||||||
if ($existingData) {
|
|
||||||
// Nếu dữ liệu đã tồn tại, update lại phép tồn
|
|
||||||
$existingData->ld_note = $ld_note;
|
|
||||||
$existingData->ld_additional_day = $ld_additional_day;
|
|
||||||
$existingData->save();
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tạo dữ liệu cho năm hiện tại
|
// Tạo dữ liệu cho năm hiện tại
|
||||||
LeaveDays::insert([
|
LeaveDays::insert([
|
||||||
'ld_user_id' => $user->id,
|
'ld_user_id' => $user->id,
|
||||||
|
|
|
||||||
|
|
@ -2,16 +2,11 @@
|
||||||
"name": "laravel/laravel",
|
"name": "laravel/laravel",
|
||||||
"type": "project",
|
"type": "project",
|
||||||
"description": "The skeleton application for the Laravel framework.",
|
"description": "The skeleton application for the Laravel framework.",
|
||||||
"keywords": [
|
"keywords": ["laravel", "framework"],
|
||||||
"laravel",
|
|
||||||
"framework"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"require": {
|
"require": {
|
||||||
"php": "^8.2",
|
"php": "^8.2",
|
||||||
"barryvdh/laravel-debugbar": "^3.9",
|
"barryvdh/laravel-debugbar": "^3.9",
|
||||||
"barryvdh/laravel-dompdf": "^2.0",
|
|
||||||
"doctrine/dbal": "^3.10",
|
|
||||||
"drnxloc/laravel-simple-html-dom": "^1.9",
|
"drnxloc/laravel-simple-html-dom": "^1.9",
|
||||||
"guzzlehttp/guzzle": "^7.8",
|
"guzzlehttp/guzzle": "^7.8",
|
||||||
"laravel/framework": "^10.10",
|
"laravel/framework": "^10.10",
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
@ -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');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
@ -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');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
@ -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');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -23,8 +23,6 @@
|
||||||
"@tabler/icons-react": "^3.19.0",
|
"@tabler/icons-react": "^3.19.0",
|
||||||
"@uiw/react-codemirror": "^4.23.5",
|
"@uiw/react-codemirror": "^4.23.5",
|
||||||
"@vanilla-extract/css": "^1.16.0",
|
"@vanilla-extract/css": "^1.16.0",
|
||||||
"@zxing/browser": "^0.2.0",
|
|
||||||
"@zxing/library": "^0.23.0",
|
|
||||||
"axios": "^1.6.1",
|
"axios": "^1.6.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"dayjs": "^1.11.10",
|
"dayjs": "^1.11.10",
|
||||||
|
|
|
||||||
|
|
@ -35,8 +35,6 @@ export const updateWorkingDays =
|
||||||
|
|
||||||
//Category
|
//Category
|
||||||
export const getListMaster = API_URL + 'v1/admin/category/get-list-master'
|
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
|
//LeaveManagement
|
||||||
export const getLeaveManagement = API_URL + 'v1/admin/leave-management'
|
export const getLeaveManagement = API_URL + 'v1/admin/leave-management'
|
||||||
|
|
@ -125,44 +123,6 @@ export const deleteDocument = API_URL + 'v1/admin/document/delete'
|
||||||
// Download File
|
// Download File
|
||||||
export const downloadFile = API_URL + 'v1/admin/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
|
// Files APIs
|
||||||
export const getFiles = API_URL + 'v1/admin/profile/files'
|
export const getFiles = API_URL + 'v1/admin/profile/files'
|
||||||
export const uploadFiles = API_URL + 'v1/admin/profile/upload-files'
|
export const uploadFiles = API_URL + 'v1/admin/profile/upload-files'
|
||||||
|
|
|
||||||
|
|
@ -250,14 +250,7 @@ export const DataTableAll = ({
|
||||||
if (query !== '') {
|
if (query !== '') {
|
||||||
setTData(
|
setTData(
|
||||||
data.filter((obj) =>
|
data.filter((obj) =>
|
||||||
Object.values(obj)?.find((c: any) =>
|
Object.values(obj)?.find((c: any) => c.toString().normalize('NFC').toLowerCase().includes(query.normalize('NFC').toLowerCase())))
|
||||||
c
|
|
||||||
.toString()
|
|
||||||
.normalize('NFC')
|
|
||||||
.toLowerCase()
|
|
||||||
.includes(query.normalize('NFC').toLowerCase()),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
if (pagination) {
|
if (pagination) {
|
||||||
|
|
@ -463,7 +456,7 @@ export const DataTablePagination = ({
|
||||||
})
|
})
|
||||||
const [selectedRows, setSelectedRows] = useState<any[]>([])
|
const [selectedRows, setSelectedRows] = useState<any[]>([])
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
let urlParams = new URLSearchParams(location.search)
|
const urlParams = new URLSearchParams(location.search)
|
||||||
|
|
||||||
// Render headers
|
// Render headers
|
||||||
const headers = columns.map((col) => (
|
const headers = columns.map((col) => (
|
||||||
|
|
@ -603,7 +596,7 @@ export const DataTablePagination = ({
|
||||||
|
|
||||||
// Remove specific parameters
|
// Remove specific parameters
|
||||||
params.delete(name)
|
params.delete(name)
|
||||||
urlParams.delete(name)
|
|
||||||
// Update the URL without reloading the page
|
// Update the URL without reloading the page
|
||||||
window.history.replaceState({}, document.title, url.toString())
|
window.history.replaceState({}, document.title, url.toString())
|
||||||
}
|
}
|
||||||
|
|
@ -635,9 +628,9 @@ export const DataTablePagination = ({
|
||||||
Array.isArray(dataFilter[key])
|
Array.isArray(dataFilter[key])
|
||||||
? dataFilter[key]
|
? dataFilter[key]
|
||||||
: key === 'to_date'
|
: key === 'to_date'
|
||||||
? Math.floor(dataFilter[key].getTime() / 1000) +
|
? Math.floor(dataFilter[key].getTime() / 1000) +
|
||||||
(60 * 60 * 23 + 60 * 59 + 59)
|
(60 * 60 * 23 + 60 * 59 + 59)
|
||||||
: Math.floor(dataFilter[key].getTime() / 1000),
|
: Math.floor(dataFilter[key].getTime() / 1000),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
@ -667,8 +660,9 @@ export const DataTablePagination = ({
|
||||||
date_used_to: date_used,
|
date_used_to: date_used,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add all attributes in 'params' to URL params
|
// 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]) => {
|
Object.entries(dataFilter).forEach(([key, value]) => {
|
||||||
const typeFilter = filterInfo.find((o) => o.key === key).type
|
const typeFilter = filterInfo.find((o) => o.key === key).type
|
||||||
const hasType = {
|
const hasType = {
|
||||||
|
|
@ -685,16 +679,17 @@ export const DataTablePagination = ({
|
||||||
if (hasType.Date) {
|
if (hasType.Date) {
|
||||||
value = value ? Date.parse(String(value)) / 1000 : '' // to unix timestamp
|
value = value ? Date.parse(String(value)) / 1000 : '' // to unix timestamp
|
||||||
}
|
}
|
||||||
console.log(String(value))
|
|
||||||
String(value).length
|
String(value).length
|
||||||
? urlParams.set(key, String(value))
|
? urlParams.set(key, String(value))
|
||||||
: urlParams.delete(key)
|
: urlParams.delete(key)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Request to get data API
|
// Request to get data API
|
||||||
const res = await get(url, Object.fromEntries(urlParams.entries()))
|
const res = await get(url, Object.fromEntries(urlParams.entries()))
|
||||||
if (res.status) {
|
if (res.status) {
|
||||||
setBaseData(res.data?.links ? res?.data : res)
|
setBaseData(res)
|
||||||
setTData(res.data?.data ? res.data?.data : res.data)
|
setTData(res.data)
|
||||||
setSkeletion(false)
|
setSkeletion(false)
|
||||||
navigate({
|
navigate({
|
||||||
pathname: location.pathname,
|
pathname: location.pathname,
|
||||||
|
|
@ -770,7 +765,7 @@ export const DataTablePagination = ({
|
||||||
|
|
||||||
if (order_by_) {
|
if (order_by_) {
|
||||||
const sortParam = {
|
const sortParam = {
|
||||||
name: order_by_.split('=')[0].split('_').slice(2).join('_'),
|
name: order_by_.split('=')[0].split('_')[2],
|
||||||
status: order_by_.split('=')[1],
|
status: order_by_.split('=')[1],
|
||||||
}
|
}
|
||||||
if (JSON.stringify(sortParam) !== JSON.stringify(statusSort)) {
|
if (JSON.stringify(sortParam) !== JSON.stringify(statusSort)) {
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,6 @@ import {
|
||||||
IconReport,
|
IconReport,
|
||||||
IconScan,
|
IconScan,
|
||||||
IconSettings,
|
IconSettings,
|
||||||
IconShredder,
|
|
||||||
IconSun,
|
IconSun,
|
||||||
IconTicket,
|
IconTicket,
|
||||||
IconUsersGroup,
|
IconUsersGroup,
|
||||||
|
|
@ -147,8 +146,8 @@ const data = [
|
||||||
link: '/organization-settings',
|
link: '/organization-settings',
|
||||||
label: 'Organization Settings',
|
label: 'Organization Settings',
|
||||||
icon: IconSettings,
|
icon: IconSettings,
|
||||||
group: 'other',
|
group: 'admin',
|
||||||
permissions: 'admin,hr',
|
permissions: 'admin',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
link: '/profiles',
|
link: '/profiles',
|
||||||
|
|
@ -157,13 +156,6 @@ const data = [
|
||||||
group: 'admin',
|
group: 'admin',
|
||||||
permissions: 'admin,accountant',
|
permissions: 'admin,accountant',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
link: '/office-support',
|
|
||||||
label: 'Office Support',
|
|
||||||
icon: IconShredder,
|
|
||||||
group: 'other',
|
|
||||||
permissions: 'admin,hr,accountant',
|
|
||||||
},
|
|
||||||
// { link: '/jira', label: 'Jira', icon: IconSubtask },
|
// { link: '/jira', label: 'Jira', icon: IconSubtask },
|
||||||
// { link: '/custom-theme', label: 'Custom Theme', icon: IconBrush },
|
// { link: '/custom-theme', label: 'Custom Theme', icon: IconBrush },
|
||||||
// { link: '/general-setting', label: 'General Setting', icon: IconSettings },
|
// { link: '/general-setting', label: 'General Setting', icon: IconSettings },
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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);
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -1,47 +1,28 @@
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
Alert,
|
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
Code,
|
|
||||||
Dialog,
|
Dialog,
|
||||||
Flex,
|
Flex,
|
||||||
Grid,
|
|
||||||
Group,
|
Group,
|
||||||
Loader,
|
Loader,
|
||||||
LoadingOverlay,
|
|
||||||
Modal,
|
Modal,
|
||||||
Paper,
|
|
||||||
Select,
|
Select,
|
||||||
Tabs,
|
Tabs,
|
||||||
Text,
|
Text,
|
||||||
TextInput,
|
TextInput,
|
||||||
Title,
|
|
||||||
} from '@mantine/core'
|
} from '@mantine/core'
|
||||||
import classes from './OrganizationSettings.module.css'
|
import classes from './OrganizationSettings.module.css'
|
||||||
import DataTableAll from '@/components/DataTable/DataTable'
|
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 { notifications } from '@mantine/notifications'
|
||||||
import {
|
import { createTechnical, deleteTechnical, listTechnical } from '@/api/Admin'
|
||||||
createTechnical,
|
|
||||||
deleteTechnical,
|
|
||||||
getWorkDay,
|
|
||||||
listTechnical,
|
|
||||||
updateWorkDay,
|
|
||||||
} from '@/api/Admin'
|
|
||||||
import { useForm } from '@mantine/form'
|
import { useForm } from '@mantine/form'
|
||||||
import { Xdelete } from '@/rtk/helpers/CRUD'
|
import { Xdelete } from '@/rtk/helpers/CRUD'
|
||||||
import moment from 'moment'
|
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() {
|
function OrganizationSettings() {
|
||||||
const [activeTab, setActiveTab] = useState<string | null>('work-day')
|
const [activeTab, setActiveTab] = useState<string | null>('technical')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -55,22 +36,22 @@ function OrganizationSettings() {
|
||||||
<Box w="100%" display={'flex'} mt={15} ml={10}>
|
<Box w="100%" display={'flex'} mt={15} ml={10}>
|
||||||
<Tabs w="100%" value={activeTab} onChange={setActiveTab}>
|
<Tabs w="100%" value={activeTab} onChange={setActiveTab}>
|
||||||
<Tabs.List>
|
<Tabs.List>
|
||||||
<Tabs.Tab value="work-day">Work Day Setting</Tabs.Tab>
|
<Tabs.Tab value="technical">Technical Setting</Tabs.Tab>
|
||||||
|
<Tabs.Tab value="second">Setting 2</Tabs.Tab>
|
||||||
{checkPermissionAdmin('admin') && (
|
<Tabs.Tab value="third">Setting 3</Tabs.Tab>
|
||||||
<Tabs.Tab value="technical">Technical Setting</Tabs.Tab>
|
|
||||||
)}
|
|
||||||
</Tabs.List>
|
</Tabs.List>
|
||||||
|
|
||||||
<Tabs.Panel value="work-day" pt="xs">
|
<Tabs.Panel value="technical" pt="xs">
|
||||||
<WorkDaySettingTab />
|
<TechnicalSettingTab />
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
|
|
||||||
{checkPermissionAdmin('admin') && (
|
<Tabs.Panel value="second" pt="xs">
|
||||||
<Tabs.Panel value="technical" pt="xs">
|
Setting 2
|
||||||
<TechnicalSettingTab />
|
</Tabs.Panel>
|
||||||
</Tabs.Panel>
|
|
||||||
)}
|
<Tabs.Panel value="third" pt="xs">
|
||||||
|
Setting 3
|
||||||
|
</Tabs.Panel>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</Box>
|
</Box>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -119,8 +100,8 @@ const TechnicalSettingTab = () => {
|
||||||
? row?.level === 1
|
? row?.level === 1
|
||||||
? { backgroundColor: '#d9d2e9' }
|
? { backgroundColor: '#d9d2e9' }
|
||||||
: row?.level === 2
|
: row?.level === 2
|
||||||
? { backgroundColor: '#ffd966' }
|
? { backgroundColor: '#ffd966' }
|
||||||
: { backgroundColor: '#cfe2f3' }
|
: { backgroundColor: '#cfe2f3' }
|
||||||
: { backgroundColor: '' }
|
: { backgroundColor: '' }
|
||||||
}
|
}
|
||||||
fw={500}
|
fw={500}
|
||||||
|
|
@ -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
|
export default OrganizationSettings
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,6 @@ import PageLogin from '@/pages/Auth/Login/Login'
|
||||||
import Document from '@/pages/Document/Document'
|
import Document from '@/pages/Document/Document'
|
||||||
import LeaveManagement from '@/pages/LeaveManagement/LeaveManagement'
|
import LeaveManagement from '@/pages/LeaveManagement/LeaveManagement'
|
||||||
import PageNotFound from '@/pages/NotFound/NotFound'
|
import PageNotFound from '@/pages/NotFound/NotFound'
|
||||||
import OfficeSupport from '@/pages/OfficeSupport/OfficeSupport'
|
|
||||||
import OrganizationSettings from '@/pages/OrganizationSettings/OrganizationSettings'
|
import OrganizationSettings from '@/pages/OrganizationSettings/OrganizationSettings'
|
||||||
import Profile from '@/pages/Profile/Profile'
|
import Profile from '@/pages/Profile/Profile'
|
||||||
import SprintReview from '@/pages/SprintReview/SprintReview'
|
import SprintReview from '@/pages/SprintReview/SprintReview'
|
||||||
|
|
@ -240,7 +239,7 @@ const mainRoutes = [
|
||||||
{
|
{
|
||||||
path: '/organization-settings',
|
path: '/organization-settings',
|
||||||
element: (
|
element: (
|
||||||
<ProtectedRoute mode="route" permission="admin,hr">
|
<ProtectedRoute mode="route" permission="admin">
|
||||||
<BasePage
|
<BasePage
|
||||||
main={
|
main={
|
||||||
<>
|
<>
|
||||||
|
|
@ -265,20 +264,6 @@ const mainRoutes = [
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: '/office-support',
|
|
||||||
element: (
|
|
||||||
<ProtectedRoute mode="route" permission="admin,hr,accountant">
|
|
||||||
<BasePage
|
|
||||||
main={
|
|
||||||
<>
|
|
||||||
<OfficeSupport />
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
></BasePage>
|
|
||||||
</ProtectedRoute>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
// {
|
// {
|
||||||
// path: '/packages',
|
// path: '/packages',
|
||||||
// element: (
|
// element: (
|
||||||
|
|
|
||||||
|
|
@ -36,11 +36,7 @@ export const create = async (
|
||||||
if (res.status === false) {
|
if (res.status === false) {
|
||||||
notifications.show({
|
notifications.show({
|
||||||
title: 'Error',
|
title: 'Error',
|
||||||
message: (
|
message: <div style={{ whiteSpace: 'pre-line' }}>{res.message ?? _NOTIFICATION_MESS.create_error}</div>,
|
||||||
<div style={{ whiteSpace: 'pre-line' }}>
|
|
||||||
{res.message ?? _NOTIFICATION_MESS.create_error}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
color: 'red',
|
color: 'red',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -120,7 +116,7 @@ export const Xdelete = async (url: string, data: any, fnc?: () => void) => {
|
||||||
try {
|
try {
|
||||||
const res = await get(url, data)
|
const res = await get(url, data)
|
||||||
|
|
||||||
if (res.status || res.success) {
|
if (res.status) {
|
||||||
notifications.show({
|
notifications.show({
|
||||||
title: 'Success',
|
title: 'Success',
|
||||||
message: _NOTIFICATION_MESS.delete_success,
|
message: _NOTIFICATION_MESS.delete_success,
|
||||||
|
|
@ -128,7 +124,7 @@ export const Xdelete = async (url: string, data: any, fnc?: () => void) => {
|
||||||
})
|
})
|
||||||
fnc && fnc()
|
fnc && fnc()
|
||||||
}
|
}
|
||||||
if (res.status === false && !res.success) {
|
if (res.status === false) {
|
||||||
notifications.show({
|
notifications.show({
|
||||||
title: 'Error',
|
title: 'Error',
|
||||||
message: res.message ?? _NOTIFICATION_MESS.delete_error,
|
message: res.message ?? _NOTIFICATION_MESS.delete_error,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
Subproject commit 56858105084f8b425be7909b5814cb2d7a5b9aff
|
||||||
Binary file not shown.
|
|
@ -1,6 +0,0 @@
|
||||||
path
|
|
||||||
__pycache__
|
|
||||||
images
|
|
||||||
uploads
|
|
||||||
venv
|
|
||||||
log.log
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
{}
|
|
||||||
|
|
@ -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ộ
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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()
|
|
||||||
|
|
@ -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"
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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...
|
|
||||||
},
|
|
||||||
},
|
|
||||||
])
|
|
||||||
```
|
|
||||||
|
|
@ -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": {}
|
|
||||||
}
|
|
||||||
|
|
@ -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",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
@ -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
|
|
@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Binary file not shown.
|
|
@ -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"]}]
|
|
||||||
|
|
@ -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 |
|
|
@ -1,7 +0,0 @@
|
||||||
import ax from "axios";
|
|
||||||
|
|
||||||
const axios = ax.create({
|
|
||||||
baseURL: import.meta.env.VITE_API_BASE_URL || "/",
|
|
||||||
});
|
|
||||||
|
|
||||||
export default axios;
|
|
||||||
|
|
@ -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();
|
|
||||||
|
|
@ -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();
|
|
||||||
|
|
@ -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 |
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
@ -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 }
|
|
||||||
|
|
@ -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 }
|
|
||||||
|
|
@ -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 }
|
|
||||||
|
|
@ -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,
|
|
||||||
}
|
|
||||||
|
|
@ -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,
|
|
||||||
}
|
|
||||||
|
|
@ -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,
|
|
||||||
}
|
|
||||||
|
|
@ -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,
|
|
||||||
}
|
|
||||||
|
|
@ -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 }
|
|
||||||
|
|
@ -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 }
|
|
||||||
|
|
@ -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 }
|
|
||||||
|
|
@ -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 }
|
|
||||||
|
|
@ -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 };
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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[];
|
|
||||||
}
|
|
||||||
|
|
@ -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);
|
|
||||||
}
|
|
||||||
|
|
@ -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 };
|
|
||||||
}
|
|
||||||
|
|
@ -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);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
import { StrictMode } from "react";
|
|
||||||
import { createRoot } from "react-dom/client";
|
|
||||||
import { ConfirmModalProvider } from "./components/confirm-modal-provider";
|
|
||||||
import "./index.css";
|
|
||||||
import Main from "./pages/main";
|
|
||||||
import { Toaster } from "@/components/ui/sonner";
|
|
||||||
|
|
||||||
createRoot(document.getElementById("root")!).render(
|
|
||||||
<StrictMode>
|
|
||||||
<ConfirmModalProvider>
|
|
||||||
<Main />
|
|
||||||
<Toaster richColors position="top-right" />
|
|
||||||
</ConfirmModalProvider>
|
|
||||||
</StrictMode>
|
|
||||||
);
|
|
||||||
|
|
@ -1,71 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { useEffect, useState, useRef } from "react";
|
|
||||||
|
|
||||||
export default function CountDown({
|
|
||||||
onCountdowned,
|
|
||||||
}: {
|
|
||||||
onCountdowned?: () => void;
|
|
||||||
}) {
|
|
||||||
const [count, setCount] = useState(3);
|
|
||||||
const [running, setRunning] = useState(true);
|
|
||||||
|
|
||||||
const calledRef = useRef(false); // tránh gọi callback nhiều lần
|
|
||||||
|
|
||||||
// Countdown logic
|
|
||||||
useEffect(() => {
|
|
||||||
if (!running) return;
|
|
||||||
|
|
||||||
if (count === 0) {
|
|
||||||
if (!calledRef.current) {
|
|
||||||
calledRef.current = true;
|
|
||||||
onCountdowned?.();
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
setCount((prev) => prev - 1);
|
|
||||||
}, 1000);
|
|
||||||
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}, [count, running, onCountdowned]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="absolute inset-0 flex flex-col items-center justify-center bg-transparent">
|
|
||||||
<div className="flex flex-col items-center gap-8">
|
|
||||||
{/* Instruction text */}
|
|
||||||
<div className="text-center space-y-3">
|
|
||||||
<p className="text-lg font-medium">Chuẩn bị sẵn sàng</p>
|
|
||||||
<p className="text-base">Vui lòng nhìn thẳng vào camera</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Timer circle */}
|
|
||||||
<div className="relative">
|
|
||||||
<div className="w-40 h-40 bg-white rounded-full flex items-center justify-center shadow-2xl">
|
|
||||||
<span className="text-8xl font-bold">{count}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Countdown text */}
|
|
||||||
<div className="text-center">
|
|
||||||
<p className="text-sm">
|
|
||||||
{count > 0 ? `Còn ${count} giây...` : "Đã hoàn thành!"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Cancel button */}
|
|
||||||
{running && count > 0 && (
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setRunning(false)}
|
|
||||||
className="mt-4 px-8 py-2"
|
|
||||||
>
|
|
||||||
Hủy
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,94 +0,0 @@
|
||||||
import type { FaceBox, FaceZoomTransform } from "@/lib/use-face-zoom";
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
box: FaceBox | null;
|
|
||||||
transform: FaceZoomTransform;
|
|
||||||
/** 0..1 stability fill — drives the progress bar above the brackets. */
|
|
||||||
progress: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Renders 4 AF-style corner brackets around the detected face, plus a thin
|
|
||||||
* progress bar above them that fills as the same face remains in view.
|
|
||||||
*
|
|
||||||
* Coordinates: `box` is in source-video ratios (0..1). The video element on
|
|
||||||
* screen has a zoom transform applied. We re-apply the same `translate + scale`
|
|
||||||
* to the bracket position so it stays glued to the face — but place it OUTSIDE
|
|
||||||
* the transformed element so border width does not scale with zoom.
|
|
||||||
*/
|
|
||||||
export default function FaceBracket({ box, transform, progress }: Props) {
|
|
||||||
if (!box) return null;
|
|
||||||
|
|
||||||
const s = transform.scale;
|
|
||||||
const tx = transform.translateX / 100;
|
|
||||||
const ty = transform.translateY / 100;
|
|
||||||
|
|
||||||
// Apply the same transform as the video (origin 0,0): point' = point * s + t
|
|
||||||
const left = box.x * s + tx;
|
|
||||||
const top = box.y * s + ty;
|
|
||||||
const width = box.width * s;
|
|
||||||
const height = box.height * s;
|
|
||||||
|
|
||||||
if (
|
|
||||||
left + width < -0.05 ||
|
|
||||||
top + height < -0.05 ||
|
|
||||||
left > 1.05 ||
|
|
||||||
top > 1.05 ||
|
|
||||||
width <= 0 ||
|
|
||||||
height <= 0
|
|
||||||
) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const armPx = Math.max(14, Math.min(40, width * 1000 * 0.18));
|
|
||||||
const cornerSize = { width: armPx, height: armPx } as const;
|
|
||||||
|
|
||||||
const cornerBase =
|
|
||||||
"absolute border-emerald-400/90 transition-opacity duration-150";
|
|
||||||
|
|
||||||
const clamped = Math.max(0, Math.min(1, progress));
|
|
||||||
const isFull = clamped >= 0.999;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="absolute pointer-events-none"
|
|
||||||
style={{
|
|
||||||
left: `${left * 100}%`,
|
|
||||||
top: `${top * 100}%`,
|
|
||||||
width: `${width * 100}%`,
|
|
||||||
height: `${height * 100}%`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={`${cornerBase} border-t-[3px] border-l-[3px] rounded-tl-md`}
|
|
||||||
style={{ ...cornerSize, left: 0, top: 0 }}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
className={`${cornerBase} border-t-[3px] border-r-[3px] rounded-tr-md`}
|
|
||||||
style={{ ...cornerSize, right: 0, top: 0 }}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
className={`${cornerBase} border-b-[3px] border-l-[3px] rounded-bl-md`}
|
|
||||||
style={{ ...cornerSize, left: 0, bottom: 0 }}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
className={`${cornerBase} border-b-[3px] border-r-[3px] rounded-br-md`}
|
|
||||||
style={{ ...cornerSize, right: 0, bottom: 0 }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Stability progress bar — sits above the box. Width is driven by the
|
|
||||||
60fps `progress` value from the hook, so no CSS transition is needed
|
|
||||||
(and a transition would actually fight the rAF updates). */}
|
|
||||||
<div className="absolute left-0 right-0 -top-3 h-1.5 rounded-full bg-white/25 overflow-hidden backdrop-blur-sm">
|
|
||||||
<div
|
|
||||||
className={`h-full rounded-full ${
|
|
||||||
isFull
|
|
||||||
? "bg-emerald-300 shadow-[0_0_12px_rgba(110,231,183,0.9)]"
|
|
||||||
: "bg-emerald-400"
|
|
||||||
}`}
|
|
||||||
style={{ width: `${clamped * 100}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,37 +0,0 @@
|
||||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { Users } from "lucide-react";
|
|
||||||
import TabUsers from "./tab-users";
|
|
||||||
|
|
||||||
export default function LeftSlidebar({
|
|
||||||
isSidebarOpen,
|
|
||||||
}: {
|
|
||||||
isSidebarOpen: boolean;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"fixed left-0 top-0 h-screen w-96 bg-white border-r border-gray-200 shadow-xl transition-transform duration-300 ease-in-out z-10",
|
|
||||||
isSidebarOpen ? "translate-x-0" : "-translate-x-full"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="h-full flex flex-col">
|
|
||||||
<Tabs defaultValue="users" className="flex-1 flex flex-col">
|
|
||||||
<div className="border-b p-4">
|
|
||||||
<TabsList className="grid w-full grid-cols-1">
|
|
||||||
<TabsTrigger
|
|
||||||
value="users"
|
|
||||||
className="flex items-center gap-1 text-xs"
|
|
||||||
>
|
|
||||||
<Users className="size-3.5" />
|
|
||||||
User
|
|
||||||
</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<TabUsers value="users" />
|
|
||||||
</Tabs>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,72 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { Camera } from "lucide-react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import { useState, type ReactNode } from "react";
|
|
||||||
import useAppStore from "@/stores/use-app-store";
|
|
||||||
|
|
||||||
interface CameraNotificationModalProps {
|
|
||||||
onClose?: () => void;
|
|
||||||
children?: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CameraNotificationModal({
|
|
||||||
children,
|
|
||||||
onClose,
|
|
||||||
}: CameraNotificationModalProps) {
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
|
|
||||||
const { setIsCountDown } = useAppStore();
|
|
||||||
|
|
||||||
const handleClose = () => {
|
|
||||||
setOpen(false);
|
|
||||||
onClose?.();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleContinue = () => {
|
|
||||||
setIsCountDown(true);
|
|
||||||
handleClose();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
|
||||||
<DialogTrigger>{children}</DialogTrigger>
|
|
||||||
<DialogContent className="sm:max-w-md">
|
|
||||||
<DialogHeader>
|
|
||||||
<div className="flex items-center justify-center mb-4">
|
|
||||||
<div className="rounded-full border p-3">
|
|
||||||
<Camera className="w-6 h-6 " />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<DialogTitle className="text-center text-lg">
|
|
||||||
Thông báo quan trọng
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogDescription className="text-center text-base pt-2">
|
|
||||||
Để có kết quả tốt nhất bạn hay nhìn thẳng vào camera nhé
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<div className="flex justify-center gap-3 pt-4">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={handleClose}
|
|
||||||
className="min-w-32 bg-transparent"
|
|
||||||
>
|
|
||||||
Hủy
|
|
||||||
</Button>
|
|
||||||
<Button onClick={handleContinue} className="min-w-32">
|
|
||||||
Tiếp tục
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,257 +0,0 @@
|
||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
||||||
"use client";
|
|
||||||
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
|
||||||
import { useEffect, useState, type ReactNode } from "react";
|
|
||||||
import { useForm } from "react-hook-form";
|
|
||||||
import { z } from "zod";
|
|
||||||
|
|
||||||
import { useConfirm } from "@/components/confirm-modal-provider";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
|
||||||
Form,
|
|
||||||
FormControl,
|
|
||||||
FormField,
|
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormMessage,
|
|
||||||
} from "@/components/ui/form";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import useAppStore from "@/stores/use-app-store";
|
|
||||||
import { Camera, Loader } from "lucide-react";
|
|
||||||
import { checkingApi } from "@/api/checking-api";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
|
|
||||||
interface UserModalProps {
|
|
||||||
user?: IUser | null;
|
|
||||||
onSave?: (user: IUser) => void;
|
|
||||||
children?: ReactNode;
|
|
||||||
trackingOpen?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const userSchema = z.object({
|
|
||||||
name: z.string().min(1, "Tên là bắt buộc"),
|
|
||||||
email: z.string().email("Email không hợp lệ"),
|
|
||||||
avatar: z.string().optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
type UserFormData = z.infer<typeof userSchema>;
|
|
||||||
|
|
||||||
export function UserModal({
|
|
||||||
user,
|
|
||||||
onSave,
|
|
||||||
children,
|
|
||||||
trackingOpen,
|
|
||||||
}: UserModalProps) {
|
|
||||||
const confirm = useConfirm();
|
|
||||||
|
|
||||||
const isEditMode = !!user;
|
|
||||||
|
|
||||||
const [open, setOpenChange] = useState(false);
|
|
||||||
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
|
|
||||||
const { captureRegisterImage, setCaptureRegisterImage } = useAppStore();
|
|
||||||
|
|
||||||
const form = useForm<UserFormData>({
|
|
||||||
resolver: zodResolver(userSchema),
|
|
||||||
defaultValues: {
|
|
||||||
name: "",
|
|
||||||
email: "",
|
|
||||||
avatar: "",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (user) {
|
|
||||||
form.reset({
|
|
||||||
name: user.name,
|
|
||||||
email: user.email,
|
|
||||||
avatar: user.avatar ?? "",
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
form.reset({
|
|
||||||
name: "",
|
|
||||||
email: "",
|
|
||||||
avatar: "",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [user, open, form]);
|
|
||||||
|
|
||||||
const handleClose = async () => {
|
|
||||||
const result = await confirm({
|
|
||||||
title: "Thông báo",
|
|
||||||
message: "Bạn muốn hủy đăng ký. Mọi dữ liệu bạn nhập sẽ bị mất",
|
|
||||||
confirmText: "Discard",
|
|
||||||
cancelText: "Hủy",
|
|
||||||
variant: "warning",
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!result) return;
|
|
||||||
|
|
||||||
setOpenChange(false);
|
|
||||||
setCaptureRegisterImage(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const onSubmit = async (values: UserFormData) => {
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
|
|
||||||
const dataToSubmit: IUser = {
|
|
||||||
// eslint-disable-next-line react-hooks/purity
|
|
||||||
id: user?.id || Date.now().toString(),
|
|
||||||
...values,
|
|
||||||
avatar: values.avatar || null,
|
|
||||||
};
|
|
||||||
|
|
||||||
const { data } = await checkingApi.register({
|
|
||||||
user: dataToSubmit,
|
|
||||||
file: captureRegisterImage,
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log({ data });
|
|
||||||
|
|
||||||
onSave?.(dataToSubmit);
|
|
||||||
setOpenChange(false);
|
|
||||||
setCaptureRegisterImage(null);
|
|
||||||
|
|
||||||
toast.success(data?.message || "Đăng ký thành công !");
|
|
||||||
} catch (error) {
|
|
||||||
console.log({ error });
|
|
||||||
toast.error((error as any)?.message || "Internal Server Error");
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (trackingOpen === undefined) return;
|
|
||||||
|
|
||||||
setOpenChange(trackingOpen);
|
|
||||||
}, [trackingOpen]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!captureRegisterImage) return;
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
URL.revokeObjectURL(captureRegisterImage);
|
|
||||||
};
|
|
||||||
}, [captureRegisterImage]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog
|
|
||||||
open={open}
|
|
||||||
onOpenChange={(nextOpen) => {
|
|
||||||
if (!nextOpen) {
|
|
||||||
handleClose();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setOpenChange(true); // mở
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DialogTrigger>{children}</DialogTrigger>
|
|
||||||
|
|
||||||
<DialogContent className="sm:max-w-[425px]">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>
|
|
||||||
{isEditMode ? "Cập nhật thông tin" : "Tạo người dùng mới"}
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
{isEditMode
|
|
||||||
? "Cập nhật thông tin người dùng của bạn"
|
|
||||||
: "Nhập thông tin để tạo người dùng mới"}
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<Form {...form}>
|
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
|
||||||
{/* Name */}
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="name"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>
|
|
||||||
Tên <span className="text-red-600">*</span>
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input placeholder="Nhập tên người dùng" {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Email */}
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="email"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>
|
|
||||||
Email <span className="text-red-600">*</span>
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input type="email" placeholder="Nhập email" {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Avatar */}
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="avatar"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Avatar URL</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
placeholder="https://example.com/avatar.jpg"
|
|
||||||
{...field}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* File */}
|
|
||||||
<FormLabel>Ảnh từ camera</FormLabel>
|
|
||||||
<a target="_blank" href={URL.createObjectURL(captureRegisterImage)}>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
className="w-full bg-transparent"
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<Camera className="w-4 h-4 mr-2" />
|
|
||||||
Xem ảnh
|
|
||||||
</Button>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<div className="flex justify-end gap-2 pt-4">
|
|
||||||
<Button type="button" variant="outline" onClick={handleClose}>
|
|
||||||
Hủy
|
|
||||||
</Button>
|
|
||||||
<Button type="submit">
|
|
||||||
{!loading && isEditMode ? "Cập nhật" : "Tạo mới"}
|
|
||||||
|
|
||||||
{loading && <Loader className="animate-spin" />}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,41 +0,0 @@
|
||||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { ClipboardList } from "lucide-react";
|
|
||||||
import TabLogs from "./tab-log";
|
|
||||||
|
|
||||||
export default function RightSlidebar({
|
|
||||||
isSidebarOpen,
|
|
||||||
}: {
|
|
||||||
isSidebarOpen: boolean;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"fixed right-0 top-0 h-screen w-96 bg-white border-l border-gray-200 shadow-xl transition-transform duration-300 ease-in-out",
|
|
||||||
isSidebarOpen ? "translate-x-0" : "translate-x-full"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="h-full flex flex-col">
|
|
||||||
<Tabs
|
|
||||||
value={"logs"}
|
|
||||||
defaultValue="features"
|
|
||||||
className="flex-1 flex flex-col"
|
|
||||||
>
|
|
||||||
<div className="border-b p-4">
|
|
||||||
<TabsList className="grid w-full grid-cols-1">
|
|
||||||
<TabsTrigger
|
|
||||||
value="logs"
|
|
||||||
className="flex items-center gap-1 text-xs"
|
|
||||||
>
|
|
||||||
<ClipboardList className="size-3.5" />
|
|
||||||
Log
|
|
||||||
</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<TabLogs value="logs" />
|
|
||||||
</Tabs>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,204 +0,0 @@
|
||||||
/* eslint-disable react-hooks/exhaustive-deps */
|
|
||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
||||||
import { checkingApi } from "@/api/checking-api";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { capture, cn, formatTime } from "@/lib/utils";
|
|
||||||
import useAppStore from "@/stores/use-app-store";
|
|
||||||
import useUserStore from "@/stores/use-user-store";
|
|
||||||
import type { AxiosError } from "axios";
|
|
||||||
import { Camera, Image, Loader, Play, Square } from "lucide-react";
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import Register from "./register";
|
|
||||||
import { speak } from "@/lib/speak";
|
|
||||||
|
|
||||||
export default function TabFeatures() {
|
|
||||||
const timeoutRef = useRef<any>(null);
|
|
||||||
|
|
||||||
const { canvasRef, videoRef } = useAppStore();
|
|
||||||
|
|
||||||
const { currentUser, setCurrentUser } = useUserStore();
|
|
||||||
|
|
||||||
const { isAutoChecking, setIsAutoChecking, setRefreshLog, autoCheckinTick } =
|
|
||||||
useAppStore();
|
|
||||||
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
|
|
||||||
const [checkPoinLoading, setCheckPoinLoading] = useState(false);
|
|
||||||
|
|
||||||
const toggleAutoCheck = () => {
|
|
||||||
// Auto mode is now driven by stable-face detection in <Main> — toggling
|
|
||||||
// this flag enables/disables the 2s presence timer + auto fire.
|
|
||||||
setIsAutoChecking(!isAutoChecking);
|
|
||||||
};
|
|
||||||
|
|
||||||
const createCheckpoint = async () => {
|
|
||||||
if (!currentUser) {
|
|
||||||
toast.warning("Vui lòng chọn user để tạo checkpoint");
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
setCheckPoinLoading(true);
|
|
||||||
const file = await capture(videoRef, canvasRef);
|
|
||||||
|
|
||||||
const { data } = await checkingApi.register({ user: currentUser, file });
|
|
||||||
|
|
||||||
if (!data) {
|
|
||||||
toast.error(
|
|
||||||
(data as any)?.message ||
|
|
||||||
"Error In Checkpoint: " + JSON.stringify(data)
|
|
||||||
);
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
toast.success(data?.message || "Tạo checkpoint thành công");
|
|
||||||
} catch (error) {
|
|
||||||
const data = error as AxiosError;
|
|
||||||
|
|
||||||
toast.error(
|
|
||||||
(data.response?.data as any)?.message ||
|
|
||||||
"Error In Checkpoint: " + JSON.stringify(data)
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
setCheckPoinLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const captureAndCheck = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
|
|
||||||
const file = await capture(videoRef, canvasRef);
|
|
||||||
|
|
||||||
const { data } = await checkingApi.checkin({ file });
|
|
||||||
|
|
||||||
if (!data || !data?.status) {
|
|
||||||
toast.error(
|
|
||||||
(data as any)?.message || "Error In Checking: " + JSON.stringify(data)
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const message =
|
|
||||||
(data as any)?.message ||
|
|
||||||
`Checking thành công lúc: ${formatTime(new Date().toLocaleString())}`;
|
|
||||||
|
|
||||||
toast.success(message);
|
|
||||||
|
|
||||||
speak({ type: data?.status_type });
|
|
||||||
setRefreshLog(true);
|
|
||||||
} catch (error) {
|
|
||||||
const data = error as AxiosError;
|
|
||||||
|
|
||||||
const message =
|
|
||||||
(data.response?.data as any)?.message ||
|
|
||||||
"Error In Checking: " + JSON.stringify(data);
|
|
||||||
|
|
||||||
if ((message as string).includes("No face detected")) return;
|
|
||||||
|
|
||||||
toast.error(message);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, [canvasRef, setCurrentUser, videoRef]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Stable-face auto trigger: Main bumps autoCheckinTick when a face has been
|
|
||||||
// present for 2s, and we fire the same checkin path used by the manual
|
|
||||||
// button. Skip the initial 0 tick on mount.
|
|
||||||
const lastHandledTick = useRef(0);
|
|
||||||
useEffect(() => {
|
|
||||||
if (autoCheckinTick === 0) return;
|
|
||||||
if (autoCheckinTick === lastHandledTick.current) return;
|
|
||||||
lastHandledTick.current = autoCheckinTick;
|
|
||||||
if (loading) return;
|
|
||||||
captureAndCheck();
|
|
||||||
}, [autoCheckinTick, captureAndCheck, loading]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const down = (e: KeyboardEvent) => {
|
|
||||||
if (e.code === "Space") {
|
|
||||||
// ← cách đúng nhất để detect phím cách
|
|
||||||
e.preventDefault(); // nếu không muốn scroll
|
|
||||||
|
|
||||||
if (loading) return;
|
|
||||||
|
|
||||||
captureAndCheck();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener("keydown", down);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener("keydown", down);
|
|
||||||
};
|
|
||||||
}, [captureAndCheck, loading]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="absolute bottom-10 px-4 right-0 left-0 grid grid-cols-3 gap-4">
|
|
||||||
<Button
|
|
||||||
onClick={captureAndCheck}
|
|
||||||
disabled={isAutoChecking}
|
|
||||||
className="w-full bg-blue-600 hover:bg-blue-700 text-white font-semibold"
|
|
||||||
>
|
|
||||||
{!loading && (
|
|
||||||
<>
|
|
||||||
<Camera className="mr-2 size-4" />
|
|
||||||
Điểm Danh Ngay
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{loading && <Loader className="size-4 animate-spin" />}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
onClick={toggleAutoCheck}
|
|
||||||
variant={isAutoChecking ? "destructive" : "outline"}
|
|
||||||
className={cn(
|
|
||||||
"w-full font-semibold",
|
|
||||||
isAutoChecking && "animate-pulse"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{!loading && isAutoChecking ? (
|
|
||||||
<>
|
|
||||||
<Square className="mr-2 size-4" />
|
|
||||||
Dừng Tự Động
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Play className="mr-2 size-4" />
|
|
||||||
Tự Động Điểm Danh
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{loading && <Loader className="size-4 animate-spin" />}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{currentUser && (
|
|
||||||
<Button
|
|
||||||
disabled={isAutoChecking}
|
|
||||||
onClick={createCheckpoint}
|
|
||||||
className={cn("w-full font-semibold")}
|
|
||||||
>
|
|
||||||
{!checkPoinLoading && (
|
|
||||||
<>
|
|
||||||
<Image />
|
|
||||||
Tạo Check Point
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{checkPoinLoading && <Loader className="size-4 animate-spin" />}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!currentUser && <Register />}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import useAppStore from "@/stores/use-app-store";
|
|
||||||
import { User2 } from "lucide-react";
|
|
||||||
// import { UserModal } from "../modals/user-modal";
|
|
||||||
import { CameraNotificationModal } from "../modals/camera-notification-modal";
|
|
||||||
import { UserModal } from "../modals/user-modal";
|
|
||||||
|
|
||||||
export default function Register() {
|
|
||||||
const { isAutoChecking, captureRegisterImage, isCountDown } = useAppStore();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<CameraNotificationModal>
|
|
||||||
<Button
|
|
||||||
onClick={() => {}}
|
|
||||||
disabled={isAutoChecking}
|
|
||||||
className="w-full bg-green-600 hover:bg-green-700 text-white font-semibold"
|
|
||||||
>
|
|
||||||
<User2 className="mr-2 size-4" />
|
|
||||||
Tạo User Checking
|
|
||||||
</Button>
|
|
||||||
</CameraNotificationModal>
|
|
||||||
|
|
||||||
{captureRegisterImage && !isCountDown && (
|
|
||||||
<UserModal trackingOpen={true} />
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,74 +0,0 @@
|
||||||
/* eslint-disable react-hooks/exhaustive-deps */
|
|
||||||
import { checkingApi } from "@/api/checking-api";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { TabsContent } from "@/components/ui/tabs";
|
|
||||||
import { cn, formatTime } from "@/lib/utils";
|
|
||||||
import useAppStore from "@/stores/use-app-store";
|
|
||||||
import { ClipboardList } from "lucide-react";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
|
|
||||||
export default function TabLogs({ value }: { value: string }) {
|
|
||||||
const [logs, setLogs] = useState<ILog[]>([]);
|
|
||||||
|
|
||||||
const { refreshLog, setRefreshLog } = useAppStore();
|
|
||||||
|
|
||||||
const loadLogs = async () => {
|
|
||||||
try {
|
|
||||||
const { data } = await checkingApi.logs();
|
|
||||||
|
|
||||||
setLogs(data);
|
|
||||||
|
|
||||||
setRefreshLog(false);
|
|
||||||
} catch (error) {
|
|
||||||
console.log(error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadLogs();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!refreshLog) return;
|
|
||||||
|
|
||||||
loadLogs();
|
|
||||||
}, [refreshLog]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TabsContent value={value} className="">
|
|
||||||
<div className="flex flex-col gap-2 flex-1 p-4 space-y-2 overflow-y-auto h-[90vh]">
|
|
||||||
{logs.length === 0 ? (
|
|
||||||
<div className="flex flex-col items-center justify-center h-full text-gray-400">
|
|
||||||
<ClipboardList className="size-16 mb-3" />
|
|
||||||
<p>Chưa có dữ liệu điểm danh</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
logs.map((log, index) => (
|
|
||||||
<div
|
|
||||||
key={index}
|
|
||||||
className={cn(
|
|
||||||
"p-3 rounded-lg border transition-all duration-200",
|
|
||||||
index === 0
|
|
||||||
? "bg-blue-50 border-blue-200"
|
|
||||||
: "bg-gray-50 border-gray-200"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between mb-1">
|
|
||||||
<span className="font-medium text-gray-900">{log.name}</span>
|
|
||||||
<Badge
|
|
||||||
className="capitalize"
|
|
||||||
variant={
|
|
||||||
log.status === "check out" ? "destructive" : "secondary"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{log.status}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-gray-600">{formatTime(log.time)}</p>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</TabsContent>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,117 +0,0 @@
|
||||||
/* eslint-disable no-constant-binary-expression */
|
|
||||||
"use client";
|
|
||||||
import { checkingApi } from "@/api/checking-api";
|
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
|
||||||
import { Card } from "@/components/ui/card";
|
|
||||||
import { TabsContent } from "@/components/ui/tabs";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import useUserStore from "@/stores/use-user-store";
|
|
||||||
import { Users } from "lucide-react";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
|
|
||||||
export default function TabUsers({ value }: { value: string }) {
|
|
||||||
const [users, setUsers] = useState<IUser[]>([]);
|
|
||||||
const { currentUser, setCurrentUser } = useUserStore();
|
|
||||||
|
|
||||||
const loadUsers = async () => {
|
|
||||||
try {
|
|
||||||
const { data } = await checkingApi.users();
|
|
||||||
|
|
||||||
setUsers(data);
|
|
||||||
} catch (error) {
|
|
||||||
console.log(error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggle = (data: IUser) => {
|
|
||||||
if (currentUser) {
|
|
||||||
if (data.id === currentUser.id) {
|
|
||||||
setCurrentUser(null);
|
|
||||||
} else {
|
|
||||||
setCurrentUser(data);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setCurrentUser(data);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
||||||
loadUsers();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TabsContent value={value} className="">
|
|
||||||
<div className="flex flex-col gap-2 flex-1 p-4 space-y-2 overflow-y-auto h-[90vh]">
|
|
||||||
{users.map((user) => (
|
|
||||||
<Card
|
|
||||||
key={user.id}
|
|
||||||
className={cn(
|
|
||||||
"p-4 cursor-pointer transition-all duration-200 hover:shadow-md hover:scale-[1.01] select-none",
|
|
||||||
currentUser?.id === user.id &&
|
|
||||||
"bg-blue-50 dark:bg-blue-950 border-blue-500 shadow-md"
|
|
||||||
)}
|
|
||||||
onClick={() => toggle(user)}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Avatar className="size-12">
|
|
||||||
<AvatarImage
|
|
||||||
src={
|
|
||||||
`https://ms.prology.net/image/storage/${user?.avatar}` || ""
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<AvatarFallback> {user.name.charAt(0)}</AvatarFallback>
|
|
||||||
</Avatar>
|
|
||||||
|
|
||||||
<div className="flex-1">
|
|
||||||
<h4 className="font-semibold text-gray-900 dark:text-gray-100">
|
|
||||||
{user.name}
|
|
||||||
</h4>
|
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
|
||||||
{user.email}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* <DropdownMenu>
|
|
||||||
<DropdownMenuTrigger
|
|
||||||
asChild
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
|
||||||
<MoreVertical className="h-4 w-4" />
|
|
||||||
<span className="sr-only">Mở menu</span>
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end" className="w-48">
|
|
||||||
<DropdownMenuItem onClick={(e) => handleViewDetails(user, e)}>
|
|
||||||
<UserCheck className="mr-2 h-4 w-4" />
|
|
||||||
<span>Xem chi tiết</span>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem onClick={(e) => handleEdit(user, e)}>
|
|
||||||
<Edit className="mr-2 h-4 w-4" />
|
|
||||||
<span>Chỉnh sửa</span>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={(e) => handleDelete(user, e)}
|
|
||||||
className="text-red-600 focus:text-red-600 dark:text-red-400 dark:focus:text-red-400"
|
|
||||||
>
|
|
||||||
<Trash2 className="mr-2 h-4 w-4" />
|
|
||||||
<span>Xóa</span>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu> */}
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{users.length <= 0 && (
|
|
||||||
<div className="flex flex-col items-center justify-center h-full text-gray-400">
|
|
||||||
<Users className="size-16 mb-3" />
|
|
||||||
<p>Chưa có dữ liệu điểm danh</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</TabsContent>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,259 +0,0 @@
|
||||||
/* eslint-disable react-hooks/exhaustive-deps */
|
|
||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { checkingApi } from "@/api/checking-api";
|
|
||||||
import { msApi } from "@/api/ms-api";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Card } from "@/components/ui/card";
|
|
||||||
import { useFaceZoom } from "@/lib/use-face-zoom";
|
|
||||||
import { capture, cn } from "@/lib/utils";
|
|
||||||
import useAppStore from "@/stores/use-app-store";
|
|
||||||
import { AnimatePresence, motion } from "framer-motion";
|
|
||||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
|
||||||
import CountDown from "./components/count-down";
|
|
||||||
import FaceBracket from "./components/face-bracket";
|
|
||||||
import LeftSlidebar from "./components/left-slidebar";
|
|
||||||
import RightSlidebar from "./components/right-slidebar";
|
|
||||||
import TabFeatures from "./components/tab-features";
|
|
||||||
|
|
||||||
export default function Main() {
|
|
||||||
const [isSidebarOpen, setIsSidebarOpen] = useState(true);
|
|
||||||
const [isLeftSidebarOpen, setIsLeftSidebarOpen] = useState(false);
|
|
||||||
// const { currentUser, setCurrentUser } = useUserStore();
|
|
||||||
|
|
||||||
const {
|
|
||||||
setCanvasRef,
|
|
||||||
setVideoRef,
|
|
||||||
setFaceZoomTransform,
|
|
||||||
isAutoChecking,
|
|
||||||
bumpAutoCheckinTick,
|
|
||||||
} = useAppStore();
|
|
||||||
const { isCountDown, setCaptureRegisterImage, setIsCountDown } =
|
|
||||||
useAppStore();
|
|
||||||
|
|
||||||
const [flashKey, setFlashKey] = useState(0);
|
|
||||||
|
|
||||||
const videoRef = useRef<HTMLVideoElement>(null);
|
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
||||||
|
|
||||||
const onStableFace = useCallback(() => {
|
|
||||||
// Fire the same path as the "Điểm Danh Ngay" button via the store trigger,
|
|
||||||
// and play a camera-shutter flash to acknowledge the capture.
|
|
||||||
setFlashKey((k) => k + 1);
|
|
||||||
bumpAutoCheckinTick();
|
|
||||||
}, [bumpAutoCheckinTick]);
|
|
||||||
|
|
||||||
const {
|
|
||||||
transform: faceZoom,
|
|
||||||
box: faceBox,
|
|
||||||
stabilityProgress: faceStability,
|
|
||||||
} = useFaceZoom(videoRef, {
|
|
||||||
enabled: true,
|
|
||||||
intervalMs: 110,
|
|
||||||
targetFaceRatio: 0.6,
|
|
||||||
minScale: 1.35,
|
|
||||||
maxScale: 4,
|
|
||||||
followSpeed: 0.11,
|
|
||||||
detectionSmoothing: 0.3,
|
|
||||||
detectorInputSize: 416,
|
|
||||||
detectorScoreThreshold: 0.3,
|
|
||||||
stabilityEnabled: isAutoChecking,
|
|
||||||
stableMs: 2000,
|
|
||||||
onStableFace,
|
|
||||||
});
|
|
||||||
|
|
||||||
const sync = async () => {
|
|
||||||
try {
|
|
||||||
const { data } = await msApi.timekeepings();
|
|
||||||
const users = (data?.data as any[]) || [];
|
|
||||||
|
|
||||||
const mappd = users.map((user) => {
|
|
||||||
return {
|
|
||||||
name: user?.user?.name,
|
|
||||||
email: user?.user?.email,
|
|
||||||
avatar: user?.user?.avatar,
|
|
||||||
} as IUser;
|
|
||||||
});
|
|
||||||
|
|
||||||
await Promise.all(
|
|
||||||
mappd.map((user) => {
|
|
||||||
return checkingApi.registerSimple({
|
|
||||||
user: user,
|
|
||||||
});
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
console.log("Sync hoàn tất!");
|
|
||||||
} catch (error) {
|
|
||||||
console.log("Sync error:", error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Initialize camera
|
|
||||||
useEffect(() => {
|
|
||||||
const initCamera = async () => {
|
|
||||||
try {
|
|
||||||
const stream = await navigator.mediaDevices.getUserMedia({
|
|
||||||
video: { width: 1280, height: 720, facingMode: "user" },
|
|
||||||
});
|
|
||||||
if (videoRef.current) {
|
|
||||||
videoRef.current.srcObject = stream;
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Không thể truy cập camera:", err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
initCamera();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (videoRef.current?.srcObject) {
|
|
||||||
const stream = videoRef.current?.srcObject as MediaStream;
|
|
||||||
stream.getTracks().forEach((track) => track.stop());
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
sync();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setCanvasRef(canvasRef);
|
|
||||||
setVideoRef(videoRef);
|
|
||||||
}, [videoRef, canvasRef]);
|
|
||||||
|
|
||||||
// Keep store in sync with the live zoom so capture() can crop to the
|
|
||||||
// visible focused region instead of the full frame.
|
|
||||||
useEffect(() => {
|
|
||||||
setFaceZoomTransform(faceZoom);
|
|
||||||
}, [faceZoom, setFaceZoomTransform]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-white">
|
|
||||||
<div className="flex h-screen">
|
|
||||||
<LeftSlidebar isSidebarOpen={isLeftSidebarOpen} />
|
|
||||||
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"flex-1 transition-all duration-300 ease-in-out",
|
|
||||||
isLeftSidebarOpen && "ml-96",
|
|
||||||
isSidebarOpen && "mr-96"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="h-full flex flex-col p-6">
|
|
||||||
{/* Video Feed */}
|
|
||||||
<Card className="flex-1 overflow-hidden bg-black relative group">
|
|
||||||
<video
|
|
||||||
ref={videoRef}
|
|
||||||
autoPlay
|
|
||||||
playsInline
|
|
||||||
className="w-full h-full object-cover will-change-transform"
|
|
||||||
style={{
|
|
||||||
transformOrigin: "0 0",
|
|
||||||
transform: `translate3d(${faceZoom.translateX}%, ${faceZoom.translateY}%, 0) scale(${faceZoom.scale})`,
|
|
||||||
backfaceVisibility: "hidden",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div className="absolute inset-0 border-4 border-blue-500/30 pointer-events-none" />
|
|
||||||
|
|
||||||
<FaceBracket
|
|
||||||
box={faceBox}
|
|
||||||
transform={faceZoom}
|
|
||||||
progress={isAutoChecking ? faceStability : 0}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Camera shutter flash — keyed so each fire replays the animation. */}
|
|
||||||
<AnimatePresence>
|
|
||||||
{flashKey > 0 && (
|
|
||||||
<motion.div
|
|
||||||
key={flashKey}
|
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: [0, 0.85, 0] }}
|
|
||||||
transition={{ duration: 0.45, times: [0, 0.18, 1] }}
|
|
||||||
className="absolute inset-0 bg-white pointer-events-none z-40"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
|
|
||||||
{/* <AnimatePresence>
|
|
||||||
{currentUser && (
|
|
||||||
<motion.div
|
|
||||||
onClick={() => setCurrentUser(null)}
|
|
||||||
key="user-card"
|
|
||||||
initial={{ opacity: 0, y: -20 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
exit={{ opacity: 0, y: -20 }}
|
|
||||||
transition={{ duration: 0.25 }}
|
|
||||||
className="absolute top-4 left-4 right-4"
|
|
||||||
>
|
|
||||||
<Card className="p-4 bg-white/95 backdrop-blur-sm border-blue-200 shadow-lg">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="size-12 rounded-full bg-blue-600 text-white flex items-center justify-center font-semibold text-lg">
|
|
||||||
{currentUser.name.charAt(0)}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 className="font-semibold text-gray-900">
|
|
||||||
{currentUser.name}
|
|
||||||
</h3>
|
|
||||||
<p className="text-sm text-gray-600">
|
|
||||||
{currentUser.email}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence> */}
|
|
||||||
|
|
||||||
{isCountDown && (
|
|
||||||
<CountDown
|
|
||||||
onCountdowned={async () => {
|
|
||||||
const data = await capture(videoRef, canvasRef);
|
|
||||||
setCaptureRegisterImage(data);
|
|
||||||
setIsCountDown(false);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Button
|
|
||||||
onClick={() => setIsLeftSidebarOpen(!isLeftSidebarOpen)}
|
|
||||||
variant="outline"
|
|
||||||
size="icon"
|
|
||||||
className="absolute top-1/2 -translate-y-1/2 left-4 bg-white/90 hover:bg-white shadow-lg"
|
|
||||||
>
|
|
||||||
{isLeftSidebarOpen ? (
|
|
||||||
<ChevronLeft className="size-4" />
|
|
||||||
) : (
|
|
||||||
<ChevronRight className="size-4" />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
onClick={() => setIsSidebarOpen(!isSidebarOpen)}
|
|
||||||
variant="outline"
|
|
||||||
size="icon"
|
|
||||||
className="absolute top-1/2 -translate-y-1/2 right-4 bg-white/90 hover:bg-white shadow-lg"
|
|
||||||
>
|
|
||||||
{isSidebarOpen ? (
|
|
||||||
<ChevronRight className="size-4" />
|
|
||||||
) : (
|
|
||||||
<ChevronLeft className="size-4" />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<TabFeatures />
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<RightSlidebar isSidebarOpen={isSidebarOpen} />
|
|
||||||
|
|
||||||
{/* Hidden Canvas for Capture */}
|
|
||||||
<canvas ref={canvasRef} className="hidden" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,49 +0,0 @@
|
||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
||||||
// src/stores/useUserStore.ts
|
|
||||||
import type { FaceZoomTransform } from "@/lib/use-face-zoom";
|
|
||||||
import { create } from "zustand";
|
|
||||||
|
|
||||||
type AppState = {
|
|
||||||
isAutoChecking: boolean;
|
|
||||||
isCountDown: boolean;
|
|
||||||
refreshLog: boolean;
|
|
||||||
captureRegisterImage: any;
|
|
||||||
videoRef: any;
|
|
||||||
canvasRef: any;
|
|
||||||
faceZoomTransform: FaceZoomTransform | null;
|
|
||||||
/** Incrementing trigger — bumped when the stable-face timer fires. */
|
|
||||||
autoCheckinTick: number;
|
|
||||||
|
|
||||||
// actions
|
|
||||||
setIsAutoChecking: (data: boolean) => void;
|
|
||||||
setIsCountDown: (data: boolean) => void;
|
|
||||||
setRefreshLog: (data: boolean) => void;
|
|
||||||
setVideoRef: (data: any) => void;
|
|
||||||
setCanvasRef: (data: any) => void;
|
|
||||||
setCaptureRegisterImage: (data: any) => void;
|
|
||||||
setFaceZoomTransform: (data: FaceZoomTransform | null) => void;
|
|
||||||
bumpAutoCheckinTick: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
const useAppStore = create<AppState>((set) => ({
|
|
||||||
isAutoChecking: false,
|
|
||||||
isCountDown: false,
|
|
||||||
captureRegisterImage: null,
|
|
||||||
canvasRef: null,
|
|
||||||
videoRef: null,
|
|
||||||
refreshLog: false,
|
|
||||||
faceZoomTransform: null,
|
|
||||||
autoCheckinTick: 0,
|
|
||||||
|
|
||||||
setIsAutoChecking: (data) => set({ isAutoChecking: data }),
|
|
||||||
setRefreshLog: (data) => set({ refreshLog: data }),
|
|
||||||
setIsCountDown: (data) => set({ isCountDown: data }),
|
|
||||||
setCaptureRegisterImage: (data) => set({ captureRegisterImage: data }),
|
|
||||||
setVideoRef: (data) => set({ videoRef: data }),
|
|
||||||
setCanvasRef: (data) => set({ canvasRef: data }),
|
|
||||||
setFaceZoomTransform: (data) => set({ faceZoomTransform: data }),
|
|
||||||
bumpAutoCheckinTick: () =>
|
|
||||||
set((s) => ({ autoCheckinTick: s.autoCheckinTick + 1 })),
|
|
||||||
}));
|
|
||||||
|
|
||||||
export default useAppStore;
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
// src/stores/useUserStore.ts
|
|
||||||
import { create } from "zustand";
|
|
||||||
|
|
||||||
type UserState = {
|
|
||||||
currentUser: IUser | null;
|
|
||||||
|
|
||||||
// actions
|
|
||||||
setCurrentUser: (data: IUser | null) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
const useUserStore = create<UserState>((set) => ({
|
|
||||||
currentUser: null,
|
|
||||||
setCurrentUser: (data) => set(() => ({ currentUser: data })),
|
|
||||||
}));
|
|
||||||
|
|
||||||
export default useUserStore;
|
|
||||||
|
|
@ -1,32 +0,0 @@
|
||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
|
||||||
"target": "ES2022",
|
|
||||||
"useDefineForClassFields": true,
|
|
||||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
|
||||||
"module": "ESNext",
|
|
||||||
"types": ["vite/client"],
|
|
||||||
"skipLibCheck": true,
|
|
||||||
|
|
||||||
/* Bundler mode */
|
|
||||||
"moduleResolution": "bundler",
|
|
||||||
"allowImportingTsExtensions": true,
|
|
||||||
"verbatimModuleSyntax": true,
|
|
||||||
"moduleDetection": "force",
|
|
||||||
"noEmit": true,
|
|
||||||
"jsx": "react-jsx",
|
|
||||||
|
|
||||||
/* Linting */
|
|
||||||
"strict": true,
|
|
||||||
"noUnusedLocals": true,
|
|
||||||
"noUnusedParameters": true,
|
|
||||||
"erasableSyntaxOnly": true,
|
|
||||||
"noFallthroughCasesInSwitch": true,
|
|
||||||
"noUncheckedSideEffectImports": true,
|
|
||||||
"baseUrl": ".",
|
|
||||||
"paths": {
|
|
||||||
"@/*": ["./src/*"]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"include": ["src"]
|
|
||||||
}
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
{
|
|
||||||
"files": [],
|
|
||||||
"references": [
|
|
||||||
{ "path": "./tsconfig.app.json" },
|
|
||||||
{ "path": "./tsconfig.node.json" }
|
|
||||||
],
|
|
||||||
"compilerOptions": {
|
|
||||||
"baseUrl": ".",
|
|
||||||
"paths": {
|
|
||||||
"@/*": ["./src/*"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
|
||||||
"target": "ES2023",
|
|
||||||
"lib": ["ES2023"],
|
|
||||||
"module": "ESNext",
|
|
||||||
"types": ["node"],
|
|
||||||
"skipLibCheck": true,
|
|
||||||
|
|
||||||
/* Bundler mode */
|
|
||||||
"moduleResolution": "bundler",
|
|
||||||
"allowImportingTsExtensions": true,
|
|
||||||
"verbatimModuleSyntax": true,
|
|
||||||
"moduleDetection": "force",
|
|
||||||
"noEmit": true,
|
|
||||||
|
|
||||||
/* Linting */
|
|
||||||
"strict": true,
|
|
||||||
"noUnusedLocals": true,
|
|
||||||
"noUnusedParameters": true,
|
|
||||||
"erasableSyntaxOnly": true,
|
|
||||||
"noFallthroughCasesInSwitch": true,
|
|
||||||
"noUncheckedSideEffectImports": true
|
|
||||||
},
|
|
||||||
"include": ["vite.config.ts"]
|
|
||||||
}
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
import { defineConfig } from "vite";
|
|
||||||
import react from "@vitejs/plugin-react";
|
|
||||||
import path from "path";
|
|
||||||
import tailwindcss from "@tailwindcss/vite";
|
|
||||||
// https://vite.dev/config/
|
|
||||||
export default defineConfig({
|
|
||||||
plugins: [
|
|
||||||
react({
|
|
||||||
babel: {
|
|
||||||
plugins: [["babel-plugin-react-compiler"]],
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
tailwindcss(),
|
|
||||||
],
|
|
||||||
resolve: {
|
|
||||||
alias: {
|
|
||||||
"@": path.resolve(__dirname, "./src"),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
from sqlalchemy import create_engine
|
|
||||||
from sqlalchemy.orm import sessionmaker, declarative_base
|
|
||||||
|
|
||||||
DATABASE_URL = "mysql+pymysql://admin:Work1234%^@localhost/face_checkin?charset=utf8mb4"
|
|
||||||
|
|
||||||
engine = create_engine(DATABASE_URL)
|
|
||||||
SessionLocal = sessionmaker(bind=engine, autoflush=False)
|
|
||||||
Base = declarative_base()
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
nohup: ignoring input
|
|
||||||
INFO: Started server process [32945]
|
|
||||||
INFO: Waiting for application startup.
|
|
||||||
INFO: Application startup complete.
|
|
||||||
INFO: Uvicorn running on http://172.16.6.38:8080 (Press CTRL+C to quit)
|
|
||||||
INFO: 172.16.6.38:57742 - "GET / HTTP/1.1" 200 OK
|
|
||||||
INFO: 172.16.6.38:57748 - "GET /static/assets/index-CDZdzCu6.css HTTP/1.1" 304 Not Modified
|
|
||||||
INFO: 172.16.6.38:57764 - "GET /static/assets/index-DW_Nku2j.js HTTP/1.1" 304 Not Modified
|
|
||||||
INFO: 172.16.6.38:57790 - "GET /logs HTTP/1.1" 200 OK
|
|
||||||
INFO: 172.16.6.38:57776 - "GET /users HTTP/1.1" 200 OK
|
|
||||||
|
|
@ -1,483 +0,0 @@
|
||||||
from fastapi import FastAPI, UploadFile, File, Form, Depends, HTTPException, BackgroundTasks
|
|
||||||
from fastapi.responses import JSONResponse
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
import face_recognition
|
|
||||||
import numpy as np
|
|
||||||
import os
|
|
||||||
import datetime
|
|
||||||
import threading
|
|
||||||
import logging
|
|
||||||
import cv2
|
|
||||||
from fastapi.staticfiles import StaticFiles
|
|
||||||
from fastapi.responses import FileResponse
|
|
||||||
from database import SessionLocal, engine
|
|
||||||
from models import Base, Student, CheckInLog, StudentEncoding
|
|
||||||
from sqlalchemy.exc import IntegrityError
|
|
||||||
from sqlalchemy import text
|
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
|
||||||
from api import create_history, send_image
|
|
||||||
|
|
||||||
logging.basicConfig(level=logging.INFO)
|
|
||||||
|
|
||||||
# --- Encoding cache (Phương án 1: RAM cache) ---
|
|
||||||
_enc_matrix: np.ndarray | None = None # shape (N, 128)
|
|
||||||
_enc_student_ids: np.ndarray | None = None # shape (N,) int64
|
|
||||||
_enc_student_names: dict = {}
|
|
||||||
_cache_lock = threading.Lock()
|
|
||||||
_cache_dirty = True
|
|
||||||
|
|
||||||
|
|
||||||
def invalidate_encoding_cache():
|
|
||||||
global _cache_dirty
|
|
||||||
_cache_dirty = True
|
|
||||||
|
|
||||||
|
|
||||||
def _load_encoding_cache(db):
|
|
||||||
global _enc_matrix, _enc_student_ids, _enc_student_names, _cache_dirty
|
|
||||||
with _cache_lock:
|
|
||||||
if not _cache_dirty and _enc_matrix is not None:
|
|
||||||
return _enc_matrix, _enc_student_ids, _enc_student_names
|
|
||||||
|
|
||||||
rows = db.execute(
|
|
||||||
text("""
|
|
||||||
SELECT s.id AS student_id, s.name AS student_name, se.encoding AS encoding_blob
|
|
||||||
FROM student_encodings se
|
|
||||||
JOIN students s ON s.id = se.student_id
|
|
||||||
""")
|
|
||||||
).fetchall()
|
|
||||||
|
|
||||||
encodings, student_ids, names = [], [], {}
|
|
||||||
for r in rows:
|
|
||||||
try:
|
|
||||||
enc = np.frombuffer(r.encoding_blob, dtype=np.float64)
|
|
||||||
if enc.size == 128:
|
|
||||||
encodings.append(enc)
|
|
||||||
student_ids.append(r.student_id)
|
|
||||||
names[r.student_id] = r.student_name
|
|
||||||
else:
|
|
||||||
logging.warning(f"encoding size invalid for student {r.student_id}: {enc.size}")
|
|
||||||
except Exception as e:
|
|
||||||
logging.exception(f"Error decoding encoding for student {r.student_id}: {e}")
|
|
||||||
|
|
||||||
if encodings:
|
|
||||||
_enc_matrix = np.vstack(encodings)
|
|
||||||
_enc_student_ids = np.array(student_ids, dtype=np.int64)
|
|
||||||
else:
|
|
||||||
_enc_matrix = np.empty((0, 128), dtype=np.float64)
|
|
||||||
_enc_student_ids = np.array([], dtype=np.int64)
|
|
||||||
|
|
||||||
_enc_student_names = names
|
|
||||||
_cache_dirty = False
|
|
||||||
logging.info(f"Encoding cache loaded: {_enc_matrix.shape[0]} encodings, {len(names)} students")
|
|
||||||
return _enc_matrix, _enc_student_ids, _enc_student_names
|
|
||||||
|
|
||||||
|
|
||||||
# --- Image preprocessing (Phương án 3: resize trước khi detect) ---
|
|
||||||
def _preprocess_image(image_data: bytes, max_width: int = 640) -> np.ndarray:
|
|
||||||
nparr = np.frombuffer(image_data, np.uint8)
|
|
||||||
img = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
|
|
||||||
h, w = img.shape[:2]
|
|
||||||
if w > max_width:
|
|
||||||
scale = max_width / w
|
|
||||||
img = cv2.resize(img, (int(w * scale), int(h * scale)), interpolation=cv2.INTER_AREA)
|
|
||||||
return cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
|
|
||||||
|
|
||||||
app = FastAPI()
|
|
||||||
|
|
||||||
# --- CORS ---
|
|
||||||
app.add_middleware(
|
|
||||||
CORSMiddleware,
|
|
||||||
allow_origins=["*"],
|
|
||||||
allow_credentials=True,
|
|
||||||
allow_methods=["*"],
|
|
||||||
allow_headers=["*"],
|
|
||||||
)
|
|
||||||
|
|
||||||
Base.metadata.create_all(bind=engine)
|
|
||||||
|
|
||||||
UPLOAD_DIR = "./uploads"
|
|
||||||
os.makedirs(UPLOAD_DIR, exist_ok=True)
|
|
||||||
|
|
||||||
def get_db():
|
|
||||||
db = SessionLocal()
|
|
||||||
try:
|
|
||||||
yield db
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
app.mount("/static", StaticFiles(directory="static"), name="static")
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/")
|
|
||||||
def root():
|
|
||||||
return FileResponse("static/index.html")
|
|
||||||
|
|
||||||
@app.post("/register")
|
|
||||||
async def register_face(
|
|
||||||
name: str = Form(...),
|
|
||||||
email: str = Form(...),
|
|
||||||
avatar: str = Form(None), # OPTIONAL
|
|
||||||
file: UploadFile = File(...)
|
|
||||||
):
|
|
||||||
db = SessionLocal()
|
|
||||||
|
|
||||||
# Check duplicate email
|
|
||||||
existing_email = db.execute(
|
|
||||||
text("SELECT id FROM students WHERE email = :email"),
|
|
||||||
{"email": email}
|
|
||||||
).fetchone()
|
|
||||||
|
|
||||||
# Save image
|
|
||||||
image_data = await file.read()
|
|
||||||
image_path = f"./uploads/{file.filename}"
|
|
||||||
with open(image_path, "wb") as f:
|
|
||||||
f.write(image_data)
|
|
||||||
|
|
||||||
# Encode face — dùng _preprocess_image để tránh load lại từ disk
|
|
||||||
image = _preprocess_image(image_data)
|
|
||||||
encodings = face_recognition.face_encodings(image)
|
|
||||||
|
|
||||||
if not encodings:
|
|
||||||
db.close()
|
|
||||||
return JSONResponse(
|
|
||||||
content={"message": "Không phát hiện khuôn mặt."},
|
|
||||||
status_code=400
|
|
||||||
)
|
|
||||||
|
|
||||||
encoding_bytes = encodings[0].tobytes()
|
|
||||||
|
|
||||||
try:
|
|
||||||
if existing_email:
|
|
||||||
# Email exists → just add new encoding
|
|
||||||
student_id = existing_email[0]
|
|
||||||
db.execute(
|
|
||||||
text("""
|
|
||||||
INSERT INTO student_encodings (student_id, encoding)
|
|
||||||
VALUES (:student_id, :encoding)
|
|
||||||
"""),
|
|
||||||
{"student_id": student_id, "encoding": encoding_bytes}
|
|
||||||
)
|
|
||||||
db.commit()
|
|
||||||
invalidate_encoding_cache()
|
|
||||||
return {"message": "Đã thêm encoding mới."}
|
|
||||||
|
|
||||||
else:
|
|
||||||
# Insert new student (avatar nullable)
|
|
||||||
db.execute(
|
|
||||||
text("""
|
|
||||||
INSERT INTO students (name, email, avatar)
|
|
||||||
VALUES (:name, :email, :avatar)
|
|
||||||
"""),
|
|
||||||
{
|
|
||||||
"name": name,
|
|
||||||
"email": email,
|
|
||||||
"avatar": avatar,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
student_id = db.execute(text("SELECT LAST_INSERT_ID()")).fetchone()[0]
|
|
||||||
|
|
||||||
# Insert encoding
|
|
||||||
db.execute(
|
|
||||||
text("""
|
|
||||||
INSERT INTO student_encodings (student_id, encoding)
|
|
||||||
VALUES (:student_id, :encoding)
|
|
||||||
"""),
|
|
||||||
{"student_id": student_id, "encoding": encoding_bytes}
|
|
||||||
)
|
|
||||||
db.commit()
|
|
||||||
invalidate_encoding_cache()
|
|
||||||
return {"message": "Đăng ký thành công."}
|
|
||||||
|
|
||||||
except IntegrityError:
|
|
||||||
db.rollback()
|
|
||||||
raise HTTPException(status_code=400, detail="Email đã tồn tại.")
|
|
||||||
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/register-simple")
|
|
||||||
async def register_student(
|
|
||||||
name: str = Form(...),
|
|
||||||
email: str = Form(...),
|
|
||||||
avatar: str = Form(None), # OPTIONAL
|
|
||||||
):
|
|
||||||
db = SessionLocal()
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Kiểm tra xem student đã tồn tại chưa
|
|
||||||
existing = db.execute(
|
|
||||||
text("SELECT id FROM students WHERE email = :email"),
|
|
||||||
{"email": email}
|
|
||||||
).fetchone()
|
|
||||||
|
|
||||||
if existing:
|
|
||||||
# UPDATE
|
|
||||||
db.execute(
|
|
||||||
text("""
|
|
||||||
UPDATE students
|
|
||||||
SET name = :name,
|
|
||||||
avatar = :avatar
|
|
||||||
WHERE email = :email
|
|
||||||
"""),
|
|
||||||
{
|
|
||||||
"name": name,
|
|
||||||
"avatar": avatar,
|
|
||||||
"email": email
|
|
||||||
}
|
|
||||||
)
|
|
||||||
db.commit()
|
|
||||||
return JSONResponse({"message": "Cập nhật thành công."}, status_code=200)
|
|
||||||
|
|
||||||
else:
|
|
||||||
# INSERT
|
|
||||||
db.execute(
|
|
||||||
text("""
|
|
||||||
INSERT INTO students (name, email, avatar)
|
|
||||||
VALUES (:name, :email, :avatar)
|
|
||||||
"""),
|
|
||||||
{
|
|
||||||
"name": name,
|
|
||||||
"email": email,
|
|
||||||
"avatar": avatar
|
|
||||||
}
|
|
||||||
)
|
|
||||||
db.commit()
|
|
||||||
return JSONResponse({"message": "Đăng ký thành công."}, status_code=201)
|
|
||||||
|
|
||||||
except IntegrityError:
|
|
||||||
db.rollback()
|
|
||||||
return JSONResponse(
|
|
||||||
{"message": "Lỗi cơ sở dữ liệu."},
|
|
||||||
status_code=400
|
|
||||||
)
|
|
||||||
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/checkin")
|
|
||||||
async def checkin(background_tasks: BackgroundTasks, file: UploadFile = File(...), camera_id: str = Form("cam1"), db: Session = Depends(get_db)):
|
|
||||||
image_data = await file.read()
|
|
||||||
|
|
||||||
# Phương án 3: resize ảnh trước khi detect — bỏ disk I/O
|
|
||||||
unknown_img = _preprocess_image(image_data)
|
|
||||||
unknown_encodings = face_recognition.face_encodings(unknown_img)
|
|
||||||
if not unknown_encodings:
|
|
||||||
return {"message": "No face detected.", "status": False}
|
|
||||||
|
|
||||||
unknown_encoding = unknown_encodings[0]
|
|
||||||
|
|
||||||
# TÙY CHỈNH: threshold nhỏ hơn → ít nhầm lẫn, nhưng dễ false negative.
|
|
||||||
# Thử: 0.4 (chặt), 0.45 (cân bằng), 0.55 (lỏng)
|
|
||||||
DIST_THRESHOLD = 0.42
|
|
||||||
|
|
||||||
# Phương án 1: dùng cache RAM thay vì query DB mỗi request
|
|
||||||
enc_matrix, enc_sids, enc_names = _load_encoding_cache(db)
|
|
||||||
|
|
||||||
if enc_matrix.shape[0] == 0:
|
|
||||||
return {"message": "No known encodings in DB.", "status": False}
|
|
||||||
|
|
||||||
# Phương án 2: vectorized — tính tất cả distances 1 lần qua BLAS
|
|
||||||
all_dists = face_recognition.face_distance(enc_matrix, unknown_encoding)
|
|
||||||
|
|
||||||
# Tìm min distance theo từng student
|
|
||||||
best_student = None
|
|
||||||
best_distance = float("inf")
|
|
||||||
second_best_distance = float("inf")
|
|
||||||
|
|
||||||
for sid in np.unique(enc_sids):
|
|
||||||
mask = enc_sids == sid
|
|
||||||
min_dist = float(np.min(all_dists[mask]))
|
|
||||||
logging.info(f"Student {sid} ({enc_names.get(sid)}) min_dist = {min_dist:.4f}")
|
|
||||||
|
|
||||||
if min_dist < best_distance:
|
|
||||||
second_best_distance = best_distance
|
|
||||||
best_distance = min_dist
|
|
||||||
best_student = int(sid)
|
|
||||||
elif min_dist < second_best_distance:
|
|
||||||
second_best_distance = min_dist
|
|
||||||
|
|
||||||
# Debug log best/second distances
|
|
||||||
logging.info(f"Best student {best_student} dist={best_distance:.4f}, second_best={second_best_distance:.4f}")
|
|
||||||
|
|
||||||
# Ratio check: nếu best much better than second best => more confident
|
|
||||||
ratio_ok = True
|
|
||||||
if second_best_distance < float("inf"):
|
|
||||||
ratio = best_distance / (second_best_distance + 1e-8)
|
|
||||||
logging.info(f"Distance ratio (best/second) = {ratio:.4f}")
|
|
||||||
# Nếu ratio quá gần 1 (ví dụ > 0.85) => không đủ phân biệt
|
|
||||||
if ratio > 0.85:
|
|
||||||
ratio_ok = False
|
|
||||||
|
|
||||||
# Quyết định match nếu best_distance nhỏ hơn threshold và ratio ok
|
|
||||||
if best_distance <= DIST_THRESHOLD and ratio_ok and best_student is not None:
|
|
||||||
# kiểm tra recent check (nửa phút trước)
|
|
||||||
now = datetime.datetime.now()
|
|
||||||
recent_check = db.execute(
|
|
||||||
text("""
|
|
||||||
SELECT id FROM checkin_logs
|
|
||||||
WHERE student_id = :student_id
|
|
||||||
AND time > :time_threshold
|
|
||||||
"""),
|
|
||||||
{
|
|
||||||
"student_id": best_student,
|
|
||||||
"time_threshold": now - datetime.timedelta(minutes=0.5)
|
|
||||||
}
|
|
||||||
).fetchone()
|
|
||||||
|
|
||||||
if recent_check:
|
|
||||||
return {"message": f"{enc_names.get(best_student)} already checked in recently.", "status": True}
|
|
||||||
|
|
||||||
last_log = db.execute(
|
|
||||||
text("""
|
|
||||||
SELECT status FROM checkin_logs
|
|
||||||
WHERE student_id = :student_id
|
|
||||||
ORDER BY time DESC LIMIT 1
|
|
||||||
"""),
|
|
||||||
{"student_id": best_student}
|
|
||||||
).fetchone()
|
|
||||||
status = "check out" if last_log and last_log.status == "check in" else "check in"
|
|
||||||
|
|
||||||
insert_result = db.execute(
|
|
||||||
text("""
|
|
||||||
INSERT INTO checkin_logs (student_id, time, camera_id, status)
|
|
||||||
VALUES (:student_id, :time, :camera_id, :status)
|
|
||||||
"""),
|
|
||||||
{
|
|
||||||
"student_id": best_student,
|
|
||||||
"time": now,
|
|
||||||
"camera_id": camera_id,
|
|
||||||
"status": status
|
|
||||||
}
|
|
||||||
)
|
|
||||||
log_id = insert_result.lastrowid
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
def _sync_to_ms(name: str, time_string: str, img_data: bytes, local_status: str, checkin_log_id: int):
|
|
||||||
try:
|
|
||||||
ms_response = create_history({"name": name.split('\n')[0], "time_string": time_string, "status": local_status})
|
|
||||||
id_log = ms_response.get('data', {}).get('id', 0)
|
|
||||||
ms_status = ms_response.get('data', {}).get('status', local_status)
|
|
||||||
if ms_status != local_status:
|
|
||||||
fix_db = SessionLocal()
|
|
||||||
try:
|
|
||||||
fix_db.execute(
|
|
||||||
text("UPDATE checkin_logs SET status = :status WHERE id = :id"),
|
|
||||||
{"status": ms_status, "id": checkin_log_id}
|
|
||||||
)
|
|
||||||
fix_db.commit()
|
|
||||||
logging.info(f"Corrected log #{checkin_log_id} status: {local_status} → {ms_status}")
|
|
||||||
finally:
|
|
||||||
fix_db.close()
|
|
||||||
send_image(id_log, img_data, name, ms_status)
|
|
||||||
except Exception as e:
|
|
||||||
logging.error(f"MS sync error: {e}")
|
|
||||||
|
|
||||||
background_tasks.add_task(
|
|
||||||
_sync_to_ms,
|
|
||||||
enc_names.get(best_student),
|
|
||||||
f"{datetime.datetime.now()}",
|
|
||||||
image_data,
|
|
||||||
status,
|
|
||||||
log_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
student = db.execute(
|
|
||||||
text("""
|
|
||||||
SELECT id, name, email
|
|
||||||
FROM students
|
|
||||||
WHERE id = :id
|
|
||||||
"""),
|
|
||||||
{"id": best_student}
|
|
||||||
).fetchone()
|
|
||||||
|
|
||||||
user_data = {
|
|
||||||
"id": student.id,
|
|
||||||
"name": student.name,
|
|
||||||
"email": student.email,
|
|
||||||
} if student else None
|
|
||||||
|
|
||||||
return {"message": f"{status} successful for {enc_names.get(best_student)} (dist={best_distance:.4f})", "status": True, "status_type": status, "data": user_data}
|
|
||||||
|
|
||||||
# Nếu không thỏa threshold/rule thì trả no match (và log lý do)
|
|
||||||
reasons = []
|
|
||||||
if best_distance > DIST_THRESHOLD:
|
|
||||||
reasons.append(f"best_distance({best_distance:.4f}) > threshold({DIST_THRESHOLD})")
|
|
||||||
if not ratio_ok:
|
|
||||||
reasons.append(f"ratio not confident ({best_distance:.4f}/{second_best_distance:.4f})")
|
|
||||||
|
|
||||||
logging.info("No confident match: " + "; ".join(reasons))
|
|
||||||
return {"message": "No match found.", "reasons": reasons, "status": False}
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/logs")
|
|
||||||
def get_logs(db: Session = Depends(get_db)):
|
|
||||||
logs = db.execute(
|
|
||||||
text("""
|
|
||||||
SELECT s.name, cl.time, cl.camera_id, cl.status
|
|
||||||
FROM checkin_logs cl
|
|
||||||
JOIN students s ON cl.student_id = s.id
|
|
||||||
ORDER BY cl.time DESC
|
|
||||||
LIMIT 20
|
|
||||||
""")
|
|
||||||
).fetchall()
|
|
||||||
|
|
||||||
result = []
|
|
||||||
for log in logs:
|
|
||||||
result.append({
|
|
||||||
"name": log.name,
|
|
||||||
"time": log.time.strftime("%Y-%m-%d %H:%M:%S"),
|
|
||||||
"camera_id": log.camera_id,
|
|
||||||
"status": log.status
|
|
||||||
})
|
|
||||||
return result
|
|
||||||
|
|
||||||
@app.get("/users")
|
|
||||||
def get_users(db: Session = Depends(get_db)):
|
|
||||||
# Lấy danh sách student
|
|
||||||
students = db.execute(
|
|
||||||
text("""
|
|
||||||
SELECT id, name, email, avatar
|
|
||||||
FROM students
|
|
||||||
ORDER BY name DESC
|
|
||||||
""")
|
|
||||||
).fetchall()
|
|
||||||
|
|
||||||
result = []
|
|
||||||
|
|
||||||
for stu in students:
|
|
||||||
student_id = stu.id
|
|
||||||
|
|
||||||
# Lấy tối đa 5 checkpoint mới nhất
|
|
||||||
checkpoints = db.execute(
|
|
||||||
text("""
|
|
||||||
SELECT id, time, camera_id
|
|
||||||
FROM checkin_logs
|
|
||||||
WHERE student_id = :sid
|
|
||||||
ORDER BY time DESC
|
|
||||||
LIMIT 5
|
|
||||||
"""),
|
|
||||||
{"sid": student_id}
|
|
||||||
).fetchall()
|
|
||||||
|
|
||||||
result.append({
|
|
||||||
"id": stu.id,
|
|
||||||
"name": stu.name,
|
|
||||||
"email": stu.email,
|
|
||||||
"avatar": stu.avatar,
|
|
||||||
"checkpoints": [
|
|
||||||
{
|
|
||||||
"id": c.id,
|
|
||||||
"time": c.time,
|
|
||||||
"camera_id": c.camera_id
|
|
||||||
}
|
|
||||||
for c in checkpoints
|
|
||||||
]
|
|
||||||
})
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1,38 +0,0 @@
|
||||||
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, LargeBinary, UniqueConstraint
|
|
||||||
from sqlalchemy.orm import relationship
|
|
||||||
from database import Base
|
|
||||||
import datetime
|
|
||||||
|
|
||||||
class Student(Base):
|
|
||||||
__tablename__ = "students"
|
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
|
||||||
name = Column(String(100), nullable=False)
|
|
||||||
email = Column(String(100), nullable=False, unique=True, index=True)
|
|
||||||
avatar = Column(String(500), nullable=True, index=True)
|
|
||||||
|
|
||||||
__table_args__ = (UniqueConstraint('email', name='uq_student_email'),)
|
|
||||||
|
|
||||||
checkins = relationship("CheckInLog", back_populates="student")
|
|
||||||
encodings = relationship("StudentEncoding", back_populates="student")
|
|
||||||
|
|
||||||
class StudentEncoding(Base):
|
|
||||||
__tablename__ = "student_encodings"
|
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
|
||||||
student_id = Column(Integer, ForeignKey("students.id"))
|
|
||||||
encoding = Column(LargeBinary, nullable=False)
|
|
||||||
created_at = Column(DateTime, default=datetime.datetime.utcnow)
|
|
||||||
|
|
||||||
student = relationship("Student", back_populates="encodings")
|
|
||||||
|
|
||||||
class CheckInLog(Base):
|
|
||||||
__tablename__ = "checkin_logs"
|
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
|
||||||
student_id = Column(Integer, ForeignKey("students.id"))
|
|
||||||
time = Column(DateTime, default=datetime.datetime.utcnow)
|
|
||||||
status = Column(String(100), nullable=True)
|
|
||||||
camera_id = Column(String(100))
|
|
||||||
|
|
||||||
student = relationship("Student", back_populates="checkins")
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
{
|
|
||||||
"name": "TrackingToolWeb",
|
|
||||||
"lockfileVersion": 3,
|
|
||||||
"requires": true,
|
|
||||||
"packages": {}
|
|
||||||
}
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
fastapi
|
|
||||||
uvicorn
|
|
||||||
sqlalchemy
|
|
||||||
face_recognition
|
|
||||||
numpy
|
|
||||||
opencv-python
|
|
||||||
requests
|
|
||||||
pymysql
|
|
||||||
# pip install -r requirements.txt
|
|
||||||
# sudo apt-get install cmake or brew install cmake
|
|
||||||
# pip install dlib
|
|
||||||
# pip install setuptools
|
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue