feat(au-checkin): upload checkin version for au #161

Merged
zelda merged 1 commits from zelda.checkin-for-au into master 2026-05-15 14:07:11 +10:00
62 changed files with 9040 additions and 0 deletions

BIN
.DS_Store vendored

Binary file not shown.

BIN
TrackingToolWebAU/.DS_Store vendored Normal file

Binary file not shown.

6
TrackingToolWebAU/.gitignore vendored Normal file
View File

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

134
TrackingToolWebAU/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ộ

View File

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

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()

25
TrackingToolWebAU/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>

5243
TrackingToolWebAU/client/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,54 @@
{
"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",
"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 @@
<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,43 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { clsx, type ClassValue } from "clsx";
import moment from "moment";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export const formatTime = (timeString: string) => {
return moment(timeString).format("DD/MM/YYYY HH:mm:ss");
};
export function capture(videoRef: any, canvasRef: any) {
if (!videoRef.current || !canvasRef.current) return;
const canvas = canvasRef.current;
const video = videoRef.current;
const context = canvas.getContext("2d");
return new Promise((resolve, reject) => {
try {
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
context.drawImage(video, 0, 0, canvas.width, canvas.height);
canvas.toBlob(
(blob: unknown) => {
if (!blob) {
reject("Không thể tạo blob từ canvas");
return;
}
resolve(blob);
},
"image/jpeg",
0.95 // chất lượng cao
);
} catch (error) {
reject(error);
}
});
}

View File

@ -0,0 +1,15 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { ConfirmModalProvider } from "./components/confirm-modal-provider";
import "./index.css";
import Main from "./pages/main";
import { Toaster } from "@/components/ui/sonner";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<ConfirmModalProvider>
<Main />
<Toaster richColors position="top-right" />
</ConfirmModalProvider>
</StrictMode>
);

View File

