Compare commits

...

73 Commits

Author SHA1 Message Date
Admin f0bed712e6 update vite build 2026-05-21 09:54:39 +07:00
Admin f1de8cb627 update build UI AU 2026-05-21 09:15:09 +07:00
Admin b50dc96ce2 update build UI AU 2026-05-20 16:49:59 +07:00
Admin 744c206743 update build UI 2026-05-20 15:30:39 +07:00
Joseph a23f2155dc update auto detect face 2026-05-20 15:23:31 +07:00
zelda 25918fcb62 Merge pull request 'update UI tablet and mobile' (#167) from zelda.fix-ui-responsive-client into master
Reviewed-on: #167
2026-05-20 17:13:36 +10:00
Admin 607882343a update UI tablet and mobile 2026-05-20 14:11:05 +07:00
Admin 8cd93c578e update(ui) add responsive for tracking 2026-05-18 10:10:58 +07:00
Admin 755297e5b0 update(ui) add responsive for tracking 2026-05-18 10:06:59 +07:00
zelda a3ec46db0a Merge pull request 'update(ui) add responsive for tracking' (#165) from zelda.checkin-for-au into master
Reviewed-on: #165
2026-05-18 12:51:15 +10:00
Admin ab7ccbe194 update(ui) add responsive for tracking 2026-05-18 09:49:56 +07:00
Joseph Le 4eb3dda0e1 update readme for trackin AU 2026-05-15 16:15:56 +07:00
Joseph Le 1cd6b3a108 clear venv folder 2026-05-15 16:14:20 +07:00
zelda 935e88bb72 Merge pull request 'update(server): connect api to erp and add func remove user' (#164) from zelda.checkin-for-au into master
Reviewed-on: #164
2026-05-15 19:06:48 +10:00
Admin 2675a60ffc update(server): connect api to erp and add func remove user 2026-05-15 16:06:33 +07:00
zelda 95e4e833b5 Merge pull request 'update(server): connect api to erp and add func remove user' (#163) from zelda.checkin-for-au into master
Reviewed-on: #163
2026-05-15 18:45:34 +10:00
Admin 8ad0ba9c25 update(server): connect api to erp and add func remove user 2026-05-15 15:43:54 +07:00
zelda 574e92bd23 Merge pull request 'zelda.checkin-for-au' (#162) from zelda.checkin-for-au into master
Reviewed-on: #162
2026-05-15 18:04:49 +10:00
Admin 925823c525 update(server): connect api to erp and add func remove user 2026-05-15 15:03:50 +07:00
Admin a594ba5ba2 update(server): connect api to erp and add func remove user 2026-05-15 15:00:50 +07:00
zelda 683f85efd4 Merge pull request 'feat(au-checkin): upload checkin version for au' (#161) from zelda.checkin-for-au into master
Reviewed-on: #161
2026-05-15 14:07:10 +10:00
Admin 6a1e19ec7f feat(au-checkin): upload checkin version for au 2026-05-15 11:06:36 +07:00
Joseph Le 8e54ca0ceb edit 2026-05-14 14:54:23 +07:00
Your Name 0f2d5db8a3 feature(office-support): add equipment management and electricity bill attachment
Equipment management (new):
- Equipment CRUD with code, name, note, optional photo (max 10MB)
- Barcode scanner using @zxing/browser:
  - Defaults to back camera on mobile, autofocus + 2x zoom
  - Reads any orientation (rotates 12 angles per cycle for arbitrary tilt)
  - Manual "Capture & decode" fallback (18 angles)
  - Horizontal aiming line + haptic feedback on success
- Assign workflow with full history (user <-> warehouse, every state recorded)
- Statistics view: equipments grouped by current holder (Accordion + KPIs)
- Image lightbox: view original at any zoom, open-in-new-tab, pinch-zoom on mobile
- Client-side resize for >10MB uploads to fit PHP upload limit

Electricity bill:
- Attachment (PDF/image) upload, view, replace, remove

Routes, API endpoints, Mantine tab integration, and DB migrations included.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 14:36:42 +07:00
root 980676fe16 update leave day 2026-05-13 10:27:54 +07:00
zelda 9d536955e4 Merge pull request 'update(checkin tool web): update status when status from server and tool are wrong' (#160) from zelda.refactor-tracking-tool-web into master
Reviewed-on: #160
2026-05-13 13:26:41 +10:00
Admin a5fa0cbcda update(checkin tool web): update status when status from server and tool are wrong 2026-05-13 10:24:34 +07:00
zelda 0a4332729d Merge pull request 'update(tracking-tool-web): refactor to speed checkin' (#159) from zelda.refactor-tracking-tool-web into master
Reviewed-on: #159
2026-05-12 11:46:05 +10:00
Admin 2f1e26d3f6 update(tracking-tool-web): refactor to speed checkin 2026-05-12 08:43:12 +07:00
andrew.ng 3d1b43e200 Merge pull request 'that-bill' (#158) from that-bill into master
Reviewed-on: #158
2026-04-29 17:29:11 +10:00
nguyentrungthat 8d86fc2182 Update OfficeSupport.tsx 2026-04-29 14:28:56 +07:00
nguyentrungthat 54b8b9737b Merge branch 'master' into that-bill 2026-04-29 14:13:55 +07:00
andrew.ng 42c5791da8 Merge pull request 'Update' (#157) from that-bill into master
Reviewed-on: #157
2026-04-29 17:13:48 +10:00
nguyentrungthat 8d5199342d Update 2026-04-29 14:13:19 +07:00
andrew.ng beccf5796c Merge pull request 'Add electricity bills module (API, model, PDF)' (#156) from that-bill into master
Reviewed-on: #156
2026-04-29 16:49:41 +10:00
nguyentrungthat baa3216c69 Add electricity bills module (API, model, PDF)
Introduce an Electricity Bills feature: adds ElectricityBill model, controller with CRUD + PDF export, Blade PDF template, and a migration to create the electricity_bills table. Registers routes (with admin permission middleware) and persists generated PDFs to public storage. Adds ResultSuccess/ResultError helpers to base Controller and updates composer to include dompdf and DBAL dependencies. Also includes frontend updates (Admin API, DataTable, Navbar, new OfficeSupport page and styles, route and CRUD helper adjustments) to support the new functionality.
2026-04-29 13:44:34 +07:00
joseph de351f0877 Merge pull request 'fix(setting): update hr permission' (#155) from vi into master
Reviewed-on: #155
2026-04-18 11:20:57 +10:00
Hoang Vi 2c94e91668 fix(setting): update hr permission 2026-04-18 07:52:39 +07:00
joseph b22c9b9cc5 Merge pull request 'feat(setting): add setting saturday work' (#154) from vi into master
Reviewed-on: #154
2026-04-09 19:24:31 +10:00
Hoang Vi f494367683 feat(setting): add setting saturday work 2026-04-09 16:11:18 +07:00
joseph 61124cd9bb Merge pull request 'fix run cron job initialize leaveDays' (#153) from vi into master
Reviewed-on: #153
2026-01-06 20:16:40 +11:00
Nguyễn Hoàng Vĩ 78066f624e fix run cron job initialize leaveDays 2026-01-06 16:02:24 +07:00
Nguyễn Hoàng Vĩ d7b7a61511 Merge branch 'master' of https://gitea.nswteam.net/joseph/ManagementSystem into vi 2026-01-06 15:59:54 +07:00
Truong Vo 8f30081e55 fix ngày phép 2026-01-05 08:20:01 +07:00
Truong Vo a0bfb19a34 Bổ sung + thêm phép đặc biệt khi chuyển phép từ năm cũ 2026-01-02 08:18:29 +07:00
Truong Vo 7e369785d5 fix 2025-12-30 10:08:18 +07:00
Nguyễn Hoàng Vĩ 54046ac676 Merge branch 'master' of https://gitea.nswteam.net/joseph/ManagementSystem into vi 2025-12-23 14:58:56 +07:00
zelda 9f921750fc Merge pull request 'fix(noti): Fix notification' (#152) from zelda.update-checkin-api into master
Reviewed-on: #152
2025-12-23 11:43:55 +11:00
Admin bd0d4fa13b fix(noti): Fix notification 2025-12-23 07:43:27 +07:00
Nguyễn Hoàng Vĩ f5362e54ef Merge branch 'master' of https://gitea.nswteam.net/joseph/ManagementSystem into vi 2025-12-22 08:20:52 +07:00
zelda 17dc0c3374 Merge pull request 'update(fe): remove speak check in and check out' (#151) from zelda.update-checkin-api into master
Reviewed-on: #151
2025-12-19 11:39:13 +11:00
Admin 29dc787c65 update(fe): remove speak check in and check out 2025-12-19 07:37:59 +07:00
zelda 6ccf5e9e8a Merge pull request 'update(fe): speak name' (#150) from zelda.update-checkin-api into master
Reviewed-on: #150
2025-12-15 17:41:55 +11:00
Admin 5736166da3 update(fe): speak name 2025-12-15 13:41:19 +07:00
zelda 7be6a12edc Merge pull request 'update(fe): Move quantity variable to env' (#149) from zelda.update-checkin-api into master
Reviewed-on: #149
2025-12-12 14:39:41 +11:00
Admin 70436f737f update(fe): Move quantity variable to env 2025-12-12 10:38:39 +07:00
zelda 25162be83d Merge pull request 'update(ttw): update auto sync user' (#148) from zelda.update-checkin-api into master
Reviewed-on: #148
2025-12-12 14:17:33 +11:00
Admin 06027e8f23 update(ttw): update auto sync user 2025-12-12 10:16:23 +07:00
zelda 8c7a24d31c Merge pull request 'update(ttw): run send image in backgound' (#147) from zelda.update-checkin-api into master
Reviewed-on: #147
2025-12-11 18:56:14 +11:00
Admin 1f08e2b503 update(ttw): run send image in backgound 2025-12-11 14:55:09 +07:00
zelda 9d77dfc5d6 Merge pull request 'feat(tkw): update ui' (#146) from zelda.update-static-file-tracking-tool-web into master
Reviewed-on: #146
2025-12-11 14:34:48 +11:00
Admin e7aa80f14b feat(tkw): update ui 2025-12-11 10:34:26 +07:00
zelda 24bd605b7c Merge pull request 'feat(tkw): update file main models' (#145) from zelda.update-static-file-tracking-tool-web into master
Reviewed-on: #145
2025-12-11 14:04:53 +11:00
Admin 2a5f7b0b25 feat(tkw): update file main models 2025-12-11 10:04:29 +07:00
zelda 9183540976 Merge pull request 'feat(tkw): update file main code' (#144) from zelda.update-static-file-tracking-tool-web into master
Reviewed-on: #144
2025-12-11 13:35:33 +11:00
Admin cdce726ff1 feat(tkw): update file main code 2025-12-11 09:35:01 +07:00
zelda 790cab255d Merge pull request 'feat(tkw): Update static file' (#143) from zelda.update-static-file-tracking-tool-web into master
Reviewed-on: #143
2025-12-11 13:31:37 +11:00
Admin 647fc455d5 feat(tkw): Update static file 2025-12-11 09:30:39 +07:00
zelda b5f5fa1748 Merge pull request 'update client for server' (#142) from zelda.push-tracking-tool-web into master
Reviewed-on: #142
2025-12-11 13:11:33 +11:00
Admin 5a564e3703 update client for server 2025-12-11 09:10:42 +07:00
joseph 0af2357bc2 Merge pull request 'feat(tkw): Tracking tool web' (#141) from zelda.push-tracking-tool-web into master
Reviewed-on: #141
2025-12-10 20:29:24 +11:00
Admin a623659b52 feat(tkw): Tracking tool web 2025-12-10 16:23:45 +07:00
dbdbd9 487f4227d8 Merge branch 'dev' of https://gitea.nswteam.net/joseph/ManagementSystem into vi 2025-08-05 16:10:32 +07:00
176 changed files with 30698 additions and 4806 deletions

BIN
.DS_Store vendored Normal file

Binary file not shown.

View File

@ -3,6 +3,7 @@
namespace Modules\Admin\app\Http\Controllers;
use App\Http\Controllers\Controller;
use Carbon\Carbon;
use Illuminate\Http\Request;
use Modules\Admin\app\Models\Category;
@ -29,4 +30,45 @@ class CategoryController extends Controller
$data = Category::where('c_type', '=', $type)->where('c_active', '=', 1)->select('id', 'c_code', 'c_name', 'c_value', 'c_type')->get();
return $data;
}
public function workDays()
{
$saturday_work_schedules = Category::where('c_type', 'SATURDAY_WORK_SCHEDULE')
->get();
return response()->json([
'data' => $saturday_work_schedules,
'status' => true
]);
}
public function updateWorkDays(Request $request)
{
$request->validate([
'c_code' => 'required|date_format:d-m-Y',
]);
$schedule = Category::where('c_type', 'SATURDAY_WORK_SCHEDULE')->first();
if (!$schedule) {
$schedule = Category::create([
'c_type' => 'SATURDAY_WORK_SCHEDULE',
'c_name' => "Ngày bắt đầu làm việc thứ 7 trong năm",
'c_code' => $request->c_code,
'c_value' => Carbon::now()->year,
'c_active' => true,
'created_at' => now(),
'updated_at' => now(),
]);
} else {
$schedule->update([
'c_code' => $request->c_code,
]);
}
return response()->json([
'status' => true,
'message' => 'Saturday work schedule updated successfully'
]);
}
}

View File

@ -0,0 +1,359 @@
<?php
namespace Modules\Admin\app\Http\Controllers;
use App\Http\Controllers\Controller;
use App\Traits\HasFilterRequest;
use App\Traits\HasOrderByRequest;
use App\Traits\HasSearchRequest;
use Illuminate\Http\Request;
use Modules\Admin\app\Models\ElectricityBill;
use Illuminate\Support\Facades\Log;
use Barryvdh\DomPDF\Facade\Pdf;
use Carbon\Carbon;
use Illuminate\Support\Facades\Storage;
class ElectricityBillController extends Controller
{
use HasOrderByRequest;
use HasFilterRequest;
use HasSearchRequest;
/**
* Get all electricity bills with pagination
*/
public function index(Request $request)
{
try {
$bills = new ElectricityBill;
// Order by
$this->orderByRequest($bills, $request);
// Filter
$this->filterRequest(
builder: $bills,
request: $request,
filterKeys: [
'billing_date' => self::F_TEXT,
]
);
// Search
$this->searchRequest(
builder: $bills,
value: $request->get('search'),
fields: ['billing_date', 'notes']
);
$responseData = $bills
->leftJoin('users as creator', 'electricity_bills.created_by', '=', 'creator.id')
->leftJoin('users as updater', 'electricity_bills.updated_by', '=', 'updater.id')
->orderBy('electricity_bills.billing_date', 'desc')
->select(
'electricity_bills.*',
'creator.name as creator_name',
'updater.name as updater_name'
)
->paginate($request->get('per_page', 15));
return AbstractController::ResultSuccess($responseData);
} catch (\Exception $e) {
Log::error('Error fetching electricity bills: ' . $e->getMessage());
return AbstractController::ResultError($e->getMessage());
}
}
/**
* Create new electricity bill
*/
public function create(Request $request)
{
try {
$validated = $request->validate([
'billing_date' => 'required|string',
'previous_reading' => 'required|numeric|min:0',
'current_reading' => 'required|numeric|min:0',
'unit_price' => 'required|numeric|min:0',
'notes' => 'nullable|string',
]);
// Check if billing_date already exists
$existingBill = ElectricityBill::where('billing_date', $validated['billing_date'])->first();
if ($existingBill) {
return AbstractController::ResultError('Bill for this month already exists', 422);
}
// Calculate total amount
$consumption = $validated['current_reading'] - $validated['previous_reading'];
$totalAmount = $consumption * $validated['unit_price'];
$bill = ElectricityBill::create([
'billing_date' => $validated['billing_date'],
'previous_reading' => $validated['previous_reading'],
'current_reading' => $validated['current_reading'],
'unit_price' => $validated['unit_price'],
'total_amount' => $totalAmount,
'notes' => $validated['notes'] ?? null,
'created_by' => auth('admins')->user()->id ?? null,
]);
return AbstractController::ResultSuccess($bill, 'Electricity bill created successfully');
} catch (\Exception $e) {
Log::error('Error creating electricity bill: ' . $e->getMessage());
return AbstractController::ResultError($e->getMessage());
}
}
/**
* Update electricity bill
*/
public function update(Request $request, $id)
{
try {
$validated = $request->validate([
'billing_date' => 'sometimes|string',
'previous_reading' => 'sometimes|numeric|min:0',
'current_reading' => 'sometimes|numeric|min:0',
'unit_price' => 'sometimes|numeric|min:0',
'notes' => 'nullable|string',
]);
$bill = ElectricityBill::findOrFail($id);
// Check if billing_date already exists (excluding current record)
if (isset($validated['billing_date'])) {
$existingBill = ElectricityBill::where('billing_date', $validated['billing_date'])
->where('id', '!=', $id)
->first();
if ($existingBill) {
return AbstractController::ResultError('Bill for this month already exists', 422);
}
}
// Recalculate total if any reading or price changed
$previousReading = $validated['previous_reading'] ?? $bill->previous_reading;
$currentReading = $validated['current_reading'] ?? $bill->current_reading;
$unitPrice = $validated['unit_price'] ?? $bill->unit_price;
$consumption = $currentReading - $previousReading;
$totalAmount = $consumption * $unitPrice;
$bill->update(array_merge($validated, [
'total_amount' => $totalAmount,
'updated_by' => auth('admins')->user()->id ?? null,
]));
return AbstractController::ResultSuccess($bill, 'Electricity bill updated successfully');
} catch (\Exception $e) {
Log::error('Error updating electricity bill: ' . $e->getMessage());
return AbstractController::ResultError($e->getMessage());
}
}
/**
* Delete electricity bill
*/
public function delete(Request $request, $id)
{
try {
$bill = ElectricityBill::findOrFail($id);
$bill->delete();
return AbstractController::ResultSuccess(null, 'Electricity bill deleted successfully');
} catch (\Exception $e) {
Log::error('Error deleting electricity bill: ' . $e->getMessage());
return AbstractController::ResultError($e->getMessage());
}
}
/**
* Export electricity bill to PDF
*/
public function exportPdf(Request $request, $id)
{
try {
$bill = ElectricityBill::findOrFail($id);
// Get month name from billing_date
$consumption = $bill->current_reading - $bill->previous_reading;
$totalText = $this->numberToVietnamese($bill->total_amount);
$date = Carbon::parse($bill->billing_date);
$dateNow = 'Ngày ' . $date->day .
' tháng ' . $date->month .
' năm ' . $date->year;
// Generate PDF
$pdf = Pdf::loadView('admin::admin.electricity_bills.pdf', [
'bill' => $bill,
'consumption' => $consumption,
'dateNow' => $dateNow,
'totalText' => $totalText
]);
$fileName = 'electricity_bill_' . $bill->billing_date . '.pdf';
$filePath = 'electricity_bills/' . $fileName;
// đảm bảo folder tồn tại
if (!Storage::disk('public')->exists('electricity_bills')) {
Storage::disk('public')->makeDirectory('electricity_bills');
}
// 👇 render 1 lần
$pdfContent = $pdf->output();
// 👇 lưu file
Storage::disk('public')->put($filePath, $pdfContent);
// update DB
$bill->update(['file_path' => $filePath]);
// 👇 trả về đúng file đã tạo
return response($pdfContent)
->header('Content-Type', 'application/pdf')
->header('Content-Disposition', 'attachment; filename="' . $fileName . '"');
} catch (\Exception $e) {
Log::error('Error exporting electricity bill to PDF: ' . $e->getMessage());
return AbstractController::ResultError($e->getMessage());
}
}
/**
* Get electricity bill by ID
*/
public function show($id)
{
try {
$bill = ElectricityBill::with(['creator', 'updater'])->findOrFail($id);
return AbstractController::ResultSuccess($bill);
} catch (\Exception $e) {
Log::error('Error fetching electricity bill: ' . $e->getMessage());
return AbstractController::ResultError($e->getMessage());
}
}
/**
* Upload an attachment (PDF or image) for an electricity bill
*/
public function uploadAttachment(Request $request, $id)
{
try {
$request->validate([
'file' => 'required|file|mimes:pdf,jpg,jpeg,png,gif,webp|max:10240',
]);
$bill = ElectricityBill::findOrFail($id);
$file = $request->file('file');
$extension = $file->getClientOriginalExtension();
$filename = 'bill_' . $bill->id . '_' . time() . '.' . $extension;
$directory = 'electricity_bills/attachments';
if (!Storage::disk('public')->exists($directory)) {
Storage::disk('public')->makeDirectory($directory);
}
// Delete old attachment if exists
if ($bill->attachment_path && Storage::disk('public')->exists($bill->attachment_path)) {
Storage::disk('public')->delete($bill->attachment_path);
}
$path = $file->storeAs($directory, $filename, 'public');
$bill->update([
'attachment_path' => $path,
'updated_by' => auth('admins')->user()->id ?? null,
]);
return AbstractController::ResultSuccess($bill, 'Attachment uploaded successfully');
} catch (\Exception $e) {
Log::error('Error uploading electricity bill attachment: ' . $e->getMessage());
return AbstractController::ResultError($e->getMessage());
}
}
/**
* Delete the attachment of an electricity bill
*/
public function deleteAttachment($id)
{
try {
$bill = ElectricityBill::findOrFail($id);
if ($bill->attachment_path && Storage::disk('public')->exists($bill->attachment_path)) {
Storage::disk('public')->delete($bill->attachment_path);
}
$bill->update([
'attachment_path' => null,
'updated_by' => auth('admins')->user()->id ?? null,
]);
return AbstractController::ResultSuccess($bill, 'Attachment deleted successfully');
} catch (\Exception $e) {
Log::error('Error deleting electricity bill attachment: ' . $e->getMessage());
return AbstractController::ResultError($e->getMessage());
}
}
function numberToVietnamese($number)
{
$units = ["", "một", "hai", "ba", "bốn", "năm", "sáu", "bảy", "tám", "chín"];
$levels = ["", "nghìn", "triệu", "tỷ"];
if ($number == 0) return "không đồng";
$number = (int)$number;
$result = "";
$level = 0;
while ($number > 0) {
$threeDigits = $number % 1000;
if ($threeDigits != 0) {
$result = $this->readThreeDigits($threeDigits, $units) . " " . $levels[$level] . " " . $result;
}
$number = floor($number / 1000);
$level++;
}
return ucfirst(trim(preg_replace('/\s+/', ' ', $result))) . " đồng";
}
function readThreeDigits($number, $units)
{
$hundreds = floor($number / 100);
$tens = floor(($number % 100) / 10);
$ones = $number % 10;
$result = "";
if ($hundreds > 0) {
$result .= $units[$hundreds] . " trăm";
if ($tens == 0 && $ones > 0) {
$result .= " lẻ";
}
}
if ($tens > 1) {
$result .= " " . $units[$tens] . " mươi";
if ($ones == 1) {
$result .= " mốt";
} elseif ($ones == 5) {
$result .= " lăm";
} elseif ($ones > 0) {
$result .= " " . $units[$ones];
}
} elseif ($tens == 1) {
$result .= " mười";
if ($ones == 5) {
$result .= " lăm";
} elseif ($ones > 0) {
$result .= " " . $units[$ones];
}
} elseif ($ones > 0) {
$result .= " " . $units[$ones];
}
return trim($result);
}
}

View File

@ -0,0 +1,517 @@
<?php
namespace Modules\Admin\app\Http\Controllers;
use App\Http\Controllers\Controller;
use App\Traits\HasFilterRequest;
use App\Traits\HasOrderByRequest;
use App\Traits\HasSearchRequest;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Modules\Admin\app\Models\Admin;
use Modules\Admin\app\Models\Equipment;
use Modules\Admin\app\Models\EquipmentAssignment;
class EquipmentController extends Controller
{
use HasOrderByRequest;
use HasFilterRequest;
use HasSearchRequest;
/**
* Get all equipments with pagination + filter + search.
*/
public function index(Request $request)
{
try {
$equipments = new Equipment;
$this->orderByRequest($equipments, $request);
$this->filterRequest(
builder: $equipments,
request: $request,
filterKeys: [
'code' => self::F_TEXT,
'name' => self::F_TEXT,
'note' => self::F_TEXT,
]
);
$this->searchRequest(
builder: $equipments,
value: $request->get('search'),
fields: ['equipments.code', 'equipments.name', 'equipments.note']
);
// Optional filter: assigned (in use) vs warehouse
$assignedFilter = $request->get('assigned');
if ($assignedFilter === 'in_use') {
$equipments->whereNotNull('equipments.current_user_id');
} elseif ($assignedFilter === 'warehouse') {
$equipments->whereNull('equipments.current_user_id');
}
$responseData = $equipments
->leftJoin('users as holder', 'equipments.current_user_id', '=', 'holder.id')
->orderBy('equipments.updated_at', 'desc')
->select(
'equipments.*',
'holder.name as current_user_name'
)
->paginate($request->get('per_page', 15));
return AbstractController::ResultSuccess($responseData);
} catch (\Exception $e) {
Log::error('Error fetching equipments: ' . $e->getMessage());
return AbstractController::ResultError($e->getMessage());
}
}
/**
* Get a single equipment by code (for barcode scan).
*/
public function showByCode(Request $request)
{
try {
$code = trim((string) $request->get('code'));
if ($code === '') {
return AbstractController::ResultError('Code is required', 422);
}
$equipment = Equipment::leftJoin('users as holder', 'equipments.current_user_id', '=', 'holder.id')
->where('equipments.code', $code)
->select('equipments.*', 'holder.name as current_user_name')
->first();
if (!$equipment) {
return AbstractController::ResultSuccess(null, 'Equipment not found');
}
$history = EquipmentAssignment::leftJoin('users', 'equipment_assignments.user_id', '=', 'users.id')
->where('equipment_assignments.equipment_id', $equipment->id)
->orderBy('equipment_assignments.assigned_at', 'desc')
->select(
'equipment_assignments.*',
'users.name as user_name'
)
->get();
$equipment->history = $history;
return AbstractController::ResultSuccess($equipment);
} catch (\Exception $e) {
Log::error('Error fetching equipment by code: ' . $e->getMessage());
return AbstractController::ResultError($e->getMessage());
}
}
/**
* Get assignment history for an equipment.
*/
public function history($id)
{
try {
$history = EquipmentAssignment::leftJoin('users', 'equipment_assignments.user_id', '=', 'users.id')
->where('equipment_assignments.equipment_id', $id)
->orderBy('equipment_assignments.assigned_at', 'desc')
->select(
'equipment_assignments.*',
'users.name as user_name'
)
->get();
return AbstractController::ResultSuccess($history);
} catch (\Exception $e) {
Log::error('Error fetching equipment history: ' . $e->getMessage());
return AbstractController::ResultError($e->getMessage());
}
}
/**
* Get all users for assignment selectbox.
*/
public function userOptions()
{
try {
$users = Admin::orderBy('name', 'asc')
->select('id', 'name', 'email')
->get();
return AbstractController::ResultSuccess($users);
} catch (\Exception $e) {
Log::error('Error fetching user options: ' . $e->getMessage());
return AbstractController::ResultError($e->getMessage());
}
}
/**
* Create new equipment. Accepts multipart with optional image file.
*/
public function create(Request $request)
{
try {
$validated = $request->validate([
'code' => 'required|string|max:255|unique:equipments,code',
'name' => 'required|string|max:255',
'note' => 'nullable|string',
'current_user_id' => 'nullable|integer|exists:users,id',
'image' => 'nullable|file|mimes:jpg,jpeg,png,gif,webp|max:10240',
]);
$authId = auth('admins')->user()->id ?? null;
$imagePath = null;
if ($request->hasFile('image')) {
$directory = 'equipments/images';
if (!Storage::disk('public')->exists($directory)) {
Storage::disk('public')->makeDirectory($directory);
}
$imagePath = $request->file('image')->store($directory, 'public');
}
$equipment = DB::transaction(function () use ($validated, $authId, $imagePath) {
$now = Carbon::now();
$currentUserId = $validated['current_user_id'] ?? null;
$equipment = Equipment::create([
'code' => $validated['code'],
'name' => $validated['name'],
'note' => $validated['note'] ?? null,
'image_path' => $imagePath,
'current_user_id' => $currentUserId,
'assigned_at' => $now,
'created_by' => $authId,
]);
// Always create an initial assignment record so the history
// shows the very first state (warehouse or user).
EquipmentAssignment::create([
'equipment_id' => $equipment->id,
'user_id' => $currentUserId, // null = at warehouse
'assigned_at' => $now,
'note' => $currentUserId ? 'Initial assignment' : 'Initial — at warehouse',
'created_by' => $authId,
]);
return $equipment;
});
return AbstractController::ResultSuccess($equipment, 'Equipment created successfully');
} catch (\Illuminate\Validation\ValidationException $e) {
return AbstractController::ResultError($e->getMessage(), 422);
} catch (\Exception $e) {
Log::error('Error creating equipment: ' . $e->getMessage());
return AbstractController::ResultError($e->getMessage());
}
}
/**
* Upload / replace the equipment image (optional, post-create or edit).
*/
public function uploadImage(Request $request, $id)
{
try {
$request->validate([
'image' => 'required|file|mimes:jpg,jpeg,png,gif,webp|max:10240',
]);
$equipment = Equipment::findOrFail($id);
// Delete old image if present
if ($equipment->image_path && Storage::disk('public')->exists($equipment->image_path)) {
Storage::disk('public')->delete($equipment->image_path);
}
$directory = 'equipments/images';
if (!Storage::disk('public')->exists($directory)) {
Storage::disk('public')->makeDirectory($directory);
}
$path = $request->file('image')->store($directory, 'public');
$equipment->update([
'image_path' => $path,
'updated_by' => auth('admins')->user()->id ?? null,
]);
return AbstractController::ResultSuccess($equipment, 'Image uploaded');
} catch (\Exception $e) {
Log::error('Error uploading equipment image: ' . $e->getMessage());
return AbstractController::ResultError($e->getMessage());
}
}
/**
* Delete the equipment image.
*/
public function deleteImage($id)
{
try {
$equipment = Equipment::findOrFail($id);
if ($equipment->image_path && Storage::disk('public')->exists($equipment->image_path)) {
Storage::disk('public')->delete($equipment->image_path);
}
$equipment->update([
'image_path' => null,
'updated_by' => auth('admins')->user()->id ?? null,
]);
return AbstractController::ResultSuccess($equipment, 'Image deleted');
} catch (\Exception $e) {
Log::error('Error deleting equipment image: ' . $e->getMessage());
return AbstractController::ResultError($e->getMessage());
}
}
/**
* Update equipment basic info (code, name, note).
*/
public function update(Request $request, $id)
{
try {
$equipment = Equipment::findOrFail($id);
$validated = $request->validate([
'code' => 'sometimes|string|max:255|unique:equipments,code,' . $id,
'name' => 'sometimes|string|max:255',
'note' => 'nullable|string',
]);
$equipment->update(array_merge($validated, [
'updated_by' => auth('admins')->user()->id ?? null,
]));
return AbstractController::ResultSuccess($equipment, 'Equipment updated successfully');
} catch (\Exception $e) {
Log::error('Error updating equipment: ' . $e->getMessage());
return AbstractController::ResultError($e->getMessage());
}
}
/**
* Assign equipment to a user, or return to warehouse (user_id = null).
* Every state change is recorded: closes the previous open record AND opens
* a new one for the new holder (user or warehouse).
*/
public function assign(Request $request, $id)
{
try {
$validated = $request->validate([
'user_id' => 'nullable|integer|exists:users,id',
'note' => 'nullable|string',
]);
$authId = auth('admins')->user()->id ?? null;
$equipment = DB::transaction(function () use ($id, $validated, $authId) {
$equipment = Equipment::lockForUpdate()->findOrFail($id);
$now = Carbon::now();
$newUserId = $validated['user_id'] ?? null;
// No-op guard: if assigning to the same holder, skip to avoid duplicate history rows
if ($equipment->current_user_id == $newUserId) {
return $equipment;
}
// Close any currently open assignment (records who/Kho was holding it until now)
EquipmentAssignment::where('equipment_id', $equipment->id)
->whereNull('returned_at')
->update([
'returned_at' => $now,
'updated_at' => $now,
]);
// Always open a new assignment row — for a user OR for the warehouse.
// user_id = null means "currently at warehouse".
EquipmentAssignment::create([
'equipment_id' => $equipment->id,
'user_id' => $newUserId,
'assigned_at' => $now,
'note' => $validated['note'] ?? ($newUserId === null ? 'Returned to warehouse' : null),
'created_by' => $authId,
]);
$equipment->update([
'current_user_id' => $newUserId,
'assigned_at' => $now,
'updated_by' => $authId,
]);
return $equipment->fresh();
});
return AbstractController::ResultSuccess($equipment, 'Equipment assignment updated');
} catch (\Exception $e) {
Log::error('Error assigning equipment: ' . $e->getMessage());
return AbstractController::ResultError($e->getMessage());
}
}
/**
* Statistics: list users grouped with the equipments they are currently holding.
*/
public function userStatistics(Request $request)
{
try {
$search = trim((string) $request->get('search', ''));
$usersQuery = DB::table('equipments')
->join('users', 'equipments.current_user_id', '=', 'users.id')
->whereNotNull('equipments.current_user_id');
if ($search !== '') {
$usersQuery->where(function ($q) use ($search) {
$q->where('users.name', 'like', "%{$search}%")
->orWhere('users.email', 'like', "%{$search}%")
->orWhere('equipments.code', 'like', "%{$search}%")
->orWhere('equipments.name', 'like', "%{$search}%");
});
}
$userTotals = (clone $usersQuery)
->select(
'users.id as user_id',
'users.name as user_name',
'users.email as user_email',
DB::raw('COUNT(equipments.id) as equipment_count')
)
->groupBy('users.id', 'users.name', 'users.email')
->orderByDesc('equipment_count')
->orderBy('users.name')
->get();
$userIds = $userTotals->pluck('user_id')->all();
$devices = Equipment::leftJoin('users as holder', 'equipments.current_user_id', '=', 'holder.id')
->whereIn('equipments.current_user_id', $userIds)
->select(
'equipments.id',
'equipments.code',
'equipments.name',
'equipments.note',
'equipments.image_path',
'equipments.current_user_id',
'equipments.assigned_at'
)
->orderBy('equipments.assigned_at', 'desc')
->get()
->groupBy('current_user_id');
$result = $userTotals->map(function ($u) use ($devices) {
return [
'user_id' => $u->user_id,
'user_name' => $u->user_name,
'user_email' => $u->user_email,
'equipment_count' => (int) $u->equipment_count,
'equipments' => ($devices->get($u->user_id) ?? collect())->values(),
];
});
// Also include warehouse summary
$warehouseCount = Equipment::whereNull('current_user_id')->count();
return AbstractController::ResultSuccess([
'users' => $result,
'warehouse_count' => $warehouseCount,
'total_equipment' => Equipment::count(),
]);
} catch (\Exception $e) {
Log::error('Error fetching equipment user statistics: ' . $e->getMessage());
return AbstractController::ResultError($e->getMessage());
}
}
/**
* Delete equipment (and its history via cascade).
*/
public function delete($id)
{
try {
$equipment = Equipment::findOrFail($id);
$equipment->delete();
return AbstractController::ResultSuccess(null, 'Equipment deleted successfully');
} catch (\Exception $e) {
Log::error('Error deleting equipment: ' . $e->getMessage());
return AbstractController::ResultError($e->getMessage());
}
}
/**
* Export the (filtered) equipment list as CSV.
*/
public function export(Request $request)
{
try {
$equipments = new Equipment;
$this->filterRequest(
builder: $equipments,
request: $request,
filterKeys: [
'code' => self::F_TEXT,
'name' => self::F_TEXT,
'note' => self::F_TEXT,
]
);
$this->searchRequest(
builder: $equipments,
value: $request->get('search'),
fields: ['equipments.code', 'equipments.name', 'equipments.note']
);
$assignedFilter = $request->get('assigned');
if ($assignedFilter === 'in_use') {
$equipments->whereNotNull('equipments.current_user_id');
} elseif ($assignedFilter === 'warehouse') {
$equipments->whereNull('equipments.current_user_id');
}
$rows = $equipments
->leftJoin('users as holder', 'equipments.current_user_id', '=', 'holder.id')
->orderBy('equipments.updated_at', 'desc')
->select(
'equipments.id',
'equipments.code',
'equipments.name',
'equipments.note',
'equipments.assigned_at',
'equipments.created_at',
'equipments.updated_at',
'holder.name as current_user_name'
)
->get();
$filename = 'equipments_' . date('Y-m-d_His') . '.csv';
$callback = function () use ($rows) {
$out = fopen('php://output', 'w');
// BOM for Excel UTF-8
fwrite($out, "\xEF\xBB\xBF");
fputcsv($out, ['ID', 'Code', 'Name', 'Note', 'Current Holder', 'Assigned At', 'Created At', 'Updated At']);
foreach ($rows as $r) {
fputcsv($out, [
$r->id,
$r->code,
$r->name,
$r->note,
$r->current_user_name ?: 'Kho',
$r->assigned_at,
$r->created_at,
$r->updated_at,
]);
}
fclose($out);
};
return response()->stream($callback, 200, [
'Content-Type' => 'text/csv; charset=UTF-8',
'Content-Disposition' => 'attachment; filename="' . $filename . '"',
]);
} catch (\Exception $e) {
Log::error('Error exporting equipments: ' . $e->getMessage());
return AbstractController::ResultError($e->getMessage());
}
}
}

View File

@ -726,6 +726,40 @@ class TicketController extends Controller
];
}
/**
* Khởi tạo dữ liệu ngày phép cho user nếu chưa
*
* @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
private function getTotalLeaveDaysInMonthToMonth($user, int $year, int $month): float
{
@ -745,7 +779,13 @@ class TicketController extends Controller
$leaveDaysInfo = LeaveDays::where('ld_user_id', $user->id)
->where('ld_year', $year)
->first();
if (!$leaveDaysInfo) {
$leaveDaysInfo = $this->initializeLeaveDaysForYear($user, $year);
if (!$leaveDaysInfo) {
return 0;
}
}
$totalAllocated = 0;
// Xử lý gửi ticket sau tháng hiện tại
if ($leaveDaysInfo && $user->is_permanent) {

View File

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

View File

@ -0,0 +1,52 @@
<?php
namespace Modules\Admin\app\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use App\Traits\HasCacheModel;
class ElectricityBill extends Model
{
use HasFactory;
use HasCacheModel;
public function __construct()
{
$this->table = 'electricity_bills';
$this->guarded = [];
}
/**
* Calculate total amount based on reading difference and unit price
*/
public function calculateTotal(): float
{
$consumption = $this->current_reading - $this->previous_reading;
return round($consumption * $this->unit_price, 2);
}
/**
* Get consumption in kWh
*/
public function getConsumption(): float
{
return $this->current_reading - $this->previous_reading;
}
/**
* Get user who created this record
*/
public function creator()
{
return $this->belongsTo(\App\Models\User::class, 'created_by');
}
/**
* Get user who updated this record
*/
public function updater()
{
return $this->belongsTo(\App\Models\User::class, 'updated_by');
}
}

View File

@ -0,0 +1,37 @@
<?php
namespace Modules\Admin\app\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Equipment extends Model
{
use HasFactory;
public function __construct()
{
$this->table = 'equipments';
$this->guarded = [];
}
public function currentUser()
{
return $this->belongsTo(\App\Models\User::class, 'current_user_id');
}
public function assignments()
{
return $this->hasMany(EquipmentAssignment::class, 'equipment_id')->orderBy('assigned_at', 'desc');
}
public function creator()
{
return $this->belongsTo(\App\Models\User::class, 'created_by');
}
public function updater()
{
return $this->belongsTo(\App\Models\User::class, 'updated_by');
}
}

View File

@ -0,0 +1,32 @@
<?php
namespace Modules\Admin\app\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class EquipmentAssignment extends Model
{
use HasFactory;
public function __construct()
{
$this->table = 'equipment_assignments';
$this->guarded = [];
}
public function equipment()
{
return $this->belongsTo(Equipment::class, 'equipment_id');
}
public function user()
{
return $this->belongsTo(\App\Models\User::class, 'user_id');
}
public function creator()
{
return $this->belongsTo(\App\Models\User::class, 'created_by');
}
}

View File

@ -0,0 +1,98 @@
<!DOCTYPE html>
<html lang="vi">
<head>
<meta charset="UTF-8">
<title>Bảng thanh toán tiền điện</title>
<style>
body {
font-family: DejaVu Sans, sans-serif;
font-size: 14px;
}
.text-center {
text-align: center;
}
.mt-10 {
margin-top: 10px;
}
.mt-20 {
margin-top: 20px;
}
table {
width: 100%;
border-collapse: collapse;
margin-top: 15px;
}
table, th, td {
border: 1px solid #000;
}
th, td {
padding: 8px;
text-align: center;
}
.no-border {
border: none;
}
.signature {
width: 100%;
margin-top: 50px;
}
.signature td {
border: none;
text-align: center;
}
</style>
</head>
<body>
<h3 class="text-center">BẢNG THANH TOÁN TIỀN ĐIỆN</h3>
<p class="text-center">({{ $dateNow ?? '' }})</p>
<div class="mt-20">
<p>- Tên doanh nghiệp: Công ty TNHH Kỹ Thuật Công Nghệ APAC</p>
<p>- số thuế: 0110038408</p>
<p>- Địa chỉ: Số 219/26/3 đường Lĩnh Nam, Phường Vĩnh Hưng, thành phố Nội, Việt Nam</p>
<p>- Tên chủ sở hữu cho thuê địa điểm sản xuất kinh doanh: Lâm Văn Mười</p>
<p>- Địa chỉ thuê: 50B31 tại Khu dân 91B giai đoạn 3, phường Tân An, thành phố Cần Thơ</p>
</div>
<table>
<thead>
<tr>
<th>Số điện đầu kỳ</th>
<th>Số điện cuối kỳ</th>
<th>Số điện tiêu thụ</th>
<th>Đơn giá</th>
<th>Thành tiền</th>
</tr>
</thead>
<tbody>
<tr>
<td>{{ number_format($bill->previous_reading) ?? 0 }}</td>
<td>{{ number_format($bill->current_reading) ?? 0 }}</td>
<td>{{ $consumption ?? 0 }}</td>
<td>{{ number_format($bill->unit_price) ?? '0' }}</td>
<td>{{ number_format($bill->total_amount) ?? '0' }}</td>
</tr>
</tbody>
</table>
<p class="mt-20">
- Tổng tiền thanh toán: <strong>{{ number_format($bill->total_amount) ?? '0' }} VND</strong>
({{ $totalText ?? '' }})
</p>
<table class="signature no-border">
<tr>
<td>
Người lập bảng <br>
(, ghi họ tên)
</td>
<td>
Đại diện doanh nghiệp<br>
(, ghi họ tên)
</td>
</tr>
</table>
</body>
</html>

View File

@ -23,6 +23,8 @@ use Modules\Admin\app\Http\Controllers\ProjectReviewController;
use Modules\Admin\app\Http\Controllers\ProfileController;
use Modules\Admin\app\Http\Controllers\TechnicalController;
use Modules\Admin\app\Http\Controllers\TestCaseForSprintController;
use Modules\Admin\app\Http\Controllers\ElectricityBillController;
use Modules\Admin\app\Http\Controllers\EquipmentController;
use Modules\Admin\app\Http\Middleware\AdminMiddleware;
/*
@ -149,6 +151,8 @@ Route::middleware('api')
'prefix' => 'category',
], function () {
Route::get('/get-list-master', [CategoryController::class, 'getListMaster']);
Route::get('/work-days', [CategoryController::class, 'workDays'])->middleware('check.permission:admin.hr');
Route::put('/update-work-days', [CategoryController::class, 'updateWorkDays'])->middleware('check.permission:admin.hr');
});
Route::group([
@ -171,6 +175,38 @@ Route::middleware('api')
Route::post('/handle-ticket', [TicketController::class, 'handleTicket'])->middleware('check.permission:admin');
});
// Electricity Bills
Route::group([
'prefix' => 'electricity-bill',
], function () {
Route::get('/', [ElectricityBillController::class, 'index'])->middleware('check.permission:admin.hr.staff.accountant');
Route::get('/{id}', [ElectricityBillController::class, 'show'])->middleware('check.permission:admin.hr.staff.accountant');
Route::post('/create', [ElectricityBillController::class, 'create'])->middleware('check.permission:admin.hr.staff.accountant');
Route::put('/{id}', [ElectricityBillController::class, 'update'])->middleware('check.permission:admin.hr.staff.accountant');
Route::get('/delete/{id}', [ElectricityBillController::class, 'delete'])->middleware('check.permission:admin.hr.staff.accountant');
Route::get('/export-pdf/{id}', [ElectricityBillController::class, 'exportPdf'])->middleware('check.permission:admin.hr.staff.accountant');
Route::post('/upload-attachment/{id}', [ElectricityBillController::class, 'uploadAttachment'])->middleware('check.permission:admin.hr.staff.accountant');
Route::delete('/delete-attachment/{id}', [ElectricityBillController::class, 'deleteAttachment'])->middleware('check.permission:admin.hr.staff.accountant');
});
// Equipments
Route::group([
'prefix' => 'equipment',
], function () {
Route::get('/', [EquipmentController::class, 'index'])->middleware('check.permission:admin.hr.staff.accountant');
Route::get('/users', [EquipmentController::class, 'userOptions'])->middleware('check.permission:admin.hr.staff.accountant');
Route::get('/user-statistics', [EquipmentController::class, 'userStatistics'])->middleware('check.permission:admin.hr.staff.accountant');
Route::get('/export', [EquipmentController::class, 'export'])->middleware('check.permission:admin.hr.staff.accountant');
Route::get('/by-code', [EquipmentController::class, 'showByCode'])->middleware('check.permission:admin.hr.staff.accountant');
Route::get('/history/{id}', [EquipmentController::class, 'history'])->middleware('check.permission:admin.hr.staff.accountant');
Route::post('/create', [EquipmentController::class, 'create'])->middleware('check.permission:admin.hr.staff.accountant');
Route::put('/{id}', [EquipmentController::class, 'update'])->middleware('check.permission:admin.hr.staff.accountant');
Route::post('/{id}/assign', [EquipmentController::class, 'assign'])->middleware('check.permission:admin.hr.staff.accountant');
Route::post('/{id}/upload-image', [EquipmentController::class, 'uploadImage'])->middleware('check.permission:admin.hr.staff.accountant');
Route::delete('/{id}/image', [EquipmentController::class, 'deleteImage'])->middleware('check.permission:admin.hr.staff.accountant');
Route::get('/delete/{id}', [EquipmentController::class, 'delete'])->middleware('check.permission:admin.hr.staff.accountant');
});
Route::group([
'prefix' => 'profile',
], function () {

View File

@ -24,9 +24,11 @@ class Kernel extends ConsoleKernel
// $schedule->command('daily:api-call')
// ->dailyAt('18:00');
// Chạy command vào ngày 31/12 lúc 23:59:59 mỗi năm
$schedule->command('initialize:leavedays')->yearlyOn(12, 31, '23:59:59');
$schedule->command('leave:deduct')->yearlyOn(3, 31, '23:59:59');
// Chạy command vào ngày 01/01 lúc 00:00 mỗi năm
$schedule->command('initialize:leavedays')->yearlyOn(1, 1, '00:00');
// Chạy command vào ngày 01/04 lúc 00:00 mỗi năm
$schedule->command('leave:deduct')->yearlyOn(4, 1, '00:00');
// Chạy buổi sáng lúc 12:00
$schedule->command('attendance:check S')->dailyAt('12:00');

View File

@ -3,6 +3,7 @@
namespace App\Jobs;
use App\Models\LeaveDays;
use App\Models\Notes;
use App\Models\User;
use Carbon\Carbon;
use Illuminate\Bus\Queueable;
@ -11,6 +12,7 @@ use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Modules\Admin\app\Models\Category;
use Illuminate\Support\Facades\DB;
class AddMonthlyLeaveDays implements ShouldQueue
{
@ -46,15 +48,48 @@ class AddMonthlyLeaveDays implements ShouldQueue
->where('ld_year', $this->year)
->first();
if (!$leaveDay) {
if (!$leaveDay && $this->month > 1) {
// Nếu chưa có dữ liệu năm hiện tại, tạo mới
// Số ngày phép bằng với tháng hiện tại
$previousYearData = LeaveDays::where('ld_user_id', $user->id)
->where('ld_year', $this->year - 1)
->first();
$ld_additional_day = 0;
$ld_note = '';
if ($previousYearData) {
$ld_additional_day = $previousYearData->ld_day_total + $previousYearData->ld_additional_day + $previousYearData->ld_special_leave_day;
$totalLeaveDaysByMonth = Notes::join('categories', function ($join) {
$join->on('notes.n_time_type', '=', 'categories.c_code')
->where('categories.c_type', 'TIME_TYPE');
})
->select(
DB::raw('notes.n_user_id as n_user_id'),
DB::raw('notes.n_year as year'),
DB::raw('SUM(categories.c_value) as leave_days')
)
->where('notes.n_year', $this->year - 1)
->where('notes.n_user_id', $user->id)
->where('notes.n_reason', 'ONLEAVE')
->groupBy(DB::raw('notes.n_year'))
->first();
if ($totalLeaveDaysByMonth) {
$ld_additional_day = $ld_additional_day - $totalLeaveDaysByMonth->leave_days;
if ($ld_additional_day < 0) {
$ld_additional_day = 0;
}
}
if ($ld_additional_day > 0) {
$ld_note = "Cộng " . $ld_additional_day . " ngày phép tồn năm trước. \n";
}
}
$leaveDay = new LeaveDays([
'ld_user_id' => $user->id,
'ld_day_total' => $this->month, // Số ngày phép bằng tháng hiện tại
'ld_year' => $this->year,
'ld_additional_day' => 0,
'ld_note' => 'Khởi tạo ngày phép đến tháng ' . $this->month,
'ld_additional_day' => $ld_additional_day,
'ld_note' => $ld_note,
'ld_special_leave_day' => 0,
]);
$leaveDay->save();

View File

@ -55,10 +55,12 @@ class DeductLeaveDays implements ShouldQueue
if ($usedOnleaveDaysTotal) {
if ($existingData->ld_additional_day > $usedOnleaveDaysTotal) {
$ld_note = "Trừ " . $existingData->ld_additional_day - $usedOnleaveDaysTotal . " ngày phép tồn năm trước. \n";
$existingData->ld_note = $existingData->ld_note . "\n" . $ld_note;
$existingData->ld_note = $existingData->ld_note . $ld_note;
$existingData->ld_additional_day = $usedOnleaveDaysTotal;
}
} 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;
}

View File

@ -42,16 +42,6 @@ class InitializeLeaveDays implements ShouldQueue
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
$previousYearData = LeaveDays::where('ld_user_id', $user->id)
->where('ld_year', $this->year - 1)
@ -61,7 +51,7 @@ class InitializeLeaveDays implements ShouldQueue
$ld_note = '';
if ($previousYearData) {
$ld_additional_day = $previousYearData->ld_day_total + $previousYearData->ld_additional_day;
$ld_additional_day = $previousYearData->ld_day_total + $previousYearData->ld_additional_day + $previousYearData->ld_special_leave_day;
$totalLeaveDaysByMonth = Notes::join('categories', function ($join) {
$join->on('notes.n_time_type', '=', 'categories.c_code')
->where('categories.c_type', 'TIME_TYPE');
@ -88,6 +78,19 @@ 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
LeaveDays::insert([
'ld_user_id' => $user->id,

View File

@ -2,11 +2,16 @@
"name": "laravel/laravel",
"type": "project",
"description": "The skeleton application for the Laravel framework.",
"keywords": ["laravel", "framework"],
"keywords": [
"laravel",
"framework"
],
"license": "MIT",
"require": {
"php": "^8.2",
"barryvdh/laravel-debugbar": "^3.9",
"barryvdh/laravel-dompdf": "^2.0",
"doctrine/dbal": "^3.10",
"drnxloc/laravel-simple-html-dom": "^1.9",
"guzzlehttp/guzzle": "^7.8",
"laravel/framework": "^10.10",

3089
BACKEND/composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,39 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('electricity_bills', function (Blueprint $table) {
$table->id();
$table->date('billing_date')->comment('Ngày lập hóa đơn');
$table->decimal('previous_reading', 12, 2)->comment('Số điện kỳ trước');
$table->decimal('current_reading', 12, 2)->comment('Số điện kỳ này');
$table->decimal('unit_price', 12, 2)->comment('Đơn giá điện');
$table->decimal('total_amount', 12, 2)->comment('Tổng tiền điện');
$table->string('notes')->nullable()->comment('Ghi chú');
$table->string('file_path')->nullable()->comment('Đường dẫn file PDF');
$table->unsignedBigInteger('created_by')->nullable();
$table->unsignedBigInteger('updated_by')->nullable();
$table->timestamps();
$table->foreign('created_by')->references('id')->on('users')->onDelete('set null');
$table->foreign('updated_by')->references('id')->on('users')->onDelete('set null');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('electricity_bills');
}
};

View File

@ -0,0 +1,23 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('electricity_bills', function (Blueprint $table) {
$table->string('attachment_path')->nullable()->after('file_path')
->comment('User-uploaded attachment (PDF or image)');
});
}
public function down(): void
{
Schema::table('electricity_bills', function (Blueprint $table) {
$table->dropColumn('attachment_path');
});
}
};

View File

@ -0,0 +1,50 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('equipments', function (Blueprint $table) {
$table->id();
$table->string('code')->unique()->comment('Mã thiết bị (dùng để scan barcode)');
$table->string('name')->comment('Tên thiết bị');
$table->text('note')->nullable()->comment('Ghi chú');
$table->unsignedBigInteger('current_user_id')->nullable()->comment('Người đang sử dụng, null = đang ở kho');
$table->timestamp('assigned_at')->nullable()->comment('Thời điểm giao thiết bị hiện tại');
$table->unsignedBigInteger('created_by')->nullable();
$table->unsignedBigInteger('updated_by')->nullable();
$table->timestamps();
$table->foreign('current_user_id')->references('id')->on('users')->onDelete('set null');
$table->foreign('created_by')->references('id')->on('users')->onDelete('set null');
$table->foreign('updated_by')->references('id')->on('users')->onDelete('set null');
});
Schema::create('equipment_assignments', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('equipment_id');
$table->unsignedBigInteger('user_id')->nullable()->comment('null = trả về kho');
$table->timestamp('assigned_at')->comment('Bắt đầu sử dụng');
$table->timestamp('returned_at')->nullable()->comment('Trả thiết bị');
$table->text('note')->nullable();
$table->unsignedBigInteger('created_by')->nullable();
$table->timestamps();
$table->foreign('equipment_id')->references('id')->on('equipments')->onDelete('cascade');
$table->foreign('user_id')->references('id')->on('users')->onDelete('set null');
$table->foreign('created_by')->references('id')->on('users')->onDelete('set null');
$table->index(['equipment_id', 'returned_at']);
});
}
public function down(): void
{
Schema::dropIfExists('equipment_assignments');
Schema::dropIfExists('equipments');
}
};

View File

@ -0,0 +1,23 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('equipments', function (Blueprint $table) {
$table->string('image_path')->nullable()->after('note')
->comment('Ảnh thiết bị (tùy chọn)');
});
}
public function down(): void
{
Schema::table('equipments', function (Blueprint $table) {
$table->dropColumn('image_path');
});
}
};

File diff suppressed because it is too large Load Diff

View File

@ -23,6 +23,8 @@
"@tabler/icons-react": "^3.19.0",
"@uiw/react-codemirror": "^4.23.5",
"@vanilla-extract/css": "^1.16.0",
"@zxing/browser": "^0.2.0",
"@zxing/library": "^0.23.0",
"axios": "^1.6.1",
"clsx": "^2.1.1",
"dayjs": "^1.11.10",

View File

@ -35,6 +35,8 @@ export const updateWorkingDays =
//Category
export const getListMaster = API_URL + 'v1/admin/category/get-list-master'
export const getWorkDay = API_URL + 'v1/admin/category/work-days'
export const updateWorkDay = API_URL + 'v1/admin/category/update-work-days'
//LeaveManagement
export const getLeaveManagement = API_URL + 'v1/admin/leave-management'
@ -123,6 +125,44 @@ export const deleteDocument = API_URL + 'v1/admin/document/delete'
// Download File
export const downloadFile = API_URL + 'v1/admin/download-file'
// Electricity Bills
export const getElectricityBills = API_URL + 'v1/admin/electricity-bill'
export const getElectricityBillById = (id: number) =>
API_URL + `v1/admin/electricity-bill/${id}`
export const createElectricityBill =
API_URL + 'v1/admin/electricity-bill/create'
export const updateElectricityBill = (id: number) =>
API_URL + `v1/admin/electricity-bill/${id}`
export const deleteElectricityBill = (id: number) =>
API_URL + `v1/admin/electricity-bill/delete/${id}`
export const exportElectricityBillPdf = (id: number) =>
API_URL + `v1/admin/electricity-bill/export-pdf/${id}`
export const uploadElectricityBillAttachment = (id: number) =>
API_URL + `v1/admin/electricity-bill/upload-attachment/${id}`
export const deleteElectricityBillAttachment = (id: number) =>
API_URL + `v1/admin/electricity-bill/delete-attachment/${id}`
// Equipments
export const getEquipments = API_URL + 'v1/admin/equipment'
export const getEquipmentUserOptions = API_URL + 'v1/admin/equipment/users'
export const getEquipmentUserStatistics =
API_URL + 'v1/admin/equipment/user-statistics'
export const exportEquipments = API_URL + 'v1/admin/equipment/export'
export const getEquipmentByCode = API_URL + 'v1/admin/equipment/by-code'
export const getEquipmentHistory = (id: number) =>
API_URL + `v1/admin/equipment/history/${id}`
export const createEquipment = API_URL + 'v1/admin/equipment/create'
export const updateEquipment = (id: number) =>
API_URL + `v1/admin/equipment/${id}`
export const assignEquipment = (id: number) =>
API_URL + `v1/admin/equipment/${id}/assign`
export const uploadEquipmentImage = (id: number) =>
API_URL + `v1/admin/equipment/${id}/upload-image`
export const deleteEquipmentImage = (id: number) =>
API_URL + `v1/admin/equipment/${id}/image`
export const deleteEquipment = (id: number) =>
API_URL + `v1/admin/equipment/delete/${id}`
// Files APIs
export const getFiles = API_URL + 'v1/admin/profile/files'
export const uploadFiles = API_URL + 'v1/admin/profile/upload-files'

View File

@ -250,7 +250,14 @@ export const DataTableAll = ({
if (query !== '') {
setTData(
data.filter((obj) =>
Object.values(obj)?.find((c: any) => c.toString().normalize('NFC').toLowerCase().includes(query.normalize('NFC').toLowerCase())))
Object.values(obj)?.find((c: any) =>
c
.toString()
.normalize('NFC')
.toLowerCase()
.includes(query.normalize('NFC').toLowerCase()),
),
),
)
} else {
if (pagination) {
@ -456,7 +463,7 @@ export const DataTablePagination = ({
})
const [selectedRows, setSelectedRows] = useState<any[]>([])
const navigate = useNavigate()
const urlParams = new URLSearchParams(location.search)
let urlParams = new URLSearchParams(location.search)
// Render headers
const headers = columns.map((col) => (
@ -596,7 +603,7 @@ export const DataTablePagination = ({
// Remove specific parameters
params.delete(name)
urlParams.delete(name)
// Update the URL without reloading the page
window.history.replaceState({}, document.title, url.toString())
}
@ -628,9 +635,9 @@ export const DataTablePagination = ({
Array.isArray(dataFilter[key])
? dataFilter[key]
: key === 'to_date'
? Math.floor(dataFilter[key].getTime() / 1000) +
(60 * 60 * 23 + 60 * 59 + 59)
: Math.floor(dataFilter[key].getTime() / 1000),
? Math.floor(dataFilter[key].getTime() / 1000) +
(60 * 60 * 23 + 60 * 59 + 59)
: Math.floor(dataFilter[key].getTime() / 1000),
})
}
})
@ -660,9 +667,8 @@ export const DataTablePagination = ({
date_used_to: date_used,
})
}
// Add all attributes in 'params' to URL params
Object.entries(params).forEach((param) => urlParams.set(...param))
urlParams = new URLSearchParams(Object.entries(params))
Object.entries(dataFilter).forEach(([key, value]) => {
const typeFilter = filterInfo.find((o) => o.key === key).type
const hasType = {
@ -679,17 +685,16 @@ export const DataTablePagination = ({
if (hasType.Date) {
value = value ? Date.parse(String(value)) / 1000 : '' // to unix timestamp
}
console.log(String(value))
String(value).length
? urlParams.set(key, String(value))
: urlParams.delete(key)
})
// Request to get data API
const res = await get(url, Object.fromEntries(urlParams.entries()))
if (res.status) {
setBaseData(res)
setTData(res.data)
setBaseData(res.data?.links ? res?.data : res)
setTData(res.data?.data ? res.data?.data : res.data)
setSkeletion(false)
navigate({
pathname: location.pathname,
@ -765,7 +770,7 @@ export const DataTablePagination = ({
if (order_by_) {
const sortParam = {
name: order_by_.split('=')[0].split('_')[2],
name: order_by_.split('=')[0].split('_').slice(2).join('_'),
status: order_by_.split('=')[1],
}
if (JSON.stringify(sortParam) !== JSON.stringify(statusSort)) {

View File

@ -39,6 +39,7 @@ import {
IconReport,
IconScan,
IconSettings,
IconShredder,
IconSun,
IconTicket,
IconUsersGroup,
@ -146,8 +147,8 @@ const data = [
link: '/organization-settings',
label: 'Organization Settings',
icon: IconSettings,
group: 'admin',
permissions: 'admin',
group: 'other',
permissions: 'admin,hr',
},
{
link: '/profiles',
@ -156,6 +157,13 @@ const data = [
group: 'admin',
permissions: 'admin,accountant',
},
{
link: '/office-support',
label: 'Office Support',
icon: IconShredder,
group: 'other',
permissions: 'admin,hr,accountant',
},
// { link: '/jira', label: 'Jira', icon: IconSubtask },
// { link: '/custom-theme', label: 'Custom Theme', icon: IconBrush },
// { link: '/general-setting', label: 'General Setting', icon: IconSettings },

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,48 @@
.title {
background-color: light-dark(var(white), var(--mantine-color-dark-7));
z-index: 100;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 var(--mantine-spacing-sm) var(--mantine-spacing-lg)
var(--mantine-spacing-sm);
border-bottom: solid rgba(201, 201, 201, 0.377) 1px;
}
.optionIcon {
display: flex;
justify-content: space-evenly;
}
.deleteIcon {
color: red;
cursor: pointer;
padding: 2px;
border-radius: 25%;
}
.editIcon {
color: rgb(9, 132, 132);
cursor: pointer;
padding: 2px;
border-radius: 25%;
}
.editIcon:hover {
background-color: rgba(203, 203, 203, 0.809);
}
.deleteIcon:hover {
background-color: rgba(203, 203, 203, 0.809);
}
.dialog {
background-color: light-dark(white, #2d353c);
text-align: center;
border: solid 1px rgb(255, 145, 0);
}
.dialogText {
color: light-dark(#2d353c, white);
}

View File

@ -0,0 +1,876 @@
import {
createElectricityBill,
deleteElectricityBill,
deleteElectricityBillAttachment,
exportElectricityBillPdf,
getElectricityBills,
updateElectricityBill,
uploadElectricityBillAttachment,
} from '@/api/Admin'
import { DataTablePagination } from '@/components/DataTable/DataTable'
import { Xdelete } from '@/rtk/helpers/CRUD'
import { get, post, put } from '@/rtk/helpers/apiService'
import {
Box,
Button,
Dialog,
FileButton,
Group,
Modal,
NumberInput,
Text,
Tabs,
Flex,
ActionIcon,
} from '@mantine/core'
import { useForm } from '@mantine/form'
import { notifications } from '@mantine/notifications'
import axios from 'axios'
import {
IconDevices,
IconDownload,
IconEdit,
IconEye,
IconFileInvoice,
IconTrash,
IconUpload,
IconX,
} from '@tabler/icons-react'
import moment from 'moment'
import { useEffect, useState } from 'react'
import classes from './OfficeSupport.module.css'
import { _NOTIFICATION_MESS } from '@/rtk/helpers/notificationMess'
import { getHeaderInfo } from '@/rtk/helpers/tokenCreator'
import { DateInput } from '@mantine/dates'
import EquipmentManagement from './EquipmentManagement'
interface ElectricityBill {
id: number
billing_date: string
previous_reading: number
current_reading: number
unit_price: number
total_amount: number
notes: string | null
file_path: string | null
attachment_path: string | null
created_by: number | null
updated_by: number | null
created_at: string | null
updated_at: string | null
creator_name?: string
updater_name?: string
}
interface ElectricityBillsResponse {
data: ElectricityBill[]
current_page: number
last_page: number
per_page: number
total: number
}
const OfficeSupport = () => {
const [activeTab, setActiveTab] = useState<string | null>('calculate')
const [listBills, setListBills] = useState<ElectricityBillsResponse>({
data: [],
current_page: 1,
last_page: 1,
per_page: 15,
total: 0,
})
const [action, setAction] = useState('')
const [item, setItem] = useState<ElectricityBill | null>(null)
const [activeBtn, setActiveBtn] = useState(false)
const [disableBtn, setDisableBtn] = useState(false)
const [confirmModal, setConfirmModal] = useState(false)
const [confirmMessage, setConfirmMessage] = useState('')
const [confirmValues, setConfirmValues] = useState<any>(null)
const [confirmLoading, setConfirmLoading] = useState(false)
const [attachmentToDelete, setAttachmentToDelete] =
useState<ElectricityBill | null>(null)
const filterInfo: any[] = []
const getAllBills = async (page: number = 1) => {
try {
const params = { page }
const res = await get(getElectricityBills, params)
if (res?.data) {
setListBills(res?.data)
}
} catch (error: any) {
notifications.show({
title: 'Error',
message: error.message ?? error,
color: 'red',
})
}
}
useEffect(() => {
getAllBills()
}, [])
const columns = [
{
name: 'billing_date',
size: '15%',
header: 'Date',
render: (row: ElectricityBill) => {
const date = new Date(row.billing_date)
return <Text fz={'sm'}>{moment(date).format('DD MMMM YYYY')}</Text>
},
},
{
name: 'previous_reading',
size: '15%',
header: 'Previous Reading',
render: (row: ElectricityBill) => (
<Text fz={'sm'}>
{Number(row.previous_reading)?.toLocaleString()} kWh
</Text>
),
},
{
name: 'current_reading',
size: '15%',
header: 'Current Reading',
render: (row: ElectricityBill) => (
<Text fz={'sm'}>
{Number(row.current_reading)?.toLocaleString()} kWh
</Text>
),
},
{
name: '#',
size: '10%',
header: 'Consumption',
render: (row: ElectricityBill) => {
const consumption =
Number(row.current_reading) - Number(row.previous_reading)
return (
<Text fz={'sm'} fw={600}>
{consumption.toLocaleString()} kWh
</Text>
)
},
},
{
name: 'unit_price',
size: '10%',
header: 'Unit Price',
render: (row: ElectricityBill) => (
<Text fz={'sm'}>
{Number(row.unit_price)?.toLocaleString(undefined, {
minimumFractionDigits: 0,
})}{' '}
VNĐ
</Text>
),
},
{
name: 'total_amount',
size: '15%',
header: 'Total Amount',
render: (row: ElectricityBill) => (
<Text fz={'sm'} fw={700} c="green">
{Number(row.total_amount)?.toLocaleString(undefined, {
minimumFractionDigits: 0,
})}{' '}
VNĐ
</Text>
),
},
{
name: 'actions',
size: '15%',
header: 'Actions',
render: (row: ElectricityBill) => {
return (
<Group gap="xs">
<ActionIcon
disabled={disableBtn}
onClick={() => handleExportPdf(row.id, row.billing_date)}
variant="outline"
w={20}
h={20}
color={'blue'}
title="Export PDF"
>
<IconDownload className={classes.deleteIcon} color="blue" />
</ActionIcon>
<FileButton
onChange={(file) => handleUploadAttachment(row.id, file)}
accept="application/pdf,image/jpeg,image/png"
>
{(props) => (
<ActionIcon
{...props}
disabled={disableBtn}
variant="outline"
w={20}
h={20}
color={'purple'}
title={
row.attachment_path
? 'Replace attachment'
: 'Upload attachment'
}
>
<IconUpload className={classes.deleteIcon} color="purple" />
</ActionIcon>
)}
</FileButton>
{row.attachment_path && (
<>
<ActionIcon
disabled={disableBtn}
onClick={() =>
window.open(
buildAttachmentUrl(row.attachment_path as string),
'_blank',
)
}
variant="outline"
w={20}
h={20}
color={'teal'}
title="View attachment"
>
<IconEye className={classes.deleteIcon} color="teal" />
</ActionIcon>
<ActionIcon
disabled={disableBtn}
onClick={() => setAttachmentToDelete(row)}
variant="outline"
w={20}
h={20}
color={'orange'}
title="Remove attachment"
>
<IconX className={classes.deleteIcon} color="orange" />
</ActionIcon>
</>
)}
<ActionIcon
disabled={disableBtn}
onClick={() => {
setItem(row)
setAction('edit')
form.setFieldValue(
'billing_date',
row?.billing_date || moment().format('YYYY-MM-DD'),
)
form.setFieldValue(
'current_reading',
Number(row?.current_reading) || 0,
)
form.setFieldValue(
'previous_reading',
Number(row?.previous_reading) || 0,
)
form.setFieldValue('unit_price', row?.unit_price || 4000)
form.setFieldValue('notes', row?.notes || '')
}}
variant="outline"
w={20}
h={20}
color={'green'}
title="Edit"
>
<IconEdit className={classes.deleteIcon} color="green" />
</ActionIcon>
<ActionIcon
disabled={disableBtn}
onClick={() => {
setAction('delete')
setItem(row)
}}
variant="outline"
w={20}
h={20}
color={'red'}
title="Delete"
>
<IconTrash className={classes.deleteIcon} color="red" />
</ActionIcon>
</Group>
)
},
},
]
const handleCreate = async (values: any) => {
try {
setDisableBtn(true)
const params = {
billing_date: values.billing_date,
previous_reading: values.previous_reading,
current_reading: values.current_reading,
unit_price: values.unit_price,
notes: values.notes || null,
}
let res
if (action === 'add') {
res = await post(createElectricityBill, params)
} else if (action === 'edit' && item) {
res = await put(updateElectricityBill(item.id), params)
}
if (res?.status) {
notifications.show({
title: 'Success',
message:
action === 'add'
? _NOTIFICATION_MESS.create_success
: 'Updated successfully',
color: 'green',
})
setAction('')
form.reset()
// Auto export PDF after creating
if (action === 'add' && res.data?.id) {
handleExportPdf(res.data.id, res.data.billing_date)
}
getAllBills()
} else if (!res?.status && res?.errors) {
if (!res?.data?.status && res?.data?.message) {
setConfirmMessage(res.data?.message)
setConfirmValues(values)
setConfirmModal(true)
} else {
notifications.show({
title: 'Error',
message: res.message ?? _NOTIFICATION_MESS.create_error,
color: 'red',
})
}
}
} catch (error: any) {
if (error.response?.message) {
const errorMess = error.response.message
notifications.show({
title: 'Error',
message: errorMess,
color: 'red',
})
}
} finally {
setDisableBtn(false)
}
}
const handleDelete = async (id: number) => {
try {
await Xdelete(deleteElectricityBill(id), {}, () => getAllBills())
} catch (error) {
console.log(error)
}
}
const handleExportPdf = async (id: number, date: string) => {
try {
setDisableBtn(true)
const header = await getHeaderInfo()
const res = await fetch(exportElectricityBillPdf(id), { ...header })
if (!res.ok) throw new Error('Export failed')
const blob = await res.blob()
const url = window.URL.createObjectURL(blob)
const newDate = moment(new Date(date)).format('DD-M-YYYY')
const a = document.createElement('a')
a.href = url
a.download = `Bảng thanh toán tiền điện APAC - ${newDate}.pdf`
a.click()
// notifications.show({
// title: 'Success',
// message: 'PDF exported successfully',
// color: 'green',
// })
setDisableBtn(false)
} catch (error: any) {
setDisableBtn(false)
notifications.show({
title: 'Error',
message: error.message,
color: 'red',
})
}
}
const handleUploadAttachment = async (id: number, file: File | null) => {
if (!file) return
const allowedTypes = [
'application/pdf',
'image/jpeg',
'image/png',
'image/gif',
'image/webp',
]
if (!allowedTypes.includes(file.type)) {
notifications.show({
title: 'Error',
message: 'Only PDF or image files are allowed',
color: 'red',
})
return
}
if (file.size > 10 * 1024 * 1024) {
notifications.show({
title: 'Error',
message: 'File size must be 10MB or less',
color: 'red',
})
return
}
try {
setDisableBtn(true)
const header = await getHeaderInfo()
header.headers['Content-Type'] = 'multipart/form-data'
const formData = new FormData()
formData.append('file', file)
const res = await axios.post(
uploadElectricityBillAttachment(id),
formData,
header,
)
if (res?.data?.status) {
notifications.show({
title: 'Success',
message: 'Attachment uploaded successfully',
color: 'green',
})
getAllBills(listBills.current_page)
} else {
notifications.show({
title: 'Error',
message: res?.data?.message ?? 'Upload failed',
color: 'red',
})
}
} catch (error: any) {
notifications.show({
title: 'Error',
message: error?.response?.data?.message ?? error.message,
color: 'red',
})
} finally {
setDisableBtn(false)
}
}
const handleDeleteAttachment = async (id: number) => {
try {
setDisableBtn(true)
const header = await getHeaderInfo()
const res = await axios.delete(
deleteElectricityBillAttachment(id),
header,
)
if (res?.data?.status) {
notifications.show({
title: 'Success',
message: 'Attachment deleted successfully',
color: 'green',
})
getAllBills(listBills.current_page)
} else {
notifications.show({
title: 'Error',
message: res?.data?.message ?? 'Delete failed',
color: 'red',
})
}
} catch (error: any) {
notifications.show({
title: 'Error',
message: error?.response?.data?.message ?? error.message,
color: 'red',
})
} finally {
setDisableBtn(false)
setAttachmentToDelete(null)
}
}
const buildAttachmentUrl = (path: string) => {
const base = import.meta.env.VITE_BACKEND_URL
return base.includes('local')
? base + 'storage/' + path
: base + 'image/storage/' + path
}
const getLastReading = () => {
if (!listBills?.data?.length) return 0
const sorted = [...listBills.data].sort(
(a, b) =>
new Date(b.billing_date).getTime() - new Date(a.billing_date).getTime(),
)
return sorted[0] ? Number(sorted[0]?.current_reading) : 0
}
const form = useForm({
initialValues: {
id: 0,
billing_date: moment().format('YYYY-MM-DD'),
previous_reading: 0,
current_reading: 0,
unit_price: 4000,
notes: '',
},
validate: {
billing_date: (value) => (!value ? 'Date is required' : null),
previous_reading: (value) =>
value < 0 ? 'Previous reading must be positive' : null,
current_reading: (value) =>
value < 0 ? 'Current reading must be positive' : null,
unit_price: (value) =>
value <= 0 ? 'Unit price must be greater than 0' : null,
},
})
// Calculate preview
const calculatePreview = () => {
const consumption =
form.values.current_reading - form.values.previous_reading
const total = consumption * form.values.unit_price
return { consumption, total }
}
return (
<div>
<div className={classes.title}>
<h3>Office Support</h3>
</div>
<Box p={20}>
<Tabs value={activeTab} onChange={setActiveTab}>
<Tabs.List>
<Tabs.Tab
value="calculate"
leftSection={<IconFileInvoice size={16} />}
>
Electricity Bill
</Tabs.Tab>
<Tabs.Tab
value="equipment"
leftSection={<IconDevices size={16} />}
>
Equipment Management
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="calculate" pt="md">
{/* Calculate Tab Content */}
<Box>
<Button
m={5}
onClick={() => {
setAction('add')
form.reset()
form.setFieldValue('previous_reading', getLastReading())
form.setFieldValue('current_reading', getLastReading())
}}
>
+ Add New Bill
</Button>
</Box>
{/* History Tab Content */}
{listBills.data.length > 0 ? (
<DataTablePagination
filterInfo={filterInfo}
data={listBills}
columns={columns}
searchInput
size=""
/>
) : (
<Text c="dimmed" ta="center" py="xl">
No electricity bills found.
</Text>
)}
</Tabs.Panel>
<Tabs.Panel value="equipment" pt="md">
<EquipmentManagement />
</Tabs.Panel>
</Tabs>
</Box>
{/* Add/Edit Modal */}
<Modal
opened={action === 'add' || action === 'edit'}
onClose={() => {
setAction('')
setItem(null)
form.reset()
}}
title={
<Text pl={'sm'} fw={700} fz={'lg'}>
{action === 'add' && 'Add Electricity Bill'}
{action === 'edit' && 'Edit Electricity Bill'}
</Text>
}
size="lg"
>
<form
onSubmit={form.onSubmit(async (values) => {
setDisableBtn(true)
await handleCreate(values)
setDisableBtn(false)
})}
>
<Box pl={'md'} pr={'md'}>
<DateInput
required
mb="md"
label="Billing Date"
placeholder="Pick date"
valueFormat="DD-MM-YYYY"
value={
form.values.billing_date
? new Date(form.values.billing_date)
: null
}
error={form.errors.billing_date}
onChange={(date) =>
form.setFieldValue(
'billing_date',
date ? moment(date).format('YYYY-MM-DD') : '',
)
}
/>
<Flex gap={'md'}>
<Box style={{ flex: 1 }}>
<NumberInput
required
mb={'md'}
label={'Previous Reading (kWh)'}
value={form.values.previous_reading}
error={form.errors.previous_reading}
onChange={(e) =>
form.setFieldValue('previous_reading', Number(e))
}
min={0}
thousandSeparator=","
/>
</Box>
<Box style={{ flex: 1 }}>
<NumberInput
required
mb={'md'}
label={'Current Reading (kWh)'}
value={form.values.current_reading}
error={form.errors.current_reading}
onChange={(e) =>
form.setFieldValue('current_reading', Number(e))
}
min={0}
thousandSeparator=","
/>
</Box>
</Flex>
<NumberInput
required
mb={'md'}
label={'Unit Price (VNĐ/kWh)'}
value={form.values.unit_price}
error={form.errors.unit_price}
onChange={(e) => form.setFieldValue('unit_price', Number(e))}
min={0}
thousandSeparator=","
/>
{/* Preview */}
<Box
p="md"
style={{
backgroundColor: '#f8f9fa',
borderRadius: '8px',
marginBottom: '16px',
}}
>
<Text fw={600} mb="sm">
Preview:
</Text>
<Text>
Consumption:{' '}
<Text span fw={600}>
{calculatePreview().consumption.toLocaleString()} kWh
</Text>
</Text>
<Text>
Total Amount:{' '}
<Text span fw={700} c="green" size="lg">
{calculatePreview().total.toLocaleString()} VNĐ
</Text>
</Text>
</Box>
<Box ta={'center'}>
<Button
mt={'lg'}
bg={'green'}
type="submit"
disabled={disableBtn}
>
{action === 'add' ? 'Create & Export PDF' : 'Update'}
</Button>
</Box>
</Box>
</form>
</Modal>
{/* Delete Confirmation Dialog */}
<Dialog
className={classes.dialog}
opened={action === 'delete'}
withCloseButton
onClose={() => setAction('')}
size="lg"
radius="md"
position={{ top: 30, right: 10 }}
>
<Text className={classes.dialogText} size="sm" mb="xs" fw={500}>
Do you want to delete this record?
<Group justify="center" m={10}>
<Button
disabled={activeBtn}
fw={700}
size="xs"
variant="light"
onClick={async () => {
setActiveBtn(true)
if (item) {
await handleDelete(item.id)
}
setActiveBtn(false)
setAction('')
setItem(null)
}}
>
Yes
</Button>
<Button
fw={700}
size="xs"
variant="light"
onClick={() => {
setAction('')
setItem(null)
}}
>
Cancel
</Button>
</Group>
</Text>
</Dialog>
{/* Remove Attachment Confirmation */}
<Modal
opened={!!attachmentToDelete}
onClose={() => setAttachmentToDelete(null)}
title={
<Text fw={700} fz="lg">
Remove Attachment
</Text>
}
centered
>
<Box p="md">
<Text mb={20}>
Are you sure you want to remove the attached file for this bill?
</Text>
<Group justify="center">
<Button
color="red"
disabled={disableBtn}
onClick={() => {
if (attachmentToDelete) {
handleDeleteAttachment(attachmentToDelete.id)
}
}}
>
Remove
</Button>
<Button
variant="default"
disabled={disableBtn}
onClick={() => setAttachmentToDelete(null)}
>
Cancel
</Button>
</Group>
</Box>
</Modal>
{/* Confirm Modal */}
<Modal
opened={confirmModal}
onClose={() => !confirmLoading && setConfirmModal(false)}
title={
<Text fw={700} fz="lg">
Warning
</Text>
}
centered
closeOnClickOutside={!confirmLoading}
closeOnEscape={!confirmLoading}
>
<Box p="md">
<Text style={{ whiteSpace: 'pre-line' }} mb={20}>
{confirmMessage}
</Text>
<Group justify="center">
<Button
color="green"
loading={confirmLoading}
onClick={async () => {
if (confirmValues) {
try {
setConfirmLoading(true)
await handleCreate(confirmValues)
setConfirmLoading(false)
setConfirmModal(false)
} catch (error) {
setConfirmLoading(false)
console.error(error)
}
}
}}
>
Confirm
</Button>
<Button
color="red"
disabled={confirmLoading}
onClick={() => {
setConfirmModal(false)
}}
>
Cancel
</Button>
</Group>
</Box>
</Modal>
</div>
)
}
export default OfficeSupport

View File

@ -1,28 +1,47 @@
import { useEffect, useState } from 'react'
import {
Alert,
Box,
Button,
Code,
Dialog,
Flex,
Grid,
Group,
Loader,
LoadingOverlay,
Modal,
Paper,
Select,
Tabs,
Text,
TextInput,
Title,
} from '@mantine/core'
import classes from './OrganizationSettings.module.css'
import DataTableAll from '@/components/DataTable/DataTable'
import { get, post } from '@/rtk/helpers/apiService'
import { get, post, put } from '@/rtk/helpers/apiService'
import { notifications } from '@mantine/notifications'
import { createTechnical, deleteTechnical, listTechnical } from '@/api/Admin'
import {
createTechnical,
deleteTechnical,
getWorkDay,
listTechnical,
updateWorkDay,
} from '@/api/Admin'
import { useForm } from '@mantine/form'
import { Xdelete } from '@/rtk/helpers/CRUD'
import moment from 'moment'
import { DatePickerInput } from '@mantine/dates'
import { IconInfoCircle } from '@tabler/icons-react'
import dayjs from 'dayjs'
import customParseFormat from 'dayjs/plugin/customParseFormat'
import { checkPermissionAdmin } from '@/rtk/localStorage'
dayjs.extend(customParseFormat)
function OrganizationSettings() {
const [activeTab, setActiveTab] = useState<string | null>('technical')
const [activeTab, setActiveTab] = useState<string | null>('work-day')
return (
<div>
@ -36,22 +55,22 @@ function OrganizationSettings() {
<Box w="100%" display={'flex'} mt={15} ml={10}>
<Tabs w="100%" value={activeTab} onChange={setActiveTab}>
<Tabs.List>
<Tabs.Tab value="technical">Technical Setting</Tabs.Tab>
<Tabs.Tab value="second">Setting 2</Tabs.Tab>
<Tabs.Tab value="third">Setting 3</Tabs.Tab>
<Tabs.Tab value="work-day">Work Day Setting</Tabs.Tab>
{checkPermissionAdmin('admin') && (
<Tabs.Tab value="technical">Technical Setting</Tabs.Tab>
)}
</Tabs.List>
<Tabs.Panel value="technical" pt="xs">
<TechnicalSettingTab />
<Tabs.Panel value="work-day" pt="xs">
<WorkDaySettingTab />
</Tabs.Panel>
<Tabs.Panel value="second" pt="xs">
Setting 2
</Tabs.Panel>
<Tabs.Panel value="third" pt="xs">
Setting 3
</Tabs.Panel>
{checkPermissionAdmin('admin') && (
<Tabs.Panel value="technical" pt="xs">
<TechnicalSettingTab />
</Tabs.Panel>
)}
</Tabs>
</Box>
</div>
@ -100,8 +119,8 @@ const TechnicalSettingTab = () => {
? row?.level === 1
? { backgroundColor: '#d9d2e9' }
: row?.level === 2
? { backgroundColor: '#ffd966' }
: { backgroundColor: '#cfe2f3' }
? { backgroundColor: '#ffd966' }
: { backgroundColor: '#cfe2f3' }
: { backgroundColor: '' }
}
fw={500}
@ -330,4 +349,126 @@ const TechnicalSettingTab = () => {
)
}
const WorkDaySettingTab = () => {
const [workDay, setWorkDay] = useState<any>(null)
const [isLoading, setIsLoading] = useState(false)
const [isSaving, setIsSaving] = useState(false)
const [selectedDate, setSelectedDate] = useState<Date | null>(null)
useEffect(() => {
getWorkDays()
}, [])
const getWorkDays = async () => {
try {
setIsLoading(true)
const res = await get(getWorkDay, {})
if (res.status && res.data?.length > 0) {
const item = res.data[0]
setWorkDay(item)
const parsed = dayjs(item.c_code, 'DD-MM-YYYY').toDate()
setSelectedDate(parsed)
}
} catch (error: any) {
notifications.show({
title: 'Error',
message: error.message ?? error,
color: 'red',
})
} finally {
setIsLoading(false)
}
}
const handleSave = async () => {
if (!selectedDate) return
try {
setIsSaving(true)
const payload = {
c_code: dayjs(selectedDate).format('DD-MM-YYYY'),
}
const res = await put(`${updateWorkDay}`, payload)
if (res.status) {
notifications.show({
title: 'Success',
message: res.message,
color: 'green',
})
}
} catch (error: any) {
notifications.show({
title: 'Error',
message: error.message ?? error,
color: 'red',
})
} finally {
setIsSaving(false)
}
}
const handleReset = () => {
if (!workDay) return
const parsed = dayjs(workDay.c_code, 'DD-MM-YYYY').toDate()
setSelectedDate(parsed)
}
return (
<Box>
<LoadingOverlay visible={isLoading} />
<Title order={4} mb="xs">
Set up Saturday as a working day.
</Title>
<Paper withBorder p="lg" radius="md">
<Grid>
<Grid.Col span={12}>
<DatePickerInput
label="Saturday Work Start Date"
description="Weeks starting from this date will include Saturday as a working day."
placeholder="Select Date"
value={selectedDate}
onChange={setSelectedDate}
valueFormat="DD/MM/YYYY"
clearable={false}
excludeDate={(date) => date.getDay() === 0}
/>
</Grid.Col>
</Grid>
{selectedDate && (
<Alert
mt="md"
variant="light"
color="blue"
icon={<IconInfoCircle size={16} />}
>
Starting from{' '}
<Text span fw={500}>
{dayjs(selectedDate).format('DD/MM/YYYY')}
</Text>
, Saturdays will be treated as working days in applicable weeks.
</Alert>
)}
<Group justify="flex-end" mt="lg" gap="sm">
<Button variant="default" onClick={handleReset} disabled={isSaving}>
Cancel
</Button>
<Button onClick={handleSave} loading={isSaving}>
Save change
</Button>
</Group>
</Paper>
<Paper withBorder p="sm" radius="md" mt="sm" bg="gray.0">
<Text size="xs" c="dimmed">
Setting type:{' '}
<Code>{workDay?.c_type ?? 'SATURDAY_WORK_SCHEDULE'}</Code>
</Text>
</Paper>
</Box>
)
}
export default OrganizationSettings

View File

@ -8,6 +8,7 @@ import PageLogin from '@/pages/Auth/Login/Login'
import Document from '@/pages/Document/Document'
import LeaveManagement from '@/pages/LeaveManagement/LeaveManagement'
import PageNotFound from '@/pages/NotFound/NotFound'
import OfficeSupport from '@/pages/OfficeSupport/OfficeSupport'
import OrganizationSettings from '@/pages/OrganizationSettings/OrganizationSettings'
import Profile from '@/pages/Profile/Profile'
import SprintReview from '@/pages/SprintReview/SprintReview'
@ -239,7 +240,7 @@ const mainRoutes = [
{
path: '/organization-settings',
element: (
<ProtectedRoute mode="route" permission="admin">
<ProtectedRoute mode="route" permission="admin,hr">
<BasePage
main={
<>
@ -264,6 +265,20 @@ const mainRoutes = [
</ProtectedRoute>
),
},
{
path: '/office-support',
element: (
<ProtectedRoute mode="route" permission="admin,hr,accountant">
<BasePage
main={
<>
<OfficeSupport />
</>
}
></BasePage>
</ProtectedRoute>
),
},
// {
// path: '/packages',
// element: (

View File

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

BIN
TrackingToolWeb/.DS_Store vendored Normal file

Binary file not shown.

6
TrackingToolWeb/.gitignore vendored Normal file
View File

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

1
TrackingToolWeb/.vscode/settings.json vendored Normal file
View File

@ -0,0 +1 @@
{}

134
TrackingToolWeb/CLAUDE.md Normal file
View File

@ -0,0 +1,134 @@
# TrackingToolWeb — CLAUDE.md
## Tổng quan dự án
Hệ thống điểm danh khuôn mặt (Face Check-in) tích hợp với Management System tại `ms.prology.net`. Camera nhận diện khuôn mặt → FastAPI backend so khớp → ghi log → đồng bộ sang hệ thống quản lý.
---
## Kiến trúc
```
Frontend (React/Vite) → Backend (FastAPI/Python) → MySQL
External MS API (ms.prology.net)
```
**Backend**: `main.py` (FastAPI) + `api.py` (external calls) + `sync.py` (data sync)
**Frontend**: `client/src/` — React 19, TypeScript, TailwindCSS, Zustand
**Database**: MySQL — database `face_checkin`
**Deployment**: Backend phục vụ luôn frontend build (`static/`) qua route `/`
---
## Commands
### Backend
```bash
# Development
uvicorn main:app --reload
# Production
nohup uvicorn main:app --host 172.16.6.38 --port 8080 > log.log 2>&1 &
```
### Frontend
```bash
cd client
npm run dev # dev server (Vite HMR)
npm run build # build to client/dist/
npm run lint # ESLint
```
### Deploy frontend
Sau khi build, copy `client/dist/` vào `static/`. Đảm bảo asset paths trong `index.html` dùng prefix `/camera/static/assets/`.
---
## Cấu hình
### Backend (hardcoded — cần đưa vào .env)
| Biến | Giá trị hiện tại | File |
|------|-----------------|------|
| DB URL | `mysql+pymysql://root:123@localhost/face_checkin` | `database.py` |
| MS API base | `https://ms.prology.net/api/v1` | `api.py` |
| JWT token | hardcoded string | `api.py` |
| Face threshold | `0.42` | `main.py:217` |
| Ratio threshold | `0.85` | `main.py:286` |
| Recent check window | 0.5 phút | `main.py` |
### Frontend (.env trong `client/`)
```
VITE_API_BASE_URL=/camera # production (proxy qua nginx)
VITE_API_BASE_MS=https://ms.prology.net
```
---
## API Endpoints
| Method | Path | Mô tả |
|--------|------|-------|
| GET | `/` | Phục vụ `static/index.html` |
| POST | `/register` | Đăng ký khuôn mặt (name, email, file ảnh) |
| POST | `/register-simple` | Đăng ký/cập nhật user không cần ảnh |
| POST | `/checkin` | Nhận diện & điểm danh (file ảnh, camera_id) |
| GET | `/logs` | 20 log điểm danh gần nhất |
| GET | `/users` | Danh sách users + 5 checkpoint gần nhất |
---
## Database Schema
```sql
students (id, name, email UNIQUE, avatar)
student_encodings (id, student_id FK, encoding BLOB[1024 bytes = 128 float64], created_at)
checkin_logs (id, student_id FK, time, camera_id, status[check in/check out])
```
**Encoding format**: `np.float64` array 128 chiều → `.tobytes()` → BLOB 1024 bytes
**Giải mã**: `np.frombuffer(blob, dtype=np.float64)` — validate `enc.size == 128`
---
## Logic nhận diện khuôn mặt (`/checkin`)
1. Nhận ảnh JPEG → lưu tạm `uploads/checkin.jpg`
2. `face_recognition.face_encodings()` → encoding 128-dim
3. Load **tất cả** encodings từ DB → so khớp `face_recognition.face_distance()`
4. Chọn student có `min_dist` nhỏ nhất
5. Kiểm tra: `best_distance ≤ 0.42` **AND** `ratio = best/second_best ≤ 0.85`
6. Kiểm tra recent check (tránh điểm danh 2 lần trong 30 giây)
7. Ghi `checkin_logs``BackgroundTask`: gửi ảnh + tạo history trên MS API
**Bottleneck chính**: Bước 3 — load toàn bộ encodings, giải mã numpy, so khớp tuần tự trong request.
---
## External API (ms.prology.net)
- `POST /api/v1/admin/tracking/scan-create` — tạo history check-in
- `POST /api/v1/admin/tracking/send-image` — upload ảnh check-in
- `GET /api/v1/admin/timekeeping` — lấy dữ liệu chấm công (dùng trong `sync.py`)
Token JWT được hardcode trong `api.py` — cần chuyển sang env variable.
---
## Frontend State Management
**Zustand stores:**
- `use-app-store.ts``isAutoChecking`, `isCountDown`, `refreshLog`, video/canvas refs
- `use-user-store.ts``currentUser` (user được chọn cho checkpoint)
**Auto check-in**: interval 3000ms, gọi `/checkin` liên tục khi `isAutoChecking = true`
---
## Các lưu ý quan trọng
- `UPLOAD_DIR = ./uploads/` — lưu ảnh tạm check-in, bị ghi đè mỗi lần (`checkin.jpg`)
- `images/{YYYY_MM_DD}/` — lưu ảnh vĩnh viễn theo ngày (tạo trong `sync.py`)
- DB session trong `/checkin` dùng `Depends(get_db)`, các endpoint khác tạo `SessionLocal()` trực tiếp — cần thống nhất
- Tối đa 10 encodings/user (giới hạn trong `sync.py`)
- CORS `allow_origins=["*"]` — chấp nhận vì deploy nội bộ

11
TrackingToolWeb/README.md Normal file
View File

@ -0,0 +1,11 @@
Run client: cd client && npm run dev or npm run build && npm run preview
==> Build client xong => coppy file asset và index vào folder static của server => thêm prefix static vào link của assets trong file index VD: /camera/static/assets
Run server uvicorn main:app --reload
nohup uvicorn main:app --host 172.16.6.38 --port 8080 > log.log 2>&1 &
ps aux | grep uvicorn
truncate -s 0 log.log

56
TrackingToolWeb/api.py Normal file
View File

@ -0,0 +1,56 @@
import os
import datetime
import requests
from fastapi import UploadFile
URL_API = "http://172.16.6.38:8000/api/v1"
def send_image(id, image_bytes, student_name: str, status: str):
id = str(id)
today = datetime.datetime.now().strftime("%Y_%m_%d")
folder_path = f"./images/{today}"
os.makedirs(folder_path, exist_ok=True)
safe_student = "".join(c for c in student_name if c.isalnum() or c in ("-", "_"))
safe_status = "".join(c for c in status if c.isalnum() or c in ("-", "_"))
timestamp = datetime.datetime.now().strftime("%Y_%m_%d_%H_%M_%S")
file_name = f"{safe_student}_{safe_status}_at_{timestamp}.png"
file_path = os.path.join(folder_path, file_name)
# Lưu xuống
with open(file_path, "wb") as f:
f.write(image_bytes)
# Gửi API
try:
with open(file_path, "rb") as image_file:
response = requests.post(
URL_API + "/admin/tracking/send-image",
data={"id": id, "file_name": file_name},
files={"image": image_file}
)
response.raise_for_status()
except Exception as e:
print("Send image failed:", e)
def create_history(data):
# Gửi yêu cầu POST với dữ liệu đã chỉ định
response = requests.post(URL_API+"/admin/tracking/scan-create", data=data)
res = response.json()
print(res)
return res
def users(params):
# Gửi yêu cầu POST với dữ liệu đã chỉ định
response = requests.get(URL_API+"/admin/timekeeping", params=params, headers={"authorization": "Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJodHRwczovL21zLnByb2xvZ3kubmV0L2FwaS92MS9hZG1pbi9sb2dpbiIsImlhdCI6MTc1Njg2MDQ1OSwiZXhwIjoxNzg4Mzk2NDU5LCJuYmYiOjE3NTY4NjA0NTksImp0aSI6IkRrb0NLbHBKV1pkNnZCN0QiLCJzdWIiOiIxNSIsInBydiI6ImQyZmYyOTMzOWE4YTNlODJjMzU4MmE1YThlNzM5ZGYxNzg5YmIxMmYifQ.DoHqHeAGGxpvzlNQ9dAZjZf2Yl573XCgNBT8ZiSx5N4"})
res = response.json()
return res

45
TrackingToolWeb/camera.py Normal file
View File

@ -0,0 +1,45 @@
import cv2
import requests
# source path/to/venv/bin/activate
API_URL = "http://localhost:8000/checkin" # Đổi lại nếu backend chạy ở địa chỉ khác
CAMERA_ID = "cam_pc_01"
def capture_and_checkin():
cap = cv2.VideoCapture(0) # Dùng camera mặc định (webcam)
if not cap.isOpened():
print("Không mở được camera.")
return
print("Đang mở camera. Nhấn phím 'c' để check-in, 'q' để thoát.")
while True:
ret, frame = cap.read()
if not ret:
print("Không đọc được frame.")
break
cv2.imshow("Camera", frame)
key = cv2.waitKey(1)
if key == ord("q"):
break
elif key == ord("c"):
# Ghi tạm ảnh ra file
filename = "frame.jpg"
cv2.imwrite(filename, frame)
# Gửi ảnh lên server
with open(filename, "rb") as f:
response = requests.post(
API_URL,
files={"file": ("frame.jpg", f, "image/jpeg")},
data={"camera_id": CAMERA_ID}
)
print("📡 Server:", response.json())
cap.release()
cv2.destroyAllWindows()
if __name__ == "__main__":
capture_and_checkin()

View File

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

25
TrackingToolWeb/client/.gitignore vendored Normal file
View File

@ -0,0 +1,25 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
*.env

View File

@ -0,0 +1,75 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## React Compiler
The React Compiler is enabled on this template. See [this documentation](https://react.dev/learn/react-compiler) for more information.
Note: This will impact Vite dev & build performances.
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

View File

@ -0,0 +1,22 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/index.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {}
}

View File

@ -0,0 +1,26 @@
import js from "@eslint/js";
import globals from "globals";
import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh";
import tseslint from "typescript-eslint";
import { defineConfig, globalIgnores } from "eslint/config";
export default defineConfig([
globalIgnores(["dist"]),
{
files: ["**/*.{ts,tsx}"],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
rules: {
"eslint-disable @typescript-eslint/no-explicit-any": "off",
},
},
]);

View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>client</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

5307
TrackingToolWeb/client/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,55 @@
{
"name": "client",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@hookform/resolvers": "^5.2.2",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.13",
"@tailwindcss/vite": "^4.1.17",
"axios": "^1.13.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"face-api.js": "^0.22.2",
"framer-motion": "^12.23.25",
"lucide-react": "^0.556.0",
"moment": "^2.30.1",
"next-themes": "^0.4.6",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-hook-form": "^7.68.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.17",
"zod": "^4.1.13",
"zustand": "^5.0.9"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/node": "^24.10.2",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"babel-plugin-react-compiler": "^1.0.0",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"tw-animate-css": "^1.4.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.46.4",
"vite": "^7.2.4"
}
}

View File

@ -0,0 +1 @@
[{"weights":[{"name":"conv0/filters","shape":[3,3,3,16],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.009007044399485869,"min":-1.2069439495311063}},{"name":"conv0/bias","shape":[16],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.005263455241334205,"min":-0.9211046672334858}},{"name":"conv1/depthwise_filter","shape":[3,3,16,1],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.004001977630690033,"min":-0.5042491814669441}},{"name":"conv1/pointwise_filter","shape":[1,1,16,32],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.013836609615999109,"min":-1.411334180831909}},{"name":"conv1/bias","shape":[32],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.0015159862590771096,"min":-0.30926119685173037}},{"name":"conv2/depthwise_filter","shape":[3,3,32,1],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.002666276225856706,"min":-0.317286870876948}},{"name":"conv2/pointwise_filter","shape":[1,1,32,64],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.015265831292844286,"min":-1.6792414422128714}},{"name":"conv2/bias","shape":[64],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.0020280554598453,"min":-0.37113414915168985}},{"name":"conv3/depthwise_filter","shape":[3,3,64,1],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.006100742489683862,"min":-0.8907084034938438}},{"name":"conv3/pointwise_filter","shape":[1,1,64,128],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.016276211832083907,"min":-2.0508026908425725}},{"name":"conv3/bias","shape":[128],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.003394414279975143,"min":-0.7637432129944072}},{"name":"conv4/depthwise_filter","shape":[3,3,128,1],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.006716050119961009,"min":-0.8059260143953211}},{"name":"conv4/pointwise_filter","shape":[1,1,128,256],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.021875603993733724,"min":-2.8875797271728514}},{"name":"conv4/bias","shape":[256],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.0041141652009066415,"min":-0.8187188749804216}},{"name":"conv5/depthwise_filter","shape":[3,3,256,1],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.008423839597141042,"min":-0.9013508368940915}},{"name":"conv5/pointwise_filter","shape":[1,1,256,512],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.030007277283014035,"min":-3.8709387695088107}},{"name":"conv5/bias","shape":[512],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.008402082966823203,"min":-1.4871686851277068}},{"name":"conv8/filters","shape":[1,1,512,25],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.028336129469030042,"min":-4.675461362389957}},{"name":"conv8/bias","shape":[25],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.002268134028303857,"min":-0.41053225912299807}}],"paths":["tiny_face_detector_model-shard1"]}]

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

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

View File

@ -0,0 +1,68 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import axios from "./axios";
class CheckingApi {
async logs() {
return await axios({
method: "GET",
url: "logs",
});
}
async users() {
return await axios({
method: "GET",
url: "users",
});
}
async register({ file, user }: { user: IUser; file: any }) {
const formData = new FormData();
formData.append("file", file, "frame.jpg");
for (const [key, value] of Object.entries(user)) {
formData.append(key, value);
}
return await axios({
headers: {
"Content-Type": "multipart/form-data",
},
method: "POST",
url: "/register",
data: formData,
});
}
async registerSimple({ user }: { user: IUser }) {
const formData = new FormData();
for (const [key, value] of Object.entries(user)) {
formData.append(key, value);
}
return await axios({
headers: {
"Content-Type": "multipart/form-data",
},
method: "POST",
url: "/register-simple",
data: formData,
});
}
async checkin({ file }: { file: any }) {
const formData = new FormData();
formData.append("file", file, "frame.jpg");
return await axios({
headers: {
"Content-Type": "multipart/form-data",
},
method: "POST",
url: "/checkin",
data: formData,
});
}
}
export const checkingApi = new CheckingApi();

View File

@ -0,0 +1,23 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import axios from "axios";
class MsApi {
async timekeepings() {
return await axios({
headers: {
Authorization:
"Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJodHRwczovL21zLnByb2xvZ3kubmV0L2FwaS92MS9hZG1pbi9sb2dpbiIsImlhdCI6MTc1Njg2MDQ1OSwiZXhwIjoxNzg4Mzk2NDU5LCJuYmYiOjE3NTY4NjA0NTksImp0aSI6IkRrb0NLbHBKV1pkNnZCN0QiLCJzdWIiOiIxNSIsInBydiI6ImQyZmYyOTMzOWE4YTNlODJjMzU4MmE1YThlNzM5ZGYxNzg5YmIxMmYifQ.DoHqHeAGGxpvzlNQ9dAZjZf2Yl573XCgNBT8ZiSx5N4",
},
baseURL: import.meta.env.VITE_API_BASE_MS + "/api/v1/admin",
method: "GET",
url: "timekeeping",
params: {
month: new Date().getMonth(),
year: new Date().getFullYear(),
},
});
}
}
export const msApi = new MsApi();

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@ -0,0 +1,125 @@
/* eslint-disable react-refresh/only-export-components */
"use client";
import type React from "react";
import { createContext, useContext, useState, useCallback } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { AlertTriangle, Info, CheckCircle } from "lucide-react";
interface ConfirmOptions {
title?: string;
message: string;
confirmText?: string;
cancelText?: string;
variant?: "default" | "destructive" | "warning" | "success";
}
interface ConfirmContextType {
confirm: (options: ConfirmOptions) => Promise<boolean>;
}
const ConfirmContext = createContext<ConfirmContextType | undefined>(undefined);
export function ConfirmModalProvider({
children,
}: {
children: React.ReactNode;
}) {
const [isOpen, setIsOpen] = useState(false);
const [options, setOptions] = useState<ConfirmOptions>({
message: "",
variant: "default",
});
const [resolveCallback, setResolveCallback] = useState<
((value: boolean) => void) | null
>(null);
const confirm = useCallback((options: ConfirmOptions): Promise<boolean> => {
setOptions({
title: options.title || "Xác nhận",
confirmText: options.confirmText || "Xác nhận",
cancelText: options.cancelText || "Hủy",
variant: options.variant || "default",
...options,
});
setIsOpen(true);
return new Promise<boolean>((resolve) => {
setResolveCallback(() => resolve);
});
}, []);
const handleConfirm = () => {
setIsOpen(false);
resolveCallback?.(true);
setResolveCallback(null);
};
const handleCancel = () => {
setIsOpen(false);
resolveCallback?.(false);
setResolveCallback(null);
};
const getIcon = () => {
switch (options.variant) {
case "destructive":
case "warning":
return <AlertTriangle className="h-6 w-6 text-destructive" />;
case "success":
return <CheckCircle className="h-6 w-6 text-green-600" />;
default:
return <Info className="h-6 w-6 text-blue-600" />;
}
};
return (
<ConfirmContext.Provider value={{ confirm }}>
{children}
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogContent>
<DialogHeader>
<div className="flex items-center gap-3">
{getIcon()}
<DialogTitle className="text-lg">{options.title}</DialogTitle>
</div>
<DialogDescription className="pt-2 text-base">
{options.message}
</DialogDescription>
</DialogHeader>
<DialogFooter className="gap-2 sm:gap-0 flex ">
<div className="flex items-center gap-2">
<Button variant="outline" onClick={handleCancel}>
{options.cancelText}
</Button>
<Button
variant={
options.variant === "destructive" ? "destructive" : "default"
}
onClick={handleConfirm}
>
{options.confirmText}
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
</ConfirmContext.Provider>
);
}
export function useConfirm() {
const context = useContext(ConfirmContext);
if (!context) {
throw new Error("useConfirm must be used within ConfirmModalProvider");
}
return context.confirm;
}

View File

@ -0,0 +1,51 @@
import * as React from "react"
import * as AvatarPrimitive from "@radix-ui/react-avatar"
import { cn } from "@/lib/utils"
function Avatar({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
className={cn(
"relative flex size-8 shrink-0 overflow-hidden rounded-full",
className
)}
{...props}
/>
)
}
function AvatarImage({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn("aspect-square size-full", className)}
{...props}
/>
)
}
function AvatarFallback({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
"bg-muted flex size-full items-center justify-center rounded-full",
className
)}
{...props}
/>
)
}
export { Avatar, AvatarImage, AvatarFallback }

