update UI tablet and mobile

This commit is contained in:
Admin 2026-05-20 14:11:05 +07:00
parent 8cd93c578e
commit 607882343a
10 changed files with 295 additions and 180 deletions

104
TrackingToolWebAU/GUIDE.md Normal file
View File

@ -0,0 +1,104 @@
# Hướng dẫn sử dụng hệ thống Face Check-in
## Yêu cầu
- Camera được kết nối và cấp quyền truy cập trên trình duyệt
- Email của nhân viên phải tồn tại trong hệ thống ERP Server
- Backend đang chạy và kết nối được database
---
## 1. Tạo User mới
User trong hệ thống local dùng để nhận diện khuôn mặt. **Email phải khớp với email trên ERP Server.**
### Các bước:
1. Đứng trước camera, đảm bảo khuôn mặt hiển thị rõ trong khung hình
2. Tab **Features** → nhấn nút **Tạo User Checking** (màu xanh lá)
3. Hệ thống chụp ảnh khuôn mặt từ camera
4. Form hiện ra → nhập:
- **Tên**: tên hiển thị của nhân viên
- **Email** _(bắt buộc)_: phải trùng với email trên ERP Server
- **Avatar URL** _(tuỳ chọn)_: đường dẫn ảnh đại diện
5. Nhấn **Tạo mới** → hệ thống lưu khuôn mặt và thông tin user
> Nếu email đã tồn tại, hệ thống sẽ cập nhật thông tin thay vì tạo mới.
---
## 2. Tạo Checkpoint (thêm ảnh nhận diện cho user)
Checkpoint là ảnh khuôn mặt bổ sung, giúp hệ thống nhận diện chính xác hơn ở nhiều góc độ/ánh sáng khác nhau.
### Các bước:
1. Tab **User** → chọn user cần thêm checkpoint (card sẽ highlight màu xanh)
2. Đứng trước camera ở góc độ/ánh sáng khác với lần đăng ký ban đầu
3. Tab **Features** → nhấn **Tạo Check Point**
4. Hệ thống chụp ảnh và lưu thêm encoding vào database
> Mỗi user có tối đa **10 checkpoint**. Nên tạo ít nhất 35 checkpoint ở các góc độ khác nhau để tăng độ chính xác.
---
## 3. Điểm danh (Check-in / Check-out)
Hệ thống tự động xác định check-in hoặc check-out dựa trên lần điểm danh trước đó.
### Điểm danh thủ công
1. Đứng trước camera
2. Tab **Features** → nhấn **Điểm Danh Ngay** (hoặc nhấn **Space**)
3. Hệ thống nhận diện khuôn mặt và ghi log
### Điểm danh tự động
1. Tab **Features** → nhấn **Tự Động Điểm Danh**
2. Camera quét liên tục mỗi **3 giây**
3. Nhấn **Dừng Tự Động** để tắt
### Kết quả trả về
| Trường hợp | Thông báo |
| ------------------------------ | ----------------------------------------------------------------- |
| Thành công | "check in successful for ..." hoặc "check out successful for ..." |
| Không nhận ra khuôn mặt | "No face detected" _(bỏ qua, không hiện toast)_ |
| Không khớp với ai | "No match found" |
| Vừa điểm danh (< 30 giây) | "... already checked in recently" |
| Email không có trên ERP Server | "[ERP Server] User not found" |
---
## 4. Xem danh sách User
- Chuyển sang tab **User** để xem toàn bộ danh sách nhân viên đã đăng ký
- Nhấn vào một user để **chọn** (dùng cho tạo checkpoint)
- Nhấn lại user đang chọn để **bỏ chọn**
---
## 5. Xóa User
> ⚠️ Xóa user sẽ xóa toàn bộ lịch sử điểm danh và dữ liệu khuôn mặt của user đó.
1. Tab **User** → tìm user cần xóa
2. Nhấn icon **thùng rác** (đỏ) ở góc phải card
3. Xác nhận trong hộp thoại hiện ra
4. User và toàn bộ dữ liệu liên quan bị xóa khỏi hệ thống local
---
## 6. Xem lịch sử điểm danh
- Chuyển sang tab **Log** để xem 20 lần điểm danh gần nhất
- Danh sách tự động cập nhật sau mỗi lần điểm danh thành công
---
## Lưu ý
- **Email là định danh duy nhất** — email trong hệ thống local phải khớp hoàn toàn với email trên ERP Server
- **Ánh sáng ảnh hưởng đến nhận diện** — nên đăng ký khuôn mặt trong điều kiện ánh sáng tương tự môi trường sử dụng
- **Khoảng cách** — đứng cách camera khoảng 5080cm để đạt kết quả tốt nhất
- Log điểm danh chỉ được ghi khi ERP Server xác nhận thành công