@ -0,0 +1,71 @@
"use client";
import { Button } from "@/components/ui/button";
import { useEffect, useState, useRef } from "react";
export default function CountDown({
onCountdowned,
}: {
onCountdowned?: () => void;
}) {
const [count, setCount] = useState(3);
const [running, setRunning] = useState(true);
const calledRef = useRef(false); // tránh gọi callback nhiều lần
// Countdown logic
useEffect(() => {
if (!running) return;
if (count === 0) {
if (!calledRef.current) {
calledRef.current = true;
onCountdowned?.();
}
return;
}
const timer = setTimeout(() => {
setCount((prev) => prev - 1);
}, 1000);
return () => clearTimeout(timer);
}, [count, running, onCountdowned]);
return (
<div className="absolute inset-0 flex flex-col items-center justify-center bg-transparent">
<div className="flex flex-col items-center gap-8">
{/* Instruction text */}
<div className="text-center space-y-3">
<p className="text-lg font-medium">Chuẩn bị sẵn sàng</p>
<p className="text-base">Vui lòng nhìn thẳng vào camera</p>
</div>
{/* Timer circle */}
<div className="relative">
<div className="w-40 h-40 bg-white rounded-full flex items-center justify-center shadow-2xl">
<span className="text-8xl font-bold">{count}</span>
</div>
</div>
{/* Countdown text */}
<div className="text-center">
<p className="text-sm">
{count > 0 ? `Còn ${count} giây...` : "Đã hoàn thành!"}
</p>
</div>
{/* Cancel button */}
{running && count > 0 && (
<Button
variant="outline"
onClick={() => setRunning(false)}
className="mt-4 px-8 py-2"
>
Hủy
</Button>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,37 @@
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { cn } from "@/lib/utils";
import { Users } from "lucide-react";
import TabUsers from "./tab-users";
export default function LeftSlidebar({
isSidebarOpen,
}: {
isSidebarOpen: boolean;
}) {
return (
<div
className={cn(
"fixed left-0 top-0 h-screen w-96 bg-white border-r border-gray-200 shadow-xl transition-transform duration-300 ease-in-out z-10",
isSidebarOpen ? "translate-x-0" : "-translate-x-full"
)}
>
<div className="h-full flex flex-col">
<Tabs defaultValue="users" className="flex-1 flex flex-col">
<div className="border-b p-4">
<TabsList className="grid w-full grid-cols-1">
<TabsTrigger
value="users"
className="flex items-center gap-1 text-xs"
>
<Users className="size-3.5" />
User
</TabsTrigger>
</TabsList>
</div>
<TabUsers value="users" />
</Tabs>
</div>
</div>
);
}

View File

@ -0,0 +1,72 @@
"use client";
import { Camera } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { useState, type ReactNode } from "react";
import useAppStore from "@/stores/use-app-store";
interface CameraNotificationModalProps {
onClose?: () => void;
children?: ReactNode;
}
export function CameraNotificationModal({
children,
onClose,
}: CameraNotificationModalProps) {
const [open, setOpen] = useState(false);
const { setIsCountDown } = useAppStore();
const handleClose = () => {
setOpen(false);
onClose?.();
};
const handleContinue = () => {
setIsCountDown(true);
handleClose();
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger>{children}</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<div className="flex items-center justify-center mb-4">
<div className="rounded-full border p-3">
<Camera className="w-6 h-6 " />
</div>
</div>
<DialogTitle className="text-center text-lg">
Thông báo quan trọng
</DialogTitle>
<DialogDescription className="text-center text-base pt-2">
Đ kết quả tốt nhất bạn hay nhìn thẳng vào camera nhé
</DialogDescription>
</DialogHeader>
<div className="flex justify-center gap-3 pt-4">
<Button
variant="outline"
onClick={handleClose}
className="min-w-32 bg-transparent"
>
Hủy
</Button>
<Button onClick={handleContinue} className="min-w-32">
Tiếp tục
</Button>
</div>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,258 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
"use client";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { zodResolver } from "@hookform/resolvers/zod";
import { useEffect, useState, type ReactNode } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { useConfirm } from "@/components/confirm-modal-provider";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import useAppStore from "@/stores/use-app-store";
import { Camera, Loader } from "lucide-react";
import { checkingApi } from "@/api/checking-api";
import { toast } from "sonner";
interface UserModalProps {
user?: IUser | null;
onSave?: (user: IUser) => void;
children?: ReactNode;
trackingOpen?: boolean;
}
const userSchema = z.object({
name: z.string().min(1, "Tên là bắt buộc"),
email: z.string().email("Email không hợp lệ"),
avatar: z.string().optional(),
});
type UserFormData = z.infer<typeof userSchema>;
export function UserModal({
user,
onSave,
children,
trackingOpen,
}: UserModalProps) {
const confirm = useConfirm();
const isEditMode = !!user;
const [open, setOpenChange] = useState(false);
const [loading, setLoading] = useState(false);
const { captureRegisterImage, setCaptureRegisterImage, setRefreshUsers } = useAppStore();
const form = useForm<UserFormData>({
resolver: zodResolver(userSchema),
defaultValues: {
name: "",
email: "",
avatar: "",
},
});
useEffect(() => {
if (user) {
form.reset({
name: user.name,
email: user.email,
avatar: user.avatar ?? "",
});
} else {
form.reset({
name: "",
email: "",
avatar: "",
});
}
}, [user, open, form]);
const handleClose = async () => {
const result = await confirm({
title: "Thông báo",
message: "Bạn muốn hủy đăng ký. Mọi dữ liệu bạn nhập sẽ bị mất",
confirmText: "Discard",
cancelText: "Hủy",
variant: "warning",
});
if (!result) return;
setOpenChange(false);
setCaptureRegisterImage(null);
};
const onSubmit = async (values: UserFormData) => {
try {
setLoading(true);
const dataToSubmit: IUser = {
// eslint-disable-next-line react-hooks/purity
id: user?.id || Date.now().toString(),
...values,
avatar: values.avatar || null,
};
const { data } = await checkingApi.register({
user: dataToSubmit,
file: captureRegisterImage,
});
console.log({ data });
onSave?.(dataToSubmit);
setOpenChange(false);
setCaptureRegisterImage(null);
setRefreshUsers(true);
toast.success(data?.message || "Đăng ký thành công !");
} catch (error) {
console.log({ error });
toast.error((error as any)?.message || "Internal Server Error");
} finally {
setLoading(false);
}
};
useEffect(() => {
if (trackingOpen === undefined) return;
setOpenChange(trackingOpen);
}, [trackingOpen]);
useEffect(() => {
if (!captureRegisterImage) return;
return () => {
URL.revokeObjectURL(captureRegisterImage);
};
}, [captureRegisterImage]);
return (
<Dialog
open={open}
onOpenChange={(nextOpen) => {
if (!nextOpen) {
handleClose();
return;
}
setOpenChange(true); // mở
}}
>
<DialogTrigger>{children}</DialogTrigger>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>
{isEditMode ? "Cập nhật thông tin" : "Tạo người dùng mới"}
</DialogTitle>
<DialogDescription>
{isEditMode
? "Cập nhật thông tin người dùng của bạn"
: "Nhập thông tin để tạo người dùng mới"}
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
{/* Name */}
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>
Tên <span className="text-red-600">*</span>
</FormLabel>
<FormControl>
<Input placeholder="Nhập tên người dùng" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Email */}
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>
Email <span className="text-red-600">*</span>
</FormLabel>
<FormControl>
<Input type="email" placeholder="Nhập email" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Avatar */}
<FormField
control={form.control}
name="avatar"
render={({ field }) => (
<FormItem>
<FormLabel>Avatar URL</FormLabel>
<FormControl>
<Input
placeholder="https://example.com/avatar.jpg"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* File */}
<FormLabel>nh từ camera</FormLabel>
<a target="_blank" href={URL.createObjectURL(captureRegisterImage)}>
<Button
size="sm"
variant="outline"
className="w-full bg-transparent"
type="button"
>
<Camera className="w-4 h-4 mr-2" />
Xem nh
</Button>
</a>
<div className="flex justify-end gap-2 pt-4">
<Button type="button" variant="outline" onClick={handleClose}>
Hủy
</Button>
<Button type="submit">
{!loading && isEditMode ? "Cập nhật" : "Tạo mới"}
{loading && <Loader className="animate-spin" />}
</Button>
</div>
</form>
</Form>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,41 @@
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { cn } from "@/lib/utils";
import { ClipboardList } from "lucide-react";
import TabLogs from "./tab-log";
export default function RightSlidebar({
isSidebarOpen,
}: {
isSidebarOpen: boolean;
}) {
return (
<div
className={cn(
"fixed right-0 top-0 h-screen w-96 bg-white border-l border-gray-200 shadow-xl transition-transform duration-300 ease-in-out",
isSidebarOpen ? "translate-x-0" : "translate-x-full"
)}
>
<div className="h-full flex flex-col">
<Tabs
value={"logs"}
defaultValue="features"
className="flex-1 flex flex-col"
>
<div className="border-b p-4">
<TabsList className="grid w-full grid-cols-1">
<TabsTrigger
value="logs"
className="flex items-center gap-1 text-xs"
>
<ClipboardList className="size-3.5" />
Log
</TabsTrigger>
</TabsList>
</div>
<TabLogs value="logs" />
</Tabs>
</div>
</div>
);
}

View File

@ -0,0 +1,202 @@
/* eslint-disable react-hooks/exhaustive-deps */
/* eslint-disable @typescript-eslint/no-explicit-any */
import { checkingApi } from "@/api/checking-api";
import { Button } from "@/components/ui/button";
import { capture, cn, formatTime } from "@/lib/utils";
import useAppStore from "@/stores/use-app-store";
import useUserStore from "@/stores/use-user-store";
import type { AxiosError } from "axios";
import { Camera, Image, Loader, Play, Square } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import Register from "./register";
import { speak } from "@/lib/speak";
export default function TabFeatures() {
const timeoutRef = useRef<any>(null);
const { canvasRef, videoRef } = useAppStore();
const { currentUser, setCurrentUser } = useUserStore();
const { isAutoChecking, setIsAutoChecking, setRefreshLog } = useAppStore();
const autoCheckIntervalRef = useRef<any>(null);
const [loading, setLoading] = useState(false);
const [checkPoinLoading, setCheckPoinLoading] = useState(false);
const toggleAutoCheck = () => {
if (isAutoChecking) {
if (autoCheckIntervalRef.current) {
clearInterval(autoCheckIntervalRef.current);
autoCheckIntervalRef.current = null;
}
setIsAutoChecking(false);
} else {
autoCheckIntervalRef.current = setInterval(() => {
captureAndCheck();
}, 3000);
setIsAutoChecking(true);
}
};
const createCheckpoint = async () => {
if (!currentUser) {
toast.warning("Vui lòng chọn user để tạo checkpoint");
return;
}
try {
setCheckPoinLoading(true);
const file = await capture(videoRef, canvasRef);
const { data } = await checkingApi.register({ user: currentUser, file });
if (!data) {
toast.error(
(data as any)?.message ||
"Error In Checkpoint: " + JSON.stringify(data)
);
return;
}
toast.success(data?.message || "Tạo checkpoint thành công");
} catch (error) {
const data = error as AxiosError;
toast.error(
(data.response?.data as any)?.message ||
"Error In Checkpoint: " + JSON.stringify(data)
);
} finally {
setCheckPoinLoading(false);
}
};
const captureAndCheck = useCallback(async () => {
try {
setLoading(true);
const file = await capture(videoRef, canvasRef);
const { data } = await checkingApi.checkin({ file });
if (!data || !data?.status) {
toast.error(
(data as any)?.message || "Error In Checking: " + JSON.stringify(data)
);
return;
}
const message =
(data as any)?.message ||
`Checking thành công lúc: ${formatTime(new Date().toLocaleString())}`;
toast.success(message);
speak({ type: data?.status_type });
setRefreshLog(true);
} catch (error) {
const data = error as AxiosError;
const message =
(data.response?.data as any)?.message ||
"Error In Checking: " + JSON.stringify(data);
if ((message as string).includes("No face detected")) return;
toast.error(message);
} finally {
setLoading(false);
}
}, [canvasRef, setCurrentUser, videoRef]);
useEffect(() => {
return () => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
};
}, []);
useEffect(() => {
const down = (e: KeyboardEvent) => {
if (e.code === "Space") {
// ← cách đúng nhất để detect phím cách
e.preventDefault(); // nếu không muốn scroll
if (loading) return;
captureAndCheck();
}
};
window.addEventListener("keydown", down);
return () => {
window.removeEventListener("keydown", down);
};
}, [captureAndCheck, loading]);
return (
<div className="absolute bottom-10 px-4 right-0 left-0 grid grid-cols-3 gap-4">
<Button
onClick={captureAndCheck}
disabled={isAutoChecking}
className="w-full bg-blue-600 hover:bg-blue-700 text-white font-semibold"
>
{!loading && (
<>
<Camera className="mr-2 size-4" />
Điểm Danh Ngay
</>
)}
{loading && <Loader className="size-4 animate-spin" />}
</Button>
<Button
onClick={toggleAutoCheck}
variant={isAutoChecking ? "destructive" : "outline"}
className={cn(
"w-full font-semibold",
isAutoChecking && "animate-pulse"
)}
>
{!loading && isAutoChecking ? (
<>
<Square className="mr-2 size-4" />
Dừng Tự Đng
</>
) : (
<>
<Play className="mr-2 size-4" />
Tự Đng Điểm Danh
</>
)}
{loading && <Loader className="size-4 animate-spin" />}
</Button>
{currentUser && (
<Button
disabled={isAutoChecking || checkPoinLoading}
onClick={createCheckpoint}
className={cn("w-full font-semibold")}
>
{!checkPoinLoading && (
<>
<Image />
Tạo Check Point
</>
)}
{checkPoinLoading && <Loader className="size-4 animate-spin" />}
</Button>
)}
{!currentUser && <Register />}
</div>
);
}

View File

@ -0,0 +1,29 @@
import { Button } from "@/components/ui/button";
import useAppStore from "@/stores/use-app-store";
import { User2 } from "lucide-react";
// import { UserModal } from "../modals/user-modal";
import { CameraNotificationModal } from "../modals/camera-notification-modal";
import { UserModal } from "../modals/user-modal";
export default function Register() {
const { isAutoChecking, captureRegisterImage, isCountDown } = useAppStore();
return (
<>
<CameraNotificationModal>
<Button
onClick={() => {}}
disabled={isAutoChecking}
className="w-full bg-green-600 hover:bg-green-700 text-white font-semibold"
>
<User2 className="mr-2 size-4" />
Tạo User Checking
</Button>
</CameraNotificationModal>
{captureRegisterImage && !isCountDown && (
<UserModal trackingOpen={true} />
)}
</>
);
}

View File

@ -0,0 +1,74 @@
/* eslint-disable react-hooks/exhaustive-deps */
import { checkingApi } from "@/api/checking-api";
import { Badge } from "@/components/ui/badge";
import { TabsContent } from "@/components/ui/tabs";
import { cn, formatTime } from "@/lib/utils";
import useAppStore from "@/stores/use-app-store";
import { ClipboardList } from "lucide-react";
import { useEffect, useState } from "react";
export default function TabLogs({ value }: { value: string }) {
const [logs, setLogs] = useState<ILog[]>([]);
const { refreshLog, setRefreshLog } = useAppStore();
const loadLogs = async () => {
try {
const { data } = await checkingApi.logs();
setLogs(data);
setRefreshLog(false);
} catch (error) {
console.log(error);
}
};
useEffect(() => {
loadLogs();
}, []);
useEffect(() => {
if (!refreshLog) return;
loadLogs();
}, [refreshLog]);
return (
<TabsContent value={value} className="">
<div className="flex flex-col gap-2 flex-1 p-4 space-y-2 overflow-y-auto h-[90vh]">
{logs.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-gray-400">
<ClipboardList className="size-16 mb-3" />
<p>Chưa dữ liệu điểm danh</p>
</div>
) : (
logs.map((log, index) => (
<div
key={index}
className={cn(
"p-3 rounded-lg border transition-all duration-200",
index === 0
? "bg-blue-50 border-blue-200"
: "bg-gray-50 border-gray-200"
)}
>
<div className="flex items-center justify-between mb-1">
<span className="font-medium text-gray-900">{log.name}</span>
<Badge
className="capitalize"
variant={
log.status === "check out" ? "destructive" : "secondary"
}
>
{log.status}
</Badge>
</div>
<p className="text-sm text-gray-600">{formatTime(log.time)}</p>
</div>
))
)}
</div>
</TabsContent>
);
}

View File

@ -0,0 +1,124 @@
/* eslint-disable no-constant-binary-expression */
"use client";
import { checkingApi } from "@/api/checking-api";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Card } from "@/components/ui/card";
import { TabsContent } from "@/components/ui/tabs";
import { cn } from "@/lib/utils";
import useAppStore from "@/stores/use-app-store";
import useUserStore from "@/stores/use-user-store";
import { Users } from "lucide-react";
import { useEffect, useState } from "react";
export default function TabUsers({ value }: { value: string }) {
const [users, setUsers] = useState<IUser[]>([]);
const { currentUser, setCurrentUser } = useUserStore();
const { refreshUsers, setRefreshUsers } = useAppStore();
const loadUsers = async () => {
try {
const { data } = await checkingApi.users();
setUsers(data);
} catch (error) {
console.log(error);
}
};
const toggle = (data: IUser) => {
if (currentUser) {
if (data.id === currentUser.id) {
setCurrentUser(null);
} else {
setCurrentUser(data);
}
} else {
setCurrentUser(data);
}
};
useEffect(() => {
loadUsers();
}, []);
useEffect(() => {
if (!refreshUsers) return;
loadUsers();
setRefreshUsers(false);
}, [refreshUsers]);
return (
<TabsContent value={value} className="">
<div className="flex flex-col gap-2 flex-1 p-4 space-y-2 overflow-y-auto h-[90vh]">
{users.map((user) => (
<Card
key={user.id}
className={cn(
"p-4 cursor-pointer transition-all duration-200 hover:shadow-md hover:scale-[1.01] select-none",
currentUser?.id === user.id &&
"bg-blue-50 dark:bg-blue-950 border-blue-500 shadow-md"
)}
onClick={() => toggle(user)}
>
<div className="flex items-center gap-3">
<Avatar className="size-12">
<AvatarImage
src={
`https://ms.prology.net/image/storage/${user?.avatar}` || ""
}
/>
<AvatarFallback> {user.name.charAt(0)}</AvatarFallback>
</Avatar>
<div className="flex-1">
<h4 className="font-semibold text-gray-900 dark:text-gray-100">
{user.name}
</h4>
<p className="text-sm text-gray-600 dark:text-gray-400">
{user.email}
</p>
</div>
{/* <DropdownMenu>
<DropdownMenuTrigger
asChild
onClick={(e) => e.stopPropagation()}
>
<Button variant="ghost" size="icon" className="h-8 w-8">
<MoreVertical className="h-4 w-4" />
<span className="sr-only">Mở menu</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem onClick={(e) => handleViewDetails(user, e)}>
<UserCheck className="mr-2 h-4 w-4" />
<span>Xem chi tiết</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={(e) => handleEdit(user, e)}>
<Edit className="mr-2 h-4 w-4" />
<span>Chỉnh sửa</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={(e) => handleDelete(user, e)}
className="text-red-600 focus:text-red-600 dark:text-red-400 dark:focus:text-red-400"
>
<Trash2 className="mr-2 h-4 w-4" />
<span>Xóa</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu> */}
</div>
</Card>
))}
</div>
{users.length <= 0 && (
<div className="flex flex-col items-center justify-center h-full text-gray-400">
<Users className="size-16 mb-3" />
<p>Chưa dữ liệu điểm danh</p>
</div>
)}
</TabsContent>
);
}

View File

@ -0,0 +1,159 @@
/* eslint-disable react-hooks/exhaustive-deps */
/* eslint-disable @typescript-eslint/no-explicit-any */
"use client";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { capture, cn } from "@/lib/utils";
import useAppStore from "@/stores/use-app-store";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import CountDown from "./components/count-down";
import LeftSlidebar from "./components/left-slidebar";
import RightSlidebar from "./components/right-slidebar";
import TabFeatures from "./components/tab-features";
export default function Main() {
const [isSidebarOpen, setIsSidebarOpen] = useState(true);
const [isLeftSidebarOpen, setIsLeftSidebarOpen] = useState(false);
// const { currentUser, setCurrentUser } = useUserStore();
const { setCanvasRef, setVideoRef } = useAppStore();
const { isCountDown, setCaptureRegisterImage, setIsCountDown } =
useAppStore();
const videoRef = useRef<HTMLVideoElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
// Initialize camera
useEffect(() => {
const initCamera = async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({
video: { width: 1280, height: 720, facingMode: "user" },
});
if (videoRef.current) {
videoRef.current.srcObject = stream;
}
} catch (err) {
console.error("Không thể truy cập camera:", err);
}
};
initCamera();
return () => {
if (videoRef.current?.srcObject) {
const stream = videoRef.current?.srcObject as MediaStream;
stream.getTracks().forEach((track) => track.stop());
}
};
}, []);
useEffect(() => {
setCanvasRef(canvasRef);
setVideoRef(videoRef);
}, [videoRef, canvasRef]);
return (
<div className="min-h-screen bg-white">
<div className="flex h-screen">
<LeftSlidebar isSidebarOpen={isLeftSidebarOpen} />
<div
className={cn(
"flex-1 transition-all duration-300 ease-in-out",
isLeftSidebarOpen && "ml-96",
isSidebarOpen && "mr-96",
)}
>
<div className="h-full flex flex-col p-6">
{/* Video Feed */}
<Card className="flex-1 overflow-hidden bg-black relative group">
<video
ref={videoRef}
autoPlay
playsInline
className="w-full h-full object-cover"
/>
<div className="absolute inset-0 border-4 border-blue-500/30 pointer-events-none" />
{/* <AnimatePresence>
{currentUser && (
<motion.div
onClick={() => setCurrentUser(null)}
key="user-card"
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.25 }}
className="absolute top-4 left-4 right-4"
>
<Card className="p-4 bg-white/95 backdrop-blur-sm border-blue-200 shadow-lg">
<div className="flex items-center gap-3">
<div className="size-12 rounded-full bg-blue-600 text-white flex items-center justify-center font-semibold text-lg">
{currentUser.name.charAt(0)}
</div>
<div>
<h3 className="font-semibold text-gray-900">
{currentUser.name}
</h3>
<p className="text-sm text-gray-600">
{currentUser.email}
</p>
</div>
</div>
</Card>
</motion.div>
)}
</AnimatePresence> */}
{isCountDown && (
<CountDown
onCountdowned={async () => {
const data = await capture(videoRef, canvasRef);
setCaptureRegisterImage(data);
setIsCountDown(false);
}}
/>
)}
<Button
onClick={() => setIsLeftSidebarOpen(!isLeftSidebarOpen)}
variant="outline"
size="icon"
className="absolute top-1/2 -translate-y-1/2 left-4 bg-white/90 hover:bg-white shadow-lg"
>
{isLeftSidebarOpen ? (
<ChevronLeft className="size-4" />
) : (
<ChevronRight className="size-4" />
)}
</Button>
<Button
onClick={() => setIsSidebarOpen(!isSidebarOpen)}
variant="outline"
size="icon"
className="absolute top-1/2 -translate-y-1/2 right-4 bg-white/90 hover:bg-white shadow-lg"
>
{isSidebarOpen ? (
<ChevronRight className="size-4" />
) : (
<ChevronLeft className="size-4" />
)}
</Button>
<TabFeatures />
</Card>
</div>
</div>
<RightSlidebar isSidebarOpen={isSidebarOpen} />
{/* Hidden Canvas for Capture */}
<canvas ref={canvasRef} className="hidden" />
</div>
</div>
);
}

View File

@ -0,0 +1,42 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
// src/stores/useUserStore.ts
import { create } from "zustand";
type AppState = {
isAutoChecking: boolean;
isCountDown: boolean;
refreshLog: boolean;
refreshUsers: boolean;
captureRegisterImage: any;
videoRef: any;
canvasRef: any;
// actions
setIsAutoChecking: (data: boolean) => void;
setIsCountDown: (data: boolean) => void;
setRefreshLog: (data: boolean) => void;
setRefreshUsers: (data: boolean) => void;
setVideoRef: (data: any) => void;
setCanvasRef: (data: any) => void;
setCaptureRegisterImage: (data: any) => void;
};
const useAppStore = create<AppState>((set) => ({
isAutoChecking: false,
isCountDown: false,
captureRegisterImage: null,
canvasRef: null,
videoRef: null,
refreshLog: false,
refreshUsers: false,
setIsAutoChecking: (data) => set({ isAutoChecking: data }),
setRefreshLog: (data) => set({ refreshLog: data }),
setRefreshUsers: (data) => set({ refreshUsers: data }),
setIsCountDown: (data) => set({ isCountDown: data }),
setCaptureRegisterImage: (data) => set({ captureRegisterImage: data }),
setVideoRef: (data) => set({ videoRef: data }),
setCanvasRef: (data) => set({ canvasRef: data }),
}));
export default useAppStore;

View File

@ -0,0 +1,16 @@
// src/stores/useUserStore.ts
import { create } from "zustand";
type UserState = {
currentUser: IUser | null;
// actions
setCurrentUser: (data: IUser | null) => void;
};
const useUserStore = create<UserState>((set) => ({
currentUser: null,
setCurrentUser: (data) => set(() => ({ currentUser: data })),
}));
export default useUserStore;

View File

@ -0,0 +1,32 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"]
}

View File

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

View File

@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

View File

@ -0,0 +1,20 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import path from "path";
import tailwindcss from "@tailwindcss/vite";
// https://vite.dev/config/
export default defineConfig({
plugins: [
react({
babel: {
plugins: [["babel-plugin-react-compiler"]],
},
}),
tailwindcss(),
],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
});

View File

@ -0,0 +1,9 @@
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, declarative_base
# DATABASE_URL = "mysql+pymysql://root:root@localhost/face_checkin_au?charset=utf8mb4"
DATABASE_URL = "mysql+pymysql://admin:Work1234%^@localhost/face_checkin_au?charset=utf8mb4"
engine = create_engine(DATABASE_URL)
SessionLocal = sessionmaker(bind=engine, autoflush=False)
Base = declarative_base()

487
TrackingToolWebAU/main.py Normal file
View File

@ -0,0 +1,487 @@
from fastapi import FastAPI, UploadFile, File, Form, Depends, HTTPException, BackgroundTasks
from fastapi.responses import JSONResponse
from sqlalchemy.orm import Session
import face_recognition
import numpy as np
import os
import datetime
import threading
import logging
import cv2
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse
from database import SessionLocal, engine
from models import Base, Student, CheckInLog, StudentEncoding
from sqlalchemy.exc import IntegrityError
from sqlalchemy import text
from fastapi.middleware.cors import CORSMiddleware
from api import create_history, send_image
logging.basicConfig(level=logging.INFO)
# --- Encoding cache (Phương án 1: RAM cache) ---
_enc_matrix: np.ndarray | None = None # shape (N, 128)
_enc_student_ids: np.ndarray | None = None # shape (N,) int64
_enc_student_names: dict = {}
_cache_lock = threading.Lock()
_cache_dirty = True
def invalidate_encoding_cache():
global _cache_dirty
_cache_dirty = True
def _load_encoding_cache(db):
global _enc_matrix, _enc_student_ids, _enc_student_names, _cache_dirty
with _cache_lock:
if not _cache_dirty and _enc_matrix is not None:
return _enc_matrix, _enc_student_ids, _enc_student_names
rows = db.execute(
text("""
SELECT s.id AS student_id, s.name AS student_name, se.encoding AS encoding_blob
FROM student_encodings se
JOIN students s ON s.id = se.student_id
""")
).fetchall()
encodings, student_ids, names = [], [], {}
for r in rows:
try:
enc = np.frombuffer(r.encoding_blob, dtype=np.float64)
if enc.size == 128:
encodings.append(enc)
student_ids.append(r.student_id)
names[r.student_id] = r.student_name
else:
logging.warning(f"encoding size invalid for student {r.student_id}: {enc.size}")
except Exception as e:
logging.exception(f"Error decoding encoding for student {r.student_id}: {e}")
if encodings:
_enc_matrix = np.vstack(encodings)
_enc_student_ids = np.array(student_ids, dtype=np.int64)
else:
_enc_matrix = np.empty((0, 128), dtype=np.float64)
_enc_student_ids = np.array([], dtype=np.int64)
_enc_student_names = names
_cache_dirty = False
logging.info(f"Encoding cache loaded: {_enc_matrix.shape[0]} encodings, {len(names)} students")
return _enc_matrix, _enc_student_ids, _enc_student_names
# --- Image preprocessing (Phương án 3: resize trước khi detect) ---
def _preprocess_image(image_data: bytes, max_width: int = 640) -> np.ndarray:
nparr = np.frombuffer(image_data, np.uint8)
img = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
h, w = img.shape[:2]
if w > max_width:
scale = max_width / w
img = cv2.resize(img, (int(w * scale), int(h * scale)), interpolation=cv2.INTER_AREA)
return cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
app = FastAPI()
# --- CORS ---
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
Base.metadata.create_all(bind=engine)
UPLOAD_DIR = "./uploads"
os.makedirs(UPLOAD_DIR, exist_ok=True)
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
app.mount("/static", StaticFiles(directory="static"), name="static")
@app.get("/")
def root():
return FileResponse("static/index.html")
@app.post("/register")
async def register_face(
name: str = Form(...),
email: str = Form(...),
avatar: str = Form(None), # OPTIONAL
file: UploadFile = File(...)
):
db = SessionLocal()
# Check duplicate email
existing_email = db.execute(
text("SELECT id FROM students WHERE email = :email"),
{"email": email}
).fetchone()
# Save image
image_data = await file.read()
image_path = f"./uploads/{file.filename}"
with open(image_path, "wb") as f:
f.write(image_data)
# Encode face — dùng _preprocess_image để tránh load lại từ disk
image = _preprocess_image(image_data)
encodings = face_recognition.face_encodings(image)
if not encodings:
db.close()
return JSONResponse(
content={"message": "Không phát hiện khuôn mặt."},
status_code=400
)
encoding_bytes = encodings[0].tobytes()
try:
if existing_email:
# Email exists → just add new encoding
student_id = existing_email[0]
db.execute(
text("""
INSERT INTO student_encodings (student_id, encoding)
VALUES (:student_id, :encoding)
"""),
{"student_id": student_id, "encoding": encoding_bytes}
)
db.commit()
invalidate_encoding_cache()
return {"message": "Đã thêm encoding mới."}
else:
# Insert student + encoding trong cùng 1 transaction để tránh student không có encoding
try:
db.execute(
text("""
INSERT INTO students (name, email, avatar)
VALUES (:name, :email, :avatar)
"""),
{
"name": name,
"email": email,
"avatar": avatar,
}
)
student_id = db.execute(text("SELECT LAST_INSERT_ID()")).fetchone()[0]
db.execute(
text("""
INSERT INTO student_encodings (student_id, encoding)
VALUES (:student_id, :encoding)
"""),
{"student_id": student_id, "encoding": encoding_bytes}
)
db.commit()
invalidate_encoding_cache()
return {"message": "Đăng ký thành công."}
except IntegrityError:
db.rollback()
raise HTTPException(status_code=400, detail="Email đã tồn tại.")
finally:
db.close()
@app.post("/register-simple")
async def register_student(
name: str = Form(...),
email: str = Form(...),
avatar: str = Form(None), # OPTIONAL
):
db = SessionLocal()
try:
# Kiểm tra xem student đã tồn tại chưa
existing = db.execute(
text("SELECT id FROM students WHERE email = :email"),
{"email": email}
).fetchone()
if existing:
# UPDATE
db.execute(
text("""
UPDATE students
SET name = :name,
avatar = :avatar
WHERE email = :email
"""),
{
"name": name,
"avatar": avatar,
"email": email
}
)
db.commit()
return JSONResponse({"message": "Cập nhật thành công."}, status_code=200)
else:
# INSERT
db.execute(
text("""
INSERT INTO students (name, email, avatar)
VALUES (:name, :email, :avatar)
"""),
{
"name": name,
"email": email,
"avatar": avatar
}
)
db.commit()
return JSONResponse({"message": "Đăng ký thành công."}, status_code=201)
except IntegrityError:
db.rollback()
return JSONResponse(
{"message": "Lỗi cơ sở dữ liệu."},
status_code=400
)
finally:
db.close()
@app.post("/checkin")
async def checkin(background_tasks: BackgroundTasks, file: UploadFile = File(...), camera_id: str = Form("cam1"), db: Session = Depends(get_db)):
image_data = await file.read()
# Phương án 3: resize ảnh trước khi detect — bỏ disk I/O
unknown_img = _preprocess_image(image_data)
unknown_encodings = face_recognition.face_encodings(unknown_img)
if not unknown_encodings:
return {"message": "No face detected.", "status": False}
unknown_encoding = unknown_encodings[0]
# TÙY CHỈNH: threshold nhỏ hơn → ít nhầm lẫn, nhưng dễ false negative.
# Thử: 0.4 (chặt), 0.45 (cân bằng), 0.55 (lỏng)
DIST_THRESHOLD = 0.42
# Phương án 1: dùng cache RAM thay vì query DB mỗi request
enc_matrix, enc_sids, enc_names = _load_encoding_cache(db)
if enc_matrix.shape[0] == 0:
return {"message": "No known encodings in DB.", "status": False}
# Phương án 2: vectorized — tính tất cả distances 1 lần qua BLAS
all_dists = face_recognition.face_distance(enc_matrix, unknown_encoding)
# Tìm min distance theo từng student
best_student = None
best_distance = float("inf")
second_best_distance = float("inf")
for sid in np.unique(enc_sids):
mask = enc_sids == sid
min_dist = float(np.min(all_dists[mask]))
logging.info(f"Student {sid} ({enc_names.get(sid)}) min_dist = {min_dist:.4f}")
if min_dist < best_distance:
second_best_distance = best_distance
best_distance = min_dist
best_student = int(sid)
elif min_dist < second_best_distance:
second_best_distance = min_dist
# Debug log best/second distances
logging.info(f"Best student {best_student} dist={best_distance:.4f}, second_best={second_best_distance:.4f}")
# Ratio check: nếu best much better than second best => more confident
ratio_ok = True
if second_best_distance < float("inf"):
ratio = best_distance / (second_best_distance + 1e-8)
logging.info(f"Distance ratio (best/second) = {ratio:.4f}")
# Nếu ratio quá gần 1 (ví dụ > 0.85) => không đủ phân biệt
if ratio > 0.85:
ratio_ok = False
# Quyết định match nếu best_distance nhỏ hơn threshold và ratio ok
if best_distance <= DIST_THRESHOLD and ratio_ok and best_student is not None:
# kiểm tra recent check (nửa phút trước)
now = datetime.datetime.now()
recent_check = db.execute(
text("""
SELECT id FROM checkin_logs
WHERE student_id = :student_id
AND time > :time_threshold
"""),
{
"student_id": best_student,
"time_threshold": now - datetime.timedelta(minutes=0.5)
}
).fetchone()
if recent_check:
return {"message": f"{enc_names.get(best_student)} already checked in recently.", "status": True}
last_log = db.execute(
text("""
SELECT status FROM checkin_logs
WHERE student_id = :student_id
ORDER BY time DESC LIMIT 1
"""),
{"student_id": best_student}
).fetchone()
status = "check out" if last_log and last_log.status == "check in" else "check in"
insert_result = db.execute(
text("""
INSERT INTO checkin_logs (student_id, time, camera_id, status)
VALUES (:student_id, :time, :camera_id, :status)
"""),
{
"student_id": best_student,
"time": now,
"camera_id": camera_id,
"status": status
}
)
log_id = insert_result.lastrowid
db.commit()
def _sync_to_ms(name: str, time_string: str, img_data: bytes, local_status: str, checkin_log_id: int):
try:
# Gửi thông tin check-in lên MS server để tạo history
ms_response = create_history({"name": name.split('\n')[0], "time_string": time_string, "status": local_status})
id_log = ms_response.get('data', {}).get('id', 0)
ms_status = ms_response.get('data', {}).get('status', local_status)
# Nếu MS server trả về status khác với status local thì đồng bộ lại DB
if ms_status != local_status:
fix_db = SessionLocal()
try:
fix_db.execute(
text("UPDATE checkin_logs SET status = :status WHERE id = :id"),
{"status": ms_status, "id": checkin_log_id}
)
fix_db.commit()
logging.info(f"Corrected log #{checkin_log_id} status: {local_status}{ms_status}")
finally:
fix_db.close()
# Upload ảnh check-in lên MS server gắn với log id vừa tạo
send_image(id_log, img_data, name, ms_status)
except Exception as e:
logging.error(f"MS sync error: {e}")
# Chạy đồng bộ MS ở background để không block response trả về client
# TODO: bỏ comment khi deploy thật
# background_tasks.add_task(
# _sync_to_ms,
# enc_names.get(best_student),
# f"{datetime.datetime.now()}",
# image_data,
# status,
# log_id,
# )
student = db.execute(
text("""
SELECT id, name, email
FROM students
WHERE id = :id
"""),
{"id": best_student}
).fetchone()
user_data = {
"id": student.id,
"name": student.name,
"email": student.email,
} if student else None
return {"message": f"{status} successful for {enc_names.get(best_student)} (dist={best_distance:.4f})", "status": True, "status_type": status, "data": user_data}
# Nếu không thỏa threshold/rule thì trả no match (và log lý do)
reasons = []
if best_distance > DIST_THRESHOLD:
reasons.append(f"best_distance({best_distance:.4f}) > threshold({DIST_THRESHOLD})")
if not ratio_ok:
reasons.append(f"ratio not confident ({best_distance:.4f}/{second_best_distance:.4f})")
logging.info("No confident match: " + "; ".join(reasons))
return {"message": "No match found.", "reasons": reasons, "status": False}
@app.get("/logs")
def get_logs(db: Session = Depends(get_db)):
logs = db.execute(
text("""
SELECT s.name, cl.time, cl.camera_id, cl.status
FROM checkin_logs cl
JOIN students s ON cl.student_id = s.id
ORDER BY cl.time DESC
LIMIT 20
""")
).fetchall()
result = []
for log in logs:
result.append({
"name": log.name,
"time": log.time.strftime("%Y-%m-%d %H:%M:%S"),
"camera_id": log.camera_id,
"status": log.status
})
return result
@app.get("/users")
def get_users(db: Session = Depends(get_db)):
# Lấy danh sách student
students = db.execute(
text("""
SELECT id, name, email, avatar
FROM students
ORDER BY name DESC
""")
).fetchall()
result = []
for stu in students:
student_id = stu.id
# Lấy tối đa 5 checkpoint mới nhất
checkpoints = db.execute(
text("""
SELECT id, time, camera_id
FROM checkin_logs
WHERE student_id = :sid
ORDER BY time DESC
LIMIT 5
"""),
{"sid": student_id}
).fetchall()
result.append({
"id": stu.id,
"name": stu.name,
"email": stu.email,
"avatar": stu.avatar,
"checkpoints": [
{
"id": c.id,
"time": c.time,
"camera_id": c.camera_id
}
for c in checkpoints
]
})
return result

View File

@ -0,0 +1,38 @@
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, LargeBinary, UniqueConstraint
from sqlalchemy.orm import relationship
from database import Base
import datetime
class Student(Base):
__tablename__ = "students"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(100), nullable=False)
email = Column(String(100), nullable=False, unique=True, index=True)
avatar = Column(String(500), nullable=True, index=True)
__table_args__ = (UniqueConstraint('email', name='uq_student_email'),)
checkins = relationship("CheckInLog", back_populates="student")
encodings = relationship("StudentEncoding", back_populates="student")
class StudentEncoding(Base):
__tablename__ = "student_encodings"
id = Column(Integer, primary_key=True, index=True)
student_id = Column(Integer, ForeignKey("students.id"))
encoding = Column(LargeBinary, nullable=False)
created_at = Column(DateTime, default=datetime.datetime.utcnow)
student = relationship("Student", back_populates="encodings")
class CheckInLog(Base):
__tablename__ = "checkin_logs"
id = Column(Integer, primary_key=True, index=True)
student_id = Column(Integer, ForeignKey("students.id"))
time = Column(DateTime, default=datetime.datetime.utcnow)
status = Column(String(100), nullable=True)
camera_id = Column(String(100))
student = relationship("Student", back_populates="checkins")

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

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

View File

@ -0,0 +1,12 @@
fastapi
uvicorn
sqlalchemy
face_recognition
numpy
opencv-python
requests
pymysql
# pip install -r requirements.txt
# sudo apt-get install cmake or brew install cmake
# pip install dlib
# pip install setuptools

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,22 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>client</title>
<script
type="module"
crossorigin
src="au/checkin/assets/index-yYwv6FSW.js"
></script>
<link
rel="stylesheet"
crossorigin
href="au/checkin/assets/index-CDZdzCu6.css"
/>
</head>
<body>
<div id="root"></div>
</body>
</html>

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