View File

@ -0,0 +1,46 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant,
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "span"
return (
<Comp
data-slot="badge"
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }

View File

@ -0,0 +1,60 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

View File

@ -0,0 +1,92 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

View File

@ -0,0 +1,141 @@
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}

View File

@ -0,0 +1,255 @@
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
)
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
)
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className
)}
{...props}
/>
)
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
)
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className
)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}

View File

@ -0,0 +1,165 @@
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { Slot } from "@radix-ui/react-slot"
import {
Controller,
FormProvider,
useFormContext,
useFormState,
type ControllerProps,
type FieldPath,
type FieldValues,
} from "react-hook-form"
import { cn } from "@/lib/utils"
import { Label } from "@/components/ui/label"
const Form = FormProvider
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = {
name: TName
}
const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue
)
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
)
}
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext)
const itemContext = React.useContext(FormItemContext)
const { getFieldState } = useFormContext()
const formState = useFormState({ name: fieldContext.name })
const fieldState = getFieldState(fieldContext.name, formState)
if (!fieldContext) {
throw new Error("useFormField should be used within <FormField>")
}
const { id } = itemContext
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
}
}
type FormItemContextValue = {
id: string
}
const FormItemContext = React.createContext<FormItemContextValue>(
{} as FormItemContextValue
)
function FormItem({ className, ...props }: React.ComponentProps<"div">) {
const id = React.useId()
return (
<FormItemContext.Provider value={{ id }}>
<div
data-slot="form-item"
className={cn("grid gap-2", className)}
{...props}
/>
</FormItemContext.Provider>
)
}
function FormLabel({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
const { error, formItemId } = useFormField()
return (
<Label
data-slot="form-label"
data-error={!!error}
className={cn("data-[error=true]:text-destructive", className)}
htmlFor={formItemId}
{...props}
/>
)
}
function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
return (
<Slot
data-slot="form-control"
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
{...props}
/>
)
}
function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
const { formDescriptionId } = useFormField()
return (
<p
data-slot="form-description"
id={formDescriptionId}
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
const { error, formMessageId } = useFormField()
const body = error ? String(error?.message ?? "") : props.children
if (!body) {
return null
}
return (
<p
data-slot="form-message"
id={formMessageId}
className={cn("text-destructive text-sm", className)}
{...props}
>
{body}
</p>
)
}
export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
}