View File

@ -2,46 +2,29 @@
/* 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 { speak } from "@/lib/speak";
import { capture, 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 { Camera, Image, Loader } 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() {
export default function TabFeatures({ inline = false }: { inline?: boolean }) {
const timeoutRef = useRef<any>(null);
const { canvasRef, videoRef } = useAppStore();
const { currentUser, setCurrentUser } = useUserStore();
const { isAutoChecking, setIsAutoChecking, setRefreshLog } = useAppStore();
const autoCheckIntervalRef = useRef<any>(null);
const { setRefreshLog } = useAppStore();
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");
@ -57,7 +40,7 @@ export default function TabFeatures() {
if (!data) {
toast.error(
(data as any)?.message ||
"Error In Checkpoint: " + JSON.stringify(data)
"Error In Checkpoint: " + JSON.stringify(data),
);
return;
@ -69,7 +52,7 @@ export default function TabFeatures() {
toast.error(
(data.response?.data as any)?.message ||
"Error In Checkpoint: " + JSON.stringify(data)
"Error In Checkpoint: " + JSON.stringify(data),
);
} finally {
setCheckPoinLoading(false);
@ -86,7 +69,8 @@ export default function TabFeatures() {
if (!data || !data?.status) {
toast.error(
(data as any)?.message || "Error In Checking: " + JSON.stringify(data)
(data as any)?.message ||
"Error In Checking: " + JSON.stringify(data),
);
return;
}
@ -139,11 +123,49 @@ export default function TabFeatures() {
};
}, [captureAndCheck, loading]);
if (inline) {
return (
<div className="absolute bottom-4 sm:bottom-6 lg:bottom-10 px-3 sm:px-4 right-0 left-0 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2 sm:gap-3 lg:gap-4 max-w-3xl mx-auto">
<div className="grid grid-cols-2 gap-2 w-full">
<Button
onClick={captureAndCheck}
className="w-full bg-blue-600 hover:bg-blue-700 text-white font-semibold text-xs"
>
{!loading ? (
<>
<Camera className="mr-1 size-4" />
Điểm Danh Ngay
</>
) : (
<Loader className="size-4 animate-spin" />
)}
</Button>
{currentUser ? (
<Button
disabled={checkPoinLoading}
onClick={createCheckpoint}
className="w-full font-semibold text-xs"
>
{!checkPoinLoading ? (
<>
<Image className="mr-1 size-4" />
Tạo Check Point
</>
) : (
<Loader className="size-4 animate-spin" />
)}
</Button>
) : (
<Register />
)}
</div>
);
}
return (
<div className="absolute bottom-4 sm:bottom-6 lg:bottom-10 px-3 sm:px-4 right-0 left-0 grid grid-cols-2 gap-2 sm:gap-3 lg:gap-4 max-w-3xl mx-auto">
<Button
onClick={captureAndCheck}
disabled={isAutoChecking}
className="w-full bg-blue-600 hover:bg-blue-700 text-white font-semibold text-xs sm:text-sm"
>
{!loading && (
@ -156,36 +178,11 @@ export default function TabFeatures() {
{loading && <Loader className="size-4 animate-spin" />}
</Button>
<Button
onClick={toggleAutoCheck}
variant={isAutoChecking ? "destructive" : "outline"}
className={cn(
"w-full font-semibold text-xs sm:text-sm",
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}
disabled={checkPoinLoading}
onClick={createCheckpoint}
className={cn(
"w-full font-semibold text-xs sm:text-sm col-span-1 sm:col-span-2 lg:col-span-1"
)}
className="w-full font-semibold text-xs sm:text-sm"
>
{!checkPoinLoading && (
<>

View File

@ -14,7 +14,7 @@ export default function Register() {
<Button
onClick={() => {}}
disabled={isAutoChecking}
className="w-full bg-green-600 hover:bg-green-700 text-white font-semibold text-xs sm:text-sm col-span-1 sm:col-span-2 lg:col-span-1"
className="w-full bg-green-600 hover:bg-green-700 text-white font-semibold text-xs sm:text-sm"
>
<User2 className="mr-2 size-4" />
Tạo User Checking

View File

@ -7,17 +7,14 @@ import useAppStore from "@/stores/use-app-store";
import { ClipboardList } from "lucide-react";
import { useEffect, useState } from "react";
export default function TabLogs({ value }: { value: string }) {
export function LogList({ className }: { className?: 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);
@ -30,17 +27,15 @@ export default function TabLogs({ value }: { value: string }) {
useEffect(() => {
if (!refreshLog) return;
loadLogs();
}, [refreshLog]);
return (
<TabsContent value={value} className="flex-1 min-h-0 overflow-hidden">
<div className="flex flex-col gap-2 p-3 sm:p-4 space-y-2 overflow-y-auto h-full">
<div className={cn("flex flex-col gap-2 p-3 overflow-y-auto h-full", className)}>
{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>
<ClipboardList className="size-10 mb-2" />
<p className="text-sm">Chưa dữ liệu điểm danh</p>
</div>
) : (
logs.map((log, index) => (
@ -54,21 +49,26 @@ export default function TabLogs({ value }: { value: string }) {
)}
>
<div className="flex items-center justify-between mb-1">
<span className="font-medium text-gray-900">{log.name}</span>
<span className="font-medium text-gray-900 text-sm">{log.name}</span>
<Badge
className="capitalize"
variant={
log.status === "check out" ? "destructive" : "secondary"
}
variant={log.status === "check out" ? "destructive" : "secondary"}
>
{log.status}
</Badge>
</div>
<p className="text-sm text-gray-600">{formatTime(log.time)}</p>
<p className="text-xs text-gray-600">{formatTime(log.time)}</p>
</div>
))
)}
</div>
);
}
export default function TabLogs({ value }: { value: string }) {
return (
<TabsContent value={value} className="flex-1 min-h-0 overflow-hidden">
<LogList />
</TabsContent>
);
}

View File

@ -12,6 +12,7 @@ import CountDown from "./components/count-down";
import LeftSlidebar from "./components/left-slidebar";
import RightSlidebar from "./components/right-slidebar";
import TabFeatures from "./components/tab-features";
import { LogList } from "./components/tab-log";
export default function Main() {
const [isSidebarOpen, setIsSidebarOpen] = useState(true);
@ -81,14 +82,19 @@ export default function Main() {
isSidebarOpen && "lg:mr-96"
)}
>
<div className="h-full flex flex-col p-2 sm:p-4 lg:p-6">
<div className="h-full flex flex-col p-2 sm:p-4 lg:p-6 gap-2 lg:gap-0">
{/* Mobile/tablet: Buttons at top */}
<div className="lg:hidden">
<TabFeatures inline />
</div>
{/* Video Feed */}
<Card className="flex-1 overflow-hidden bg-black relative group">
<Card className="flex-1 min-h-0 overflow-hidden bg-black relative group">
<video
ref={videoRef}
autoPlay
playsInline
className="w-full h-full object-cover"
className="w-full h-full object-contain lg:object-cover"
/>
<div className="absolute inset-0 border-4 border-blue-500/30 pointer-events-none" />
@ -158,8 +164,16 @@ export default function Main() {
)}
</Button>
{/* Desktop only: buttons overlaid at bottom of video */}
<div className="hidden lg:block">
<TabFeatures />
</div>
</Card>
{/* Mobile/tablet: Log at bottom */}
<div className="lg:hidden h-44 border rounded-lg bg-white overflow-hidden">
<LogList />
</div>
</div>
</div>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -8,12 +8,12 @@
<script
type="module"
crossorigin
src="/au/checkin/static/assets/index-CZPaGL7L.js"
src="/au/checkin/static/assets/index-6M6pT1EU.js"
></script>
<link
rel="stylesheet"
crossorigin
href="/au/checkin/static/assets/index-vMXWhc_A.css"
href="/au/checkin/static/assets/index-HoPs24tN.css"
/>
</head>
<body>