Compare commits

...

117 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
joseph b35d4d7ca5 Merge pull request 'dev' (#140) from dev into master
Reviewed-on: #140
2025-08-05 19:13:38 +10:00
joseph 522034ab76 Merge pull request 'update timekeeping, leave UI' (#139) from vi into dev
Reviewed-on: #139
2025-08-05 19:13:12 +10:00
dbdbd9 487f4227d8 Merge branch 'dev' of https://gitea.nswteam.net/joseph/ManagementSystem into vi 2025-08-05 16:10:32 +07:00
dbdbd9 56d2889297 update timekeeping, leave UI 2025-08-05 16:08:48 +07:00
joseph 7c36add579 Merge pull request 'dev' (#138) from dev into master
Reviewed-on: #138
2025-08-05 18:27:42 +10:00
joseph 6c61819f2e Merge pull request 'update handle for separated user' (#137) from vi into dev
Reviewed-on: #137
2025-08-05 18:26:09 +10:00
dbdbd9 f649b2a6ac Merge branch 'dev' of https://gitea.nswteam.net/joseph/ManagementSystem into vi 2025-08-05 15:19:15 +07:00
dbdbd9 d335803cf8 update handle for separated user 2025-08-05 15:14:50 +07:00
joseph da0bb9ad0d Merge pull request 'dev' (#136) from dev into master
Reviewed-on: #136
2025-07-31 12:47:25 +10:00
joseph d3d9acb1ce Merge pull request 'fix calculate work time' (#135) from vi.ticket-email into dev
Reviewed-on: #135
2025-07-31 12:32:10 +10:00
dbdbd9 a4751e9d38 Merge branch 'dev' of https://gitea.nswteam.net/joseph/ManagementSystem into vi.ticket-email 2025-07-31 09:15:59 +07:00
dbdbd9 b52537cfe4 fix calculate work time 2025-07-31 09:14:33 +07:00
joseph efaf906f0a Merge pull request 'dev' (#134) from dev into master
Reviewed-on: #134
2025-07-02 12:54:43 +10:00
joseph 3e865e5575 Merge pull request 'update permission display for leave management' (#133) from vi.ticket-email into dev
Reviewed-on: #133
2025-07-02 11:23:51 +10:00
dbdbd9 4f6f3dd803 Merge branch 'dev' of https://gitea.nswteam.net/joseph/ManagementSystem into vi.ticket-email 2025-07-02 07:46:58 +07:00
dbdbd9 ee281986b8 update permission display for leave management 2025-07-01 16:36:21 +07:00
joseph 6e1e452bf1 Merge pull request 'Deploy to prod' (#132) from dev into master
Reviewed-on: #132
2025-07-01 17:54:53 +10:00
joseph 6528b7bc59 Merge pull request 'add update note status' (#131) from vi.ticket-email into dev
Reviewed-on: #131
2025-07-01 17:26:34 +10:00
dbdbd9 fd1be27414 Merge branch 'dev' of https://gitea.nswteam.net/joseph/ManagementSystem into vi.ticket-email 2025-07-01 13:41:05 +07:00
dbdbd9 d064b242e6 add update note status 2025-07-01 13:40:48 +07:00
joseph 8f6977be72 Merge pull request 'dev' (#130) from dev into master
Reviewed-on: #130
2025-06-30 18:37:38 +10:00
joseph af67644416 Merge pull request 'adjust UI, add send mail when delete note/update refuse ticket' (#129) from vi.ticket-email into dev
Reviewed-on: #129
2025-06-30 18:14:26 +10:00
dbdbd9 705e8f9216 adjust UI, add send mail when delete note/update refuse ticket 2025-06-30 14:58:06 +07:00
joseph db4feedde3 Merge pull request 'update handle calculate onleave ticket in next months' (#128) from vi.ticket-email into dev
Reviewed-on: #128
2025-06-30 16:11:00 +10:00
dbdbd9 27b59ae939 update msg 2025-06-30 11:04:44 +07:00
dbdbd9 034b9eee2c update handle calculate onleave ticket in next months 2025-06-30 10:57:02 +07:00
joseph 543b1af58c Merge pull request 'update UI leave management, add func update old data' (#127) from vi.ticket-email into dev
Reviewed-on: #127
2025-06-27 12:51:16 +10:00
dbdbd9 d09fa6d432 comment log 2025-06-27 09:48:15 +07:00
dbdbd9 40f80579dc update UI leave management, add func update old data 2025-06-27 09:45:21 +07:00
joseph b1e7aaa1ea Merge pull request 'fix calculate onleave day' (#126) from vi.ticket-email into dev
Reviewed-on: #126
2025-06-25 13:37:41 +10:00
hoangvi.ng a883662f0b fix calculate onleave day 2025-06-25 10:34:02 +07:00
joseph a11f67f252 Merge pull request 'refactor create, handle ticket' (#125) from vi.ticket-email into dev
Reviewed-on: #125
2025-06-24 18:45:44 +10:00
dbdbd9 fb6c58f1c0 handle onleave new user, adjust background jobs 2025-06-24 15:39:11 +07:00
dbdbd9 c2c9322e7d update delete note, add update ticket 2025-06-21 11:11:02 +07:00
dbdbd9 3263bce784 fix refactor function check leave day, update handle ticket send mail 2025-06-20 14:29:47 +07:00
dbdbd9 500d6ec34d fix warnning on_leave_note_pay message 2025-06-18 15:23:55 +07:00
dbdbd9 1b35f8481b refactor create, handle ticket 2025-06-17 15:44:44 +07:00
vincent.vo 172af363de Merge pull request 'truong-leave-day' (#124) from truong-leave-day into dev
Reviewed-on: #124
2025-06-16 16:34:55 +10:00
joseph 1328320713 Merge pull request 'add confirm/refuse in ticket mail' (#123) from vi.ticket-email into dev
Reviewed-on: #123
2025-06-12 12:54:55 +10:00
dbdbd9 7217a83e33 add confirm/refuse in ticket mail 2025-06-11 16:44:11 +07:00
dbdbd9 8474bd97a1 Merge branch 'dev' of https://gitea.nswteam.net/joseph/ManagementSystem into vi.document 2025-06-11 14:52:24 +07:00
vincent.vo f8c2af2ec8 Merge pull request 'Hiệu chỉnh hàm tính toán ngày phép' (#122) from truong-leave-day into dev
Reviewed-on: #122
2025-06-11 17:49:54 +10:00
dbdbd9 fa47d32b07 Merge branch 'dev' of https://gitea.nswteam.net/joseph/ManagementSystem into vi.document 2025-06-10 10:05:47 +07:00
joseph 160596a7a3 Merge pull request 'truong-leave-day' (#116) from truong-leave-day into dev
Reviewed-on: #116
2025-06-10 12:51:31 +10:00
dbdbd9 5cd7071459 Merge branch 'master' of https://gitea.nswteam.net/joseph/ManagementSystem into vi.document 2025-03-27 07:50:25 +07:00
205 changed files with 33619 additions and 5827 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

@ -7,13 +7,18 @@ use App\Http\Controllers\Controller;
use App\Jobs\InitializeLeaveDays;
use App\Models\LeaveDays;
use App\Models\Notes;
use Carbon\Carbon;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
use Maatwebsite\Excel\Facades\Excel;
use Modules\Admin\app\Models\Ticket;
use App\Traits\AnalyzeData;
class LeaveManagementController extends Controller
{
use AnalyzeData;
public function get(Request $request)
{
$yearNow = $request->query('year', now()->year);
@ -38,6 +43,7 @@ class LeaveManagementController extends Controller
$join->on('reason.c_type', DB::raw("CONCAT('REASON_NOTES')"));
})
->select(
DB::raw('notes.id as id'),
DB::raw('notes.n_user_id as n_user_id'),
DB::raw('notes.n_time_type as time_type'),
DB::raw('notes.n_year as year'),
@ -51,13 +57,14 @@ class LeaveManagementController extends Controller
)
// ->where('notes.n_user_id', "1")
->where('notes.n_year', $year)
->whereIn('notes.n_reason', ['ONLEAVE', 'LEAVE_WITHOUT_PAY'])
->whereIn('notes.n_reason', ['ONLEAVE', 'LEAVE_WITHOUT_PAY', 'TEMPORARY_ONLEAVE'])
// ->groupBy("notes.n_user_id")
->orderBy('notes.n_month')
->orderBy('notes.n_day')
->get()
->map(function ($item) {
return [
"id" => $item->id,
"day" => $item->day,
"n_user_id" => $item->n_user_id,
"reason_code" => $item->reason_code,
@ -76,6 +83,7 @@ class LeaveManagementController extends Controller
'users.name as user_name',
'users.email',
'users.avatar',
'users.is_separated',
'users.created_at as user_created_at',
'users.permission',
'users.updated_at as user_updated_at',
@ -97,6 +105,7 @@ class LeaveManagementController extends Controller
'name' => $item->user_name,
'email' => $item->email,
'avatar' => $item->avatar,
'is_separated' => $item->is_separated,
'created_at' => $item->user_created_at,
'permission' => $item->permission,
'updated_at' => $item->user_updated_at,
@ -145,6 +154,38 @@ class LeaveManagementController extends Controller
return response()->json(['status' => true, 'message' => 'Updated successfully']);
}
public function updateNoteStatus(Request $request)
{
$rules = [
'id' => 'required',
'n_reason' => 'required|in:ONLEAVE,LEAVE_WITHOUT_PAY'
];
// Validate the request
$request->validate($rules);
$id = $request->input('id');
$reason = $request->input('n_reason');
$note = Notes::find($id);
if (!$note) {
return response()->json(['message' => 'Note not found', 'status' => false]);
}
$note->n_reason = $reason;
$note->save();
// Clear Timekeeping cache
$ticket = Ticket::find($note->ticket_id);
if ($ticket) {
$this->createOrUpdateRecordForCurrentMonth(Carbon::parse($ticket->start_date)->month, Carbon::parse($ticket->start_date)->year);
$this->createOrUpdateRecordForCurrentMonth(Carbon::parse($ticket->end_date)->month, Carbon::parse($ticket->end_date)->year);
} else {
$this->createOrUpdateRecordForCurrentMonth(Carbon::parse($note->created_at)->month, Carbon::parse($note->created_at)->year);
}
return response()->json(data: ['message' => 'Update success', 'status' => true]);
}
public function export(Request $request)
{
$year = $request->query('year', now()->year);
@ -155,7 +196,7 @@ class LeaveManagementController extends Controller
}
// Lọc chỉ lấy user có permission bao gồm staff
$staffData = $leaveDays->filter(function($user) {
$staffData = $leaveDays->filter(function ($user) {
return isset($user['user']['permission']) && strpos($user['user']['permission'], 'staff') !== false;
});

File diff suppressed because it is too large Load Diff

View File

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

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;
/*
@ -44,6 +46,7 @@ Route::middleware('api')
Route::post('login', [AdminController::class, 'login']);
Route::post('reset-password', [AdminController::class, 'resetPassword']);
Route::get('forgot-password', [AdminController::class, 'forgotPassword']);
Route::get('/email-handle-ticket', [TicketController::class, 'handleTicketEmail'])->name('email.ticket.handle');
});
// NOTE after login
@ -148,6 +151,8 @@ Route::middleware('api')
'prefix' => 'category',
], function () {
Route::get('/get-list-master', [CategoryController::class, 'getListMaster']);
Route::get('/work-days', [CategoryController::class, 'workDays'])->middleware('check.permission:admin.hr');
Route::put('/update-work-days', [CategoryController::class, 'updateWorkDays'])->middleware('check.permission:admin.hr');
});
Route::group([
@ -156,6 +161,7 @@ Route::middleware('api')
Route::get('/', [LeaveManagementController::class, 'get'])->middleware('check.permission:admin.hr.staff.accountant');
Route::get('/export', [LeaveManagementController::class, 'export'])->middleware('check.permission:admin.hr.staff.accountant');
Route::post('/saveNoteLeave', [LeaveManagementController::class, 'saveNoteLeave'])->middleware('check.permission:admin.hr');
Route::post('/updateNoteStatus', [LeaveManagementController::class, 'updateNoteStatus'])->middleware('check.permission:admin.hr');
});
Route::group([
@ -163,11 +169,44 @@ Route::middleware('api')
], function () {
Route::get('/all', [TicketController::class, 'getAll'])->middleware('check.permission:admin.hr');
Route::get('/getByUserId', [TicketController::class, 'getByUserId'])->middleware('check.permission:admin.hr.staff');
Route::post('/update', [TicketController::class, 'updateTicket'])->middleware('check.permission:admin.hr');
Route::post('/create', [TicketController::class, 'createTicket'])->middleware('check.permission:admin.hr.staff');
Route::get('/delete', [TicketController::class, 'deleteTicket'])->middleware('check.permission:admin.hr.staff');
Route::post('/handle-ticket', [TicketController::class, 'handleTicket'])->middleware('check.permission:admin');
});
// Electricity Bills
Route::group([
'prefix' => 'electricity-bill',
], function () {
Route::get('/', [ElectricityBillController::class, 'index'])->middleware('check.permission:admin.hr.staff.accountant');
Route::get('/{id}', [ElectricityBillController::class, 'show'])->middleware('check.permission:admin.hr.staff.accountant');
Route::post('/create', [ElectricityBillController::class, 'create'])->middleware('check.permission:admin.hr.staff.accountant');
Route::put('/{id}', [ElectricityBillController::class, 'update'])->middleware('check.permission:admin.hr.staff.accountant');
Route::get('/delete/{id}', [ElectricityBillController::class, 'delete'])->middleware('check.permission:admin.hr.staff.accountant');
Route::get('/export-pdf/{id}', [ElectricityBillController::class, 'exportPdf'])->middleware('check.permission:admin.hr.staff.accountant');
Route::post('/upload-attachment/{id}', [ElectricityBillController::class, 'uploadAttachment'])->middleware('check.permission:admin.hr.staff.accountant');
Route::delete('/delete-attachment/{id}', [ElectricityBillController::class, 'deleteAttachment'])->middleware('check.permission:admin.hr.staff.accountant');
});
// Equipments
Route::group([
'prefix' => 'equipment',
], function () {
Route::get('/', [EquipmentController::class, 'index'])->middleware('check.permission:admin.hr.staff.accountant');
Route::get('/users', [EquipmentController::class, 'userOptions'])->middleware('check.permission:admin.hr.staff.accountant');
Route::get('/user-statistics', [EquipmentController::class, 'userStatistics'])->middleware('check.permission:admin.hr.staff.accountant');
Route::get('/export', [EquipmentController::class, 'export'])->middleware('check.permission:admin.hr.staff.accountant');
Route::get('/by-code', [EquipmentController::class, 'showByCode'])->middleware('check.permission:admin.hr.staff.accountant');
Route::get('/history/{id}', [EquipmentController::class, 'history'])->middleware('check.permission:admin.hr.staff.accountant');
Route::post('/create', [EquipmentController::class, 'create'])->middleware('check.permission:admin.hr.staff.accountant');
Route::put('/{id}', [EquipmentController::class, 'update'])->middleware('check.permission:admin.hr.staff.accountant');
Route::post('/{id}/assign', [EquipmentController::class, 'assign'])->middleware('check.permission:admin.hr.staff.accountant');
Route::post('/{id}/upload-image', [EquipmentController::class, 'uploadImage'])->middleware('check.permission:admin.hr.staff.accountant');
Route::delete('/{id}/image', [EquipmentController::class, 'deleteImage'])->middleware('check.permission:admin.hr.staff.accountant');
Route::get('/delete/{id}', [EquipmentController::class, 'delete'])->middleware('check.permission:admin.hr.staff.accountant');
});
Route::group([
'prefix' => 'profile',
], function () {

View File

@ -3,18 +3,23 @@
namespace Modules\Auth\app\Http\Controllers;
use App\Http\Controllers\Controller;
use App\Models\LeaveDays;
use App\Traits\IsAPI;
use Carbon\Carbon;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
use Modules\Auth\app\Models\User;
use Illuminate\Support\Str;
use Modules\Admin\app\Models\Category;
use SimpleSoftwareIO\QrCode\Facades\QrCode;
use App\Traits\AnalyzeData;
class UserController extends Controller
{
use IsAPI;
use AnalyzeData;
public function __construct()
{
$this->middleware('jwt.auth');
@ -34,9 +39,41 @@ class UserController extends Controller
]);
if ($request->has('id')) {
$payload = $request->only(['name', 'email', 'permission']);
$payload = $request->only(['name', 'email', 'permission', 'is_permanent', 'is_separated']);
$user = User::find($request->id);
// Không cho chuyển từ chính thức thành lại thử việc
if (!$request->is_permanent && $user->is_permanent) {
return response()->json(['status' => false, 'message' => 'You cannot change an employee from permanent to probationary.']);
}
if (!$request->is_separated && $user->is_separated) {
return response()->json(['status' => false, 'message' => 'You cannot change status of separated employee.']);
}
// Thêm ngày phép khi thành nhân viên chính thức
if ($request->is_permanent && !$user->is_permanent) {
$userLeaveDay = LeaveDays::where('ld_user_id', $user->id)
->where('ld_year', Carbon::now()->year)
->first();
if ($userLeaveDay) {
$permanentCategory = Category::where('c_type', 'PERMANENT_ONLEAVE')->where('c_code', "PERMANENT")->first();
$permanentDefault = (int) $permanentCategory->c_value; // Ngày phép khi thành nv chính thức
$userLeaveDay->ld_day_total = $permanentDefault;
$newNote = "Cộng ngày phép cho nhân viên chính thức"; // Thêm ghi chú
if (!empty($userLeaveDay->ld_note)) {
$userLeaveDay->ld_note = $userLeaveDay->ld_note . "\n" . $newNote;
} else {
$userLeaveDay->ld_note = $newNote;
}
$userLeaveDay->save();
}
$payload['permanent_date'] = Carbon::now()->toDateString();
}
$user->update($payload);
return response()->json(['data' => $user, 'status' => true, 'message' => 'Update successful']);
} else {
@ -44,9 +81,23 @@ class UserController extends Controller
'name' => $request->name,
'email' => $request->email,
'password' => bcrypt('Work@1234'),
'permission' => $request->permission
'permission' => $request->permission,
'is_permanent' => false,
'is_separated' => false
]);
// Khởi tạo LeaveDays cho nhân viên mới
LeaveDays::insert([
'ld_user_id' => $user->id,
'ld_day_total' => 0,
'ld_year' => Carbon::now()->year,
'ld_additional_day' => 0,
'ld_note' => '',
'created_at' => now(),
'updated_at' => now(),
]);
$this->createOrUpdateRecordForCurrentMonth(Carbon::now()->month, Carbon::now()->year);
$user_res = [
'name' => $user->name,
'email' => $user->email,
@ -98,8 +149,6 @@ class UserController extends Controller
return response()->json(['data' => ['user' => $user_res, 'gitea' => "dev", 'zulip' => "dev"], 'status' => true, 'message' => 'Create successful']);
}
}
return response()->json(['status' => false, 'message' => 'Process fail']);
}
public function delete(Request $request)

View File

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

View File

@ -21,4 +21,4 @@ class AddMonthlyLeaveDaysCommand extends Command
$year = $this->argument('year');
AddMonthlyLeaveDays::dispatch($month, $year);
}
}
}

View File

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

View File

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

View File

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

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;
@ -10,6 +11,8 @@ use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Modules\Admin\app\Models\Category;
use Illuminate\Support\Facades\DB;
class AddMonthlyLeaveDays implements ShouldQueue
{
@ -18,6 +21,8 @@ class AddMonthlyLeaveDays implements ShouldQueue
protected $month;
protected $year;
private const ONLEAVE_PER_MONTH = 1; // Ngày phép cộng mỗi tháng
public function __construct($month = null, $year = null)
{
$this->month = $month ?? Carbon::now()->month;
@ -29,38 +34,86 @@ class AddMonthlyLeaveDays implements ShouldQueue
$users = User::get();
foreach ($users as $user) {
// Nếu là nhân viên chưa chính thức, ko cộng phép
if (!$user->is_permanent) {
continue;
}
// Nếu là nhân viên nghỉ việc, ko cộng phép
if ($user->is_separated) {
continue;
}
$leaveDay = LeaveDays::where('ld_user_id', $user->id)
->where('ld_year', $this->year)
->first();
if (!$leaveDay) {
if (!$leaveDay && $this->month > 1) {
// Nếu chưa có dữ liệu năm hiện tại, tạo mới
// Số ngày phép bằng với tháng hiện tại
$previousYearData = LeaveDays::where('ld_user_id', $user->id)
->where('ld_year', $this->year - 1)
->first();
$ld_additional_day = 0;
$ld_note = '';
if ($previousYearData) {
$ld_additional_day = $previousYearData->ld_day_total + $previousYearData->ld_additional_day + $previousYearData->ld_special_leave_day;
$totalLeaveDaysByMonth = Notes::join('categories', function ($join) {
$join->on('notes.n_time_type', '=', 'categories.c_code')
->where('categories.c_type', 'TIME_TYPE');
})
->select(
DB::raw('notes.n_user_id as n_user_id'),
DB::raw('notes.n_year as year'),
DB::raw('SUM(categories.c_value) as leave_days')
)
->where('notes.n_year', $this->year - 1)
->where('notes.n_user_id', $user->id)
->where('notes.n_reason', 'ONLEAVE')
->groupBy(DB::raw('notes.n_year'))
->first();
if ($totalLeaveDaysByMonth) {
$ld_additional_day = $ld_additional_day - $totalLeaveDaysByMonth->leave_days;
if ($ld_additional_day < 0) {
$ld_additional_day = 0;
}
}
if ($ld_additional_day > 0) {
$ld_note = "Cộng " . $ld_additional_day . " ngày phép tồn năm trước. \n";
}
}
$leaveDay = new LeaveDays([
'ld_user_id' => $user->id,
'ld_day_total' => $this->month, // Số ngày phép bằng tháng hiện tại
'ld_year' => $this->year,
'ld_additional_day' => 0,
'ld_note' => 'Khởi tạo ngày phép đến tháng ' . $this->month,
'ld_additional_day' => $ld_additional_day,
'ld_note' => $ld_note,
'ld_special_leave_day' => 0,
]);
$leaveDay->save();
} else {
// Kiểm tra nếu số ngày phép hiện tại nhỏ hơn tháng hiện tại
if ($leaveDay->ld_day_total < $this->month) {
// Cập nhật số ngày phép bằng với tháng hiện tại
$oldDays = $leaveDay->ld_day_total;
$leaveDay->ld_day_total = $this->month;
// Check có phải là nhân viên chính thức trong năm nay (Nhân viên mới)
if ($user->permanent_date && $user->permanent_date !== '0000-00-00') {
$permenantYear = Carbon::parse($user->permanent_date)->year;
// Xử lý ghi chú
$newNote = "Cập nhật ngày phép đến tháng " . $this->month;
if (!empty($leaveDay->ld_note)) {
// Nếu đã có ghi chú, thêm ghi chú mới vào và xuống dòng
$leaveDay->ld_note = $leaveDay->ld_note . "\n" . $newNote;
} else {
// Nếu chưa có ghi chú, gán ghi chú mới
$leaveDay->ld_note = $newNote;
if ($permenantYear === $this->year) {
$permanentCategory = Category::where('c_type', 'PERMANENT_ONLEAVE')->where('c_code', "PERMANENT")->first();
$permanentDefault = (int) $permanentCategory->c_value; // Ngày phép khi thành nv chính thức
$permanentMonth = Carbon::parse($user->permanent_date)->month;
if ($this->month > $leaveDay->ld_day_total - ($permanentDefault - $permanentMonth)) {
$leaveDay->ld_day_total += self::ONLEAVE_PER_MONTH;
$leaveDay->save();
}
}
}
// Kiểm tra nếu số ngày phép hiện tại nhỏ hơn tháng hiện tại (Nhân viên cũ)
if ($leaveDay->ld_day_total < $this->month) {
// Cộng mỗi tháng 1 ngày phép cho nhân viên
$leaveDay->ld_day_total += self::ONLEAVE_PER_MONTH;
$leaveDay->save();
}
}

View File

@ -38,11 +38,14 @@ class CheckUserAttendanceJob implements ShouldQueue
{
// Lấy tất cả người dùng
$users = User::where('permission', 'not like', '%admin%')
->where('permission', 'not like', '%accountant%')->get();
->where('permission', 'not like', '%accountant%')->get();
foreach ($users as $key => $user) {
// if ($user->id != 4) {
// continue;
// }
// Check nhân viên nghỉ việc
if ($user->is_separated) {
continue;
}
// Kiểm tra dựa trên period (Sáng 'S' hoặc Chiều 'C')
if ($this->period === 'S') {
$this->checkMorning($user);
@ -128,14 +131,19 @@ class CheckUserAttendanceJob implements ShouldQueue
$reason = 'KHONG PHEP';
//Check ngày hợp lệ nếu có check và check out của user khác
$dateNow = Tracking::whereBetween('time_string', [$startTime, $endTime])
->get();
$hasCheckIndateNow = $dateNow->where('status', 'check in')->isNotEmpty();
$hasCheckOutdateNow = $dateNow->where('status', 'check out')->isNotEmpty();
$dateNow = Tracking::whereBetween('time_string', [$startTime, $endTime])->exists();
// Nếu không có check-in hoặc check-out, và chưa có ticket, tạo ticket mới
if (($hasCheckIndateNow && $hasCheckOutdateNow) && !$hasCheckIn && !$hasCheckOut && !$existingTicket) {
Ticket::create([
// Nếu không có check in/out, không gửi ticket và có check in/out của người khác => tạo ticket không phép
if ($hasCheckIn || $hasCheckOut) {
return;
}
if ($existingTicket) {
return;
}
if ($dateNow) {
$ticket = Ticket::create([
'user_id' => $userId,
'start_date' => $today->format('Y-m-d'),
'start_period' => $periodCode,
@ -159,8 +167,10 @@ class CheckUserAttendanceJob implements ShouldQueue
$admins = Admin::where('permission', 'like', '%admin%')->get();
foreach ($admins as $key => $value) {
$data = array(
"ticket_id" => $ticket->id,
"email_template" => "email.notification_tickets",
"email" => $user->email,
"admin_email" => $value->email,
"name" => $user->name,
"date" => $dataMasterStartPeriod->c_name . " (" . $formattedStartDate . ") - " . $dataMasterEndPeriod->c_name . " (" . $formattedEndDate . ")",
"type" => $dataMasterType->c_name,

View File

@ -10,7 +10,6 @@ use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\DB;
use Carbon\Carbon;
class DeductLeaveDays implements ShouldQueue
@ -42,38 +41,30 @@ class DeductLeaveDays implements ShouldQueue
continue;
}
$totalLeaveDaysByMonth = Notes::join('categories', function ($join) {
// Lấy tổng ngày nghỉ phép 3 tháng đầu trong năm
$usedOnleaveDaysTotal = Notes::join('categories', function ($join) {
$join->on('notes.n_time_type', '=', 'categories.c_code')
->where('categories.c_type', 'TIME_TYPE');
})
->select(
DB::raw('notes.n_user_id as n_user_id'),
DB::raw('notes.n_year as year'),
DB::raw('SUM(categories.c_value) as leave_days')
)
->where('notes.n_year', $this->year)
->where('notes.n_user_id', $user->id)
->where('notes.n_reason', 'ONLEAVE')
->groupBy(DB::raw('notes.n_year'))
->first();
if ($totalLeaveDaysByMonth) {
//Nếu ngày phép thừa năm trước chưa sử dụng hết => cập nhật lại ngày đó (Ngày tồn đọng - ngày sử dụng)
if ($existingData->ld_additional_day > $totalLeaveDaysByMonth->leave_days) {
LeaveDays::where('ld_year', $this->year)
->where('ld_user_id', $user->id)
->update([
'ld_additional_day' => $totalLeaveDaysByMonth->leave_days,
]);
->where('n_user_id', $user->id)
->where('n_year', $this->year)
->where('n_month', "<=", 3)
->where('n_reason', 'ONLEAVE')
->sum('categories.c_value');
if ($usedOnleaveDaysTotal) {
if ($existingData->ld_additional_day > $usedOnleaveDaysTotal) {
$ld_note = "Trừ " . $existingData->ld_additional_day - $usedOnleaveDaysTotal . " ngày phép tồn năm trước. \n";
$existingData->ld_note = $existingData->ld_note . $ld_note;
$existingData->ld_additional_day = $usedOnleaveDaysTotal;
}
} else {
//Nếu không sử dụng ngày nghỉ còn lại ở năm rồi thì xóa => theo luật ld
LeaveDays::where('ld_year', $this->year)
->where('ld_user_id', $user->id)
->update([
'ld_additional_day' => "0",
]);
$ld_note = "Trừ " . $existingData->ld_additional_day . " ngày phép tồn năm trước. \n";
$existingData->ld_note = $existingData->ld_note . $ld_note;
$existingData->ld_additional_day = 0;
}
$existingData->save();
}
}
}

View File

@ -34,15 +34,11 @@ class InitializeLeaveDays implements ShouldQueue
public function handle(): void
{
$users = User::get();
$ld_day_total = 12;
foreach ($users as $user) {
// Kiểm tra xem dữ liệu của user này đã tồn tại cho năm hiện tại chưa
$existingData = LeaveDays::where('ld_user_id', $user->id)
->where('ld_year', $this->year)
->first();
$ld_day_total = Carbon::now()->month; // Khởi tạo phép hiện có bằng tháng hiện tại
if ($existingData) {
// Nếu dữ liệu đã tồn tại, bỏ qua user này
foreach ($users as $user) {
// Check nhân viên nghỉ việc
if ($user->is_separated) {
continue;
}
@ -55,7 +51,7 @@ class InitializeLeaveDays implements ShouldQueue
$ld_note = '';
if ($previousYearData) {
$ld_additional_day = $previousYearData->ld_day_total + $previousYearData->ld_additional_day;
$ld_additional_day = $previousYearData->ld_day_total + $previousYearData->ld_additional_day + $previousYearData->ld_special_leave_day;
$totalLeaveDaysByMonth = Notes::join('categories', function ($join) {
$join->on('notes.n_time_type', '=', 'categories.c_code')
->where('categories.c_type', 'TIME_TYPE');
@ -76,13 +72,29 @@ class InitializeLeaveDays implements ShouldQueue
$ld_additional_day = 0;
}
}
$ld_note = 'Cộng dồn ngày phép năm cũ';
if ($ld_additional_day > 0) {
$ld_note = "Cộng " . $ld_additional_day . " ngày phép tồn năm trước. \n";
}
}
// Kiểm tra xem dữ liệu của user này đã tồn tại cho năm hiện tại chưa
$existingData = LeaveDays::where('ld_user_id', $user->id)
->where('ld_year', $this->year)
->first();
if ($existingData) {
// Nếu dữ liệu đã tồn tại, update lại phép tồn
$existingData->ld_note = $ld_note;
$existingData->ld_additional_day = $ld_additional_day;
$existingData->save();
continue;
}
// Tạo dữ liệu cho năm hiện tại
LeaveDays::insert([
'ld_user_id' => $user->id,
'ld_day_total' => $ld_day_total,
'ld_day_total' => $user->is_permanent ? $ld_day_total : 0, // Nếu là nhân viên mới, ko cấp phép
'ld_year' => $this->year,
'ld_additional_day' => $ld_additional_day,
'ld_note' => $ld_note,

View File

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

View File

@ -18,6 +18,7 @@ class Notes extends Model
'n_time_type',
'n_reason',
'n_note',
'ticket_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

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

View File

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

View File

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

View File

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

View File

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

View File

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

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');
});
}
};

View File

@ -1,4 +1,3 @@
<!DOCTYPE html>
<html lang="en">
@ -37,119 +36,167 @@
<title>{{ $data['subject'] }}</title>
</head>
<body>
<body style="
<body style="
font-family: Arial, Helvetica, sans-serif;
background-color: #edf2f7;
">
<table style="margin: 0 auto">
<tr>
<td>
<table
style="
<table style="margin: 0 auto">
<tr>
<td>
<table
style="
margin: 0 auto;
width: 768px;
">
<tr>
<td align="center" valign="top" style="padding: 36px 24px;">
<a href="{{ config('app.url') }}" target="_blank" style="display: inline-block;">
<img src="https://apactech.io/wp-content/uploads/2022/12/APAC-TECH_side-e1670975093601-190x78.png"
alt="Logo" border="0" width="100"
style="display: block; width: 100px; max-width: 100px; min-width: 48px;">
</a>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td>
<table
style="
<tr>
<td align="center" valign="top" style="padding: 36px 24px;">
<a href="{{ config('app.url') }}" target="_blank" style="display: inline-block;">
<img src="https://apactech.io/wp-content/uploads/2022/12/APAC-TECH_side-e1670975093601-190x78.png"
alt="Logo" border="0" width="100"
style="display: block; width: 100px; max-width: 100px; min-width: 48px;">
</a>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td>
<table
style="
margin: 0 auto;
background-color: #ffffff;
width: 768px;
padding: 24px;
">
<tr>
<td>
<h3 style="color: #222222; margin: 5px 0 0 0; font-weight: bold">
Dear Admin,
</h3>
</td>
</tr>
<tr>
<td>
<p style=" white-space:pre-line; margin: 0; margin-bottom: 5px">
Employee <span style="color: #222222;font-weight: bold;">{{ $data['name'] }}</span> has sent a request ticket, the specific content is as follows:
</p>
</td>
</tr>
<tr>
<td>
<div style="padding-left: 10px;color: #696969; margin-bottom: 15px">
<p style="padding: 3px;">Name: <span style="color: #222222;font-weight: bold;">{{ $data['name'] }}</span></p>
<p style="padding: 3px;">Date: <span style="color: #222222;font-weight: bold;">{{ $data['date'] }}</span></p>
<p style="padding: 3px;">Type: <span style="color: #222222;font-weight: bold;">{{ $data['type'] }}</span></p>
<p style="padding: 3px;">Note: <span style="color: #222222;font-weight: bold;">{{ $data['note'] }}</span></p>
</div>
</td>
</tr>
<tr>
<td>
<p style="margin:0 0 16px;padding:5px;margin: 5px;text-align: center;">
<a href='{{ config('app.url') . $data['link'] }}'
style="
color: #fff;
border-radius: 10px;
background-color: rgba(68,115,196);
background-image: linear-gradient(to top left,rgba(0,0,0,.2),rgba(0,0,0,.2) 30%,rgba(0,0,0,0));
text-decoration: none;
display: inline-block;
font-weight: 600;
font-size: 16px;
line-height: 150%;
text-align: center;
margin: 0;
padding: 10px 12px;
">
Check now</a>
</p>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td>
<table
style="
<tr>
<td>
<h3 style="color: #222222; margin: 5px 0 0 0; font-weight: bold">
Dear Admin,
</h3>
</td>
</tr>
<tr>
<td>
<p style=" white-space:pre-line; margin: 0; margin-bottom: 5px">
Employee <span style="color: #222222;font-weight: bold;">{{ $data['name'] }}</span> has sent a <a href="{{ config('app.client_url') . $data['link'] }}"> request ticket</a>, the specific content is as follows:
</p>
</td>
</tr>
<tr>
<td>
<div style="padding-left: 10px;color: #696969; margin-bottom: 15px">
<p style="padding: 3px;">Name: <span style="color: #222222;font-weight: bold;">{{ $data['name'] }}</span></p>
<p style="padding: 3px;">Date: <span style="color: #222222;font-weight: bold;">{{ $data['date'] }}</span></p>
<p style="padding: 3px;">Type: <span style="color: #222222;font-weight: bold;">{{ $data['type'] }}</span></p>
<p style="padding: 3px;">Note: <span style="color: #222222;font-weight: bold;">{{ $data['note'] }}</span></p>
</div>
</td>
</tr>
<tr>
<td>
<p style="text-align: center">
You can quick
<span style="font-weight: bold">Confirm</span> or
<span style="font-weight: bold">Refuse</span> here:
</p>
<div
style="
display: flex;
justify-content: center;
gap: 10px;
margin-top: 10px;
">
<a
href="{{ route('email.ticket.handle', ['ticket_id' => $data['ticket_id'], 'action' => 'confirm', 'admin_email' => $data['admin_email']]) }}"
style="
color: #fff;
border-radius: 10px;
background-color: #12b886;
background-image: linear-gradient(
to top left,
rgba(0, 0, 0, 0.2),
rgba(0, 0, 0, 0.2) 30%,
rgba(0, 0, 0, 0)
);
text-decoration: none;
display: inline-block;
font-weight: 600;
font-size: 16px;
line-height: 150%;
text-align: center;
margin: 0;
padding: 10px 12px;
">
Confirm</a>
<a
href="{{ route('email.ticket.handle', ['ticket_id' => $data['ticket_id'], 'action' => 'refuse', 'admin_email' => $data['admin_email']]) }}"
style="
color: #fff;
border-radius: 10px;
background-color: #f03e3e;
background-image: linear-gradient(
to top left,
rgba(0, 0, 0, 0.2),
rgba(0, 0, 0, 0.2) 30%,
rgba(0, 0, 0, 0)
);
text-decoration: none;
display: inline-block;
font-weight: 600;
font-size: 16px;
line-height: 150%;
text-align: center;
margin: 0;
padding: 10px 12px;
">
Refuse</a>
</div>
</td>
</tr>
<tr>
<td style="color: #222222;">
<div style="margin-top: 3rem">
<p><span style="font-weight: bold">Note</span>: If you are redirected to a <span style="font-weight: bold">404 page</span>, it means:</p>
<p>1. The ticket has already been approved by another admin.</p>
<p>2. The ticket has been deleted.</p>
</div>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td>
<table
style="
margin: 0 auto;
width: 768px;
">
<tr>
<td>
<h5
style="
<tr>
<td>
<h5
style="
color: #222222;
text-align: center;
padding: 10px 36px;
margin: 0;
">
<p>© 2024 APAC Tech.</p>
</h5>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
<p>© 2024 APAC Tech.</p>
</h5>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
</html>

View File

@ -0,0 +1,13 @@
<?php
use Carbon\Carbon;
require_once __DIR__ . "/../vendor/autoload.php";
$app = include_once __DIR__ . '/../bootstrap/app.php';
$kernel = $app->make(Illuminate\Contracts\Console\Kernel::class);
$kernel->bootstrap();
// Cập nhật lại data cho tới tháng hiện tại
$currentMonth = Carbon::now()->month;
$tmpClass = $app->make('Modules\Admin\app\Http\Controllers\TicketController');
$tmpClass->updateOldData($currentMonth, 2025); // Params: month, year

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

View File

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

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,42 @@
export const PageNotFound = () => {
return <>{'Not found!'}</>
import {
Box,
Button,
Container,
Image,
SimpleGrid,
Text,
Title,
} from '@mantine/core'
import image404 from '../../../public/404Image.jpg'
import { useNavigate } from 'react-router-dom'
const PageNotFound = () => {
const navigate = useNavigate()
return (
<Container mt="lg">
<SimpleGrid spacing={{ base: 40, sm: 80 }} cols={{ base: 1, sm: 2 }}>
<Box py="3rem">
<Title> Something is not right...</Title>
<Text c="dimmed" size="lg">
Page you are trying to open does not exist. You may have mistyped
the address, or the page has been moved to another URL. If you think
this is an error contact support.
</Text>
<Button
variant="outline"
size="md"
mt="xl"
onClick={() => navigate('/')}
>
Get back to home page
</Button>
</Box>
<Image src={image404} />
</SimpleGrid>
</Container>
)
}
export default PageNotFound

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

@ -79,6 +79,7 @@ const Tickets = () => {
const [item, setItem] = useState({ id: 0 })
const [activeBtn, setActiveBtn] = useState(false)
const [disableBtn, setDisableBtn] = useState(false)
const [isFetchData, setIsFetch] = useState(false)
const [dataTimeType, setDataTimeType] = useState<DataTimeType[]>([])
const [dataReason, setDataReason] = useState<DataReason[]>([])
@ -109,6 +110,8 @@ const Tickets = () => {
useEffect(() => {
const fetchData = async () => {
setIsFetch(true)
const resultTimeType = await getListMasterByType('TIME_TYPE')
setDataTimeType(
resultTimeType.filter((item: DataTimeType) => item.c_code !== 'ALL'),
@ -116,6 +119,8 @@ const Tickets = () => {
const resultReason = await getListMasterByType('REASON')
setDataReason(resultReason)
setIsFetch(false)
}
fetchData()
@ -369,6 +374,7 @@ const Tickets = () => {
setAction('add')
form.reset()
}}
disabled={isFetchData}
>
+ Add
</Button>
@ -554,7 +560,8 @@ const Tickets = () => {
if (confirmValues) {
try {
setConfirmLoading(true)
action === 'add' && (await handleCreate(confirmValues, true))
action === 'add' &&
(await handleCreate(confirmValues, true))
setConfirmLoading(false)
setConfirmModal(false)
} catch (error) {

View File

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

View File

@ -1,6 +1,11 @@
import { getListMaster, getTickets, handleTicket } from '@/api/Admin'
import {
getListMaster,
getTickets,
handleTicket,
updateTicket,
} from '@/api/Admin'
import { DataTablePagination } from '@/components/DataTable/DataTable'
import { create } from '@/rtk/helpers/CRUD'
import { create, update } from '@/rtk/helpers/CRUD'
import { get } from '@/rtk/helpers/apiService'
import {
Badge,
@ -9,12 +14,13 @@ import {
HoverCard,
Modal,
Select,
Switch,
Text,
Textarea,
} from '@mantine/core'
import { useForm } from '@mantine/form'
import { notifications } from '@mantine/notifications'
import { IconCheckbox, IconSquareXFilled } from '@tabler/icons-react'
import { IconCheckbox, IconEdit, IconSquareXFilled } from '@tabler/icons-react'
import moment from 'moment'
import { useEffect, useState } from 'react'
import classes from './TicketsManagement.module.css'
@ -24,6 +30,7 @@ type TTickets = {
ticket_id: number
admin_note: string
action: string
status: string
}
type TListTickets = {
@ -78,6 +85,7 @@ const TicketsManagement = () => {
end_period: '',
reason: '',
type: '',
status: '',
})
const [disableBtn, setDisableBtn] = useState(false)
const [filter, setFilter] = useState({
@ -86,6 +94,7 @@ const TicketsManagement = () => {
})
const [dataTimeType, setDataTimeType] = useState<DataTimeType[]>([])
const [dataReason, setDataReason] = useState<DataReason[]>([])
const [isRefuseConfirmOpen, setIsRefuseConfirmOpen] = useState<boolean>(false)
const getListMasterByType = async (type: string) => {
try {
@ -156,7 +165,7 @@ const TicketsManagement = () => {
},
{
name: 'reason',
size: '15%',
size: '10%',
header: 'Notes',
render: (row: any) => {
return (
@ -198,7 +207,7 @@ const TicketsManagement = () => {
},
{
name: 'admin_note',
size: '15%',
size: '10%',
header: 'Admin Notes',
render: (row: any) => {
return (
@ -226,6 +235,18 @@ const TicketsManagement = () => {
size: '10%',
header: 'Updated By',
},
{
name: 'updated_at',
size: '10%',
header: 'Updated At',
render: (row: any) => {
if (row?.updated_at) {
return (
<Box>{moment(row?.updated_at).format('HH:mm:ss DD/MM/YYYY')}</Box>
)
}
},
},
{
name: '#',
size: '5%',
@ -254,7 +275,23 @@ const TicketsManagement = () => {
height={20}
/>
</Box>
) : null
) : (
<Box className={classes.optionIcon}>
<IconEdit
className={classes.editIcon}
onClick={() => {
setAction('update')
setItem(row)
form.reset()
console.log(row)
form.setFieldValue('status', row.status)
form.setFieldValue('admin_note', row.admin_note)
}}
width={20}
height={20}
/>
</Box>
)
},
},
]
@ -347,6 +384,27 @@ const TicketsManagement = () => {
}
}
const handleUpdate = async (values: TTickets) => {
try {
const res = await update(
updateTicket + `?ticket_id=${item?.id}`,
{
ticket_id: item.id,
admin_note: values.admin_note,
status: values.status,
},
getAllTickets,
)
if (res === true) {
setAction('')
setIsRefuseConfirmOpen(false)
form.reset()
}
} catch (error) {
console.log(error)
}
}
useEffect(() => {
getAllTickets()
}, [filter])
@ -356,6 +414,7 @@ const TicketsManagement = () => {
ticket_id: 0,
action: '',
admin_note: '',
status: '',
},
})
@ -416,21 +475,38 @@ const TicketsManagement = () => {
</Box>
<Modal
opened={action === 'confirm' || action === 'refuse'}
opened={
action === 'confirm' || action === 'refuse' || action === 'update'
}
onClose={() => {
setAction('')
setIsRefuseConfirmOpen(false)
form.reset()
}}
title={
<Text pl={'sm'} fw={700} fz={'lg'}>
{action === 'confirm' ? 'Confirm Ticket' : 'Refuse Ticket'}
{action === 'confirm'
? 'Confirm Ticket'
: action === 'refuse'
? 'Refuse Ticket'
: 'Update Ticket'}
</Text>
}
>
<form
onSubmit={form.onSubmit(async (values) => {
setDisableBtn(true)
await handleSave(values)
if (action === 'update') {
if (values.status === 'REFUSED' && item.status !== 'REFUSED') {
setIsRefuseConfirmOpen(true)
} else {
await handleUpdate(values)
}
} else {
await handleSave(values)
}
setDisableBtn(false)
})}
>
@ -509,14 +585,39 @@ const TicketsManagement = () => {
<Textarea
label="Admin Notes"
// required
value={form.values.admin_note}
onChange={(e) => form.setFieldValue('admin_note', e.target.value)}
/>
{action === 'update' && item.status !== 'REFUSED' ? (
<Switch
mt="md"
color="red"
label={
<Text size="sm">
Change status to{' '}
<Text span color="red" fw="bold">
Refuse
</Text>
</Text>
}
style={{ width: 'fit-content' }}
checked={form.values.status === 'REFUSED'}
onChange={(event) =>
form.setFieldValue(
'status',
event.currentTarget.checked ? 'REFUSED' : 'CONFIRMED',
)
}
/>
) : (
''
)}
<Box ta={'center'}>
<Button
mt={'lg'}
bg={'green'}
bg={action === 'update' ? 'blue' : 'green'}
type="submit"
disabled={disableBtn}
>
@ -526,6 +627,50 @@ const TicketsManagement = () => {
</Box>
</form>
</Modal>
<Modal
opened={isRefuseConfirmOpen}
onClose={() => {
setIsRefuseConfirmOpen(false)
}}
centered
size="sm"
classNames={{
content: classes.deleteModal,
}}
>
<Text className={classes.deleteModalTitle}>Confirm Update</Text>
<Text className={classes.deleteModalContent}>
Changing ticket status to <strong>Refused</strong> will also delete
all related notes.
</Text>
<Text className={classes.deleteModalContent}>
Are you sure you want to proceed?
</Text>
<Box className={classes.deleteModalFooter}>
<Button
variant="outline"
onClick={() => {
setIsRefuseConfirmOpen(false)
}}
disabled={disableBtn}
>
Cancel
</Button>
<Button
className={classes.deleteButton}
onClick={async () => {
setDisableBtn(true)
await handleUpdate(form.values)
setDisableBtn(false)
}}
disabled={disableBtn}
>
Confirm
</Button>
</Box>
</Modal>
</div>
)
}

View File

@ -24,6 +24,7 @@ import {
TextInput,
Tooltip,
Modal,
Badge,
} from '@mantine/core'
import { useDisclosure } from '@mantine/hooks'
import { notifications } from '@mantine/notifications'
@ -48,6 +49,7 @@ interface User {
permission: string
remember_token: string | null
avatar: string
is_separated: boolean
created_at: string | null
updated_at: string | null
}
@ -148,6 +150,7 @@ const Timekeeping = () => {
const [exportOption, setExportOption] = useState('default')
const [isDeleteConfirmOpen, setIsDeleteConfirmOpen] = useState(false)
const [isDisableDeleteBtn, setIsDisableDeleteBtn] = useState(false)
const [noteToDelete, setNoteToDelete] = useState<any>(null)
const getListMasterByType = async (type: string) => {
@ -467,7 +470,9 @@ const Timekeeping = () => {
const handleConfirmDelete = async () => {
if (noteToDelete) {
setIsDisableDeleteBtn(true)
await handleDelete(noteToDelete.id)
setIsDisableDeleteBtn(false)
setIsDeleteConfirmOpen(false)
setNoteToDelete(null)
}
@ -493,7 +498,11 @@ const Timekeeping = () => {
>
<Text className={classes.deleteModalTitle}>Confirm Delete</Text>
<Text className={classes.deleteModalContent}>
Are you sure you want to delete this note?
This action will change the ticket status to <strong>Refused</strong>{' '}
and delete all related notes.
</Text>
<Text className={classes.deleteModalContent}>
Are you sure you want to proceed?
</Text>
<Box className={classes.deleteModalFooter}>
<Button
@ -502,10 +511,15 @@ const Timekeeping = () => {
setIsDeleteConfirmOpen(false)
setNoteToDelete(null)
}}
disabled={isDisableDeleteBtn}
>
Cancel
</Button>
<Button className={classes.deleteButton} onClick={handleConfirmDelete}>
<Button
className={classes.deleteButton}
onClick={handleConfirmDelete}
disabled={isDisableDeleteBtn}
>
Delete
</Button>
</Box>
@ -935,14 +949,8 @@ const Timekeeping = () => {
2
return (
<Table.Tr key={user.user.id} className={classes.tableTr}>
<Table.Td>
<Tooltip
// position={'auto'}
multiline
// opened
// offset={{ mainAxis: 5, crossAxis: 0 }}
label={showTooltipAllNote(user)}
>
<Table.Td pos="relative">
<Tooltip multiline label={showTooltipAllNote(user)}>
<div style={{ display: 'flex', alignItems: 'center' }}>
<Avatar
size={'md'}
@ -960,6 +968,27 @@ const Timekeeping = () => {
{user.user.name}
</div>
</Tooltip>
{user.user.is_separated ? (
<Tooltip label="Nhân viên không còn làm việc, đang xử lý hồ sơ">
<Badge
color="red"
pos="absolute"
top={0}
right={0}
radius={0}
size="xs"
style={{
fontSize: 10,
textTransform: 'capitalize',
}}
>
Off
</Badge>
</Tooltip>
) : (
''
)}
</Table.Td>
<Table.Td ta={'center'}>{totalDays}</Table.Td>
<Table.Td ta={'center'}>{workingDays - totalDays}</Table.Td>

View File

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

View File

@ -13,6 +13,7 @@ import {
Group,
Modal,
MultiSelect,
Select,
Text,
TextInput,
} from '@mantine/core'
@ -20,32 +21,50 @@ import { useForm } from '@mantine/form'
import { IconEdit, IconX } from '@tabler/icons-react'
import { useEffect, useState } from 'react'
import classes from './UsersManagement.module.css'
type TUserForm = {
id: number
email: string
name: string
permission: string
status: string
}
const UsersManagement = () => {
const [users, setUsers] = useState<TUser[]>([])
const [action, setAction] = useState('')
const [activeBtn, setActiveBtn] = useState(false)
const [item, setItem] = useState({ id: 0 })
const [item, setItem] = useState({
id: 0,
is_permanent: false,
is_separated: false,
})
const [disableBtn, setDisableBtn] = useState(false)
const [info, setInfo] = useState('')
const [isPermanentConfirmOpen, setIsPermanentConfirmOpen] =
useState<boolean>(false)
const [isSeparatedConfirmOpen, setIsSeparatedConfirmOpen] =
useState<boolean>(false)
const columns = [
{
name: 'id',
size: '3%',
size: '5%',
header: 'ID',
},
{
name: 'name',
size: '17%',
size: '20%',
header: 'Name',
},
{
name: 'email',
size: '26%',
size: '25%',
header: 'Email',
},
{
name: 'permission',
size: '10%',
size: '20%',
header: 'Permission',
render: (row: TUser) => {
if (row.permission.includes(',')) {
@ -57,9 +76,25 @@ const UsersManagement = () => {
}
},
},
{
name: 'is_permanent',
size: '20%',
header: 'Employment Type',
render: (row: TUser) => {
if (row.is_separated) {
return <Badge color="red">Separated</Badge>
}
return row.is_permanent ? (
<Badge color="teal">Permanent</Badge>
) : (
<Badge color="violet">Probation</Badge>
)
},
},
{
name: '#',
size: '5%',
size: '10%',
header: 'Action',
render: (row: TUser) => {
return (
@ -68,7 +103,12 @@ const UsersManagement = () => {
className={classes.editIcon}
onClick={() => {
setAction('edit')
form.setValues(row)
setItem(row)
form.reset()
form.setValues({
...row,
status: row.is_permanent ? 'permanent' : '',
})
}}
width={20}
height={20}
@ -94,6 +134,7 @@ const UsersManagement = () => {
name: '',
email: '',
permission: '',
status: '',
},
})
@ -108,7 +149,7 @@ const UsersManagement = () => {
}
}
const handleCreate = async (values: TUser) => {
const handleCreate = async (values: TUserForm) => {
try {
const { id, ...data } = values
const res = await post(createOrUpdateUser, data)
@ -123,11 +164,25 @@ const UsersManagement = () => {
}
}
const handleUpdate = async (values: TUser) => {
const handleUpdate = async (values: TUserForm) => {
try {
const res = await update(createOrUpdateUser, values, getAll)
const res = await update(
createOrUpdateUser,
{
...values,
is_permanent: item.is_permanent
? true
: values.status === 'permanent',
is_separated: item.is_separated
? true
: values.status === 'separated',
},
getAll,
)
if (res === true) {
setAction('')
setIsPermanentConfirmOpen(false)
setIsSeparatedConfirmOpen(false)
form.reset()
}
} catch (error) {
@ -172,6 +227,8 @@ const UsersManagement = () => {
opened={action === 'add' || action === 'edit'}
onClose={() => {
setAction('')
setIsPermanentConfirmOpen(false)
setIsSeparatedConfirmOpen(false)
form.reset()
}}
title={
@ -183,9 +240,17 @@ const UsersManagement = () => {
<form
onSubmit={form.onSubmit(async (values) => {
setDisableBtn(true)
action === 'edit'
? await handleUpdate(values)
: await handleCreate(values)
if (action === 'edit') {
if (values.status === 'permanent' && !item.is_permanent) {
setIsPermanentConfirmOpen(true)
} else if (values.status === 'separated' && !item.is_separated) {
setIsSeparatedConfirmOpen(true)
} else {
await handleUpdate(values)
}
} else {
await handleCreate(values)
}
setDisableBtn(false)
})}
>
@ -196,6 +261,7 @@ const UsersManagement = () => {
value={form.values.name}
error={form.errors.name}
onChange={(e) => form.setFieldValue('name', e.target.value)}
required
/>
<TextInput
@ -204,6 +270,7 @@ const UsersManagement = () => {
value={form.values.email}
error={form.errors.email}
onChange={(e) => form.setFieldValue('email', e.target.value)}
required
/>
<MultiSelect
@ -224,7 +291,29 @@ const UsersManagement = () => {
e!.filter((p) => p.trim() !== '').join(','),
)
}
mb={'md'}
/>
{action === 'edit' && !item.is_separated ? (
<Select
label="Employment type"
data={[
{ value: 'permanent', label: 'Permanent' },
{ value: 'separated', label: 'Separated' },
]}
value={form.values.status}
onChange={(value) => {
if (value) {
form.setFieldValue('status', value)
}
}}
w="50%"
required
/>
) : (
''
)}
<Box ta={'center'}>
{action === 'add' ? (
<Button
@ -273,6 +362,93 @@ const UsersManagement = () => {
{info}
</Code>
</Modal>
{/* Confirm change to permanent employee */}
<Modal
opened={isPermanentConfirmOpen}
onClose={() => setIsPermanentConfirmOpen(false)}
centered
size="sm"
classNames={{
content: classes.confirmModal,
}}
>
<Text className={classes.confirmModalTitle}>Confirm Update</Text>
<Text className={classes.confirmModalContent}>
This action will change the employment type from{' '}
<strong>Probation</strong> to <strong>Permanent</strong>.
</Text>
<Text className={classes.confirmModalContent}>
Are you sure you want to proceed?
</Text>
<Box className={classes.confirmModalFooter}>
<Button
variant="outline"
onClick={() => {
setIsPermanentConfirmOpen(false)
}}
disabled={disableBtn}
>
Cancel
</Button>
<Button
className={classes.confirmButton}
onClick={async () => {
setDisableBtn(true)
await handleUpdate(form.values)
setDisableBtn(false)
}}
disabled={disableBtn}
>
Confirm
</Button>
</Box>
</Modal>
{/* Confirm change to separated employee */}
<Modal
opened={isSeparatedConfirmOpen}
onClose={() => setIsSeparatedConfirmOpen(false)}
centered
size="sm"
classNames={{
content: classes.deleteModal,
}}
>
<Text className={classes.deleteModalTitle}>Confirm Update</Text>
<Text className={classes.deleteModalContent}>
This action will change the employment type to{' '}
<strong>Separated</strong>.
</Text>
<Text className={classes.deleteModalContent}>
Are you sure you want to proceed?
</Text>
<Box className={classes.deleteModalFooter}>
<Button
variant="outline"
onClick={() => {
setIsSeparatedConfirmOpen(false)
}}
disabled={disableBtn}
>
Cancel
</Button>
<Button
className={classes.deleteButton}
onClick={async () => {
setDisableBtn(true)
await handleUpdate(form.values)
setDisableBtn(false)
}}
disabled={disableBtn}
>
Confirm
</Button>
</Box>
</Modal>
<Dialog
className={classes.dialog}
opened={action === 'delete'}

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,

View File

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

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 };
}

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