View File

@ -0,0 +1,21 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
{...props}
/>
)
}
export { Input }

View File

@ -0,0 +1,24 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cn } from "@/lib/utils"
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }

View File

@ -0,0 +1,56 @@
import * as React from "react"
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
import { cn } from "@/lib/utils"
function ScrollArea({
className,
children,
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn("relative", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
)
}
function ScrollBar({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
return (
<ScrollAreaPrimitive.ScrollAreaScrollbar
data-slot="scroll-area-scrollbar"
orientation={orientation}
className={cn(
"flex touch-none p-px transition-colors select-none",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb
data-slot="scroll-area-thumb"
className="bg-border relative flex-1 rounded-full"
/>
</ScrollAreaPrimitive.ScrollAreaScrollbar>
)
}
export { ScrollArea, ScrollBar }

View File

@ -0,0 +1,38 @@
import {
CircleCheckIcon,
InfoIcon,
Loader2Icon,
OctagonXIcon,
TriangleAlertIcon,
} from "lucide-react"
import { useTheme } from "next-themes"
import { Toaster as Sonner, type ToasterProps } from "sonner"
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
icons={{
success: <CircleCheckIcon className="size-4" />,
info: <InfoIcon className="size-4" />,
warning: <TriangleAlertIcon className="size-4" />,
error: <OctagonXIcon className="size-4" />,
loading: <Loader2Icon className="size-4 animate-spin" />,
}}
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
"--border-radius": "var(--radius)",
} as React.CSSProperties
}
{...props}
/>
)
}
export { Toaster }

View File

@ -0,0 +1,64 @@
import * as React from "react";
import * as TabsPrimitive from "@radix-ui/react-tabs";
import { cn } from "@/lib/utils";
function Tabs({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
);
}
function TabsList({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.List>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
className={cn(
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
className
)}
{...props}
/>
);
}
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
);
}
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn("flex-1 outline-none", className)}
{...props}
/>
);
}
export { Tabs, TabsList, TabsTrigger, TabsContent };

View File

@ -0,0 +1,120 @@
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}

View File

@ -0,0 +1,15 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
interface ILog {
name: string;
time: string;
camera_id: string;
status: string;
}
interface IUser {
id: string;
name: string;
email: string;
avatar?: string | null;
checkpoints?: any[];
}

View File

@ -0,0 +1,27 @@
export function speak({
type,
str,
}: {
type?: "check out" | "check in";
str?: string;
}) {
// Ưu tiên str, nếu không có thì dùng type
const text =
str ||
(type === "check in"
? "Check-in successful"
: type === "check out"
? "Check-out successful"
: "");
if (!text) return; // không có gì để đọc
const utterance = new SpeechSynthesisUtterance(text);
utterance.lang = "en-US";
utterance.rate = 1;
utterance.pitch = 1;
utterance.volume = 1;
speechSynthesis.speak(utterance);
}

View File

@ -0,0 +1,388 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { useEffect, useRef, useState, type RefObject } from "react";
export type FaceZoomTransform = {
scale: number;
translateX: number; // percent of element width
translateY: number; // percent of element height
};
export type FaceBox = {
/** All values are ratios (0..1) of the source video frame. */
x: number;
y: number;
width: number;
height: number;
};
export type FaceZoomResult = {
transform: FaceZoomTransform;
box: FaceBox | null;
/** Detector confidence (0..1). */
confidence: number;
/** 0..1 fill progress of the stable-face timer. Updates at 60fps. */
stabilityProgress: number;
};
const IDLE_TRANSFORM: FaceZoomTransform = {
scale: 1,
translateX: 0,
translateY: 0,
};
type Options = {
enabled?: boolean;
intervalMs?: number;
targetFaceRatio?: number;
minScale?: number;
maxScale?: number;
/** Per-frame lerp factor at 60fps (0..1). Higher = snappier. */
followSpeed?: number;
/** EMA weight on each new detection (0..1). Lower = smoother target. */
detectionSmoothing?: number;
detectorInputSize?: number;
detectorScoreThreshold?: number;
// --- Stable-face auto trigger ---
/** Enable the stable-face callback. */
stabilityEnabled?: boolean;
/** Continuous duration (ms) the same face must stay before firing. */
stableMs?: number;
/** Max center movement (frame ratio) to still count as same face. */
stabilityPositionThreshold?: number;
/** Max relative face-size change to still count as same face. */
stabilitySizeThreshold?: number;
/** Fires once when stability duration is met; re-arms on absence/new person. */
onStableFace?: () => void;
};
type FilteredSample = {
fx: number; // face center x ratio
fy: number; // face center y ratio
fh: number; // face height ratio
};
export function useFaceZoom(
videoRef: RefObject<HTMLVideoElement | null>,
{
enabled = true,
intervalMs = 120,
targetFaceRatio = 0.6,
minScale = 1.35,
maxScale = 4,
followSpeed = 0.11,
detectionSmoothing = 0.35,
detectorInputSize = 416,
detectorScoreThreshold = 0.3,
stabilityEnabled = false,
stableMs = 2000,
stabilityPositionThreshold = 0.12,
stabilitySizeThreshold = 0.4,
onStableFace,
}: Options = {}
): FaceZoomResult {
const [transform, setTransform] = useState<FaceZoomTransform>(IDLE_TRANSFORM);
const [box, setBox] = useState<FaceBox | null>(null);
const [confidence, setConfidence] = useState(0);
const [stabilityProgress, setStabilityProgress] = useState(0);
const stabilityProgressRef = useRef(0);
const currentRef = useRef<FaceZoomTransform>(IDLE_TRANSFORM);
const targetRef = useRef<FaceZoomTransform>(IDLE_TRANSFORM);
const filteredRef = useRef<FilteredSample | null>(null);
const filteredBoxRef = useRef<FaceBox | null>(null);
const filteredConfRef = useRef(0);
const missCountRef = useRef(0);
// Stability state — kept in refs so they survive ticks without re-rendering.
const stableSinceRef = useRef<number | null>(null);
const stableFiredRef = useRef(false);
const stablePrevRef = useRef<FilteredSample | null>(null);
// Latest stability config (so updating it doesn't tear down the detection loop).
const stabilityCfgRef = useRef({
enabled: stabilityEnabled,
stableMs,
positionThreshold: stabilityPositionThreshold,
sizeThreshold: stabilitySizeThreshold,
onStableFace,
});
useEffect(() => {
stabilityCfgRef.current = {
enabled: stabilityEnabled,
stableMs,
positionThreshold: stabilityPositionThreshold,
sizeThreshold: stabilitySizeThreshold,
onStableFace,
};
// When disabling, also clear pending state so it re-arms cleanly next time.
if (!stabilityEnabled) {
stableSinceRef.current = null;
stableFiredRef.current = false;
stablePrevRef.current = null;
}
}, [
stabilityEnabled,
stableMs,
stabilityPositionThreshold,
stabilitySizeThreshold,
onStableFace,
]);
useEffect(() => {
if (!enabled) {
currentRef.current = IDLE_TRANSFORM;
targetRef.current = IDLE_TRANSFORM;
filteredRef.current = null;
filteredBoxRef.current = null;
filteredConfRef.current = 0;
setTransform(IDLE_TRANSFORM);
setBox(null);
setConfidence(0);
return;
}
let cancelled = false;
let intervalId: number | null = null;
let rafId: number | null = null;
let lastTs = 0;
let faceapi: any = null;
let detectorOptions: any = null;
let running = false;
const animate = (ts: number) => {
if (cancelled) return;
const dt = lastTs ? (ts - lastTs) / 1000 : 1 / 60;
lastTs = ts;
// Frame-rate-aware lerp: stays consistent at 60/120Hz.
const k = 1 - Math.pow(1 - followSpeed, dt * 60);
const cur = currentRef.current;
const tgt = targetRef.current;
const next: FaceZoomTransform = {
scale: cur.scale + (tgt.scale - cur.scale) * k,
translateX: cur.translateX + (tgt.translateX - cur.translateX) * k,
translateY: cur.translateY + (tgt.translateY - cur.translateY) * k,
};
currentRef.current = next;
const dScale = Math.abs(next.scale - tgt.scale);
const dTx = Math.abs(next.translateX - tgt.translateX);
const dTy = Math.abs(next.translateY - tgt.translateY);
// Skip re-render when essentially settled — avoids React churn.
if (dScale > 0.0005 || dTx > 0.02 || dTy > 0.02) {
setTransform(next);
} else if (
next.scale !== tgt.scale ||
next.translateX !== tgt.translateX ||
next.translateY !== tgt.translateY
) {
currentRef.current = tgt;
setTransform(tgt);
}
// Smooth 60fps fill of the stability progress (independent of the
// ~110ms detector tick → no stair-step in the progress bar).
const cfg = stabilityCfgRef.current;
let nextProgress = 0;
if (cfg.enabled) {
if (stableFiredRef.current) {
nextProgress = 1;
} else if (stableSinceRef.current !== null && cfg.stableMs > 0) {
nextProgress = Math.min(
1,
(ts - stableSinceRef.current) / cfg.stableMs
);
}
}
if (Math.abs(nextProgress - stabilityProgressRef.current) > 0.003) {
stabilityProgressRef.current = nextProgress;
setStabilityProgress(nextProgress);
} else if (
(nextProgress === 0 || nextProgress === 1) &&
stabilityProgressRef.current !== nextProgress
) {
// Snap to exact endpoints so the bar fully clears / fills.
stabilityProgressRef.current = nextProgress;
setStabilityProgress(nextProgress);
}
rafId = requestAnimationFrame(animate);
};
const tick = async () => {
if (running) return;
const video = videoRef.current;
if (!video || video.readyState < 2 || !video.videoWidth) return;
running = true;
try {
const detection = await faceapi.detectSingleFace(video, detectorOptions);
if (cancelled) return;
if (!detection) {
missCountRef.current += 1;
// Stability resets quickly so the timer truly restarts on absence.
if (missCountRef.current >= 2) {
stableSinceRef.current = null;
stableFiredRef.current = false;
stablePrevRef.current = null;
}
// Hold zoom position longer to avoid jarring zoom-out on brief misses.
if (missCountRef.current >= 25) {
targetRef.current = IDLE_TRANSFORM;
filteredRef.current = null;
filteredBoxRef.current = null;
filteredConfRef.current = 0;
setBox(null);
setConfidence(0);
}
return;
}
missCountRef.current = 0;
const { x, y, width, height } = detection.box;
const score = detection.score ?? detection.classScore ?? 0;
const vw = video.videoWidth;
const vh = video.videoHeight;
if (!vw || !vh) return;
const sample: FilteredSample = {
fx: (x + width / 2) / vw,
fy: (y + height / 2) / vh,
fh: height / vh,
};
const sampleBox: FaceBox = {
x: x / vw,
y: y / vh,
width: width / vw,
height: height / vh,
};
const a = detectionSmoothing;
const prev = filteredRef.current;
const filt: FilteredSample = prev
? {
fx: prev.fx + (sample.fx - prev.fx) * a,
fy: prev.fy + (sample.fy - prev.fy) * a,
fh: prev.fh + (sample.fh - prev.fh) * a,
}
: sample;
filteredRef.current = filt;
const prevBox = filteredBoxRef.current;
const filtBox: FaceBox = prevBox
? {
x: prevBox.x + (sampleBox.x - prevBox.x) * a,
y: prevBox.y + (sampleBox.y - prevBox.y) * a,
width: prevBox.width + (sampleBox.width - prevBox.width) * a,
height: prevBox.height + (sampleBox.height - prevBox.height) * a,
}
: sampleBox;
filteredBoxRef.current = filtBox;
setBox(filtBox);
const filtConf =
filteredConfRef.current + (score - filteredConfRef.current) * a;
filteredConfRef.current = filtConf;
setConfidence(filtConf);
// --- Stable-face tracking (uses RAW sample so a new person trips it
// immediately, before EMA can drift toward the new position). ---
const cfg = stabilityCfgRef.current;
if (cfg.enabled) {
const prevS = stablePrevRef.current;
const now = performance.now();
let sameFace = false;
if (prevS) {
const dx = Math.abs(sample.fx - prevS.fx);
const dy = Math.abs(sample.fy - prevS.fy);
const dh =
Math.abs(sample.fh - prevS.fh) / Math.max(prevS.fh, 0.01);
sameFace =
dx < cfg.positionThreshold &&
dy < cfg.positionThreshold &&
dh < cfg.sizeThreshold;
}
if (!sameFace) {
// First detection after absence OR a different person → restart timer.
stableSinceRef.current = now;
stableFiredRef.current = false;
} else if (
!stableFiredRef.current &&
stableSinceRef.current !== null &&
now - stableSinceRef.current >= cfg.stableMs
) {
stableFiredRef.current = true;
try {
cfg.onStableFace?.();
} catch (e) {
console.error("onStableFace handler threw", e);
}
}
stablePrevRef.current = sample;
}
let scale = targetFaceRatio / Math.max(filt.fh, 0.05);
scale = Math.max(minScale, Math.min(scale, maxScale));
const halfViewX = 0.5 / scale;
const halfViewY = 0.5 / scale;
const fx = Math.min(1 - halfViewX, Math.max(halfViewX, filt.fx));
const fy = Math.min(1 - halfViewY, Math.max(halfViewY, filt.fy));
targetRef.current = {
scale,
translateX: (0.5 - fx * scale) * 100,
translateY: (0.5 - fy * scale) * 100,
};
} catch (err) {
console.debug("face detect error", err);
} finally {
running = false;
}
};
const load = async () => {
try {
faceapi = await import("face-api.js");
const modelUrl = `${import.meta.env.BASE_URL}models`.replace(
/\/\/+/g,
"/"
);
if (!faceapi.nets.tinyFaceDetector.isLoaded) {
await faceapi.nets.tinyFaceDetector.loadFromUri(modelUrl);
}
if (cancelled) return;
detectorOptions = new faceapi.TinyFaceDetectorOptions({
inputSize: detectorInputSize,
scoreThreshold: detectorScoreThreshold,
});
intervalId = window.setInterval(tick, intervalMs);
rafId = requestAnimationFrame(animate);
} catch (err) {
console.error("[useFaceZoom] failed to load face-api models", err);
}
};
load();
return () => {
cancelled = true;
if (intervalId !== null) clearInterval(intervalId);
if (rafId !== null) cancelAnimationFrame(rafId);
};
}, [
videoRef,
enabled,
intervalMs,
targetFaceRatio,
minScale,
maxScale,
followSpeed,
detectionSmoothing,
detectorInputSize,
detectorScoreThreshold,
]);
return { transform, box, confidence, stabilityProgress };
}

View File

@ -0,0 +1,82 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { clsx, type ClassValue } from "clsx";
import moment from "moment";
import { twMerge } from "tailwind-merge";
import useAppStore from "@/stores/use-app-store";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export const formatTime = (timeString: string) => {
return moment(timeString).format("DD/MM/YYYY HH:mm:ss");
};
export function capture(videoRef: any, canvasRef: any) {
if (!videoRef.current || !canvasRef.current) return;
const canvas = canvasRef.current;
const video = videoRef.current;
const context = canvas.getContext("2d");
const vw: number = video.videoWidth;
const vh: number = video.videoHeight;
// Crop to the same region the user sees on screen (zoomed/focused view).
// The video element has transform: translate(tx%, ty%) scale(s) with
// transform-origin (0,0). The visible source region in video coords is:
// srcX = (-tx/100)/s * vw
// srcY = (-ty/100)/s * vh
// srcW = vw / s
// srcH = vh / s
let srcX = 0;
let srcY = 0;
let srcW = vw;
let srcH = vh;
const t = useAppStore.getState().faceZoomTransform;
if (t && t.scale > 1.0001) {
const s = t.scale;
srcW = vw / s;
srcH = vh / s;
srcX = (-t.translateX / 100 / s) * vw;
srcY = (-t.translateY / 100 / s) * vh;
// Clamp inside the source frame (the on-screen clamp already prevents
// empty edges, but rounding can drift a sub-pixel out of range).
srcX = Math.max(0, Math.min(vw - srcW, srcX));
srcY = Math.max(0, Math.min(vh - srcH, srcY));
}
return new Promise((resolve, reject) => {
try {
canvas.width = Math.round(srcW);
canvas.height = Math.round(srcH);
context.drawImage(
video,
srcX,
srcY,
srcW,
srcH,
0,
0,
canvas.width,
canvas.height
);
canvas.toBlob(
(blob: unknown) => {
if (!blob) {
reject("Không thể tạo blob từ canvas");
return;
}
resolve(blob);
},
"image/jpeg",
0.95 // chất lượng cao
);
} catch (error) {
reject(error);
}
});
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,117 @@
/* 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 dữ liệu điểm danh</p>
</div>
)}
</TabsContent>
);
}

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,13 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
],
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}
}

View File

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

View File

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

View File

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

10
TrackingToolWeb/log.log Normal file
View File

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

483
TrackingToolWeb/main.py Normal file
View File

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

38
TrackingToolWeb/models.py Normal file
View File

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

6
TrackingToolWeb/package-lock.json generated Normal file
View File

@ -0,0 +1,6 @@
{
"name": "TrackingToolWeb",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}

View File

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

View File

@ -0,0 +1,22 @@
<!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>
<script
type="module"
crossorigin
src="/camera/static/assets/index-_MGhSlOr.js"
></script>
<link
rel="stylesheet"
crossorigin
href="/camera/static/assets/index-B9NPk65I.css"
/>
</head>
<body>
<div id="root"></div>
</body>
</html>

Some files were not shown because too many files have changed in this diff Show More